1245 lines
49 KiB
TypeScript
1245 lines
49 KiB
TypeScript
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';
|
||
|
||
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;
|
||
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);
|
||
|
||
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<string, unknown> | 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<string, unknown> | 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<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 },
|
||
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<string, unknown>;
|
||
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 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 };
|
||
},
|
||
);
|
||
if (!result) return reply.notFound('Активный сотрудник не найден.');
|
||
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);
|
||
},
|
||
);
|
||
|
||
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<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');
|
||
}
|