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
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.
113 lines
7.1 KiB
PL/PgSQL
113 lines
7.1 KiB
PL/PgSQL
-- 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;
|