-- 145_insight.sql -- #948 Part A (ТЗ §7.13/§8.9): manual-entry "insight" — аналитик пишет свободную -- заметку/intel про локацию или участок, с пометкой «непублично» (is_confidential). -- -- Access control: backend rbac_guard (app/main.py) хард-блокирует ТОЛЬКО -- /api/v1/admin/*. На /api/v1/insights pilot отсекается FRONTEND-овым RouteGuard -- (allowed_paths из /me) + Caddy, НЕ бэкендом; до эндпоинта доходят analyst+admin. -- is_confidential — хранимая маркировка (per-record backend-gate НЕТ; будущий gate -- публичной выдачи), сейчас просто хранимый+фильтруемый флаг (политика #962: analyst -- видит конфиденциальные данные). created_by фиксирует автора (из X-Authenticated-User). -- -- Location ref: пока plain TEXT district + опциональный cad_num (ссылка на участок). -- Part B (#948, §8.2) промоутит district в Location FK — НЕ в этом таске. -- -- Audit (§19, интеграция с #962): WRITE-запросы (POST/PUT/DELETE) аудируются -- middleware app/core/audit_middleware.py с action='insight_write'. Поэтому ниже -- (блок 4) аддитивно расширяем CHECK audit_log.action, чтобы 'insight_write' -- проходил констрейнт. Это idempotent ALTER (DROP IF EXISTS + ADD) — НЕ правит -- 144_audit_log.sql (тот уже применён на проде/смёржен; правка применённой -- миграции не переиграется через _schema_migrations). -- -- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз NN=145). -- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + guarded CHECK. BEGIN; -- ── 1. Таблица insight ──────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS insight ( id BIGSERIAL PRIMARY KEY, created_by TEXT NOT NULL, -- автор, из X-Authenticated-User title TEXT NOT NULL, -- заголовок заметки body TEXT NOT NULL, -- тело заметки (free-form intel) category TEXT, -- competition|permitting|demand|risk|other is_confidential BOOLEAN NOT NULL DEFAULT FALSE, -- «непублично» (§7.13) district TEXT, -- location ref (Part B → Location FK) cad_num TEXT, -- опциональная ссылка на участок lon DOUBLE PRECISION CHECK (lon IS NULL OR lon BETWEEN -180 AND 180), lat DOUBLE PRECISION CHECK (lat IS NULL OR lat BETWEEN -90 AND 90), -- Опциональный map-pin: GENERATED из lon/lat (как user_custom_pois.geom #254). -- NULL если координаты не заданы (обе должны быть заданы вместе). geom GEOMETRY(POINT, 4326) GENERATED ALWAYS AS ( CASE WHEN lon IS NOT NULL AND lat IS NOT NULL THEN ST_SetSRID(ST_MakePoint(lon, lat), 4326) END ) STORED, source TEXT, -- источник intel (опционально) created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); COMMENT ON TABLE insight IS 'Manual-entry аналитические заметки/intel про локацию или участок (#948 Part A, ' 'ТЗ §7.13/§8.9). is_confidential = маркировка «непублично». created_by = автор ' 'из X-Authenticated-User. district — plain TEXT (Part B промоутит в Location FK).'; COMMENT ON COLUMN insight.is_confidential IS '«Непублично» (§7.13): маркировка чувствительной заметки. Сейчас хранимый+' 'фильтруемый флаг; будущий gate публичной выдачи.'; COMMENT ON COLUMN insight.category IS 'Свободная категория (рекоменд.: competition|permitting|demand|risk|other).'; COMMENT ON COLUMN insight.geom IS 'Опциональный map-pin, GENERATED из lon/lat. NULL если координаты не заданы.'; -- ── 2. CHECK на category (guarded — idempotent при ре-apply) ────────────────── -- Мягкий enum: разрешаем рекомендованные значения + NULL. Расширять список — -- через новую миграцию (DROP IF EXISTS + ADD), не правкой этой. DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'ck_insight_category' ) THEN ALTER TABLE insight ADD CONSTRAINT ck_insight_category CHECK ( category IS NULL OR category IN ('competition', 'permitting', 'demand', 'risk', 'other') ); END IF; END $$; -- ── 3. Индексы: фильтры list-эндпоинта ──────────────────────────────────────── CREATE INDEX IF NOT EXISTS idx_insight_district ON insight (district); CREATE INDEX IF NOT EXISTS idx_insight_cad_num ON insight (cad_num); CREATE INDEX IF NOT EXISTS idx_insight_is_confidential ON insight (is_confidential); CREATE INDEX IF NOT EXISTS idx_insight_created_by ON insight (created_by); -- Опциональный geo-lookup (как user_custom_pois_geom_gist #254). CREATE INDEX IF NOT EXISTS idx_insight_geom_gist ON insight USING GIST (geom); -- ── 4. Расширяем CHECK audit_log.action → +'insight_write' (§19, #962) ───────── -- Middleware аудирует WRITE insight'ов как action='insight_write'. 144_audit_log -- разрешал только analyze|forecast|export|other; добавляем 'insight_write'. -- Idempotent: DROP IF EXISTS + ADD пере-создаёт констрейнт с расширенным набором -- (повторный apply этой миграции даст тот же результат). Таблица audit_log создана -- в 144 (она в HEAD/проде до этой миграции) — но guard'имся to_regclass на случай -- среды, где 144 ещё не накатан, чтобы 145 не падал. DO $$ BEGIN IF to_regclass('public.audit_log') IS NOT NULL THEN ALTER TABLE audit_log DROP CONSTRAINT IF EXISTS ck_audit_log_action; ALTER TABLE audit_log ADD CONSTRAINT ck_audit_log_action CHECK (action IN ('analyze', 'forecast', 'export', 'insight_write', 'other')); END IF; END $$; COMMIT;