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'; 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; 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; gender?: 'male' | 'female' | ''; } interface EmployeePatchBody { personnel_number?: string; display_name?: string; position?: string; gender?: 'male' | 'female' | ''; status?: 'active' | 'blocked' | 'dismissed'; } 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 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], ); 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.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 }, 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, gender, status) VALUES ($1, $2, $3, $4, $5, $6, 'active') ON CONFLICT (tenant_id, personnel_number) DO NOTHING RETURNING id, core_user_id, personnel_number, display_name, position, gender, status`, [employeeId, context.tenantId, personnelNumber, displayName, request.body.position?.trim() ?? '', 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 }, 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(); 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, 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 }; } const saved = await client.query( `UPDATE staff_employees SET personnel_number = COALESCE($3, personnel_number), display_name = COALESCE($4, display_name), position = COALESCE($5, position), gender = COALESCE($6, gender), status = COALESCE($7, status), updated_at = now() WHERE tenant_id = $1 AND id = $2 RETURNING id, core_user_id, personnel_number, display_name, position, gender, status`, [context.tenantId, employeeId, personnelNumber, displayName, request.body.position?.trim(), request.body.gender, request.body.status], ); 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, '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('Сотрудник с таким табельным номером уже существует.'); } return reply.send(outcome.employee); }); 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) => { 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, 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 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.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); }, ); } 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'); }