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(); }); });