Primer commit - Sprint 1

This commit is contained in:
markosbenitez
2026-05-31 04:33:07 -03:00
commit ef9e4bf998
22 changed files with 2515 additions and 0 deletions

View File

@@ -0,0 +1,135 @@
-- ============================================================
-- Migration 001: Create base tables
-- Contract: DATA_MODEL.md — do not add tables without approval (Nivel 3)
-- Dependency order: organizations, consent_versions → surveys → questions
-- → consent_records → responses → answers
-- ============================================================
-- organizations: empresas cliente
CREATE TABLE IF NOT EXISTS public.organizations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
sector text,
size_bucket text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- consent_versions: textos de consentimiento versionados (INMUTABLES — ver migration 004)
CREATE TABLE IF NOT EXISTS public.consent_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
version text NOT NULL UNIQUE,
body text NOT NULL,
scopes jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- surveys: definición de cada estudio
-- Dos ejes de clasificación ortogonales (ADR-001 §2):
-- data_sensitivity → consentimiento / seguridad legal
-- security_class → RLS / cifrado (ORTOGONAL al anterior)
CREATE TABLE IF NOT EXISTS public.surveys (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES public.organizations(id),
title text NOT NULL,
status text NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'live', 'closed')),
-- Eje 1: anonimato — irreversible una vez publicada
is_anonymous boolean NOT NULL,
-- Eje 2a: sensibilidad legal → alimenta consentimiento
data_sensitivity text NOT NULL
CHECK (data_sensitivity IN ('comun', 'sensible', 'anonimizado')),
-- Eje 2b: seguridad → alimenta RLS/cifrado (ORTOGONAL al anterior)
security_class text NOT NULL
CHECK (security_class IN ('publico', 'interno', 'confidencial')),
-- Rol de responsable (Modo A = nosotros; Modo B = empresa. v1 solo usa A)
controller_role text NOT NULL DEFAULT 'A'
CHECK (controller_role IN ('A', 'B')),
consent_version_id uuid REFERENCES public.consent_versions(id),
brand_config jsonb,
-- Umbral de k-anonimato mandatorio (regla no negociable #3 CLAUDE.md)
k_threshold int NOT NULL DEFAULT 5 CHECK (k_threshold > 0),
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
-- questions: preguntas de la encuesta
-- is_sensitive: auto-true para tipos salud/discapacidad (ver trigger en migration 004)
CREATE TABLE IF NOT EXISTS public.questions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
survey_id uuid NOT NULL REFERENCES public.surveys(id) ON DELETE CASCADE,
code text NOT NULL,
type text NOT NULL
CHECK (type IN (
'text_short', 'text_long',
'single_choice', 'multiple_choice',
'rating', 'nps', 'matrix', 'ranking',
'date', 'slider', 'yes_no', 'section'
)),
prompt text NOT NULL,
position int NOT NULL,
is_sensitive boolean NOT NULL DEFAULT false,
max_select int,
options jsonb,
-- Formato: [{ "if_question": "CODE", "op": "eq", "value": "yes", "action": "show|hide|skip", "target": "CODE" }]
conditional_rules jsonb,
required boolean NOT NULL DEFAULT false,
UNIQUE (survey_id, code),
UNIQUE (survey_id, position)
);
-- consent_records: qué consintió cada respuesta (INMUTABLE — ver trigger en migration 004)
-- Sin UPDATE/DELETE — se inserta una vez junto con cada response
CREATE TABLE IF NOT EXISTS public.consent_records (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
consent_version_id uuid NOT NULL REFERENCES public.consent_versions(id),
granted_scopes jsonb NOT NULL,
granted_at timestamptz NOT NULL DEFAULT now()
);
-- responses: una submission
-- respondent_id RESERVADO y NULL en encuestas anónimas (regla no negociable #2 CLAUDE.md)
-- Trigger en migration 004 rechaza respondent_id no nulo cuando is_anonymous=true
-- Cuasi-identificadores en bucket — minimización: rango etario, no fecha exacta
CREATE TABLE IF NOT EXISTS public.responses (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
survey_id uuid NOT NULL REFERENCES public.surveys(id),
company_id uuid NOT NULL REFERENCES public.organizations(id),
respondent_id uuid, -- RESERVADO, NULL en v1 (encuestas anónimas)
ip_hash text, -- SHA-256 + salt, dedup sin identidad
consent_record_id uuid NOT NULL REFERENCES public.consent_records(id),
submitted_at timestamptz NOT NULL DEFAULT now(),
-- Cuasi-identificadores denormalizados para segmentación eficiente
qi_age_bucket text,
qi_sector text,
qi_zone text
);
-- answers: valor de cada pregunta en una submission
-- El texto libre (text_long) NO se expone en agg_segment (regla no negociable #4 CLAUDE.md)
CREATE TABLE IF NOT EXISTS public.answers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
response_id uuid NOT NULL REFERENCES public.responses(id) ON DELETE CASCADE,
question_id uuid NOT NULL REFERENCES public.questions(id),
value jsonb NOT NULL,
UNIQUE (response_id, question_id)
);
-- ============================================================
-- Indexes
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_surveys_org_id ON public.surveys(org_id);
CREATE INDEX IF NOT EXISTS idx_surveys_status ON public.surveys(status);
CREATE INDEX IF NOT EXISTS idx_questions_survey ON public.questions(survey_id);
CREATE INDEX IF NOT EXISTS idx_questions_position ON public.questions(survey_id, position);
-- Índice compuesto para agg_segment: filtro por encuesta y agrupación por QIs
CREATE INDEX IF NOT EXISTS idx_responses_survey ON public.responses(survey_id);
CREATE INDEX IF NOT EXISTS idx_responses_company ON public.responses(company_id);
CREATE INDEX IF NOT EXISTS idx_responses_ip_hash ON public.responses(ip_hash);
CREATE INDEX IF NOT EXISTS idx_responses_qi_seg ON public.responses(survey_id, qi_age_bucket, qi_sector, qi_zone);
CREATE INDEX IF NOT EXISTS idx_answers_response ON public.answers(response_id);
CREATE INDEX IF NOT EXISTS idx_answers_question ON public.answers(question_id);
CREATE INDEX IF NOT EXISTS idx_consent_rec_cv ON public.consent_records(consent_version_id);