295 lines
13 KiB
TypeScript
295 lines
13 KiB
TypeScript
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;
|
||
}
|