From 87a6a447c2db5c366ae4bcdfd8f1b3e7ef786256 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Sat, 26 Sep 2026 12:08:31 +0700 Subject: [PATCH] feat(pwa): mobile-first employee portal with offline sync and push integration --- api/openapi.yaml | 70 +++++++++++++++++++++ src/portal-ui.ts | 138 +++++++++++++++++++++++++++++++++++++++-- src/portal.ts | 155 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 355 insertions(+), 8 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 1100a55..c2602ae 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -590,6 +590,76 @@ paths: responses: '200': { 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: securitySchemes: bearerAuth: diff --git a/src/portal-ui.ts b/src/portal-ui.ts index 3d549d2..00a67fe 100644 --- a/src/portal-ui.ts +++ b/src/portal-ui.ts @@ -6,6 +6,11 @@ export const portalHtml = ` Krona Staff + + + + + @@ -51,7 +56,7 @@ export const portalHtml = `

Последние события

Отметки терминала

● в реальном времени
- +

Сверка терминала

Рабочие смены

ДатаСотрудникПодразделениеИнтервалЧасыСостояниеПричина правки

Учёт времени

Табель за 40 дней

Изменения сохраняет руководитель
ДатаСотрудникЧасыСтатусПримечание
@@ -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 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} +@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 = @@ -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{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); })(); `; +export const portalIcon192 = ``; + +export const portalIcon512 = ``; + export const portalManifest = JSON.stringify({ name: 'Krona Staff', short_name: 'Staff', start_url: '/staff', + scope: '/staff', display: 'standalone', background_color: '#F3EAD7', theme_color: '#657255', 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 portalServiceWorkerEnhanced = portalServiceWorker.replace( - 'krona-staff-v3', - 'krona-staff-v4', -); +export const portalServiceWorkerEnhanced = `const CACHE='krona-staff-v5'; +const ASSETS=['/staff','/staff/styles.css','/staff/app.js','/staff/manifest.webmanifest','/staff/icon-192.svg','/staff/icon-512.svg']; +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);})); +});`; + diff --git a/src/portal.ts b/src/portal.ts index 5335036..99f1818 100644 --- a/src/portal.ts +++ b/src/portal.ts @@ -8,9 +8,24 @@ import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/i import type { Database } from './infrastructure/database.js'; import { reconcileWorkSession } from './domain/attendance/work-session.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}$/; +interface PushSubscriptionBody { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; + user_agent?: string | undefined; +} + +interface DeleteSubscriptionBody { + endpoint: string; +} + interface AttendanceBody { event_id?: string; employee_id: string; @@ -83,6 +98,121 @@ export async function registerPortalRoutes( : new StandaloneIdentityAdapter(config); 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 }>( '/session', { @@ -653,6 +783,7 @@ export async function registerPortalRoutes( ); if (inserted.rowCount === 0) return { id: eventId, duplicate: true }; + const outboxId = newUlid(); const eventType = request.body.event_type === 'check_in' ? 'attendance.clock.in' : 'attendance.clock.out'; await client.query( @@ -661,7 +792,7 @@ export async function registerPortalRoutes( actor_type, actor_id, payload, correlation_id, occurred_at) VALUES ($1, $2, $3, 1, 'Employee', $4, 'service', $5, $6, $7, $8)`, [ - newUlid(), + outboxId, context.tenantId, eventType, employeeId, @@ -671,11 +802,29 @@ export async function registerPortalRoutes( occurredAt.toISOString(), ], ); - return { id: eventId, duplicate: false }; + return { id: eventId, outbox_id: outboxId, duplicate: false }; }, ); 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, + }); }, );