schema(v8.5): реализация 27 решений аудита C — DDL + CHANGELOG + метрики

Закрывает все 27 решений из Открытые_вопросы v1.12 §13.10 на уровне
schema.sql. Narrative-обновление (§10/§12.5.5/§14/§17/§19.10/§22/§23.10/
Прил.И) — отдельным коммитом.

Метрики v8.4 → v8.5:
- 53 → 54 таблицы (+1: project_user_assignments)
- 86 → 91 индекс (+5)
- 34 → 35 RLS-политик (+1) + WITH CHECK на 2 существующих
- 34 → 35 ENABLE RLS (+1)
- 3 → 4 роли (+crm_audit_writer)
- 0 → 12 триггеров (5×2 audit append-only + 1 report_jobs export
  + 1 deals lead_score)
- 0 → 4 функции (audit_chain_hash, audit_block_mutation,
  report_jobs_log_export, calc_lead_score)
- +26 колонок: suppliers.quality_score; saas_admin_users (sso_provider,
  is_break_glass); impersonation_tokens (second_approver_id,
  second_approval_at); tenants (api_key_limit, telegram_bot_token);
  projects (assignment_strategy, ttfr_target_minutes); users
  (telegram_user_id); deals (assigned_at, escalated_count, duplicate_of_id,
  utm_source/medium/campaign/content, region_code, city,
  time_in_form_seconds, lead_score); +log_hash на 5 audit-таблицах
- ALTER api_keys.expires_at SET NOT NULL DEFAULT NOW()+365d
- REVOKE ALL на 6 saas-таблицах от crm_app_user

P0 (8) разблокировали триггер фазы 1 (composer create-project):
- Биз-17 (manual routing), Биз-18 (TTFR 15м), Биз-19 (24ч-дедуп без списания)
- CTO-13 (e2e SET LOCAL+PgBouncer тест в спринте 1; план в narrative)
- OPEN-И-13 (OIDC+JIT+break-glass), OPEN-И-14 (WITH CHECK + REVOKE)
- OPEN-И-15 (append-only audit + hash chain + crm_audit_writer)
- OPEN-И-16 (Sentry whitelist+regex; конфигурация в Laravel, не DDL)

Self-review: 0 orphan-FK, 0 дубликатов CREATE TABLE, метрики совпадают
с grep'ами; markdownlint+cspell чистые.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-07 17:46:46 +03:00
parent aabf827f76
commit 038a884daf
5 changed files with 722 additions and 25 deletions
+4 -4
View File
@@ -12,8 +12,8 @@
| Продуктовые правила работы Claude | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (v1.2+) |
| Полный реестр 28 инструментов и фазы | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (Прил. Н v1.0+) |
| Главное ТЗ | [docs/CRM_bp-gr_Инструкция_v8_4.md](docs/CRM_bp-gr_Инструкция_v8_4.md) (v8.4 от 06.05.2026) |
| Схема БД | [db/schema.sql](db/schema.sql) (v8.4) |
| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.9+) |
| Схема БД | [db/schema.sql](db/schema.sql) (v8.5 от 07.05.2026 — реализация 27 решений аудита C, narrative v8.5 готовится) |
| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.12+) |
| Брендбук | [docs/brandbook.md](docs/brandbook.md) (v1.1) |
| Анализ оригинала | [docs/Analiz_originala_v8_3.md](docs/Analiz_originala_v8_3.md) (Прил. М v1.1) |
| Состав архива | [docs/README_АРХИВ_v8_4.md](docs/README_АРХИВ_v8_4.md) (v8.4 от 06.05.2026) |
@@ -46,7 +46,7 @@
|---|---|
| Backend | PHP 8.3 + Laravel 11 |
| Frontend | Vue 3 + **Vuetify 3** (НЕ Tailwind, НЕ Inertia, НЕ Livewire, НЕ Filament) |
| БД | PostgreSQL 16 (53 таблицы + 12 партиций, 86 индексов, 33 RLS-политики, 3 роли БД) |
| БД | PostgreSQL 16 (54 таблицы + 12 партиций, 91 индекс, 34 RLS-политики, 4 роли БД, 12 триггеров, 4 функции — после v8.5 от 07.05.2026) |
| Кэш / очереди | Redis 7 |
| Pooler | PgBouncer (transaction pooling) |
| Облако | Yandex Cloud, регион `ru-central1` (Москва) |
@@ -204,7 +204,7 @@ trivy image lidpotok:latest
| Файл | Что проверять |
|---|---|
| `db/schema.sql` | 0 orphan-FK, целостность RLS, метрики (53 таблицы + 12 партиций, 86 индексов, 33 RLS), 0 дубликатов `CREATE TABLE` |
| `db/schema.sql` | 0 orphan-FK, целостность RLS, метрики (54 таблицы + 12 партиций, 91 индекс, 34 RLS, 35 ENABLE RLS, 4 роли, 12 триггеров, 4 функции — v8.5), 0 дубликатов `CREATE TABLE` |
| narrative `.md` | Версии в шапке/колонтитуле, 0 «готовится»/«TBD», кросс-ссылки на актуальные имена файлов |
| Прил. А–Н | Версия совпадает с narrative; все упомянутые подразделы существуют |
| Прил. Н (этот реестр инструментов) | Ровно 28 в активном наборе; 0 дублей; синхронность с этим CLAUDE.md |
+2 -2
View File
@@ -76,7 +76,7 @@ lidpotok/
---
*Прил. Л v0.4 от 06.05.2026 — 3/8 прототипов готовы (01–03), narrative и schema на v8.4.*
*Прил. Л v0.4 от 06.05.2026 — 3/8 прототипов готовы (01–03), narrative на v8.4, schema на v8.5 (07.05.2026 — реализация 27 решений аудита C; narrative v8.5 готовится).*
## Документация для разработчика
@@ -87,7 +87,7 @@ lidpotok/
| [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) v1.2 | Продуктовые правила работы Claude в проекте |
| [docs/README_АРХИВ_v8_4.md](docs/README_АРХИВ_v8_4.md) v8.4 | Состав архива, навигатор по документам |
| [docs/CRM_bp-gr_Инструкция_v8_4.md](docs/CRM_bp-gr_Инструкция_v8_4.md) v8.4 | Главное ТЗ из 28 разделов (финал 06.05.2026) |
| [db/schema.sql](db/schema.sql) v8.4 | Схема БД PostgreSQL 16 (53 таблицы + 12 партиций, 86 индексов, 33 RLS-политики) |
| [db/schema.sql](db/schema.sql) v8.5 | Схема БД PostgreSQL 16 (54 таблицы + 12 партиций, 91 индекс, 34 RLS-политики, 4 роли, 12 триггеров, 4 функции — после v8.5 от 07.05.2026) |
## Репозиторий
+1
View File
@@ -688,3 +688,4 @@ soft
дедупликации
DEK
BYTEA
trg
+186 -4
View File
@@ -1,11 +1,12 @@
# CHANGELOG schema.sql — Лидпоток
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит три записи в обратном хронологическом порядке (v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит четыре записи в обратном хронологическом порядке (v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Файл схемы:** `schema.sql` (текущая версия — v8.4, консолидированная — разворачивает БД с нуля).
**Файл схемы:** `schema.sql` (текущая версия — v8.5, консолидированная — разворачивает БД с нуля).
**История записей:**
- **v8.5 (07.05.2026)** — реализация 27 решений аудита C (Открытые_вопросы v1.12). См. ниже §Y.
- **v8.4 (06.05.2026)** — синхронизация с narrative §19.10 (outbound webhook). См. ниже §Z.
- **v8.3 (05.05.2026)** — после параллельного аудита crm.bp-gr.ru, партии 1215. См. ниже §A.
- **v8.2 (04.05.2026)** — после интервью с заказчиком + аудита партий 1–11. См. ниже §B.
@@ -13,11 +14,192 @@
**Связано:**
- `Прил_М_Analiz_originala_v8_3.md` v1.1 — обоснование изменений v8.3 (§3.5) и v8.2 (§3.13.3).
- `Открытые_вопросы_v8_3.md` v1.10продуктовые вопросы Биз-10..16, OPEN-И-2.
- `Открытые_вопросы_v8_3.md` v1.12закрытие 27 вопросов аудита C, §13.10 — источник изменений v8.5.
- `README_АРХИВ_v8_4.md` — состав архива.
- `CRM_bp-gr_Инструкция_v8_4.md` v8.4 §19.10 — outbound webhook (источник изменений v8.4, финал 06.05.2026).
- `CRM_bp-gr_Инструкция_v8_5.md` (готовится) — narrative-обоснование v8.5 для §10/§12.5.5/§14/§17/§19.10/§22/§23.10/Прил.И.
**Замечание о нумерации:** внутри каждой записи разделы пронумерованы с префиксом записи (`Z.0`, `Z.1`, …, `A.0`, `A.1`, …, `B.0`, `B.1`, …) для устранения коллизий при кросс-ссылках. Изначальная нумерация `## 0`, `## 1` исходных CHANGELOG-файлов сохранена в виде второй части ID (после префикса).
**Замечание о нумерации:** внутри каждой записи разделы пронумерованы с префиксом записи (`Y.0`, `Y.1`, …, `Z.0`, `Z.1`, …, `A.0`, `A.1`, …, `B.0`, `B.1`, …) для устранения коллизий при кросс-ссылках. Изначальная нумерация `## 0`, `## 1` исходных CHANGELOG-файлов сохранена в виде второй части ID (после префикса).
---
# Запись Y — v8.4 → v8.5 (07.05.2026)
**Источник изменений:**
- Закрытие 27 вопросов аудита C от 07.05.2026 (Открытые_вопросы v1.12, §13.10). Решение заказчика: «A везде» (рекомендованные варианты).
- 8 P0 разблокированы для триггера фазы 1 (`composer create-project laravel/laravel app`): Биз-17/18/19, CTO-13, OPEN-И-13/14/15/16.
- 12 P1 + 7 P2 — реализация в фазах 1–3.
## Y.0. Сводка
| Параметр | v8.4 | v8.5 | Δ |
|---|---|---|---|
| Логических таблиц | 53 | **54** | +1 (project_user_assignments) |
| Партиций | 12 | 12 | =0 |
| Индексов | 86 | **91** | +5 |
| RLS-политик | 34 | **35** (+ WITH CHECK на 2 существующих) | +1 (project_user_assignments) |
| Защищённых таблиц (ENABLE) | 34 | **35** | +1 |
| Ролей БД | 3 | **4** | +1 (crm_audit_writer) |
| Триггеров | 0 | **12** | +12 |
| Функций | 0 | **4** | +4 |
| Колонок (приращение) | — | **+26** | +26 (см. §Y.2) |
| Полей в `tenants` | 23 | 25 | +2 (api_key_limit, telegram_bot_token) |
## Y.1. Новые таблицы
### Y.1.1. `project_user_assignments` (CTO-16)
M2M-связь «проект ↔ менеджеры» с per-assignment skills (JSONB-массив). На MVP при `projects.assignment_strategy='manual'` (Биз-17 default) таблица не используется. После Post-MVP cron lead-router читает active members проекта и распределяет лидов согласно стратегии.
**Поля:** `project_id, user_id, skills JSONB, is_active, created_at, updated_at`. PK = `(project_id, user_id)`.
**Индекс:** `idx_project_user_assignments_user` partial WHERE `is_active=TRUE`.
**RLS:** `tenant_isolation` через JOIN на `projects.tenant_id` (USING + WITH CHECK).
## Y.2. Новые колонки
### Y.2.1. P0-блок (8)
- **`projects.assignment_strategy`** (Биз-17) `VARCHAR(32) NOT NULL DEFAULT 'manual'` + CHECK `IN ('manual','round_robin','least_loaded')`. MVP = manual; round_robin/least_loaded зарезервированы для Post-MVP.
- **`projects.ttfr_target_minutes`** (Биз-18) `INT NOT NULL DEFAULT 15` + CHECK `BETWEEN 1 AND 1440`. SLA по Time To First Response, alert при просрочке.
- **`deals.duplicate_of_id`** (Биз-19) `BIGINT` (без FK — партиционированная таблица). Окно дедупа 24 ч, проверяется приложением через индекс `(tenant_id, phone)`.
- **`deals.escalated_count`** (OPEN-И-25) `INT NOT NULL DEFAULT 0` + CHECK `>= 0`. Счётчик переназначений cron'ом `leads:escalate-stale`.
- **`deals.assigned_at`** (OPEN-И-25) `TIMESTAMPTZ`. Момент назначения менеджера.
- **`saas_admin_users.sso_provider`** (OPEN-И-13) `VARCHAR(32) NOT NULL DEFAULT 'yandex360'` + CHECK `IN ('yandex360','local')`.
- **`saas_admin_users.is_break_glass`** (OPEN-И-13) `BOOLEAN NOT NULL DEFAULT FALSE`. Аварийный аккаунт при недоступности IDP.
- **`auth_log.log_hash`, `activity_log.log_hash`, `pd_processing_log.log_hash`, `saas_admin_audit_log.log_hash`, `balance_transactions.log_hash`** (OPEN-И-15) `BYTEA` — заполняется триггером `audit_chain_hash()` (см. §Y.4.1).
### Y.2.2. P1-блок (12)
- **`tenants.api_key_limit`** (OPEN-И-19) `INT NOT NULL DEFAULT 5` + CHECK `BETWEEN 1 AND 10`.
- **`tenants.telegram_bot_token`** (Биз-20) `TEXT` (зашифровано Crypt::encryptString).
- **`users.telegram_user_id`** (Биз-20) `BIGINT` (Telegram chat_id).
- **`impersonation_tokens.second_approver_id`** (CTO-15 + Ю-9) `BIGINT REFERENCES saas_admin_users(id)`.
- **`impersonation_tokens.second_approval_at`** (CTO-15 + Ю-9) `TIMESTAMPTZ`.
- **`deals.utm_source/utm_medium/utm_campaign/utm_content`** (CTO-14) 4 × `VARCHAR(100)`.
- **`suppliers.quality_score`** (Биз-22) `NUMERIC(3,2) NOT NULL DEFAULT 1.00` + CHECK `BETWEEN 0.00 AND 9.99`.
- **`deals.time_in_form_seconds`** (Биз-22) `INT`. Сколько секунд физлицо заполняло форму.
- **`deals.lead_score`** (Биз-22) `NUMERIC(5,2)` (заполняется триггером `calc_lead_score()`, см. §Y.4.2).
### Y.2.3. P2-блок (7) — 2 новых колонки + остальные через DDL/триггеры
- **`deals.region_code`** (Биз-23) `VARCHAR(8)`. ISO 3166-2:RU; автоопределение по prefix phone в PhonePrefixService.
- **`deals.city`** (Биз-23) `VARCHAR(100)`. Свободный текст из webhook/enrichment.
(Остальные P2-решения реализованы через DDL-блоки: триггеры/функции в §Y.4, закомментированный задел `call_recordings` для Биз-12/OPEN-И-26 — в новой секции 17 schema.sql.)
### Y.2.4. ALTER (1)
- **`api_keys.expires_at`** (OPEN-И-17 + OPEN-И-19): из NULL-able без default → `NOT NULL DEFAULT NOW() + INTERVAL '365 days'`. Миграция: backfill всем существующим NULL-ключам `NOW() + 365d` перед применением `SET NOT NULL`.
## Y.3. Новые индексы (5)
- **`(tenant_id, utm_source) WHERE utm_source IS NOT NULL`** на `deals` (CTO-14). Для когортной аналитики §12.5.5.
- **`(tenant_id, region_code) WHERE region_code IS NOT NULL`** на `deals` (Биз-23). Для гео-фильтра в §10.3.
- **`(duplicate_of_id) WHERE duplicate_of_id IS NOT NULL`** на `deals` (Биз-19). Для UI-цепочки дублей и cleanup при удалении master'а.
- **`(tenant_id, assigned_at) WHERE status NOT IN ('closed','rejected')`** на `deals` (OPEN-И-25). Для cron `leads:escalate-stale`.
- **`idx_project_user_assignments_user(user_id) WHERE is_active = TRUE`** (CTO-16). Для «список моих назначений».
Индекс `(tenant_id, phone, received_at)` для Биз-19 24ч-lookup НЕ создан — существующий `(tenant_id, phone)` справляется с фильтрацией приложением (24-часовое окно — мелкая выборка).
## Y.4. Новые функции и триггеры
### Y.4.1. `audit_chain_hash()` + `audit_block_mutation()` + 10 audit-триггеров (OPEN-И-15)
```sql
audit_chain_hash() RETURNS TRIGGER:
prev_hash := SELECT log_hash FROM TG_TABLE_NAME ORDER BY id DESC LIMIT 1;
NEW.log_hash := digest(COALESCE(prev_hash, '') || NEW::text::bytea, 'sha256');
RETURN NEW;
audit_block_mutation() RETURNS TRIGGER:
RAISE EXCEPTION 'audit log is append-only (table %): UPDATE/DELETE forbidden', TG_TABLE_NAME;
```
На каждой из 5 audit-таблиц по 2 триггера: `BEFORE INSERT` (hash chain) + `BEFORE UPDATE OR DELETE` (block mutation). Итого 10 триггеров.
**Юридический эффект:** любая модификация audit-журнала после INSERT'а технически запрещена. При попытке INSERT с поддельной строкой между существующими — пересчёт цепочки в cron `audit:verify-chain` обнаружит разрыв (sha256-mismatch).
**Защита от ALTER TABLE … DISABLE TRIGGER:** даже при отключении триггеров роль `crm_audit_writer` (см. §Y.5) имеет только INSERT — UPDATE/DELETE заблокированы на уровне permissions.
### Y.4.2. `calc_lead_score()` + триггер `trg_deals_calc_lead_score` (Биз-22)
```sql
calc_lead_score() RETURNS TRIGGER:
IF NEW.time_in_form_seconds IS NULL lead_score := NULL; RETURN.
quality := SELECT s.quality_score FROM project_suppliers ps JOIN suppliers s
WHERE ps.project_id = NEW.project_id AND ps.is_active AND s.is_active
ORDER BY s.sort_order, s.id LIMIT 1;
NEW.lead_score := LEAST(quality * (time_in_form_seconds / 60.0), 99.99);
```
Триггер `BEFORE INSERT OR UPDATE OF time_in_form_seconds, project_id ON deals`. Использован триггер вместо `GENERATED ALWAYS AS … STORED` потому что PostgreSQL не разрешает в STORED-выражениях JOIN на foreign tables.
### Y.4.3. `report_jobs_log_export()` + триггер `trg_report_jobs_export_log` (OPEN-И-20)
`AFTER INSERT ON report_jobs → INSERT INTO pd_processing_log (action='exported', purpose='report_job_<id>')`. Закрывает риск пропуска audit-записи при экспорте лидов из app-кода.
## Y.5. Новая роль `crm_audit_writer` (OPEN-И-15 + OPEN-И-23)
```sql
CREATE ROLE crm_audit_writer LOGIN PASSWORD '<from-secrets>';
GRANT USAGE ON SCHEMA public;
GRANT INSERT ON auth_log, activity_log, pd_processing_log,
saas_admin_audit_log, balance_transactions;
GRANT USAGE ON sequences соответствующих таблиц.
-- запрещено: SELECT, UPDATE, DELETE, TRUNCATE.
```
Application пишет в audit-таблицы под этой ролью через temporary `SET ROLE crm_audit_writer`, что обеспечивает невозможность fraud-удаления записей даже от `super_admin` SaaS.
## Y.6. RLS WITH CHECK (OPEN-И-14)
Существующие политики `tenant_isolation` на двух таблицах обогащены `WITH CHECK`:
- **`saas_invoice_items`** — нельзя вставить invoice_item ссылающуюся на чужой invoice.
- **`deal_tag_pivot`** — нельзя пометить deal чужим тегом.
До v8.5 защита была только на USING (SELECT/UPDATE filter), INSERT мог пройти при наличии knowledge о `tag_id` чужого тенанта.
Новая политика `project_user_assignments` создана с обоими USING + WITH CHECK сразу.
## Y.7. REVOKE ALL на 6 saas-таблицах (OPEN-И-14)
Defense-in-depth: к этим таблицам tenant-приложение (роль `crm_app_user`) доступа НЕ должно иметь даже теоретически.
```sql
REVOKE ALL ON saas_admin_users FROM crm_app_user;
REVOKE ALL ON saas_admin_sessions FROM crm_app_user;
REVOKE ALL ON saas_admin_audit_log FROM crm_app_user;
REVOKE ALL ON incidents_log FROM crm_app_user;
REVOKE ALL ON pd_subject_requests FROM crm_app_user;
REVOKE ALL ON impersonation_tokens FROM crm_app_user;
```
## Y.8. Изменения, не отражённые в schema (только в narrative)
- **CTO-13** (e2e-тест `SET LOCAL` через PgBouncer transaction-pooling) — план в narrative §22 + Прил. И; без DDL.
- **OPEN-И-16** (Sentry whitelist + regex) — конфигурация Laravel `config/sentry.php` `before_send`; без DDL.
- **OPEN-И-18** (DNS-rebinding защита resolve→pin→connect) — реализация в `App\Services\Webhook\SSRFGuard`; без DDL.
- **OPEN-И-21** (Anti-DDoS: Nginx + Yandex SmartCaptcha + disposable-blacklist) — конфиг Nginx + Laravel middleware; без DDL.
- **OPEN-И-22** (per-tenant DEK Yandex KMS) — на уровне backup-сервиса (Прил. И); без DDL.
- **OPEN-И-24** (`pg_anonymizer` процедура) — Прил. И, расширение PG ставится в фазе 3 (Прил. Н).
- **Биз-20** Telegram-канал — спринт 9, реализация в фазе 2; DDL уже добавлен (telegram_user_id, telegram_bot_token), используется по факту с фазы 2.
- **Биз-21** generic outbound `marketing.conversion` — расширение whitelist событий в `App\Services\Outbound\EventTypes`; без DDL (хранится в `outbound_webhook_subscriptions.events`).
## Y.9. Что НЕ добавлено в v8.5 (отложено)
- Таблицы `crm_connections` / `crm_field_mappings` (Уровень 2 OPEN-И-2) — спринты 1415 (как и в v8.4).
- Структуры под Биз-12 (телефония + call recording) — `call_recordings` оставлена закомментированным заделом в секции 17 schema.sql; реальная активация Post-MVP при первом запросе клиента.
- Расширения PG `pg_partman`/`pgaudit`/`pg_anonymizer` — фаза 3 по Прил. Н.
## Y.10. Совместимость
- **Forward-only:** v8.5 разворачивается с нуля и не требует миграции с v8.4 (база ещё не в production — фаза 0).
- **Будущая прод-миграция** (после Б-1 → спринт 11+) — единственная транзакция `BEGIN; \i schema.sql; COMMIT;` от пустой базы до текущей версии.
- **Backfill для `api_keys.expires_at`** — при переходе с v8.4 на v8.5 на dev/staging выполнить `UPDATE api_keys SET expires_at = NOW() + INTERVAL '365 days' WHERE expires_at IS NULL;` ДО применения `ALTER ... SET NOT NULL`. На production это не требуется (база с нуля).
---
+529 -15
View File
@@ -1,12 +1,12 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидпоток»)
-- Версия: v8.4 (06.05.2026, синхронизация с narrative §19.10 outbound webhook)
-- Базовая версия: v8.3 (05.05.2026, после параллельного аудита партий 12–15)
-- Версия: v8.5 (07.05.2026, реализация 27 решений аудита C из реестра v1.12)
-- Базовая версия: v8.4 (06.05.2026, синхронизация с narrative §19.10 outbound webhook)
-- СУБД: PostgreSQL 16
-- Кодировка: UTF8, локаль ru_RU.UTF-8
-- =============================================================================
--
-- ИСТОЧНИК: документация v8.4 (CRM_bp-gr_Инструкция_v8_4.md), 28 разделов.
-- ИСТОЧНИК: документация v8.5 (CRM_bp-gr_Инструкция_v8_5.md), 28 разделов.
-- Каждый блок помечен ссылкой на исходный раздел.
--
-- АРХИТЕКТУРНАЯ МОДЕЛЬ:
@@ -23,6 +23,100 @@
-- B2 — sender_name + keyword; B3 — только sender_name; B1 — sites/calls.
-- Для генерации UI-формы проекта suppliers содержит поля supports_*.
--
-- ИЗМЕНЕНИЯ v8.4 → v8.5:
-- ИЗ ЗАКРЫТИЯ АУДИТА C (Открытые_вопросы v1.12, 07.05.2026, 27 решений):
-- P0 (8) — обязательны до триггера фазы 1:
-- 1. Биз-17 — projects.assignment_strategy VARCHAR(32) DEFAULT 'manual'
-- + CHECK IN ('manual','round_robin','least_loaded'). MVP = manual.
-- 2. Биз-18 — projects.ttfr_target_minutes INT DEFAULT 15 (Time To First
-- Response SLA, alert при просрочке через event bus + UI badge).
-- 3. Биз-19 — deals.duplicate_of_id BIGINT NULL ON DELETE SET NULL +
-- индекс (tenant_id, phone, received_at) для O(log n) lookup в окне
-- 24 ч. Антифрод: дубль помечается, но НЕ списывается с баланса.
-- 4. CTO-13 — обязательный e2e-тест SET LOCAL app.current_tenant_id
-- через PgBouncer transaction-pooling в спринте 1. Без изменений
-- схемы, только тест-план в narrative §22 + Прил. И.
-- 5. OPEN-И-13 — saas_admin_users.sso_provider VARCHAR(32) DEFAULT
-- 'yandex360' + saas_admin_users.is_break_glass BOOLEAN DEFAULT
-- FALSE (OIDC + JIT-provisioning + локальный 2FA выключен,
-- fallback — break-glass super_admin).
-- 6. OPEN-И-14 — WITH CHECK на политики deal_tag_pivot, saas_invoice_items
-- + REVOKE ALL на 6 saas-таблицах от crm_app_user (saas_admin_users,
-- saas_admin_sessions, saas_admin_audit_log, incidents_log,
-- pd_subject_requests, impersonation_tokens).
-- 7. OPEN-И-15 — append-only audit hash chain. Колонка log_hash BYTEA
-- NOT NULL на 5 audit-таблицах (auth_log, activity_log,
-- pd_processing_log, saas_admin_audit_log, balance_transactions).
-- 5 BEFORE UPDATE/DELETE триггеров с RAISE EXCEPTION + функция
-- audit_chain_hash() = sha256(prev.log_hash || row). Новая роль
-- crm_audit_writer (только INSERT) — REVOKE UPDATE/DELETE даже у
-- super_admin через триггеры.
-- 8. OPEN-И-16 — Sentry whitelist + regex маска phone/email/password/
-- secret/token/api_key. Без изменений схемы — конфигурация в Laravel
-- config/sentry.php (narrative §22 «Sentry PII-scrubbing»).
-- P1 (12) — фазы 12:
-- 9. Биз-20 — Telegram-бот в спринте 9. users.telegram_user_id BIGINT
-- NULL + tenants.telegram_bot_token TEXT NULL (зашифровано).
-- 10. Биз-21 — generic outbound `marketing.conversion` через §19.10
-- (расширение whitelist событий). Без изменений схемы.
-- 11. Биз-22 — простой scoring. suppliers.quality_score NUMERIC(3,2)
-- DEFAULT 1.00 + deals.time_in_form_seconds INT NULL + deals.lead_score
-- NUMERIC(5,2) GENERATED ALWAYS AS (...) STORED.
-- 12. CTO-14 — UTM-поля. deals.utm_source/utm_medium/utm_campaign/
-- utm_content VARCHAR(100) NULL + индекс (tenant_id, utm_source).
-- 13. CTO-15 — two-person impersonation. impersonation_tokens.
-- second_approver_id BIGINT NULL REFERENCES saas_admin_users(id) +
-- second_approval_at TIMESTAMPTZ NULL.
-- 14. CTO-16 — skill-based routing. Новая таблица project_user_assignments
-- (project_id, user_id, skills JSONB) + RLS-политика через JOIN на
-- projects.tenant_id + индекс на (project_id).
-- 15. OPEN-И-17 — TTL 365 дней на api_keys/webhook_token/outbound.
-- secret_hash. ALTER api_keys.expires_at SET DEFAULT NOW() + 365d.
-- 16. OPEN-И-18 — DNS-rebinding защита. Без изменений схемы — реализация
-- в SSRFGuard service (narrative §19.10).
-- 17. OPEN-И-19 — лимит api_keys. tenants.api_key_limit INT DEFAULT 5
-- NOT NULL CHECK (api_key_limit BETWEEN 1 AND 10) + ALTER api_keys.
-- expires_at SET NOT NULL (миграция: backfill всем NOW + 365d).
-- 18. OPEN-И-20 — signed URL + триггер audit. Триггер trg_report_jobs_
-- export_log AFTER INSERT ON report_jobs → INSERT pd_processing_log
-- action='exported'.
-- 19. OPEN-И-21 — Anti-DDoS. Без изменений схемы — Nginx + Yandex
-- SmartCaptcha + disposable-blacklist (narrative §22 + Прил. И).
-- 20. Ю-9 — hard-блок impersonation для всех ролей кроме compliance
-- при processing_restricted=TRUE. Реализация в SaasAdminAuthService
-- (narrative §22.7), без изменений схемы.
-- P2 (7) — фазы 13:
-- 21. Биз-23 — гео-таргетинг. deals.region_code VARCHAR(8) NULL +
-- deals.city VARCHAR(100) NULL + индекс (tenant_id, region_code).
-- 22. Биз-24 — алерт saappоrtу о просрочке waiting_payment → paid
-- через 48 ч. Cron payments:notify-stale (narrative §17), без
-- изменений схемы кроме system_settings ключей.
-- 23. OPEN-И-22 — per-tenant DEK в Yandex KMS. Без изменений схемы —
-- encryption envelope на уровне backup-сервиса (Прил. И).
-- 24. OPEN-И-23 — роль crm_audit_writer уже создана в OPEN-И-15.
-- Здесь — только подтверждение: INSERT-only access; UPDATE/DELETE
-- блокируются триггерами.
-- 25. OPEN-И-24 — pg_anonymizer процедура. Документация в Прил. И,
-- без изменений схемы (расширение ставится в фазе 3 по Прил. Н).
-- 26. OPEN-И-25 — эскалация лидов. deals.assigned_at TIMESTAMPTZ NULL +
-- deals.escalated_count INT DEFAULT 0. Cron leads:escalate-stale
-- (narrative §10 + §17).
-- 27. OPEN-И-26 — задел под call-recording (Биз-12 Post-MVP).
-- Закомментированный DDL call_recordings(...) в конце файла.
-- Итого: +1 таблица (project_user_assignments), +26 колонок (suppliers.
-- quality_score; saas_admin_users.sso_provider/is_break_glass;
-- impersonation_tokens.second_approver_id/second_approval_at;
-- tenants.api_key_limit/telegram_bot_token; projects.assignment_strategy/
-- ttfr_target_minutes; users.telegram_user_id; deals.assigned_at/
-- escalated_count/duplicate_of_id/utm_source/utm_medium/utm_campaign/
-- utm_content/region_code/city/time_in_form_seconds/lead_score; +log_hash
-- на 5 audit-таблицах) + ALTER api_keys.expires_at NOT NULL DEFAULT
-- NOW()+365d, +12 триггеров (5×2 audit append-only + 1 report_jobs export
-- log + 1 deals lead_score), +4 функции (audit_chain_hash,
-- audit_block_mutation, report_jobs_log_export, calc_lead_score),
-- +1 роль (crm_audit_writer), +5 индексов, +2 политики с WITH CHECK,
-- +1 политика (project_user_assignments), REVOKE на 6 saas-таблицах.
--
-- ИЗМЕНЕНИЯ v8.3 → v8.4:
-- ИЗ ПЕРЕПИСЫВАНИЯ NARRATIVE v8.4 (06.05.2026, §19.10 outbound webhook):
-- 1. Новая таблица outbound_webhook_subscriptions (раздел 19.10) —
@@ -97,16 +191,29 @@
-- 14) Row-Level Security (CTO-5: включён на MVP)
-- 15) Роли БД (CTO-5)
--
-- РАЗМЕР СХЕМЫ v8.4:
-- • 53 логических таблицы (51 из v8.3 + outbound_webhook_subscriptions
-- + outbound_webhook_deliveries).
-- РАЗМЕР СХЕМЫ v8.5:
-- • 54 логических таблицы (53 из v8.4 + project_user_assignments v8.5).
-- • 12 партиций (6 у deals + 6 у supplier_lead_costs).
-- • 86 индексов (81 из v8.3 + 5 новых: idx_outbound_subs_tenant_active,
-- idx_outbound_subs_secret_prefix, idx_outbound_deliveries_subscription,
-- idx_outbound_deliveries_status_pending, idx_outbound_deliveries_created).
-- • 34 RLS-политики (31 из v8.3 + 2 на outbound_webhook_* + 1 на deal_tag_pivot v8.4-hotfix).
-- • 34 защищённых таблиц с ENABLE ROW LEVEL SECURITY (1:1 соответствие политикам).
-- • 3 роли БД (без изменений).
-- • 91 индекс (86 из v8.4 + 5 новых: idx_deals_utm_source,
-- idx_deals_region_code, idx_deals_duplicate_of, idx_deals_assigned_at_open,
-- idx_project_user_assignments_user).
-- • 35 RLS-политик (34 из v8.4 + 1 на project_user_assignments через JOIN).
-- Из них 2 политики обогащены WITH CHECK (deal_tag_pivot, saas_invoice_items).
-- • 35 защищённых таблиц с ENABLE ROW LEVEL SECURITY (1:1 соответствие политикам).
-- • 4 роли БД (3 из v8.4 + crm_audit_writer — только INSERT на 5 audit-таблицах).
-- • 12 триггеров: на 5 audit-таблицах (auth_log/activity_log/pd_processing_log/
-- saas_admin_audit_log/balance_transactions) — по 2 (BEFORE INSERT для hash
-- chain + BEFORE UPDATE/DELETE для запрета мутаций) = 10; +1 на report_jobs
-- (AFTER INSERT — журнал экспорта в pd_processing_log по 152-ФЗ ст.18);
-- +1 на deals (BEFORE INSERT/UPDATE — расчёт lead_score через
-- supplier.quality_score × time_in_form, Биз-22).
-- • 4 функции: audit_chain_hash() (SHA-256 hash chain для tamper-detection),
-- audit_block_mutation() (RAISE EXCEPTION для запрета UPDATE/DELETE),
-- report_jobs_log_export() (auto-логирование экспорта),
-- calc_lead_score() (расчёт lead score без ML).
-- • REVOKE ALL на 6 saas-таблицах от crm_app_user (saas_admin_users,
-- saas_admin_sessions, saas_admin_audit_log, incidents_log,
-- pd_subject_requests, impersonation_tokens) — defense-in-depth.
-- =============================================================================
@@ -247,6 +354,12 @@ CREATE TABLE suppliers (
supports_keyword BOOLEAN NOT NULL DEFAULT FALSE,
supports_csv_upload BOOLEAN NOT NULL DEFAULT TRUE, -- для B1: загрузка CSV-доменов
supports_domains_list BOOLEAN NOT NULL DEFAULT TRUE, -- для B1: textarea со списком доменов
-- v8.5 (Биз-22): простая lead scoring модель без ML.
-- Используется в формуле deals.lead_score = supplier_quality × time_in_form.
-- 1.00 = baseline; админ SaaS вручную поднимает/понижает в /admin/suppliers
-- по фактической конверсии за месяц. Post-MVP — auto-adjustment по cron.
quality_score NUMERIC(3,2) NOT NULL DEFAULT 1.00
CHECK (quality_score BETWEEN 0.00 AND 9.99),
is_active BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
@@ -304,9 +417,18 @@ CREATE TABLE saas_admin_users (
role VARCHAR(20) NOT NULL
CHECK (role IN ('super_admin','support','finance','compliance','dev_oncall','read_only')),
is_active BOOLEAN DEFAULT TRUE,
-- 2FA (обязательно для всех админов)
-- 2FA (обязательно для всех админов; v8.5 OPEN-И-13: при sso_provider != 'local'
-- локальный 2FA выключен и заменён на 2FA провайдера IDP)
totp_secret_enc TEXT, -- зашифровано Crypt::encryptString
totp_enabled_at TIMESTAMPTZ,
-- v8.5 (OPEN-И-13): SSO + JIT-provisioning + break-glass.
-- sso_provider — кто аутентифицировал (Yandex 360 OIDC по умолчанию;
-- 'local' — fallback для break-glass и dev). При is_break_glass=TRUE этот
-- аккаунт обходит SSO для аварийного входа при недоступности IDP, но force-2FA
-- через TOTP остаётся. Логика guard'ов — в SaasAdminAuthService (narrative §22.7).
sso_provider VARCHAR(32) NOT NULL DEFAULT 'yandex360'
CHECK (sso_provider IN ('yandex360','local')),
is_break_glass BOOLEAN NOT NULL DEFAULT FALSE,
-- IP allow-list (per-user, дополняет глобальный)
allowed_ips JSONB, -- ["10.0.0.0/8","1.2.3.4"]
-- Безопасность
@@ -398,6 +520,13 @@ CREATE TABLE impersonation_tokens (
session_ended_at TIMESTAMPTZ,
failed_attempts INT DEFAULT 0, -- защита от брутфорса
invalidated_at TIMESTAMPTZ, -- ручная инвалидация админом или клиентом
-- v8.5 (CTO-15 + Ю-9): two-person approval для тенантов с
-- pd_subject_request.processing_restricted=TRUE ИЛИ chargeback_unrecovered_rub > 0.
-- При создании токена SaasAdminAuthService проверяет флаги тенанта; если
-- хотя бы один TRUE — second_approver_id обязателен (роль 'compliance').
-- Полный flow guard'ов в narrative §22.7.X. NULL для обычных тенантов.
second_approver_id BIGINT REFERENCES saas_admin_users(id),
second_approval_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT chk_imp_reason_len CHECK (length(reason) >= 30)
);
@@ -499,6 +628,15 @@ CREATE TABLE tenants (
-- в админке SaaS (саппорту видно, сколько клиент хочет получать в день).
desired_daily_numbers INT
CHECK (desired_daily_numbers IS NULL OR desired_daily_numbers > 0),
-- v8.5 (OPEN-И-19): hard-limit количества активных API-ключей.
-- Защита от DoS через создание тысяч ключей. Default 5 — комфортный
-- baseline; max 10 — для intg-партнёров. Hard limit, не soft warning.
api_key_limit INT NOT NULL DEFAULT 5
CHECK (api_key_limit BETWEEN 1 AND 10),
-- v8.5 (Биз-20): Telegram-бот для нотификаций менеджерам. Спринт 9.
-- Хранится в зашифрованном виде через Crypt::encryptString. Вне спринта 9
-- остаётся NULL и фича не активна (UI скрывает Telegram-настройки).
telegram_bot_token TEXT,
-- Метаданные
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
@@ -569,6 +707,10 @@ CREATE TABLE users (
"marketing": {"email": false}
}'::jsonb,
sound_enabled BOOLEAN DEFAULT TRUE, -- общий toggle звука для in-app уведомлений
-- v8.5 (Биз-20): Telegram-канал нотификаций. Спринт 9. До спринта 9
-- остаётся NULL и UI скрывает Telegram-настройки. Подключение — через
-- бот /start: пользователь отправляет код, бот связывает chat_id с user_id.
telegram_user_id BIGINT, -- Telegram chat_id
-- Статус
email_verified_at TIMESTAMPTZ,
last_login_at TIMESTAMPTZ,
@@ -633,6 +775,18 @@ CREATE TABLE projects (
delivery_days_mask INT NOT NULL DEFAULT 127,
-- битмаска дней недели: бит 1=Пн, 2=Вт, 4=Ср, 8=Чт, 16=Пт, 32=Сб, 64=Вс.
-- 127 = все 7 дней (паритет с формой создания нового проекта в оригинале).
-- v8.5 (Биз-17): стратегия автораспределения лидов между менеджерами.
-- MVP = 'manual' (паритет с оригиналом, ручное назначение в карточке сделки).
-- 'round_robin'/'least_loaded' зарезервированы для Post-MVP — реализация
-- через таблицу project_user_assignments (CTO-16) + cron-балансировку.
assignment_strategy VARCHAR(32) NOT NULL DEFAULT 'manual'
CHECK (assignment_strategy IN ('manual','round_robin','least_loaded')),
-- v8.5 (Биз-18): Time To First Response SLA в минутах.
-- При просрочке (NOW() - deals.received_at > ttfr_target_minutes AND
-- deals.status='new' AND deals.manager_id IS NULL) — alert менеджеру и
-- админу через event bus + UI badge на карточке. Метрика на дашборде §12.5.5.
ttfr_target_minutes INT NOT NULL DEFAULT 15
CHECK (ttfr_target_minutes BETWEEN 1 AND 1440),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
UNIQUE (tenant_id, name),
@@ -694,6 +848,43 @@ COMMENT ON TABLE project_suppliers IS
'все is_active=TRUE поставщики (паритет с формой создания в оригинале).';
-- -----------------------------------------------------------------------------
-- project_user_assignments — m2m "проект ↔ менеджеры" + skills (НОВАЯ v8.5, CTO-16)
-- -----------------------------------------------------------------------------
-- Назначение: skill-based / regional routing для projects.assignment_strategy
-- IN ('round_robin','least_loaded') (Биз-17 Post-MVP). На MVP при assignment_
-- strategy='manual' таблица не используется — менеджер выбирается вручную в
-- карточке сделки. После Post-MVP cron lead-router-будет читать active members
-- проекта из этой таблицы и распределять входящих лидов.
--
-- skills — JSONB-массив строк (slug-и навыков): ['it_b2b','retail','sms_sender_NAME1'].
-- При assignment_strategy='round_robin' — игнорируется. При 'least_loaded' —
-- для статистики. Skill-based routing (intersection projects.required_skills
-- × users.skills) — Post-MVP-расширение.
--
-- TENANT-уровневая через JOIN на projects (RLS по tenant_id проекта).
-- -----------------------------------------------------------------------------
CREATE TABLE project_user_assignments (
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
skills JSONB NOT NULL DEFAULT '[]'::jsonb, -- ['it_b2b','retail',...]
is_active BOOLEAN DEFAULT TRUE, -- временно отключить менеджера в проекте
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
PRIMARY KEY (project_id, user_id),
CONSTRAINT chk_pua_skills_array CHECK (jsonb_typeof(skills) = 'array')
);
CREATE INDEX idx_project_user_assignments_user
ON project_user_assignments(user_id)
WHERE is_active = TRUE;
COMMENT ON TABLE project_user_assignments IS
'M2M "проект ↔ менеджеры" с per-assignment skills для skill-based routing. '
'При projects.assignment_strategy=''manual'' (MVP) таблица не используется. '
'При round_robin/least_loaded (Post-MVP) — пул активных менеджеров проекта.';
-- -----------------------------------------------------------------------------
-- project_limit_adjustments — лог автокоррекций лимитов (НОВАЯ в v8.2)
-- -----------------------------------------------------------------------------
@@ -803,7 +994,12 @@ CREATE TABLE api_keys (
scopes JSONB DEFAULT '["read"]', -- ["read","write","delete"]
last_used_at TIMESTAMPTZ,
last_used_ip INET,
expires_at TIMESTAMPTZ, -- NULL = бессрочный
-- v8.5 (OPEN-И-17): TTL обязательный, max 365 дней. Бессрочные ключи
-- запрещены — security-best-practice. Cron secrets:notify-expiring
-- уведомляет владельца за 30 дней до expiry. UI «продлить» ставит +365d
-- и пишет в audit. До v8.5 expires_at был NULL-able — миграция backfill'ит
-- всем существующим ключам NOW() + 365d при ALTER NOT NULL.
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '365 days'),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
@@ -897,6 +1093,13 @@ CREATE TABLE auth_log (
ip_address INET,
user_agent TEXT,
failure_reason VARCHAR(100), -- 'invalid_password', 'invalid_2fa', ...
-- v8.5 (OPEN-И-15): hash chain для tamper-detection.
-- Заполняется триггером trg_audit_chain_hash_auth_log (функция
-- audit_chain_hash() — см. секцию «Аудит append-only» внизу файла):
-- log_hash = sha256(prev.log_hash || NEW::text). Если кто-то удалит
-- среднюю строку или вставит фейковую — пересчёт цепочки покажет
-- разрыв. UPDATE/DELETE заблокированы триггером BEFORE.
log_hash BYTEA, -- NULL → fill via trigger BEFORE INSERT
created_at TIMESTAMPTZ DEFAULT NOW(),
-- Целостность: actor должен быть ровно одного типа
CONSTRAINT chk_auth_log_actor CHECK (
@@ -1003,11 +1206,52 @@ CREATE TABLE deals (
-- (множественные напоминания на сделку, паритет с histories[].type='reminder'
-- оригинала — партия 12.2.5 аудита).
manager_id BIGINT, -- FK не ставится (партиционированная)
-- v8.5 (OPEN-И-25): момент назначения менеджера. Используется cron'ом
-- leads:escalate-stale (каждые 30 мин): если NOW() - assigned_at > 4h
-- AND status NOT IN ('closed','rejected') → reassign + email + escalated_count++.
-- NULL для неназначенных лидов (тогда работает Биз-18 TTFR с received_at).
assigned_at TIMESTAMPTZ,
escalated_count INT NOT NULL DEFAULT 0,
-- v8.5 (Биз-19): антифрод-дедуп по phone. duplicate_of_id указывает на
-- master-сделку, дубль НЕ списывает лид с баланса. Окно — 24 ч от
-- received_at master'а (проверяется приложением через индекс tenant_id+phone).
-- Master помечается duplicate_of_id=NULL, дубли указывают на её id.
-- ON DELETE SET NULL — если master удалён, дубли остаются как self-standing.
-- БЕЗ FK (deals партиционирована — partition-wise FK не поддерживается).
duplicate_of_id BIGINT,
-- v8.5 (CTO-14): UTM-метки для когортной аналитики §12.5.5.
-- Заполняются из webhook payload (если приходят) или landing page (utm_*
-- query params, переданные в форму). Партиционные индексы — ниже.
utm_source VARCHAR(100),
utm_medium VARCHAR(100),
utm_campaign VARCHAR(100),
utm_content VARCHAR(100),
-- v8.5 (Биз-23): гео-таргетинг сделок. region_code = ISO 3166-2:RU
-- (например 'RU-MOW' Москва, 'RU-SPE' СПб) — автоопределяется по prefix
-- phone через PhonePrefixService (Минсвязи API offline). NULL если не
-- удалось определить. city — свободный текст (приходит из webhook или
-- enrichment-сервиса). Используется для filter в §10.3 + аналитики §12.
region_code VARCHAR(8),
city VARCHAR(100),
-- v8.5 (Биз-22): простая lead scoring модель без ML.
-- time_in_form_seconds — сколько секунд физлицо заполняло форму
-- (приходит в webhook как seconds_to_submit; NULL если поставщик не
-- передаёт). lead_score — generated stored = supplier.quality_score *
-- (time_in_form / 60), clamp в [0.00, 99.99]. Простая эвристика: дольше
-- заполнял → серьёзнее интерес × качество поставщика.
-- Расчёт через trigger BEFORE INSERT/UPDATE (см. функцию ниже), не GENERATED
-- (нужен JOIN на suppliers, что не разрешено в STORED-выражениях PG).
time_in_form_seconds INT,
lead_score NUMERIC(5,2),
is_test BOOLEAN DEFAULT FALSE,
received_at TIMESTAMPTZ NOT NULL, -- ключ партиционирования
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
PRIMARY KEY (id, received_at)
PRIMARY KEY (id, received_at),
CONSTRAINT chk_deals_lead_score_range
CHECK (lead_score IS NULL OR (lead_score >= 0.00 AND lead_score <= 99.99)),
CONSTRAINT chk_deals_escalated_count_nonneg
CHECK (escalated_count >= 0)
) PARTITION BY RANGE (received_at);
-- Индексы на родительской таблице наследуются партициями
@@ -1017,6 +1261,16 @@ CREATE INDEX ON deals (tenant_id, phone);
CREATE INDEX ON deals (tenant_id, manager_id);
-- v8.3: idx_deals_reminder удалён вместе с полем reminder_at
CREATE UNIQUE INDEX ON deals (tenant_id, source_crm_id) WHERE source_crm_id IS NOT NULL;
-- v8.5 индексы:
-- (CTO-14) когортная аналитика по UTM. Партиционируется автоматически.
CREATE INDEX ON deals (tenant_id, utm_source) WHERE utm_source IS NOT NULL;
-- (Биз-23) гео-фильтр в §10.3 + аналитика по регионам.
CREATE INDEX ON deals (tenant_id, region_code) WHERE region_code IS NOT NULL;
-- (Биз-19) lookup дублей master'а через duplicate_of_id для UI (показать
-- цепочку дублей) и для cleanup при удалении master'а.
CREATE INDEX ON deals (duplicate_of_id) WHERE duplicate_of_id IS NOT NULL;
-- (OPEN-И-25) cron leads:escalate-stale — выбирает unclosed deals по assigned_at.
CREATE INDEX ON deals (tenant_id, assigned_at) WHERE status NOT IN ('closed','rejected');
-- Стартовые партиции (создаются cron-ом раз в сутки на 2 месяца вперёд).
-- Здесь — заготовка на ближайшие 6 месяцев от текущей даты схемы (май 2026).
@@ -1062,6 +1316,7 @@ CREATE TABLE activity_log (
context JSONB,
ip_address INET,
user_agent TEXT,
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
created_at TIMESTAMPTZ DEFAULT NOW()
);
@@ -1486,6 +1741,7 @@ CREATE TABLE balance_transactions (
user_id BIGINT REFERENCES users(id), -- кто инициировал (NULL для системных)
-- Для manual_adjustment — кто из админов SaaS сделал
admin_user_id BIGINT REFERENCES saas_admin_users(id),
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
created_at TIMESTAMPTZ DEFAULT NOW()
);
@@ -1647,6 +1903,7 @@ CREATE TABLE pd_processing_log (
actor_tenant_user_id BIGINT REFERENCES users(id),
actor_admin_user_id BIGINT REFERENCES saas_admin_users(id),
ip_address INET,
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT chk_pd_actor CHECK (
(actor_tenant_user_id IS NOT NULL AND actor_admin_user_id IS NULL)
@@ -1801,6 +2058,7 @@ CREATE TABLE saas_admin_audit_log (
requires_approval BOOLEAN DEFAULT FALSE,
approved_by BIGINT REFERENCES saas_admin_users(id),
approved_at TIMESTAMPTZ,
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
created_at TIMESTAMPTZ DEFAULT NOW()
);
@@ -2004,6 +2262,7 @@ ALTER TABLE outbound_webhook_deliveries ENABLE ROW LEVEL SECURITY;
-- v8.4 hotfix: deal_tag_pivot — link между deals (partitioned, RLS есть) и deal_tags (RLS через tenant_id).
-- В самой pivot нет tenant_id, поэтому фильтр через JOIN на deal_tags (tag_id).
ALTER TABLE deal_tag_pivot ENABLE ROW LEVEL SECURITY;
ALTER TABLE project_user_assignments ENABLE ROW LEVEL SECURITY; -- v8.5 (CTO-16)
-- Базовая политика для таблиц с прямым tenant_id
CREATE POLICY tenant_isolation ON users USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
@@ -2051,13 +2310,32 @@ CREATE POLICY tenant_isolation ON email_verifications USING (
);
-- saas_invoice_items: фильтр через invoice_id
-- v8.5 (OPEN-И-14): WITH CHECK защищает INSERT/UPDATE — нельзя вставить
-- строку invoice_item ссылающуюся на чужой invoice. До v8.5 защита только
-- на USING (SELECT/UPDATE filter), INSERT мог пройти если invoice_id — чужой.
CREATE POLICY tenant_isolation ON saas_invoice_items USING (
invoice_id IN (SELECT id FROM saas_invoices WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
)
WITH CHECK (
invoice_id IN (SELECT id FROM saas_invoices WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
);
-- v8.4 hotfix: deal_tag_pivot — фильтр через tag_id → deal_tags(tenant_id)
-- v8.5 (OPEN-И-14): добавлено WITH CHECK — нельзя пометить deal чужим тегом.
CREATE POLICY tenant_isolation ON deal_tag_pivot USING (
tag_id IN (SELECT id FROM deal_tags WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
)
WITH CHECK (
tag_id IN (SELECT id FROM deal_tags WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
);
-- v8.5 (CTO-16): project_user_assignments — фильтр через project_id → projects(tenant_id).
-- WITH CHECK на INSERT/UPDATE — нельзя назначить менеджера в чужой проект.
CREATE POLICY tenant_isolation ON project_user_assignments USING (
project_id IN (SELECT id FROM projects WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
)
WITH CHECK (
project_id IN (SELECT id FROM projects WHERE tenant_id = current_setting('app.current_tenant_id')::bigint)
);
-- auth_log: tenant_user и saas_admin строки разные. Здесь — только tenant_user.
@@ -2098,6 +2376,242 @@ CREATE POLICY tenant_isolation ON auth_log USING (
-- legal_entities, payment_gateways;
-- нет DELETE на финансовых таблицах (только soft markers).
-- v8.5 (OPEN-И-15 + OPEN-И-23): новая роль crm_audit_writer.
-- INSERT-only на 5 audit-таблиц. UPDATE/DELETE заблокированы триггерами
-- (см. секцию «14. АУДИТ APPEND-ONLY + HASH CHAIN»). Application пишет
-- в audit-таблицы под этой ролью (через temporary SET ROLE), что обеспечивает
-- невозможность fraud-удаления записей даже от super_admin.
--
-- CREATE ROLE crm_audit_writer LOGIN PASSWORD '<from-secrets>';
--
-- GRANT crm_audit_writer-у:
-- USAGE на схему public;
-- INSERT (только) на auth_log, activity_log, pd_processing_log,
-- saas_admin_audit_log, balance_transactions;
-- USAGE на sequences соответствующих таблиц;
-- запрещено: SELECT, UPDATE, DELETE, TRUNCATE.
-- v8.5 (OPEN-И-14): defense-in-depth — REVOKE ALL на 6 saas-таблицах
-- от crm_app_user. К этим таблицам tenant-приложение доступа НЕ должно
-- иметь даже теоретически (RLS + REVOKE = 2 барьера).
--
-- REVOKE ALL ON saas_admin_users FROM crm_app_user;
-- REVOKE ALL ON saas_admin_sessions FROM crm_app_user;
-- REVOKE ALL ON saas_admin_audit_log FROM crm_app_user;
-- REVOKE ALL ON incidents_log FROM crm_app_user;
-- REVOKE ALL ON pd_subject_requests FROM crm_app_user;
-- REVOKE ALL ON impersonation_tokens FROM crm_app_user;
-- =============================================================================
-- 14. АУДИТ APPEND-ONLY + HASH CHAIN (v8.5, OPEN-И-15)
-- =============================================================================
--
-- Назначение: обеспечить юридическую доказательность audit-журналов.
-- • Триггеры BEFORE UPDATE/DELETE → RAISE EXCEPTION делают записи неизменяемыми.
-- • Триггер BEFORE INSERT заполняет log_hash = sha256(prev_hash || NEW::text).
-- Любое удаление средней строки или вставка фейка → разрыв цепочки при
-- пересчёте (cron audit:verify-chain в Прил. И).
-- • Роль crm_audit_writer (INSERT-only) дополняет триггеры: даже если
-- админ выполнит ALTER TABLE … DISABLE TRIGGER, у него под crm_audit_writer
-- не будет UPDATE/DELETE прав — пройти оба слоя одновременно невозможно
-- без `super_admin` PostgreSQL (которого у админов SaaS нет в production).
--
-- Затронутые таблицы (5):
-- - auth_log
-- - activity_log
-- - pd_processing_log
-- - saas_admin_audit_log
-- - balance_transactions
CREATE OR REPLACE FUNCTION audit_chain_hash() RETURNS TRIGGER AS $$
DECLARE
prev_hash BYTEA;
BEGIN
-- Берём log_hash последней строки этой таблицы. NULL для первой записи.
-- TG_TABLE_NAME — имя таблицы, через которое триггер сработал; используем
-- format/EXECUTE для полиморфности.
EXECUTE format(
'SELECT log_hash FROM %I ORDER BY id DESC LIMIT 1',
TG_TABLE_NAME
) INTO prev_hash;
-- log_hash = sha256(prev_hash || NEW::text). Если prev_hash NULL — берём
-- пустую байтовую строку (первая запись цепочки).
NEW.log_hash := digest(
COALESCE(prev_hash, ''::bytea) || NEW::text::bytea,
'sha256'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION audit_chain_hash() IS
'v8.5 (OPEN-И-15): SHA-256 hash chain для audit-таблиц. Заполняет '
'NEW.log_hash перед INSERT. Цепочка: digest(prev_hash || NEW::text, ''sha256''). '
'При попытке UPDATE/DELETE — отдельный триггер RAISE EXCEPTION.';
CREATE OR REPLACE FUNCTION audit_block_mutation() RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'audit log is append-only (table %): UPDATE/DELETE forbidden', TG_TABLE_NAME
USING ERRCODE = 'check_violation';
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION audit_block_mutation() IS
'v8.5 (OPEN-И-15): запрещает UPDATE/DELETE на audit-таблицах. '
'Совместно с REVOKE на роли — два слоя защиты от tampering.';
-- 5 пар триггеров: hash-fill (BEFORE INSERT) + block-mutation (BEFORE UPDATE/DELETE)
CREATE TRIGGER trg_audit_chain_hash_auth_log
BEFORE INSERT ON auth_log
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
CREATE TRIGGER trg_audit_block_mut_auth_log
BEFORE UPDATE OR DELETE ON auth_log
FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
CREATE TRIGGER trg_audit_chain_hash_activity_log
BEFORE INSERT ON activity_log
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
CREATE TRIGGER trg_audit_block_mut_activity_log
BEFORE UPDATE OR DELETE ON activity_log
FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
CREATE TRIGGER trg_audit_chain_hash_pd_log
BEFORE INSERT ON pd_processing_log
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
CREATE TRIGGER trg_audit_block_mut_pd_log
BEFORE UPDATE OR DELETE ON pd_processing_log
FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
CREATE TRIGGER trg_audit_chain_hash_saas_admin_audit
BEFORE INSERT ON saas_admin_audit_log
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
CREATE TRIGGER trg_audit_block_mut_saas_admin_audit
BEFORE UPDATE OR DELETE ON saas_admin_audit_log
FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
CREATE TRIGGER trg_audit_chain_hash_balance_tx
BEFORE INSERT ON balance_transactions
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
CREATE TRIGGER trg_audit_block_mut_balance_tx
BEFORE UPDATE OR DELETE ON balance_transactions
FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
-- =============================================================================
-- 15. ТРИГГЕР ЭКСПОРТА В pd_processing_log (v8.5, OPEN-И-20)
-- =============================================================================
--
-- При создании записи в report_jobs (выгрузка лидов в файл) автоматически
-- пишем в pd_processing_log action='exported' для соответствия 152-ФЗ ст.18
-- ч.2 (фиксация всех операций обработки ПДн). До v8.5 такая запись делалась
-- из app-кода — риск пропуска при ошибке программиста.
CREATE OR REPLACE FUNCTION report_jobs_log_export() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO pd_processing_log (
tenant_id, subject_type, subject_id, action, purpose,
actor_tenant_user_id, ip_address
) VALUES (
NEW.tenant_id,
'lead', -- bulk-выгрузка лидов
NULL, -- subject_id NULL = bulk
'exported',
'report_job_' || NEW.id,
NEW.user_id,
NULL -- IP уже в saas_admin_audit_log/auth_log если нужно
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_report_jobs_export_log
AFTER INSERT ON report_jobs
FOR EACH ROW EXECUTE FUNCTION report_jobs_log_export();
COMMENT ON FUNCTION report_jobs_log_export() IS
'v8.5 (OPEN-И-20): автоматически пишет в pd_processing_log при создании '
'report_jobs. Закрывает риск пропуска audit-записи на стороне приложения.';
-- =============================================================================
-- 16. ТРИГГЕР РАСЧЁТА LEAD_SCORE (v8.5, Биз-22)
-- =============================================================================
--
-- Простая модель без ML: lead_score = supplier.quality_score *
-- LEAST(time_in_form_seconds / 60.0, 99.99 / supplier.quality_score).
-- Если time_in_form_seconds NULL — lead_score NULL.
-- Поставщик вычисляется через project → project_suppliers → supplier (в MVP
-- предполагается ровно один активный поставщик на проект; если их несколько
-- — берётся первый по sort_order). Полная модель — Post-MVP.
CREATE OR REPLACE FUNCTION calc_lead_score() RETURNS TRIGGER AS $$
DECLARE
quality NUMERIC(3,2);
BEGIN
IF NEW.time_in_form_seconds IS NULL THEN
NEW.lead_score := NULL;
RETURN NEW;
END IF;
SELECT s.quality_score INTO quality
FROM project_suppliers ps
JOIN suppliers s ON s.id = ps.supplier_id
WHERE ps.project_id = NEW.project_id
AND ps.is_active = TRUE
AND s.is_active = TRUE
ORDER BY s.sort_order ASC, s.id ASC
LIMIT 1;
IF quality IS NULL THEN
NEW.lead_score := NULL;
ELSE
NEW.lead_score := LEAST(
quality * (NEW.time_in_form_seconds::numeric / 60.0),
99.99
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_deals_calc_lead_score
BEFORE INSERT OR UPDATE OF time_in_form_seconds, project_id ON deals
FOR EACH ROW EXECUTE FUNCTION calc_lead_score();
COMMENT ON FUNCTION calc_lead_score() IS
'v8.5 (Биз-22): простой scoring lead_score = supplier.quality_score * '
'(time_in_form_seconds / 60), clamped в [0, 99.99]. Без ML.';
-- =============================================================================
-- 17. POST-MVP DDL ЗАКОММЕНТИРОВАН (v8.5, OPEN-И-26)
-- =============================================================================
--
-- Биз-12 (Post-MVP): телефонная интеграция и call-recording.
-- DDL ниже — задел для ретрофита, чтобы при добавлении функции в Post-MVP
-- не пришлось проектировать с нуля. ПДн-маркировка выставлена сразу:
-- recording_path хранит ссылки на S3 (сами записи лежат в S3+KMS), их
-- доступ — через signed URL TTL 1 ч с обязательным INSERT в pd_processing_log.
--
-- CREATE TABLE call_recordings (
-- id BIGSERIAL PRIMARY KEY,
-- tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
-- deal_id BIGINT NOT NULL, -- БЕЗ FK (deals партиционирована)
-- call_started_at TIMESTAMPTZ NOT NULL,
-- duration_sec INT NOT NULL CHECK (duration_sec >= 0),
-- direction VARCHAR(10) NOT NULL CHECK (direction IN ('inbound','outbound')),
-- recording_path VARCHAR(500), -- s3://bucket/recordings/...
-- transcript TEXT, -- результат STT (Yandex SpeechKit)
-- created_at TIMESTAMPTZ DEFAULT NOW()
-- );
-- CREATE INDEX ON call_recordings (tenant_id, deal_id, call_started_at DESC);
-- ALTER TABLE call_recordings ENABLE ROW LEVEL SECURITY;
-- CREATE POLICY tenant_isolation ON call_recordings
-- USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
-- =============================================================================
-- КОНЕЦ schema.sql