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

8
supabase/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local

413
supabase/config.toml Normal file
View File

@@ -0,0 +1,413 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "Motor_de_encuestas"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
# Controls whether new tables, views, sequences and functions created in the `public` schema by
# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`)
# without explicit GRANTs. Leave unset today to preserve local behaviour. The implicit default
# flips to `false` on 2026-05-30 to match the new cloud default, and the field is removed in
# 2026-10-30 once the always-revoked behaviour is permanent. Set to `false` to opt in early.
# auto_expose_new_tables = false
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# Maximum amount of time to wait for health check when starting the local database.
health_timeout = "2m"
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
# Uncomment to reject non-secure connections to the database.
# [db.ssl_enforcement]
# enabled = true
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
[storage.vector]
enabled = false
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
# external_url = ""
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to auth.external_url.
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
# Configure passkey sign-ins.
# [auth.passkey]
# enabled = false
# Configure WebAuthn relying party settings (required when passkey is enabled).
# [auth.webauthn]
# rp_display_name = "Supabase"
# rp_id = "localhost"
# rp_origins = ["http://127.0.0.1:3000"]
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./templates/password_changed_notification.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth callback URL derived from auth.external_url.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
# [experimental.pgdelta]
# When enabled, pg-delta becomes the active engine for supported schema flows.
# enabled = false
# Directory under `supabase/` where declarative files are written.
# declarative_schema_path = "./database"
# JSON string passed through to pg-delta SQL formatting.
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"

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

View File

@@ -0,0 +1,13 @@
-- ============================================================
-- Migration 002: Enable Row Level Security en todas las tablas
-- Intención de RLS: DATA_MODEL.md §"Intención de RLS"
-- Policies concretas: migration 003
-- ============================================================
ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.consent_versions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.surveys ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.questions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.consent_records ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.responses ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.answers ENABLE ROW LEVEL SECURITY;

View File

@@ -0,0 +1,148 @@
-- ============================================================
-- Migration 003: RLS helper functions + policies
--
-- Roles de la plataforma (via JWT custom claim "app_role"):
-- 'org_admin' → empresa cliente: accede solo a agregados de su org, nunca filas crudas
-- 'analyst' → análisis interno (InQ): accede vía service_role que bypasa RLS
-- 'respondent' → rellenador de encuesta: puede insertar, no leer
-- (no claim) → anon: puede leer encuestas live y consentimientos para rellenar el form
--
-- CRÍTICO: ninguna policy devuelve filas crudas de responses/answers a org_admin
-- El acceso de org_admin es SOLO vía la función agg_segment (migration 005)
-- ============================================================
-- ============================================================
-- Funciones helper para leer JWT claims
-- ============================================================
CREATE OR REPLACE FUNCTION public.current_app_role()
RETURNS text
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public
AS $$
SELECT COALESCE(auth.jwt() ->> 'app_role', '')
$$;
CREATE OR REPLACE FUNCTION public.current_org_id()
RETURNS uuid
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public
AS $$
SELECT NULLIF(auth.jwt() ->> 'org_id', '')::uuid
$$;
-- ============================================================
-- organizations
-- org_admin ve solo su propia org; analyst usa service_role
-- ============================================================
CREATE POLICY "org_select_own"
ON public.organizations FOR SELECT
TO authenticated
USING (
(public.current_app_role() = 'org_admin' AND id = public.current_org_id())
OR public.current_app_role() = 'analyst'
);
-- ============================================================
-- consent_versions
-- Cualquiera (incluido anon) puede leer el texto de consentimiento
-- para mostrarlo al respondente antes de que envíe.
-- INSERT: solo via service_role (sin policy de INSERT para auth/anon)
-- UPDATE/DELETE: bloqueados por trigger en migration 004
-- ============================================================
CREATE POLICY "consent_versions_select_all"
ON public.consent_versions FOR SELECT
USING (true);
-- ============================================================
-- surveys
-- anon: solo encuestas live (para rellenar el formulario)
-- org_admin: todas sus encuestas en cualquier estado
-- analyst: service_role (bypasa RLS)
-- ============================================================
CREATE POLICY "surveys_select_live_anon"
ON public.surveys FOR SELECT
TO anon
USING (status = 'live');
CREATE POLICY "surveys_select_auth"
ON public.surveys FOR SELECT
TO authenticated
USING (
public.current_app_role() = 'analyst'
OR (
public.current_app_role() = 'org_admin'
AND org_id = public.current_org_id()
)
);
-- ============================================================
-- questions
-- anon: preguntas de encuestas live (necesario para mostrar el form)
-- org_admin: preguntas de sus encuestas
-- ============================================================
CREATE POLICY "questions_select_live"
ON public.questions FOR SELECT
TO anon
USING (
EXISTS (
SELECT 1 FROM public.surveys s
WHERE s.id = survey_id AND s.status = 'live'
)
);
CREATE POLICY "questions_select_auth"
ON public.questions FOR SELECT
TO authenticated
USING (
public.current_app_role() = 'analyst'
OR (
public.current_app_role() = 'org_admin'
AND EXISTS (
SELECT 1 FROM public.surveys s
WHERE s.id = survey_id AND s.org_id = public.current_org_id()
)
)
);
-- ============================================================
-- consent_records
-- anon/authenticated pueden INSERT al enviar el formulario
-- SELECT: solo service_role (analyst interno)
-- UPDATE/DELETE: bloqueados por trigger en migration 004
-- ============================================================
CREATE POLICY "consent_records_insert"
ON public.consent_records FOR INSERT
TO anon, authenticated
WITH CHECK (true);
-- ============================================================
-- responses
-- CRÍTICO: NO hay policy SELECT para org_admin ni anon.
-- Resultado: org_admin recibe 0 filas en SELECT directo → solo puede
-- acceder mediante agg_segment, que aplica k_threshold.
-- INSERT: permitido a anon/authenticated solo para encuestas live.
-- ============================================================
CREATE POLICY "responses_insert"
ON public.responses FOR INSERT
TO anon, authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM public.surveys s
WHERE s.id = survey_id AND s.status = 'live'
)
);
-- ============================================================
-- answers
-- Mismo patrón que responses: INSERT permitido, 0 SELECT para org_admin.
-- El texto libre (type='text_long') no se expone en agg_segment (regla #4).
-- ============================================================
CREATE POLICY "answers_insert"
ON public.answers FOR INSERT
TO anon, authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM public.responses r
WHERE r.id = response_id
)
);

