136 lines
7.0 KiB
SQL
136 lines
7.0 KiB
SQL
-- ============================================================
|
|
-- 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);
|