feat(pwa): mobile-first employee portal with offline sync and push integration

This commit is contained in:
Ochenstarik 2026-09-26 12:08:31 +07:00
parent fb1270a0a8
commit 87a6a447c2
3 changed files with 355 additions and 8 deletions

View file

@ -590,6 +590,76 @@ paths:
responses: responses:
'200': { description: Пакет импортирован повторяемо без дублирования } '200': { description: Пакет импортирован повторяемо без дублирования }
'403': { description: Недостаточно прав } '403': { description: Недостаточно прав }
/push/public-key:
get:
summary: Получить публичный VAPID-ключ для WebPush
operationId: getPushPublicKey
tags: [PushNotifications]
responses:
'200':
description: Публичный VAPID-ключ
content:
application/json:
schema:
type: object
required: [public_key]
properties:
public_key: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
/push/subscriptions:
post:
summary: Сохранить WebPush-подписку текущего сотрудника
operationId: createPushSubscription
tags: [PushNotifications]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [endpoint, keys]
properties:
endpoint: { type: string, minLength: 10, maxLength: 2000 }
keys:
type: object
required: [p256dh, auth]
properties:
p256dh: { type: string, minLength: 10, maxLength: 250 }
auth: { type: string, minLength: 10, maxLength: 100 }
user_agent: { type: string, maxLength: 500 }
responses:
'201':
description: Подписка сохранена
content:
application/json:
schema:
type: object
required: [id, endpoint]
properties:
id: { type: string }
endpoint: { type: string }
'401': { description: Требуется авторизация }
'403': { description: Недостаточно прав }
'404': { description: Сотрудник не найден }
delete:
summary: Удалить WebPush-подписку
operationId: deletePushSubscription
tags: [PushNotifications]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [endpoint]
properties:
endpoint: { type: string, minLength: 10, maxLength: 2000 }
responses:
'204': { description: Подписка удалена }
'401': { description: Требуется авторизация }
'403': { description: Недостаточно прав }
components: components:
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:

View file