View File

@@ -0,0 +1,87 @@
-- ============================================================
-- Migration 004: Constraints y triggers de privacidad
--
-- 1. Trigger: rechazar respondent_id no nulo en encuestas anónimas
-- → Regla no negociable #2 de CLAUDE.md (barrera técnica)
--
-- 2. Trigger: inmutabilidad de consent_records
-- → ADR-003: sin UPDATE/DELETE, evidencia permanente del consentimiento
--
-- 3. Trigger: inmutabilidad de consent_versions
-- → Textos de consentimiento históricos son inmutables
-- ============================================================
-- ============================================================
-- 1. Trigger: respondent_id prohibido en encuestas anónimas
-- ============================================================
CREATE OR REPLACE FUNCTION public.enforce_anonymous_no_respondent_id()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
DECLARE
v_is_anonymous boolean;
BEGIN
-- Leer la propiedad de la encuesta referida
SELECT is_anonymous INTO v_is_anonymous
FROM public.surveys
WHERE id = NEW.survey_id;
IF v_is_anonymous AND NEW.respondent_id IS NOT NULL THEN
RAISE EXCEPTION
'Privacy violation: respondent_id must be NULL in anonymous surveys (survey_id: %). '
'See CLAUDE.md rule #2.',
NEW.survey_id
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_responses_anonymous_no_respondent_id
BEFORE INSERT OR UPDATE ON public.responses
FOR EACH ROW EXECUTE FUNCTION public.enforce_anonymous_no_respondent_id();
-- ============================================================
-- 2. Trigger: inmutabilidad de consent_records (ADR-003)
-- El consentimiento es evidencia, no UI. Nunca se modifica.
-- ============================================================
CREATE OR REPLACE FUNCTION public.deny_consent_records_mutation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION
'consent_records is immutable: % is not permitted. '
'Each response consent is permanent evidence (ADR-003).',
TG_OP
USING ERRCODE = 'insufficient_privilege';
END;
$$;
CREATE TRIGGER trg_consent_records_immutable
BEFORE UPDATE OR DELETE ON public.consent_records
FOR EACH ROW EXECUTE FUNCTION public.deny_consent_records_mutation();
-- ============================================================
-- 3. Trigger: inmutabilidad de consent_versions
-- Los textos históricos no se alteran. Cambiar el wording del
-- consentimiento crea una nueva versión, nunca edita la existente.
-- ============================================================
CREATE OR REPLACE FUNCTION public.deny_consent_versions_mutation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION
'consent_versions is immutable: % is not permitted. '
'Create a new version instead of modifying an existing one.',
TG_OP
USING ERRCODE = 'insufficient_privilege';
END;
$$;
CREATE TRIGGER trg_consent_versions_immutable
BEFORE UPDATE OR DELETE ON public.consent_versions
FOR EACH ROW EXECUTE FUNCTION public.deny_consent_versions_mutation();

View File

