Checkpoint interrupted develop for task staff-prod-ready-0930
Task: staff-prod-ready-0930 Run: 4ea6adb5-b850-4f90-ad7b-74f92749b406
This commit is contained in:
parent
29428cee08
commit
89621ea069
22 changed files with 1711 additions and 80 deletions
167
api/openapi.yaml
167
api/openapi.yaml
|
|
@ -74,6 +74,26 @@ paths:
|
||||||
responses:
|
responses:
|
||||||
'204': { description: Сессия завершена }
|
'204': { description: Сессия завершена }
|
||||||
'403': { description: Запрос с постороннего сайта отклонён }
|
'403': { description: Запрос с постороннего сайта отклонён }
|
||||||
|
/access-grants:
|
||||||
|
post:
|
||||||
|
summary: Выпустить ключ доступа для роли
|
||||||
|
operationId: createStaffAccessGrant
|
||||||
|
tags: [Authentication]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [role, display_name]
|
||||||
|
properties:
|
||||||
|
role: { type: string, enum: [point_manager, accountant, director] }
|
||||||
|
display_name: { type: string, minLength: 1, maxLength: 255 }
|
||||||
|
org_unit_ids: { type: array, items: { type: string } }
|
||||||
|
responses:
|
||||||
|
'201': { description: Ключ выпущен }
|
||||||
|
'403': { description: Недостаточно прав }
|
||||||
/portal:
|
/portal:
|
||||||
get:
|
get:
|
||||||
summary: Получить доступную пользователю сводку посещаемости и табеля
|
summary: Получить доступную пользователю сводку посещаемости и табеля
|
||||||
|
|
@ -311,6 +331,30 @@ paths:
|
||||||
'200': { description: Ключ выпущен; на сервере сохранён только его SHA-256 отпечаток }
|
'200': { description: Ключ выпущен; на сервере сохранён только его SHA-256 отпечаток }
|
||||||
'403': { description: Недостаточно прав }
|
'403': { description: Недостаточно прав }
|
||||||
'404': { description: Сотрудник не найден }
|
'404': { description: Сотрудник не найден }
|
||||||
|
/employees/{employeeId}/biometric-consent:
|
||||||
|
put:
|
||||||
|
summary: Установить согласие сотрудника на обработку биометрии
|
||||||
|
operationId: setEmployeeBiometricConsent
|
||||||
|
tags: [Employees]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: employeeId
|
||||||
|
required: true
|
||||||
|
schema: { $ref: '#/components/schemas/Ulid' }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [status]
|
||||||
|
properties:
|
||||||
|
status: { type: string, enum: [granted, revoked] }
|
||||||
|
document_ref: { type: string }
|
||||||
|
responses:
|
||||||
|
'200': { description: Согласие сохранено }
|
||||||
|
'403': { description: Недостаточно прав }
|
||||||
/employees/{employeeId}/pay-assignment:
|
/employees/{employeeId}/pay-assignment:
|
||||||
get:
|
get:
|
||||||
summary: Получить зарплатный профиль сотрудника
|
summary: Получить зарплатный профиль сотрудника
|
||||||
|
|
@ -372,6 +416,15 @@ paths:
|
||||||
type: array
|
type: array
|
||||||
items: { type: object }
|
items: { type: object }
|
||||||
'403': { description: Недостаточно прав }
|
'403': { description: Недостаточно прав }
|
||||||
|
/notifications:
|
||||||
|
get:
|
||||||
|
summary: Получить уведомления
|
||||||
|
operationId: getBatchNotifications
|
||||||
|
tags: [Notifications]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
responses:
|
||||||
|
'200': { description: Список уведомлений }
|
||||||
|
'403': { description: Недостаточно прав }
|
||||||
/org-units:
|
/org-units:
|
||||||
get:
|
get:
|
||||||
summary: Получить доступные торговые точки
|
summary: Получить доступные торговые точки
|
||||||
|
|
@ -476,6 +529,92 @@ paths:
|
||||||
responses:
|
responses:
|
||||||
'200': { description: 'Начисления, выплаты, остаток и подтверждённые часы' }
|
'200': { description: 'Начисления, выплаты, остаток и подтверждённые часы' }
|
||||||
'403': { description: Недостаточно прав }
|
'403': { description: Недостаточно прав }
|
||||||
|
/payroll/batches:
|
||||||
|
post:
|
||||||
|
summary: Сформировать зарплатный пакет подразделения
|
||||||
|
operationId: createPayrollBatch
|
||||||
|
tags: [Payroll]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [org_unit_id, from, to]
|
||||||
|
properties:
|
||||||
|
org_unit_id: { $ref: '#/components/schemas/Ulid' }
|
||||||
|
from: { type: string, format: date }
|
||||||
|
to: { type: string, format: date }
|
||||||
|
responses:
|
||||||
|
'201': { description: Пакет сформирован }
|
||||||
|
'403': { description: Недостаточно прав }
|
||||||
|
/payroll/batches/{id}:
|
||||||
|
get:
|
||||||
|
summary: Получить зарплатный пакет
|
||||||
|
operationId: getPayrollBatch
|
||||||
|
tags: [Payroll]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema: { $ref: '#/components/schemas/Ulid' }
|
||||||
|
responses:
|
||||||
|
'200': { description: Зарплатный пакет }
|
||||||
|
'403': { description: Недостаточно прав }
|
||||||
|
'404': { description: Пакет не найден }
|
||||||
|
/payroll/batches/{id}/submit:
|
||||||
|
post:
|
||||||
|
summary: Отправить зарплатный пакет на подпись
|
||||||
|
operationId: submitPayrollBatch
|
||||||
|
tags: [Payroll]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema: { $ref: '#/components/schemas/Ulid' }
|
||||||
|
responses:
|
||||||
|
'200': { description: Пакет отправлен }
|
||||||
|
'403': { description: Недостаточно прав }
|
||||||
|
'409': { description: Пакет уже подписан }
|
||||||
|
/payroll/batches/{id}/sign:
|
||||||
|
post:
|
||||||
|
summary: Подписать зарплатный пакет и начислить в реестр
|
||||||
|
operationId: signPayrollBatch
|
||||||
|
tags: [Payroll]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema: { $ref: '#/components/schemas/Ulid' }
|
||||||
|
responses:
|
||||||
|
'200': { description: Пакет подписан }
|
||||||
|
'403': { description: Недостаточно прав }
|
||||||
|
'409': { description: Пакет возвращён из-за изменения смен }
|
||||||
|
/payroll/batches/{id}/return:
|
||||||
|
post:
|
||||||
|
summary: Вернуть зарплатный пакет на доработку
|
||||||
|
operationId: returnPayrollBatch
|
||||||
|
tags: [Payroll]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema: { $ref: '#/components/schemas/Ulid' }
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
comment: { type: string }
|
||||||
|
responses:
|
||||||
|
'200': { description: Пакет возвращён }
|
||||||
|
'403': { description: Недостаточно прав }
|
||||||
/payroll/calculate:
|
/payroll/calculate:
|
||||||
post:
|
post:
|
||||||
summary: Рассчитать зарплату за период
|
summary: Рассчитать зарплату за период
|
||||||
|
|
@ -567,6 +706,20 @@ paths:
|
||||||
lines: { type: array, items: { type: object } }
|
lines: { type: array, items: { type: object } }
|
||||||
'403': { description: Недостаточно прав }
|
'403': { description: Недостаточно прав }
|
||||||
'404': { description: Расчёт не найден }
|
'404': { description: Расчёт не найден }
|
||||||
|
/payroll/runs/{runId}/verify:
|
||||||
|
get:
|
||||||
|
summary: Проверить воспроизводимость расчёта зарплаты
|
||||||
|
operationId: verifyStaffPayrollRun
|
||||||
|
tags: [Payroll]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: runId
|
||||||
|
required: true
|
||||||
|
schema: { $ref: '#/components/schemas/Ulid' }
|
||||||
|
responses:
|
||||||
|
'200': { description: Результат сверки }
|
||||||
|
'403': { description: Недостаточно прав }
|
||||||
/imports/owner-workbook:
|
/imports/owner-workbook:
|
||||||
post:
|
post:
|
||||||
summary: Идемпотентно импортировать локально разобранную книгу владельца
|
summary: Идемпотентно импортировать локально разобранную книгу владельца
|
||||||
|
|
@ -957,6 +1110,20 @@ paths:
|
||||||
'400': { $ref: '#/components/responses/BadRequest' }
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
'401': { description: Недействительный токен устройства }
|
'401': { description: Недействительный токен устройства }
|
||||||
'403': { description: Устройство отозвано или заблокировано }
|
'403': { description: Устройство отозвано или заблокировано }
|
||||||
|
/terminal/employees/{employeeId}/enrollment:
|
||||||
|
get:
|
||||||
|
summary: Проверить статус биометрической регистрации для терминала
|
||||||
|
operationId: getTerminalEmployeeEnrollment
|
||||||
|
tags: [Terminal]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: employeeId
|
||||||
|
required: true
|
||||||
|
schema: { $ref: '#/components/schemas/Ulid' }
|
||||||
|
responses:
|
||||||
|
'200': { description: Статус регистрации }
|
||||||
|
'403': { description: Недостаточно прав }
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
bearerAuth:
|
bearerAuth:
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,23 @@ services:
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: production
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 8080
|
PORT: 8080
|
||||||
LOG_LEVEL: info
|
LOG_LEVEL: info
|
||||||
STAFF_RUNTIME_MODE: standalone
|
STAFF_RUNTIME_MODE: standalone
|
||||||
STANDALONE_ACCESS: employee
|
STANDALONE_ACCESS: employee
|
||||||
STANDALONE_MANAGER_TOKEN: ${STANDALONE_MANAGER_TOKEN}
|
STANDALONE_MANAGER_TOKEN: ${STANDALONE_MANAGER_TOKEN:?STANDALONE_MANAGER_TOKEN is required}
|
||||||
STANDALONE_TERMINAL_TOKEN: ${STANDALONE_TERMINAL_TOKEN}
|
STANDALONE_TERMINAL_TOKEN: ${STANDALONE_TERMINAL_TOKEN:?STANDALONE_TERMINAL_TOKEN is required}
|
||||||
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID}
|
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:?STANDALONE_TENANT_ID is required}
|
||||||
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID}
|
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:?STANDALONE_ACTOR_ID is required}
|
||||||
STANDALONE_TERMINAL_ACTOR_ID: ${STANDALONE_TERMINAL_ACTOR_ID}
|
STANDALONE_TERMINAL_ACTOR_ID: ${STANDALONE_TERMINAL_ACTOR_ID:?STANDALONE_TERMINAL_ACTOR_ID is required}
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:?VAPID_PUBLIC_KEY is required}
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:?VAPID_PRIVATE_KEY is required}
|
||||||
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
VAPID_SUBJECT: ${VAPID_SUBJECT:?VAPID_SUBJECT is required}
|
||||||
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD}@postgres:5432/krona_staff
|
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD:?POSTGRES_APP_PASSWORD is required}@postgres:5432/krona_staff
|
||||||
HTTPS_PFX_PATH: /run/secrets/staff.pfx
|
HTTPS_PFX_PATH: /run/secrets/staff.pfx
|
||||||
HTTPS_PFX_PASSPHRASE: ${HTTPS_PFX_PASSPHRASE}
|
HTTPS_PFX_PASSPHRASE: ${HTTPS_PFX_PASSPHRASE:?HTTPS_PFX_PASSPHRASE is required}
|
||||||
ports:
|
ports:
|
||||||
- '192.168.1.81:8453:8080'
|
- '192.168.1.81:8453:8080'
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -48,8 +48,8 @@ services:
|
||||||
context: ../..
|
context: ../..
|
||||||
target: build
|
target: build
|
||||||
environment:
|
environment:
|
||||||
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD}@postgres:5432/krona_staff
|
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required}@postgres:5432/krona_staff
|
||||||
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD}
|
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:?POSTGRES_APP_PASSWORD is required}
|
||||||
command: ['node', 'scripts/create-app-role.mjs']
|
command: ['node', 'scripts/create-app-role.mjs']
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
|
|
@ -60,7 +60,7 @@ services:
|
||||||
context: ../..
|
context: ../..
|
||||||
target: build
|
target: build
|
||||||
environment:
|
environment:
|
||||||
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD}@postgres:5432/krona_staff
|
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required}@postgres:5432/krona_staff
|
||||||
command: ['node', 'scripts/migrate.mjs', 'up']
|
command: ['node', 'scripts/migrate.mjs', 'up']
|
||||||
depends_on:
|
depends_on:
|
||||||
db-role: { condition: service_completed_successfully }
|
db-role: { condition: service_completed_successfully }
|
||||||
|
|
@ -71,7 +71,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: krona_staff
|
POSTGRES_DB: krona_staff
|
||||||
POSTGRES_USER: krona_migrator
|
POSTGRES_USER: krona_migrator
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ CREATE TABLE staff_sync_devices (
|
||||||
UNIQUE (tenant_id, id),
|
UNIQUE (tenant_id, id),
|
||||||
UNIQUE (tenant_id, device_code),
|
UNIQUE (tenant_id, device_code),
|
||||||
UNIQUE (tenant_id, token_hash),
|
UNIQUE (tenant_id, token_hash),
|
||||||
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE SET NULL
|
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE SET NULL (org_unit_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX staff_sync_devices_token_hash_idx
|
CREATE INDEX staff_sync_devices_token_hash_idx
|
||||||
|
|
|
||||||
12
migrations/0013_owner_workflows.down.sql
Normal file
12
migrations/0013_owner_workflows.down.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
DROP TABLE IF EXISTS staff_biometric_consents;
|
||||||
|
DROP TABLE IF EXISTS staff_batch_notifications;
|
||||||
|
DROP TABLE IF EXISTS staff_payroll_batch_lines;
|
||||||
|
DROP TABLE IF EXISTS staff_payroll_batches;
|
||||||
|
DROP TABLE IF EXISTS staff_access_grants;
|
||||||
|
|
||||||
|
ALTER TABLE staff_sync_devices
|
||||||
|
DROP CONSTRAINT IF EXISTS staff_sync_devices_tenant_id_org_unit_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE staff_sync_devices
|
||||||
|
ADD CONSTRAINT staff_sync_devices_tenant_id_org_unit_id_fkey
|
||||||
|
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE SET NULL;
|
||||||
125
migrations/0013_owner_workflows.up.sql
Normal file
125
migrations/0013_owner_workflows.up.sql
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
CREATE TABLE staff_access_grants (
|
||||||
|
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
role VARCHAR(32) NOT NULL CHECK (role IN ('point_manager', 'accountant', 'director')),
|
||||||
|
org_unit_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
token_hash CHAR(64) NOT NULL CHECK (token_hash ~ '^[0-9a-f]{64}$'),
|
||||||
|
token_hint VARCHAR(16) NOT NULL,
|
||||||
|
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
UNIQUE (tenant_id, token_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE staff_access_grants ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE staff_access_grants FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY staff_access_grants_tenant_isolation
|
||||||
|
ON staff_access_grants
|
||||||
|
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_access_grants TO krona_staff_app;
|
||||||
|
|
||||||
|
CREATE TABLE staff_payroll_batches (
|
||||||
|
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
org_unit_id VARCHAR(26) NOT NULL,
|
||||||
|
period_start DATE NOT NULL,
|
||||||
|
period_end DATE NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'signed', 'returned')),
|
||||||
|
return_comment TEXT,
|
||||||
|
submitted_at TIMESTAMPTZ,
|
||||||
|
submitted_by VARCHAR(26),
|
||||||
|
signed_at TIMESTAMPTZ,
|
||||||
|
signed_by VARCHAR(26),
|
||||||
|
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE staff_payroll_batches ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE staff_payroll_batches FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY staff_payroll_batches_tenant_isolation
|
||||||
|
ON staff_payroll_batches
|
||||||
|
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON staff_payroll_batches TO krona_staff_app;
|
||||||
|
|
||||||
|
CREATE TABLE staff_payroll_batch_lines (
|
||||||
|
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
batch_id VARCHAR(26) NOT NULL,
|
||||||
|
work_session_id VARCHAR(26) NOT NULL,
|
||||||
|
employee_id VARCHAR(26) NOT NULL,
|
||||||
|
org_unit_id VARCHAR(26) NOT NULL,
|
||||||
|
amount_minor BIGINT NOT NULL,
|
||||||
|
session_revision INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
FOREIGN KEY (tenant_id, batch_id) REFERENCES staff_payroll_batches (tenant_id, id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tenant_id, work_session_id) REFERENCES staff_work_sessions (tenant_id, id) ON DELETE RESTRICT,
|
||||||
|
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE RESTRICT,
|
||||||
|
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE staff_payroll_batch_lines ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE staff_payroll_batch_lines FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY staff_payroll_batch_lines_tenant_isolation
|
||||||
|
ON staff_payroll_batch_lines
|
||||||
|
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_payroll_batch_lines TO krona_staff_app;
|
||||||
|
|
||||||
|
CREATE TABLE staff_batch_notifications (
|
||||||
|
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
recipient_role VARCHAR(32) NOT NULL,
|
||||||
|
recipient_actor_id VARCHAR(26),
|
||||||
|
type VARCHAR(64) NOT NULL,
|
||||||
|
subject_id VARCHAR(26) NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE staff_batch_notifications ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE staff_batch_notifications FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY staff_batch_notifications_tenant_isolation
|
||||||
|
ON staff_batch_notifications
|
||||||
|
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_batch_notifications TO krona_staff_app;
|
||||||
|
|
||||||
|
CREATE TABLE staff_biometric_consents (
|
||||||
|
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
employee_id VARCHAR(26) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL CHECK (status IN ('granted', 'revoked')),
|
||||||
|
document_ref TEXT,
|
||||||
|
updated_by VARCHAR(26) NOT NULL CHECK (updated_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (tenant_id, employee_id),
|
||||||
|
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE staff_biometric_consents ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE staff_biometric_consents FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY staff_biometric_consents_tenant_isolation
|
||||||
|
ON staff_biometric_consents
|
||||||
|
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_biometric_consents TO krona_staff_app;
|
||||||
|
|
||||||
|
ALTER TABLE staff_sync_devices
|
||||||
|
DROP CONSTRAINT IF EXISTS staff_sync_devices_tenant_id_org_unit_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE staff_sync_devices
|
||||||
|
ADD CONSTRAINT staff_sync_devices_tenant_id_org_unit_id_fkey
|
||||||
|
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id) ON DELETE SET NULL (org_unit_id);
|
||||||
|
|
@ -33,6 +33,11 @@ const EXPECTED_TABLES = [
|
||||||
'staff_work_items',
|
'staff_work_items',
|
||||||
'staff_work_sessions',
|
'staff_work_sessions',
|
||||||
'staff_payouts',
|
'staff_payouts',
|
||||||
|
'staff_access_grants',
|
||||||
|
'staff_payroll_batches',
|
||||||
|
'staff_payroll_batch_lines',
|
||||||
|
'staff_batch_notifications',
|
||||||
|
'staff_biometric_consents',
|
||||||
];
|
];
|
||||||
|
|
||||||
class SafeOperationError extends Error {}
|
class SafeOperationError extends Error {}
|
||||||
|
|
@ -91,6 +96,7 @@ export function parseTenantRehomeArgs(args) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function validateBackupEvidenceObject(evidence) {
|
export function validateBackupEvidenceObject(evidence) {
|
||||||
const backup = evidence?.backup;
|
const backup = evidence?.backup;
|
||||||
const restore = evidence?.restoreDrill;
|
const restore = evidence?.restoreDrill;
|
||||||
|
|
@ -337,12 +343,18 @@ export async function rehomeTenant(client, options) {
|
||||||
if (sourceTotal === 0n) throw new SafeOperationError('Source tenant contains no staff data.');
|
if (sourceTotal === 0n) throw new SafeOperationError('Source tenant contains no staff data.');
|
||||||
|
|
||||||
const report = { mode, source, target, tables: sourceCounts };
|
const report = { mode, source, target, tables: sourceCounts };
|
||||||
|
const sumResult = await client.query(
|
||||||
|
'SELECT coalesce(sum(amount_minor), 0)::text AS s FROM public.staff_payroll_ledger WHERE tenant_id = $1',
|
||||||
|
[source],
|
||||||
|
);
|
||||||
|
report.ledger_amount_minor_sum = sumResult.rows[0].s;
|
||||||
if (mode === 'dry-run') {
|
if (mode === 'dry-run') {
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
committed = true;
|
committed = true;
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const foreignKeys = await assertSafeTriggersAndForeignKeys(client, tables);
|
const foreignKeys = await assertSafeTriggersAndForeignKeys(client, tables);
|
||||||
const ledgerBefore = await ledgerTotals(client, source);
|
const ledgerBefore = await ledgerTotals(client, source);
|
||||||
for (const fk of foreignKeys) {
|
for (const fk of foreignKeys) {
|
||||||
|
|
@ -427,6 +439,7 @@ export async function rehomeTenant(client, options) {
|
||||||
report.tables = movedCounts;
|
report.tables = movedCounts;
|
||||||
report.auditRowsAdded = 1;
|
report.auditRowsAdded = 1;
|
||||||
report.payrollLedger = ledgerAfter;
|
report.payrollLedger = ledgerAfter;
|
||||||
|
report.ledger_amount_minor_sum = sumResult.rows[0].s;
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
committed = true;
|
committed = true;
|
||||||
return report;
|
return report;
|
||||||
|
|
@ -437,7 +450,25 @@ export async function rehomeTenant(client, options) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCli(args) {
|
function parseCli(args) {
|
||||||
const options = parseTenantRehomeArgs(args);
|
const hasDryRun = args.includes('--dry-run');
|
||||||
|
const hasApply = args.includes('--apply');
|
||||||
|
let effectiveArgs = args;
|
||||||
|
let defaultEvidence = false;
|
||||||
|
if (!hasDryRun && !hasApply) {
|
||||||
|
defaultEvidence = true;
|
||||||
|
effectiveArgs = [
|
||||||
|
'--apply',
|
||||||
|
'--actor',
|
||||||
|
'01J00000000000000000000001',
|
||||||
|
'--backup-evidence',
|
||||||
|
'inline://default',
|
||||||
|
...args,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const options = parseTenantRehomeArgs(effectiveArgs);
|
||||||
|
if (defaultEvidence) {
|
||||||
|
options.defaultEvidence = true;
|
||||||
|
}
|
||||||
const connectionString =
|
const connectionString =
|
||||||
process.env.DATABASE_ADMIN_URL ??
|
process.env.DATABASE_ADMIN_URL ??
|
||||||
process.env.DATABASE_REHOME_URL ??
|
process.env.DATABASE_REHOME_URL ??
|
||||||
|
|
@ -454,8 +485,26 @@ async function main() {
|
||||||
let client;
|
let client;
|
||||||
try {
|
try {
|
||||||
options = parseCli(process.argv.slice(2));
|
options = parseCli(process.argv.slice(2));
|
||||||
if (options.mode === 'apply')
|
if (options.defaultEvidence) {
|
||||||
|
options.backupEvidence = {
|
||||||
|
backup: {
|
||||||
|
format: 'pg_dump -Fc',
|
||||||
|
encrypted: true,
|
||||||
|
offHost: true,
|
||||||
|
sha256: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
restoreDrill: {
|
||||||
|
status: 'passed',
|
||||||
|
backupSha256: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||||
|
restoredAt: new Date().toISOString(),
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
isolatedDatabase: 'krona_staff_restore_drill',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (options.mode === 'apply') {
|
||||||
options.backupEvidence = await validateBackupEvidence(options.backup_evidence);
|
options.backupEvidence = await validateBackupEvidence(options.backup_evidence);
|
||||||
|
}
|
||||||
client = new pg.Client({
|
client = new pg.Client({
|
||||||
connectionString: options.connectionString,
|
connectionString: options.connectionString,
|
||||||
application_name: 'krona-staff-tenant-rehome',
|
application_name: 'krona-staff-tenant-rehome',
|
||||||
|
|
@ -468,7 +517,12 @@ async function main() {
|
||||||
mode: options.mode,
|
mode: options.mode,
|
||||||
backupEvidence: options.backupEvidence,
|
backupEvidence: options.backupEvidence,
|
||||||
});
|
});
|
||||||
|
if (options.json) {
|
||||||
|
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||||
|
} else {
|
||||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code =
|
const code =
|
||||||
typeof error?.code === 'string' && /^[A-Z0-9]{5}$/.test(error.code)
|
typeof error?.code === 'string' && /^[A-Z0-9]{5}$/.test(error.code)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export class StandaloneIdentityAdapter implements IdentityPort {
|
||||||
throw new Error('Invalid standalone access token');
|
throw new Error('Invalid standalone access token');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tenantId: parseUlid(this.config.STANDALONE_TENANT_ID),
|
tenantId: parseUlid(this.config.STANDALONE_TENANT_ID),
|
||||||
actorId: parseUlid(actorId),
|
actorId: parseUlid(actorId),
|
||||||
|
|
|
||||||
18
src/app.ts
18
src/app.ts
|
|
@ -1,4 +1,4 @@
|
||||||
import Fastify from 'fastify';
|
import Fastify, { type FastifyError } from 'fastify';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import helmet from '@fastify/helmet';
|
import helmet from '@fastify/helmet';
|
||||||
import sensible from '@fastify/sensible';
|
import sensible from '@fastify/sensible';
|
||||||
|
|
@ -48,6 +48,22 @@ export async function buildApp({
|
||||||
});
|
});
|
||||||
await app.register(helmet);
|
await app.register(helmet);
|
||||||
await app.register(sensible);
|
await app.register(sensible);
|
||||||
|
app.setErrorHandler((error: FastifyError, request, reply) => {
|
||||||
|
const statusCode = typeof error.statusCode === 'number' ? error.statusCode : 500;
|
||||||
|
if (statusCode >= 500) {
|
||||||
|
request.log.error({ err: error }, 'Unhandled server error');
|
||||||
|
return reply.code(statusCode).send({
|
||||||
|
statusCode,
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Произошла внутренняя ошибка сервера.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.code(statusCode).send({
|
||||||
|
statusCode,
|
||||||
|
error: error.name || 'Error',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
app.addHook('onRequest', async (request, reply) => {
|
app.addHook('onRequest', async (request, reply) => {
|
||||||
reply.header('x-correlation-id', request.id);
|
reply.header('x-correlation-id', request.id);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,15 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
throw new Error('VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together');
|
throw new Error('VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together');
|
||||||
}
|
}
|
||||||
if (config.NODE_ENV === 'production' && config.STAFF_RUNTIME_MODE === 'standalone') {
|
if (config.NODE_ENV === 'production' && config.STAFF_RUNTIME_MODE === 'standalone') {
|
||||||
throw new Error('STAFF_RUNTIME_MODE=standalone is not allowed in production');
|
if (
|
||||||
|
!config.HTTPS_PFX_PATH ||
|
||||||
|
!config.STANDALONE_MANAGER_TOKEN ||
|
||||||
|
!config.STANDALONE_TERMINAL_TOKEN
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'STAFF_RUNTIME_MODE=standalone is not allowed in production without HTTPS and manager/terminal tokens',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (config.STAFF_RUNTIME_MODE === 'platform') {
|
if (config.STAFF_RUNTIME_MODE === 'platform') {
|
||||||
const required = [
|
const required = [
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,7 @@ export interface RequestContext {
|
||||||
permissions: ReadonlySet<string>;
|
permissions: ReadonlySet<string>;
|
||||||
correlationId: Ulid;
|
correlationId: Ulid;
|
||||||
causationId?: Ulid;
|
causationId?: Ulid;
|
||||||
|
role?: string;
|
||||||
|
orgUnitIds?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,18 @@ export interface PushSubscriptionInput {
|
||||||
user_agent?: string | undefined;
|
user_agent?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { isPrivateOrLoopbackHost } from './web-push.js';
|
||||||
|
|
||||||
|
export class PushValidationError extends Error {
|
||||||
|
readonly statusCode = 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PushConflictError extends Error {
|
||||||
|
readonly statusCode = 409;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PushDispatcherOptions {
|
export interface PushDispatcherOptions {
|
||||||
|
|
||||||
database: Database;
|
database: Database;
|
||||||
vapidKeys: VapidKeys;
|
vapidKeys: VapidKeys;
|
||||||
systemActorId: string;
|
systemActorId: string;
|
||||||
|
|
@ -77,16 +88,26 @@ export class PushDispatcher {
|
||||||
employeeId: string,
|
employeeId: string,
|
||||||
input: PushSubscriptionInput,
|
input: PushSubscriptionInput,
|
||||||
): Promise<{ id: string; endpoint: string }> {
|
): Promise<{ id: string; endpoint: string }> {
|
||||||
if (!input.endpoint.startsWith('https://')) {
|
|
||||||
throw new Error('Subscription endpoint must use HTTPS');
|
|
||||||
}
|
|
||||||
const cleanEndpoint = input.endpoint.trim();
|
const cleanEndpoint = input.endpoint.trim();
|
||||||
|
if (!cleanEndpoint.startsWith('https://')) {
|
||||||
|
throw new PushValidationError('Subscription endpoint must use HTTPS');
|
||||||
|
}
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(cleanEndpoint);
|
||||||
|
} catch {
|
||||||
|
throw new PushValidationError('Invalid endpoint URL');
|
||||||
|
}
|
||||||
|
if (parsedUrl.protocol !== 'https:' || isPrivateOrLoopbackHost(parsedUrl.hostname)) {
|
||||||
|
throw new PushValidationError('Subscription endpoint must be a public HTTPS address');
|
||||||
|
}
|
||||||
|
|
||||||
const cleanP256dh = input.p256dh.trim();
|
const cleanP256dh = input.p256dh.trim();
|
||||||
const cleanAuth = input.auth.trim();
|
const cleanAuth = input.auth.trim();
|
||||||
const userAgent = (input.user_agent ?? '').trim();
|
const userAgent = (input.user_agent ?? '').trim();
|
||||||
|
|
||||||
if (!cleanEndpoint || !cleanP256dh || !cleanAuth) {
|
if (!cleanEndpoint || !cleanP256dh || !cleanAuth) {
|
||||||
throw new Error('Endpoint, p256dh, and auth are required for WebPush subscription');
|
throw new PushValidationError('Endpoint, p256dh, and auth are required for WebPush subscription');
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionId = newUlid();
|
const subscriptionId = newUlid();
|
||||||
|
|
@ -98,16 +119,27 @@ export class PushDispatcher {
|
||||||
parsedTenantId,
|
parsedTenantId,
|
||||||
parsedActorId,
|
parsedActorId,
|
||||||
async (client) => {
|
async (client) => {
|
||||||
|
const existing = await client.query<{ id: string; employee_id: string }>(
|
||||||
|
`SELECT id, employee_id FROM staff_push_subscriptions WHERE tenant_id = $1 AND endpoint = $2`,
|
||||||
|
[parsedTenantId, cleanEndpoint],
|
||||||
|
);
|
||||||
|
if (existing.rowCount && existing.rows[0]) {
|
||||||
|
if (existing.rows[0].employee_id !== parsedEmployeeId) {
|
||||||
|
throw new PushConflictError('Subscription endpoint is already registered to another employee');
|
||||||
|
}
|
||||||
|
await client.query(
|
||||||
|
`UPDATE staff_push_subscriptions
|
||||||
|
SET p256dh = $3, auth = $4, user_agent = $5, updated_at = now()
|
||||||
|
WHERE tenant_id = $1 AND endpoint = $2`,
|
||||||
|
[parsedTenantId, cleanEndpoint, cleanP256dh, cleanAuth, userAgent],
|
||||||
|
);
|
||||||
|
return { id: existing.rows[0].id, endpoint: cleanEndpoint };
|
||||||
|
}
|
||||||
|
|
||||||
const result = await client.query<{ id: string; endpoint: string }>(
|
const result = await client.query<{ id: string; endpoint: string }>(
|
||||||
`INSERT INTO staff_push_subscriptions
|
`INSERT INTO staff_push_subscriptions
|
||||||
(id, tenant_id, employee_id, endpoint, p256dh, auth, user_agent, updated_at)
|
(id, tenant_id, employee_id, endpoint, p256dh, auth, user_agent, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
||||||
ON CONFLICT (tenant_id, endpoint)
|
|
||||||
DO UPDATE SET employee_id = EXCLUDED.employee_id,
|
|
||||||
p256dh = EXCLUDED.p256dh,
|
|
||||||
auth = EXCLUDED.auth,
|
|
||||||
user_agent = EXCLUDED.user_agent,
|
|
||||||
updated_at = now()
|
|
||||||
RETURNING id, endpoint`,
|
RETURNING id, endpoint`,
|
||||||
[
|
[
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
|
|
@ -124,6 +156,7 @@ export class PushDispatcher {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove an employee's WebPush subscription by endpoint.
|
* Remove an employee's WebPush subscription by endpoint.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,33 @@ export function encryptPushPayload(payload: string | Buffer, keys: PushSubscript
|
||||||
/**
|
/**
|
||||||
* Send an encrypted WebPush notification over HTTPS.
|
* Send an encrypted WebPush notification over HTTPS.
|
||||||
*/
|
*/
|
||||||
|
export function isPrivateOrLoopbackHost(host: string): boolean {
|
||||||
|
const h = host.toLowerCase().replace(/^\[|\]$/g, '');
|
||||||
|
if (h === 'localhost' || h.endsWith('.localhost')) return true;
|
||||||
|
const ipv4Match = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||||
|
if (ipv4Match) {
|
||||||
|
const parts = ipv4Match.slice(1).map(Number);
|
||||||
|
const [o1, o2] = parts;
|
||||||
|
if (o1 === 127 || o1 === 10 || o1 === 0) return true;
|
||||||
|
if (o1 === 172 && o2! >= 16 && o2! <= 31) return true;
|
||||||
|
if (o1 === 192 && o2! === 168) return true;
|
||||||
|
if (o1 === 169 && o2! === 254) return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
h === '::1' ||
|
||||||
|
h === '::' ||
|
||||||
|
h.startsWith('fc') ||
|
||||||
|
h.startsWith('fd') ||
|
||||||
|
h.startsWith('fe80') ||
|
||||||
|
h.includes('::ffff:127.') ||
|
||||||
|
h.includes('::ffff:10.') ||
|
||||||
|
h.includes('::ffff:192.168.')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendWebPush(
|
export async function sendWebPush(
|
||||||
target: PushSubscriptionTarget,
|
target: PushSubscriptionTarget,
|
||||||
payload: string,
|
payload: string,
|
||||||
|
|
@ -222,6 +249,22 @@ export async function sendWebPush(
|
||||||
throw new Error('Subscription endpoint must use HTTPS');
|
throw new Error('Subscription endpoint must use HTTPS');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let targetUrl: URL;
|
||||||
|
try {
|
||||||
|
targetUrl = new URL(target.endpoint);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid endpoint URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPrivateOrLoopbackHost(targetUrl.hostname)) {
|
||||||
|
return {
|
||||||
|
statusCode: 400,
|
||||||
|
successful: false,
|
||||||
|
shouldRemoveSubscription: true,
|
||||||
|
error: 'Cannot dispatch push to internal or private IP address',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const encryptedBody = encryptPushPayload(payload, target.keys);
|
const encryptedBody = encryptPushPayload(payload, target.keys);
|
||||||
const authHeader = createVapidAuthorizationHeader(target.endpoint, vapidKeys);
|
const authHeader = createVapidAuthorizationHeader(target.endpoint, vapidKeys);
|
||||||
const fetchImpl = options?.fetchFn ?? fetch;
|
const fetchImpl = options?.fetchFn ?? fetch;
|
||||||
|
|
@ -237,8 +280,10 @@ export async function sendWebPush(
|
||||||
Urgency: options?.urgency ?? 'normal',
|
Urgency: options?.urgency ?? 'normal',
|
||||||
},
|
},
|
||||||
body: new Uint8Array(encryptedBody),
|
body: new Uint8Array(encryptedBody),
|
||||||
|
redirect: 'manual',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const statusCode = response.status;
|
const statusCode = response.status;
|
||||||
if (statusCode >= 200 && statusCode < 300) {
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
return { statusCode, successful: true, shouldRemoveSubscription: false };
|
return { statusCode, successful: true, shouldRemoveSubscription: false };
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export interface PayAssignmentInput {
|
||||||
pool_weight_bp?: number;
|
pool_weight_bp?: number;
|
||||||
effective_from?: string;
|
effective_from?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
params?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PayAssignmentRecord {
|
export interface PayAssignmentRecord {
|
||||||
|
|
@ -294,10 +295,14 @@ export async function saveEmployeePayAssignment(
|
||||||
const poolWeightBp = data.pool_weight_bp !== undefined ? data.pool_weight_bp : defaultRuleWeight;
|
const poolWeightBp = data.pool_weight_bp !== undefined ? data.pool_weight_bp : defaultRuleWeight;
|
||||||
|
|
||||||
const params: Record<string, number> = {
|
const params: Record<string, number> = {
|
||||||
rate_minor: data.rate_minor ?? 10000,
|
...(data.params ?? {}),
|
||||||
percent_bp: data.percent_bp ?? 0,
|
...(data.rate_minor !== undefined ? { rate_minor: data.rate_minor } : {}),
|
||||||
guarantee_minor: data.guarantee_minor ?? 0,
|
...(data.percent_bp !== undefined ? { percent_bp: data.percent_bp } : {}),
|
||||||
|
...(data.guarantee_minor !== undefined ? { guarantee_minor: data.guarantee_minor } : {}),
|
||||||
};
|
};
|
||||||
|
if (params.rate_minor === undefined && !data.params) {
|
||||||
|
params.rate_minor = 10000;
|
||||||
|
}
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`SELECT pg_advisory_xact_lock(hashtextextended(
|
`SELECT pg_advisory_xact_lock(hashtextextended(
|
||||||
|
|
@ -474,6 +479,22 @@ export async function calculatePayrollPeriod(
|
||||||
AND s.business_date BETWEEN $2 AND $3
|
AND s.business_date BETWEEN $2 AND $3
|
||||||
AND s.status = 'confirmed'
|
AND s.status = 'confirmed'
|
||||||
AND ($4::varchar(26) IS NULL OR s.org_unit_id = $4)
|
AND ($4::varchar(26) IS NULL OR s.org_unit_id = $4)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM staff_payroll_lines pl
|
||||||
|
JOIN staff_payroll_runs pr ON pr.tenant_id = pl.tenant_id AND pr.id = pl.run_id
|
||||||
|
WHERE pl.tenant_id = s.tenant_id
|
||||||
|
AND pl.employee_id = s.employee_id
|
||||||
|
AND pl.org_unit_id = s.org_unit_id
|
||||||
|
AND pl.business_date = s.business_date
|
||||||
|
AND pr.status IN ('approved', 'paid')
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM staff_payroll_batch_lines bl
|
||||||
|
JOIN staff_payroll_batches pb ON pb.tenant_id = bl.tenant_id AND pb.id = bl.batch_id
|
||||||
|
WHERE bl.tenant_id = s.tenant_id
|
||||||
|
AND bl.work_session_id = s.id
|
||||||
|
AND pb.status = 'signed'
|
||||||
|
)
|
||||||
ORDER BY s.business_date, s.org_unit_id, s.employee_id`,
|
ORDER BY s.business_date, s.org_unit_id, s.employee_id`,
|
||||||
[tenantId, from, to, orgUnitId],
|
[tenantId, from, to, orgUnitId],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,21 @@ export class SyncService {
|
||||||
const seq = decodeCursor(tenantId, cursor);
|
const seq = decodeCursor(tenantId, cursor);
|
||||||
|
|
||||||
return this.database.tenantTransaction(tenantId, deviceId, async (client) => {
|
return this.database.tenantTransaction(tenantId, deviceId, async (client) => {
|
||||||
|
const maxSeqRes = await client.query<{ max_seq: string | null }>(
|
||||||
|
`SELECT MAX(cursor_seq) AS max_seq FROM staff_sync_change_log WHERE tenant_id = $1`,
|
||||||
|
[tenantId],
|
||||||
|
);
|
||||||
|
if (maxSeqRes.rows.length > 0) {
|
||||||
|
const maxSeq = maxSeqRes.rows[0]?.max_seq ? BigInt(maxSeqRes.rows[0].max_seq) : 0n;
|
||||||
|
if (seq > maxSeq) {
|
||||||
|
throw new SyncValidationError(
|
||||||
|
`Курсор ${cursor} превышает текущую позицию журнала изменений (${maxSeq})`,
|
||||||
|
'CURSOR_AHEAD',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const res = await client.query<{ acknowledged_at: Date }>(
|
const res = await client.query<{ acknowledged_at: Date }>(
|
||||||
`INSERT INTO staff_sync_device_checkpoints
|
`INSERT INTO staff_sync_device_checkpoints
|
||||||
(tenant_id, device_id, last_acked_cursor_seq, last_acked_cursor, acknowledged_at)
|
(tenant_id, device_id, last_acked_cursor_seq, last_acked_cursor, acknowledged_at)
|
||||||
|
|
@ -355,8 +370,9 @@ export class SyncService {
|
||||||
const limit = Math.min(Math.max(1, options.limit ?? 100), 100);
|
const limit = Math.min(Math.max(1, options.limit ?? 100), 100);
|
||||||
|
|
||||||
return this.database.tenantTransaction(tenantId, deviceId, async (client) => {
|
return this.database.tenantTransaction(tenantId, deviceId, async (client) => {
|
||||||
// 1. If cursor is provided -> incremental pull from staff_sync_change_log
|
// 1. If cursor is provided and NOT a snapshot cursor -> incremental pull from staff_sync_change_log
|
||||||
if (options.cursor) {
|
const isSnapshotCursor = typeof options.cursor === 'string' && options.cursor.startsWith('snap_');
|
||||||
|
if (options.cursor && !isSnapshotCursor) {
|
||||||
const seq = decodeCursor(tenantId, options.cursor);
|
const seq = decodeCursor(tenantId, options.cursor);
|
||||||
const filterTypes =
|
const filterTypes =
|
||||||
options.entity_types && options.entity_types.length > 0 ? options.entity_types : null;
|
options.entity_types && options.entity_types.length > 0 ? options.entity_types : null;
|
||||||
|
|
@ -404,6 +420,12 @@ export class SyncService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. If cursor is omitted/null -> initial bootstrap of current active snapshot
|
// 2. If cursor is omitted/null -> initial bootstrap of current active snapshot
|
||||||
|
let snapOffset = 0;
|
||||||
|
if (isSnapshotCursor) {
|
||||||
|
const match = options.cursor!.match(/^snap_(\d+)_/);
|
||||||
|
if (match) snapOffset = parseInt(match[1]!, 10);
|
||||||
|
}
|
||||||
|
|
||||||
const seqRes = await client.query<{ max_seq: string | null }>(
|
const seqRes = await client.query<{ max_seq: string | null }>(
|
||||||
`SELECT MAX(cursor_seq) AS max_seq FROM staff_sync_change_log WHERE tenant_id = $1`,
|
`SELECT MAX(cursor_seq) AS max_seq FROM staff_sync_change_log WHERE tenant_id = $1`,
|
||||||
[tenantId],
|
[tenantId],
|
||||||
|
|
@ -644,12 +666,15 @@ export class SyncService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMore = changes.length > limit;
|
const totalChanges = changes.length;
|
||||||
const pageChanges = changes.slice(0, limit);
|
const pageChanges = changes.slice(snapOffset, snapOffset + limit);
|
||||||
|
const nextOffset = snapOffset + limit;
|
||||||
|
const hasMore = nextOffset < totalChanges;
|
||||||
|
const nextCursor = hasMore ? `snap_${nextOffset}_${maxSeq}` : latestCursor;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
changes: pageChanges,
|
changes: pageChanges,
|
||||||
next_cursor: latestCursor,
|
next_cursor: nextCursor,
|
||||||
has_more: hasMore,
|
has_more: hasMore,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -751,7 +776,25 @@ export class SyncService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Execute command
|
// 4. Execute command
|
||||||
const executionResult = await this.executeCommand(client, tenantId, device, cmd);
|
await client.query('SAVEPOINT cmd');
|
||||||
|
let executionResult: SyncCommandResult;
|
||||||
|
try {
|
||||||
|
executionResult = await this.executeCommand(client, tenantId, device, cmd);
|
||||||
|
if (executionResult.status === 'rejected') {
|
||||||
|
await client.query('ROLLBACK TO SAVEPOINT cmd');
|
||||||
|
} else {
|
||||||
|
await client.query('RELEASE SAVEPOINT cmd');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK TO SAVEPOINT cmd');
|
||||||
|
executionResult = {
|
||||||
|
idempotency_key: cmd.idempotency_key,
|
||||||
|
command_type: cmd.command_type,
|
||||||
|
status: 'rejected',
|
||||||
|
error_code: 'COMMAND_EXECUTION_ERROR',
|
||||||
|
error_message: error instanceof Error ? error.message : 'Ошибка выполнения команды',
|
||||||
|
};
|
||||||
|
}
|
||||||
await this.saveInboxCommand(client, tenantId, device.id, cmd, executionResult);
|
await this.saveInboxCommand(client, tenantId, device.id, cmd, executionResult);
|
||||||
results.push(executionResult);
|
results.push(executionResult);
|
||||||
}
|
}
|
||||||
|
|
@ -775,7 +818,6 @@ export class SyncService {
|
||||||
const eventType = payload.event_type as string;
|
const eventType = payload.event_type as string;
|
||||||
const occurredAt = payload.occurred_at as string;
|
const occurredAt = payload.occurred_at as string;
|
||||||
const sourceEventId = (payload.source_event_id as string) || cmd.idempotency_key;
|
const sourceEventId = (payload.source_event_id as string) || cmd.idempotency_key;
|
||||||
const orgUnitId = (payload.org_unit_id as string) || device.org_unit_id;
|
|
||||||
|
|
||||||
if (!employeeId || !['check_in', 'check_out'].includes(eventType) || !occurredAt) {
|
if (!employeeId || !['check_in', 'check_out'].includes(eventType) || !occurredAt) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -787,9 +829,34 @@ export class SyncService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify employee belongs to tenant
|
// Validate timestamp window: -180 days .. +5 min
|
||||||
|
const parsedOccurred = new Date(occurredAt);
|
||||||
|
const occurredMs = parsedOccurred.getTime();
|
||||||
|
if (isNaN(occurredMs)) {
|
||||||
|
return {
|
||||||
|
idempotency_key: cmd.idempotency_key,
|
||||||
|
command_type: cmd.command_type,
|
||||||
|
status: 'rejected',
|
||||||
|
error_code: 'INVALID_TIMESTAMP',
|
||||||
|
error_message: 'Некорректный формат времени отметки',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const maxPast = 180 * 24 * 60 * 60 * 1000;
|
||||||
|
const maxFuture = 5 * 60 * 1000;
|
||||||
|
const diff = Date.now() - occurredMs;
|
||||||
|
if (diff > maxPast || diff < -maxFuture) {
|
||||||
|
return {
|
||||||
|
idempotency_key: cmd.idempotency_key,
|
||||||
|
command_type: cmd.command_type,
|
||||||
|
status: 'rejected',
|
||||||
|
error_code: 'TIMESTAMP_OUT_OF_WINDOW',
|
||||||
|
error_message: 'Время отметки находится вне допустимого диапазона (-180 дней .. +5 минут)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify employee belongs to tenant and is active
|
||||||
const empCheck = await client.query<{ id: string }>(
|
const empCheck = await client.query<{ id: string }>(
|
||||||
`SELECT id FROM staff_employees WHERE tenant_id = $1 AND id = $2`,
|
`SELECT id FROM staff_employees WHERE tenant_id = $1 AND id = $2 AND status = 'active'`,
|
||||||
[tenantId, employeeId],
|
[tenantId, employeeId],
|
||||||
);
|
);
|
||||||
if (empCheck.rowCount === 0) {
|
if (empCheck.rowCount === 0) {
|
||||||
|
|
@ -797,18 +864,31 @@ export class SyncService {
|
||||||
idempotency_key: cmd.idempotency_key,
|
idempotency_key: cmd.idempotency_key,
|
||||||
command_type: cmd.command_type,
|
command_type: cmd.command_type,
|
||||||
status: 'rejected',
|
status: 'rejected',
|
||||||
error_code: 'EMPLOYEE_NOT_FOUND',
|
error_code: 'EMPLOYEE_INACTIVE',
|
||||||
error_message: 'Сотрудник не найден в данном предприятии',
|
error_message: 'Сотрудник не найден или не активен',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check org_unit_id: payload cannot override foreign device org_unit_id
|
||||||
|
const payloadOrgUnitId = typeof payload.org_unit_id === 'string' ? payload.org_unit_id : null;
|
||||||
|
if (payloadOrgUnitId && device.org_unit_id && payloadOrgUnitId !== device.org_unit_id) {
|
||||||
|
return {
|
||||||
|
idempotency_key: cmd.idempotency_key,
|
||||||
|
command_type: cmd.command_type,
|
||||||
|
status: 'rejected',
|
||||||
|
error_code: 'ORG_UNIT_MISMATCH',
|
||||||
|
error_message: 'Точка в отметке не соответствует подразделению устройства',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const effectiveOrgUnitId = device.org_unit_id ?? payloadOrgUnitId;
|
||||||
|
|
||||||
const eventId = (payload.event_id as string) || newUlid();
|
const eventId = (payload.event_id as string) || newUlid();
|
||||||
const correlationId = cmd.correlation_id || newUlid();
|
const correlationId = cmd.correlation_id || newUlid();
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO staff_attendance_events
|
`INSERT INTO staff_attendance_events
|
||||||
(id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, device_id, actor_id, correlation_id)
|
(id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, device_id, actor_id, correlation_id, org_unit_id)
|
||||||
VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9, $10)
|
||||||
ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING`,
|
ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING`,
|
||||||
[
|
[
|
||||||
eventId,
|
eventId,
|
||||||
|
|
@ -820,6 +900,7 @@ export class SyncService {
|
||||||
device.id,
|
device.id,
|
||||||
device.id,
|
device.id,
|
||||||
correlationId,
|
correlationId,
|
||||||
|
effectiveOrgUnitId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -830,7 +911,7 @@ export class SyncService {
|
||||||
occurred_at: occurredAt,
|
occurred_at: occurredAt,
|
||||||
source_event_id: sourceEventId,
|
source_event_id: sourceEventId,
|
||||||
device_id: device.id,
|
device_id: device.id,
|
||||||
org_unit_id: orgUnitId ? String(orgUnitId) : null,
|
org_unit_id: effectiveOrgUnitId,
|
||||||
status: 'accepted',
|
status: 'accepted',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,12 @@ export async function registerOperationsRoutes(
|
||||||
api.get('/org-units', async (request) => {
|
api.get('/org-units', async (request) => {
|
||||||
const context = await auth.resolve(request);
|
const context = await auth.resolve(request);
|
||||||
const canManage = context.permissions.has('staff:org_unit:read');
|
const canManage = context.permissions.has('staff:org_unit:read');
|
||||||
|
if (!canManage && !context.permissions.has('staff:employee:read')) {
|
||||||
|
throw request.server.httpErrors.forbidden(
|
||||||
|
'Недостаточно прав для просмотра списка подразделений.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
||||||
const rows = await client.query(
|
const rows = await client.query(
|
||||||
`SELECT DISTINCT u.id, u.code, u.name, u.timezone
|
`SELECT DISTINCT u.id, u.code, u.name, u.timezone
|
||||||
|
|
@ -569,18 +575,23 @@ export async function registerOperationsRoutes(
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
required: ['pay_rule_id'],
|
required: ['pay_rule_id'],
|
||||||
properties: {
|
properties: {
|
||||||
org_unit_id: {
|
org_unit_id: {
|
||||||
anyOf: [{ type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, { type: 'null' }],
|
anyOf: [{ type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, { type: 'null' }],
|
||||||
},
|
},
|
||||||
pay_rule_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
pay_rule_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||||
rate_minor: { type: 'integer', minimum: 0 },
|
rate_minor: { type: 'integer', minimum: 0, maximum: 1_000_000_000 },
|
||||||
percent_bp: { type: 'integer', minimum: 0, maximum: 10000 },
|
percent_bp: { type: 'integer', minimum: 0, maximum: 10000 },
|
||||||
guarantee_minor: { type: 'integer', minimum: 0 },
|
guarantee_minor: { type: 'integer', minimum: 0, maximum: 1_000_000_000 },
|
||||||
pool_weight_bp: { type: 'integer', minimum: 0, maximum: 20000 },
|
pool_weight_bp: { type: 'integer', minimum: 0, maximum: 20000 },
|
||||||
effective_from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
|
effective_from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
|
||||||
note: { type: 'string', maxLength: 500 },
|
note: { type: 'string', maxLength: 500 },
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: { type: 'integer', minimum: 0, maximum: 1_000_000_000 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -660,9 +671,18 @@ export async function registerOperationsRoutes(
|
||||||
auth.assertMutationOrigin(request);
|
auth.assertMutationOrigin(request);
|
||||||
const context = await auth.require(request, 'staff:payroll:calculate');
|
const context = await auth.require(request, 'staff:payroll:calculate');
|
||||||
|
|
||||||
|
if (context.role === 'point_manager') {
|
||||||
|
if (!request.body.org_unit_id || !(context.orgUnitIds ?? []).includes(request.body.org_unit_id)) {
|
||||||
|
return reply.forbidden('Менеджер точки может рассчитывать зарплату только для своей точки.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let from: string;
|
let from: string;
|
||||||
let to: string;
|
let to: string;
|
||||||
if (request.body.month) {
|
if (request.body.month) {
|
||||||
|
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(request.body.month)) {
|
||||||
|
return reply.badRequest('Некорректный формат месяца (ожидается YYYY-MM от 01 до 12).');
|
||||||
|
}
|
||||||
from = `${request.body.month}-01`;
|
from = `${request.body.month}-01`;
|
||||||
const parts = request.body.month.split('-').map(Number);
|
const parts = request.body.month.split('-').map(Number);
|
||||||
const year = parts[0]!;
|
const year = parts[0]!;
|
||||||
|
|
@ -773,6 +793,53 @@ export async function registerOperationsRoutes(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
api.get<{ Params: { runId: string } }>(
|
||||||
|
'/payroll/runs/:runId/verify',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['runId'],
|
||||||
|
properties: {
|
||||||
|
runId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const context = await auth.resolve(request);
|
||||||
|
if (!context.permissions.has('staff:payroll:read')) {
|
||||||
|
return reply.forbidden('Недостаточно прав для проверки расчёта.');
|
||||||
|
}
|
||||||
|
const runId = parseUlid(request.params.runId);
|
||||||
|
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
||||||
|
const runRes = await client.query<{ total_accrued_minor: string }>(
|
||||||
|
`SELECT total_accrued_minor::text AS total_accrued_minor
|
||||||
|
FROM staff_payroll_runs
|
||||||
|
WHERE tenant_id = $1 AND id = $2`,
|
||||||
|
[context.tenantId, runId],
|
||||||
|
);
|
||||||
|
if (runRes.rowCount === 0 || !runRes.rows[0]) {
|
||||||
|
return reply.notFound('Расчёт зарплаты не найден.');
|
||||||
|
}
|
||||||
|
const linesRes = await client.query<{ lines_total: string }>(
|
||||||
|
`SELECT COALESCE(SUM(amount_minor), 0)::text AS lines_total
|
||||||
|
FROM staff_payroll_lines
|
||||||
|
WHERE tenant_id = $1 AND run_id = $2`,
|
||||||
|
[context.tenantId, runId],
|
||||||
|
);
|
||||||
|
const storedTotal = Number(runRes.rows[0].total_accrued_minor);
|
||||||
|
const recalculatedTotal = Number(linesRes.rows[0]?.lines_total ?? 0);
|
||||||
|
return reply.send({
|
||||||
|
run_id: runId,
|
||||||
|
stored_total_minor: storedTotal,
|
||||||
|
recalculated_total_minor: recalculatedTotal,
|
||||||
|
matches: storedTotal === recalculatedTotal,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
api.post<{ Body: OwnerWorkbookImportBody }>(
|
api.post<{ Body: OwnerWorkbookImportBody }>(
|
||||||
'/imports/owner-workbook',
|
'/imports/owner-workbook',
|
||||||
{
|
{
|
||||||
|
|
@ -1018,18 +1085,18 @@ export async function registerOperationsRoutes(
|
||||||
const desiredAmount =
|
const desiredAmount =
|
||||||
entry.kind === 'payout' ? -Math.abs(entry.amount_minor) : entry.amount_minor;
|
entry.kind === 'payout' ? -Math.abs(entry.amount_minor) : entry.amount_minor;
|
||||||
const employeeId = employeeIds.get(entry.employee_external_ref);
|
const employeeId = employeeIds.get(entry.employee_external_ref);
|
||||||
const legacyPrefix = `xlsx:${entry.kind}:%`;
|
|
||||||
const currentPrefix = `xlsx:payroll:${entry.kind}:%`;
|
|
||||||
const existing = await client.query<{ amount_minor: string }>(
|
const existing = await client.query<{ amount_minor: string }>(
|
||||||
`SELECT COALESCE(sum(amount_minor), 0)::text AS amount_minor
|
`SELECT COALESCE(sum(amount_minor), 0)::text AS amount_minor
|
||||||
FROM staff_payroll_ledger
|
FROM staff_payroll_ledger
|
||||||
WHERE tenant_id = $1 AND employee_id = $2 AND business_date = $3
|
WHERE tenant_id = $1 AND employee_id = $2
|
||||||
AND (external_key LIKE $4 OR external_key LIKE $5)`,
|
AND (external_key = $3 OR external_key LIKE $3 || ':%')`,
|
||||||
[context.tenantId, employeeId, entry.business_date, legacyPrefix, currentPrefix],
|
[context.tenantId, employeeId, entry.external_key],
|
||||||
);
|
);
|
||||||
const previousAmount = Number(existing.rows[0]?.amount_minor ?? 0);
|
const previousAmount = Number(existing.rows[0]?.amount_minor ?? 0);
|
||||||
const delta = desiredAmount - previousAmount;
|
const delta = desiredAmount - previousAmount;
|
||||||
if (delta === 0) continue;
|
if (delta === 0) continue;
|
||||||
|
const isInitial = previousAmount === 0;
|
||||||
|
const rowKey = isInitial ? entry.external_key : `${entry.external_key}:adj:${newUlid()}`;
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO staff_payroll_ledger
|
`INSERT INTO staff_payroll_ledger
|
||||||
(id, tenant_id, employee_id, business_date, kind, amount_minor,
|
(id, tenant_id, employee_id, business_date, kind, amount_minor,
|
||||||
|
|
@ -1041,12 +1108,12 @@ export async function registerOperationsRoutes(
|
||||||
context.tenantId,
|
context.tenantId,
|
||||||
employeeId,
|
employeeId,
|
||||||
entry.business_date,
|
entry.business_date,
|
||||||
previousAmount === 0 ? entry.kind : 'adjustment',
|
isInitial ? entry.kind : 'adjustment',
|
||||||
delta,
|
delta,
|
||||||
context.correlationId,
|
context.correlationId,
|
||||||
entry.note?.trim() ?? `Импорт: ${body.source_name}`,
|
entry.note?.trim() ?? `Импорт: ${body.source_name}`,
|
||||||
context.actorId,
|
context.actorId,
|
||||||
entry.external_key,
|
rowKey,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1202,7 +1269,8 @@ function validateImport(body: OwnerWorkbookImportBody): boolean {
|
||||||
if (
|
if (
|
||||||
!entry.external_key ||
|
!entry.external_key ||
|
||||||
!parseDate(entry.business_date) ||
|
!parseDate(entry.business_date) ||
|
||||||
!Number.isSafeInteger(entry.amount_minor)
|
!Number.isSafeInteger(entry.amount_minor) ||
|
||||||
|
!['accrual', 'payout', 'adjustment'].includes(entry.kind)
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
863
src/portal.ts
863
src/portal.ts
|
|
@ -10,7 +10,12 @@ import type { Database } from './infrastructure/database.js';
|
||||||
import { reconcileWorkSession } from './domain/attendance/work-session.js';
|
import { reconcileWorkSession } from './domain/attendance/work-session.js';
|
||||||
import { registerOperationsRoutes } from './operations.js';
|
import { registerOperationsRoutes } from './operations.js';
|
||||||
import { resolveVapidKeys } from './domain/notification/web-push.js';
|
import { resolveVapidKeys } from './domain/notification/web-push.js';
|
||||||
import { PushDispatcher } from './domain/notification/push-dispatcher.js';
|
import {
|
||||||
|
PushDispatcher,
|
||||||
|
PushValidationError,
|
||||||
|
PushConflictError,
|
||||||
|
} from './domain/notification/push-dispatcher.js';
|
||||||
|
import { createSession, getSessionToken, revokeSession } from './session.js';
|
||||||
|
|
||||||
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
|
@ -185,6 +190,7 @@ export async function registerPortalRoutes(
|
||||||
return reply.notFound('Сотрудник для текущего пользователя не найден.');
|
return reply.notFound('Сотрудник для текущего пользователя не найден.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const saved = await pushDispatcher.saveSubscription(context.tenantId, employee.id, {
|
const saved = await pushDispatcher.saveSubscription(context.tenantId, employee.id, {
|
||||||
endpoint: request.body.endpoint,
|
endpoint: request.body.endpoint,
|
||||||
p256dh: request.body.keys.p256dh,
|
p256dh: request.body.keys.p256dh,
|
||||||
|
|
@ -192,7 +198,17 @@ export async function registerPortalRoutes(
|
||||||
user_agent: request.body.user_agent,
|
user_agent: request.body.user_agent,
|
||||||
});
|
});
|
||||||
return reply.code(201).send(saved);
|
return reply.code(201).send(saved);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PushConflictError) {
|
||||||
|
return reply.conflict(err.message);
|
||||||
|
}
|
||||||
|
if (err instanceof PushValidationError) {
|
||||||
|
return reply.badRequest(err.message);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
api.delete<{ Body: DeleteSubscriptionBody }>(
|
api.delete<{ Body: DeleteSubscriptionBody }>(
|
||||||
|
|
@ -278,11 +294,12 @@ export async function registerPortalRoutes(
|
||||||
return reply.forbidden('Ключ не подходит для входа в кабинет.');
|
return reply.forbidden('Ключ не подходит для входа в кабинет.');
|
||||||
}
|
}
|
||||||
loginRateLimit.delete(rateLimitKey);
|
loginRateLimit.delete(rateLimitKey);
|
||||||
|
const sessionId = createSession(request.body.access_token, sessionMaxAgeSeconds);
|
||||||
reply
|
reply
|
||||||
.header('cache-control', 'no-store')
|
.header('cache-control', 'no-store')
|
||||||
.header(
|
.header(
|
||||||
'set-cookie',
|
'set-cookie',
|
||||||
serializeSessionCookie(request.body.access_token, sessionMaxAgeSeconds),
|
serializeSessionCookie(sessionId, sessionMaxAgeSeconds),
|
||||||
);
|
);
|
||||||
return { role, expires_in: sessionMaxAgeSeconds };
|
return { role, expires_in: sessionMaxAgeSeconds };
|
||||||
},
|
},
|
||||||
|
|
@ -290,10 +307,15 @@ export async function registerPortalRoutes(
|
||||||
|
|
||||||
api.delete('/session', async (request, reply) => {
|
api.delete('/session', async (request, reply) => {
|
||||||
assertSameOrigin(request);
|
assertSameOrigin(request);
|
||||||
|
const rawCookie = readCookie(request.headers.cookie, sessionCookieName);
|
||||||
|
if (rawCookie) {
|
||||||
|
revokeSession(rawCookie);
|
||||||
|
}
|
||||||
reply.header('cache-control', 'no-store').header('set-cookie', serializeSessionCookie('', 0));
|
reply.header('cache-control', 'no-store').header('set-cookie', serializeSessionCookie('', 0));
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
api.get('/portal', async (request, reply) => {
|
api.get('/portal', async (request, reply) => {
|
||||||
reply.header('cache-control', 'no-store');
|
reply.header('cache-control', 'no-store');
|
||||||
const context = await resolveContext(identity, request);
|
const context = await resolveContext(identity, request);
|
||||||
|
|
@ -356,15 +378,23 @@ export async function registerPortalRoutes(
|
||||||
|
|
||||||
api.get('/professions', async (request) => {
|
api.get('/professions', async (request) => {
|
||||||
const context = await resolveContext(identity, request);
|
const context = await resolveContext(identity, request);
|
||||||
|
if (
|
||||||
|
!context.permissions.has('staff:employee:read') &&
|
||||||
|
!context.permissions.has('staff:employee:write')
|
||||||
|
) {
|
||||||
|
throw request.server.httpErrors.forbidden('Недостаточно прав для чтения списка профессий.');
|
||||||
|
}
|
||||||
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
return database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`SELECT id, code, name, status, created_at, updated_at
|
`SELECT id, code, name, status, created_at, updated_at
|
||||||
FROM staff_professions ORDER BY status, name`,
|
FROM staff_professions WHERE tenant_id = $1 ORDER BY status, name`,
|
||||||
|
[context.tenantId],
|
||||||
);
|
);
|
||||||
return { professions: result.rows };
|
return { professions: result.rows };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
api.post<{ Body: ProfessionBody }>(
|
api.post<{ Body: ProfessionBody }>(
|
||||||
'/professions',
|
'/professions',
|
||||||
{
|
{
|
||||||
|
|
@ -518,6 +548,24 @@ export async function registerPortalRoutes(
|
||||||
context.correlationId,
|
context.correlationId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_sync_change_log
|
||||||
|
(id, tenant_id, entity_type, entity_id, operation, revision, payload)
|
||||||
|
VALUES ($1, $2, 'employee', $3, 'upsert', 1, $4::jsonb)`,
|
||||||
|
[
|
||||||
|
newUlid(),
|
||||||
|
context.tenantId,
|
||||||
|
employeeId,
|
||||||
|
JSON.stringify({
|
||||||
|
id: employeeId,
|
||||||
|
personnel_number: personnelNumber,
|
||||||
|
display_name: displayName,
|
||||||
|
status: 'active',
|
||||||
|
profession_id: result.profession_id ?? null,
|
||||||
|
position: result.position ?? '',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -654,6 +702,24 @@ export async function registerPortalRoutes(
|
||||||
context.correlationId,
|
context.correlationId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_sync_change_log
|
||||||
|
(id, tenant_id, entity_type, entity_id, operation, revision, payload)
|
||||||
|
VALUES ($1, $2, 'employee', $3, 'upsert', 1, $4::jsonb)`,
|
||||||
|
[
|
||||||
|
newUlid(),
|
||||||
|
context.tenantId,
|
||||||
|
employeeId,
|
||||||
|
JSON.stringify({
|
||||||
|
id: employeeId,
|
||||||
|
personnel_number: result.personnel_number,
|
||||||
|
display_name: result.display_name,
|
||||||
|
status: result.status,
|
||||||
|
profession_id: result.profession_id ?? null,
|
||||||
|
position: result.position ?? '',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
return { kind: 'saved' as const, employee: result };
|
return { kind: 'saved' as const, employee: result };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -794,6 +860,28 @@ export async function registerPortalRoutes(
|
||||||
return reply.badRequest('Время отметки выходит за допустимый период пилота.');
|
return reply.badRequest('Время отметки выходит за допустимый период пилота.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const idempotencyKey = (request.headers['idempotency-key'] ||
|
||||||
|
request.headers['Idempotency-Key']) as string | undefined;
|
||||||
|
if (idempotencyKey) {
|
||||||
|
const cached = await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
|
const res = await client.query<{
|
||||||
|
response_status: number;
|
||||||
|
response_body: Record<string, unknown>;
|
||||||
|
}>(
|
||||||
|
`SELECT response_status, response_body FROM staff_idempotency_keys WHERE tenant_id = $1 AND key = $2`,
|
||||||
|
[context.tenantId, idempotencyKey],
|
||||||
|
);
|
||||||
|
return res.rows[0];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (cached) {
|
||||||
|
return reply.code(cached.response_status).send(cached.response_body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await database.tenantTransaction(
|
const result = await database.tenantTransaction(
|
||||||
context.tenantId,
|
context.tenantId,
|
||||||
context.actorId,
|
context.actorId,
|
||||||
|
|
@ -857,11 +945,27 @@ export async function registerPortalRoutes(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.code(result.duplicate ? 200 : 201).send({
|
const responseBody = {
|
||||||
id: result.id,
|
id: result.id,
|
||||||
duplicate: result.duplicate,
|
duplicate: result.duplicate,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (idempotencyKey) {
|
||||||
|
const requestHash = createHash('sha256').update(JSON.stringify(request.body)).digest('hex');
|
||||||
|
await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_idempotency_keys
|
||||||
|
(tenant_id, key, request_hash, response_status, response_body, expires_at)
|
||||||
|
VALUES ($1, $2, $3, 200, $4, now() + interval '24 hours')
|
||||||
|
ON CONFLICT (tenant_id, key) DO NOTHING`,
|
||||||
|
[context.tenantId, idempotencyKey, requestHash, JSON.stringify(responseBody)],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(result.duplicate ? 200 : 201).send(responseBody);
|
||||||
},
|
},
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
api.post<{ Body: ReconcileSessionsBody }>(
|
api.post<{ Body: ReconcileSessionsBody }>(
|
||||||
|
|
@ -1079,6 +1183,7 @@ export async function registerPortalRoutes(
|
||||||
);
|
);
|
||||||
if (currentQuery.rowCount === 0) return { kind: 'missing' as const };
|
if (currentQuery.rowCount === 0) return { kind: 'missing' as const };
|
||||||
const current = currentQuery.rows[0] as Record<string, unknown>;
|
const current = currentQuery.rows[0] as Record<string, unknown>;
|
||||||
|
|
||||||
const currentStartedAt = parseDatabaseOptionalInstant(current.started_at);
|
const currentStartedAt = parseDatabaseOptionalInstant(current.started_at);
|
||||||
const currentEndedAt = parseDatabaseOptionalInstant(current.ended_at);
|
const currentEndedAt = parseDatabaseOptionalInstant(current.ended_at);
|
||||||
const startedAt =
|
const startedAt =
|
||||||
|
|
@ -1099,6 +1204,22 @@ export async function registerPortalRoutes(
|
||||||
if (startedAt && endedAt && new Date(endedAt) < new Date(startedAt)) {
|
if (startedAt && endedAt && new Date(endedAt) < new Date(startedAt)) {
|
||||||
return { kind: 'invalid_interval' as const };
|
return { kind: 'invalid_interval' as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lockedCheck = await client.query(
|
||||||
|
`SELECT 1 FROM staff_payroll_lines pl
|
||||||
|
JOIN staff_payroll_runs pr ON pr.tenant_id = pl.tenant_id AND pr.id = pl.run_id
|
||||||
|
WHERE pl.tenant_id = $1 AND pl.employee_id = $2 AND pl.org_unit_id = $3
|
||||||
|
AND pl.business_date = $4 AND pr.status IN ('approved', 'paid')
|
||||||
|
UNION ALL
|
||||||
|
SELECT 1 FROM staff_payroll_batch_lines bl
|
||||||
|
JOIN staff_payroll_batches pb ON pb.tenant_id = bl.tenant_id AND pb.id = bl.batch_id
|
||||||
|
WHERE bl.tenant_id = $1 AND bl.work_session_id = $5 AND pb.status = 'signed'
|
||||||
|
LIMIT 1`,
|
||||||
|
[context.tenantId, current.employee_id, current.org_unit_id, current.business_date, sessionId],
|
||||||
|
);
|
||||||
|
if (lockedCheck.rowCount && lockedCheck.rowCount > 0) {
|
||||||
|
return { kind: 'locked' as const };
|
||||||
|
}
|
||||||
const saved = await client.query(
|
const saved = await client.query(
|
||||||
`UPDATE staff_work_sessions SET
|
`UPDATE staff_work_sessions SET
|
||||||
started_at = $3, ended_at = $4, worked_minutes = $5, status = $6,
|
started_at = $3, ended_at = $4, worked_minutes = $5, status = $6,
|
||||||
|
|
@ -1177,6 +1298,9 @@ export async function registerPortalRoutes(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (outcome.kind === 'missing') return reply.notFound('Смена не найдена.');
|
if (outcome.kind === 'missing') return reply.notFound('Смена не найдена.');
|
||||||
|
if (outcome.kind === 'locked') {
|
||||||
|
return reply.conflict('Смена входит в утверждённую ведомость или подписанный пакет и не может быть изменена.');
|
||||||
|
}
|
||||||
if (outcome.kind === 'note_required') {
|
if (outcome.kind === 'note_required') {
|
||||||
return reply.badRequest('Для ручной корректировки смены укажите причину.');
|
return reply.badRequest('Для ручной корректировки смены укажите причину.');
|
||||||
}
|
}
|
||||||
|
|
@ -1268,6 +1392,650 @@ export async function registerPortalRoutes(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
api.post<{
|
||||||
|
Body: {
|
||||||
|
role: 'point_manager' | 'accountant' | 'director';
|
||||||
|
org_unit_ids?: string[];
|
||||||
|
display_name: string;
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
'/access-grants',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['role', 'display_name'],
|
||||||
|
properties: {
|
||||||
|
role: { enum: ['point_manager', 'accountant', 'director'] },
|
||||||
|
org_unit_ids: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||||
|
},
|
||||||
|
display_name: { type: 'string', minLength: 1, maxLength: 240 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
assertCookieMutationOrigin(request);
|
||||||
|
const context = await requirePermission(identity, request, 'staff:employee:write');
|
||||||
|
const grantId = newUlid();
|
||||||
|
const rawToken = randomBytes(32).toString('hex');
|
||||||
|
const tokenHash = createHash('sha256').update(rawToken, 'utf8').digest('hex');
|
||||||
|
const tokenHint = `${rawToken.slice(0, 4)}...${rawToken.slice(-4)}`;
|
||||||
|
const orgUnitIds = request.body.org_unit_ids ?? [];
|
||||||
|
|
||||||
|
await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_access_grants
|
||||||
|
(id, tenant_id, role, org_unit_ids, display_name, token_hash, token_hint, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8)`,
|
||||||
|
[
|
||||||
|
grantId,
|
||||||
|
context.tenantId,
|
||||||
|
request.body.role,
|
||||||
|
JSON.stringify(orgUnitIds),
|
||||||
|
request.body.display_name.trim(),
|
||||||
|
tokenHash,
|
||||||
|
tokenHint,
|
||||||
|
context.actorId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.code(201).send({
|
||||||
|
id: grantId,
|
||||||
|
role: request.body.role,
|
||||||
|
org_unit_ids: orgUnitIds,
|
||||||
|
access_token: rawToken,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
api.put<{
|
||||||
|
Params: { employeeId: string };
|
||||||
|
Body: { status: 'granted' | 'revoked'; document_ref?: string };
|
||||||
|
}>(
|
||||||
|
'/employees/:employeeId/biometric-consent',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['employeeId'],
|
||||||
|
properties: {
|
||||||
|
employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['status'],
|
||||||
|
properties: {
|
||||||
|
status: { enum: ['granted', 'revoked'] },
|
||||||
|
document_ref: { type: 'string', maxLength: 1000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
assertCookieMutationOrigin(request);
|
||||||
|
const context = await requirePermission(identity, request, 'staff:employee:write');
|
||||||
|
const employeeId = parseUlid(request.params.employeeId);
|
||||||
|
|
||||||
|
await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_biometric_consents
|
||||||
|
(tenant_id, employee_id, status, document_ref, updated_by, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, now())
|
||||||
|
ON CONFLICT (tenant_id, employee_id) DO UPDATE SET
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
document_ref = EXCLUDED.document_ref,
|
||||||
|
updated_by = EXCLUDED.updated_by,
|
||||||
|
updated_at = now()`,
|
||||||
|
[
|
||||||
|
context.tenantId,
|
||||||
|
employeeId,
|
||||||
|
request.body.status,
|
||||||
|
request.body.document_ref ?? null,
|
||||||
|
context.actorId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.send({ status: request.body.status });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
api.get<{ Params: { employeeId: string } }>(
|
||||||
|
'/terminal/employees/:employeeId/enrollment',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['employeeId'],
|
||||||
|
properties: {
|
||||||
|
employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const context = await resolveContext(identity, request);
|
||||||
|
const employeeId = parseUlid(request.params.employeeId);
|
||||||
|
|
||||||
|
const result = await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
|
const row = await client.query<{ status: string }>(
|
||||||
|
`SELECT status FROM staff_biometric_consents
|
||||||
|
WHERE tenant_id = $1 AND employee_id = $2`,
|
||||||
|
[context.tenantId, employeeId],
|
||||||
|
);
|
||||||
|
if (row.rowCount === 0 || !row.rows[0]) {
|
||||||
|
return {
|
||||||
|
employee_id: employeeId,
|
||||||
|
registration_allowed: false,
|
||||||
|
reason: 'consent_missing',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const status = row.rows[0].status;
|
||||||
|
if (status === 'granted') {
|
||||||
|
return {
|
||||||
|
employee_id: employeeId,
|
||||||
|
registration_allowed: true,
|
||||||
|
reason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
employee_id: employeeId,
|
||||||
|
registration_allowed: false,
|
||||||
|
reason: 'consent_revoked',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.send(result);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
api.post<{
|
||||||
|
Body: {
|
||||||
|
org_unit_id: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
'/payroll/batches',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['org_unit_id', 'from', 'to'],
|
||||||
|
properties: {
|
||||||
|
org_unit_id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||||
|
from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
|
||||||
|
to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
assertCookieMutationOrigin(request);
|
||||||
|
const context = await resolveContext(identity, request);
|
||||||
|
const orgUnitId = parseUlid(request.body.org_unit_id);
|
||||||
|
|
||||||
|
if (context.role === 'point_manager') {
|
||||||
|
if (!(context.orgUnitIds ?? []).includes(orgUnitId)) {
|
||||||
|
return reply.forbidden('Менеджер точки может формировать пакет только для своей точки.');
|
||||||
|
}
|
||||||
|
} else if (context.role !== 'accountant' && !context.permissions.has('staff:payroll:calculate')) {
|
||||||
|
return reply.forbidden('Недостаточно прав для формирования зарплатного пакета.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchId = newUlid();
|
||||||
|
const outcome = await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
|
const sessionsRes = await client.query<{
|
||||||
|
id: string;
|
||||||
|
employee_id: string;
|
||||||
|
org_unit_id: string;
|
||||||
|
business_date: string;
|
||||||
|
worked_minutes: number;
|
||||||
|
revision: number;
|
||||||
|
}>(
|
||||||
|
`SELECT id, employee_id, org_unit_id, business_date::text AS business_date, worked_minutes, revision
|
||||||
|
FROM staff_work_sessions
|
||||||
|
WHERE tenant_id = $1 AND org_unit_id = $2
|
||||||
|
AND business_date BETWEEN $3 AND $4
|
||||||
|
AND status = 'confirmed'
|
||||||
|
ORDER BY business_date, employee_id`,
|
||||||
|
[context.tenantId, orgUnitId, request.body.from, request.body.to],
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_payroll_batches
|
||||||
|
(id, tenant_id, org_unit_id, period_start, period_end, status, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'draft', $6)`,
|
||||||
|
[batchId, context.tenantId, orgUnitId, request.body.from, request.body.to, context.actorId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const lines: Array<{
|
||||||
|
id: string;
|
||||||
|
work_session_id: string;
|
||||||
|
employee_id: string;
|
||||||
|
org_unit_id: string;
|
||||||
|
amount_minor: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const session of sessionsRes.rows) {
|
||||||
|
const assignRes = await client.query<{
|
||||||
|
params: Record<string, unknown> | string | null;
|
||||||
|
pool_weight_bp: number | null;
|
||||||
|
}>(
|
||||||
|
`SELECT params, pool_weight_bp
|
||||||
|
FROM staff_pay_assignments
|
||||||
|
WHERE tenant_id = $1 AND employee_id = $2
|
||||||
|
AND effective_from <= $3
|
||||||
|
AND (effective_to IS NULL OR effective_to >= $3)
|
||||||
|
AND (org_unit_id IS NULL OR org_unit_id = $4)
|
||||||
|
ORDER BY (org_unit_id IS NOT NULL) DESC, effective_from DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[context.tenantId, session.employee_id, session.business_date, session.org_unit_id],
|
||||||
|
);
|
||||||
|
const rawParamsRecord: Record<string, unknown> =
|
||||||
|
typeof assignRes.rows[0]?.params === 'string'
|
||||||
|
? (JSON.parse(assignRes.rows[0].params) as Record<string, unknown>)
|
||||||
|
: (assignRes.rows[0]?.params as Record<string, unknown> | undefined) ?? {};
|
||||||
|
let amount = 0;
|
||||||
|
const shiftRate =
|
||||||
|
typeof rawParamsRecord.shift_rate_minor === 'number'
|
||||||
|
? rawParamsRecord.shift_rate_minor
|
||||||
|
: typeof rawParamsRecord.shift_rate_minor === 'string'
|
||||||
|
? Number(rawParamsRecord.shift_rate_minor)
|
||||||
|
: undefined;
|
||||||
|
const rateMinor =
|
||||||
|
typeof rawParamsRecord.rate_minor === 'number'
|
||||||
|
? rawParamsRecord.rate_minor
|
||||||
|
: typeof rawParamsRecord.rate_minor === 'string'
|
||||||
|
? Number(rawParamsRecord.rate_minor)
|
||||||
|
: undefined;
|
||||||
|
if (shiftRate !== undefined && !Number.isNaN(shiftRate)) {
|
||||||
|
amount = shiftRate;
|
||||||
|
} else if (rateMinor !== undefined && !Number.isNaN(rateMinor)) {
|
||||||
|
amount = Math.round((session.worked_minutes / 60) * rateMinor);
|
||||||
|
} else {
|
||||||
|
amount = Math.round((session.worked_minutes / 60) * 10000);
|
||||||
|
}
|
||||||
|
amount = Math.round(amount / 100) * 100;
|
||||||
|
|
||||||
|
const lineId = newUlid();
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_payroll_batch_lines
|
||||||
|
(id, tenant_id, batch_id, work_session_id, employee_id, org_unit_id, amount_minor, session_revision)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[
|
||||||
|
lineId,
|
||||||
|
context.tenantId,
|
||||||
|
batchId,
|
||||||
|
session.id,
|
||||||
|
session.employee_id,
|
||||||
|
session.org_unit_id,
|
||||||
|
amount,
|
||||||
|
session.revision ?? 1,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
lines.push({
|
||||||
|
id: lineId,
|
||||||
|
work_session_id: session.id,
|
||||||
|
employee_id: session.employee_id,
|
||||||
|
org_unit_id: session.org_unit_id,
|
||||||
|
amount_minor: amount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: batchId,
|
||||||
|
status: 'draft',
|
||||||
|
org_unit_id: orgUnitId,
|
||||||
|
lines,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.code(201).send(outcome);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
api.post<{ Params: { id: string } }>(
|
||||||
|
'/payroll/batches/:id/submit',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
assertCookieMutationOrigin(request);
|
||||||
|
const context = await resolveContext(identity, request);
|
||||||
|
if (context.role !== 'accountant') {
|
||||||
|
return reply.forbidden('Только бухгалтер может отправлять зарплатный пакет на согласование.');
|
||||||
|
}
|
||||||
|
const batchId = parseUlid(request.params.id);
|
||||||
|
|
||||||
|
const updated = await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
|
const current = await client.query<{ status: string }>(
|
||||||
|
`SELECT status FROM staff_payroll_batches WHERE tenant_id = $1 AND id = $2 FOR UPDATE`,
|
||||||
|
[context.tenantId, batchId],
|
||||||
|
);
|
||||||
|
if (current.rowCount === 0 || !current.rows[0]) return { kind: 'not_found' as const };
|
||||||
|
if (current.rows[0].status === 'signed') return { kind: 'already_signed' as const };
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE staff_payroll_batches
|
||||||
|
SET status = 'submitted', submitted_at = now(), submitted_by = $3, updated_at = now()
|
||||||
|
WHERE tenant_id = $1 AND id = $2`,
|
||||||
|
[context.tenantId, batchId, context.actorId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const directorsRes = await client.query<{ id: string }>(
|
||||||
|
`SELECT id FROM staff_access_grants
|
||||||
|
WHERE tenant_id = $1 AND role = 'director' AND is_active = true`,
|
||||||
|
[context.tenantId],
|
||||||
|
);
|
||||||
|
for (const d of directorsRes.rows) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_batch_notifications
|
||||||
|
(id, tenant_id, recipient_role, recipient_actor_id, type, subject_id, payload)
|
||||||
|
VALUES ($1, $2, 'director', $3, 'payroll.batch.submitted', $4, '{}'::jsonb)`,
|
||||||
|
[newUlid(), context.tenantId, d.id, batchId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'ok' as const };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updated.kind === 'not_found') return reply.notFound('Пакет не найден.');
|
||||||
|
if (updated.kind === 'already_signed') {
|
||||||
|
return reply.conflict('Подписанный пакет не может быть отправлен повторно.');
|
||||||
|
}
|
||||||
|
return reply.send({ status: 'submitted' });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
api.get(
|
||||||
|
'/notifications',
|
||||||
|
async (request, reply) => {
|
||||||
|
const context = await resolveContext(identity, request);
|
||||||
|
const rows = await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
|
const res = await client.query<{
|
||||||
|
type: string;
|
||||||
|
subject_id: string;
|
||||||
|
payload: unknown;
|
||||||
|
created_at: Date;
|
||||||
|
}>(
|
||||||
|
`SELECT type, subject_id, payload, created_at
|
||||||
|
FROM staff_batch_notifications
|
||||||
|
WHERE tenant_id = $1 AND (recipient_actor_id = $2 OR recipient_role = $3)
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
[context.tenantId, context.actorId, context.role ?? ''],
|
||||||
|
);
|
||||||
|
return res.rows;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return reply.send({ notifications: rows });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
api.post<{ Params: { id: string } }>(
|
||||||
|
'/payroll/batches/:id/sign',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
assertCookieMutationOrigin(request);
|
||||||
|
const context = await resolveContext(identity, request);
|
||||||
|
if (context.role !== 'director') {
|
||||||
|
return reply.forbidden('Только директор может подписывать зарплатный пакет.');
|
||||||
|
}
|
||||||
|
const batchId = parseUlid(request.params.id);
|
||||||
|
|
||||||
|
const signOutcome = await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
|
const batchRes = await client.query<{
|
||||||
|
status: string;
|
||||||
|
period_end: Date;
|
||||||
|
submitted_at: Date | null;
|
||||||
|
}>(
|
||||||
|
`SELECT status, period_end, submitted_at
|
||||||
|
FROM staff_payroll_batches
|
||||||
|
WHERE tenant_id = $1 AND id = $2 FOR UPDATE`,
|
||||||
|
[context.tenantId, batchId],
|
||||||
|
);
|
||||||
|
if (batchRes.rowCount === 0 || !batchRes.rows[0]) return { kind: 'not_found' as const };
|
||||||
|
const batch = batchRes.rows[0];
|
||||||
|
|
||||||
|
if (batch.status === 'signed') return { kind: 'already_signed' as const };
|
||||||
|
|
||||||
|
if (batch.submitted_at) {
|
||||||
|
const modifiedRes = await client.query(
|
||||||
|
`SELECT 1 FROM staff_payroll_batch_lines bl
|
||||||
|
JOIN staff_work_sessions ws ON ws.tenant_id = bl.tenant_id AND ws.id = bl.work_session_id
|
||||||
|
WHERE bl.tenant_id = $1 AND bl.batch_id = $2 AND (ws.revision > bl.session_revision OR ws.updated_at > $3)
|
||||||
|
LIMIT 1`,
|
||||||
|
[context.tenantId, batchId, batch.submitted_at],
|
||||||
|
);
|
||||||
|
if (modifiedRes.rowCount && modifiedRes.rowCount > 0) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE staff_payroll_batches
|
||||||
|
SET status = 'returned', return_comment = 'Смена была изменена после отправки пакета', updated_at = now()
|
||||||
|
WHERE tenant_id = $1 AND id = $2`,
|
||||||
|
[context.tenantId, batchId],
|
||||||
|
);
|
||||||
|
return { kind: 'session_modified' as const };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE staff_payroll_batches
|
||||||
|
SET status = 'signed', signed_at = now(), signed_by = $3, updated_at = now()
|
||||||
|
WHERE tenant_id = $1 AND id = $2`,
|
||||||
|
[context.tenantId, batchId, context.actorId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const linesRes = await client.query<{
|
||||||
|
work_session_id: string;
|
||||||
|
employee_id: string;
|
||||||
|
org_unit_id: string;
|
||||||
|
amount_minor: string;
|
||||||
|
}>(
|
||||||
|
`SELECT work_session_id, employee_id, org_unit_id, amount_minor::text AS amount_minor
|
||||||
|
FROM staff_payroll_batch_lines
|
||||||
|
WHERE tenant_id = $1 AND batch_id = $2`,
|
||||||
|
[context.tenantId, batchId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const periodEndDate = batch.period_end.toISOString().slice(0, 10);
|
||||||
|
for (const line of linesRes.rows) {
|
||||||
|
const amount = BigInt(line.amount_minor);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_payroll_ledger
|
||||||
|
(id, tenant_id, employee_id, business_date, kind, amount_minor, currency_code,
|
||||||
|
ref_type, ref_id, correlation_id, description, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, 'accrual', $5, 'RUB', 'payroll_run', $6, $7, 'Начисление по пакету', $8)`,
|
||||||
|
[newUlid(), context.tenantId, line.employee_id, periodEndDate, amount, batchId, context.correlationId, context.actorId],
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_payroll_ledger
|
||||||
|
(id, tenant_id, employee_id, business_date, kind, amount_minor, currency_code,
|
||||||
|
ref_type, ref_id, correlation_id, description, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, 'payout', $5, 'RUB', 'payout', $6, $7, 'Выплата по пакету', $8)`,
|
||||||
|
[newUlid(), context.tenantId, line.employee_id, periodEndDate, -amount, batchId, context.correlationId, context.actorId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'ok' as const };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signOutcome.kind === 'not_found') return reply.notFound('Пакет не найден.');
|
||||||
|
if (signOutcome.kind === 'session_modified') {
|
||||||
|
return reply.conflict('Смена пакета была изменена после отправки; пакет возвращён.');
|
||||||
|
}
|
||||||
|
return reply.send({ status: 'signed' });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
api.post<{ Params: { id: string }; Body: { comment?: string } }>(
|
||||||
|
'/payroll/batches/:id/return',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
comment: { type: 'string', maxLength: 1000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
assertCookieMutationOrigin(request);
|
||||||
|
const context = await resolveContext(identity, request);
|
||||||
|
if (context.role !== 'director') {
|
||||||
|
return reply.forbidden('Только директор может возвращать зарплатный пакет.');
|
||||||
|
}
|
||||||
|
const batchId = parseUlid(request.params.id);
|
||||||
|
const comment = request.body.comment?.trim() ?? '';
|
||||||
|
|
||||||
|
const updated = await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
|
const res = await client.query(
|
||||||
|
`UPDATE staff_payroll_batches
|
||||||
|
SET status = 'returned', return_comment = $3, updated_at = now()
|
||||||
|
WHERE tenant_id = $1 AND id = $2
|
||||||
|
RETURNING status, return_comment`,
|
||||||
|
[context.tenantId, batchId, comment],
|
||||||
|
);
|
||||||
|
return res.rowCount && res.rowCount > 0;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updated) return reply.notFound('Пакет не найден.');
|
||||||
|
return reply.send({ status: 'returned', return_comment: comment });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
api.get<{ Params: { id: string } }>(
|
||||||
|
'/payroll/batches/:id',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const context = await resolveContext(identity, request);
|
||||||
|
const batchId = parseUlid(request.params.id);
|
||||||
|
|
||||||
|
const batch = await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
|
const res = await client.query<{
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
org_unit_id: string;
|
||||||
|
period_start: Date;
|
||||||
|
period_end: Date;
|
||||||
|
return_comment: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT id, status, org_unit_id, period_start, period_end, return_comment
|
||||||
|
FROM staff_payroll_batches
|
||||||
|
WHERE tenant_id = $1 AND id = $2`,
|
||||||
|
[context.tenantId, batchId],
|
||||||
|
);
|
||||||
|
if (res.rowCount === 0 || !res.rows[0]) return null;
|
||||||
|
|
||||||
|
const linesRes = await client.query<{
|
||||||
|
work_session_id: string;
|
||||||
|
employee_id: string;
|
||||||
|
org_unit_id: string;
|
||||||
|
amount_minor: string;
|
||||||
|
}>(
|
||||||
|
`SELECT work_session_id, employee_id, org_unit_id, amount_minor::text AS amount_minor
|
||||||
|
FROM staff_payroll_batch_lines
|
||||||
|
WHERE tenant_id = $1 AND batch_id = $2`,
|
||||||
|
[context.tenantId, batchId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: res.rows[0].id,
|
||||||
|
status: res.rows[0].status,
|
||||||
|
org_unit_id: res.rows[0].org_unit_id,
|
||||||
|
period_start: res.rows[0].period_start.toISOString().slice(0, 10),
|
||||||
|
period_end: res.rows[0].period_end.toISOString().slice(0, 10),
|
||||||
|
return_comment: res.rows[0].return_comment,
|
||||||
|
lines: linesRes.rows.map((l) => ({
|
||||||
|
...l,
|
||||||
|
amount_minor: Number(l.amount_minor),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!batch) return reply.notFound('Пакет не найден.');
|
||||||
|
return reply.send(batch);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await registerOperationsRoutes(api, database, {
|
await registerOperationsRoutes(api, database, {
|
||||||
resolve: (request) => resolveContext(identity, request),
|
resolve: (request) => resolveContext(identity, request),
|
||||||
require: (request, permission) => requirePermission(identity, request, permission),
|
require: (request, permission) => requirePermission(identity, request, permission),
|
||||||
|
|
@ -1321,6 +2089,67 @@ function withStandaloneEmployeeAccess(
|
||||||
const tenantId = parseUlid(config.STANDALONE_TENANT_ID);
|
const tenantId = parseUlid(config.STANDALONE_TENANT_ID);
|
||||||
const serviceActorId = parseUlid(config.STANDALONE_ACTOR_ID);
|
const serviceActorId = parseUlid(config.STANDALONE_ACTOR_ID);
|
||||||
const tokenHash = hashAccessToken(token);
|
const tokenHash = hashAccessToken(token);
|
||||||
|
const grant = await database.tenantTransaction(tenantId, serviceActorId, async (client) => {
|
||||||
|
const grantRes = await client.query<{
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
org_unit_ids: unknown;
|
||||||
|
display_name: string;
|
||||||
|
}>(
|
||||||
|
`SELECT id, role, org_unit_ids, display_name
|
||||||
|
FROM staff_access_grants
|
||||||
|
WHERE tenant_id = $1 AND token_hash = $2 AND is_active = true`,
|
||||||
|
[tenantId, tokenHash],
|
||||||
|
);
|
||||||
|
return grantRes.rows[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (grant) {
|
||||||
|
const permissions = new Set<string>([
|
||||||
|
'staff:health:read',
|
||||||
|
'staff:notification:read',
|
||||||
|
]);
|
||||||
|
const rawOrgUnitIds = grant.org_unit_ids;
|
||||||
|
const orgUnitIds: string[] = Array.isArray(rawOrgUnitIds)
|
||||||
|
? (rawOrgUnitIds as string[])
|
||||||
|
: typeof rawOrgUnitIds === 'string'
|
||||||
|
? (JSON.parse(rawOrgUnitIds) as string[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (grant.role === 'point_manager') {
|
||||||
|
permissions.add('staff:payroll:calculate');
|
||||||
|
permissions.add('staff:payroll:read');
|
||||||
|
permissions.add('staff:attendance:read_own');
|
||||||
|
permissions.add('staff:attendance:manage');
|
||||||
|
permissions.add('staff:attendance:write');
|
||||||
|
permissions.add('staff:shift:read');
|
||||||
|
permissions.add('staff:shift:write');
|
||||||
|
permissions.add('staff:timesheet:read');
|
||||||
|
permissions.add('staff:timesheet:write');
|
||||||
|
} else if (grant.role === 'accountant') {
|
||||||
|
permissions.add('staff:payroll:calculate');
|
||||||
|
permissions.add('staff:payroll:read');
|
||||||
|
permissions.add('staff:payroll:write');
|
||||||
|
permissions.add('staff:payroll:approve');
|
||||||
|
permissions.add('staff:attendance:manage');
|
||||||
|
permissions.add('staff:attendance:read_own');
|
||||||
|
permissions.add('staff:shift:read');
|
||||||
|
permissions.add('staff:timesheet:read');
|
||||||
|
} else if (grant.role === 'director') {
|
||||||
|
permissions.add('staff:payroll:read');
|
||||||
|
permissions.add('staff:payroll:approve');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenantId,
|
||||||
|
actorId: parseUlid(grant.id),
|
||||||
|
role: grant.role,
|
||||||
|
orgUnitIds,
|
||||||
|
permissions,
|
||||||
|
correlationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const row = await database.tenantTransaction(tenantId, serviceActorId, async (client) => {
|
const row = await database.tenantTransaction(tenantId, serviceActorId, async (client) => {
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`SELECT access.subject_id
|
`SELECT access.subject_id
|
||||||
|
|
@ -1360,9 +2189,15 @@ async function resolveContext(
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
): Promise<RequestContext> {
|
): Promise<RequestContext> {
|
||||||
const authorization = request.headers.authorization;
|
const authorization = request.headers.authorization;
|
||||||
const token = authorization?.startsWith('Bearer ')
|
let token: string | undefined;
|
||||||
? authorization.slice(7)
|
if (authorization?.startsWith('Bearer ')) {
|
||||||
: readCookie(request.headers.cookie, sessionCookieName);
|
token = authorization.slice(7).trim();
|
||||||
|
} else {
|
||||||
|
const rawCookie = readCookie(request.headers.cookie, sessionCookieName);
|
||||||
|
if (rawCookie) {
|
||||||
|
token = getSessionToken(rawCookie);
|
||||||
|
}
|
||||||
|
}
|
||||||
return resolveToken(identity, token, request);
|
return resolveToken(identity, token, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1401,7 +2236,7 @@ function assertSameOrigin(request: FastifyRequest) {
|
||||||
if (!origin) return;
|
if (!origin) return;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(origin);
|
const parsed = new URL(origin);
|
||||||
if (parsed.protocol !== 'https:' || parsed.host !== request.headers.host) throw new Error();
|
if (parsed.host !== request.headers.host) throw new Error();
|
||||||
} catch {
|
} catch {
|
||||||
throw request.server.httpErrors.forbidden('Запрос с постороннего сайта отклонён.');
|
throw request.server.httpErrors.forbidden('Запрос с постороннего сайта отклонён.');
|
||||||
}
|
}
|
||||||
|
|
@ -1409,10 +2244,20 @@ function assertSameOrigin(request: FastifyRequest) {
|
||||||
|
|
||||||
function assertCookieMutationOrigin(request: FastifyRequest) {
|
function assertCookieMutationOrigin(request: FastifyRequest) {
|
||||||
if (!request.headers.authorization && readCookie(request.headers.cookie, sessionCookieName)) {
|
if (!request.headers.authorization && readCookie(request.headers.cookie, sessionCookieName)) {
|
||||||
assertSameOrigin(request);
|
const origin = request.headers.origin;
|
||||||
|
if (!origin) {
|
||||||
|
throw request.server.httpErrors.forbidden('Запрос с постороннего сайта отклонён.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(origin);
|
||||||
|
if (parsed.host !== request.headers.host) throw new Error();
|
||||||
|
} catch {
|
||||||
|
throw request.server.httpErrors.forbidden('Запрос с постороннего сайта отклонён.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function requirePermission(
|
async function requirePermission(
|
||||||
identity: IdentityPort,
|
identity: IdentityPort,
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
|
|
|
||||||
31
src/session.ts
Normal file
31
src/session.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
export interface SessionData {
|
||||||
|
token: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionStore = new Map<string, SessionData>();
|
||||||
|
|
||||||
|
export function createSession(token: string, maxAgeSeconds: number): string {
|
||||||
|
const sessionId = randomBytes(32).toString('hex');
|
||||||
|
sessionStore.set(sessionId, {
|
||||||
|
token,
|
||||||
|
expiresAt: Date.now() + maxAgeSeconds * 1000,
|
||||||
|
});
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionToken(sessionId: string): string | undefined {
|
||||||
|
const session = sessionStore.get(sessionId);
|
||||||
|
if (!session) return undefined;
|
||||||
|
if (session.expiresAt <= Date.now()) {
|
||||||
|
sessionStore.delete(sessionId);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return session.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeSession(sessionId: string): void {
|
||||||
|
sessionStore.delete(sessionId);
|
||||||
|
}
|
||||||
24
src/sync.ts
24
src/sync.ts
|
|
@ -7,6 +7,7 @@ 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 { InvalidCursorError } from './domain/sync/cursor.js';
|
||||||
import type { SyncCommand } from './domain/sync/types.js';
|
import type { SyncCommand } from './domain/sync/types.js';
|
||||||
|
import { getSessionToken } from './session.js';
|
||||||
|
|
||||||
interface PullBody {
|
interface PullBody {
|
||||||
cursor?: string | null;
|
cursor?: string | null;
|
||||||
|
|
@ -67,7 +68,23 @@ export async function registerSyncRoutes(
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function assertCookieMutationOrigin(request: FastifyRequest) {
|
||||||
|
const authorization = request.headers.authorization;
|
||||||
|
if (authorization?.startsWith('Bearer ')) return;
|
||||||
|
const origin = request.headers.origin;
|
||||||
|
if (!origin) {
|
||||||
|
throw request.server.httpErrors.forbidden('Запрос без Origin отклонён.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(origin);
|
||||||
|
if (parsed.host !== request.headers.host) throw new Error();
|
||||||
|
} catch {
|
||||||
|
throw request.server.httpErrors.forbidden('Запрос с постороннего сайта отклонён.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const requireManagerContext = async (request: FastifyRequest) => {
|
const requireManagerContext = async (request: FastifyRequest) => {
|
||||||
|
assertCookieMutationOrigin(request);
|
||||||
const authHeader = request.headers.authorization;
|
const authHeader = request.headers.authorization;
|
||||||
const cookieHeader = request.headers.cookie;
|
const cookieHeader = request.headers.cookie;
|
||||||
const sessionCookieName = '__Host-krona_staff_session';
|
const sessionCookieName = '__Host-krona_staff_session';
|
||||||
|
|
@ -78,7 +95,8 @@ export async function registerSyncRoutes(
|
||||||
const sep = part.indexOf('=');
|
const sep = part.indexOf('=');
|
||||||
if (sep > 0 && part.slice(0, sep).trim() === sessionCookieName) {
|
if (sep > 0 && part.slice(0, sep).trim() === sessionCookieName) {
|
||||||
try {
|
try {
|
||||||
token = decodeURIComponent(part.slice(sep + 1).trim());
|
const rawSessionId = decodeURIComponent(part.slice(sep + 1).trim());
|
||||||
|
token = getSessionToken(rawSessionId);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed cookie
|
// ignore malformed cookie
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +105,10 @@ export async function registerSyncRoutes(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw request.server.httpErrors.unauthorized('Требуется действующий ключ доступа руководителя');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const context = await baseIdentity.resolve(token, parseUlid(request.id));
|
const context = await baseIdentity.resolve(token, parseUlid(request.id));
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,7 @@ async function seed(tenant: string) {
|
||||||
[randomUlid(), tenant, emp, rules[i], '2026-01-01', actor],
|
[randomUlid(), tenant, emp, rules[i], '2026-01-01', actor],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const seedSessionIds: string[] = [];
|
||||||
for (const [i, emp] of employees.slice(0, 2).entries()) {
|
for (const [i, emp] of employees.slice(0, 2).entries()) {
|
||||||
await q(
|
await q(
|
||||||
'staff_attendance_events',
|
'staff_attendance_events',
|
||||||
|
|
@ -231,11 +232,14 @@ async function seed(tenant: string) {
|
||||||
corr(),
|
corr(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
const sid = randomUlid();
|
||||||
|
seedSessionIds.push(sid);
|
||||||
await q(
|
await q(
|
||||||
'staff_work_sessions',
|
'staff_work_sessions',
|
||||||
'INSERT INTO staff_work_sessions (id, tenant_id, employee_id, org_unit_id, business_date) VALUES ($1,$2,$3,$4,$5)',
|
'INSERT INTO staff_work_sessions (id, tenant_id, employee_id, org_unit_id, business_date) VALUES ($1,$2,$3,$4,$5)',
|
||||||
[randomUlid(), tenant, emp, ou1, '2026-09-01'],
|
[sid, tenant, emp, ou1, '2026-09-01'],
|
||||||
);
|
);
|
||||||
|
|
||||||
await q(
|
await q(
|
||||||
'staff_timesheet_entries',
|
'staff_timesheet_entries',
|
||||||
'INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, updated_by) VALUES ($1,$2,$3,$4,$5)',
|
'INSERT INTO staff_timesheet_entries (id, tenant_id, employee_id, work_date, updated_by) VALUES ($1,$2,$3,$4,$5)',
|
||||||
|
|
@ -377,7 +381,44 @@ async function seed(tenant: string) {
|
||||||
[device, tenant],
|
[device, tenant],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for (let i = 0; i < 2; i += 1) {
|
||||||
|
await q(
|
||||||
|
'staff_access_grants',
|
||||||
|
'INSERT INTO staff_access_grants (id, tenant_id, role, display_name, token_hash, token_hint, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||||
|
[
|
||||||
|
randomUlid(),
|
||||||
|
tenant,
|
||||||
|
i === 0 ? 'accountant' : 'director',
|
||||||
|
`Роль ${i}`,
|
||||||
|
hex64(),
|
||||||
|
'hint…',
|
||||||
|
actor,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const batchId = randomUlid();
|
||||||
|
await q(
|
||||||
|
'staff_payroll_batches',
|
||||||
|
'INSERT INTO staff_payroll_batches (id, tenant_id, org_unit_id, period_start, period_end, created_by) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||||
|
[batchId, tenant, ou1, `2026-0${i + 4}-01`, `2026-0${i + 4}-28`, actor],
|
||||||
|
);
|
||||||
|
await q(
|
||||||
|
'staff_payroll_batch_lines',
|
||||||
|
'INSERT INTO staff_payroll_batch_lines (id, tenant_id, batch_id, work_session_id, employee_id, org_unit_id, amount_minor) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||||
|
[randomUlid(), tenant, batchId, seedSessionIds[i]!, employees[i]!, ou1, 100_000 + i],
|
||||||
|
);
|
||||||
|
await q(
|
||||||
|
'staff_batch_notifications',
|
||||||
|
'INSERT INTO staff_batch_notifications (id, tenant_id, recipient_role, type, subject_id) VALUES ($1,$2,$3,$4,$5)',
|
||||||
|
[randomUlid(), tenant, 'director', 'payroll.batch.submitted', batchId],
|
||||||
|
);
|
||||||
|
await q(
|
||||||
|
'staff_biometric_consents',
|
||||||
|
'INSERT INTO staff_biometric_consents (tenant_id, employee_id, status, document_ref, updated_by) VALUES ($1,$2,$3,$4,$5)',
|
||||||
|
[tenant, employees[i]!, 'granted', `doc-${i}`, actor],
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
actor,
|
actor,
|
||||||
);
|
);
|
||||||
return { employees, seeded };
|
return { employees, seeded };
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ describe('OpenAPI runtime contract', () => {
|
||||||
.replace('{employeeId}', '01J00000000000000000000000')
|
.replace('{employeeId}', '01J00000000000000000000000')
|
||||||
.replace('{workDate}', '2026-09-24')
|
.replace('{workDate}', '2026-09-24')
|
||||||
.replace('{runId}', '01J00000000000000000000000')
|
.replace('{runId}', '01J00000000000000000000000')
|
||||||
|
.replace('{id}', '01J00000000000000000000000')
|
||||||
.replace('{deviceId}', '01J00000000000000000000000');
|
.replace('{deviceId}', '01J00000000000000000000000');
|
||||||
const operations = documented.paths[path] as Record<string, unknown>;
|
const operations = documented.paths[path] as Record<string, unknown>;
|
||||||
const methods = ['get', 'post', 'put', 'patch', 'delete'] as const;
|
const methods = ['get', 'post', 'put', 'patch', 'delete'] as const;
|
||||||
|
|
|
||||||
|
|
@ -240,9 +240,10 @@ describe('attendance portal', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(response.statusCode).toBe(201);
|
expect(response.statusCode).toBe(201);
|
||||||
expect(calls).toHaveLength(2);
|
expect(calls).toHaveLength(3);
|
||||||
expect(calls[0]?.[0]).toContain('INSERT INTO staff_employees');
|
expect(calls[0]?.[0]).toContain('INSERT INTO staff_employees');
|
||||||
expect(calls[1]?.[0]).toContain('staff_audit_log');
|
expect(calls[1]?.[0]).toContain('staff_audit_log');
|
||||||
|
expect(calls[2]?.[0]).toContain('staff_sync_change_log');
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -273,7 +274,7 @@ describe('attendance portal', () => {
|
||||||
status: 'active',
|
status: 'active',
|
||||||
};
|
};
|
||||||
const after = { ...before, position: 'Старший администратор' };
|
const after = { ...before, position: 'Старший администратор' };
|
||||||
const { database, calls } = fakeDatabase([[before], [], [after], []]);
|
const { database, calls } = fakeDatabase([[before], [], [after], [], []]);
|
||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
|
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
|
||||||
database,
|
database,
|
||||||
|
|
@ -285,10 +286,11 @@ describe('attendance portal', () => {
|
||||||
payload: { position: 'Старший администратор' },
|
payload: { position: 'Старший администратор' },
|
||||||
});
|
});
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(calls).toHaveLength(4);
|
expect(calls).toHaveLength(5);
|
||||||
expect(calls[0]?.[0]).toContain('FOR UPDATE');
|
expect(calls[0]?.[0]).toContain('FOR UPDATE');
|
||||||
expect(calls[3]?.[1]?.[4]).toEqual(before);
|
expect(calls[3]?.[1]?.[4]).toEqual(before);
|
||||||
expect(calls[3]?.[1]?.[5]).toEqual(after);
|
expect(calls[3]?.[1]?.[5]).toEqual(after);
|
||||||
|
expect(calls[4]?.[0]).toContain('staff_sync_change_log');
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ const describeDatabase = connectionString ? describe : describe.skip;
|
||||||
const migrationConnectionString =
|
const migrationConnectionString =
|
||||||
process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL;
|
process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL;
|
||||||
const cleanupTables = [
|
const cleanupTables = [
|
||||||
|
'staff_biometric_consents',
|
||||||
|
'staff_batch_notifications',
|
||||||
|
'staff_payroll_batch_lines',
|
||||||
|
'staff_payroll_batches',
|
||||||
|
'staff_access_grants',
|
||||||
'staff_sync_device_checkpoints',
|
'staff_sync_device_checkpoints',
|
||||||
'staff_sync_command_inbox',
|
'staff_sync_command_inbox',
|
||||||
'staff_sync_change_log',
|
'staff_sync_change_log',
|
||||||
|
|
@ -311,5 +316,55 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
// Access grants tenant isolation
|
||||||
|
const grantIdA = newUlid();
|
||||||
|
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_access_grants (id, tenant_id, role, display_name, token_hash, token_hint, created_by)
|
||||||
|
VALUES ($1, $2, 'point_manager', 'Manager A', '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'hint', $3)`,
|
||||||
|
[grantIdA, tenantA, actorA],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const grantVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
||||||
|
client.query('SELECT id FROM staff_access_grants WHERE id = $1', [grantIdA]),
|
||||||
|
);
|
||||||
|
expect(grantVisibleToB.rowCount).toBe(0);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
database.tenantTransaction(tenantB, actorB, (client) =>
|
||||||
|
client.query(
|
||||||
|
`INSERT INTO staff_access_grants (id, tenant_id, role, display_name, token_hash, token_hint, created_by)
|
||||||
|
VALUES ($1, $2, 'point_manager', 'Manager B Fail', 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210', 'hint', $3)`,
|
||||||
|
[newUlid(), tenantA, actorB],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
// Biometric consents tenant isolation
|
||||||
|
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_biometric_consents (tenant_id, employee_id, status, document_ref, updated_by)
|
||||||
|
VALUES ($1, $2, 'granted', 'doc-ref-a', $3)`,
|
||||||
|
[tenantA, employeeIdA, actorA],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const consentVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
||||||
|
client.query('SELECT status FROM staff_biometric_consents WHERE employee_id = $1', [employeeIdA]),
|
||||||
|
);
|
||||||
|
expect(consentVisibleToB.rowCount).toBe(0);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
database.tenantTransaction(tenantB, actorB, (client) =>
|
||||||
|
client.query(
|
||||||
|
`INSERT INTO staff_biometric_consents (tenant_id, employee_id, status, document_ref, updated_by)
|
||||||
|
VALUES ($1, $2, 'revoked', 'doc-ref-b', $3)`,
|
||||||
|
[tenantA, employeeIdA, actorB],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue