gendesign/data/sql/145_insight.sql
Light1YT 6400ebde24
All checks were successful
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m41s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m50s
Deploy / deploy (push) Successful in 1m22s
CI / backend-tests (push) Successful in 6m35s
CI / backend-tests (pull_request) Successful in 6m32s
feat(insights): manual-entry Insight entity CRUD + §19 audit (#948 part A)
Insight = аналитик пишет free-form intel про локацию/участок с пометкой
«непублично» (is_confidential, §7.13/§8.9). CRUD под /api/v1/insights;
created_by из X-Authenticated-User (не из тела — спуфинг автора невозможен).
Зеркало custom_pois: raw-SQL service + Pydantic v2 schemas, sync def handlers
(threadpool, off event loop).

- data/sql/145_insight.sql: table insight (idempotent, geom GENERATED из lon/lat,
  индексы district/cad_num/is_confidential/created_by/GIST); аддитивно расширяет
  CHECK audit_log.action += insight_write (тот же constraint ck_audit_log_action,
  to_regclass-guarded, superset — не ломает #962-аудит; НЕ правит applied 144).
- audit_middleware.classify_path: method-aware insight_write для POST/PUT/DELETE
  /api/v1/insights (GET-чтения не аудируются); обратно совместимо с parcels.
- ACL: backend rbac_guard hard-блокирует только /api/v1/admin/*; pilot отсекается
  frontend RouteGuard + Caddy (как parcels/custom_pois). is_confidential — стораемая
  пометка без per-record backend-гейта (analyst видит, per #962-политике).
- tests: 28 insight (CRUD+filters+required+#261 commit) + 5 audit. 86 зелёных, ruff.

Part B (Location first-class, §8.2) — отдельный follow-up.

Refs #948.
2026-06-08 12:41:39 +05:00

113 lines
7.1 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 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;