import { createHash, randomBytes } from 'node:crypto'; import type { FastifyInstance, FastifyRequest } from 'fastify'; import type { AppConfig } from './config.js'; import type { RequestContext } from './core/context.js'; import { newUlid, parseUlid } from './core/ids.js'; import type { IdentityPort } from './core/ports.js'; import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js'; 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; event_type: 'check_in' | 'check_out'; occurred_at: string; source_event_id: string; device_id?: string; org_unit_id?: string; } interface TimesheetBody { minutes_worked: number; status: 'draft' | 'submitted' | 'approved' | 'rejected'; note?: string; } interface EmployeeBody { personnel_number: string; display_name: string; position?: string; profession_id?: string; gender?: 'male' | 'female' | ''; } interface EmployeePatchBody { personnel_number?: string; display_name?: string; position?: string; profession_id?: string | null; gender?: 'male' | 'female' | ''; status?: 'active' | 'blocked' | 'dismissed'; } interface ProfessionBody { name: string; status?: 'active' | 'archived'; } interface ReconcileSessionsBody { date_from: string; date_to: string; business_day_start_hour?: number; automatic_close_hour?: number; open_shift_threshold_minutes?: number; } interface ConfirmSessionBody { status: 'confirmed' | 'rejected'; worked_minutes?: number; started_at?: string | null; ended_at?: string | null; correction_note?: string; } interface SessionBody { access_token: string; } const sessionCookieName = '__Host-krona_staff_session'; const sessionMaxAgeSeconds = 8 * 60 * 60; export async function registerPortalRoutes( api: FastifyInstance, config: AppConfig, database: Database, ) { const baseIdentity: IdentityPort = config.STAFF_RUNTIME_MODE === 'platform' ? new PlatformIdentityAdapter() : 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', { schema: { body: { type: 'object', additionalProperties: false, required: ['access_token'], properties: { access_token: { type: 'string', minLength: 32, maxLength: 4096 } }, }, }, }, async (request, reply) => { assertSameOrigin(request); if (config.STAFF_RUNTIME_MODE === 'standalone' && !config.STANDALONE_MANAGER_TOKEN) { return reply.serviceUnavailable('Вход администратора ещё не настроен.'); } const context = await resolveToken(identity, request.body.access_token, request); const role = context.permissions.has('staff:attendance:manage') ? 'manager' : context.permissions.has('staff:attendance:read_own') ? 'employee' : null; if (!role) { return reply.forbidden('Ключ не подходит для входа в кабинет.'); } reply .header('cache-control', 'no-store') .header( 'set-cookie', serializeSessionCookie(request.body.access_token, sessionMaxAgeSeconds), ); return { role, expires_in: sessionMaxAgeSeconds }; }, ); api.delete('/session', async (request, reply) => { assertSameOrigin(request); reply.header('cache-control', 'no-store').header('set-cookie', serializeSessionCookie('', 0)); return reply.code(204).send(); }); api.get('/portal', async (request, reply) => { reply.header('cache-control', 'no-store'); const context = await resolveContext(identity, request); const canManage = context.permissions.has('staff:attendance:manage'); if (!canManage && !context.permissions.has('staff:attendance:read_own')) { return reply.forbidden('Недостаточно прав для просмотра посещаемости.'); } return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { const employees = await client.query( `SELECT e.id, e.core_user_id, e.personnel_number, e.display_name, e.position, e.profession_id, p.name AS profession_name, e.gender, e.status FROM staff_employees e LEFT JOIN staff_professions p ON p.tenant_id = e.tenant_id AND p.id = e.profession_id WHERE e.status <> 'dismissed' AND ($1::boolean OR e.core_user_id = $2) ORDER BY e.display_name`, [canManage, context.actorId], ); const attendance = await client.query( `SELECT a.id, a.employee_id, e.display_name, a.event_type, a.source, a.occurred_at, a.received_at, a.status FROM staff_attendance_events a JOIN staff_employees e ON e.tenant_id = a.tenant_id AND e.id = a.employee_id WHERE ($1::boolean OR e.core_user_id = $2) ORDER BY a.occurred_at DESC LIMIT 100`, [canManage, context.actorId], ); const timesheet = await client.query( `SELECT t.id, t.employee_id, e.display_name, t.work_date, t.minutes_worked, t.status, t.note, t.revision, t.updated_at FROM staff_timesheet_entries t JOIN staff_employees e ON e.tenant_id = t.tenant_id AND e.id = t.employee_id WHERE t.work_date >= (CURRENT_DATE - INTERVAL '40 days') AND ($1::boolean OR e.core_user_id = $2) ORDER BY t.work_date DESC, e.display_name`, [canManage, context.actorId], ); const workSessions = await client.query( `SELECT s.id, s.employee_id, e.display_name, s.org_unit_id, u.name AS org_unit_name, s.business_date, s.started_at, s.ended_at, s.worked_minutes, s.status, s.anomaly, s.correction_note, s.auto_closed, s.revision FROM staff_work_sessions s JOIN staff_employees e ON e.tenant_id = s.tenant_id AND e.id = s.employee_id JOIN staff_org_units u ON u.tenant_id = s.tenant_id AND u.id = s.org_unit_id WHERE s.business_date >= (CURRENT_DATE - INTERVAL '40 days') AND ($1::boolean OR e.core_user_id = $2) ORDER BY s.business_date DESC, e.display_name`, [canManage, context.actorId], ); return { viewer: { actor_id: context.actorId, role: canManage ? 'manager' : 'employee' }, employees: employees.rows, attendance: attendance.rows, timesheet: timesheet.rows, work_sessions: workSessions.rows, refreshed_at: new Date().toISOString(), }; }); }); api.get('/professions', async (request) => { const context = await resolveContext(identity, request); return database.tenantTransaction(context.tenantId, context.actorId, async (client) => { const result = await client.query( `SELECT id, code, name, status, created_at, updated_at FROM staff_professions ORDER BY status, name`, ); return { professions: result.rows }; }); }); api.post<{ Body: ProfessionBody }>( '/professions', { schema: { body: { type: 'object', additionalProperties: false, required: ['name'], properties: { name: { type: 'string', minLength: 1, maxLength: 160 }, }, }, }, }, async (request, reply) => { assertCookieMutationOrigin(request); const context = await requirePermission(identity, request, 'staff:employee:write'); const name = request.body.name.trim(); if (!name) return reply.badRequest('Название профессии обязательно.'); const id = newUlid(); const saved = await database.tenantTransaction( context.tenantId, context.actorId, async (client) => { const result = await client.query( `INSERT INTO staff_professions (id, tenant_id, code, name) VALUES ($1, $2, $3, $4) ON CONFLICT (tenant_id, name) DO NOTHING RETURNING id, code, name, status`, [id, context.tenantId, `profession-${id.toLowerCase()}`, name], ); return result.rows[0] as Record | undefined; }, ); if (!saved) return reply.conflict('Такая профессия уже существует.'); return reply.code(201).send(saved); }, ); api.patch<{ Params: { professionId: string }; Body: ProfessionBody }>( '/professions/:professionId', { schema: { params: { type: 'object', required: ['professionId'], properties: { professionId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, }, }, body: { type: 'object', additionalProperties: false, minProperties: 1, properties: { name: { type: 'string', minLength: 1, maxLength: 160 }, status: { enum: ['active', 'archived'] }, }, }, }, }, async (request, reply) => { assertCookieMutationOrigin(request); const context = await requirePermission(identity, request, 'staff:employee:write'); const professionId = parseUlid(request.params.professionId); const name = request.body.name?.trim(); const saved = await database.tenantTransaction( context.tenantId, context.actorId, async (client) => { const result = await client.query( `UPDATE staff_professions SET name = COALESCE($3, name), status = COALESCE($4, status), updated_at = now() WHERE tenant_id = $1 AND id = $2 RETURNING id, code, name, status`, [context.tenantId, professionId, name, request.body.status], ); return result.rows[0] as Record | undefined; }, ); if (!saved) return reply.notFound('Профессия не найдена.'); return reply.send(saved); }, ); api.post<{ Body: EmployeeBody }>( '/employees', { schema: { body: { type: 'object', additionalProperties: false, required: ['personnel_number', 'display_name'], properties: { personnel_number: { type: 'string', minLength: 1, maxLength: 80 }, display_name: { type: 'string', minLength: 1, maxLength: 240 }, position: { type: 'string', maxLength: 160 }, profession_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, gender: { enum: ['', 'male', 'female'] }, }, }, }, }, async (request, reply) => { assertCookieMutationOrigin(request); const context = await requirePermission(identity, request, 'staff:employee:write'); const employeeId = newUlid(); const personnelNumber = request.body.personnel_number.trim(); const displayName = request.body.display_name.trim(); if (!personnelNumber || !displayName) return reply.badRequest('Табельный номер и имя обязательны.'); const employee = await database.tenantTransaction( context.tenantId, context.actorId, async (client) => { const saved = await client.query( `INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, position, profession_id, gender, status) VALUES ($1, $2, $3, $4, $5, COALESCE($6, (SELECT id FROM staff_professions WHERE tenant_id = $2 AND name = $5 AND status = 'active' LIMIT 1)), $7, 'active') ON CONFLICT (tenant_id, personnel_number) DO NOTHING RETURNING id, core_user_id, personnel_number, display_name, position, profession_id, gender, status`, [ employeeId, context.tenantId, personnelNumber, displayName, request.body.position?.trim() ?? '', request.body.profession_id ?? null, request.body.gender ?? '', ], ); if (saved.rowCount === 0) return null; const result = saved.rows[0] as Record; await client.query( `INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id) VALUES ($1, $2, $3, 'employee.created', 'Employee', $4, $5, $6)`, [ newUlid(), context.tenantId, context.actorId, employeeId, result, context.correlationId, ], ); return result; }, ); if (!employee) return reply.conflict('Сотрудник с таким табельным номером уже существует.'); return reply.code(201).send(employee); }, ); api.patch<{ Params: { employeeId: string }; Body: EmployeePatchBody }>( '/employees/:employeeId', { schema: { params: { type: 'object', required: ['employeeId'], properties: { employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, }, }, body: { type: 'object', additionalProperties: false, minProperties: 1, properties: { personnel_number: { type: 'string', minLength: 1, maxLength: 80 }, display_name: { type: 'string', minLength: 1, maxLength: 240 }, position: { type: 'string', maxLength: 160 }, profession_id: { anyOf: [{ type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, { type: 'null' }], }, gender: { enum: ['', 'male', 'female'] }, status: { enum: ['active', 'blocked', 'dismissed'] }, }, }, }, }, async (request, reply) => { assertCookieMutationOrigin(request); const context = await requirePermission(identity, request, 'staff:employee:write'); const employeeId = parseUlid(request.params.employeeId); const personnelNumber = request.body.personnel_number?.trim(); const displayName = request.body.display_name?.trim(); const hasPosition = Object.prototype.hasOwnProperty.call(request.body, 'position'); const hasProfession = Object.prototype.hasOwnProperty.call(request.body, 'profession_id'); if (personnelNumber === '' || displayName === '') return reply.badRequest('Табельный номер и имя не могут быть пустыми.'); const outcome = await database.tenantTransaction( context.tenantId, context.actorId, async (client) => { const current = await client.query( `SELECT id, core_user_id, personnel_number, display_name, position, profession_id, gender, status FROM staff_employees WHERE tenant_id = $1 AND id = $2 FOR UPDATE`, [context.tenantId, employeeId], ); if (current.rowCount === 0) return { kind: 'missing' as const }; if (personnelNumber) { const duplicate = await client.query( `SELECT id FROM staff_employees WHERE tenant_id = $1 AND personnel_number = $2 AND id <> $3 LIMIT 1`, [context.tenantId, personnelNumber, employeeId], ); if ((duplicate.rowCount ?? 0) > 0) return { kind: 'conflict' as const }; } let nextPosition = request.body.position?.trim(); let nextProfessionId = request.body.profession_id; let professionTouched = hasProfession || hasPosition; if (hasProfession && nextProfessionId) { const profession = await client.query<{ id: string; name: string }>( `SELECT id, name FROM staff_professions WHERE tenant_id = $1 AND id = $2 AND status = 'active'`, [context.tenantId, nextProfessionId], ); if (!profession.rows[0]) return { kind: 'invalid_profession' as const }; nextProfessionId = profession.rows[0].id; nextPosition = profession.rows[0].name; } else if (hasProfession && nextProfessionId === null) { nextPosition ??= ''; } else if (hasPosition) { const profession = await client.query<{ id: string }>( `SELECT id FROM staff_professions WHERE tenant_id = $1 AND name = $2 AND status = 'active' LIMIT 1`, [context.tenantId, nextPosition ?? ''], ); nextProfessionId = profession.rows[0]?.id ?? null; } else { professionTouched = false; } const saved = await client.query( `UPDATE staff_employees SET personnel_number = COALESCE($3, personnel_number), display_name = COALESCE($4, display_name), position = CASE WHEN $5::boolean THEN $6 ELSE position END, profession_id = CASE WHEN $7::boolean THEN $8::varchar(26) ELSE profession_id END, gender = COALESCE($9, gender), status = COALESCE($10, status), updated_at = now() WHERE tenant_id = $1 AND id = $2 RETURNING id, core_user_id, personnel_number, display_name, position, profession_id, gender, status`, [ context.tenantId, employeeId, personnelNumber, displayName, hasPosition || hasProfession, nextPosition, professionTouched, nextProfessionId, request.body.gender, request.body.status, ], ); const result = saved.rows[0] as Record; if (result.status !== 'active') { await client.query( `DELETE FROM staff_standalone_employee_access WHERE tenant_id = $1 AND employee_id = $2`, [context.tenantId, employeeId], ); } await client.query( `INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, entity_id, old_values, new_values, correlation_id) VALUES ($1, $2, $3, 'employee.updated', 'Employee', $4, $5, $6, $7)`, [ newUlid(), context.tenantId, context.actorId, employeeId, current.rows[0], result, context.correlationId, ], ); return { kind: 'saved' as const, employee: result }; }, ); if (outcome.kind === 'missing') return reply.notFound('Сотрудник не найден.'); if (outcome.kind === 'conflict') { return reply.conflict('Сотрудник с таким табельным номером уже существует.'); } if (outcome.kind === 'invalid_profession') return reply.badRequest('Профессия не найдена или отключена.'); return reply.send(outcome.employee); }, ); api.post<{ Params: { employeeId: string } }>( '/employees/:employeeId/access-token', { schema: { params: { type: 'object', additionalProperties: false, required: ['employeeId'], properties: { employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, }, }, }, }, async (request, reply) => { assertCookieMutationOrigin(request); if (config.STAFF_RUNTIME_MODE !== 'standalone') { return reply.conflict('В платформенном режиме доступом сотрудников управляет Core SSO.'); } const context = await requirePermission(identity, request, 'staff:employee:write'); const employeeId = parseUlid(request.params.employeeId); const accessToken = randomBytes(32).toString('base64url'); const tokenHash = hashAccessToken(accessToken); const tokenHint = accessToken.slice(-6); const saved = await database.tenantTransaction( context.tenantId, context.actorId, async (client) => { const employee = await client.query( `SELECT id, core_user_id, display_name FROM staff_employees WHERE tenant_id = $1 AND id = $2 AND status = 'active' FOR UPDATE`, [context.tenantId, employeeId], ); if (employee.rowCount === 0) return null; const employeeRow = employee.rows[0] as | { core_user_id?: string | null; display_name?: string; } | undefined; const subjectId = String(employeeRow?.core_user_id ?? newUlid()); await client.query( `UPDATE staff_employees SET core_user_id = $3, updated_at = now() WHERE tenant_id = $1 AND id = $2`, [context.tenantId, employeeId, subjectId], ); await client.query( `INSERT INTO staff_standalone_employee_access (id, tenant_id, employee_id, subject_id, token_hash, token_hint, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (tenant_id, employee_id) DO UPDATE SET subject_id = EXCLUDED.subject_id, token_hash = EXCLUDED.token_hash, token_hint = EXCLUDED.token_hint, created_by = EXCLUDED.created_by, rotated_at = now()`, [ newUlid(), context.tenantId, employeeId, subjectId, tokenHash, tokenHint, context.actorId, ], ); await client.query( `INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id) VALUES ($1, $2, $3, 'employee.access.rotated', 'Employee', $4, $5, $6)`, [ newUlid(), context.tenantId, context.actorId, employeeId, { subject_id: subjectId, token_hint: tokenHint }, context.correlationId, ], ); return { employee_id: employeeId, display_name: employeeRow?.display_name, subject_id: subjectId, token_hint: tokenHint, }; }, ); if (!saved) return reply.notFound('Активный сотрудник не найден.'); return reply.send({ ...saved, access_token: accessToken }); }, ); api.post<{ Body: AttendanceBody }>( '/attendance/events', { schema: { body: { type: 'object', additionalProperties: false, required: ['employee_id', 'event_type', 'occurred_at', 'source_event_id'], properties: { event_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, employee_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, event_type: { enum: ['check_in', 'check_out'] }, occurred_at: { type: 'string', format: 'date-time' }, source_event_id: { type: 'string', minLength: 1, maxLength: 200 }, device_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, org_unit_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, }, }, }, }, async (request, reply) => { const context = await requirePermission(identity, request, 'staff:attendance:clock'); const eventId = request.body.event_id ? parseUlid(request.body.event_id) : newUlid(); const employeeId = parseUlid(request.body.employee_id); const deviceId = request.body.device_id ? parseUlid(request.body.device_id) : null; const orgUnitId = request.body.org_unit_id ? parseUlid(request.body.org_unit_id) : null; const occurredAt = new Date(request.body.occurred_at); if (Number.isNaN(occurredAt.valueOf())) return reply.badRequest('Некорректное время отметки.'); const now = Date.now(); if ( occurredAt.valueOf() > now + 5 * 60_000 || occurredAt.valueOf() < now - 180 * 86_400_000 ) { return reply.badRequest('Время отметки выходит за допустимый период пилота.'); } const result = await database.tenantTransaction( context.tenantId, context.actorId, async (client) => { const employee = await client.query( `SELECT id FROM staff_employees WHERE tenant_id = $1 AND id = $2 AND status = 'active'`, [context.tenantId, employeeId], ); if (employee.rowCount === 0) return null; const inserted = await client.query( `INSERT INTO staff_attendance_events (id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, device_id, org_unit_id, actor_id, correlation_id) VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9, $10) ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING RETURNING id`, [ eventId, context.tenantId, employeeId, request.body.event_type, request.body.source_event_id, occurredAt.toISOString(), deviceId, orgUnitId, context.actorId, context.correlationId, ], ); 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( `INSERT INTO staff_outbox (id, tenant_id, event_type, event_version, subject_type, subject_id, actor_type, actor_id, payload, correlation_id, occurred_at) VALUES ($1, $2, $3, 1, 'Employee', $4, 'service', $5, $6, $7, $8)`, [ outboxId, context.tenantId, eventType, employeeId, context.actorId, { attendance_event_id: eventId, channel: 'platform_notification' }, context.correlationId, occurredAt.toISOString(), ], ); return { id: eventId, outbox_id: outboxId, duplicate: false }; }, ); if (!result) return reply.notFound('Активный сотрудник не найден.'); 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, }); }, ); api.post<{ Body: ReconcileSessionsBody }>( '/work-sessions/reconcile', { schema: { body: { type: 'object', additionalProperties: false, required: ['date_from', 'date_to'], properties: { date_from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, date_to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, business_day_start_hour: { type: 'integer', minimum: 0, maximum: 23 }, automatic_close_hour: { type: 'integer', minimum: 0, maximum: 23 }, open_shift_threshold_minutes: { type: 'integer', minimum: 60, maximum: 2880 }, }, }, }, }, async (request, reply) => { assertCookieMutationOrigin(request); const context = await requirePermission(identity, request, 'staff:shift:confirm'); const from = parseDateOnly(request.body.date_from); const to = parseDateOnly(request.body.date_to); if (!from || !to || to < from) return reply.badRequest('Некорректный период сверки.'); const periodDays = Math.floor((to.valueOf() - from.valueOf()) / 86_400_000) + 1; if (periodDays > 40) return reply.badRequest('За один раз можно сверить не более 40 дней.'); const businessDayStartHour = request.body.business_day_start_hour ?? 4; const automaticCloseHour = request.body.automatic_close_hour ?? 2; const thresholdMinutes = request.body.open_shift_threshold_minutes ?? 14 * 60; const now = new Date().toISOString(); const summary = await database.tenantTransaction( context.tenantId, context.actorId, async (client) => { const attendance = await client.query( `WITH categorized AS ( SELECT a.id, a.employee_id, a.event_type, a.occurred_at, COALESCE(a.org_unit_id, e.primary_org_unit_id) AS org_unit_id, COALESCE(u.timezone, 'UTC') AS timezone, ((a.occurred_at AT TIME ZONE COALESCE(u.timezone, 'UTC')) - make_interval(hours => $3))::date AS business_date FROM staff_attendance_events a JOIN staff_employees e ON e.tenant_id = a.tenant_id AND e.id = a.employee_id LEFT JOIN staff_org_units u ON u.tenant_id = a.tenant_id AND u.id = COALESCE(a.org_unit_id, e.primary_org_unit_id) WHERE a.status = 'accepted' AND a.occurred_at >= ($1::date - INTERVAL '2 days') AND a.occurred_at < ($2::date + INTERVAL '3 days') ) SELECT id, employee_id, event_type, occurred_at, org_unit_id, timezone, business_date::text, (((business_date + 1)::timestamp + make_interval(hours => $4)) AT TIME ZONE timezone) AS automatic_close_at FROM categorized WHERE business_date BETWEEN $1::date AND $2::date ORDER BY employee_id, org_unit_id NULLS LAST, business_date, occurred_at, id`, [ request.body.date_from, request.body.date_to, businessDayStartHour, automaticCloseHour, ], ); type AttendanceRow = { id: string; employee_id: string; event_type: 'check_in' | 'check_out'; occurred_at: Date | string; org_unit_id: string | null; business_date: string; automatic_close_at: Date | string; }; const grouped = new Map(); let unresolvedEvents = 0; for (const row of attendance.rows as AttendanceRow[]) { if (!row.org_unit_id) { unresolvedEvents += 1; continue; } const key = `${row.employee_id}:${row.org_unit_id}:${row.business_date}`; const group = grouped.get(key) ?? []; group.push(row); grouped.set(key, group); } let createdOrUpdated = 0; let confirmedSkipped = 0; let anomalous = 0; for (const rows of grouped.values()) { const first = rows[0]; if (!first?.org_unit_id) continue; const candidate = reconcileWorkSession( rows.map((row) => ({ id: row.id, type: row.event_type, occurredAt: toIsoInstant(row.occurred_at), })), { now, openShiftThresholdMinutes: thresholdMinutes, automaticCloseAt: toIsoInstant(first.automatic_close_at), }, ); if (!candidate) continue; if (candidate.anomalies.length > 0) anomalous += 1; const saved = await client.query( `INSERT INTO staff_work_sessions (id, tenant_id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, source, status, anomaly, source_event_ids, auto_closed) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'terminal', $9, $10, $11, $12) ON CONFLICT (tenant_id, employee_id, org_unit_id, business_date) DO UPDATE SET started_at = EXCLUDED.started_at, ended_at = EXCLUDED.ended_at, worked_minutes = EXCLUDED.worked_minutes, status = EXCLUDED.status, anomaly = EXCLUDED.anomaly, source_event_ids = EXCLUDED.source_event_ids, auto_closed = EXCLUDED.auto_closed, updated_at = now(), revision = staff_work_sessions.revision + 1 WHERE staff_work_sessions.status <> 'confirmed' RETURNING id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, status, anomaly, source_event_ids, auto_closed, revision`, [ newUlid(), context.tenantId, first.employee_id, first.org_unit_id, first.business_date, candidate.startedAt, candidate.endedAt, candidate.workedMinutes, candidate.status, candidate.anomalies.join(',') || null, JSON.stringify(candidate.attendanceEventIds), candidate.anomalies.includes('auto_closed'), ], ); if (saved.rowCount === 0) { confirmedSkipped += 1; continue; } createdOrUpdated += 1; const result = saved.rows[0] as Record; await client.query( `INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id) VALUES ($1, $2, $3, 'work_session.reconciled', 'WorkSession', $4, $5, $6)`, [ newUlid(), context.tenantId, context.actorId, result.id, result, context.correlationId, ], ); } return { groups: grouped.size, sessions_created_or_updated: createdOrUpdated, confirmed_sessions_skipped: confirmedSkipped, anomalous_sessions: anomalous, unresolved_events: unresolvedEvents, }; }, ); return reply.send(summary); }, ); api.patch<{ Params: { sessionId: string }; Body: ConfirmSessionBody }>( '/work-sessions/:sessionId', { schema: { params: { type: 'object', required: ['sessionId'], properties: { sessionId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, }, }, body: { type: 'object', additionalProperties: false, required: ['status'], properties: { status: { enum: ['confirmed', 'rejected'] }, worked_minutes: { type: 'integer', minimum: 0, maximum: 2880 }, started_at: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] }, ended_at: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] }, correction_note: { type: 'string', maxLength: 1000 }, }, }, }, }, async (request, reply) => { assertCookieMutationOrigin(request); const context = await requirePermission(identity, request, 'staff:shift:confirm'); const sessionId = parseUlid(request.params.sessionId); const outcome = await database.tenantTransaction( context.tenantId, context.actorId, async (client) => { const currentQuery = await client.query( `SELECT id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, status, anomaly, correction_note, source_event_ids, auto_closed, revision FROM staff_work_sessions WHERE tenant_id = $1 AND id = $2 FOR UPDATE`, [context.tenantId, sessionId], ); if (currentQuery.rowCount === 0) return { kind: 'missing' as const }; const current = currentQuery.rows[0] as Record; const currentStartedAt = parseDatabaseOptionalInstant(current.started_at); const currentEndedAt = parseDatabaseOptionalInstant(current.ended_at); const startedAt = request.body.started_at === undefined ? currentStartedAt : parseOptionalInstant(request.body.started_at); const endedAt = request.body.ended_at === undefined ? currentEndedAt : parseOptionalInstant(request.body.ended_at); const workedMinutes = request.body.worked_minutes ?? Number(current.worked_minutes); const changed = workedMinutes !== Number(current.worked_minutes) || startedAt !== currentStartedAt || endedAt !== currentEndedAt; const correctionNote = request.body.correction_note?.trim() ?? ''; if (changed && !correctionNote) return { kind: 'note_required' as const }; if (startedAt && endedAt && new Date(endedAt) < new Date(startedAt)) { return { kind: 'invalid_interval' as const }; } const saved = await client.query( `UPDATE staff_work_sessions SET started_at = $3, ended_at = $4, worked_minutes = $5, status = $6, correction_note = $7, confirmed_by = CASE WHEN $6 = 'confirmed' THEN $8 ELSE NULL END, confirmed_at = CASE WHEN $6 = 'confirmed' THEN now() ELSE NULL END, updated_at = now(), revision = revision + 1 WHERE tenant_id = $1 AND id = $2 RETURNING id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, status, anomaly, correction_note, source_event_ids, auto_closed, confirmed_by, confirmed_at, revision`, [ context.tenantId, sessionId, startedAt, endedAt, workedMinutes, request.body.status, correctionNote, context.actorId, ], ); const result = saved.rows[0] as Record; await client.query( `INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, entity_id, old_values, new_values, correlation_id) VALUES ($1, $2, $3, 'work_session.reviewed', 'WorkSession', $4, $5, $6, $7)`, [ newUlid(), context.tenantId, context.actorId, sessionId, current, result, context.correlationId, ], ); if (request.body.status === 'confirmed') { await client.query( `INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, minutes_worked, status, note, updated_by) VALUES ($1, $2, $3, $4, $5, 'submitted', $6, $7) ON CONFLICT (tenant_id, employee_id, work_date) DO UPDATE SET minutes_worked = EXCLUDED.minutes_worked, status = CASE WHEN staff_timesheet_entries.status = 'approved' THEN staff_timesheet_entries.status ELSE 'submitted' END, note = EXCLUDED.note, revision = staff_timesheet_entries.revision + 1, updated_by = EXCLUDED.updated_by, updated_at = now()`, [ newUlid(), context.tenantId, current.employee_id, current.business_date, workedMinutes, correctionNote, context.actorId, ], ); await client.query( `INSERT INTO staff_outbox (id, tenant_id, event_type, event_version, subject_type, subject_id, actor_type, actor_id, payload, correlation_id) VALUES ($1, $2, 'staff.work_session.confirmed', 1, 'WorkSession', $3, 'user', $4, $5, $6)`, [ newUlid(), context.tenantId, sessionId, context.actorId, { employee_id: current.employee_id, business_date: current.business_date, worked_minutes: workedMinutes, }, context.correlationId, ], ); } return { kind: 'saved' as const, session: result }; }, ); if (outcome.kind === 'missing') return reply.notFound('Смена не найдена.'); if (outcome.kind === 'note_required') { return reply.badRequest('Для ручной корректировки смены укажите причину.'); } if (outcome.kind === 'invalid_interval') { return reply.badRequest('Окончание смены не может быть раньше начала.'); } return reply.send(outcome.session); }, ); api.put<{ Params: { employeeId: string; workDate: string }; Body: TimesheetBody }>( '/timesheets/:employeeId/:workDate', { schema: { params: { type: 'object', required: ['employeeId', 'workDate'], properties: { employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, workDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, }, }, body: { type: 'object', additionalProperties: false, required: ['minutes_worked', 'status'], properties: { minutes_worked: { type: 'integer', minimum: 0, maximum: 1440 }, status: { enum: ['draft', 'submitted', 'approved', 'rejected'] }, note: { type: 'string', maxLength: 1000 }, }, }, }, }, async (request, reply) => { assertCookieMutationOrigin(request); const context = await requirePermission(identity, request, 'staff:attendance:manage'); if (!datePattern.test(request.params.workDate)) return reply.badRequest('Некорректная дата.'); const employeeId = parseUlid(request.params.employeeId); if ( request.body.status === 'approved' && !context.permissions.has('staff:attendance:approve') ) { return reply.forbidden('Для утверждения табеля требуется отдельное право.'); } const row = await database.tenantTransaction( context.tenantId, context.actorId, async (client) => { const saved = await client.query( `INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, minutes_worked, status, note, updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (tenant_id, employee_id, work_date) DO UPDATE SET minutes_worked = EXCLUDED.minutes_worked, status = EXCLUDED.status, note = EXCLUDED.note, revision = staff_timesheet_entries.revision + 1, updated_by = EXCLUDED.updated_by, updated_at = now() RETURNING id, employee_id, work_date, minutes_worked, status, note, revision, updated_at`, [ newUlid(), context.tenantId, employeeId, request.params.workDate, request.body.minutes_worked, request.body.status, request.body.note ?? '', context.actorId, ], ); const result = saved.rows[0] as Record; await client.query( `INSERT INTO staff_audit_log (id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id) VALUES ($1, $2, $3, 'timesheet.entry.saved', 'TimesheetEntry', $4, $5, $6)`, [ newUlid(), context.tenantId, context.actorId, result.id, result, context.correlationId, ], ); return result; }, ); return reply.send(row); }, ); await registerOperationsRoutes(api, database, { resolve: (request) => resolveContext(identity, request), require: (request, permission) => requirePermission(identity, request, permission), assertMutationOrigin: assertCookieMutationOrigin, }); } function withStandaloneEmployeeAccess( baseIdentity: IdentityPort, config: AppConfig, database: Database, ): IdentityPort { if (config.STAFF_RUNTIME_MODE !== 'standalone' || !config.STANDALONE_MANAGER_TOKEN) { return baseIdentity; } return { async resolve(token, correlationId) { try { return await baseIdentity.resolve(token, correlationId); } catch (baseError) { if (!token || token.length < 32) throw baseError; const tenantId = parseUlid(config.STANDALONE_TENANT_ID); const serviceActorId = parseUlid(config.STANDALONE_ACTOR_ID); const tokenHash = hashAccessToken(token); const row = await database.tenantTransaction(tenantId, serviceActorId, async (client) => { const result = await client.query( `SELECT access.subject_id FROM staff_standalone_employee_access access JOIN staff_employees employee ON employee.tenant_id = access.tenant_id AND employee.id = access.employee_id WHERE access.tenant_id = $1 AND access.token_hash = $2 AND employee.status = 'active'`, [tenantId, tokenHash], ); return result.rows[0] as { subject_id?: string } | undefined; }); if (!row?.subject_id) throw baseError; return { tenantId, actorId: parseUlid(row.subject_id), permissions: new Set([ 'staff:health:read', 'staff:attendance:read_own', 'staff:shift:read', 'staff:payslip:read_own', ]), correlationId, }; } }, }; } function hashAccessToken(token: string): string { return createHash('sha256').update(token, 'utf8').digest('hex'); } async function resolveContext( identity: IdentityPort, request: FastifyRequest, ): Promise { const authorization = request.headers.authorization; const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : readCookie(request.headers.cookie, sessionCookieName); return resolveToken(identity, token, request); } async function resolveToken( identity: IdentityPort, token: string | undefined, request: FastifyRequest, ): Promise { try { return await identity.resolve(token, parseUlid(request.id)); } catch { throw request.server.httpErrors.unauthorized('Требуется действующий ключ доступа.'); } } function readCookie(header: string | undefined, name: string): string | undefined { if (!header) return undefined; for (const part of header.split(';')) { const separator = part.indexOf('='); if (separator < 0 || part.slice(0, separator).trim() !== name) continue; try { return decodeURIComponent(part.slice(separator + 1).trim()); } catch { return undefined; } } return undefined; } function serializeSessionCookie(value: string, maxAge: number): string { return `${sessionCookieName}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Strict`; } function assertSameOrigin(request: FastifyRequest) { const origin = request.headers.origin; if (!origin) return; try { const parsed = new URL(origin); if (parsed.protocol !== 'https:' || parsed.host !== request.headers.host) throw new Error(); } catch { throw request.server.httpErrors.forbidden('Запрос с постороннего сайта отклонён.'); } } function assertCookieMutationOrigin(request: FastifyRequest) { if (!request.headers.authorization && readCookie(request.headers.cookie, sessionCookieName)) { assertSameOrigin(request); } } async function requirePermission( identity: IdentityPort, request: FastifyRequest, permission: string, ) { const context = await resolveContext(identity, request); if (!context.permissions.has(permission)) { throw request.server.httpErrors.forbidden('Недостаточно прав для этой операции.'); } return context; } function parseDateOnly(value: string): Date | null { if (!datePattern.test(value)) return null; const parsed = new Date(`${value}T00:00:00.000Z`); return Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== value ? null : parsed; } function toIsoInstant(value: Date | string): string { const parsed = value instanceof Date ? value : new Date(value); if (Number.isNaN(parsed.valueOf())) throw new Error('Database returned an invalid timestamp'); return parsed.toISOString(); } function parseOptionalInstant(value: string | null): string | null { if (value === null) return null; const parsed = new Date(value); if (Number.isNaN(parsed.valueOf())) throw new Error('Invalid date-time'); return parsed.toISOString(); } function parseDatabaseOptionalInstant(value: unknown): string | null { if (value === null || value === undefined) return null; if (value instanceof Date) return toIsoInstant(value); if (typeof value === 'string') return toIsoInstant(value); throw new Error('Database returned an invalid optional timestamp'); }