krona-staff/src/portal.ts
Ochenstarik 3464998168
Some checks are pending
ci / verify (push) Waiting to run
feat: add secure attendance portal and payroll foundation
2026-09-24 15:59:38 +07:00

295 lines
13 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';
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
interface AttendanceBody {
event_id?: string;
employee_id: string;
event_type: 'check_in' | 'check_out';
occurred_at: string;
source_event_id: string;
device_id?: string;
}
interface TimesheetBody {
minutes_worked: number;
status: 'draft' | 'submitted' | 'approved' | 'rejected';
note?: string;
}
interface SessionBody {
access_token: string;
}
const sessionCookieName = '__Host-krona_staff_session';
const sessionMaxAgeSeconds = 8 * 60 * 60;
export async function registerPortalRoutes(
api: FastifyInstance,
config: AppConfig,
database: Database,
) {
const identity: IdentityPort = config.STAFF_RUNTIME_MODE === 'platform'
? new PlatformIdentityAdapter()
: new StandaloneIdentityAdapter(config);
api.post<{ Body: SessionBody }>('/session', {
schema: {
body: {
type: 'object', additionalProperties: false,
required: ['access_token'],
properties: { access_token: { type: 'string', minLength: 32, maxLength: 4096 } },
},
},
}, async (request, reply) => {
assertSameOrigin(request);
if (config.STAFF_RUNTIME_MODE === 'standalone' && !config.STANDALONE_MANAGER_TOKEN) {
return reply.serviceUnavailable('Вход администратора ещё не настроен.');
}
const context = await resolveToken(identity, request.body.access_token, request);
if (!context.permissions.has('staff:attendance:manage')) {
return reply.forbidden('Для входа требуется ключ администратора.');
}
reply
.header('cache-control', 'no-store')
.header('set-cookie', serializeSessionCookie(request.body.access_token, sessionMaxAgeSeconds));
return { role: 'manager', expires_in: sessionMaxAgeSeconds };
});
api.delete('/session', async (request, reply) => {
assertSameOrigin(request);
reply
.header('cache-control', 'no-store')
.header('set-cookie', serializeSessionCookie('', 0));
return reply.code(204).send();
});
api.get('/portal', async (request, reply) => {
reply.header('cache-control', 'no-store');
const context = await resolveContext(identity, request);
const canManage = context.permissions.has('staff:attendance:manage');
if (!canManage && !context.permissions.has('staff:attendance:read_own')) {
return reply.forbidden('Недостаточно прав для просмотра посещаемости.');
}
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
const employees = await client.query(
`SELECT e.id, e.core_user_id, e.personnel_number, e.display_name, e.position, e.gender, e.status
FROM staff_employees e
WHERE e.status <> 'dismissed' AND ($1::boolean OR e.core_user_id = $2)
ORDER BY e.display_name`,
[canManage, context.actorId],
);
const attendance = await client.query(
`SELECT a.id, a.employee_id, e.display_name, a.event_type, a.source,
a.occurred_at, a.received_at, a.status
FROM staff_attendance_events a
JOIN staff_employees e ON e.tenant_id = a.tenant_id AND e.id = a.employee_id
WHERE ($1::boolean OR e.core_user_id = $2)
ORDER BY a.occurred_at DESC LIMIT 100`,
[canManage, context.actorId],
);
const timesheet = await client.query(
`SELECT t.id, t.employee_id, e.display_name, t.work_date, t.minutes_worked,
t.status, t.note, t.revision, t.updated_at
FROM staff_timesheet_entries t
JOIN staff_employees e ON e.tenant_id = t.tenant_id AND e.id = t.employee_id
WHERE t.work_date >= (CURRENT_DATE - INTERVAL '40 days')
AND ($1::boolean OR e.core_user_id = $2)
ORDER BY t.work_date DESC, e.display_name`,
[canManage, context.actorId],
);
return {
viewer: { actor_id: context.actorId, role: canManage ? 'manager' : 'employee' },
employees: employees.rows,
attendance: attendance.rows,
timesheet: timesheet.rows,
refreshed_at: new Date().toISOString(),
};
});
});
api.post<{ Body: AttendanceBody }>('/attendance/events', {
schema: {
body: {
type: 'object', additionalProperties: false,
required: ['employee_id', 'event_type', 'occurred_at', 'source_event_id'],
properties: {
event_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
employee_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
event_type: { enum: ['check_in', 'check_out'] },
occurred_at: { type: 'string', format: 'date-time' },
source_event_id: { type: 'string', minLength: 1, maxLength: 200 },
device_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
},
},
},
}, async (request, reply) => {
const context = await requirePermission(identity, request, 'staff:attendance:clock');
const eventId = request.body.event_id ? parseUlid(request.body.event_id) : newUlid();
const employeeId = parseUlid(request.body.employee_id);
const deviceId = request.body.device_id ? parseUlid(request.body.device_id) : null;
const occurredAt = new Date(request.body.occurred_at);
if (Number.isNaN(occurredAt.valueOf())) return reply.badRequest('Некорректное время отметки.');
const now = Date.now();
if (occurredAt.valueOf() > now + 5 * 60_000 || occurredAt.valueOf() < now - 180 * 86_400_000) {
return reply.badRequest('Время отметки выходит за допустимый период пилота.');
}
const result = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
await client.query(
`INSERT INTO staff_employees
(id, tenant_id, personnel_number, display_name, position, gender, status)
VALUES ($1, $2, $3, $4, '', '', 'active')
ON CONFLICT (id) DO NOTHING`,
[employeeId, context.tenantId, `pilot-${employeeId}`, `Сотрудник ${employeeId.slice(-6)}`],
);
const inserted = await client.query(
`INSERT INTO staff_attendance_events
(id, tenant_id, employee_id, event_type, source, source_event_id,
occurred_at, device_id, actor_id, correlation_id)
VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9)
ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING RETURNING id`,
[eventId, context.tenantId, employeeId, request.body.event_type,
request.body.source_event_id, occurredAt.toISOString(), deviceId,
context.actorId, context.correlationId],
);
if (inserted.rowCount === 0) return { id: eventId, duplicate: true };
const eventType = request.body.event_type === 'check_in'
? 'attendance.clock.in'
: 'attendance.clock.out';
await client.query(
`INSERT INTO staff_outbox
(id, tenant_id, event_type, event_version, subject_type, subject_id,
actor_type, actor_id, payload, correlation_id, occurred_at)
VALUES ($1, $2, $3, 1, 'Employee', $4, 'service', $5, $6, $7, $8)`,
[newUlid(), context.tenantId, eventType, employeeId, context.actorId,
{ attendance_event_id: eventId, channel: 'platform_notification' },
context.correlationId, occurredAt.toISOString()],
);
return { id: eventId, duplicate: false };
});
return reply.code(result.duplicate ? 200 : 201).send(result);
});
api.put<{ Params: { employeeId: string; workDate: string }; Body: TimesheetBody }>(
'/timesheets/:employeeId/:workDate',
{
schema: {
params: { type: 'object', required: ['employeeId', 'workDate'], properties: {
employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
workDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
} },
body: { type: 'object', additionalProperties: false,
required: ['minutes_worked', 'status'], properties: {
minutes_worked: { type: 'integer', minimum: 0, maximum: 1440 },
status: { enum: ['draft', 'submitted', 'approved', 'rejected'] },
note: { type: 'string', maxLength: 1000 },
} },
},
},
async (request, reply) => {
assertCookieMutationOrigin(request);
const context = await requirePermission(identity, request, 'staff:attendance:manage');
if (!datePattern.test(request.params.workDate)) return reply.badRequest('Некорректная дата.');
const employeeId = parseUlid(request.params.employeeId);
if (request.body.status === 'approved' && !context.permissions.has('staff:attendance:approve')) {
return reply.forbidden('Для утверждения табеля требуется отдельное право.');
}
const row = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
const saved = await client.query(
`INSERT INTO staff_timesheet_entries
(id, tenant_id, employee_id, work_date, minutes_worked, status, note, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (tenant_id, employee_id, work_date) DO UPDATE SET
minutes_worked = EXCLUDED.minutes_worked, status = EXCLUDED.status,
note = EXCLUDED.note, revision = staff_timesheet_entries.revision + 1,
updated_by = EXCLUDED.updated_by, updated_at = now()
RETURNING id, employee_id, work_date, minutes_worked, status, note, revision, updated_at`,
[newUlid(), context.tenantId, employeeId, request.params.workDate,
request.body.minutes_worked, request.body.status, request.body.note ?? '', context.actorId],
);
const result = saved.rows[0] as Record<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;
}