krona-staff/src/portal.ts
Ochenstarik 7cf5142678 style: rebuild formatting without sync behavior changes
Recreate the formatting baseline from f5ac179 using only Prettier output. Leave sync and tenant-isolation files byte-identical to the base so Antigravity and Grok retain ownership. Normalized comparison confirms all 19 changed files are formatting-only; npm run check passes. Full format check retains five upstream warnings in excluded files.
2026-09-26 17:16:51 +07:00

1406 lines
54 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 { createHash, randomBytes } from 'node:crypto';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import type { AppConfig } from './config.js';
import type { RequestContext } from './core/context.js';
import { newUlid, parseUlid } from './core/ids.js';
import type { IdentityPort } from './core/ports.js';
import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
import type { Database } from './infrastructure/database.js';
import { reconcileWorkSession } from './domain/attendance/work-session.js';
import { registerOperationsRoutes } from './operations.js';
import { resolveVapidKeys } from './domain/notification/web-push.js';
import { PushDispatcher } from './domain/notification/push-dispatcher.js';
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
interface PushSubscriptionBody {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
user_agent?: string | undefined;
}
interface DeleteSubscriptionBody {
endpoint: string;
}
interface AttendanceBody {
event_id?: string;
employee_id: string;
event_type: 'check_in' | 'check_out';
occurred_at: string;
source_event_id: string;
device_id?: string;
org_unit_id?: string;
}
interface TimesheetBody {
minutes_worked: number;
status: 'draft' | 'submitted' | 'approved' | 'rejected';
note?: string;
}
interface EmployeeBody {
personnel_number: string;
display_name: string;
position?: string;
profession_id?: string;
gender?: 'male' | 'female' | '';
}
interface EmployeePatchBody {
personnel_number?: string;
display_name?: string;
position?: string;
profession_id?: string | null;
gender?: 'male' | 'female' | '';
status?: 'active' | 'blocked' | 'dismissed';
}
interface ProfessionBody {
name: string;
status?: 'active' | 'archived';
}
interface ReconcileSessionsBody {
date_from: string;
date_to: string;
business_day_start_hour?: number;
automatic_close_hour?: number;
open_shift_threshold_minutes?: number;
}
interface ConfirmSessionBody {
status: 'confirmed' | 'rejected';
worked_minutes?: number;
started_at?: string | null;
ended_at?: string | null;
correction_note?: string;
}
interface SessionBody {
access_token: string;
}
const sessionCookieName = '__Host-krona_staff_session';
const sessionMaxAgeSeconds = 8 * 60 * 60;
export async function registerPortalRoutes(
api: FastifyInstance,
config: AppConfig,
database: Database,
) {
const baseIdentity: IdentityPort =
config.STAFF_RUNTIME_MODE === 'platform'
? new PlatformIdentityAdapter()
: new StandaloneIdentityAdapter(config);
const identity = withStandaloneEmployeeAccess(baseIdentity, config, database);
const vapidKeys = resolveVapidKeys(config);
const pushDispatcher = new PushDispatcher({
database,
vapidKeys,
systemActorId: config.STANDALONE_ACTOR_ID,
});
api.get(
'/push/public-key',
{
schema: {
tags: ['PushNotifications'],
response: {
200: {
type: 'object',
required: ['public_key'],
properties: { public_key: { type: 'string' } },
},
},
},
},
async (_request, reply) => {
reply.header('cache-control', 'public, max-age=86400');
return { public_key: vapidKeys.publicKey };
},
);
api.post<{ Body: PushSubscriptionBody }>(
'/push/subscriptions',
{
schema: {
tags: ['PushNotifications'],
body: {
type: 'object',
additionalProperties: false,
required: ['endpoint', 'keys'],
properties: {
endpoint: { type: 'string', minLength: 10, maxLength: 2000 },
keys: {
type: 'object',
additionalProperties: false,
required: ['p256dh', 'auth'],
properties: {
p256dh: { type: 'string', minLength: 10, maxLength: 250 },
auth: { type: 'string', minLength: 10, maxLength: 100 },
},
},
user_agent: { type: 'string', maxLength: 500 },
},
},
},
},
async (request, reply) => {
assertCookieMutationOrigin(request);
const context = await resolveContext(identity, request);
const canReadOwn = context.permissions.has('staff:attendance:read_own');
const canManage = context.permissions.has('staff:attendance:manage');
if (!canReadOwn && !canManage) {
return reply.forbidden('Недостаточно прав для настройки push-уведомлений.');
}
const employee = await database.tenantTransaction(
context.tenantId,
context.actorId,
async (client) => {
const res = await client.query<{ id: string }>(
`SELECT id FROM staff_employees
WHERE tenant_id = $1 AND (core_user_id = $2 OR id = $2) AND status = 'active'
LIMIT 1`,
[context.tenantId, context.actorId],
);
return res.rows[0];
},
);
if (!employee) {
return reply.notFound('Сотрудник для текущего пользователя не найден.');
}
const saved = await pushDispatcher.saveSubscription(context.tenantId, employee.id, {
endpoint: request.body.endpoint,
p256dh: request.body.keys.p256dh,
auth: request.body.keys.auth,
user_agent: request.body.user_agent,
});
return reply.code(201).send(saved);
},
);
api.delete<{ Body: DeleteSubscriptionBody }>(
'/push/subscriptions',
{
schema: {
tags: ['PushNotifications'],
body: {
type: 'object',
additionalProperties: false,
required: ['endpoint'],
properties: {
endpoint: { type: 'string', minLength: 10, maxLength: 2000 },
},
},
},
},
async (request, reply) => {
assertCookieMutationOrigin(request);
const context = await resolveContext(identity, request);
const employee = await database.tenantTransaction(
context.tenantId,
context.actorId,
async (client) => {
const res = await client.query<{ id: string }>(
`SELECT id FROM staff_employees
WHERE tenant_id = $1 AND (core_user_id = $2 OR id = $2) AND status = 'active'
LIMIT 1`,
[context.tenantId, context.actorId],
);
return res.rows[0];
},
);
if (!employee) {
return reply.notFound('Сотрудник для текущего пользователя не найден.');
}
await pushDispatcher.deleteSubscription(context.tenantId, employee.id, request.body.endpoint);
return reply.code(204).send();
},
);
api.post<{ Body: SessionBody }>(
'/session',
{
schema: {
body: {
type: 'object',
additionalProperties: false,
required: ['access_token'],
properties: { access_token: { type: 'string', minLength: 32, maxLength: 4096 } },
},
},
},
async (request, reply) => {
assertSameOrigin(request);
if (config.STAFF_RUNTIME_MODE === 'standalone' && !config.STANDALONE_MANAGER_TOKEN) {
return reply.serviceUnavailable('Вход администратора ещё не настроен.');
}
const context = await resolveToken(identity, request.body.access_token, request);
const role = context.permissions.has('staff:attendance:manage')
? 'manager'
: context.permissions.has('staff:attendance:read_own')
? 'employee'
: null;
if (!role) {
return reply.forbidden('Ключ не подходит для входа в кабинет.');
}
reply
.header('cache-control', 'no-store')
.header(
'set-cookie',
serializeSessionCookie(request.body.access_token, sessionMaxAgeSeconds),
);
return { role, expires_in: sessionMaxAgeSeconds };
},
);
api.delete('/session', async (request, reply) => {
assertSameOrigin(request);
reply.header('cache-control', 'no-store').header('set-cookie', serializeSessionCookie('', 0));
return reply.code(204).send();
});
api.get('/portal', async (request, reply) => {
reply.header('cache-control', 'no-store');
const context = await resolveContext(identity, request);
const canManage = context.permissions.has('staff:attendance:manage');
if (!canManage && !context.permissions.has('staff:attendance:read_own')) {
return reply.forbidden('Недостаточно прав для просмотра посещаемости.');
}
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
const employees = await client.query(
`SELECT e.id, e.core_user_id, e.personnel_number, e.display_name, e.position,
e.profession_id, p.name AS profession_name, e.gender, e.status
FROM staff_employees e
LEFT JOIN staff_professions p ON p.tenant_id = e.tenant_id AND p.id = e.profession_id
WHERE e.status <> 'dismissed' AND ($1::boolean OR e.core_user_id = $2)
ORDER BY e.display_name`,
[canManage, context.actorId],
);
const attendance = await client.query(
`SELECT a.id, a.employee_id, e.display_name, a.event_type, a.source,
a.occurred_at, a.received_at, a.status
FROM staff_attendance_events a
JOIN staff_employees e ON e.tenant_id = a.tenant_id AND e.id = a.employee_id
WHERE ($1::boolean OR e.core_user_id = $2)
ORDER BY a.occurred_at DESC LIMIT 100`,
[canManage, context.actorId],
);
const timesheet = await client.query(
`SELECT t.id, t.employee_id, e.display_name, t.work_date, t.minutes_worked,
t.status, t.note, t.revision, t.updated_at
FROM staff_timesheet_entries t
JOIN staff_employees e ON e.tenant_id = t.tenant_id AND e.id = t.employee_id
WHERE t.work_date >= (CURRENT_DATE - INTERVAL '40 days')
AND ($1::boolean OR e.core_user_id = $2)
ORDER BY t.work_date DESC, e.display_name`,
[canManage, context.actorId],
);
const workSessions = await client.query(
`SELECT s.id, s.employee_id, e.display_name, s.org_unit_id, u.name AS org_unit_name,
s.business_date, s.started_at, s.ended_at, s.worked_minutes, s.status,
s.anomaly, s.correction_note, s.auto_closed, s.revision
FROM staff_work_sessions s
JOIN staff_employees e ON e.tenant_id = s.tenant_id AND e.id = s.employee_id
JOIN staff_org_units u ON u.tenant_id = s.tenant_id AND u.id = s.org_unit_id
WHERE s.business_date >= (CURRENT_DATE - INTERVAL '40 days')
AND ($1::boolean OR e.core_user_id = $2)
ORDER BY s.business_date DESC, e.display_name`,
[canManage, context.actorId],
);
return {
viewer: { actor_id: context.actorId, role: canManage ? 'manager' : 'employee' },
employees: employees.rows,
attendance: attendance.rows,
timesheet: timesheet.rows,
work_sessions: workSessions.rows,
refreshed_at: new Date().toISOString(),
};
});
});
api.get('/professions', async (request) => {
const context = await resolveContext(identity, request);
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
const result = await client.query(
`SELECT id, code, name, status, created_at, updated_at
FROM staff_professions ORDER BY status, name`,
);
return { professions: result.rows };
});
});
api.post<{ Body: ProfessionBody }>(
'/professions',
{
schema: {
body: {
type: 'object',
additionalProperties: false,
required: ['name'],
properties: {
name: { type: 'string', minLength: 1, maxLength: 160 },
},
},
},
},
async (request, reply) => {
assertCookieMutationOrigin(request);
const context = await requirePermission(identity, request, 'staff:employee:write');
const name = request.body.name.trim();
if (!name) return reply.badRequest('Название профессии обязательно.');
const id = newUlid();
const saved = await database.tenantTransaction(
context.tenantId,
context.actorId,
async (client) => {
const result = await client.query(
`INSERT INTO staff_professions (id, tenant_id, code, name)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tenant_id, name) DO NOTHING
RETURNING id, code, name, status`,
[id, context.tenantId, `profession-${id.toLowerCase()}`, name],
);
return result.rows[0] as Record<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 outboxId = newUlid();
const eventType =
request.body.event_type === 'check_in' ? 'attendance.clock.in' : 'attendance.clock.out';
await client.query(
`INSERT INTO staff_outbox
(id, tenant_id, event_type, event_version, subject_type, subject_id,
actor_type, actor_id, payload, correlation_id, occurred_at)
VALUES ($1, $2, $3, 1, 'Employee', $4, 'service', $5, $6, $7, $8)`,
[
outboxId,
context.tenantId,
eventType,
employeeId,
context.actorId,
{ attendance_event_id: eventId, channel: 'platform_notification' },
context.correlationId,
occurredAt.toISOString(),
],
);
return { id: eventId, outbox_id: outboxId, duplicate: false };
},
);
if (!result) return reply.notFound('Активный сотрудник не найден.');
if (!result.duplicate && result.outbox_id) {
const outboxIdToDispatch = result.outbox_id;
setImmediate(() => {
void pushDispatcher
.dispatchForOutbox(context.tenantId, outboxIdToDispatch)
.catch((err: unknown) => {
request.log.error(
{ err, outboxId: outboxIdToDispatch },
'Background push dispatch error',
);
});
});
}
return reply.code(result.duplicate ? 200 : 201).send({
id: result.id,
duplicate: result.duplicate,
});
},
);
api.post<{ Body: ReconcileSessionsBody }>(
'/work-sessions/reconcile',
{
schema: {
body: {
type: 'object',
additionalProperties: false,
required: ['date_from', 'date_to'],
properties: {
date_from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
date_to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
business_day_start_hour: { type: 'integer', minimum: 0, maximum: 23 },
automatic_close_hour: { type: 'integer', minimum: 0, maximum: 23 },
open_shift_threshold_minutes: { type: 'integer', minimum: 60, maximum: 2880 },
},
},
},
},
async (request, reply) => {
assertCookieMutationOrigin(request);
const context = await requirePermission(identity, request, 'staff:shift:confirm');
const from = parseDateOnly(request.body.date_from);
const to = parseDateOnly(request.body.date_to);
if (!from || !to || to < from) return reply.badRequest('Некорректный период сверки.');
const periodDays = Math.floor((to.valueOf() - from.valueOf()) / 86_400_000) + 1;
if (periodDays > 40) return reply.badRequest('За один раз можно сверить не более 40 дней.');
const businessDayStartHour = request.body.business_day_start_hour ?? 4;
const automaticCloseHour = request.body.automatic_close_hour ?? 2;
const thresholdMinutes = request.body.open_shift_threshold_minutes ?? 14 * 60;
const now = new Date().toISOString();
const summary = await database.tenantTransaction(
context.tenantId,
context.actorId,
async (client) => {
const attendance = await client.query(
`WITH categorized AS (
SELECT a.id, a.employee_id, a.event_type, a.occurred_at,
COALESCE(a.org_unit_id, e.primary_org_unit_id) AS org_unit_id,
COALESCE(u.timezone, 'UTC') AS timezone,
((a.occurred_at AT TIME ZONE COALESCE(u.timezone, 'UTC'))
- make_interval(hours => $3))::date AS business_date
FROM staff_attendance_events a
JOIN staff_employees e ON e.tenant_id = a.tenant_id AND e.id = a.employee_id
LEFT JOIN staff_org_units u ON u.tenant_id = a.tenant_id
AND u.id = COALESCE(a.org_unit_id, e.primary_org_unit_id)
WHERE a.status = 'accepted'
AND a.occurred_at >= ($1::date - INTERVAL '2 days')
AND a.occurred_at < ($2::date + INTERVAL '3 days')
)
SELECT id, employee_id, event_type, occurred_at, org_unit_id, timezone,
business_date::text,
(((business_date + 1)::timestamp + make_interval(hours => $4))
AT TIME ZONE timezone) AS automatic_close_at
FROM categorized
WHERE business_date BETWEEN $1::date AND $2::date
ORDER BY employee_id, org_unit_id NULLS LAST, business_date, occurred_at, id`,
[
request.body.date_from,
request.body.date_to,
businessDayStartHour,
automaticCloseHour,
],
);
type AttendanceRow = {
id: string;
employee_id: string;
event_type: 'check_in' | 'check_out';
occurred_at: Date | string;
org_unit_id: string | null;
business_date: string;
automatic_close_at: Date | string;
};
const grouped = new Map<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');
}