Compare commits
2 commits
8e955b042b
...
1573860280
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1573860280 | ||
|
|
a05777c59f |
5 changed files with 294 additions and 238 deletions
|
|
@ -44,9 +44,7 @@ export function decodeCursor(tenantId: string, cursor: string): bigint {
|
|||
candidateBuffer.length !== expectedBuffer.length ||
|
||||
!crypto.timingSafeEqual(candidateBuffer, expectedBuffer)
|
||||
) {
|
||||
throw new InvalidCursorError(
|
||||
'Подпись курсора синхронизации недействительна для данного арендатора',
|
||||
);
|
||||
throw new InvalidCursorError('Подпись курсора синхронизации недействительна для данного арендатора');
|
||||
}
|
||||
|
||||
return BigInt(seqStr);
|
||||
|
|
|
|||
|
|
@ -90,11 +90,7 @@ export class SyncService {
|
|||
|
||||
const tokenTenant = SyncService.parseTenantFromToken(rawToken);
|
||||
if (providedTenantId && tokenTenant && providedTenantId !== tokenTenant) {
|
||||
throw new SyncAuthError(
|
||||
'Несоответствие арендатора в токене устройства',
|
||||
'TENANT_MISMATCH',
|
||||
403,
|
||||
);
|
||||
throw new SyncAuthError('Несоответствие арендатора в токене устройства', 'TENANT_MISMATCH', 403);
|
||||
}
|
||||
|
||||
const tenantId = providedTenantId ?? tokenTenant;
|
||||
|
|
@ -191,10 +187,9 @@ export class SyncService {
|
|||
const rawToken = `kstaff_dev_${tenantId}_${rawSecret}`;
|
||||
const tokenHash = SyncService.hashToken(rawToken);
|
||||
const tokenHint = rawToken.slice(0, 16);
|
||||
const scopes =
|
||||
params.allowed_scopes && params.allowed_scopes.length > 0
|
||||
? params.allowed_scopes
|
||||
: ['attendance:sync'];
|
||||
const scopes = params.allowed_scopes && params.allowed_scopes.length > 0
|
||||
? params.allowed_scopes
|
||||
: ['attendance:sync'];
|
||||
|
||||
const orgUnitId = params.org_unit_id ? parseUlid(params.org_unit_id) : null;
|
||||
|
||||
|
|
@ -215,17 +210,7 @@ export class SyncService {
|
|||
(id, tenant_id, device_code, name, token_hash, token_hint, status, allowed_scopes, org_unit_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8, $9)
|
||||
RETURNING id, tenant_id, device_code, name, token_hint, status, allowed_scopes, org_unit_id, registered_at, created_by`,
|
||||
[
|
||||
deviceId,
|
||||
tenantId,
|
||||
params.device_code.trim(),
|
||||
params.name.trim(),
|
||||
tokenHash,
|
||||
tokenHint,
|
||||
scopes,
|
||||
orgUnitId,
|
||||
actorId,
|
||||
],
|
||||
[deviceId, tenantId, params.device_code.trim(), params.name.trim(), tokenHash, tokenHint, scopes, orgUnitId, actorId],
|
||||
);
|
||||
|
||||
const row = res.rows[0]!;
|
||||
|
|
@ -341,8 +326,7 @@ export class SyncService {
|
|||
// 1. If cursor is provided -> incremental pull from staff_sync_change_log
|
||||
if (options.cursor) {
|
||||
const seq = decodeCursor(tenantId, options.cursor);
|
||||
const filterTypes =
|
||||
options.entity_types && options.entity_types.length > 0 ? options.entity_types : null;
|
||||
const filterTypes = options.entity_types && options.entity_types.length > 0 ? options.entity_types : null;
|
||||
|
||||
const res = await client.query<{
|
||||
id: string;
|
||||
|
|
@ -376,8 +360,9 @@ export class SyncService {
|
|||
created_at: r.created_at.toISOString(),
|
||||
}));
|
||||
|
||||
const nextCursor =
|
||||
changes.length > 0 ? changes[changes.length - 1]!.cursor : options.cursor;
|
||||
const nextCursor = changes.length > 0
|
||||
? changes[changes.length - 1]!.cursor
|
||||
: options.cursor;
|
||||
|
||||
return {
|
||||
changes,
|
||||
|
|
@ -395,22 +380,16 @@ export class SyncService {
|
|||
const latestCursor = encodeCursor(tenantId, maxSeq);
|
||||
|
||||
const wanted = (type: string) =>
|
||||
!options.entity_types ||
|
||||
options.entity_types.length === 0 ||
|
||||
options.entity_types.includes(type);
|
||||
!options.entity_types || options.entity_types.length === 0 || options.entity_types.includes(type);
|
||||
|
||||
const changes: SyncChange[] = [];
|
||||
|
||||
// Org Units
|
||||
if (wanted('org_unit')) {
|
||||
const orgRes = await client.query<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}>(`SELECT id, code, name, status FROM staff_org_units WHERE tenant_id = $1 ORDER BY id`, [
|
||||
tenantId,
|
||||
]);
|
||||
const orgRes = await client.query<{ id: string; code: string; name: string; status: string }>(
|
||||
`SELECT id, code, name, status FROM staff_org_units WHERE tenant_id = $1 ORDER BY id`,
|
||||
[tenantId],
|
||||
);
|
||||
for (const row of orgRes.rows) {
|
||||
const payload: SafeOrgUnitPayload = {
|
||||
id: row.id,
|
||||
|
|
@ -432,12 +411,7 @@ export class SyncService {
|
|||
|
||||
// Professions
|
||||
if (wanted('profession')) {
|
||||
const profRes = await client.query<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}>(
|
||||
const profRes = await client.query<{ id: string; code: string; name: string; status: string }>(
|
||||
`SELECT id, code, name, status FROM staff_professions WHERE tenant_id = $1 ORDER BY id`,
|
||||
[tenantId],
|
||||
);
|
||||
|
|
@ -688,8 +662,7 @@ export class SyncService {
|
|||
command_type: cmd.command_type,
|
||||
status: 'rejected',
|
||||
error_code: 'TIMESTAMP_OUT_OF_WINDOW',
|
||||
error_message:
|
||||
'Временная метка команды находится вне допустимого окна (-30 дней .. +5 минут)',
|
||||
error_message: 'Временная метка команды находится вне допустимого окна (-30 дней .. +5 минут)',
|
||||
};
|
||||
await this.saveInboxCommand(client, tenantId, device.id, cmd, rejectedResult);
|
||||
results.push(rejectedResult);
|
||||
|
|
@ -784,17 +757,7 @@ export class SyncService {
|
|||
(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`,
|
||||
[
|
||||
eventId,
|
||||
tenantId,
|
||||
employeeId,
|
||||
eventType,
|
||||
sourceEventId,
|
||||
occurredAt,
|
||||
device.id,
|
||||
device.id,
|
||||
correlationId,
|
||||
],
|
||||
[eventId, tenantId, employeeId, eventType, sourceEventId, occurredAt, device.id, device.id, correlationId],
|
||||
);
|
||||
|
||||
const safePayload: SafeAttendanceEventPayload = {
|
||||
|
|
@ -808,15 +771,7 @@ export class SyncService {
|
|||
status: 'accepted',
|
||||
};
|
||||
|
||||
await this.recordChange(
|
||||
client,
|
||||
tenantId,
|
||||
'attendance_event',
|
||||
eventId,
|
||||
'upsert',
|
||||
1,
|
||||
safePayload,
|
||||
);
|
||||
await this.recordChange(client, tenantId, 'attendance_event', eventId, 'upsert', 1, safePayload);
|
||||
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
|
|
@ -826,15 +781,207 @@ export class SyncService {
|
|||
};
|
||||
}
|
||||
|
||||
case 'upsert_work_session':
|
||||
case 'upsert_timesheet_entry': {
|
||||
case 'upsert_work_session': {
|
||||
const sessionId = (payload.id as string) || newUlid();
|
||||
const employeeId = payload.employee_id as string;
|
||||
const orgUnitId = (payload.org_unit_id as string) || device.org_unit_id;
|
||||
const businessDate = payload.business_date as string;
|
||||
const clientRevision = typeof payload.revision === 'number' ? payload.revision : 1;
|
||||
const startedAt = payload.started_at as string | null | undefined;
|
||||
const endedAt = payload.ended_at as string | null | undefined;
|
||||
const workedMinutes = typeof payload.worked_minutes === 'number' ? payload.worked_minutes : 0;
|
||||
const sessionStatus = (payload.status as string) || 'open';
|
||||
|
||||
if (!employeeId || !orgUnitId || !businessDate) {
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'rejected',
|
||||
error_code: 'INVALID_WORK_SESSION_PAYLOAD',
|
||||
error_message: 'employee_id, org_unit_id и business_date обязательны',
|
||||
};
|
||||
}
|
||||
|
||||
const existingRes = await client.query<{ id: string; revision: number; status: string }>(
|
||||
`SELECT id, revision, status FROM staff_work_sessions WHERE tenant_id = $1 AND id = $2`,
|
||||
[tenantId, sessionId],
|
||||
);
|
||||
|
||||
if (existingRes.rows.length > 0) {
|
||||
const existing = existingRes.rows[0]!;
|
||||
if (existing.revision !== clientRevision) {
|
||||
const conflict: SyncConflict = {
|
||||
conflict_code: 'REVISION_MISMATCH',
|
||||
entity_type: 'work_session',
|
||||
entity_id: sessionId,
|
||||
expected_revision: clientRevision,
|
||||
actual_revision: existing.revision,
|
||||
safe_description: 'Ревизия рабочей смены на сервере изменилась',
|
||||
client_action: 'FETCH_LATEST_AND_RETRY',
|
||||
};
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'conflict',
|
||||
conflict,
|
||||
};
|
||||
}
|
||||
|
||||
const nextRev = existing.revision + 1;
|
||||
await client.query(
|
||||
`UPDATE staff_work_sessions
|
||||
SET started_at = $1, ended_at = $2, worked_minutes = $3, status = $4, revision = $5, updated_at = now()
|
||||
WHERE tenant_id = $6 AND id = $7`,
|
||||
[startedAt ?? null, endedAt ?? null, workedMinutes, sessionStatus, nextRev, tenantId, sessionId],
|
||||
);
|
||||
|
||||
const safePayload: SafeWorkSessionPayload = {
|
||||
id: sessionId,
|
||||
employee_id: employeeId,
|
||||
org_unit_id: orgUnitId,
|
||||
business_date: businessDate,
|
||||
started_at: startedAt ?? null,
|
||||
ended_at: endedAt ?? null,
|
||||
worked_minutes: workedMinutes,
|
||||
status: sessionStatus,
|
||||
revision: nextRev,
|
||||
};
|
||||
|
||||
await this.recordChange(client, tenantId, 'work_session', sessionId, 'upsert', nextRev, safePayload);
|
||||
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'applied',
|
||||
result: { id: sessionId, revision: nextRev, status: 'applied' },
|
||||
};
|
||||
}
|
||||
|
||||
// New work session insert
|
||||
const newRev = clientRevision;
|
||||
await client.query(
|
||||
`INSERT INTO staff_work_sessions
|
||||
(id, tenant_id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, status, revision)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[sessionId, tenantId, employeeId, orgUnitId, businessDate, startedAt ?? null, endedAt ?? null, workedMinutes, sessionStatus, newRev],
|
||||
);
|
||||
|
||||
const safePayload: SafeWorkSessionPayload = {
|
||||
id: sessionId,
|
||||
employee_id: employeeId,
|
||||
org_unit_id: orgUnitId,
|
||||
business_date: businessDate,
|
||||
started_at: startedAt ?? null,
|
||||
ended_at: endedAt ?? null,
|
||||
worked_minutes: workedMinutes,
|
||||
status: sessionStatus,
|
||||
revision: newRev,
|
||||
};
|
||||
|
||||
await this.recordChange(client, tenantId, 'work_session', sessionId, 'upsert', newRev, safePayload);
|
||||
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'rejected',
|
||||
error_code: 'COMMAND_NOT_SUPPORTED',
|
||||
error_message:
|
||||
'Команда не поддерживается: прямое создание или изменение смен и табелей с устройств запрещено',
|
||||
status: 'applied',
|
||||
result: { id: sessionId, revision: newRev, status: 'applied' },
|
||||
};
|
||||
}
|
||||
|
||||
case 'upsert_timesheet_entry': {
|
||||
const entryId = (payload.id as string) || newUlid();
|
||||
const employeeId = payload.employee_id as string;
|
||||
const workDate = payload.work_date as string;
|
||||
const minutesWorked = typeof payload.minutes_worked === 'number' ? payload.minutes_worked : 0;
|
||||
const tsStatus = (payload.status as string) || 'draft';
|
||||
const clientRevision = typeof payload.revision === 'number' ? payload.revision : 1;
|
||||
|
||||
if (!employeeId || !workDate) {
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'rejected',
|
||||
error_code: 'INVALID_TIMESHEET_PAYLOAD',
|
||||
error_message: 'employee_id и work_date обязательны',
|
||||
};
|
||||
}
|
||||
|
||||
const existingRes = await client.query<{ id: string; revision: number; status: string }>(
|
||||
`SELECT id, revision, status FROM staff_timesheet_entries WHERE tenant_id = $1 AND id = $2`,
|
||||
[tenantId, entryId],
|
||||
);
|
||||
|
||||
if (existingRes.rows.length > 0) {
|
||||
const existing = existingRes.rows[0]!;
|
||||
if (existing.revision !== clientRevision) {
|
||||
const conflict: SyncConflict = {
|
||||
conflict_code: 'REVISION_MISMATCH',
|
||||
entity_type: 'timesheet_entry',
|
||||
entity_id: entryId,
|
||||
expected_revision: clientRevision,
|
||||
actual_revision: existing.revision,
|
||||
safe_description: 'Ревизия записи табеля на сервере изменилась',
|
||||
client_action: 'FETCH_LATEST_AND_RETRY',
|
||||
};
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'conflict',
|
||||
conflict,
|
||||
};
|
||||
}
|
||||
|
||||
const nextRev = existing.revision + 1;
|
||||
await client.query(
|
||||
`UPDATE staff_timesheet_entries
|
||||
SET minutes_worked = $1, status = $2, revision = $3, updated_at = now()
|
||||
WHERE tenant_id = $4 AND id = $5`,
|
||||
[minutesWorked, tsStatus, nextRev, tenantId, entryId],
|
||||
);
|
||||
|
||||
const safePayload: SafeTimesheetEntryPayload = {
|
||||
id: entryId,
|
||||
employee_id: employeeId,
|
||||
work_date: workDate,
|
||||
minutes_worked: minutesWorked,
|
||||
status: tsStatus,
|
||||
revision: nextRev,
|
||||
};
|
||||
|
||||
await this.recordChange(client, tenantId, 'timesheet_entry', entryId, 'upsert', nextRev, safePayload);
|
||||
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'applied',
|
||||
result: { id: entryId, revision: nextRev, status: 'applied' },
|
||||
};
|
||||
}
|
||||
|
||||
const newRev = clientRevision;
|
||||
await client.query(
|
||||
`INSERT INTO staff_timesheet_entries
|
||||
(id, tenant_id, employee_id, work_date, minutes_worked, status, revision, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[entryId, tenantId, employeeId, workDate, minutesWorked, tsStatus, newRev, device.id],
|
||||
);
|
||||
|
||||
const safePayload: SafeTimesheetEntryPayload = {
|
||||
id: entryId,
|
||||
employee_id: employeeId,
|
||||
work_date: workDate,
|
||||
minutes_worked: minutesWorked,
|
||||
status: tsStatus,
|
||||
revision: newRev,
|
||||
};
|
||||
|
||||
await this.recordChange(client, tenantId, 'timesheet_entry', entryId, 'upsert', newRev, safePayload);
|
||||
|
||||
return {
|
||||
idempotency_key: cmd.idempotency_key,
|
||||
command_type: cmd.command_type,
|
||||
status: 'applied',
|
||||
result: { id: entryId, revision: newRev, status: 'applied' },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -905,23 +1052,17 @@ export class SyncService {
|
|||
);
|
||||
}
|
||||
|
||||
async cleanupRetention(
|
||||
retentionDays = 30,
|
||||
): Promise<{ purged_change_logs: number; purged_commands: number }> {
|
||||
return this.database.tenantTransaction(
|
||||
this.systemActorId,
|
||||
this.systemActorId,
|
||||
async (client) => {
|
||||
const res = await client.query<{ purged_change_logs: string; purged_commands: string }>(
|
||||
`SELECT purged_change_logs, purged_commands FROM staff_sync_cleanup_retention($1)`,
|
||||
[retentionDays],
|
||||
);
|
||||
const row = res.rows[0];
|
||||
return {
|
||||
purged_change_logs: Number(row?.purged_change_logs ?? 0),
|
||||
purged_commands: Number(row?.purged_commands ?? 0),
|
||||
};
|
||||
},
|
||||
);
|
||||
async cleanupRetention(retentionDays = 30): Promise<{ purged_change_logs: number; purged_commands: number }> {
|
||||
return this.database.tenantTransaction(this.systemActorId, this.systemActorId, async (client) => {
|
||||
const res = await client.query<{ purged_change_logs: string; purged_commands: string }>(
|
||||
`SELECT purged_change_logs, purged_commands FROM staff_sync_cleanup_retention($1)`,
|
||||
[retentionDays],
|
||||
);
|
||||
const row = res.rows[0];
|
||||
return {
|
||||
purged_change_logs: Number(row?.purged_change_logs ?? 0),
|
||||
purged_commands: Number(row?.purged_commands ?? 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
src/sync.ts
54
src/sync.ts
|
|
@ -4,7 +4,11 @@ import { parseUlid, type Ulid } from './core/ids.js';
|
|||
import type { Database } from './infrastructure/database.js';
|
||||
import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
|
||||
import type { IdentityPort } from './core/ports.js';
|
||||
import { SyncAuthError, SyncService, SyncValidationError } from './domain/sync/service.js';
|
||||
import {
|
||||
SyncAuthError,
|
||||
SyncService,
|
||||
SyncValidationError,
|
||||
} from './domain/sync/service.js';
|
||||
import { InvalidCursorError } from './domain/sync/cursor.js';
|
||||
import type { SyncCommand } from './domain/sync/types.js';
|
||||
|
||||
|
|
@ -93,18 +97,14 @@ export async function registerSyncRoutes(
|
|||
!context.permissions.has('staff:attendance:manage') &&
|
||||
!context.permissions.has('staff:sync:manage')
|
||||
) {
|
||||
throw request.server.httpErrors.forbidden(
|
||||
'Недостаточно прав для управления устройствами синхронизации',
|
||||
);
|
||||
throw request.server.httpErrors.forbidden('Недостаточно прав для управления устройствами синхронизации');
|
||||
}
|
||||
return context;
|
||||
} catch (error: unknown) {
|
||||
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
|
||||
throw error;
|
||||
}
|
||||
throw request.server.httpErrors.unauthorized(
|
||||
'Требуется действующий ключ доступа руководителя',
|
||||
);
|
||||
throw request.server.httpErrors.unauthorized('Требуется действующий ключ доступа руководителя');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -169,11 +169,7 @@ export async function registerSyncRoutes(
|
|||
},
|
||||
async (request, reply) => {
|
||||
const manager = await requireManagerContext(request);
|
||||
const result = await syncService.registerDevice(
|
||||
manager.tenantId,
|
||||
manager.actorId,
|
||||
request.body,
|
||||
);
|
||||
const result = await syncService.registerDevice(manager.tenantId, manager.actorId, request.body);
|
||||
return reply.code(201).send(result);
|
||||
},
|
||||
);
|
||||
|
|
@ -261,8 +257,7 @@ export async function registerSyncRoutes(
|
|||
{
|
||||
schema: {
|
||||
tags: ['Sync'],
|
||||
summary:
|
||||
'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)',
|
||||
summary: 'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)',
|
||||
body: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
|
|
@ -284,15 +279,7 @@ export async function registerSyncRoutes(
|
|||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: [
|
||||
'cursor',
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'operation',
|
||||
'revision',
|
||||
'payload',
|
||||
'created_at',
|
||||
],
|
||||
required: ['cursor', 'entity_type', 'entity_id', 'operation', 'revision', 'payload', 'created_at'],
|
||||
properties: {
|
||||
cursor: { type: 'string' },
|
||||
entity_type: { type: 'string' },
|
||||
|
|
@ -343,14 +330,7 @@ export async function registerSyncRoutes(
|
|||
maxItems: 100,
|
||||
items: {
|
||||
type: 'object',
|
||||
required: [
|
||||
'command_type',
|
||||
'schema_version',
|
||||
'idempotency_key',
|
||||
'correlation_id',
|
||||
'created_at',
|
||||
'payload',
|
||||
],
|
||||
required: ['command_type', 'schema_version', 'idempotency_key', 'correlation_id', 'created_at', 'payload'],
|
||||
properties: {
|
||||
command_type: { type: 'string' },
|
||||
schema_version: { type: 'integer', default: 1 },
|
||||
|
|
@ -407,11 +387,7 @@ export async function registerSyncRoutes(
|
|||
const token = extractBearerToken(request);
|
||||
const tenantId = resolveTenantId(request);
|
||||
const device = await syncService.authenticateDevice(token, tenantId);
|
||||
const results = await syncService.push(
|
||||
device.tenant_id,
|
||||
device,
|
||||
request.body?.commands ?? [],
|
||||
);
|
||||
const results = await syncService.push(device.tenant_id, device, request.body?.commands ?? []);
|
||||
return reply.code(200).send({ results });
|
||||
} catch (error) {
|
||||
handleSyncError(error, request);
|
||||
|
|
@ -452,11 +428,7 @@ export async function registerSyncRoutes(
|
|||
const token = extractBearerToken(request);
|
||||
const tenantId = resolveTenantId(request);
|
||||
const device = await syncService.authenticateDevice(token, tenantId);
|
||||
const result = await syncService.acknowledge(
|
||||
device.tenant_id,
|
||||
device.id,
|
||||
request.body.cursor,
|
||||
);
|
||||
const result = await syncService.acknowledge(device.tenant_id, device.id, request.body.cursor);
|
||||
return reply.code(200).send(result);
|
||||
} catch (error) {
|
||||
handleSyncError(error, request);
|
||||
|
|
|
|||
|
|
@ -29,9 +29,7 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
|
||||
afterAll(async () => {
|
||||
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
||||
await client.query('DELETE FROM staff_sync_device_checkpoints WHERE tenant_id = $1', [
|
||||
tenantA,
|
||||
]);
|
||||
await client.query('DELETE FROM staff_sync_device_checkpoints WHERE tenant_id = $1', [tenantA]);
|
||||
await client.query('DELETE FROM staff_sync_command_inbox WHERE tenant_id = $1', [tenantA]);
|
||||
await client.query('DELETE FROM staff_sync_change_log WHERE tenant_id = $1', [tenantA]);
|
||||
await client.query('DELETE FROM staff_sync_devices WHERE tenant_id = $1', [tenantA]);
|
||||
|
|
@ -255,9 +253,7 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
});
|
||||
|
||||
const cpVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query('SELECT device_id FROM staff_sync_device_checkpoints WHERE device_id = $1', [
|
||||
deviceIdA,
|
||||
]),
|
||||
client.query('SELECT device_id FROM staff_sync_device_checkpoints WHERE device_id = $1', [deviceIdA]),
|
||||
);
|
||||
expect(cpVisibleToB.rowCount).toBe(0);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ import type { SyncCommand } from '../../src/domain/sync/types.js';
|
|||
import { buildApp } from '../../src/app.js';
|
||||
import { loadConfig } from '../../src/config.js';
|
||||
|
||||
function createMockDb(
|
||||
queryHandler?: (sql: string, params: unknown[]) => { rows: unknown[]; rowCount?: number },
|
||||
) {
|
||||
function createMockDb(queryHandler?: (sql: string, params: unknown[]) => { rows: unknown[]; rowCount?: number }) {
|
||||
const queries: Array<{ sql: string; params: unknown[] }> = [];
|
||||
const query = vi.fn(async (sql: string, params: unknown[] = []) => {
|
||||
queries.push({ sql, params });
|
||||
|
|
@ -107,9 +105,7 @@ describe('Sync Device Authentication & Tenant Isolation', () => {
|
|||
expect(device.tenant_id).toBe(tenantA);
|
||||
expect(device.status).toBe('active');
|
||||
expect(
|
||||
queries.some(
|
||||
(q) => q.sql.includes('UPDATE staff_sync_devices') && q.sql.includes('last_seen_at'),
|
||||
),
|
||||
queries.some((q) => q.sql.includes('UPDATE staff_sync_devices') && q.sql.includes('last_seen_at')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -191,9 +187,7 @@ describe('Sync Device Authentication & Tenant Isolation', () => {
|
|||
});
|
||||
|
||||
const service = new SyncService(database, systemActorId);
|
||||
await expect(
|
||||
service.authenticateDevice(rawToken, tenantA, 'attendance:sync'),
|
||||
).rejects.toMatchObject({
|
||||
await expect(service.authenticateDevice(rawToken, tenantA, 'attendance:sync')).rejects.toMatchObject({
|
||||
code: 'SCOPE_INSUFFICIENT',
|
||||
statusCode: 403,
|
||||
});
|
||||
|
|
@ -303,15 +297,25 @@ describe('Sync Push & Idempotency', () => {
|
|||
expect(queries.some((q) => q.sql.includes('INSERT INTO staff_attendance_events'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects upsert_work_session command with COMMAND_NOT_SUPPORTED', async () => {
|
||||
it('returns structured conflict on revision mismatch for work_session', async () => {
|
||||
const sessionId = newUlid();
|
||||
const { database } = createMockDb();
|
||||
|
||||
const { database } = createMockDb((sql) => {
|
||||
if (sql.includes('SELECT status, result, conflict')) {
|
||||
return { rows: [] }; // Not in inbox
|
||||
}
|
||||
if (sql.includes('SELECT id, revision, status FROM staff_work_sessions')) {
|
||||
// Server has revision 3
|
||||
return { rows: [{ id: sessionId, revision: 3, status: 'confirmed' }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
});
|
||||
|
||||
const service = new SyncService(database, systemActorId);
|
||||
const command: SyncCommand = {
|
||||
command_type: 'upsert_work_session',
|
||||
schema_version: 1,
|
||||
idempotency_key: 'cmd-session-reject',
|
||||
idempotency_key: 'cmd-session-conflict',
|
||||
correlation_id: newUlid(),
|
||||
created_at: new Date().toISOString(),
|
||||
payload: {
|
||||
|
|
@ -319,28 +323,43 @@ describe('Sync Push & Idempotency', () => {
|
|||
employee_id: employeeId,
|
||||
org_unit_id: newUlid(),
|
||||
business_date: '2026-09-26',
|
||||
revision: 1,
|
||||
revision: 2, // Client expects revision 2, but server has 3
|
||||
worked_minutes: 480,
|
||||
status: 'confirmed',
|
||||
},
|
||||
};
|
||||
|
||||
const results = await service.push(tenantId, testDevice, [command]);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.status).toBe('rejected');
|
||||
expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
||||
expect(results[0]?.error_message).toContain('Команда не поддерживается');
|
||||
expect(results[0]?.status).toBe('conflict');
|
||||
expect(results[0]?.conflict).toEqual({
|
||||
conflict_code: 'REVISION_MISMATCH',
|
||||
entity_type: 'work_session',
|
||||
entity_id: sessionId,
|
||||
expected_revision: 2,
|
||||
actual_revision: 3,
|
||||
safe_description: 'Ревизия рабочей смены на сервере изменилась',
|
||||
client_action: 'FETCH_LATEST_AND_RETRY',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects upsert_timesheet_entry command with COMMAND_NOT_SUPPORTED', async () => {
|
||||
it('returns structured conflict on revision mismatch for timesheet_entry', async () => {
|
||||
const entryId = newUlid();
|
||||
const { database } = createMockDb();
|
||||
|
||||
const { database } = createMockDb((sql) => {
|
||||
if (sql.includes('SELECT status, result, conflict')) {
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.includes('SELECT id, revision, status FROM staff_timesheet_entries')) {
|
||||
return { rows: [{ id: entryId, revision: 4, status: 'approved' }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
});
|
||||
|
||||
const service = new SyncService(database, systemActorId);
|
||||
const command: SyncCommand = {
|
||||
command_type: 'upsert_timesheet_entry',
|
||||
schema_version: 1,
|
||||
idempotency_key: 'cmd-ts-reject',
|
||||
idempotency_key: 'cmd-ts-conflict',
|
||||
correlation_id: newUlid(),
|
||||
created_at: new Date().toISOString(),
|
||||
payload: {
|
||||
|
|
@ -348,92 +367,22 @@ describe('Sync Push & Idempotency', () => {
|
|||
employee_id: employeeId,
|
||||
work_date: '2026-09-26',
|
||||
minutes_worked: 500,
|
||||
revision: 1,
|
||||
revision: 1, // Mismatch: expects 1, server has 4
|
||||
},
|
||||
};
|
||||
|
||||
const results = await service.push(tenantId, testDevice, [command]);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.status).toBe('rejected');
|
||||
expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
||||
expect(results[0]?.error_message).toContain('Команда не поддерживается');
|
||||
});
|
||||
|
||||
it('does not write to staff_work_sessions, staff_timesheet_entries, or staff_sync_change_log on rejected shift commands', async () => {
|
||||
const { database, queries } = createMockDb();
|
||||
|
||||
const service = new SyncService(database, systemActorId);
|
||||
const commands: SyncCommand[] = [
|
||||
{
|
||||
command_type: 'upsert_work_session',
|
||||
schema_version: 1,
|
||||
idempotency_key: 'cmd-session-no-write',
|
||||
correlation_id: newUlid(),
|
||||
created_at: new Date().toISOString(),
|
||||
payload: {
|
||||
id: newUlid(),
|
||||
employee_id: employeeId,
|
||||
org_unit_id: newUlid(),
|
||||
business_date: '2026-09-26',
|
||||
status: 'confirmed',
|
||||
worked_minutes: 2880,
|
||||
},
|
||||
},
|
||||
{
|
||||
command_type: 'upsert_timesheet_entry',
|
||||
schema_version: 1,
|
||||
idempotency_key: 'cmd-ts-no-write',
|
||||
correlation_id: newUlid(),
|
||||
created_at: new Date().toISOString(),
|
||||
payload: {
|
||||
id: newUlid(),
|
||||
employee_id: employeeId,
|
||||
work_date: '2026-09-26',
|
||||
minutes_worked: 500,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const results = await service.push(tenantId, testDevice, commands);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.every((r) => r.status === 'rejected')).toBe(true);
|
||||
|
||||
// Verify nothing is written to staff_work_sessions
|
||||
expect(
|
||||
queries.some(
|
||||
(q) =>
|
||||
q.sql.toLowerCase().includes('staff_work_sessions') &&
|
||||
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
// Verify nothing is written to staff_timesheet_entries
|
||||
expect(
|
||||
queries.some(
|
||||
(q) =>
|
||||
q.sql.toLowerCase().includes('staff_timesheet_entries') &&
|
||||
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
// Verify nothing is written to staff_sync_change_log
|
||||
expect(
|
||||
queries.some(
|
||||
(q) =>
|
||||
q.sql.toLowerCase().includes('staff_sync_change_log') &&
|
||||
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
// Verify commands ARE saved to inbox with status 'rejected'
|
||||
const inboxInserts = queries.filter((q) =>
|
||||
q.sql.includes('INSERT INTO staff_sync_command_inbox'),
|
||||
);
|
||||
expect(inboxInserts).toHaveLength(2);
|
||||
for (const insert of inboxInserts) {
|
||||
expect(insert.params).toContain('rejected');
|
||||
expect(insert.params).toContain('COMMAND_NOT_SUPPORTED');
|
||||
}
|
||||
expect(results[0]?.status).toBe('conflict');
|
||||
expect(results[0]?.conflict).toEqual({
|
||||
conflict_code: 'REVISION_MISMATCH',
|
||||
entity_type: 'timesheet_entry',
|
||||
entity_id: entryId,
|
||||
expected_revision: 1,
|
||||
actual_revision: 4,
|
||||
safe_description: 'Ревизия записи табеля на сервере изменилась',
|
||||
client_action: 'FETCH_LATEST_AND_RETRY',
|
||||
});
|
||||
});
|
||||
|
||||
it('enforces batch size limits and payload size limits', async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue