Files
portal/db/schema_modules.sql
T

1728 lines
105 KiB
PL/PgSQL
Raw Normal View History

-- =============================================================================
-- schema_modules.sql — КАНОН МОДУЛЕЙ, дописанных поверх schema.sql миграциями
-- Версия: v1.0 (02.08.2026)
-- =============================================================================
--
-- 🔴 ЭТОТ ФАЙЛ НЕ ИСПОЛНЯЕТСЯ. Он только описывает.
--
-- Почему отдельный файл, а не тело schema.sql. `db/schema.sql` — не просто
-- документ: миграция `0001_01_01_000000_load_initial_schema.php` заливает его
-- ЦЕЛИКОМ как первый шаг сборки, а все остальные миграции ложатся поверх.
-- Значит всё, что перенесено в тело schema.sql, обязано не столкнуться с
-- дельта-миграцией, которая создаёт то же самое. Замер 02.08.2026: эти таблицы
-- трогают 77 миграций, 49 из них без стражей (`hasTable` / `hasColumn` /
-- `IF NOT EXISTS`). Свести их в тело — большая отдельная работа с риском сломать
-- сборку с нуля, и это решение владельца, а не побочный шаг сверки канона.
--
-- Пока такого решения нет, канон живёт в двух файлах:
-- • db/schema.sql — исходная схема, ИСПОЛНЯЕТСЯ первой миграцией;
-- • db/schema_modules.sql — всё, что дописали миграции после неё (этот файл).
-- Вместе они описывают базу полностью — это проверено сверкой:
-- node tools/sverka-kanona-shemy.mjs db/schema.sql db/schema_modules.sql stolbcy.txt
--
-- Порядок операторов внутри файла — исполнимый (родитель раньше ребёнка), чтобы
-- при будущем сведении его можно было взять как есть. Но сам по себе он никуда
-- не заливается: заливка привела бы к столкновению с дельта-миграциями.
--
-- Что описано: разделы 18–21 (реклама клиента, телеграм-рассылки клиента, отдел
-- продаж, служебные) + поздние столбцы уже описанных в schema.sql таблиц.
-- Журнал: db/CHANGELOG_schema.md, запись v9.33.
-- =============================================================================
-- =============================================================================
-- 18. РЕКЛАМНЫЙ МОДУЛЬ КЛИЕНТА (ad_*) — v8.86, canon-sync
-- =============================================================================
-- Клиент из кабинета заказывает рекламу на свою аудиторию: собирается сегмент из
-- телефонов его лидов, на него запускается кампания в Яндекс.Директе, деньги
-- списываются с отдельного рекламного кошелька (НЕ с баланса за лиды).
-- Выкачено на бой 25.07.2026, см. память project-reklama-modul-vykat-2026-07-25.
--
-- 🪤 Времена здесь — TIMESTAMP(0) БЕЗ пояса (умолчание Laravel timestamps()), а не
-- TIMESTAMPTZ, как в старших разделах. Столбец без пояса молча отбрасывает смещение
-- из строки: фикстуру и запись всегда приводить к UTC (App\Support\MskBoundary).
-- Исключение — ad_campaigns.revived_at: он с поясом.
--
-- 🔴 RLS: все таблицы с tenant_id — ENABLE + FORCE + политика tenant_isolation.
-- Служебная роль crm_supplier_worker ходит мимо RLS (BYPASSRLS), но ей всё равно
-- нужен GRANT: пропущенный grant на счётчик = «permission denied» на бою.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- ad_campaigns — рекламная кампания клиента
-- -----------------------------------------------------------------------------
-- mode: auto (портал сам ведёт) | manual. channel: пока только yandex.
-- Пара budget_rub/paid_impressions — что клиент купил; yandex_cost_rub/
-- delivered_impressions — что реально израсходовано у площадки; разница и есть
-- наша маржа (ad_settings.ad_margin_percent).
-- 🪤 snapshot_from/snapshot_to — КАЛЕНДАРНЫЕ даты (DATE), не мгновения: их
-- сравнивают с днём, а не с отметкой времени, переводить в пояс нельзя.
-- -----------------------------------------------------------------------------
CREATE TABLE ad_campaigns (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel VARCHAR(16) NOT NULL DEFAULT 'yandex',
name VARCHAR(255) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'draft',
mode VARCHAR(10) NOT NULL DEFAULT 'auto',
-- Аудитория
audience_days SMALLINT NOT NULL DEFAULT 10,
use_uploaded_list BOOLEAN NOT NULL DEFAULT FALSE,
snapshot_from DATE,
snapshot_to DATE,
-- Деньги и объём
weekly_budget_rub NUMERIC(14,2),
daily_budget_rub NUMERIC(14,2),
budget_rub NUMERIC(14,2),
click_bid_rub NUMERIC(14,2),
client_cpm_rub NUMERIC(8,2),
frequency SMALLINT,
frequency_period_days SMALLINT,
run_days SMALLINT,
shows_until DATE,
estimated_impressions BIGINT,
paid_impressions BIGINT,
delivered_impressions BIGINT NOT NULL DEFAULT 0,
yandex_cost_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
charged_client_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
-- Идентификаторы на стороне Яндекса
yandex_segment_id BIGINT,
yandex_retargeting_list_id BIGINT,
yandex_campaign_id BIGINT,
yandex_ad_group_id BIGINT,
yandex_creative_id BIGINT,
yandex_ad_id BIGINT,
landing_url VARCHAR(1024),
moderation_reason VARCHAR(255),
banners_approved_at TIMESTAMP(0),
launched_at TIMESTAMP(0),
revived_at TIMESTAMPTZ(0),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0),
-- Составной ключ для FK дочерних: ребёнок обязан лежать в том же тенанте.
CONSTRAINT uq_ad_campaign_tenant UNIQUE (id, tenant_id)
);
CREATE INDEX ad_campaigns_tenant_id_status_index ON ad_campaigns (tenant_id, status);
ALTER TABLE ad_campaigns ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_campaigns FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_campaigns
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- ad_campaign_banners — картинки кампании, по одной на размерный слот
-- -----------------------------------------------------------------------------
-- included = участвует в показе. Уникальность по слоту (тенант+кампания+ШхВ):
-- на один размер — одна картинка.
-- -----------------------------------------------------------------------------
CREATE TABLE ad_campaign_banners (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
campaign_id BIGINT NOT NULL REFERENCES ad_campaigns(id) ON DELETE CASCADE,
width SMALLINT NOT NULL,
height SMALLINT NOT NULL,
path VARCHAR(255) NOT NULL,
bytes INTEGER NOT NULL,
included BOOLEAN NOT NULL DEFAULT TRUE,
yandex_creative_id BIGINT,
yandex_ad_id BIGINT,
moderation_status VARCHAR(16) NOT NULL DEFAULT 'draft',
moderation_reason VARCHAR(255),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
CREATE INDEX ad_campaign_banners_tenant_id_campaign_id_index ON ad_campaign_banners (tenant_id, campaign_id);
CREATE UNIQUE INDEX uq_ad_campaign_banner_slot ON ad_campaign_banners (tenant_id, campaign_id, width, height);
ALTER TABLE ad_campaign_banners ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_campaign_banners FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_campaign_banners
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- ad_campaign_ads — текстовые объявления кампании
-- -----------------------------------------------------------------------------
-- Длины title/title2/text — предельные у Яндекса (56/45/96 знаков).
-- moderation_status: draft | sent | accepted | rejected.
-- 🔑 «Создать» ≠ «запустить»: объявление, созданное без ads.moderate, остаётся
-- черновиком и не показывается (урок 30.07.2026).
-- -----------------------------------------------------------------------------
CREATE TABLE ad_campaign_ads (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
campaign_id BIGINT NOT NULL REFERENCES ad_campaigns(id) ON DELETE CASCADE,
title VARCHAR(56) NOT NULL,
title2 VARCHAR(45),
text VARCHAR(96) NOT NULL,
href VARCHAR(1024) NOT NULL,
image_normal_hash VARCHAR(255),
image_wide_hash VARCHAR(255),
yandex_ad_id BIGINT,
moderation_status VARCHAR(16) NOT NULL DEFAULT 'draft',
moderation_reason VARCHAR(255),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
CREATE INDEX ad_campaign_ads_tenant_id_campaign_id_index ON ad_campaign_ads (tenant_id, campaign_id);
ALTER TABLE ad_campaign_ads ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_campaign_ads FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_campaign_ads
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- ad_campaign_messages — переписка клиента с модератором по кампании
-- -----------------------------------------------------------------------------
-- author: client | admin. banner_id — если реплика про конкретную картинку.
-- FK на кампанию СОСТАВНОЙ (campaign_id, tenant_id): реплика не может уехать
-- в чужой тенант. Свой составной ключ (id, campaign_id) нужен ad_creative_jobs.
-- -----------------------------------------------------------------------------
CREATE TABLE ad_campaign_messages (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
campaign_id BIGINT NOT NULL,
banner_id BIGINT REFERENCES ad_campaign_banners(id) ON DELETE SET NULL,
author VARCHAR(16) NOT NULL,
body TEXT NOT NULL,
file_path VARCHAR(512),
file_name VARCHAR(255),
file_size INTEGER,
file_mime VARCHAR(128),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0),
CONSTRAINT uq_ad_campaign_message_campaign UNIQUE (id, campaign_id),
CONSTRAINT fk_ad_campaign_message_campaign FOREIGN KEY (campaign_id, tenant_id)
REFERENCES ad_campaigns (id, tenant_id) ON DELETE CASCADE
);
CREATE INDEX ad_campaign_messages_tenant_id_campaign_id_id_index
ON ad_campaign_messages (tenant_id, campaign_id, id);
ALTER TABLE ad_campaign_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_campaign_messages FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_campaign_messages
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- ad_creative_jobs — очередь работ робота в кабинете Яндекса
-- -----------------------------------------------------------------------------
-- kind: upload (залить картинку) | inspect (посмотреть, что с модерацией) и т. п.
-- uq_creative_job_single_taken — частичный уникальный индекс по status='taken':
-- в работе одновременно может быть только ОДНА задача (робот один).
-- FK на кампанию и на реплику — составные, оба с защитой от ухода в чужой тенант.
-- -----------------------------------------------------------------------------
CREATE TABLE ad_creative_jobs (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
campaign_id BIGINT NOT NULL,
message_id BIGINT,
kind VARCHAR(16) NOT NULL DEFAULT 'upload',
status VARCHAR(16) NOT NULL DEFAULT 'queued',
attempts SMALLINT NOT NULL DEFAULT 0,
snapshot_before JSONB,
failure_reason VARCHAR(1024),
yandex_ad_id BIGINT,
taken_at TIMESTAMP(0),
finished_at TIMESTAMP(0),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0),
CONSTRAINT fk_creative_job_campaign_tenant FOREIGN KEY (campaign_id, tenant_id)
REFERENCES ad_campaigns (id, tenant_id) ON DELETE CASCADE,
CONSTRAINT fk_creative_job_message_campaign FOREIGN KEY (message_id, campaign_id)
REFERENCES ad_campaign_messages (id, campaign_id) ON DELETE SET NULL (message_id)
);
CREATE INDEX ad_creative_jobs_status_index ON ad_creative_jobs (status);
CREATE INDEX ad_creative_jobs_tenant_id_campaign_id_index ON ad_creative_jobs (tenant_id, campaign_id);
CREATE INDEX idx_creative_job_inspect ON ad_creative_jobs (campaign_id, kind, yandex_ad_id);
CREATE UNIQUE INDEX uq_creative_job_single_taken ON ad_creative_jobs (status) WHERE status = 'taken';
ALTER TABLE ad_creative_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_creative_jobs FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_creative_jobs
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- ad_campaign_phones — телефоны аудитории кампании (слепок на момент запуска)
-- -----------------------------------------------------------------------------
-- ПДн: только номер, без имён. expires_at — когда номер выпадает из сегмента.
-- -----------------------------------------------------------------------------
CREATE TABLE ad_campaign_phones (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
campaign_id BIGINT NOT NULL REFERENCES ad_campaigns(id) ON DELETE CASCADE,
phone VARCHAR(11) NOT NULL,
expires_at TIMESTAMP(0),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0),
UNIQUE (tenant_id, campaign_id, phone)
);
CREATE INDEX ad_campaign_phones_tenant_id_campaign_id_index ON ad_campaign_phones (tenant_id, campaign_id);
ALTER TABLE ad_campaign_phones ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_campaign_phones FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_campaign_phones
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- ad_wallets / ad_wallet_holds / ad_wallet_transactions — рекламный кошелёк
-- -----------------------------------------------------------------------------
-- Отдельный от баланса за лиды кошелёк: пополняется счётом с
-- credit_target='advertising'. frozen_rub — сумма живых удержаний (holds) под
-- запущенные кампании; списание превращает удержание в проводку.
-- external_key + tenant_id уникальны — защита от двойного зачисления одного
-- и того же внешнего платежа.
-- 🪤 ad_wallet_transactions.created_at — TIMESTAMP(0) БЕЗ пояса: строка со
-- смещением «+03:00» ляжет туда молча не тем моментом (обжигались 02.08.2026).
-- -----------------------------------------------------------------------------
CREATE TABLE ad_wallets (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE,
balance_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
frozen_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
ALTER TABLE ad_wallets ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_wallets FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_wallets
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE TABLE ad_wallet_holds (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel VARCHAR(32) NOT NULL,
source_type VARCHAR(255) NOT NULL, -- класс модели-источника (кампания)
source_id BIGINT NOT NULL,
amount_rub NUMERIC(14,2) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0),
UNIQUE (tenant_id, channel, source_type, source_id)
);
CREATE INDEX ad_wallet_holds_tenant_id_status_index ON ad_wallet_holds (tenant_id, status);
ALTER TABLE ad_wallet_holds ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_wallet_holds FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_wallet_holds
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE TABLE ad_wallet_transactions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
type VARCHAR(32) NOT NULL,
amount_rub NUMERIC(14,2) NOT NULL,
balance_rub_after NUMERIC(14,2) NOT NULL,
channel VARCHAR(32),
related_type VARCHAR(255),
related_id BIGINT,
description VARCHAR(255),
external_key VARCHAR(255),
created_at TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (tenant_id, external_key)
);
CREATE INDEX ad_wallet_transactions_tenant_id_created_at_index ON ad_wallet_transactions (tenant_id, created_at);
CREATE INDEX ad_wallet_transactions_related_type_related_id_index ON ad_wallet_transactions (related_type, related_id);
ALTER TABLE ad_wallet_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE ad_wallet_transactions FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ad_wallet_transactions
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- ad_settings — одна строка настроек рекламного модуля (SaaS-level, без RLS)
-- -----------------------------------------------------------------------------
-- client_cpm_rub — цена тысячи показов для клиента; ad_margin_percent и
-- markup_percent — наша наценка над расходом у площадки.
-- -----------------------------------------------------------------------------
CREATE TABLE ad_settings (
id BIGSERIAL PRIMARY KEY,
markup_percent NUMERIC(5,2) NOT NULL DEFAULT 30.00,
client_cpm_rub NUMERIC(8,2) NOT NULL DEFAULT 120.00,
ad_margin_percent NUMERIC(5,2) NOT NULL DEFAULT 40,
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
-- -----------------------------------------------------------------------------
-- external_service_balances — светофор остатков на внешних сервисах (SaaS-level)
-- -----------------------------------------------------------------------------
-- Одна строка на сервис (service_key — PK): Яндекс, МТС, смс-провайдер и т. п.
-- light: green | amber | red | grey (grey = не смогли спросить). Заполняет джоб
-- под crm_supplier_worker, читает админка. Без RLS и tenant_id — это про нас, не
-- про клиентов.
-- -----------------------------------------------------------------------------
CREATE TABLE external_service_balances (
service_key VARCHAR(32) PRIMARY KEY,
balance_amount NUMERIC(14,2),
currency VARCHAR(8) NOT NULL DEFAULT 'RUB',
daily_spend_estimate NUMERIC(14,2),
days_left INTEGER,
light VARCHAR(8) NOT NULL DEFAULT 'green'
CHECK (light IN ('green','amber','red','grey')),
ok BOOLEAN NOT NULL DEFAULT FALSE,
error TEXT,
checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Права рекламного модуля. Роли могут отсутствовать (dev под postgres) — проверяем.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT SELECT, INSERT, UPDATE ON ad_campaigns TO crm_app_user;
GRANT SELECT, INSERT, UPDATE ON ad_campaign_ads TO crm_app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ad_campaign_banners TO crm_app_user;
GRANT SELECT, INSERT ON ad_campaign_messages TO crm_app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ad_campaign_phones TO crm_app_user;
GRANT SELECT, INSERT, UPDATE ON ad_creative_jobs TO crm_app_user;
GRANT SELECT, INSERT, UPDATE ON ad_wallets TO crm_app_user;
GRANT SELECT, INSERT, UPDATE ON ad_wallet_holds TO crm_app_user;
GRANT SELECT, INSERT ON ad_wallet_transactions TO crm_app_user;
GRANT SELECT ON ad_settings TO crm_app_user;
GRANT USAGE, SELECT ON SEQUENCE
ad_campaigns_id_seq, ad_campaign_ads_id_seq, ad_campaign_banners_id_seq,
ad_campaign_messages_id_seq, ad_campaign_phones_id_seq, ad_creative_jobs_id_seq,
ad_wallets_id_seq, ad_wallet_holds_id_seq, ad_wallet_transactions_id_seq
TO crm_app_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT ON ad_campaigns TO crm_admin_user;
-- Робот дописывает номер креатива в кампанию — точечное право на один столбец.
GRANT UPDATE (yandex_creative_id) ON ad_campaigns TO crm_admin_user;
GRANT SELECT ON ad_campaign_ads TO crm_admin_user;
GRANT SELECT, UPDATE ON ad_campaign_banners TO crm_admin_user;
GRANT SELECT, INSERT ON ad_campaign_messages TO crm_admin_user;
GRANT SELECT ON ad_campaign_phones TO crm_admin_user;
GRANT SELECT, UPDATE ON ad_creative_jobs TO crm_admin_user;
GRANT SELECT ON ad_wallets TO crm_admin_user;
GRANT SELECT ON ad_wallet_holds TO crm_admin_user;
GRANT SELECT ON ad_wallet_transactions TO crm_admin_user;
GRANT SELECT, UPDATE ON ad_settings TO crm_admin_user;
GRANT USAGE, SELECT ON SEQUENCE
ad_campaigns_id_seq, ad_campaign_ads_id_seq, ad_campaign_banners_id_seq,
ad_campaign_messages_id_seq, ad_campaign_phones_id_seq, ad_creative_jobs_id_seq,
ad_wallets_id_seq, ad_wallet_holds_id_seq, ad_wallet_transactions_id_seq,
ad_settings_id_seq
TO crm_admin_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT, UPDATE ON ad_campaigns TO crm_supplier_worker;
GRANT SELECT, UPDATE ON ad_campaign_ads TO crm_supplier_worker;
GRANT SELECT, UPDATE ON ad_campaign_banners TO crm_supplier_worker;
GRANT SELECT, INSERT ON ad_campaign_messages TO crm_supplier_worker;
GRANT SELECT, INSERT, UPDATE ON external_service_balances TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE
ad_campaigns_id_seq, ad_campaign_ads_id_seq, ad_campaign_banners_id_seq,
ad_campaign_messages_id_seq, ad_campaign_phones_id_seq, ad_creative_jobs_id_seq,
ad_wallets_id_seq, ad_wallet_holds_id_seq, ad_wallet_transactions_id_seq,
ad_settings_id_seq
TO crm_supplier_worker;
END IF;
END $$;
-- =============================================================================
-- 19. ТЕЛЕГРАМ-РАССЫЛКИ КЛИЕНТА (client_tg_*) — v8.86, canon-sync
-- =============================================================================
-- Клиент заказывает рассылку в Телеграме через рекламный кабинет МТС: собирается
-- аудитория из его лидов, робот заводит кампанию, деньги берутся из рекламного
-- кошелька (раздел 18). Модуль в ветке, на бой НЕ выкачен — ждёт, пока канал МТС
-- на боевом начнёт отправлять (без этого рассылки создаются и оплачиваются, а до
-- людей не доходят). Времена — TIMESTAMP(0) без пояса, как в разделе 18.
--
-- 🪤 У админской роли на кампаниях должен быть не только SELECT: канал ПИШЕТ
-- в client_tg_campaigns. На dev под postgres это невидимо, на бою — отказ.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- client_tg_campaigns — рассылка клиента
-- -----------------------------------------------------------------------------
-- audience_kind: deals (по своим лидам) | uploaded (загруженный список).
-- planned_count — сколько собрали, matched_count — сколько подтвердил МТС.
-- moderator_file_path — файл, который отдаём модератору площадки.
-- -----------------------------------------------------------------------------
CREATE TABLE client_tg_campaigns (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
status VARCHAR(16) NOT NULL DEFAULT 'draft',
status_reason VARCHAR(500),
ad_headline VARCHAR(40),
ad_text VARCHAR(1000) NOT NULL,
ad_link VARCHAR(500),
media_path VARCHAR(500),
moderator_file_path VARCHAR(500),
ord_category VARCHAR(200) NOT NULL DEFAULT 'Размещение рекламы',
budget_cap_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
audience_kind VARCHAR(16) NOT NULL DEFAULT 'deals',
audience_params JSONB,
planned_count INTEGER NOT NULL DEFAULT 0,
matched_count INTEGER,
estimated_cost_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
actual_cost_rub NUMERIC(14,2),
mts_campaign_id VARCHAR(32),
created_by BIGINT,
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
CREATE INDEX client_tg_campaigns_tenant_id_created_at_index ON client_tg_campaigns (tenant_id, created_at);
ALTER TABLE client_tg_campaigns ENABLE ROW LEVEL SECURITY;
ALTER TABLE client_tg_campaigns FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON client_tg_campaigns
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- client_tg_campaign_phones — телефоны аудитории рассылки (слепок)
-- -----------------------------------------------------------------------------
CREATE TABLE client_tg_campaign_phones (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
campaign_id BIGINT NOT NULL,
phone VARCHAR(11) NOT NULL,
expires_at TIMESTAMP(0),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
CREATE INDEX client_tg_campaign_phones_tenant_id_campaign_id_index
ON client_tg_campaign_phones (tenant_id, campaign_id);
ALTER TABLE client_tg_campaign_phones ENABLE ROW LEVEL SECURITY;
ALTER TABLE client_tg_campaign_phones FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON client_tg_campaign_phones
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- client_tg_robot_jobs — очередь работ робота в кабинете МТС
-- -----------------------------------------------------------------------------
-- Портал не ходит в кабинет сам: кладёт задание, робот ОПРАШИВАЕТ очередь
-- (связка переделана на опрос 31.07.2026 — робот за забором, стучаться некуда).
-- mode — что делать (создать / залить список / снять отчёт), payload/result — JSONB.
-- -----------------------------------------------------------------------------
CREATE TABLE client_tg_robot_jobs (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
campaign_id BIGINT NOT NULL REFERENCES client_tg_campaigns(id) ON DELETE CASCADE,
mode VARCHAR(16) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'queued',
attempts SMALLINT NOT NULL DEFAULT 0,
result JSONB,
failure_reason VARCHAR(1024),
taken_at TIMESTAMP(0),
finished_at TIMESTAMP(0),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
CREATE INDEX client_tg_robot_jobs_status_index ON client_tg_robot_jobs (status);
CREATE INDEX client_tg_robot_jobs_tenant_id_campaign_id_index ON client_tg_robot_jobs (tenant_id, campaign_id);
ALTER TABLE client_tg_robot_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE client_tg_robot_jobs FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON client_tg_robot_jobs
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- client_tg_contacts / client_tg_optouts — своя книжка номеров и отказы
-- -----------------------------------------------------------------------------
-- Отказ (optout) — номер, которому больше не пишем. Уникальность в пределах тенанта.
-- -----------------------------------------------------------------------------
CREATE TABLE client_tg_contacts (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
phone VARCHAR(11) NOT NULL,
name TEXT,
operator TEXT,
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0),
UNIQUE (tenant_id, phone)
);
ALTER TABLE client_tg_contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE client_tg_contacts FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON client_tg_contacts
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
CREATE TABLE client_tg_optouts (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
phone VARCHAR(11) NOT NULL,
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0),
UNIQUE (tenant_id, phone)
);
ALTER TABLE client_tg_optouts ENABLE ROW LEVEL SECURITY;
ALTER TABLE client_tg_optouts FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON client_tg_optouts
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- client_tg_auto_rule — одно правило автозапуска на тенанта
-- -----------------------------------------------------------------------------
-- Когда включено, портал сам собирает пачку и запускает рассылку, как только
-- накопилось batch_threshold номеров. daily_limit_rub/spent_today_rub/spent_date —
-- дневной потолок расхода; spent_date хранит КАЛЕНДАРНЫЙ день (DATE), сравнение
-- ведётся по московским суткам.
-- -----------------------------------------------------------------------------
CREATE TABLE client_tg_auto_rule (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
ad_text VARCHAR(1000) NOT NULL,
ad_link VARCHAR(500),
ord_category VARCHAR(200) NOT NULL DEFAULT 'Размещение рекламы',
budget_cap_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
daily_limit_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
spent_today_rub NUMERIC(14,2) NOT NULL DEFAULT 0.00,
spent_date DATE,
batch_threshold INTEGER,
updated_by BIGINT,
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
ALTER TABLE client_tg_auto_rule ENABLE ROW LEVEL SECURITY;
ALTER TABLE client_tg_auto_rule FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON client_tg_auto_rule
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- -----------------------------------------------------------------------------
-- client_tg_tariffs — лесенка цен за сообщение (SaaS-level, без RLS)
-- -----------------------------------------------------------------------------
-- min_qty — от какого объёма действует цена; строка с наибольшим подходящим
-- min_qty и даёт цену.
-- -----------------------------------------------------------------------------
CREATE TABLE client_tg_tariffs (
id BIGSERIAL PRIMARY KEY,
min_qty INTEGER NOT NULL UNIQUE,
price_rub NUMERIC(6,2) NOT NULL,
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT SELECT, INSERT, UPDATE ON client_tg_campaigns TO crm_app_user;
GRANT SELECT, INSERT, DELETE ON client_tg_campaign_phones TO crm_app_user;
GRANT SELECT, INSERT, UPDATE ON client_tg_robot_jobs TO crm_app_user;
GRANT SELECT, INSERT, DELETE ON client_tg_contacts TO crm_app_user;
GRANT SELECT, INSERT, DELETE ON client_tg_optouts TO crm_app_user;
GRANT SELECT, INSERT, UPDATE ON client_tg_auto_rule TO crm_app_user;
GRANT SELECT ON client_tg_tariffs TO crm_app_user;
GRANT USAGE, SELECT ON SEQUENCE client_tg_robot_jobs_id_seq TO crm_app_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
-- 🔴 UPDATE на кампаниях обязателен: админский канал в них ПИШЕТ.
GRANT SELECT, UPDATE ON client_tg_campaigns TO crm_admin_user;
GRANT SELECT, UPDATE ON client_tg_robot_jobs TO crm_admin_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON client_tg_tariffs TO crm_admin_user;
GRANT USAGE, SELECT ON SEQUENCE
client_tg_campaigns_id_seq, client_tg_campaign_phones_id_seq,
client_tg_robot_jobs_id_seq, client_tg_contacts_id_seq,
client_tg_optouts_id_seq, client_tg_auto_rule_id_seq,
client_tg_tariffs_id_seq
TO crm_admin_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT ON client_tg_campaigns TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE
client_tg_campaigns_id_seq, client_tg_campaign_phones_id_seq,
client_tg_robot_jobs_id_seq, client_tg_contacts_id_seq,
client_tg_optouts_id_seq, client_tg_auto_rule_id_seq,
client_tg_tariffs_id_seq
TO crm_supplier_worker;
END IF;
END $$;
-- =============================================================================
-- 20. ОТДЕЛ ПРОДАЖ (sales_*) — v8.86, canon-sync
-- =============================================================================
-- Отдельный портал для НАШИХ продавцов (не для клиентов): своя воронка «фирма →
-- переговоры → зарегистрировался → платит», свои тарифы и выплаты, своя рассылка
-- и «прогрев» будущих клиентов рекламой до звонка.
--
-- 🔴 Все таблицы SaaS-уровня: БЕЗ RLS и без tenant_id (даже там, где tenant_id
-- есть — это ссылка на клиента, а не разрез доступа). Ходят только служебные роли
-- crm_admin_user и crm_supplier_worker; клиентская crm_app_user сюда не допущена.
-- Времена — TIMESTAMPTZ (в отличие от разделов 18–19).
-- =============================================================================
-- -----------------------------------------------------------------------------
-- sales_tariffs / sales_users — продавцы и их вознаграждение
-- -----------------------------------------------------------------------------
-- kind тарифа: daily_salary (оклад за день) | topup_step (процент со ступени
-- пополнения). role: manager | head — «head» видит весь отдел.
-- 🪤 Ролей ровно две; 'admin' в проверках падает на sales_users_role_check.
-- -----------------------------------------------------------------------------
CREATE TABLE sales_tariffs (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
kind VARCHAR(20) NOT NULL CHECK (kind IN ('daily_salary','topup_step')),
params JSONB NOT NULL DEFAULT '{}'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE TABLE sales_users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role VARCHAR(10) NOT NULL DEFAULT 'manager'
CHECK (role IN ('manager','head')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
base_salary_rub NUMERIC(12,2) NOT NULL DEFAULT 0,
current_tariff_id BIGINT REFERENCES sales_tariffs(id),
created_by BIGINT REFERENCES sales_users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
-- -----------------------------------------------------------------------------
-- sales_prospects — карточка будущего клиента в воронке отдела продаж
-- -----------------------------------------------------------------------------
-- stage — 12 стадий воронки; source: search (нашли поиском) | manager (завёл сам).
-- test_channel/test_target — куда отправляли пробные лиды, kp_channel/kp_target —
-- куда выслали коммерческое предложение (стадии manual_testing и kp_sent, v9.28).
-- prev_stage помнит, откуда пришли, чтобы вернуть карточку назад одним нажатием.
-- linked_tenant_id — связь с зарегистрировавшимся клиентом.
-- Уникальность (продавец, ИНН) — частичная: без ИНН фирму можно завести дважды.
-- -----------------------------------------------------------------------------
CREATE TABLE sales_prospects (
id BIGSERIAL PRIMARY KEY,
sales_user_id BIGINT NOT NULL REFERENCES sales_users(id),
assigned_by BIGINT REFERENCES sales_users(id),
stage VARCHAR(16) NOT NULL DEFAULT 'new'
CHECK (stage IN ('new','in_work','negotiation','manual_testing',
'registered','testing','topped_up','user',
'kp_sent','rejected','no_answer','trash')),
prev_stage VARCHAR(16),
source VARCHAR(16) NOT NULL DEFAULT 'search'
CHECK (source IN ('search','manager')),
firm_name VARCHAR(500) NOT NULL,
legal_name VARCHAR(500),
city VARCHAR(255),
phone VARCHAR(64),
site VARCHAR(500),
inn VARCHAR(20),
rating_label VARCHAR(32),
contacts JSONB NOT NULL DEFAULT '[]'::jsonb,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
next_call_at TIMESTAMPTZ,
reason TEXT,
notes TEXT,
registered_email VARCHAR(255),
linked_tenant_id BIGINT REFERENCES tenants(id),
test_channel VARCHAR(8) CHECK (test_channel IS NULL OR test_channel IN ('phone','site')),
test_target VARCHAR(500),
kp_channel VARCHAR(16) CHECK (kp_channel IS NULL OR kp_channel IN ('email','whatsapp','telegram','max','other')),
kp_target VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_prospect_user ON sales_prospects (sales_user_id);
CREATE INDEX idx_prospect_stage ON sales_prospects (stage);
CREATE INDEX idx_prospect_source ON sales_prospects (source);
CREATE UNIQUE INDEX uq_prospect_user_inn ON sales_prospects (sales_user_id, inn) WHERE inn IS NOT NULL;
-- -----------------------------------------------------------------------------
-- sales_prospect_moves / sales_prospect_notes — история карточки
-- -----------------------------------------------------------------------------
-- moves — каждый переход между стадиями (from_stage NULL = карточка только заведена).
-- notes.kind: note (запись продавца) | stage (автоматическая пометка о переходе).
-- -----------------------------------------------------------------------------
CREATE TABLE sales_prospect_moves (
id BIGSERIAL PRIMARY KEY,
prospect_id BIGINT NOT NULL REFERENCES sales_prospects(id) ON DELETE CASCADE,
sales_user_id BIGINT REFERENCES sales_users(id),
from_stage VARCHAR(16),
to_stage VARCHAR(16) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_prospect_move_card ON sales_prospect_moves (prospect_id, created_at DESC);
CREATE INDEX idx_prospect_move_when ON sales_prospect_moves (created_at);
CREATE TABLE sales_prospect_notes (
id BIGSERIAL PRIMARY KEY,
prospect_id BIGINT NOT NULL REFERENCES sales_prospects(id) ON DELETE CASCADE,
sales_user_id BIGINT REFERENCES sales_users(id),
kind VARCHAR(16) NOT NULL DEFAULT 'note' CHECK (kind IN ('note','stage')),
title VARCHAR(500),
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_prospect_note_card ON sales_prospect_notes (prospect_id, created_at DESC);
-- -----------------------------------------------------------------------------
-- sales_client_assignments — за каким продавцом закреплён клиент
-- -----------------------------------------------------------------------------
-- Клиент закреплён ровно за одним продавцом (tenant_id уникален). tariff_kind/
-- tariff_params — слепок условий на момент закрепления: изменение тарифа задним
-- числом не переписывает уже начисленное.
-- -----------------------------------------------------------------------------
CREATE TABLE sales_client_assignments (
id BIGSERIAL PRIMARY KEY,
sales_user_id BIGINT NOT NULL REFERENCES sales_users(id),
tenant_id BIGINT NOT NULL UNIQUE REFERENCES tenants(id),
tariff_id BIGINT REFERENCES sales_tariffs(id),
tariff_kind VARCHAR(20),
tariff_params JSONB NOT NULL DEFAULT '{}'::jsonb,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_sca_sales_user ON sales_client_assignments (sales_user_id);
-- -----------------------------------------------------------------------------
-- sales_attachment_requests — заявка продавца «этот клиент мой»
-- -----------------------------------------------------------------------------
-- login_input — что продавец назвал (почта или логин). status: pending |
-- approved | rejected | not_found (такого клиента у нас нет).
-- -----------------------------------------------------------------------------
CREATE TABLE sales_attachment_requests (
id BIGSERIAL PRIMARY KEY,
sales_user_id BIGINT NOT NULL REFERENCES sales_users(id),
login_input VARCHAR(255) NOT NULL,
tenant_id BIGINT REFERENCES tenants(id),
status VARCHAR(12) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','approved','rejected','not_found')),
comment TEXT,
decided_by BIGINT REFERENCES sales_users(id),
decided_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_sar_status ON sales_attachment_requests (status);
-- -----------------------------------------------------------------------------
-- sales_payouts — выплаты продавцам, только дописывание
-- -----------------------------------------------------------------------------
-- 🔴 Строку нельзя ни изменить, ни удалить — стережёт триггер: деньги, однажды
-- записанные, задним числом не переписываются. Ошиблись — новая строка с минусом.
-- 🪤 paid_on — КАЛЕНДАРНАЯ дата (DATE), а не мгновение: в пояс не переводить.
-- -----------------------------------------------------------------------------
CREATE TABLE sales_payouts (
id BIGSERIAL PRIMARY KEY,
sales_user_id BIGINT NOT NULL REFERENCES sales_users(id),
amount_rub NUMERIC(12,2) NOT NULL CHECK (amount_rub > 0),
paid_on DATE NOT NULL,
comment TEXT,
created_by BIGINT NOT NULL REFERENCES sales_users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_payout_user ON sales_payouts (sales_user_id);
CREATE OR REPLACE FUNCTION sales_payouts_no_mutate() RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'sales_payouts is append-only';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sales_payouts_no_mutate
BEFORE UPDATE OR DELETE ON sales_payouts
FOR EACH ROW EXECUTE FUNCTION sales_payouts_no_mutate();
-- -----------------------------------------------------------------------------
-- sales_sms_senders / sales_sms_campaigns / sales_sms_messages / sales_sms_optouts
-- -----------------------------------------------------------------------------
-- Смс-рассылка отдела продаж. tenant_id здесь NULL = рассылка наша собственная,
-- не от имени клиента; поэтому уникальность считается через COALESCE(tenant_id, 0).
-- 🪤 sales_sms_messages БЕЗ created_at/updated_at (timestamps=false у модели):
-- время события живёт в sent_at/delivered_at.
-- -----------------------------------------------------------------------------
CREATE TABLE sales_sms_senders (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT,
name VARCHAR(11) NOT NULL, -- имя отправителя у оператора
provider_key VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','pending','active','rejected')),
monthly_fee_kopecks INTEGER,
submitted_at TIMESTAMPTZ,
approved_at TIMESTAMPTZ,
rejected_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uniq_sms_sender ON sales_sms_senders (COALESCE(tenant_id, 0::bigint), name, provider_key);
CREATE TABLE sales_sms_campaigns (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT,
sender_id BIGINT REFERENCES sales_sms_senders(id),
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','queued','sending','done','failed','canceled')),
created_by BIGINT NOT NULL,
planned_count INTEGER NOT NULL DEFAULT 0,
sent_count INTEGER NOT NULL DEFAULT 0,
failed_count INTEGER NOT NULL DEFAULT 0,
skipped_count INTEGER NOT NULL DEFAULT 0,
estimated_cost_kopecks INTEGER NOT NULL DEFAULT 0,
actual_cost_kopecks INTEGER NOT NULL DEFAULT 0,
balance_transaction_id BIGINT,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE sales_sms_messages (
id BIGSERIAL PRIMARY KEY,
campaign_id BIGINT NOT NULL REFERENCES sales_sms_campaigns(id) ON DELETE CASCADE,
phone_id BIGINT,
phone VARCHAR(11) NOT NULL,
operator VARCHAR(30),
phone_type VARCHAR(12),
provider_key VARCHAR(20),
status VARCHAR(24) NOT NULL DEFAULT 'pending',
segments SMALLINT NOT NULL DEFAULT 1,
cost_kopecks INTEGER NOT NULL DEFAULT 0,
provider_message_id VARCHAR(64),
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
error TEXT
);
CREATE INDEX idx_sms_messages_campaign ON sales_sms_messages (campaign_id, status);
CREATE UNIQUE INDEX uniq_sms_message_per_campaign ON sales_sms_messages (campaign_id, phone);
CREATE TABLE sales_sms_optouts (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT,
phone VARCHAR(11) NOT NULL,
reason VARCHAR(30) NOT NULL DEFAULT 'manual'
CHECK (reason IN ('manual','reply_stop','complaint','operator')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uniq_sms_optout ON sales_sms_optouts (COALESCE(tenant_id, 0::bigint), phone);
-- -----------------------------------------------------------------------------
-- sales_ad_audience_* — «прогрев»: фирму видят в рекламе ещё до звонка
-- -----------------------------------------------------------------------------
-- Найденные фирмы попадают в аудиторию рекламных площадок (Яндекс, ВК, МТС) и
-- некоторое время видят нашу рекламу; продавец звонит уже «тёплому».
-- firms — сама фирма, phones — её номера в аудитории, firm_channels — на каких
-- площадках она сейчас, warming_episodes — летопись «когда и где грели»,
-- platforms — настройки площадок, state — единственная строка общего рубильника.
-- -----------------------------------------------------------------------------
CREATE TABLE sales_ad_audience_firms (
id BIGSERIAL PRIMARY KEY,
firm_inn VARCHAR(20),
firm_name VARCHAR(500) NOT NULL,
legal_name VARCHAR(500),
city VARCHAR(255),
rubric VARCHAR(255),
site VARCHAR(500),
phone VARCHAR(64),
rating_label VARCHAR(32),
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
contacts JSONB NOT NULL DEFAULT '[]'::jsonb,
prospect_id BIGINT, -- карточка в воронке, если уже заведена
-- Старый одиночный признак канала (channels) оставлен для совместимости,
-- живые флаги — ch_yandex/ch_vk/ch_mts плюс строки firm_channels.
channels VARCHAR(8) NOT NULL DEFAULT 'yandex'
CHECK (channels IN ('yandex','vk','both')),
ch_yandex BOOLEAN NOT NULL DEFAULT FALSE,
ch_vk BOOLEAN NOT NULL DEFAULT FALSE,
ch_mts BOOLEAN NOT NULL DEFAULT FALSE,
stage_seen VARCHAR(16),
stage_changed_at TIMESTAMPTZ,
warmup_started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ready_at TIMESTAMPTZ,
assigned_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
stop_reason VARCHAR(32),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uq_ad_firm_inn ON sales_ad_audience_firms (firm_inn) WHERE firm_inn IS NOT NULL;
CREATE INDEX idx_ad_firm_channels ON sales_ad_audience_firms (channels);
CREATE INDEX idx_ad_firm_prospect ON sales_ad_audience_firms (prospect_id) WHERE prospect_id IS NOT NULL;
CREATE INDEX idx_ad_firm_ch_yandex ON sales_ad_audience_firms (ch_yandex) WHERE ch_yandex;
CREATE INDEX idx_ad_firm_ch_vk ON sales_ad_audience_firms (ch_vk) WHERE ch_vk;
CREATE INDEX idx_ad_firm_ch_mts ON sales_ad_audience_firms (ch_mts) WHERE ch_mts;
CREATE TABLE sales_ad_audience_firm_channels (
id BIGSERIAL PRIMARY KEY,
firm_id BIGINT NOT NULL REFERENCES sales_ad_audience_firms(id) ON DELETE CASCADE,
channel VARCHAR(8) NOT NULL CONSTRAINT chk_afc_channel
CHECK (channel IN ('yandex','vk','mts','sms')),
status VARCHAR(12) NOT NULL DEFAULT 'loaded' CONSTRAINT chk_afc_status
CHECK (status IN ('loaded','warming')),
mode VARCHAR(8) CONSTRAINT chk_afc_mode
CHECK (mode IS NULL OR mode IN ('funnel','flat')),
flat_days INTEGER CONSTRAINT chk_afc_flat_days
CHECK (flat_days IS NULL OR (flat_days >= 1 AND flat_days <= 365)),
warming_started_at TIMESTAMPTZ,
warmed_times INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uq_afc_firm_channel ON sales_ad_audience_firm_channels (firm_id, channel);
CREATE INDEX idx_afc_warming ON sales_ad_audience_firm_channels (channel) WHERE status = 'warming';
CREATE TABLE sales_ad_audience_phones (
id BIGSERIAL PRIMARY KEY,
phone VARCHAR(11) NOT NULL UNIQUE,
firm_id BIGINT REFERENCES sales_ad_audience_firms(id) ON DELETE CASCADE,
firm_inn VARCHAR(20),
firm_name VARCHAR(500),
city VARCHAR(255),
rubric VARCHAR(255),
operator VARCHAR(30),
phone_type VARCHAR(12),
state VARCHAR(16) NOT NULL DEFAULT 'active' CONSTRAINT chk_ad_phone_state
CHECK (state IN ('active','paused','stopped')),
resume_at TIMESTAMPTZ,
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
synced_at TIMESTAMPTZ,
removed_at TIMESTAMPTZ
);
CREATE INDEX idx_ad_audience_live ON sales_ad_audience_phones (expires_at) WHERE removed_at IS NULL;
CREATE INDEX idx_ad_phone_state ON sales_ad_audience_phones (state) WHERE removed_at IS NULL;
-- Одна строка на запуск прогрева фирмы на канале. ended_at NULL = греется прямо
-- сейчас; для смс started_at = ended_at (мгновенное событие).
CREATE TABLE sales_ad_audience_warming_episodes (
id BIGSERIAL PRIMARY KEY,
firm_id BIGINT NOT NULL REFERENCES sales_ad_audience_firms(id) ON DELETE CASCADE,
channel VARCHAR(8) NOT NULL CONSTRAINT chk_awe_channel
CHECK (channel IN ('yandex','vk','mts','sms')),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_awe_firm_channel ON sales_ad_audience_warming_episodes (firm_id, channel);
CREATE INDEX idx_awe_open ON sales_ad_audience_warming_episodes (firm_id, channel) WHERE ended_at IS NULL;
-- Настройки площадки: сколько дней держать фирму в аудитории на каждой стадии
-- воронки. Все «дни» ограничены 1..365 — защита от опечатки в админке.
CREATE TABLE sales_ad_audience_platforms (
platform VARCHAR(8) PRIMARY KEY CONSTRAINT chk_ad_platform_code
CHECK (platform IN ('yandex','vk','mts')),
enabled BOOLEAN NOT NULL DEFAULT FALSE,
min_phones INTEGER NOT NULL,
external_id BIGINT,
status VARCHAR(16) NOT NULL DEFAULT 'ok' CONSTRAINT chk_ad_platform_status
CHECK (status IN ('ok','no_access','waiting_volume','working')),
last_synced_at TIMESTAMPTZ,
last_error TEXT,
warmup_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_adp_warmup_days CHECK (warmup_days BETWEEN 1 AND 365),
new_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_adp_new_days CHECK (new_days BETWEEN 1 AND 365),
in_work_days INTEGER NOT NULL DEFAULT 14 CONSTRAINT chk_adp_in_work_days CHECK (in_work_days BETWEEN 1 AND 365),
negotiation_overdue_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_adp_negotiation_overdue_days CHECK (negotiation_overdue_days BETWEEN 1 AND 365),
negotiation_far_threshold_days INTEGER NOT NULL DEFAULT 14 CONSTRAINT chk_adp_negotiation_far_threshold_days CHECK (negotiation_far_threshold_days BETWEEN 1 AND 365),
negotiation_far_head_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_adp_negotiation_far_head_days CHECK (negotiation_far_head_days BETWEEN 1 AND 365),
negotiation_far_lead_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_adp_negotiation_far_lead_days CHECK (negotiation_far_lead_days BETWEEN 1 AND 365),
no_answer_days INTEGER NOT NULL DEFAULT 7 CONSTRAINT chk_adp_no_answer_days CHECK (no_answer_days BETWEEN 1 AND 365),
rejected_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_adp_rejected_days CHECK (rejected_days BETWEEN 1 AND 365),
registered_days INTEGER NOT NULL DEFAULT 30 CONSTRAINT chk_adp_registered_days CHECK (registered_days BETWEEN 1 AND 365),
testing_days INTEGER NOT NULL DEFAULT 30 CONSTRAINT chk_adp_testing_days CHECK (testing_days BETWEEN 1 AND 365),
days INTEGER NOT NULL DEFAULT 30 CONSTRAINT chk_adp_days CHECK (days BETWEEN 1 AND 365),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Общий рубильник прогрева. Строка ровно одна: id BOOLEAN с проверкой CHECK (id) —
-- вторую строку положить физически некуда.
CREATE TABLE sales_ad_audience_state (
id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id),
enabled BOOLEAN NOT NULL DEFAULT FALSE,
days INTEGER NOT NULL DEFAULT 30 CHECK (days BETWEEN 1 AND 365),
yandex_segment_id BIGINT,
last_synced_at TIMESTAMPTZ,
last_error TEXT,
vk_list_id BIGINT,
vk_last_synced_at TIMESTAMPTZ,
vk_last_error TEXT,
vk_status VARCHAR(16) NOT NULL DEFAULT 'no_access' CONSTRAINT chk_ad_vk_status
CHECK (vk_status IN ('no_access','waiting_volume','working')),
warmup_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_ad_warmup_days CHECK (warmup_days BETWEEN 1 AND 365),
new_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_ad_new_days CHECK (new_days BETWEEN 1 AND 365),
in_work_days INTEGER NOT NULL DEFAULT 14 CONSTRAINT chk_ad_in_work_days CHECK (in_work_days BETWEEN 1 AND 365),
negotiation_overdue_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_ad_negotiation_overdue_days CHECK (negotiation_overdue_days BETWEEN 1 AND 365),
negotiation_far_threshold_days INTEGER NOT NULL DEFAULT 14 CONSTRAINT chk_ad_negotiation_far_threshold_days CHECK (negotiation_far_threshold_days BETWEEN 1 AND 365),
negotiation_far_head_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_ad_negotiation_far_head_days CHECK (negotiation_far_head_days BETWEEN 1 AND 365),
negotiation_far_lead_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_ad_negotiation_far_lead_days CHECK (negotiation_far_lead_days BETWEEN 1 AND 365),
no_answer_days INTEGER NOT NULL DEFAULT 7 CONSTRAINT chk_ad_no_answer_days CHECK (no_answer_days BETWEEN 1 AND 365),
rejected_days INTEGER NOT NULL DEFAULT 3 CONSTRAINT chk_ad_rejected_days CHECK (rejected_days BETWEEN 1 AND 365),
registered_days INTEGER NOT NULL DEFAULT 30 CONSTRAINT chk_ad_registered_days CHECK (registered_days BETWEEN 1 AND 365),
testing_days INTEGER NOT NULL DEFAULT 30 CONSTRAINT chk_ad_testing_days CHECK (testing_days BETWEEN 1 AND 365),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Права отдела продаж: клиентской роли здесь нет вовсе.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT, INSERT, UPDATE ON
sales_tariffs, sales_users, sales_prospects, sales_client_assignments,
sales_attachment_requests, sales_sms_senders, sales_sms_campaigns,
sales_sms_messages, sales_ad_audience_phones, sales_ad_audience_platforms,
sales_ad_audience_state
TO crm_admin_user;
GRANT SELECT, INSERT ON sales_payouts, sales_prospect_moves, sales_prospect_notes TO crm_admin_user;
GRANT SELECT, INSERT, DELETE ON sales_sms_optouts TO crm_admin_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON
sales_ad_audience_firms, sales_ad_audience_firm_channels,
sales_ad_audience_warming_episodes
TO crm_admin_user;
GRANT USAGE, SELECT ON SEQUENCE
sales_tariffs_id_seq, sales_users_id_seq, sales_prospects_id_seq,
sales_prospect_moves_id_seq, sales_prospect_notes_id_seq,
sales_client_assignments_id_seq, sales_attachment_requests_id_seq,
sales_payouts_id_seq, sales_sms_senders_id_seq, sales_sms_campaigns_id_seq,
sales_sms_messages_id_seq, sales_sms_optouts_id_seq,
sales_ad_audience_firms_id_seq, sales_ad_audience_firm_channels_id_seq,
sales_ad_audience_phones_id_seq, sales_ad_audience_warming_episodes_id_seq
TO crm_admin_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT, INSERT, UPDATE ON
sales_tariffs, sales_users, sales_prospects, sales_client_assignments,
sales_attachment_requests, sales_sms_senders, sales_sms_campaigns,
sales_sms_messages, sales_ad_audience_phones, sales_ad_audience_platforms,
sales_ad_audience_state
TO crm_supplier_worker;
GRANT SELECT, INSERT ON sales_payouts, sales_prospect_moves, sales_prospect_notes TO crm_supplier_worker;
GRANT SELECT, INSERT, DELETE ON sales_sms_optouts TO crm_supplier_worker;
GRANT SELECT, INSERT, UPDATE, DELETE ON
sales_ad_audience_firms, sales_ad_audience_firm_channels,
sales_ad_audience_warming_episodes
TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE
sales_tariffs_id_seq, sales_users_id_seq, sales_prospects_id_seq,
sales_prospect_moves_id_seq, sales_prospect_notes_id_seq,
sales_client_assignments_id_seq, sales_attachment_requests_id_seq,
sales_payouts_id_seq, sales_sms_senders_id_seq, sales_sms_campaigns_id_seq,
sales_sms_messages_id_seq, sales_sms_optouts_id_seq,
sales_ad_audience_firms_id_seq, sales_ad_audience_firm_channels_id_seq,
sales_ad_audience_phones_id_seq, sales_ad_audience_warming_episodes_id_seq
TO crm_supplier_worker;
END IF;
END $$;
-- =============================================================================
-- 21. СЛУЖЕБНЫЕ ТАБЛИЦЫ БЕЗ РАЗДЕЛА — v8.86, canon-sync
-- =============================================================================
-- -----------------------------------------------------------------------------
-- personal_access_tokens — токены Laravel Sanctum
-- -----------------------------------------------------------------------------
-- Ставит сам Sanctum; в каноне нужна, иначе `migrate:fresh` со схемы даёт
-- «отношение не существует» при входе по токену. Владелец токена — любая модель
-- (tokenable_type/tokenable_id), поэтому внешнего ключа тут нет.
-- -----------------------------------------------------------------------------
CREATE TABLE personal_access_tokens (
id BIGSERIAL PRIMARY KEY,
tokenable_type VARCHAR(255) NOT NULL,
tokenable_id BIGINT NOT NULL,
name TEXT NOT NULL,
token VARCHAR(64) NOT NULL UNIQUE,
abilities TEXT,
last_used_at TIMESTAMP(0),
expires_at TIMESTAMP(0),
created_at TIMESTAMP(0),
updated_at TIMESTAMP(0)
);
CREATE INDEX personal_access_tokens_tokenable_type_tokenable_id_index
ON personal_access_tokens (tokenable_type, tokenable_id);
CREATE INDEX personal_access_tokens_expires_at_index ON personal_access_tokens (expires_at);
-- -----------------------------------------------------------------------------
-- supplier_order_checks — сверка заказанного у поставщика с тем, что стоит в ЛК
-- -----------------------------------------------------------------------------
-- intended_rows — сколько строк мы намеревались выставить, live_rows — сколько
-- реально стоит в кабинете поставщика. status: ok | mismatch | unable_to_verify
-- (в кабинет не попали — это НЕ расхождение, а «не смогли посмотреть»).
-- SaaS-level, без RLS; пишет crm_supplier_worker через соединение pgsql_supplier.
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_order_checks (
id BIGSERIAL PRIMARY KEY,
checked_at TIMESTAMPTZ NOT NULL,
sync_run_id BIGINT,
intended_rows INTEGER NOT NULL DEFAULT 0,
live_rows INTEGER NOT NULL DEFAULT 0,
mismatch_count INTEGER NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL
CHECK (status IN ('ok','mismatch','unable_to_verify')),
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_supplier_order_checks_checked ON supplier_order_checks (checked_at DESC);
CREATE INDEX idx_supplier_order_checks_status ON supplier_order_checks (status);
-- -----------------------------------------------------------------------------
-- supplier_webhook_log — журнал стуков поставщика в наш вебхук
-- -----------------------------------------------------------------------------
-- Заведён 31.07.2026: журнала не было вовсе, и 70 отказов «404» за десять дней
-- никто не видел. Секрет не хранится — только отпечаток первых знаков
-- (secret_fingerprint), чтобы отличить «пришли не с тем ключом» от «пришли не туда».
-- ПДн нет: телефон не пишем, только номер лида у поставщика.
-- SaaS-level, без RLS; соединение pgsql_supplier.
-- 🪤 Запись идёт ВТОРЫМ соединением — обычный откат теста её не достаёт
-- (лечится трейтом SharesSupplierPdo).
-- -----------------------------------------------------------------------------
CREATE TABLE supplier_webhook_log (
id BIGSERIAL PRIMARY KEY,
status VARCHAR(32) NOT NULL,
secret_fingerprint VARCHAR(8),
ip_address VARCHAR(45),
target_host VARCHAR(255),
supplier_lead_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_supplier_webhook_log_created ON supplier_webhook_log (created_at DESC);
CREATE INDEX idx_supplier_webhook_log_status ON supplier_webhook_log (status, created_at DESC);
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON personal_access_tokens TO crm_admin_user;
GRANT SELECT ON supplier_webhook_log TO crm_admin_user;
GRANT USAGE, SELECT ON SEQUENCE
personal_access_tokens_id_seq, supplier_order_checks_id_seq,
supplier_webhook_log_id_seq
TO crm_admin_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON personal_access_tokens TO crm_supplier_worker;
GRANT SELECT, INSERT ON supplier_order_checks TO crm_supplier_worker;
GRANT SELECT, INSERT, DELETE ON supplier_webhook_log TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE
personal_access_tokens_id_seq, supplier_order_checks_id_seq,
supplier_webhook_log_id_seq
TO crm_supplier_worker;
END IF;
END $$;
-- =============================================================================
-- 22. ПОЗДНИЕ ПРАВКИ ТАБЛИЦ, ОПИСАННЫХ В schema.sql
-- =============================================================================
-- Столбцы и переименования, которые миграции дописали к таблицам из тела
-- schema.sql. В само тело их перенести НЕЛЬЗЯ: соответствующие миграции сделаны
-- без стражей и на сборке с нуля столкнутся сами с собой. Каждая строка ниже
-- помечена своей миграцией.
--
-- 🪤 Здесь только те правки, что в schema.sql не влезли. Остальные поздние
-- столбцы (supplier_csv_reconcile_log.pending_count, saas_invoices.credit_target,
-- saas_transactions.credit_target) в теле schema.sql УЖЕ есть — их миграции
-- написаны через `ADD COLUMN IF NOT EXISTS` и столкновения не дают.
-- -----------------------------------------------------------------------------
-- Автоподбор: пачка прогонов, запущенных одним нажатием. Миграция 2026_07_08_120000
-- (Blueprint без стража — потому и не в теле schema.sql).
ALTER TABLE autopodbor_runs ADD COLUMN batch_id UUID;
CREATE INDEX autopodbor_runs_tenant_batch_idx ON autopodbor_runs (tenant_id, batch_id);
CREATE INDEX autopodbor_runs_status_kind_idx ON autopodbor_runs (status, kind);
-- Свой чат вместо Jivo (спека 2026-07-13-own-chat-widget-design §5).
-- Миграция 2026_07_13_100000_chat_widget_columns: номер разговора теперь наш,
-- добавлены «откуда пришёл», «кто клиент» (если вошёл) и адрес.
-- Переименование сделано без стража — потому и не в теле schema.sql.
-- На проде таблицы не было (бот там не стоял) — переименование безопасно.
ALTER TABLE bot_dialogs RENAME COLUMN jivo_chat_id TO chat_id;
ALTER TABLE bot_dialogs ADD COLUMN source VARCHAR(16) NOT NULL DEFAULT 'portal'; -- portal | landing
ALTER TABLE bot_dialogs ADD COLUMN user_id BIGINT; -- клиент, если вошёл (без FK: диалоги переживают удаление)
ALTER TABLE bot_dialogs ADD COLUMN ip VARCHAR(45);
-- Опрос окошка: «дай реплики этого разговора после id N».
CREATE INDEX idx_bot_dialogs_chat_seq ON bot_dialogs (chat_id, id);
-- NB: указатель idx_bot_dialogs_chat в schema.sql заведён по старому имени столбца;
-- после переименования PostgreSQL сам считает его индексом по chat_id.
-- =============================================================================
-- 23. МОДУЛЬ ОБЗВОНА (obzvon_*) — задача З-1.1, запись журнала v9.68
-- =============================================================================
-- Голосовой робот сам звонит по списку номеров, разговаривает с человеком и
-- переводит на менеджера клиента. Клиент за это платит. Здесь — место, куда
-- робот кладёт результат: без него нечего списывать, нечего показать клиенту и
-- нечем ответить через год на вопрос «за что вы взяли деньги в августе».
--
-- Мест ТРИ, а не одно:
-- • obzvon_calls — одна попытка набора (по номеру их бывает до 12, Р53);
-- • obzvon_number_results — единственный итог на всю работу с номером (Т84);
-- • расшифровка — прямо в строке звонка (obzvon_calls.transcript).
-- Отдельного хранилища под расшифровку НЕТ: Р50 сняло бессрочную обезличенную
-- расшифровку, а то, что живёт три месяца, обезличивать незачем.
--
-- 🔴 Имя своё, а не call_recordings из закомментированного чертежа раздела 17
-- db/schema.sql: чертёж был про телефонию вообще и не знает ни статуса обзвона,
-- ни двух сроков хранения со следом удаления, ни «почему звонили», ни двух плеч,
-- ни попыток с итогом. Занять чужое имя наполовину хуже, чем завести своё.
--
-- 🪤 Времена здесь — TIMESTAMPTZ (как в теле schema.sql и в lead_charges), а НЕ
-- TIMESTAMP(0) без пояса, как в разделах 18–19. Звонок происходит в конкретный
-- миг, и пояс у него терять нельзя.
--
-- Миграция: app/database/migrations/2026_08_05_100000_create_obzvon_calls.php.
-- План: docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md §З-1.1.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- obzvon_number_results — ЕДИНСТВЕННЫЙ итог по номеру (Т84)
-- -----------------------------------------------------------------------------
-- Именно он стоит в карточке сделки (Т65), а не последняя попытка: двенадцать
-- недозвонов обязаны дать ОДИН итог «не дозвонились», а не двенадцать строк.
-- Замок, который это держит, — UNIQUE (tenant_id, campaign_id, phone).
--
-- 🔴 Это НЕ статус сделки: у сделки своя воронка (14 значений), у обзвона своя.
-- 🟡 Раскладка семи исходов Т21 на «про попытку» и «про номер» владельцем ещё не
-- сделана (план §З-2.4, осталось открытым) — список ниже это восемь статусов
-- обзвона Т65 и лежит в одном месте, чтобы переиграть его стоило минуту.
--
-- campaign_id БЕЗ FK: таблицы кампаний обзвона ещё нет, её строит соседняя задача.
-- deal_id БЕЗ прямого FK на столбец — составной FK на партиционированную
-- deals(id, received_at) ниже.
-- -----------------------------------------------------------------------------
CREATE TABLE obzvon_number_results (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
campaign_id BIGINT NOT NULL,
phone VARCHAR(20) NOT NULL,
deal_id BIGINT,
deal_received_at TIMESTAMPTZ,
outcome VARCHAR(24),
outcome_at TIMESTAMPTZ,
callback_at TIMESTAMPTZ,
attempts_count SMALLINT NOT NULL DEFAULT 0,
last_call_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT obzvon_number_results_one_per_phone UNIQUE (tenant_id, campaign_id, phone),
CONSTRAINT chk_obzvon_number_results_attempts CHECK (attempts_count >= 0),
CONSTRAINT chk_obzvon_number_results_deal_pair
CHECK ((deal_id IS NULL) = (deal_received_at IS NULL)),
CONSTRAINT chk_obzvon_number_results_outcome
CHECK (outcome IS NULL OR outcome IN (
'transferred', 'transfer_failed', 'callback', 'email_sent',
'not_target', 'refused', 'no_answer', 'aggression'))
);
-- -----------------------------------------------------------------------------
-- obzvon_calls — ОДНА попытка набора
-- -----------------------------------------------------------------------------
-- 🔴 Два плеча врозь (Т78, Р44): leg1_* — робот с человеком, leg2_* — человек с
-- менеджером клиента после перевода, у второго свой признак «состоялось». Плату
-- за второе плечо владелец отложил (развилка 22), но УЧЁТ отложить нельзя:
-- пересчитать задним числом то, чего не записали, невозможно.
--
-- 🔴 Цена ЗАПОМНЕНА в строке вместе со снимком тарифа (Т57): price_kopecks —
-- сколько взяли, tariff_answer_kopecks / tariff_minute_kopecks — по какому тарифу
-- посчитали. Смена тарифа завтра НЕ перепишет вчерашний счёт, потому что счёт
-- ничего не спрашивает у тарифа. Образец — lead_charges.tier_no +
-- price_per_lead_kopecks. Деньги целыми копейками, не дробными числами.
-- billable = FALSE — единственное исключение тарифа (Т87, Р49): дозвон на
-- закрытый номер считается, но строки в счёте не даёт.
--
-- 🔴 Строку НИКОГДА не удаляют целиком: вместе с ней уходят деньги. Чистка идёт
-- тремя ступенями ПО ТОЙ ЖЕ строке (Т88): месяц — стирается звук (Т70), три
-- месяца — расшифровка (Т71), дальше живут сухие итоги. Отсюда два срока и два
-- следа удаления: без них карточка не отличит «стёрто по сроку» от «не
-- записалось вовсе» и покажет сломанный проигрыватель (Т73). Прав на DELETE не
-- выдано ни одной роли — намеренно.
--
-- external_call_id + частичный UNIQUE — против задвоенного отчёта робота: повтор
-- по тому же звонку база не примет, а задвоенный отчёт это задвоенные деньги (Т62).
-- launched_by_user_id / launched_at — «почему звонили»: кто и когда поставил
-- кампанию в работу (Т68).
-- -----------------------------------------------------------------------------
CREATE TABLE obzvon_calls (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
number_result_id BIGINT REFERENCES obzvon_number_results(id) ON DELETE SET NULL,
campaign_id BIGINT,
script_id BIGINT,
launched_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
launched_at TIMESTAMPTZ,
deal_id BIGINT,
deal_received_at TIMESTAMPTZ,
phone VARCHAR(20) NOT NULL,
from_number VARCHAR(20),
attempt_no SMALLINT NOT NULL DEFAULT 1,
direction VARCHAR(10) NOT NULL DEFAULT 'outbound',
external_call_id VARCHAR(100),
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
answered BOOLEAN NOT NULL DEFAULT FALSE,
leg1_duration_sec INTEGER NOT NULL DEFAULT 0,
leg2_connected BOOLEAN NOT NULL DEFAULT FALSE,
leg2_started_at TIMESTAMPTZ,
leg2_duration_sec INTEGER NOT NULL DEFAULT 0,
attempt_outcome VARCHAR(16),
recording_path VARCHAR(500),
recording_expires_at TIMESTAMPTZ,
recording_deleted_at TIMESTAMPTZ,
transcript TEXT,
transcript_expires_at TIMESTAMPTZ,
transcript_deleted_at TIMESTAMPTZ,
billable BOOLEAN NOT NULL DEFAULT TRUE,
price_kopecks INTEGER NOT NULL DEFAULT 0,
billed_minutes SMALLINT NOT NULL DEFAULT 0,
tariff_answer_kopecks INTEGER NOT NULL DEFAULT 0,
tariff_minute_kopecks INTEGER NOT NULL DEFAULT 0,
billing_note VARCHAR(64),
charged_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_obzvon_calls_direction CHECK (direction IN ('inbound', 'outbound')),
CONSTRAINT chk_obzvon_calls_attempt_no CHECK (attempt_no >= 1),
CONSTRAINT chk_obzvon_calls_outcome
CHECK (attempt_outcome IS NULL OR attempt_outcome IN (
'answered', 'no_answer', 'busy', 'declined')),
CONSTRAINT chk_obzvon_calls_deal_pair
CHECK ((deal_id IS NULL) = (deal_received_at IS NULL)),
CONSTRAINT chk_obzvon_calls_leg1_nonneg CHECK (leg1_duration_sec >= 0),
CONSTRAINT chk_obzvon_calls_leg2_nonneg CHECK (leg2_duration_sec >= 0),
CONSTRAINT chk_obzvon_calls_leg2_silent
CHECK (leg2_connected OR (leg2_duration_sec = 0 AND leg2_started_at IS NULL)),
CONSTRAINT chk_obzvon_calls_money_nonneg
CHECK (price_kopecks >= 0 AND billed_minutes >= 0
AND tariff_answer_kopecks >= 0 AND tariff_minute_kopecks >= 0),
CONSTRAINT chk_obzvon_calls_free_is_zero CHECK (billable OR price_kopecks = 0)
);
-- Составные FK на партиционированную deals(id, received_at) — как у lead_charges.
-- DEFERRABLE INITIALLY DEFERRED: сделку и звонок кладут в одной транзакции.
-- ON DELETE CASCADE отвечает на проверку «сделку удалили — звонки не висят».
ALTER TABLE obzvon_number_results
ADD CONSTRAINT obzvon_number_results_deals_fk
FOREIGN KEY (deal_id, deal_received_at)
REFERENCES deals(id, received_at)
ON DELETE CASCADE
DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE obzvon_calls
ADD CONSTRAINT obzvon_calls_deals_fk
FOREIGN KEY (deal_id, deal_received_at)
REFERENCES deals(id, received_at)
ON DELETE CASCADE
DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX obzvon_number_results_deal_idx
ON obzvon_number_results (deal_id, deal_received_at);
CREATE INDEX obzvon_number_results_tenant_outcome_idx
ON obzvon_number_results (tenant_id, outcome);
CREATE INDEX obzvon_calls_tenant_started_idx
ON obzvon_calls (tenant_id, started_at DESC);
CREATE INDEX obzvon_calls_deal_idx
ON obzvon_calls (deal_id, deal_received_at);
CREATE INDEX obzvon_calls_number_result_idx
ON obzvon_calls (number_result_id);
CREATE INDEX obzvon_calls_tenant_campaign_phone_idx
ON obzvon_calls (tenant_id, campaign_id, phone);
CREATE UNIQUE INDEX obzvon_calls_external_id_uniq
ON obzvon_calls (tenant_id, external_call_id)
WHERE external_call_id IS NOT NULL;
-- Указатели под чистку по срокам (Т88): джоб ищет просроченные, а не перебирает всё.
CREATE INDEX obzvon_calls_recording_cleanup_idx
ON obzvon_calls (recording_expires_at)
WHERE recording_path IS NOT NULL AND recording_deleted_at IS NULL;
CREATE INDEX obzvon_calls_transcript_cleanup_idx
ON obzvon_calls (transcript_expires_at)
WHERE transcript_deleted_at IS NULL;
-- 🔴 RLS: ENABLE + FORCE (даже владелец таблицы подчиняется политике) + USING и
-- WITH CHECK — иначе клиент А сможет подложить строку клиенту Б.
-- 🪤 Служебным ролям этого МАЛО: после накатки обязателен перезапуск
-- db/03_service_bypass_policies.sql, иначе служебная роль молча правит НОЛЬ строк.
ALTER TABLE obzvon_number_results ENABLE ROW LEVEL SECURITY;
ALTER TABLE obzvon_number_results FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON obzvon_number_results
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
ALTER TABLE obzvon_calls ENABLE ROW LEVEL SECURITY;
ALTER TABLE obzvon_calls FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON obzvon_calls
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- Права: SELECT + INSERT + UPDATE. DELETE не выдан НИКОМУ намеренно — вместе со
-- строкой звонка уходят деньги, а чистка по срокам только обнуляет столбцы.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT SELECT, INSERT, UPDATE ON obzvon_calls, obzvon_number_results TO crm_app_user;
GRANT USAGE, SELECT ON SEQUENCE obzvon_calls_id_seq, obzvon_number_results_id_seq TO crm_app_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT, INSERT, UPDATE ON obzvon_calls, obzvon_number_results TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE obzvon_calls_id_seq, obzvon_number_results_id_seq TO crm_supplier_worker;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT, INSERT, UPDATE ON obzvon_calls, obzvon_number_results TO crm_admin_user;
GRANT USAGE, SELECT ON SEQUENCE obzvon_calls_id_seq, obzvon_number_results_id_seq TO crm_admin_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
GRANT SELECT, INSERT, UPDATE ON obzvon_calls, obzvon_number_results TO crm_migrator;
GRANT USAGE, SELECT ON SEQUENCE obzvon_calls_id_seq, obzvon_number_results_id_seq TO crm_migrator;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_readonly') THEN
GRANT SELECT ON obzvon_calls, obzvon_number_results TO crm_readonly;
END IF;
END $$;
-- -----------------------------------------------------------------------------
-- obzvon_tariffs — ДВЕ ЦИФРЫ ТАРИФА, которые правит админ (задача З-1.3, Т52/Т56)
-- -----------------------------------------------------------------------------
-- Правило цены звонка живёт в коде (App\Services\Obzvon\ObzvonTarifikator) и
-- больше нигде. Здесь — только сами два числа, которые в него приходят снаружи:
-- за снятую трубку и за начатую минуту. Без этой таблицы поправить цену можно
-- было бы только руками программиста и новой сборкой.
--
-- 🔴 Обе цифры назначены владельцем ВСЛЕПУЮ: сколько нам самим стоит звонок, ни
-- разу не измерено. 2 ₽ и 10 ₽ — не расчёт, а первое приближение. Записано прямо
-- рядом с числами намеренно: через месяц никто не должен принять их за
-- обоснованные — и менять их будут наверняка.
--
-- 🔴 Строка ОДНА на весь портал: замок CHECK (id = 1). Две строки означали бы два
-- разных ответа на вопрос «сколько стоит звонок», и какой попадёт в счёт — как
-- повезёт. RLS нет по той же причине, что у client_sms_settings и
-- client_tg_tariffs: прайс единый для всех клиентов, таблица не клиентская.
--
-- 🔴 Деньги — целые копейки: те же единицы, что у obzvon_calls.tariff_answer_kopecks
-- и tariff_minute_kopecks. Смена тарифа НЕ пересчитывает вчерашние звонки (Т57) —
-- цена и снимок тарифа лежат в самой строке звонка; здесь только «сколько будет
-- стоить СЛЕДУЮЩИЙ».
--
-- Правки тарифа оставляют след в saas_admin_audit_log, action 'obzvon_tariff.update'
-- (кто, когда, с чего на что) — своей таблицы истории у тарифа нет намеренно.
--
-- Миграция: app/database/migrations/2026_08_05_110000_create_obzvon_tariffs.php.
-- План: docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md §З-1.3.
CREATE TABLE obzvon_tariffs (
id BIGSERIAL PRIMARY KEY,
answer_kopecks INTEGER NOT NULL DEFAULT 200,
minute_kopecks INTEGER NOT NULL DEFAULT 1000,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT obzvon_tariffs_singleton CHECK (id = 1),
CONSTRAINT chk_obzvon_tariffs_nonneg
CHECK (answer_kopecks >= 0 AND minute_kopecks >= 0)
);
-- Умолчание владельца: 2 ₽ за снятую трубку и 10 ₽ за начатую минуту.
-- 🔴 Обе цифры назначены ВСЛЕПУЮ — себестоимость звонка не измерена ни разу.
INSERT INTO obzvon_tariffs (id, answer_kopecks, minute_kopecks) VALUES (1, 200, 1000);
-- Явный id не двигает счётчик, а без этого следующая вставка попробовала бы
-- занять тот же id и упала бы «дубликатом» вместо честного «строка тут одна».
SELECT setval('obzvon_tariffs_id_seq', 1, true);
-- Права: править тариф может только админ-роль. Рантайм-роли и служебная роль
-- джобов читают — им нужна цена следующего звонка, но менять её они не вправе.
-- DELETE не выдан НИКОМУ: без строки тарифа звонок считать не по чему.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT SELECT ON obzvon_tariffs TO crm_app_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT ON obzvon_tariffs TO crm_supplier_worker;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT, INSERT, UPDATE ON obzvon_tariffs TO crm_admin_user;
GRANT USAGE, SELECT ON SEQUENCE obzvon_tariffs_id_seq TO crm_admin_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
GRANT SELECT, INSERT, UPDATE ON obzvon_tariffs TO crm_migrator;
GRANT USAGE, SELECT ON SEQUENCE obzvon_tariffs_id_seq TO crm_migrator;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_readonly') THEN
GRANT SELECT ON obzvon_tariffs TO crm_readonly;
END IF;
END $$;
-- =============================================================================
-- 24. МАТЕРИАЛЫ КЛИЕНТА ДЛЯ ОБУЧЕНИЯ РОБОТА — задача З-2.3, запись журнала v9.70
-- =============================================================================
-- Клиент отдаёт нам записи СВОИХ холодных разговоров, чтобы робот говорил как
-- надо. 🔴 Это голоса живых людей, которые об этом не знают и согласия нам не
-- давали. Отсюда всё устройство таблицы ниже.
--
-- Миграция: app/database/migrations/2026_08_05_120000_create_obzvon_materialy_klienta.php.
-- План: docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md §З-2.3.
--
-- 🔴 ДВЕ ДАТЫ ИСТЕЧЕНИЯ, А НЕ ОДНА — решение владельца Р82 от 05.08.2026:
-- • звук чужой записи — 30 дней;
-- • расшифровка чужой записи — шесть месяцев.
-- Обе NOT NULL. Строка без даты истечения — это голос человека, которого не
-- найдёт ни одна чистка и который пролежит у нас вечно. Сами сроки живут в
-- system_settings (`obzvon_materialy_srok_zvuka_dney`,
-- `obzvon_materialy_srok_rasshifrovki_mesyacev`), а не в коде.
--
-- 🔴 ДВА СЛЕДА УДАЛЕНИЯ ВРОЗЬ. Команда чистки из З-2.2 обязана уметь стереть
-- звук, НЕ тронув текст (пять месяцев текст живёт без звука). А карточка обязана
-- отличать «стёрто по сроку» от «не загружали вовсе» и говорить это словами
-- (Т93), а не показывать сломанный проигрыватель.
-- Два замка держат это честным:
-- chk_obzvon_materialy_zvuk_styort — есть след удаления звука ⇒ пути нет;
-- chk_obzvon_materialy_tekst_styort — есть след удаления текста ⇒ текста нет.
-- Без них строка могла бы утверждать «стёрто», продолжая ссылаться на живой файл.
--
-- ⚠️ СТИРАЕТ НЕ ЭТА ЗАДАЧА, а З-2.2, и она НЕ построена. Здесь материалы только
-- принимаются, изолируются и получают две даты истечения.
--
-- 🪤 От какой даты считать полгода, владельцем НЕ задано (от загрузки к нам или
-- от даты самого разговора). До ответа считаем от загрузки — единственной даты,
-- которую мы знаем точно. Столбец `recorded_at` заведён заранее и заполняется,
-- если клиент дату разговора сообщил: в день ответа владельца будет по чему
-- пересчитать.
--
-- DELETE не выдан НИКОМУ намеренно: строка остаётся носителем надписи Т93. Уходит
-- она только вместе с клиентом — по ON DELETE CASCADE на tenants.
-- -----------------------------------------------------------------------------
CREATE TABLE obzvon_materialy_klienta (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
uploaded_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
disk VARCHAR(32) NOT NULL DEFAULT 'obzvon_materialy',
file_path VARCHAR(500),
original_name VARCHAR(255) NOT NULL,
size_bytes BIGINT NOT NULL,
mime_type VARCHAR(100) NOT NULL,
recorded_at TIMESTAMPTZ,
transcript TEXT,
recording_expires_at TIMESTAMPTZ NOT NULL,
recording_deleted_at TIMESTAMPTZ,
transcript_expires_at TIMESTAMPTZ NOT NULL,
transcript_deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_obzvon_materialy_size CHECK (size_bytes > 0),
CONSTRAINT chk_obzvon_materialy_zvuk_styort
CHECK (recording_deleted_at IS NULL OR file_path IS NULL),
CONSTRAINT chk_obzvon_materialy_tekst_styort
CHECK (transcript_deleted_at IS NULL OR transcript IS NULL)
);
-- Один файл — одна строка. Две строки на один файл означали бы, что чистка по
-- сроку одной строки вслепую стирает звук у второй.
CREATE INDEX obzvon_materialy_klienta_tenant_idx
ON obzvon_materialy_klienta (tenant_id, created_at DESC);
CREATE UNIQUE INDEX obzvon_materialy_klienta_file_uniq
ON obzvon_materialy_klienta (disk, file_path)
WHERE file_path IS NOT NULL;
-- Указатели под чистку по срокам: команда З-2.2 ищет просроченные, а не
-- перебирает всё подряд.
CREATE INDEX obzvon_materialy_klienta_zvuk_cleanup_idx
ON obzvon_materialy_klienta (recording_expires_at)
WHERE file_path IS NOT NULL AND recording_deleted_at IS NULL;
CREATE INDEX obzvon_materialy_klienta_tekst_cleanup_idx
ON obzvon_materialy_klienta (transcript_expires_at)
WHERE transcript_deleted_at IS NULL;
-- 🔴 RLS: ENABLE + FORCE + USING и WITH CHECK. Без WITH CHECK клиент А смог бы
-- подложить свою запись клиенту Б. Это Т6: клиент А не видит и не может открыть
-- материалы клиента Б.
-- 🪤 Служебным ролям этого МАЛО: после накатки обязателен перезапуск
-- db/03_service_bypass_policies.sql, иначе служебная роль молча правит НОЛЬ
-- строк — и чистка из З-2.2 отчитается «стёрто», ничего не стерев.
ALTER TABLE obzvon_materialy_klienta ENABLE ROW LEVEL SECURITY;
ALTER TABLE obzvon_materialy_klienta FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON obzvon_materialy_klienta
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint);
-- Права: SELECT + INSERT + UPDATE. DELETE не выдан НИКОМУ — строка остаётся
-- носителем надписи Т93, чистка только обнуляет столбцы.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT SELECT, INSERT, UPDATE ON obzvon_materialy_klienta TO crm_app_user;
GRANT USAGE, SELECT ON SEQUENCE obzvon_materialy_klienta_id_seq TO crm_app_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT, INSERT, UPDATE ON obzvon_materialy_klienta TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE obzvon_materialy_klienta_id_seq TO crm_supplier_worker;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT, INSERT, UPDATE ON obzvon_materialy_klienta TO crm_admin_user;
GRANT USAGE, SELECT ON SEQUENCE obzvon_materialy_klienta_id_seq TO crm_admin_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
GRANT SELECT, INSERT, UPDATE ON obzvon_materialy_klienta TO crm_migrator;
GRANT USAGE, SELECT ON SEQUENCE obzvon_materialy_klienta_id_seq TO crm_migrator;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_readonly') THEN
GRANT SELECT ON obzvon_materialy_klienta TO crm_readonly;
END IF;
END $$;
-- =============================================================================
-- КОНЕЦ schema_modules.sql
-- =============================================================================