krona-staff/src/portal.ts
2026-09-24 17:00:08 +07:00

731 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown>;
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<string, unknown>;
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<string, AttendanceRow[]>();
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<RequestContext> {
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<RequestContext> {
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');
}