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,
+ });
},
);