diff --git a/README.md b/README.md
index dac7e8b..c0d7ea5 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ docker compose up --build app postgres
- `npm run db:migrate` — применить ещё не выполненные миграции. Откат удаляет только последнюю миграцию и требует `ALLOW_MIGRATION_DOWN=true`.
- `npm run check:contracts` — проверить manifest/settings/OpenAPI.
-Архитектурные решения: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), интеграция: [docs/PLATFORM-INTEGRATION.md](docs/PLATFORM-INTEGRATION.md), будущий объём: [docs/ROADMAP.md](docs/ROADMAP.md).
+Архитектурные решения: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), интеграция: [docs/PLATFORM-INTEGRATION.md](docs/PLATFORM-INTEGRATION.md), запуск и чек-лист боевого тестирования: [docs/RUN-MANUAL-TESTING.md](docs/RUN-MANUAL-TESTING.md), будущий объём: [docs/ROADMAP.md](docs/ROADMAP.md).
## Расчётный контур
diff --git a/api/openapi.yaml b/api/openapi.yaml
index 5a0b88a..c32f496 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -74,6 +74,26 @@ paths:
responses:
'204': { 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:
get:
summary: Получить доступную пользователю сводку посещаемости и табеля
@@ -311,6 +331,30 @@ paths:
'200': { description: Ключ выпущен; на сервере сохранён только его SHA-256 отпечаток }
'403': { 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:
get:
summary: Получить зарплатный профиль сотрудника
@@ -372,6 +416,15 @@ paths:
type: array
items: { type: object }
'403': { description: Недостаточно прав }
+ /notifications:
+ get:
+ summary: Получить уведомления
+ operationId: getBatchNotifications
+ tags: [Notifications]
+ security: [{ bearerAuth: [] }]
+ responses:
+ '200': { description: Список уведомлений }
+ '403': { description: Недостаточно прав }
/org-units:
get:
summary: Получить доступные торговые точки
@@ -476,6 +529,92 @@ paths:
responses:
'200': { 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:
post:
summary: Рассчитать зарплату за период
@@ -567,6 +706,20 @@ paths:
lines: { type: array, items: { type: object } }
'403': { 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:
post:
summary: Идемпотентно импортировать локально разобранную книгу владельца
@@ -957,6 +1110,20 @@ paths:
'400': { $ref: '#/components/responses/BadRequest' }
'401': { 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:
securitySchemes:
bearerAuth:
diff --git a/deploy/pilot/compose.yaml b/deploy/pilot/compose.yaml
index bdb349c..3af33ae 100644
--- a/deploy/pilot/compose.yaml
+++ b/deploy/pilot/compose.yaml
@@ -5,23 +5,23 @@ services:
build:
context: ../..
environment:
- NODE_ENV: development
+ NODE_ENV: production
HOST: 0.0.0.0
PORT: 8080
LOG_LEVEL: info
STAFF_RUNTIME_MODE: standalone
STANDALONE_ACCESS: employee
- STANDALONE_MANAGER_TOKEN: ${STANDALONE_MANAGER_TOKEN}
- STANDALONE_TERMINAL_TOKEN: ${STANDALONE_TERMINAL_TOKEN}
- STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID}
- STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID}
- STANDALONE_TERMINAL_ACTOR_ID: ${STANDALONE_TERMINAL_ACTOR_ID}
- VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
- VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT: ${VAPID_SUBJECT}
- DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD}@postgres:5432/krona_staff
+ STANDALONE_MANAGER_TOKEN: ${STANDALONE_MANAGER_TOKEN:?STANDALONE_MANAGER_TOKEN is required}
+ STANDALONE_TERMINAL_TOKEN: ${STANDALONE_TERMINAL_TOKEN:?STANDALONE_TERMINAL_TOKEN is required}
+ STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:?STANDALONE_TENANT_ID is required}
+ STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:?STANDALONE_ACTOR_ID is required}
+ STANDALONE_TERMINAL_ACTOR_ID: ${STANDALONE_TERMINAL_ACTOR_ID:?STANDALONE_TERMINAL_ACTOR_ID is required}
+ VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:?VAPID_PUBLIC_KEY is required}
+ VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:?VAPID_PRIVATE_KEY is required}
+ VAPID_SUBJECT: ${VAPID_SUBJECT:?VAPID_SUBJECT is required}
+ 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_PASSPHRASE: ${HTTPS_PFX_PASSPHRASE}
+ HTTPS_PFX_PASSPHRASE: ${HTTPS_PFX_PASSPHRASE:?HTTPS_PFX_PASSPHRASE is required}
ports:
- '192.168.1.81:8453:8080'
volumes:
@@ -48,8 +48,8 @@ services:
context: ../..
target: build
environment:
- DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD}@postgres:5432/krona_staff
- POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD}
+ 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 is required}
command: ['node', 'scripts/create-app-role.mjs']
depends_on:
postgres: { condition: service_healthy }
@@ -60,7 +60,7 @@ services:
context: ../..
target: build
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']
depends_on:
db-role: { condition: service_completed_successfully }
@@ -71,7 +71,7 @@ services:
environment:
POSTGRES_DB: krona_staff
POSTGRES_USER: krona_migrator
- POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD}
+ POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
diff --git a/docs/RUN-MANUAL-TESTING.md b/docs/RUN-MANUAL-TESTING.md
new file mode 100644
index 0000000..186bdcd
--- /dev/null
+++ b/docs/RUN-MANUAL-TESTING.md
@@ -0,0 +1,156 @@
+# Руководство по развёртыванию и чек-лист боевого тестирования Krona Staff
+
+## 1. Назначение и архитектурные инварианты
+
+Krona Staff — автономно развёртываемый блок Krona Business Platform, отвечающий за учёт рабочего времени, терминалы посещаемости и управленческий расчёт зарплаты.
+
+### Ключевые инварианты (AGENTS.md)
+
+1. **Разделение доменов**: Core владеет Tenant, User, Role, SSO, Platform Settings, Files, Notifications. Staff владеет Employee и HR-доменом (`User != Employee`).
+2. **Изоляция арендаторов**: Все таблицы содержат `tenant_id` (ULID 26 символов). В PostgreSQL включен и принудительно активирован Row-Level Security (`FORCE ROW LEVEL SECURITY`). Запросы выполняются через tenant-scoped транзакции с выставлением `app.tenant_id`.
+3. **Разделение ролей БД**:
+ - `krona_migrator` — владелец схемы и таблиц, выполняет миграции DDL, `NOSUPERUSER NOBYPASSRLS`.
+ - `krona_staff_app` — runtime-пользователь приложения с минимальными DML правами (SELECT, INSERT, UPDATE, DELETE на прикладные таблицы; нет прав на `schema_migrations`, нет прав DDL).
+4. **Типы данных**:
+ - Идентификаторы — ULID (`VARCHAR(26)`).
+ - Денежные суммы — `BIGINT` в минимальных единицах (копейках/minor units) + ISO 4217 код валюты (например, `'RUB'`).
+ - Временные метки — UTC (`TIMESTAMPTZ`).
+5. **Надёжность**: Транзакционный Outbox, идемпотентность по `Idempotency-Key`, CSRF-защита сессионных cookie (`Origin`/`Sec-Fetch-Site`), Rate-limiting ключей входа.
+
+---
+
+## 2. Развёртывание и запуск
+
+### 2.1. Системные требования
+
+- **ОС**: Linux (Ubuntu 22.04+/Debian 12+) или Windows Server 2022+ / Windows 11 (для сборки терминала).
+- **Среда выполнения**: Node.js 22 LTS, npm 10+.
+- **База данных**: PostgreSQL 17 (поддерживается 17.0+).
+- **Сеть**: Порт 8080 (HTTP) или 8453 (HTTPS пилот).
+
+### 2.2. Запуск через Docker Compose (рекомендуемый пилот)
+
+```bash
+# 1. Перейдите в каталог deploy/pilot
+cd deploy/pilot
+
+# 2. Задайте ULID организации из Krona Business Platform
+export STANDALONE_TENANT_ID="01J0000000000000000000000A"
+
+# 3. Выполните скрипт первичной инициализации:
+# - Сгенерирует стойкие пароли БД, токены менеджера и терминала в secrets/
+# - Сгенерирует ключи Web Push (VAPID)
+# - Создаст самоподписанный TLS-сертификат для терминала
+./setup.sh
+
+# 4. Запустите стек (PostgreSQL + мигратор + app)
+docker compose up -d --build
+
+# 5. Проверьте запуск контейнеров и миграций
+docker compose ps
+docker compose logs -f app
+```
+
+### 2.3. Запуск без Docker (Bare-Metal / Dev стенд)
+
+1. **Создайте базу данных и роли в PostgreSQL**:
+
+ ```sql
+ -- Под пользователем postgres (DBA):
+ CREATE ROLE krona_migrator LOGIN PASSWORD 'migrator_secure_password' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
+ CREATE ROLE krona_staff_app LOGIN PASSWORD 'app_secure_password' NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
+
+ CREATE DATABASE krona_staff OWNER krona_migrator ENCODING 'UTF8';
+ \c krona_staff
+ ALTER SCHEMA public OWNER TO krona_migrator;
+ REVOKE CREATE ON SCHEMA public FROM PUBLIC;
+ REVOKE ALL ON DATABASE krona_staff FROM PUBLIC;
+ GRANT CONNECT ON DATABASE krona_staff TO krona_staff_app;
+ ```
+
+2. **Настройте переменные окружения (`.env`)**:
+
+ ```bash
+ cp .env.example .env
+ ```
+
+ Укажите в `.env`:
+
+ ```ini
+ PORT=8080
+ NODE_ENV=production
+ STANDALONE_TENANT_ID=01J0000000000000000000000A
+ STANDALONE_ACTOR_ID=01J00000000000000000000001
+ STANDALONE_MANAGER_TOKEN=your_secure_manager_token_hex_or_ulid
+ STANDALONE_TERMINAL_TOKEN=your_secure_terminal_token_hex_or_ulid
+ DATABASE_URL=postgres://krona_staff_app:app_secure_password@127.0.0.1:5432/krona_staff
+ DATABASE_MIGRATION_URL=postgres://krona_migrator:migrator_secure_password@127.0.0.1:5432/krona_staff
+ ```
+
+3. **Сгенерируйте VAPID ключи**:
+
+ ```bash
+ node scripts/generate-vapid-keys.mjs >> .env
+ ```
+
+4. **Примените миграции**:
+
+ ```bash
+ npm run db:migrate
+ ```
+
+5. **Соберите и запустите приложение**:
+ ```bash
+ npm run build
+ npm run start
+ ```
+
+---
+
+## 3. Чек-лист ручного боевого тестирования владельцем
+
+Каждый шаг должен быть выполнен последовательно. Ожидаемый результат сверяется с фактическим.
+
+| № | Шаг / Сценарий | Действие / HTTP-запрос | Ожидаемый результат | Статус |
+| ------ | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | :----: |
+| **1** | **Системные проверки (Health)** | `GET /api/v1/staff/live`
`GET /api/v1/staff/ready`
`GET /api/v1/staff/version` | HTTP 200 на всех эндпоинтах. `ready` подтверждает соединение с БД под ролью `krona_staff_app`. `version` возвращает `runtime: standalone` и semver. | [ ] |
+| **2** | **Контракт OpenAPI** | `GET /api/v1/staff/openapi.json` | HTTP 200, возвращается валидная спецификация OpenAPI 3.1 со всеми роутами модуля. | [ ] |
+| **3** | **Защита доступа (401)** | `GET /api/v1/staff/portal` без заголовка Authorization | HTTP 401 Unauthorized. Ошибки с деталями SQL отсутствуют. | [ ] |
+| **4** | **Защита от CSRF** | `POST /api/v1/staff/portal/employees` с cookie сессии, но без заголовка `Origin` или с чужим Origin | HTTP 403 Forbidden (CSRF token missing/invalid). | [ ] |
+| **5** | **Защита от перебора паролей (429)** | 35 последовательных запросов с неверным токеном/ключом | После исчерпания лимита возвращается HTTP 429 Too Many Requests с заголовком `Retry-After`. | [ ] |
+| **6** | **Создание сотрудника** | `POST /api/v1/staff/employees`
Headers: `Authorization: Bearer `
Body: `{"personnel_number": "EMP-001", "display_name": "Иванов Иван", "profession_code": "cook"}` | HTTP 201 Created. Создан сотрудник, присвоен ULID, запись зафиксирована в `staff_employees`. | [ ] |
+| **7** | **Проверка справочника профессий** | `GET /api/v1/staff/professions` | HTTP 200, список активных профессий (`cook`, `cashier`, `barista`, `waiter` и др.). | [ ] |
+| **8** | **Назначение тарифной ставки сотруднику** | `POST /api/v1/staff/payroll/assignments`
Body: привязка правила (почасовая ставка 350 руб/час = 35000 minor units). | HTTP 201 Created. Версионированное правило сохранено в `staff_pay_assignments`. | [ ] |
+| **9** | **Отметка прихода через терминал** | `POST /api/v1/staff/attendance/events`
Headers: `Authorization: Bearer `
Body: `{"event_id": "", "employee_id": "", "event_type": "check_in", "occurred_at": ""}` | HTTP 201 Created. Событие записано в `staff_attendance_events`. Отправлено событие в outbox. | [ ] |
+| **10** | **Идемпотентность отметки** | Повтор запроса шага 9 с тем же `event_id` и `source_event_id` | HTTP 200 OK (или 201), дубликат не создаётся, количество записей не удваивается. | [ ] |
+| **11** | **Отметка ухода через терминал** | `POST /api/v1/staff/attendance/events` с `event_type: "check_out"` спустя 8 часов | HTTP 201 Created. Формируется завершённая рабочая смена. | [ ] |
+| **12** | **Подтверждение смены менеджером** | `POST /api/v1/staff/work-sessions/:id/confirm` | HTTP 200 OK. Смена переходит в статус `confirmed`, часы готовы к начислению. | [ ] |
+| **13** | **Авторасчёт ведомости зарплаты** | `POST /api/v1/staff/payroll/runs/auto-calc`
Body: период с 1 по 15 число месяца. | HTTP 201 Created. Начислено: 8 ч × 350 руб = 2800 руб (280000 minor units). Сохранена расшифровка формулы расчёта. | [ ] |
+| **14** | **Согласование ведомости бухгалтером** | `POST /api/v1/staff/payroll/batches/:id/approve` | HTTP 200 OK. Статус пакета переходит в `approved`. | [ ] |
+| **15** | **Подписание ведомости директором** | `POST /api/v1/staff/payroll/batches/:id/sign`
Body: `{"signature": "director_sign_key", "note": "Выплата за первую половину месяца"}` | HTTP 200 OK. Пакет подписан. Созданы строки начислений в `staff_payroll_ledger` с полем `note` (без ошибки 500!). | [ ] |
+| **16** | **Проверка реестра выплат (Ledger)** | `GET /api/v1/staff/payroll/ledger` | HTTP 200 OK. Отображаются начисления и выплаты, сальдо корректно сходится. | [ ] |
+| **17** | **Web Push уведомления сотруднику** | `POST /api/v1/staff/portal/push-subscriptions` с валидным FCM/W3C endpoint. | HTTP 201 Created. Подписка сохранена. При наступлении события формируется delivery без ложных блокировок хоста. | [ ] |
+| **18** | **Уведомления директору без дублей** | `GET /api/v1/staff/notifications/unread` под учётной записью директора | HTTP 200 OK. Каждому директору отдаются ровно его уведомления без дублирования на каждого получателя. | [ ] |
+| **19** | **Офлайн-синхронизация: регистрация терминала** | `POST /api/v1/staff/sync/devices/register` с токеном менеджера | HTTP 201 Created. Возвращается токен устройства `kstaff_dev_...`. Токен сохраняется в терминале. | [ ] |
+| **20** | **Офлайн-синхронизация: начальный Pull (C0)** | `GET /api/v1/staff/sync/pull` с токеном устройства (без курсора) | HTTP 200 OK. Возвращаются справочники (сотрудники, оргструктура, тарифы) и курсор `cursor_ulid`. | [ ] |
+| **21** | **Офлайн-синхронизация: пакет команд Push** | `POST /api/v1/staff/sync/push` с массивом офлайн-отметок посещаемости | HTTP 200 OK. Все корректные команды применены, битые команды получают понятную ошибку, статус транзакции не ломает весь пакет. | [ ] |
+| **22** | **Отзыв прав уволенного сотрудника** | Перевод сотрудника в статус `fired` / деактивация доступа в `staff_access_grants` | Попытка входа уволенного сотрудника немедленно возвращает HTTP 401 Unauthorized. | [ ] |
+| **23** | **Проверка утилиты миграции арендатора (Rehome)** | `node scripts/rehome-tenant.mjs --dry-run --source --target ` | Утилита производит корректное сравнение количества строк по всем таблицам (`Number`) без сбоев типов и ошибок внешних ключей. | [ ] |
+
+---
+
+## 4. Диагностика и устранение неполадок
+
+1. **Ошибка `401 Unauthorized` при запросе от терминала**:
+ - Проверьте, передан ли заголовок `Authorization: Bearer ` или токен зарегистрированного устройства.
+2. **Ошибка `403 Forbidden` при запросах из веб-интерфейса**:
+ - Убедитесь, что запрос отправляется с доверенного Origin (`localhost`, `127.0.0.1` или IP пилота). Сессионные мутирующие запросы требуют валидный Origin.
+3. **Проверка логов транзакций и RLS**:
+ ```bash
+ # Просмотр логов контейнера
+ docker compose logs -f app
+ ```
+4. **Проверка состояния миграций БД**:
+ ```bash
+ node scripts/check-migrations.mjs
+ ```
diff --git a/migrations/0011_offline_sync_backend.up.sql b/migrations/0011_offline_sync_backend.up.sql
index 6426b25..f44c9f6 100644
--- a/migrations/0011_offline_sync_backend.up.sql
+++ b/migrations/0011_offline_sync_backend.up.sql
@@ -18,7 +18,7 @@ CREATE TABLE staff_sync_devices (
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, device_code),
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
diff --git a/migrations/0013_owner_workflows.down.sql b/migrations/0013_owner_workflows.down.sql
new file mode 100644
index 0000000..f870151
--- /dev/null
+++ b/migrations/0013_owner_workflows.down.sql
@@ -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;
diff --git a/migrations/0013_owner_workflows.up.sql b/migrations/0013_owner_workflows.up.sql
new file mode 100644
index 0000000..349c723
--- /dev/null
+++ b/migrations/0013_owner_workflows.up.sql
@@ -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);
diff --git a/package-lock.json b/package-lock.json
index fc4e7a7..f41259f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1074,9 +1074,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1095,9 +1092,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1116,9 +1110,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1137,9 +1128,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1158,9 +1146,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1179,9 +1164,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1507,9 +1489,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "5.0.9",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
- "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1691,9 +1673,9 @@
}
},
"node_modules/ajv/node_modules/fast-uri": {
- "version": "3.1.7",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
- "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [
{
"type": "github",
@@ -1776,9 +1758,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
- "version": "1.1.18",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
- "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2265,9 +2247,9 @@
}
},
"node_modules/fast-uri": {
- "version": "4.1.4",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz",
- "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==",
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz",
+ "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==",
"funding": [
{
"type": "github",
@@ -2662,9 +2644,9 @@
}
},
"node_modules/json-schema-resolver/node_modules/fast-uri": {
- "version": "3.1.7",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
- "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [
{
"type": "github",
@@ -2900,9 +2882,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2925,9 +2904,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2950,9 +2926,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2975,9 +2948,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
diff --git a/scripts/rehome-tenant.mjs b/scripts/rehome-tenant.mjs
index 6f11eba..3eb39c5 100644
--- a/scripts/rehome-tenant.mjs
+++ b/scripts/rehome-tenant.mjs
@@ -33,6 +33,11 @@ const EXPECTED_TABLES = [
'staff_work_items',
'staff_work_sessions',
'staff_payouts',
+ 'staff_access_grants',
+ 'staff_payroll_batches',
+ 'staff_payroll_batch_lines',
+ 'staff_batch_notifications',
+ 'staff_biometric_consents',
];
class SafeOperationError extends Error {}
@@ -260,7 +265,7 @@ async function tableCounts(client, tables, tenantId) {
`SELECT count(*)::text AS row_count FROM ${qualified('public', table)} WHERE tenant_id = $1`,
[tenantId],
);
- counts[table] = rows[0].row_count;
+ counts[table] = Number(rows[0].row_count);
}
return counts;
}
@@ -337,6 +342,11 @@ export async function rehomeTenant(client, options) {
if (sourceTotal === 0n) throw new SafeOperationError('Source tenant contains no staff data.');
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') {
await client.query('COMMIT');
committed = true;
@@ -427,6 +437,7 @@ export async function rehomeTenant(client, options) {
report.tables = movedCounts;
report.auditRowsAdded = 1;
report.payrollLedger = ledgerAfter;
+ report.ledger_amount_minor_sum = sumResult.rows[0].s;
await client.query('COMMIT');
committed = true;
return report;
@@ -437,7 +448,25 @@ export async function rehomeTenant(client, options) {
}
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 =
process.env.DATABASE_ADMIN_URL ??
process.env.DATABASE_REHOME_URL ??
@@ -454,8 +483,26 @@ async function main() {
let client;
try {
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);
+ }
client = new pg.Client({
connectionString: options.connectionString,
application_name: 'krona-staff-tenant-rehome',
@@ -468,7 +515,11 @@ async function main() {
mode: options.mode,
backupEvidence: options.backupEvidence,
});
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
+ if (options.json) {
+ process.stdout.write(`${JSON.stringify(report)}\n`);
+ } else {
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
+ }
} catch (error) {
const code =
typeof error?.code === 'string' && /^[A-Z0-9]{5}$/.test(error.code)
diff --git a/src/adapters/identity.ts b/src/adapters/identity.ts
index 183d615..d3e3c4c 100644
--- a/src/adapters/identity.ts
+++ b/src/adapters/identity.ts
@@ -40,7 +40,10 @@ export class StandaloneIdentityAdapter implements IdentityPort {
} else {
throw new Error('Invalid standalone access token');
}
+ } else if (access === 'manager') {
+ throw new Error('Standalone access tokens are not configured');
}
+
return {
tenantId: parseUlid(this.config.STANDALONE_TENANT_ID),
actorId: parseUlid(actorId),
diff --git a/src/app.ts b/src/app.ts
index ddf8a4c..0f7b93c 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,4 +1,4 @@
-import Fastify from 'fastify';
+import Fastify, { type FastifyError } from 'fastify';
import { readFileSync } from 'node:fs';
import helmet from '@fastify/helmet';
import sensible from '@fastify/sensible';
@@ -45,9 +45,30 @@ export async function buildApp({
const supplied = request.headers['x-correlation-id']?.toString();
return supplied && ulidPattern.test(supplied) ? supplied : newUlid();
},
+ ajv: {
+ customOptions: {
+ removeAdditional: false,
+ },
+ },
});
await app.register(helmet);
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) => {
reply.header('x-correlation-id', request.id);
});
diff --git a/src/config.ts b/src/config.ts
index f5f2e54..20139ae 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -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');
}
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') {
const required = [
diff --git a/src/core/context.ts b/src/core/context.ts
index 9fa75c0..b1a8c69 100644
--- a/src/core/context.ts
+++ b/src/core/context.ts
@@ -6,4 +6,6 @@ export interface RequestContext {
permissions: ReadonlySet;
correlationId: Ulid;
causationId?: Ulid;
+ role?: string;
+ orgUnitIds?: readonly string[];
}
diff --git a/src/domain/notification/push-dispatcher.ts b/src/domain/notification/push-dispatcher.ts
index f6420d2..d4efa56 100644
--- a/src/domain/notification/push-dispatcher.ts
+++ b/src/domain/notification/push-dispatcher.ts
@@ -14,6 +14,16 @@ export interface PushSubscriptionInput {
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 {
database: Database;
vapidKeys: VapidKeys;
@@ -77,16 +87,28 @@ export class PushDispatcher {
employeeId: string,
input: PushSubscriptionInput,
): Promise<{ id: string; endpoint: string }> {
- if (!input.endpoint.startsWith('https://')) {
- throw new Error('Subscription endpoint must use HTTPS');
- }
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 cleanAuth = input.auth.trim();
const userAgent = (input.user_agent ?? '').trim();
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();
@@ -98,16 +120,29 @@ export class PushDispatcher {
parsedTenantId,
parsedActorId,
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 }>(
`INSERT INTO staff_push_subscriptions
(id, tenant_id, employee_id, endpoint, p256dh, auth, user_agent, updated_at)
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`,
[
subscriptionId,
diff --git a/src/domain/notification/web-push.ts b/src/domain/notification/web-push.ts
index 5670b84..20c6807 100644
--- a/src/domain/notification/web-push.ts
+++ b/src/domain/notification/web-push.ts
@@ -1,4 +1,5 @@
import crypto from 'node:crypto';
+import net from 'node:net';
export interface VapidKeys {
publicKey: string; // base64url uncompressed P-256 (65 bytes)
@@ -212,6 +213,46 @@ export function encryptPushPayload(payload: string | Buffer, keys: PushSubscript
/**
* 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') ||
+ h.endsWith('.local') ||
+ h.endsWith('.internal')
+ ) {
+ return true;
+ }
+ const ipType = net.isIP(h);
+ if (ipType === 4) {
+ const parts = h.split('.').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;
+ return false;
+ }
+ if (ipType === 6) {
+ if (h === '::1' || h === '::') return true;
+ if (
+ h.startsWith('fc') ||
+ h.startsWith('fd') ||
+ h.startsWith('fe8') ||
+ h.startsWith('fe9') ||
+ h.startsWith('fea') ||
+ h.startsWith('feb')
+ ) {
+ return true;
+ }
+ if (h.startsWith('::ffff:')) {
+ return isPrivateOrLoopbackHost(h.slice(7));
+ }
+ return false;
+ }
+ return false;
+}
+
export async function sendWebPush(
target: PushSubscriptionTarget,
payload: string,
@@ -222,6 +263,22 @@ export async function sendWebPush(
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 authHeader = createVapidAuthorizationHeader(target.endpoint, vapidKeys);
const fetchImpl = options?.fetchFn ?? fetch;
@@ -237,6 +294,7 @@ export async function sendWebPush(
Urgency: options?.urgency ?? 'normal',
},
body: new Uint8Array(encryptedBody),
+ redirect: 'manual',
});
const statusCode = response.status;
diff --git a/src/domain/payroll/service.ts b/src/domain/payroll/service.ts
index 9cd51a6..3e29411 100644
--- a/src/domain/payroll/service.ts
+++ b/src/domain/payroll/service.ts
@@ -15,6 +15,7 @@ export interface PayAssignmentInput {
pool_weight_bp?: number;
effective_from?: string;
note?: string;
+ params?: Record;
}
export interface PayAssignmentRecord {
@@ -294,10 +295,14 @@ export async function saveEmployeePayAssignment(
const poolWeightBp = data.pool_weight_bp !== undefined ? data.pool_weight_bp : defaultRuleWeight;
const params: Record = {
- rate_minor: data.rate_minor ?? 10000,
- percent_bp: data.percent_bp ?? 0,
- guarantee_minor: data.guarantee_minor ?? 0,
+ ...(data.params ?? {}),
+ ...(data.rate_minor !== undefined ? { rate_minor: data.rate_minor } : {}),
+ ...(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(
`SELECT pg_advisory_xact_lock(hashtextextended(
@@ -474,6 +479,22 @@ export async function calculatePayrollPeriod(
AND s.business_date BETWEEN $2 AND $3
AND s.status = 'confirmed'
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`,
[tenantId, from, to, orgUnitId],
);
diff --git a/src/domain/sync/service.ts b/src/domain/sync/service.ts
index f9aef94..338c14d 100644
--- a/src/domain/sync/service.ts
+++ b/src/domain/sync/service.ts
@@ -312,6 +312,21 @@ export class SyncService {
const seq = decodeCursor(tenantId, cursor);
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 }>(
`INSERT INTO staff_sync_device_checkpoints
(tenant_id, device_id, last_acked_cursor_seq, last_acked_cursor, acknowledged_at)
@@ -355,8 +370,10 @@ export class SyncService {
const limit = Math.min(Math.max(1, options.limit ?? 100), 100);
return this.database.tenantTransaction(tenantId, deviceId, async (client) => {
- // 1. If cursor is provided -> incremental pull from staff_sync_change_log
- if (options.cursor) {
+ // 1. If cursor is provided and NOT a snapshot cursor -> incremental pull from staff_sync_change_log
+ const isSnapshotCursor =
+ typeof options.cursor === 'string' && options.cursor.startsWith('snap_');
+ if (options.cursor && !isSnapshotCursor) {
const seq = decodeCursor(tenantId, options.cursor);
const filterTypes =
options.entity_types && options.entity_types.length > 0 ? options.entity_types : null;
@@ -404,6 +421,12 @@ export class SyncService {
}
// 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 }>(
`SELECT MAX(cursor_seq) AS max_seq FROM staff_sync_change_log WHERE tenant_id = $1`,
[tenantId],
@@ -644,12 +667,15 @@ export class SyncService {
}
}
- const hasMore = changes.length > limit;
- const pageChanges = changes.slice(0, limit);
+ const totalChanges = changes.length;
+ const pageChanges = changes.slice(snapOffset, snapOffset + limit);
+ const nextOffset = snapOffset + limit;
+ const hasMore = nextOffset < totalChanges;
+ const nextCursor = hasMore ? `snap_${nextOffset}_${maxSeq}` : latestCursor;
return {
changes: pageChanges,
- next_cursor: latestCursor,
+ next_cursor: nextCursor,
has_more: hasMore,
};
});
@@ -751,7 +777,25 @@ export class SyncService {
}
// 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);
results.push(executionResult);
}
@@ -775,7 +819,6 @@ export class SyncService {
const eventType = payload.event_type as string;
const occurredAt = payload.occurred_at as string;
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) {
return {
@@ -787,9 +830,35 @@ 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 }>(
- `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],
);
if (empCheck.rowCount === 0) {
@@ -797,18 +866,32 @@ export class SyncService {
idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type,
status: 'rejected',
- error_code: 'EMPLOYEE_NOT_FOUND',
- error_message: 'Сотрудник не найден в данном предприятии',
+ error_code: 'EMPLOYEE_INACTIVE',
+ 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 correlationId = cmd.correlation_id || newUlid();
await client.query(
`INSERT INTO staff_attendance_events
- (id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, device_id, actor_id, correlation_id)
- VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9)
+ (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, $10)
ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING`,
[
eventId,
@@ -820,6 +903,7 @@ export class SyncService {
device.id,
device.id,
correlationId,
+ effectiveOrgUnitId,
],
);
@@ -830,7 +914,7 @@ export class SyncService {
occurred_at: occurredAt,
source_event_id: sourceEventId,
device_id: device.id,
- org_unit_id: orgUnitId ? String(orgUnitId) : null,
+ org_unit_id: effectiveOrgUnitId,
status: 'accepted',
};
diff --git a/src/operations.ts b/src/operations.ts
index c93bae0..60a8fb7 100644
--- a/src/operations.ts
+++ b/src/operations.ts
@@ -131,6 +131,12 @@ export async function registerOperationsRoutes(
api.get('/org-units', async (request) => {
const context = await auth.resolve(request);
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) => {
const rows = await client.query(
`SELECT DISTINCT u.id, u.code, u.name, u.timezone
@@ -569,18 +575,23 @@ export async function registerOperationsRoutes(
},
body: {
type: 'object',
+ additionalProperties: false,
required: ['pay_rule_id'],
properties: {
org_unit_id: {
anyOf: [{ type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }, { type: 'null' }],
},
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 },
- 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 },
effective_from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
note: { type: 'string', maxLength: 500 },
+ params: {
+ type: 'object',
+ additionalProperties: { type: 'integer', minimum: 0, maximum: 1_000_000_000 },
+ },
},
},
},
@@ -660,9 +671,23 @@ export async function registerOperationsRoutes(
auth.assertMutationOrigin(request);
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 to: string;
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`;
const parts = request.body.month.split('-').map(Number);
const year = parts[0]!;
@@ -773,6 +798,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 }>(
'/imports/owner-workbook',
{
@@ -1018,18 +1090,20 @@ export async function registerOperationsRoutes(
const desiredAmount =
entry.kind === 'payout' ? -Math.abs(entry.amount_minor) : entry.amount_minor;
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 }>(
`SELECT COALESCE(sum(amount_minor), 0)::text AS amount_minor
FROM staff_payroll_ledger
- WHERE tenant_id = $1 AND employee_id = $2 AND business_date = $3
- AND (external_key LIKE $4 OR external_key LIKE $5)`,
- [context.tenantId, employeeId, entry.business_date, legacyPrefix, currentPrefix],
+ WHERE tenant_id = $1 AND employee_id = $2
+ AND (external_key = $3 OR external_key LIKE $3 || ':%')`,
+ [context.tenantId, employeeId, entry.external_key],
);
const previousAmount = Number(existing.rows[0]?.amount_minor ?? 0);
const delta = desiredAmount - previousAmount;
if (delta === 0) continue;
+ const isInitial = previousAmount === 0;
+ const rowKey = isInitial
+ ? entry.external_key
+ : `${entry.external_key}:adj:${newUlid()}`;
await client.query(
`INSERT INTO staff_payroll_ledger
(id, tenant_id, employee_id, business_date, kind, amount_minor,
@@ -1041,12 +1115,12 @@ export async function registerOperationsRoutes(
context.tenantId,
employeeId,
entry.business_date,
- previousAmount === 0 ? entry.kind : 'adjustment',
+ isInitial ? entry.kind : 'adjustment',
delta,
context.correlationId,
entry.note?.trim() ?? `Импорт: ${body.source_name}`,
context.actorId,
- entry.external_key,
+ rowKey,
],
);
}
@@ -1202,7 +1276,8 @@ function validateImport(body: OwnerWorkbookImportBody): boolean {
if (
!entry.external_key ||
!parseDate(entry.business_date) ||
- !Number.isSafeInteger(entry.amount_minor)
+ !Number.isSafeInteger(entry.amount_minor) ||
+ !['accrual', 'payout', 'adjustment'].includes(entry.kind)
)
return false;
}
diff --git a/src/portal.ts b/src/portal.ts
index f22dc6d..4906466 100644
--- a/src/portal.ts
+++ b/src/portal.ts
@@ -10,7 +10,12 @@ import type { Database } from './infrastructure/database.js';
import { reconcileWorkSession } from './domain/attendance/work-session.js';
import { registerOperationsRoutes } from './operations.js';
import { resolveVapidKeys } from './domain/notification/web-push.js';
-import { PushDispatcher } from './domain/notification/push-dispatcher.js';
+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}$/;
@@ -185,13 +190,23 @@ export async function registerPortalRoutes(
return reply.notFound('Сотрудник для текущего пользователя не найден.');
}
- const saved = await pushDispatcher.saveSubscription(context.tenantId, employee.id, {
- endpoint: request.body.endpoint,
- p256dh: request.body.keys.p256dh,
- auth: request.body.keys.auth,
- user_agent: request.body.user_agent,
- });
- return reply.code(201).send(saved);
+ try {
+ const saved = await pushDispatcher.saveSubscription(context.tenantId, employee.id, {
+ endpoint: request.body.endpoint,
+ p256dh: request.body.keys.p256dh,
+ auth: request.body.keys.auth,
+ user_agent: request.body.user_agent,
+ });
+ return reply.code(201).send(saved);
+ } catch (err) {
+ if (err instanceof PushConflictError) {
+ return reply.conflict(err.message);
+ }
+ if (err instanceof PushValidationError) {
+ return reply.badRequest(err.message);
+ }
+ throw err;
+ }
},
);
@@ -278,18 +293,20 @@ export async function registerPortalRoutes(
return reply.forbidden('Ключ не подходит для входа в кабинет.');
}
loginRateLimit.delete(rateLimitKey);
+ const sessionId = createSession(request.body.access_token, sessionMaxAgeSeconds);
reply
.header('cache-control', 'no-store')
- .header(
- 'set-cookie',
- serializeSessionCookie(request.body.access_token, sessionMaxAgeSeconds),
- );
+ .header('set-cookie', serializeSessionCookie(sessionId, sessionMaxAgeSeconds));
return { role, expires_in: sessionMaxAgeSeconds };
},
);
api.delete('/session', async (request, reply) => {
assertSameOrigin(request);
+ const rawCookie = readCookie(request.headers.cookie, sessionCookieName);
+ if (rawCookie) {
+ revokeSession(rawCookie);
+ }
reply.header('cache-control', 'no-store').header('set-cookie', serializeSessionCookie('', 0));
return reply.code(204).send();
});
@@ -356,10 +373,17 @@ export async function registerPortalRoutes(
api.get('/professions', async (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) => {
const result = await client.query(
`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 };
});
@@ -518,6 +542,24 @@ export async function registerPortalRoutes(
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;
},
);
@@ -654,6 +696,24 @@ export async function registerPortalRoutes(
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 };
},
);
@@ -794,6 +854,28 @@ export async function registerPortalRoutes(
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;
+ }>(
+ `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(
context.tenantId,
context.actorId,
@@ -857,10 +939,25 @@ export async function registerPortalRoutes(
});
}
- return reply.code(result.duplicate ? 200 : 201).send({
+ const responseBody = {
id: result.id,
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);
},
);
@@ -1079,6 +1176,7 @@ export async function registerPortalRoutes(
);
if (currentQuery.rowCount === 0) return { kind: 'missing' as const };
const current = currentQuery.rows[0] as Record;
+
const currentStartedAt = parseDatabaseOptionalInstant(current.started_at);
const currentEndedAt = parseDatabaseOptionalInstant(current.ended_at);
const startedAt =
@@ -1099,6 +1197,28 @@ export async function registerPortalRoutes(
if (startedAt && endedAt && new Date(endedAt) < new Date(startedAt)) {
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(
`UPDATE staff_work_sessions SET
started_at = $3, ended_at = $4, worked_minutes = $5, status = $6,
@@ -1177,6 +1297,11 @@ export async function registerPortalRoutes(
},
);
if (outcome.kind === 'missing') return reply.notFound('Смена не найдена.');
+ if (outcome.kind === 'locked') {
+ return reply.conflict(
+ 'Смена входит в утверждённую ведомость или подписанный пакет и не может быть изменена.',
+ );
+ }
if (outcome.kind === 'note_required') {
return reply.badRequest('Для ручной корректировки смены укажите причину.');
}
@@ -1268,6 +1393,669 @@ 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 | 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 =
+ typeof assignRes.rows[0]?.params === 'string'
+ ? (JSON.parse(assignRes.rows[0].params) as Record)
+ : ((assignRes.rows[0]?.params as Record | 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_actor_id IS NULL AND 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, note, 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, note, 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, {
resolve: (request) => resolveContext(identity, request),
require: (request, permission) => requirePermission(identity, request, permission),
@@ -1309,7 +2097,7 @@ function withStandaloneEmployeeAccess(
config: AppConfig,
database: Database,
): IdentityPort {
- if (config.STAFF_RUNTIME_MODE !== 'standalone' || !config.STANDALONE_MANAGER_TOKEN) {
+ if (config.STAFF_RUNTIME_MODE !== 'standalone') {
return baseIdentity;
}
return {
@@ -1321,6 +2109,64 @@ function withStandaloneEmployeeAccess(
const tenantId = parseUlid(config.STANDALONE_TENANT_ID);
const serviceActorId = parseUlid(config.STANDALONE_ACTOR_ID);
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(['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 result = await client.query(
`SELECT access.subject_id
@@ -1360,9 +2206,15 @@ async function resolveContext(
request: FastifyRequest,
): Promise {
const authorization = request.headers.authorization;
- const token = authorization?.startsWith('Bearer ')
- ? authorization.slice(7)
- : readCookie(request.headers.cookie, sessionCookieName);
+ let token: string | undefined;
+ if (authorization?.startsWith('Bearer ')) {
+ token = authorization.slice(7).trim();
+ } else {
+ const rawCookie = readCookie(request.headers.cookie, sessionCookieName);
+ if (rawCookie) {
+ token = getSessionToken(rawCookie);
+ }
+ }
return resolveToken(identity, token, request);
}
@@ -1401,7 +2253,7 @@ function assertSameOrigin(request: FastifyRequest) {
if (!origin) return;
try {
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 {
throw request.server.httpErrors.forbidden('Запрос с постороннего сайта отклонён.');
}
@@ -1409,7 +2261,16 @@ function assertSameOrigin(request: FastifyRequest) {
function assertCookieMutationOrigin(request: FastifyRequest) {
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('Запрос с постороннего сайта отклонён.');
+ }
}
}
diff --git a/src/session.ts b/src/session.ts
new file mode 100644
index 0000000..661ef14
--- /dev/null
+++ b/src/session.ts
@@ -0,0 +1,31 @@
+import { randomBytes } from 'node:crypto';
+
+export interface SessionData {
+ token: string;
+ expiresAt: number;
+}
+
+const sessionStore = new Map();
+
+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);
+}
diff --git a/src/sync.ts b/src/sync.ts
index b393ed2..6f24187 100644
--- a/src/sync.ts
+++ b/src/sync.ts
@@ -7,6 +7,7 @@ import type { IdentityPort } from './core/ports.js';
import { SyncAuthError, SyncService, SyncValidationError } from './domain/sync/service.js';
import { InvalidCursorError } from './domain/sync/cursor.js';
import type { SyncCommand } from './domain/sync/types.js';
+import { getSessionToken } from './session.js';
interface PullBody {
cursor?: string | null;
@@ -67,7 +68,25 @@ export async function registerSyncRoutes(
return undefined;
};
+ function assertCookieMutationOrigin(request: FastifyRequest) {
+ const authorization = request.headers.authorization;
+ if (authorization?.startsWith('Bearer ')) return;
+ const cookie = request.headers.cookie;
+ if (!cookie || !cookie.includes('__Host-krona_staff_session=')) 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) => {
+ assertCookieMutationOrigin(request);
const authHeader = request.headers.authorization;
const cookieHeader = request.headers.cookie;
const sessionCookieName = '__Host-krona_staff_session';
@@ -78,7 +97,8 @@ export async function registerSyncRoutes(
const sep = part.indexOf('=');
if (sep > 0 && part.slice(0, sep).trim() === sessionCookieName) {
try {
- token = decodeURIComponent(part.slice(sep + 1).trim());
+ const rawSessionId = decodeURIComponent(part.slice(sep + 1).trim());
+ token = getSessionToken(rawSessionId);
} catch {
// ignore malformed cookie
}
@@ -87,6 +107,12 @@ export async function registerSyncRoutes(
}
}
+ if (!token) {
+ throw request.server.httpErrors.unauthorized(
+ 'Требуется действующий ключ доступа руководителя',
+ );
+ }
+
try {
const context = await baseIdentity.resolve(token, parseUlid(request.id));
if (
diff --git a/tests/e2e/60-pilot-rehome.e2e.ts b/tests/e2e/60-pilot-rehome.e2e.ts
index a0d839f..f4bc964 100644
--- a/tests/e2e/60-pilot-rehome.e2e.ts
+++ b/tests/e2e/60-pilot-rehome.e2e.ts
@@ -215,6 +215,7 @@ async function seed(tenant: string) {
[randomUlid(), tenant, emp, rules[i], '2026-01-01', actor],
);
}
+ const seedSessionIds: string[] = [];
for (const [i, emp] of employees.slice(0, 2).entries()) {
await q(
'staff_attendance_events',
@@ -231,11 +232,14 @@ async function seed(tenant: string) {
corr(),
],
);
+ const sid = randomUlid();
+ seedSessionIds.push(sid);
await q(
'staff_work_sessions',
'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(
'staff_timesheet_entries',
'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],
);
}
+ 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,
);
return { employees, seeded };
diff --git a/tests/e2e/known-defects.json b/tests/e2e/known-defects.json
index 7fc1c9d..d3d86d1 100644
--- a/tests/e2e/known-defects.json
+++ b/tests/e2e/known-defects.json
@@ -1,51 +1,4 @@
{
"comment": "ID сценариев, красный цвет которых сейчас ожидаем (открытые дефекты или ещё не реализованные решения владельца). Когда дефект исправлен и все тесты ID зелёные, scripts/test/run-e2e.mjs падает, пока ID не убран отсюда. Добавлять ID сюда можно только вместе с записью в REPORT.md. Ключи — двойные ID «владелец/мой» (docs/audit/2026-09-26/AUDIT.md и TASK-GROK-2 §2), для сценариев без ID владельца — мой ID.",
- "defects": {
- "K4/N1": "POST /employees: 42P08 inconsistent types for $2 на реальной PostgreSQL",
- "K3/N2": "подтверждение смены / расчёт / утверждение: staff_outbox.occurred_at NULL (23502)",
- "K12/N3": "SSRF через push-endpoint на внутренние адреса",
- "K5/N4": "двойное начисление при пересекающихся расчётах (сейчас блокирован N2)",
- "N9": "перепривязка чужого push-endpoint",
- "N10": "справочники доступны терминалу",
- "N11": "импорт с неверным kind → 500",
- "N12": "pay-assignment без additionalProperties:false и верхних границ",
- "K1/F2": "standalone без токенов даёт права STANDALONE_ACCESS анониму",
- "K10/F3": "cookie = сырой токен, logout не отзывает",
- "K11/F4": "нет rate limit на вход",
- "F7": "Idempotency-Key не используется",
- "F10": "CSRF: мутации по cookie без Origin разрешены",
- "K13/SYNC-A": "устройство само подтверждает смену через upsert_work_session",
- "K13/SYNC-B": "устройство само утверждает табель через upsert_timesheet_entry",
- "SYNC-C0": "начальная загрузка /sync/pull падает 500: column status does not exist (staff_org_units)",
- "S5/SYNC-C": "начальная загрузка обрезается на 100 сущностях (сейчас также блокирована SYNC-C0)",
- "S5/SYNC-D": "изменения из портала не пишутся в staff_sync_change_log",
- "S4/SYNC-E": "отметки уволенных сотрудников принимаются через sync",
- "S4/SYNC-F": "sync не сохраняет org_unit_id отметки и принимает чужую точку",
- "S3/SYNC-G": "одна битая команда роняет весь пакет (500)",
- "S8/SYNC-H": "регистрация устройства по cookie без проверки Origin",
- "K1/SYNC-I": "без токенов анонимно регистрируется устройство",
- "S2/SYNC-J": "sync принимает отметки вне окна времени",
- "SYNC-K": "ack принимает курсор больше выданного сервером",
- "K1/PILOT-1": "deploy/pilot/compose.yaml: 14 подстановок без :? (пустые токены/пароли молча проходят)",
- "K2/PILOT-2": "deploy/pilot/compose.yaml: app с NODE_ENV=development",
- "D6/OWN-01": "решение владельца: роли точки (этап 4 Codex)",
- "D6/OWN-02": "решение владельца: пакет отправляет только бухгалтер",
- "OWN-03": "решение владельца: уведомления директорам",
- "OWN-04": "решение владельца: подписывает только директор",
- "OWN-05": "решение владельца: ledger пуст до подписи",
- "OWN-06": "решение владельца: подпись → ledger, неизменяемость",
- "K19/OWN-07": "решение владельца: изменение смены после отправки возвращает пакет",
- "OWN-08": "решение владельца: возврат с комментарием",
- "OWN-09": "решение владельца: смена в чужой точке идёт в зарплату той точки",
- "K17/OWN-10": "решение владельца: нет согласия на биометрию → регистрация запрещена",
- "K17/OWN-11": "решение владельца: отзыв согласия меняет статус для терминала",
- "K6/D5": "отклонение смены из подписанной ведомости: 200 без сторно (сейчас блокирован K3 на подтверждении)",
- "K7/D2": "повторный импорт книги теряет суммы: две точки в один день → 222,00 вместо 333,00 ₽",
- "K8/D1": "подписанная ведомость не воспроизводится после смены ставки; контракта verify нет (C4), сейчас блокирован K3",
- "K9/D3": "параметры пресета официанта (shift_rate_minor и др.) не сохраняются",
- "S9": "удаление точки с устройством: 23502 null value in column tenant_id (ON DELETE SET NULL на составном FK)",
- "A4": "ответы 500 содержат SQLSTATE и текст PostgreSQL",
- "N12-DATE": "month=2026-13 → 500 (22008) вместо 400",
- "PILOT-REHOME": "нет scripts/rehome-tenant.mjs (TASK-CODEX-2 п.2.2): перенос заглушки 01J00000000000000000000000 на настоящий tenant не реализован"
- }
+ "defects": {}
}
diff --git a/tests/integration/openapi-runtime.test.ts b/tests/integration/openapi-runtime.test.ts
index f9aa7ec..2add91f 100644
--- a/tests/integration/openapi-runtime.test.ts
+++ b/tests/integration/openapi-runtime.test.ts
@@ -54,6 +54,7 @@ describe('OpenAPI runtime contract', () => {
.replace('{employeeId}', '01J00000000000000000000000')
.replace('{workDate}', '2026-09-24')
.replace('{runId}', '01J00000000000000000000000')
+ .replace('{id}', '01J00000000000000000000000')
.replace('{deviceId}', '01J00000000000000000000000');
const operations = documented.paths[path] as Record;
const methods = ['get', 'post', 'put', 'patch', 'delete'] as const;
diff --git a/tests/integration/portal.test.ts b/tests/integration/portal.test.ts
index 23419bf..b29c911 100644
--- a/tests/integration/portal.test.ts
+++ b/tests/integration/portal.test.ts
@@ -240,9 +240,10 @@ describe('attendance portal', () => {
},
});
expect(response.statusCode).toBe(201);
- expect(calls).toHaveLength(2);
+ expect(calls).toHaveLength(3);
expect(calls[0]?.[0]).toContain('INSERT INTO staff_employees');
expect(calls[1]?.[0]).toContain('staff_audit_log');
+ expect(calls[2]?.[0]).toContain('staff_sync_change_log');
await app.close();
});
@@ -273,7 +274,7 @@ describe('attendance portal', () => {
status: 'active',
};
const after = { ...before, position: 'Старший администратор' };
- const { database, calls } = fakeDatabase([[before], [], [after], []]);
+ const { database, calls } = fakeDatabase([[before], [], [after], [], []]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database,
@@ -285,10 +286,11 @@ describe('attendance portal', () => {
payload: { position: 'Старший администратор' },
});
expect(response.statusCode).toBe(200);
- expect(calls).toHaveLength(4);
+ expect(calls).toHaveLength(5);
expect(calls[0]?.[0]).toContain('FOR UPDATE');
expect(calls[3]?.[1]?.[4]).toEqual(before);
expect(calls[3]?.[1]?.[5]).toEqual(after);
+ expect(calls[4]?.[0]).toContain('staff_sync_change_log');
await app.close();
});
diff --git a/tests/integration/sync-bootstrap.test.ts b/tests/integration/sync-bootstrap.test.ts
index c54007c..1e3194a 100644
--- a/tests/integration/sync-bootstrap.test.ts
+++ b/tests/integration/sync-bootstrap.test.ts
@@ -1,7 +1,7 @@
import pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Database } from '../../src/infrastructure/database.js';
-import { newUlid, parseUlid } from '../../src/core/ids.js';
+import { newUlid } from '../../src/core/ids.js';
import { SyncService } from '../../src/domain/sync/service.js';
import { buildApp } from '../../src/app.js';
import { loadConfig } from '../../src/config.js';
@@ -26,8 +26,8 @@ const cleanupTables = [
describeDatabase('Sync Bootstrap SYNC-C0 (Live PostgreSQL)', () => {
const database = new Database(connectionString!);
- const tenantId = parseUlid('01J0000000000000000000000A');
- const actorId = parseUlid('01J00000000000000000000001');
+ const tenantId = newUlid();
+ const actorId = newUlid();
const orgUnitId = newUlid();
const employeeId = newUlid();
const deviceId = newUlid();
diff --git a/tests/integration/sync-rejection.test.ts b/tests/integration/sync-rejection.test.ts
index 85c883d..d86f2e2 100644
--- a/tests/integration/sync-rejection.test.ts
+++ b/tests/integration/sync-rejection.test.ts
@@ -1,7 +1,7 @@
import pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Database } from '../../src/infrastructure/database.js';
-import { newUlid, parseUlid } from '../../src/core/ids.js';
+import { newUlid } from '../../src/core/ids.js';
import { SyncService } from '../../src/domain/sync/service.js';
import type { SyncCommand } from '../../src/domain/sync/types.js';
@@ -21,8 +21,8 @@ const cleanupTables = [
describeDatabase('Device Sync Command Rejection (Live PostgreSQL)', () => {
const database = new Database(connectionString!);
- const tenantId = parseUlid('01J0000000000000000000000A');
- const actorId = parseUlid('01J00000000000000000000001');
+ const tenantId = newUlid();
+ const actorId = newUlid();
const deviceId = newUlid();
const employeeId = newUlid();
const sessionId = newUlid();
diff --git a/tests/integration/tenant-isolation.test.ts b/tests/integration/tenant-isolation.test.ts
index d806d26..1e2d8cd 100644
--- a/tests/integration/tenant-isolation.test.ts
+++ b/tests/integration/tenant-isolation.test.ts
@@ -1,7 +1,7 @@
import pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Database } from '../../src/infrastructure/database.js';
-import { newUlid, parseUlid } from '../../src/core/ids.js';
+import { newUlid } from '../../src/core/ids.js';
const connectionString = process.env.DATABASE_TEST_URL;
const describeDatabase = connectionString ? describe : describe.skip;
@@ -12,6 +12,11 @@ const describeDatabase = connectionString ? describe : describe.skip;
const migrationConnectionString =
process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL;
const cleanupTables = [
+ 'staff_biometric_consents',
+ 'staff_batch_notifications',
+ 'staff_payroll_batch_lines',
+ 'staff_payroll_batches',
+ 'staff_access_grants',
'staff_sync_device_checkpoints',
'staff_sync_command_inbox',
'staff_sync_change_log',
@@ -25,10 +30,10 @@ const cleanupTables = [
describeDatabase('PostgreSQL tenant isolation', () => {
const database = new Database(connectionString!);
- const tenantA = parseUlid('01J0000000000000000000000A');
- const tenantB = parseUlid('01J0000000000000000000000B');
- const actorA = parseUlid('01J0000000000000000000001A');
- const actorB = parseUlid('01J0000000000000000000001B');
+ const tenantA = newUlid();
+ const tenantB = newUlid();
+ const actorA = newUlid();
+ const actorB = newUlid();
const eventId = newUlid();
const professionId = newUlid();
const employeeIdA = newUlid();
@@ -311,5 +316,56 @@ describeDatabase('PostgreSQL tenant isolation', () => {
),
),
).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();
});
});