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