@ -6,6 +6,11 @@ export const portalHtml = `<!doctype html>
<meta name="theme-color" content="#657255"> <meta name="theme-color" content="#657255">
<title>Krona Staff</title> <title>Krona Staff</title>
<link rel="manifest" href="/staff/manifest.webmanifest"> <link rel="manifest" href="/staff/manifest.webmanifest">
<link rel="icon" type="image/svg+xml" href="/staff/icon-192.svg">
<link rel="apple-touch-icon" href="/staff/icon-192.svg">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="stylesheet" href="/staff/styles.css"> <link rel="stylesheet" href="/staff/styles.css">
<style>[hidden]{display:none!important}.auth-screen{min-height:100vh;display:grid;place-items:center;padding:28px;background:var(--omni-bg-workspace)}.auth-card{width:min(100%,460px);padding:42px;background:var(--omni-surface-raised);border:1px solid var(--omni-border);border-radius:var(--omni-radius-xl);box-shadow:var(--omni-shadow-strong)}.auth-brand{display:flex;align-items:center;gap:12px;margin-bottom:42px;font-size:20px}.auth-card h1{margin:6px 0 12px;font:500 38px/1.08 var(--omni-font-serif)}.auth-copy{margin:0 0 26px;color:var(--omni-text-secondary);line-height:1.55}.auth-card form{display:grid;gap:10px}.auth-card label{font-size:13px;font-weight:700}.auth-card input{width:100%;min-height:50px;padding:0 14px;border:1px solid var(--omni-border-strong);border-radius:var(--omni-radius-md);background:var(--omni-surface-raised);color:var(--omni-text-primary)}.auth-card button{margin-top:6px;min-height:50px}.auth-card small{display:block;margin-top:20px;color:var(--omni-text-secondary);line-height:1.45}.login-error{margin-top:16px;padding:12px 14px;border:1px solid var(--omni-danger);border-radius:var(--omni-radius-md);background:var(--omni-danger-soft);color:var(--omni-danger)}@media(max-width:520px){.auth-screen{padding:16px}.auth-card{padding:28px 22px}.auth-card h1{font-size:32px}.auth-brand{margin-bottom:32px}}</style> <style>[hidden]{display:none!important}.auth-screen{min-height:100vh;display:grid;place-items:center;padding:28px;background:var(--omni-bg-workspace)}.auth-card{width:min(100%,460px);padding:42px;background:var(--omni-surface-raised);border:1px solid var(--omni-border);border-radius:var(--omni-radius-xl);box-shadow:var(--omni-shadow-strong)}.auth-brand{display:flex;align-items:center;gap:12px;margin-bottom:42px;font-size:20px}.auth-card h1{margin:6px 0 12px;font:500 38px/1.08 var(--omni-font-serif)}.auth-copy{margin:0 0 26px;color:var(--omni-text-secondary);line-height:1.55}.auth-card form{display:grid;gap:10px}.auth-card label{font-size:13px;font-weight:700}.auth-card input{width:100%;min-height:50px;padding:0 14px;border:1px solid var(--omni-border-strong);border-radius:var(--omni-radius-md);background:var(--omni-surface-raised);color:var(--omni-text-primary)}.auth-card button{margin-top:6px;min-height:50px}.auth-card small{display:block;margin-top:20px;color:var(--omni-text-secondary);line-height:1.45}.login-error{margin-top:16px;padding:12px 14px;border:1px solid var(--omni-danger);border-radius:var(--omni-radius-md);background:var(--omni-danger-soft);color:var(--omni-danger)}@media(max-width:520px){.auth-screen{padding:16px}.auth-card{padding:28px 22px}.auth-card h1{font-size:32px}.auth-brand{margin-bottom:32px}}</style>
</head> </head>
@ -51,7 +56,7 @@ export const portalHtml = `<!doctype html>
</section> </section>
<section class="layout"> <section class="layout">
<article class="panel attendance-panel"><div class="panel-head"><div><p class="eyebrow">Последние события</p><h2>Отметки терминала</h2></div><span class="live">● в реальном времени</span></div><div id="attendance" class="feed"></div></article> <article class="panel attendance-panel"><div class="panel-head"><div><p class="eyebrow">Последние события</p><h2>Отметки терминала</h2></div><span class="live">● в реальном времени</span></div><div id="attendance" class="feed"></div></article>
<aside class="panel people-panel"><div class="panel-head"><div><p class="eyebrow">Команда</p><h2>Сотрудники</h2></div><div class="actions"><button id="manage-professions" class="quiet compact" type="button">Профессии</button><button id="add-employee" class="quiet compact" type="button">Добавить</button></div></div><div id="employees" class="people"></div></aside> <aside class="panel people-panel manager-only"><div class="panel-head"><div><p class="eyebrow">Команда</p><h2>Сотрудники</h2></div><div class="actions"><button id="manage-professions" class="quiet compact" type="button">Профессии</button><button id="add-employee" class="quiet compact" type="button">Добавить</button></div></div><div id="employees" class="people"></div></aside>
</section> </section>
<section class="panel sessions-panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">Сверка терминала</p><h2>Рабочие смены</h2></div><button id="reconcile-sessions" class="quiet" type="button">Сверить 7 дней</button></div><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Подразделение</th><th>Интервал</th><th>Часы</th><th>Состояние</th><th>Причина правки</th><th></th></tr></thead><tbody id="work-sessions"></tbody></table></div></section> <section class="panel sessions-panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">Сверка терминала</p><h2>Рабочие смены</h2></div><button id="reconcile-sessions" class="quiet" type="button">Сверить 7 дней</button></div><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Подразделение</th><th>Интервал</th><th>Часы</th><th>Состояние</th><th>Причина правки</th><th></th></tr></thead><tbody id="work-sessions"></tbody></table></div></section>
<section class="panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">Учёт времени</p><h2>Табель за 40 дней</h2></div><span id="timesheet-help">Изменения сохраняет руководитель</span></div><form id="timesheet-create" class="timesheet-create"><label>Сотрудник<select id="new-time-employee" required></select></label><label>Дата<input id="new-time-date" type="date" required></label><label>Часы<input id="new-time-hours" type="number" min="0" max="24" step="0.25" value="8" required></label><label>Статус<select id="new-time-status"><option value="draft">Черновик</option><option value="submitted">На проверке</option><option value="approved">Утверждён</option><option value="rejected">Возвращён</option></select></label><label class="grow">Примечание<input id="new-time-note" maxlength="1000" placeholder="Необязательно"></label><button type="submit">Добавить строку</button></form><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Часы</th><th>Статус</th><th>Примечание</th><th></th></tr></thead><tbody id="timesheet"></tbody></table></div></section> <section class="panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">Учёт времени</p><h2>Табель за 40 дней</h2></div><span id="timesheet-help">Изменения сохраняет руководитель</span></div><form id="timesheet-create" class="timesheet-create"><label>Сотрудник<select id="new-time-employee" required></select></label><label>Дата<input id="new-time-date" type="date" required></label><label>Часы<input id="new-time-hours" type="number" min="0" max="24" step="0.25" value="8" required></label><label>Статус<select id="new-time-status"><option value="draft">Черновик</option><option value="submitted">На проверке</option><option value="approved">Утверждён</option><option value="rejected">Возвращён</option></select></label><label class="grow">Примечание<input id="new-time-note" maxlength="1000" placeholder="Необязательно"></label><button type="submit">Добавить строку</button></form><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Часы</th><th>Статус</th><th>Примечание</th><th></th></tr></thead><tbody id="timesheet"></tbody></table></div></section>
@ -139,6 +144,17 @@ body,.auth-screen{background:var(--omni-bg-workspace);color:var(--omni-text-prim
.payroll-warnings ul{margin:6px 0 0 18px;padding:0} .payroll-warnings ul{margin:6px 0 0 18px;padding:0}
.payroll-warnings li{margin-bottom:4px} .payroll-warnings li{margin-bottom:4px}
.section-nav{background:color-mix(in srgb,var(--omni-surface) 94%,transparent)}.period-control input,.stack-form input,.stack-form select,.inline-form input,.inline-form select{background:var(--omni-surface-raised)}.shift-card{background:var(--omni-primary-soft)}.shift-card.actual{background:var(--omni-gold-soft)}.payroll-warnings{background:var(--omni-warning-soft)!important;border-color:color-mix(in srgb,var(--omni-warning) 35%,transparent)!important;color:var(--omni-warning)!important}[data-omni-theme="graphite-forest-dark"]{--omni-warning:#E0B96C;--omni-warning-soft:#403522} .section-nav{background:color-mix(in srgb,var(--omni-surface) 94%,transparent)}.period-control input,.stack-form input,.stack-form select,.inline-form input,.inline-form select{background:var(--omni-surface-raised)}.shift-card{background:var(--omni-primary-soft)}.shift-card.actual{background:var(--omni-gold-soft)}.payroll-warnings{background:var(--omni-warning-soft)!important;border-color:color-mix(in srgb,var(--omni-warning) 35%,transparent)!important;color:var(--omni-warning)!important}[data-omni-theme="graphite-forest-dark"]{--omni-warning:#E0B96C;--omni-warning-soft:#403522}
@media(max-width:680px){
body{padding-bottom:76px}
.section-nav{position:fixed;bottom:0;top:auto;left:0;right:0;z-index:90;display:flex;justify-content:space-around;padding:8px 8px calc(8px + env(safe-area-inset-bottom,0px));background:var(--omni-surface);border-top:1px solid var(--omni-border);border-bottom:none;box-shadow:0 -4px 18px rgba(0,0,0,.08);gap:4px}
.section-nav .nav-item{flex:1 1 auto;text-align:center;min-height:42px;padding:6px 4px;font-size:13px;border-radius:8px}
.period-control{display:none}
.status.offline{color:var(--omni-warning,#B78335);font-weight:700;font-size:12px}
.people-panel{order:unset}
.status,#notify{display:inline-flex}
}
#notify.active{background:var(--omni-primary-soft,#DDE7DB);color:var(--omni-primary,#315F46)}
.status.offline{color:var(--omni-warning,#B78335);font-weight:600}
`; `;
export const portalJsEnhanced = export const portalJsEnhanced =
@ -324,21 +340,133 @@ export const portalJsEnhanced =
}); });
} }
const notifyBtn=byId('notify');
async function updatePushStatus(){
if(!('serviceWorker' in navigator)||!('PushManager' in window)||!notifyBtn) return;
try{
const reg=await navigator.serviceWorker.ready;
const sub=await reg.pushManager.getSubscription();
notifyBtn.textContent=sub?'Уведомления включены':'Включить push';
notifyBtn.classList.toggle('active',!!sub);
}catch{}
}
if(notifyBtn){
notifyBtn.addEventListener('click',async()=>{
if(!('serviceWorker' in navigator)||!('PushManager' in window)){
alert('Этот браузер не поддерживает WebPush-уведомления.');
return;
}
const reg=await navigator.serviceWorker.ready;
const existing=await reg.pushManager.getSubscription();
if(existing){
try{await send('/push/subscriptions','DELETE',{endpoint:existing.endpoint});}catch{}
await existing.unsubscribe();
await updatePushStatus();
return;
}
const perm=await Notification.requestPermission();
if(perm!=='granted'){
alert('Разрешение на push-уведомления не предоставлено.');
return;
}
try{
notifyBtn.disabled=true;
const res=await get('/push/public-key');
const pub=res.public_key;
const padding='='.repeat((4-(pub.length%4))%4);
const b64=(pub+padding).replace(/-/g,'+').replace(/_/g,'/');
const raw=atob(b64);
const arr=new Uint8Array(raw.length);
for(let i=0;i<raw.length;++i) arr[i]=raw.charCodeAt(i);
const sub=await reg.pushManager.subscribe({userVisibleOnly:true,applicationServerKey:arr});
const json=sub.toJSON();
await send('/push/subscriptions','POST',{
endpoint:sub.endpoint,
keys:{p256dh:json.keys.p256dh,auth:json.keys.auth},
user_agent:navigator.userAgent
});
await updatePushStatus();
const msg=byId('message');
if(msg){
msg.textContent='Push-уведомления о фиксации отпечатка успешно включены!';
msg.hidden=false;
setTimeout(()=>{msg.hidden=true;},4000);
}
}catch(err){
alert('Ошибка подключения push: '+(err.message||err));
}finally{
notifyBtn.disabled=false;
}
});
setTimeout(updatePushStatus,500);
}
window.addEventListener('online',()=>{
const conn=byId('connection');if(conn){conn.textContent='Синхронизировано';conn.className='status ok';}
loadActive();
});
window.addEventListener('offline',()=>{
const conn=byId('connection');if(conn){conn.textContent='Нет связи. Показаны сохранённые данные';conn.className='status offline';}
});
const observer=new MutationObserver(()=>{if(role!=='manager')return;document.querySelectorAll('.edit-employee').forEach(edit=>{if(edit.parentElement.querySelector('.person-access'))return;const button=document.createElement('button');button.type='button';button.className='quiet compact person-access';button.dataset.id=edit.dataset.id;button.textContent='Личный вход';edit.insertAdjacentElement('afterend',button)});fillSelects()});observer.observe(byId('employees'),{childList:true,subtree:true});const shellObserver=new MutationObserver(()=>{if(!byId('app-shell').hidden){initialized=false;initialize()}else initialized=false});shellObserver.observe(byId('app-shell'),{attributes:true,attributeFilter:['hidden']});setTimeout(initialize,0); const observer=new MutationObserver(()=>{if(role!=='manager')return;document.querySelectorAll('.edit-employee').forEach(edit=>{if(edit.parentElement.querySelector('.person-access'))return;const button=document.createElement('button');button.type='button';button.className='quiet compact person-access';button.dataset.id=edit.dataset.id;button.textContent='Личный вход';edit.insertAdjacentElement('afterend',button)});fillSelects()});observer.observe(byId('employees'),{childList:true,subtree:true});const shellObserver=new MutationObserver(()=>{if(!byId('app-shell').hidden){initialized=false;initialize()}else initialized=false});shellObserver.observe(byId('app-shell'),{attributes:true,attributeFilter:['hidden']});setTimeout(initialize,0);
})(); })();
`; `;
export const portalIcon192 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192"><rect width="192" height="192" rx="44" fill="#315F46"/><circle cx="96" cy="96" r="64" fill="#657255" opacity="0.35"/><path d="M 60 48 L 60 144 M 60 96 L 132 48 M 90 76 L 132 144" stroke="#B78335" stroke-width="16" stroke-linecap="round" stroke-linejoin="round" fill="none"/><circle cx="60" cy="48" r="6" fill="#EFE0C2"/><circle cx="132" cy="48" r="6" fill="#EFE0C2"/><circle cx="132" cy="144" r="6" fill="#EFE0C2"/></svg>`;
export const portalIcon512 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"><rect width="512" height="512" rx="120" fill="#315F46"/><circle cx="256" cy="256" r="170" fill="#657255" opacity="0.35"/><path d="M 160 128 L 160 384 M 160 256 L 352 128 M 240 200 L 352 384" stroke="#B78335" stroke-width="44" stroke-linecap="round" stroke-linejoin="round" fill="none"/><circle cx="160" cy="128" r="16" fill="#EFE0C2"/><circle cx="352" cy="128" r="16" fill="#EFE0C2"/><circle cx="352" cy="384" r="16" fill="#EFE0C2"/></svg>`;
export const portalManifest = JSON.stringify({ export const portalManifest = JSON.stringify({
name: 'Krona Staff', name: 'Krona Staff',
short_name: 'Staff', short_name: 'Staff',
start_url: '/staff', start_url: '/staff',
scope: '/staff',
display: 'standalone', display: 'standalone',
background_color: '#F3EAD7', background_color: '#F3EAD7',
theme_color: '#657255', theme_color: '#657255',
lang: 'ru', lang: 'ru',
icons: [
{
src: '/staff/icon-192.svg',
sizes: '192x192',
type: 'image/svg+xml',
purpose: 'any maskable',
},
{
src: '/staff/icon-512.svg',
sizes: '512x512',
type: 'image/svg+xml',
purpose: 'any maskable',
},
],
}); });
export const portalServiceWorker = `const CACHE='krona-staff-v3';const ASSETS=['/staff','/staff/styles.css','/staff/app.js','/staff/manifest.webmanifest'];self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(ASSETS))));self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k))))));self.addEventListener('fetch',e=>{if(e.request.method==='GET'&&new URL(e.request.url).pathname.startsWith('/staff'))e.respondWith(fetch(e.request).then(r=>{const copy=r.clone();caches.open(CACHE).then(c=>c.put(e.request,copy));return r;}).catch(()=>caches.match(e.request)));});`; export const portalServiceWorker = `const CACHE='krona-staff-v3';const ASSETS=['/staff','/staff/styles.css','/staff/app.js','/staff/manifest.webmanifest'];self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(ASSETS))));self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k))))));self.addEventListener('fetch',e=>{if(e.request.method==='GET'&&new URL(e.request.url).pathname.startsWith('/staff'))e.respondWith(fetch(e.request).then(r=>{const copy=r.clone();caches.open(CACHE).then(c=>c.put(e.request,copy));return r;}).catch(()=>caches.match(e.request)));});`;
export const portalServiceWorkerEnhanced = portalServiceWorker.replace( export const portalServiceWorkerEnhanced = `const CACHE='krona-staff-v5';
'krona-staff-v3', const ASSETS=['/staff','/staff/styles.css','/staff/app.js','/staff/manifest.webmanifest','/staff/icon-192.svg','/staff/icon-512.svg'];
'krona-staff-v4', self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(ASSETS)).then(()=>self.skipWaiting())));
); self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k)))).then(()=>self.clients.claim())));
self.addEventListener('fetch',e=>{
if(e.request.method==='GET'){
const url=new URL(e.request.url);
if(url.pathname.startsWith('/api/v1/staff/')){
e.respondWith(fetch(e.request).then(r=>{if(r.ok){const copy=r.clone();caches.open(CACHE).then(c=>c.put(e.request,copy));}return r;}).catch(()=>caches.match(e.request)));
return;
}
if(url.pathname.startsWith('/staff')){
e.respondWith(fetch(e.request).then(r=>{const copy=r.clone();caches.open(CACHE).then(c=>c.put(e.request,copy));return r;}).catch(()=>caches.match(e.request)));
}
}
});
self.addEventListener('push',e=>{
let data={title:'Krona Staff',body:'Отметка рабочего времени зарегистрирована',icon:'/staff/icon-192.svg',badge:'/staff/icon-192.svg',tag:'attendance',data:{url:'/staff'}};
if(e.data){
try{data=Object.assign(data,e.data.json());}catch{data.body=e.data.text()||data.body;}
}
e.waitUntil(self.registration.showNotification(data.title,{body:data.body,icon:data.icon||'/staff/icon-192.svg',badge:data.badge||'/staff/icon-192.svg',tag:data.tag||'attendance',renotify:true,data:data.data||{url:'/staff'}}));
});
self.addEventListener('notificationclick',e=>{
e.notification.close();
const url=(e.notification.data&&e.notification.data.url)||'/staff';
e.waitUntil(clients.matchAll({type:'window',includeUncontrolled:true}).then(wList=>{for(const c of wList){if(c.url.includes('/staff')&&'focus' in c)return c.focus();}if(clients.openWindow)return clients.openWindow(url);}));
});`;

View file

@ -8,9 +8,24 @@ import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/i
import type { Database } from './infrastructure/database.js'; import type { Database } from './infrastructure/database.js';
import { reconcileWorkSession } from './domain/attendance/work-session.js'; import { reconcileWorkSession } from './domain/attendance/work-session.js';
import { registerOperationsRoutes } from './operations.js'; import { registerOperationsRoutes } from './operations.js';
import { resolveVapidKeys } from './domain/notification/web-push.js';
import { PushDispatcher } from './domain/notification/push-dispatcher.js';
const datePattern = /^\d{4}-\d{2}-\d{2}$/; const datePattern = /^\d{4}-\d{2}-\d{2}$/;
interface PushSubscriptionBody {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
user_agent?: string | undefined;
}
interface DeleteSubscriptionBody {
endpoint: string;
}
interface AttendanceBody { interface AttendanceBody {
event_id?: string; event_id?: string;
employee_id: string; employee_id: string;
@ -83,6 +98,121 @@ export async function registerPortalRoutes(
: new StandaloneIdentityAdapter(config); : new StandaloneIdentityAdapter(config);
const identity = withStandaloneEmployeeAccess(baseIdentity, config, database); const identity = withStandaloneEmployeeAccess(baseIdentity, config, database);
const vapidKeys = resolveVapidKeys(config);
const pushDispatcher = new PushDispatcher({
database,
vapidKeys,
systemActorId: config.STANDALONE_ACTOR_ID,
});
api.get(
'/push/public-key',
{
schema: {
tags: ['PushNotifications'],
response: {
200: {
type: 'object',
required: ['public_key'],
properties: { public_key: { type: 'string' } },
},
},
},
},
async (_request, reply) => {
reply.header('cache-control', 'public, max-age=86400');
return { public_key: vapidKeys.publicKey };
},
);
api.post<{ Body: PushSubscriptionBody }>(
'/push/subscriptions',
{
schema: {
tags: ['PushNotifications'],
body: {
type: 'object',
additionalProperties: false,
required: ['endpoint', 'keys'],
properties: {
endpoint: { type: 'string', minLength: 10, maxLength: 2000 },
keys: {
type: 'object',
additionalProperties: false,
required: ['p256dh', 'auth'],
properties: {
p256dh: { type: 'string', minLength: 10, maxLength: 250 },
auth: { type: 'string', minLength: 10, maxLength: 100 },
},
},
user_agent: { type: 'string', maxLength: 500 },
},
},
},
},
async (request, reply) => {
assertCookieMutationOrigin(request);
const context = await resolveContext(identity, request);
const canReadOwn = context.permissions.has('staff:attendance:read_own');
const canManage = context.permissions.has('staff:attendance:manage');
if (!canReadOwn && !canManage) {
return reply.forbidden('Недостаточно прав для настройки push-уведомлений.');
}
const employee = await database.tenantTransaction(
context.tenantId,
context.actorId,
async (client) => {
const res = await client.query<{ id: string }>(
`SELECT id FROM staff_employees
WHERE tenant_id = $1 AND (core_user_id = $2 OR id = $2) AND status = 'active'
LIMIT 1`,
[context.tenantId, context.actorId],
);
return res.rows[0];
},
);
if (!employee) {
return reply.notFound('Сотрудник для текущего пользователя не найден.');
}
const saved = await pushDispatcher.saveSubscription(
context.tenantId,
employee.id,
{
endpoint: request.body.endpoint,
p256dh: request.body.keys.p256dh,
auth: request.body.keys.auth,
user_agent: request.body.user_agent,
},
);
return reply.code(201).send(saved);
},
);
api.delete<{ Body: DeleteSubscriptionBody }>(
'/push/subscriptions',
{
schema: {
tags: ['PushNotifications'],
body: {
type: 'object',
additionalProperties: false,
required: ['endpoint'],
properties: {
endpoint: { type: 'string', minLength: 10, maxLength: 2000 },
},
},
},
},
async (request, reply) => {
assertCookieMutationOrigin(request);
const context = await resolveContext(identity, request);
await pushDispatcher.deleteSubscription(context.tenantId, request.body.endpoint);
return reply.code(204).send();
},
);
api.post<{ Body: SessionBody }>( api.post<{ Body: SessionBody }>(
'/session', '/session',
{ {
@ -653,6 +783,7 @@ export async function registerPortalRoutes(
); );
if (inserted.rowCount === 0) return { id: eventId, duplicate: true }; if (inserted.rowCount === 0) return { id: eventId, duplicate: true };
const outboxId = newUlid();
const eventType = const eventType =
request.body.event_type === 'check_in' ? 'attendance.clock.in' : 'attendance.clock.out'; request.body.event_type === 'check_in' ? 'attendance.clock.in' : 'attendance.clock.out';
await client.query( await client.query(
@ -661,7 +792,7 @@ export async function registerPortalRoutes(
actor_type, actor_id, payload, correlation_id, occurred_at) actor_type, actor_id, payload, correlation_id, occurred_at)
VALUES ($1, $2, $3, 1, 'Employee', $4, 'service', $5, $6, $7, $8)`, VALUES ($1, $2, $3, 1, 'Employee', $4, 'service', $5, $6, $7, $8)`,
[ [
newUlid(), outboxId,
context.tenantId, context.tenantId,
eventType, eventType,
employeeId, employeeId,
@ -671,11 +802,29 @@ export async function registerPortalRoutes(
occurredAt.toISOString(), occurredAt.toISOString(),
], ],
); );
return { id: eventId, duplicate: false }; return { id: eventId, outbox_id: outboxId, duplicate: false };
}, },
); );
if (!result) return reply.notFound('Активный сотрудник не найден.'); if (!result) return reply.notFound('Активный сотрудник не найден.');
return reply.code(result.duplicate ? 200 : 201).send(result);
if (!result.duplicate && result.outbox_id) {
const outboxIdToDispatch = result.outbox_id;
setImmediate(() => {
void pushDispatcher
.dispatchForOutbox(context.tenantId, outboxIdToDispatch)
.catch((err: unknown) => {
request.log.error(
{ err, outboxId: outboxIdToDispatch },
'Background push dispatch error',
);
});
});
}
return reply.code(result.duplicate ? 200 : 201).send({
id: result.id,
duplicate: result.duplicate,
});
}, },
); );