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'; const datePattern = /^\d{4}-\d{2}-\d{2}$/; interface AttendanceBody { event_id?: string; employee_id: string; event_type: 'check_in' | 'check_out'; occurred_at: string; source_event_id: string; device_id?: string; } interface TimesheetBody { minutes_worked: number; status: 'draft' | 'submitted' | 'approved' | 'rejected'; 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 identity: IdentityPort = config.STAFF_RUNTIME_MODE === 'platform' ? new PlatformIdentityAdapter() : new StandaloneIdentityAdapter(config); 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); if (!context.permissions.has('staff:attendance:manage')) { return reply.forbidden('Для входа требуется ключ администратора.'); } reply .header('cache-control', 'no-store') .header('set-cookie', serializeSessionCookie(request.body.access_token, sessionMaxAgeSeconds)); return { role: 'manager', 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.gender, e.status FROM staff_employees e 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], ); return { viewer: { actor_id: context.actorId, role: canManage ? 'manager' : 'employee' }, employees: employees.rows, attendance: attendance.rows, timesheet: timesheet.rows, refreshed_at: new Date().toISOString(), }; }); }); 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}$' }, }, }, }, }, 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 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) => { await client.query( `INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, position, gender, status) VALUES ($1, $2, $3, $4, '', '', 'active') ON CONFLICT (id) DO NOTHING`, [employeeId, context.tenantId, `pilot-${employeeId}`, `Сотрудник ${employeeId.slice(-6)}`], ); 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, actor_id, correlation_id) VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9) 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, context.actorId, context.correlationId], ); if (inserted.rowCount === 0) return { id: eventId, duplicate: true }; 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)`, [newUlid(), context.tenantId, eventType, employeeId, context.actorId, { attendance_event_id: eventId, channel: 'platform_notification' }, context.correlationId, occurredAt.toISOString()], ); return { id: eventId, duplicate: false }; }); return reply.code(result.duplicate ? 200 : 201).send(result); }); 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); }, ); } 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; }