@@ -0,0 +1,124 @@
-- ============================================================
-- Migration 005: Función agg_segment — agregación con k-anonimato
--
-- DISEÑO DE SEGURIDAD:
-- SECURITY DEFINER → puede leer responses directamente (bypasa RLS).
-- Pero aplica tres capas de protección propias:
-- a) Valida que el caller (org_admin) sea dueño de la encuesta.
-- b) Whitelists las dimensiones permitidas (previene SQL injection).
-- c) Suprime toda celda con count < k_threshold → devuelve n=NULL,
-- suppressed=TRUE en lugar del valor real.
--
-- El texto libre (type='text_long') NUNCA aparece en el output:
-- la función agrega solo sobre cuasi-identificadores de responses,
-- no sobre el contenido de answers (regla no negociable #4 CLAUDE.md).
--
-- SIGNATURE:
-- agg_segment(p_survey_id uuid, p_dimensions text[])
-- RETURNS TABLE(segment jsonb, n bigint, suppressed boolean)
--
-- p_dimensions: subconjunto de ['qi_age_bucket', 'qi_sector', 'qi_zone']
-- segment: objeto JSONB con los valores de las dimensiones pedidas
-- n: count del segmento, o NULL si suppressed
-- suppressed: TRUE si count < k_threshold de la encuesta
-- ============================================================
CREATE OR REPLACE FUNCTION public.agg_segment(
p_survey_id uuid,
p_dimensions text[]
)
RETURNS TABLE (
segment jsonb,
n bigint,
suppressed boolean
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_k_threshold int;
v_valid_dims CONSTANT text[] := ARRAY['qi_age_bucket', 'qi_sector', 'qi_zone'];
v_dim text;
v_select_cols text[] := ARRAY[]::text[];
v_json_kv text[] := ARRAY[]::text[];
v_group_cols text;
v_inner_sql text;
v_outer_sql text;
BEGIN
-- 1. Verificar que la encuesta existe y obtener su umbral de k-anonimato
SELECT k_threshold INTO v_k_threshold
FROM public.surveys
WHERE id = p_survey_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Survey not found: %', p_survey_id;
END IF;
-- 2. Verificar autorización del caller
-- Si es org_admin, la encuesta debe pertenecer a su organización.
-- analyst usa service_role (bypasa RLS y esta función), no llega acá con app_role='analyst'.
IF public.current_app_role() = 'org_admin' THEN
IF NOT EXISTS (
SELECT 1 FROM public.surveys
WHERE id = p_survey_id AND org_id = public.current_org_id()
) THEN
RAISE EXCEPTION
'Access denied: survey % does not belong to caller organization',
p_survey_id;
END IF;
END IF;
-- 3. Validar que se pidió al menos una dimensión
IF p_dimensions IS NULL OR array_length(p_dimensions, 1) IS NULL THEN
RAISE EXCEPTION 'At least one dimension is required';
END IF;
-- 4. Validar dimensiones contra whitelist (prevención de SQL injection)
FOREACH v_dim IN ARRAY p_dimensions LOOP
IF NOT (v_dim = ANY(v_valid_dims)) THEN
RAISE EXCEPTION
'Invalid dimension: "%" — allowed: %',
v_dim,
array_to_string(v_valid_dims, ', ');
END IF;
v_select_cols := v_select_cols || quote_ident(v_dim);
v_json_kv := v_json_kv || (quote_literal(v_dim) || ', ' || quote_ident(v_dim));
END LOOP;
v_group_cols := array_to_string(v_select_cols, ', ');
-- 5. SQL interno: agrupar responses por las dimensiones pedidas
v_inner_sql := format(
'SELECT
jsonb_build_object(%s) AS segment,
count(*) AS raw_n
FROM public.responses
WHERE survey_id = %L
GROUP BY %s',
array_to_string(v_json_kv, ', '),
p_survey_id,
v_group_cols
);
-- 6. SQL externo: aplicar umbral de k-anonimato
-- n = NULL y suppressed = TRUE cuando raw_n < k_threshold
-- n = raw_n y suppressed = FALSE cuando raw_n >= k_threshold
v_outer_sql := format(
'SELECT
segment,
CASE WHEN raw_n >= %s THEN raw_n ELSE NULL END AS n,
(raw_n < %s) AS suppressed
FROM (%s) sub
ORDER BY suppressed DESC, segment',
v_k_threshold,
v_k_threshold,
v_inner_sql
);
RETURN QUERY EXECUTE v_outer_sql;
END;
$$;
-- org_admin puede llamar a esta función (aplica su propia autorización interna)
GRANT EXECUTE ON FUNCTION public.agg_segment(uuid, text[]) TO authenticated;

View File

@@ -0,0 +1,78 @@
-- ============================================================
-- Migration 006: GRANTs explícitos de tabla
--
-- CONTEXTO: A partir de 2026-05-30, Supabase cambia el default de
-- auto_expose_new_tables a false. Sin GRANTs explícitos, los roles
-- anon/authenticated no pueden acceder a las tablas aunque haya
-- políticas RLS. Las RLS policies son la capa de restricción por-fila;
-- los GRANTs son la capa de acceso al objeto. Ambas son necesarias.
--
-- Diseño de capas para responses/answers:
-- Capa 1 (objeto): authenticated tiene SELECT → el role puede hacer queries
-- Capa 2 (RLS): sin policy SELECT para org_admin → resultado = 0 filas
-- Capa 3 (datos): para obtener datos reales, org_admin llama agg_segment,
-- que aplica k_threshold internamente (SECURITY DEFINER)
--
-- Esta layering cumple la intención del DATA_MODEL: "0 filas crudas",
-- no "permission denied". El resultado es indistinguible para el caller
-- (tabla vacía), pero la arquitectura es auditable y testeable con RLS.
--
-- Principio mínimo de privilegios:
-- anon → solo lo necesario para responder una encuesta
-- authenticated → lo que necesita org_admin + respondentes autenticados
-- service_role → bypasa RLS, no necesita GRANTs adicionales aquí
-- ============================================================
-- Acceso al schema public
GRANT USAGE ON SCHEMA public TO anon, authenticated;
-- ============================================================
-- organizations
-- authenticated (org_admin) lee solo su org (filtrado por RLS)
-- ============================================================
GRANT SELECT ON public.organizations TO authenticated;
-- ============================================================
-- consent_versions
-- anon lee el texto de consentimiento para mostrarlo en el form
-- ============================================================
GRANT SELECT ON public.consent_versions TO anon, authenticated;
-- ============================================================
-- surveys
-- anon: encuestas live (para renderizar el form)
-- authenticated: sus encuestas en cualquier estado
-- ============================================================
GRANT SELECT ON public.surveys TO anon, authenticated;
-- ============================================================
-- questions
-- anon: preguntas de encuestas live
-- authenticated: preguntas de sus encuestas
-- ============================================================
GRANT SELECT ON public.questions TO anon, authenticated;
-- ============================================================
-- consent_records
-- INSERT: al enviar el form
-- SELECT: authenticated puede consultar (RLS filtra; analyst usa service_role)
-- ============================================================
GRANT INSERT ON public.consent_records TO anon, authenticated;
GRANT SELECT ON public.consent_records TO authenticated;
-- ============================================================
-- responses
-- INSERT: al enviar el form
-- SELECT: authenticated puede hacer el query (RLS devuelve 0 filas para org_admin)
-- El acceso con datos reales es solo via agg_segment (SECURITY DEFINER)
-- ============================================================
GRANT INSERT ON public.responses TO anon, authenticated;
GRANT SELECT ON public.responses TO authenticated;
-- ============================================================
-- answers
-- INSERT: al enviar el form
-- SELECT: authenticated puede hacer el query (RLS devuelve 0 filas para org_admin)
-- ============================================================
GRANT INSERT ON public.answers TO anon, authenticated;
GRANT SELECT ON public.answers TO authenticated;

307
supabase/seed.sql Normal file
View File

@@ -0,0 +1,307 @@
-- ============================================================
-- Seed: Etapa 1 — Encuesta de Mapeo de Cuidadores
--
-- Carga:
-- 1. 1 organización de prueba
-- 2. 1 consent_version v1 (scopes: operativo + macro_n1)
-- 3. 1 encuesta "Mapeo de Cuidadores" (anónima, sensible, confidencial, Modo A)
-- 4. 9 preguntas con códigos del mockup:
-- B1 (yes_no), B2, B4, B5 — situación de cuidado (B5 is_sensitive=true)
-- C1 (yes_no) — impacto laboral
-- D1 (yes_no) — apoyo recibido
-- G4 (single_choice) — frecuencia de estrés [condicional a C1=yes]
-- H1 (multiple_choice, max_select=3) — apoyo preferido
-- H3 (text_long) — comentarios libres
-- 5. conditional_rules: B1 → show B2/B4/B5; C1 → show G4
--
-- UUIDs fijos para reproducibilidad y referencia desde tests.
-- Nota: el mockup original no estaba disponible; los textos de pregunta
-- son diseñados según el contexto "mapeo de cuidadores". Ver ETAPA_1_PROMPT.md.
-- ============================================================
-- UUIDs de referencia:
-- org: 10000000-0000-0000-0000-000000000001
-- consent_v1: 20000000-0000-0000-0000-000000000001
-- survey: 30000000-0000-0000-0000-000000000001
-- q_B1..q_H3: 4000...0001 al 4000...0009
-- ============================================================
-- 1. Organización de prueba
-- ============================================================
INSERT INTO public.organizations (id, name, sector, size_bucket)
VALUES (
'10000000-0000-0000-0000-000000000001',
'Empresa Demo S.A.',
'Servicios',
'100-500'
)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 2. Versión de consentimiento v1
-- Scopes ofrecidos: operativo (dashboard empresa) + macro_n1 (estudio país anonimizado)
-- NO incluye macro_n3 (atribuible) — decisión de v1 (ADR-001)
-- ============================================================
INSERT INTO public.consent_versions (id, version, body, scopes)
VALUES (
'20000000-0000-0000-0000-000000000001',
'v1',
'Esta encuesta es completamente anónima: no registramos tu nombre, número de documento '
'ni ningún dato que permita identificarte personalmente. '
'Tus respuestas se usarán con dos propósitos: '
'(1) Ayudar a tu empresa a comprender la situación de cuidado de sus colaboradores y '
'diseñar apoyos más adecuados (uso operativo, solo agregados). '
'(2) Contribuir a un estudio de alcance nacional sobre la "economía del cuidado" en Paraguay, '
'utilizando únicamente datos anonimizados e irreversiblemente desvinculados de cualquier empresa '
'(Nivel 1 anonimizado). '
'Una vez enviada tu respuesta, al ser anónima, no es posible retirarla ni modificarla. '
'Podés elegir qué propósitos autorizás marcando las casillas a continuación.',
'["operativo", "macro_n1"]'::jsonb
)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 3. Encuesta: Mapeo de Cuidadores
-- Anónima + Sensible (contiene datos de salud/discapacidad) + Confidencial
-- Modo A: nosotros como responsables del tratamiento
-- k_threshold=5: ninguna celda expuesta con n<5
-- ============================================================
INSERT INTO public.surveys (
id, org_id, title, status,
is_anonymous, data_sensitivity, security_class, controller_role,
consent_version_id, k_threshold
)
VALUES (
'30000000-0000-0000-0000-000000000001',
'10000000-0000-0000-0000-000000000001',
'Mapeo de Cuidadores — Edición 2026',
'draft',
true, -- anónima (regla no negociable #2: respondent_id siempre NULL)
'sensible', -- contiene preguntas de salud/discapacidad (B5)
'confidencial', -- acceso restringido a la empresa y analistas internos
'A', -- Modo A: InQ es responsable del tratamiento (v1 solo Modo A)
'20000000-0000-0000-0000-000000000001',
5 -- umbral k-anonimato por defecto
)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 4. Preguntas de la encuesta
-- Orden: position define el flujo de presentación
-- conditional_rules en el formato: [{ if_question, op, value, action, target }]
-- if_question = code de la pregunta trigger
-- target = code de la pregunta afectada
-- ============================================================
-- B1: Pregunta disparadora — ¿Tenés persona a cargo?
-- Si es "yes" → se despliegan B2, B4, B5
INSERT INTO public.questions (
id, survey_id, code, type, prompt, position,
is_sensitive, required, conditional_rules
)
VALUES (
'40000000-0000-0000-0000-000000000001',
'30000000-0000-0000-0000-000000000001',
'B1',
'yes_no',
'¿Tenés a cargo a una o más personas con discapacidad permanente o adultos mayores '
'(60 años o más) que requieran cuidados especiales de forma regular?',
1,
false,
true,
'[
{"if_question": "B1", "op": "eq", "value": "yes", "action": "show", "target": "B2"},
{"if_question": "B1", "op": "eq", "value": "yes", "action": "show", "target": "B4"},
{"if_question": "B1", "op": "eq", "value": "yes", "action": "show", "target": "B5"}
]'::jsonb
)
ON CONFLICT (id) DO NOTHING;
-- B2: ¿Cuántas personas a cargo? (condicional a B1=yes)
INSERT INTO public.questions (
id, survey_id, code, type, prompt, position,
is_sensitive, required, options
)
VALUES (
'40000000-0000-0000-0000-000000000002',
'30000000-0000-0000-0000-000000000001',
'B2',
'single_choice',
'¿Cuántas personas a tu cargo requieren cuidado especial?',
2,
false,
false,
'[
{"value": "1", "label": "1 persona"},
{"value": "2", "label": "2 personas"},
{"value": "3+", "label": "3 o más personas"}
]'::jsonb
)
ON CONFLICT (id) DO NOTHING;
-- B4: Relación con la persona a cargo (condicional a B1=yes)
INSERT INTO public.questions (
id, survey_id, code, type, prompt, position,
is_sensitive, required, options
)
VALUES (
'40000000-0000-0000-0000-000000000003',
'30000000-0000-0000-0000-000000000001',
'B4',
'single_choice',
'¿Cuál es tu relación con la principal persona que cuidás?',
3,
false,
false,
'[
{"value": "hijo_a", "label": "Hijo/a"},
{"value": "padre_madre", "label": "Padre, madre o suegro/a"},
{"value": "conyuge", "label": "Cónyuge o pareja"},
{"value": "hermano_a", "label": "Hermano/a"},
{"value": "otro_familiar", "label": "Otro familiar"},
{"value": "otra", "label": "Otra relación"}
]'::jsonb
)
ON CONFLICT (id) DO NOTHING;
-- B5: Tipo de discapacidad/condición (condicional a B1=yes)
-- is_sensitive=true: contiene datos de salud (categoría especial Ley 7593/2025)
INSERT INTO public.questions (
id, survey_id, code, type, prompt, position,
is_sensitive, required, options
)
VALUES (
'40000000-0000-0000-0000-000000000004',
'30000000-0000-0000-0000-000000000001',
'B5',
'multiple_choice',
'¿Qué tipo de discapacidad o condición tiene la persona que cuidás? '
'(Podés marcar más de una opción)',
4,
true, -- SENSIBLE: dato de salud/discapacidad (Ley 7593, categoría especial)
false,
'[
{"value": "fisica", "label": "Física o motriz"},
{"value": "visual", "label": "Visual"},
{"value": "auditiva", "label": "Auditiva"},
{"value": "mental", "label": "Mental o psicosocial"},
{"value": "intelectual", "label": "Intelectual o cognitiva"},
{"value": "am_sin_cert", "label": "Adulto mayor sin discapacidad certificada"},
{"value": "otra", "label": "Otra condición"}
]'::jsonb
)
ON CONFLICT (id) DO NOTHING;
-- C1: Impacto laboral
-- Si es "yes" → se despliega G4
INSERT INTO public.questions (
id, survey_id, code, type, prompt, position,
is_sensitive, required, conditional_rules
)
VALUES (
'40000000-0000-0000-0000-000000000005',
'30000000-0000-0000-0000-000000000001',
'C1',
'yes_no',
'¿Sentís que tu rol de cuidador/a afecta tu asistencia, puntualidad o rendimiento '
'en el trabajo?',
5,
false,
true,
'[
{"if_question": "C1", "op": "eq", "value": "yes", "action": "show", "target": "G4"}
]'::jsonb
)
ON CONFLICT (id) DO NOTHING;
-- D1: Apoyo recibido de la empresa
-- Declarada como trigger de sub-preguntas; sub-preguntas se agregan en iteración posterior
INSERT INTO public.questions (
id, survey_id, code, type, prompt, position,
is_sensitive, required, conditional_rules
)
VALUES (
'40000000-0000-0000-0000-000000000006',
'30000000-0000-0000-0000-000000000001',
'D1',
'yes_no',
'¿Tu empresa te ofrece actualmente algún apoyo o beneficio pensado para personas que '
'cuidan a familiares con discapacidad o adultos mayores?',
6,
false,
true,
'[]'::jsonb -- sub-preguntas a agregar en iteración posterior (mockup pendiente)
)
ON CONFLICT (id) DO NOTHING;
-- G4: Frecuencia de estrés (condicional a C1=yes)
INSERT INTO public.questions (
id, survey_id, code, type, prompt, position,
is_sensitive, required, options
)
VALUES (
'40000000-0000-0000-0000-000000000007',
'30000000-0000-0000-0000-000000000001',
'G4',
'single_choice',
'¿Con qué frecuencia la situación de cuidado te genera preocupación o estrés '
'durante tu jornada laboral?',
7,
false,
false,
'[
{"value": "nunca", "label": "Nunca"},
{"value": "raramente", "label": "Raramente (1-2 veces por mes)"},
{"value": "a_veces", "label": "A veces (1-2 veces por semana)"},
{"value": "frecuentemente","label": "Frecuentemente (casi todos los días)"},
{"value": "siempre", "label": "Siempre (todos los días)"}
]'::jsonb
)
ON CONFLICT (id) DO NOTHING;
-- H1: Apoyo preferido (max_select=3)
INSERT INTO public.questions (
id, survey_id, code, type, prompt, position,
is_sensitive, required, max_select, options
)
VALUES (
'40000000-0000-0000-0000-000000000008',
'30000000-0000-0000-0000-000000000001',
'H1',
'multiple_choice',
'¿Qué tipos de apoyo de parte de la empresa valorarías más? '
'Elegí hasta 3 opciones.',
8,
false,
false,
3, -- max_select=3 (mockup H1)
'[
{"value": "horario_flex", "label": "Horarios flexibles o trabajo híbrido"},
{"value": "teletrabajo", "label": "Teletrabajo permanente"},
{"value": "licencias", "label": "Licencias especiales para situaciones de cuidado"},
{"value": "apoyo_eco", "label": "Apoyo económico o subsidio"},
{"value": "orientacion", "label": "Orientación o asesoramiento profesional"},
{"value": "grupos_apoyo", "label": "Grupos de apoyo entre pares"},
{"value": "recursos_ext", "label": "Conexión con servicios o recursos externos"},
{"value": "otro", "label": "Otro (especificar en comentarios)"}
]'::jsonb
)
ON CONFLICT (id) DO NOTHING;
-- H3: Texto libre — NO expuesto en el store analítico crudo (regla #4 CLAUDE.md)
INSERT INTO public.questions (
id, survey_id, code, type, prompt, position,
is_sensitive, required
)
VALUES (
'40000000-0000-0000-0000-000000000009',
'30000000-0000-0000-0000-000000000001',
'H3',
'text_long',
'Si querés compartir algo más sobre tu situación o tenés sugerencias para la empresa, '
'podés hacerlo acá. (Opcional — tu respuesta es anónima)',
9,
false,
false
)
ON CONFLICT (id) DO NOTHING;

View File

@@ -0,0 +1,309 @@
-- ============================================================
-- Tests pgTAP: Etapa 1 — Esquema, RLS, Constraints, k-anonimato
--
-- Ejecutar con: supabase test db
-- Requiere: Docker + supabase start + extensión pgtap
--
-- Cubre las 6 validaciones mandatorias del ETAPA_1_PROMPT.md:
-- 1. Migración desde cero sin errores (verificado al correr supabase db reset)
-- 2. Esquema coincide con DATA_MODEL.md
-- 3. RLS: org_admin no ve filas crudas de responses ni answers
-- 4. Constraint: respondent_id en encuesta anónima → error
-- 5. k-anonimato: agg_segment n=3 → suppressed; n=5 → valor real
-- 6. Inmutabilidad: UPDATE/DELETE en consent_records → error
-- ============================================================
BEGIN;
SELECT plan(25);
-- ============================================================
-- SECCIÓN 1: Estructura del esquema (7 tablas + columnas clave)
-- ============================================================
SELECT has_table('public', 'organizations', '1.1 tabla organizations existe');
SELECT has_table('public', 'consent_versions', '1.2 tabla consent_versions existe');
SELECT has_table('public', 'surveys', '1.3 tabla surveys existe');
SELECT has_table('public', 'questions', '1.4 tabla questions existe');
SELECT has_table('public', 'consent_records', '1.5 tabla consent_records existe');
SELECT has_table('public', 'responses', '1.6 tabla responses existe');
SELECT has_table('public', 'answers', '1.7 tabla answers existe');
-- respondent_id debe existir y ser nullable (reservado pero NULL en v1)
SELECT has_column('public', 'responses', 'respondent_id',
'1.8 responses.respondent_id existe (reservado)');
SELECT col_is_null('public', 'responses', 'respondent_id',
'1.9 responses.respondent_id es nullable');
-- función agg_segment debe existir
SELECT has_function('public', 'agg_segment', ARRAY['uuid', 'text[]'],
'1.10 función agg_segment(uuid, text[]) existe');
-- ============================================================
-- SECCIÓN 2: Constraint — respondent_id en encuesta anónima
-- (Regla no negociable #2 de CLAUDE.md)
-- ============================================================
DO $$
DECLARE
v_org_id uuid := '10000000-ffff-0000-0000-000000000001';
v_cv_id uuid := '20000000-ffff-0000-0000-000000000001';
v_survey_id uuid := '30000000-ffff-0000-0000-000000000001';
v_cr_id uuid := '50000000-ffff-0000-0000-000000000001';
BEGIN
INSERT INTO public.organizations (id, name) VALUES (v_org_id, '_test_constraint_org');
INSERT INTO public.consent_versions (id, version, body, scopes)
VALUES (v_cv_id, '_test-cv-constraint', 'body', '["operativo"]'::jsonb);
INSERT INTO public.surveys (id, org_id, title, is_anonymous, data_sensitivity, security_class)
VALUES (v_survey_id, v_org_id, '_test_anon_survey', true, 'sensible', 'confidencial');
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb);
END;
$$;
SELECT throws_ok(
format(
'INSERT INTO public.responses (survey_id, company_id, respondent_id, consent_record_id)
VALUES (%L, %L, gen_random_uuid(), %L)',
'30000000-ffff-0000-0000-000000000001',
'10000000-ffff-0000-0000-000000000001',
'50000000-ffff-0000-0000-000000000001'
),
'23514', -- check_violation SQLSTATE
NULL,
'2.1 respondent_id en encuesta anónima → check_violation'
);
-- Una response SIN respondent_id en encuesta anónima debe poder insertarse
SELECT lives_ok(
format(
'INSERT INTO public.responses (survey_id, company_id, consent_record_id, qi_age_bucket)
VALUES (%L, %L, %L, %L)',
'30000000-ffff-0000-0000-000000000001',
'10000000-ffff-0000-0000-000000000001',
'50000000-ffff-0000-0000-000000000001',
'25-34'
),
'2.2 response sin respondent_id en encuesta anónima → permitido'
);
-- ============================================================
-- SECCIÓN 3: Inmutabilidad de consent_records
-- (ADR-003: evidencia permanente del consentimiento)
-- ============================================================
DO $$
DECLARE
v_cv_id uuid := '20000000-eeee-0000-0000-000000000001';
v_cr_id uuid := '50000000-eeee-0000-0000-000000000001';
BEGIN
INSERT INTO public.consent_versions (id, version, body, scopes)
VALUES (v_cv_id, '_test-cv-immutable', 'body', '["operativo"]'::jsonb);
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb);
END;
$$;
SELECT throws_ok(
format(
'UPDATE public.consent_records SET granted_at = now() WHERE id = %L',
'50000000-eeee-0000-0000-000000000001'
),
'42501', -- insufficient_privilege SQLSTATE
NULL,
'3.1 UPDATE en consent_records → error (inmutabilidad)'
);
SELECT throws_ok(
format(
'DELETE FROM public.consent_records WHERE id = %L',
'50000000-eeee-0000-0000-000000000001'
),
'42501',
NULL,
'3.2 DELETE en consent_records → error (inmutabilidad)'
);
-- ============================================================
-- SECCIÓN 4: k-anonimato — agg_segment
-- k_threshold=5: n=3 debe estar suprimido; n=5 debe mostrar valor
-- ============================================================
DO $$
DECLARE
v_org_id uuid := '10000000-dddd-0000-0000-000000000001';
v_cv_id uuid := '20000000-dddd-0000-0000-000000000001';
v_survey_id uuid := '30000000-dddd-0000-0000-000000000001';
v_cr_id uuid;
i int;
BEGIN
INSERT INTO public.organizations (id, name) VALUES (v_org_id, '_test_kanonimato_org');
INSERT INTO public.consent_versions (id, version, body, scopes)
VALUES (v_cv_id, '_test-cv-kanon', 'body', '["operativo"]'::jsonb);
INSERT INTO public.surveys (
id, org_id, title, is_anonymous, data_sensitivity, security_class, k_threshold
)
VALUES (v_survey_id, v_org_id, '_test_kanonimato_survey', true, 'comun', 'confidencial', 5);
-- Insertar 3 responses en el mismo segmento (25-34 / Salud)
FOR i IN 1..3 LOOP
v_cr_id := gen_random_uuid();
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb);
INSERT INTO public.responses (survey_id, company_id, consent_record_id, qi_age_bucket, qi_sector)
VALUES (v_survey_id, v_org_id, v_cr_id, '25-34', 'Salud');
END LOOP;
END;
$$;
-- n=3 → suppressed debe ser TRUE, n debe ser NULL
SELECT ok(
(SELECT suppressed
FROM public.agg_segment('30000000-dddd-0000-0000-000000000001', ARRAY['qi_age_bucket'])
WHERE (segment->>'qi_age_bucket') = '25-34'),
'4.1 agg_segment n=3 → suppressed=true'
);
SELECT is(
(SELECT n
FROM public.agg_segment('30000000-dddd-0000-0000-000000000001', ARRAY['qi_age_bucket'])
WHERE (segment->>'qi_age_bucket') = '25-34'),
NULL::bigint,
'4.2 agg_segment n=3 → n=NULL (celda suprimida)'
);
-- Agregar 2 responses más → total 5
DO $$
DECLARE
v_cv_id uuid := '20000000-dddd-0000-0000-000000000001';
v_survey_id uuid := '30000000-dddd-0000-0000-000000000001';
v_org_id uuid := '10000000-dddd-0000-0000-000000000001';
v_cr_id uuid;
i int;
BEGIN
FOR i IN 1..2 LOOP
v_cr_id := gen_random_uuid();
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb);
INSERT INTO public.responses (survey_id, company_id, consent_record_id, qi_age_bucket, qi_sector)
VALUES (v_survey_id, v_org_id, v_cr_id, '25-34', 'Salud');
END LOOP;
END;
$$;
-- n=5 → suppressed debe ser FALSE, n debe ser 5
SELECT ok(
NOT (SELECT suppressed
FROM public.agg_segment('30000000-dddd-0000-0000-000000000001', ARRAY['qi_age_bucket'])
WHERE (segment->>'qi_age_bucket') = '25-34'),
'4.3 agg_segment n=5 → suppressed=false'
);
SELECT is(
(SELECT n
FROM public.agg_segment('30000000-dddd-0000-0000-000000000001', ARRAY['qi_age_bucket'])
WHERE (segment->>'qi_age_bucket') = '25-34'),
5::bigint,
'4.4 agg_segment n=5 → n=5 (valor real visible)'
);
-- ============================================================
-- SECCIÓN 5: RLS — org_admin no puede ver filas crudas
-- Se simula el rol 'authenticated' con JWT claim app_role='org_admin'
-- ============================================================
DO $$
DECLARE
v_org_a uuid := '10000000-aaaa-0000-0000-000000000001';
v_cv_id uuid := '20000000-aaaa-0000-0000-000000000001';
v_srv_id uuid := '30000000-aaaa-0000-0000-000000000001';
v_cr_id uuid := gen_random_uuid();
v_resp_count bigint;
v_ans_count bigint;
BEGIN
-- Setup: crear org, encuesta y una response
INSERT INTO public.organizations (id, name) VALUES (v_org_a, '_test_rls_org_a');
INSERT INTO public.consent_versions (id, version, body, scopes)
VALUES (v_cv_id, '_test-cv-rls', 'body', '["operativo"]'::jsonb);
INSERT INTO public.surveys (id, org_id, title, is_anonymous, data_sensitivity, security_class, status)
VALUES (v_srv_id, v_org_a, '_test_rls_survey', true, 'comun', 'confidencial', 'live');
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb);
INSERT INTO public.responses (survey_id, company_id, consent_record_id, qi_age_bucket)
VALUES (v_srv_id, v_org_a, v_cr_id, '35-44');
-- Simular JWT de org_admin de v_org_a
PERFORM set_config('request.jwt.claims',
json_build_object('app_role', 'org_admin', 'org_id', v_org_a::text)::text,
true
);
-- Cambiar al rol authenticated (con RLS activo)
SET LOCAL ROLE authenticated;
SELECT count(*) INTO v_resp_count FROM public.responses;
SELECT count(*) INTO v_ans_count FROM public.answers;
RESET ROLE;
-- Limpiar config
PERFORM set_config('request.jwt.claims', '', true);
IF v_resp_count != 0 THEN
RAISE EXCEPTION
'RLS FAIL: org_admin ve % filas crudas en responses (esperado: 0)',
v_resp_count;
END IF;
IF v_ans_count != 0 THEN
RAISE EXCEPTION
'RLS FAIL: org_admin ve % filas crudas en answers (esperado: 0)',
v_ans_count;
END IF;
END;
$$;
SELECT pass('5.1 RLS: org_admin ve 0 filas crudas en responses');
SELECT pass('5.2 RLS: org_admin ve 0 filas crudas en answers');
-- ============================================================
-- SECCIÓN 6: Verificación del seed
-- ============================================================
SELECT is(
(SELECT count(*)::int FROM public.questions
WHERE survey_id = '30000000-0000-0000-0000-000000000001'),
9,
'6.1 Seed: encuesta tiene 9 preguntas'
);
SELECT is(
(SELECT count(*)::int FROM public.questions
WHERE survey_id = '30000000-0000-0000-0000-000000000001'
AND jsonb_array_length(COALESCE(conditional_rules, '[]'::jsonb)) > 0),
3,
'6.2 Seed: 3 preguntas tienen conditional_rules (B1, C1, D1 declaradas)'
);
-- B1 tiene exactamente 3 reglas condicionales (→ B2, B4, B5)
SELECT is(
(SELECT jsonb_array_length(conditional_rules)
FROM public.questions
WHERE survey_id = '30000000-0000-0000-0000-000000000001' AND code = 'B1'),
3,
'6.3 Seed: B1 tiene 3 reglas condicionales (despliega B2, B4, B5)'
);
-- H1 tiene max_select=3
SELECT is(
(SELECT max_select FROM public.questions
WHERE survey_id = '30000000-0000-0000-0000-000000000001' AND code = 'H1'),
3,
'6.4 Seed: H1 max_select=3'
);
-- B5 es la única pregunta marcada is_sensitive=true
SELECT is(
(SELECT count(*)::int FROM public.questions
WHERE survey_id = '30000000-0000-0000-0000-000000000001' AND is_sensitive = true),
1,
'6.5 Seed: exactamente 1 pregunta is_sensitive=true (B5)'
);
SELECT * FROM finish();
ROLLBACK;