27 Commits
v3.0.0 ... main

Author SHA1 Message Date
markosbenitez
cf09346f29 feat(auth): recuperación de contraseña self-service en /login
Reusa /auth/confirm sin tocarlo — ya escucha PASSWORD_RECOVERY (lo
armamos para invitaciones, Supabase emite el mismo evento para
recovery). Antes, cualquiera que olvidara su contraseña dependía de
que el director la reseteara a mano por SQL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 13:09:42 -03:00
markosbenitez
9461ee1e2e feat(admin): menú de navegación compartido — dashboard, respuestas, usuarios
AdminNavBar reemplaza los links sueltos y duplicados entre páginas
(cada una solo enlazaba a una de las otras dos). Ahora las tres
pantallas detrás del login (/dashboard, /admin/responses,
/admin/usuarios) comparten la misma barra con las 3 secciones +
cerrar sesión, sin importar por dónde se entre primero.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 12:43:28 -03:00
markosbenitez
3bf92f015c docs: guía de prueba del formulario + lectura del dashboard, seed Fundación
Guía dirigida a dos audiencias: equipo técnico (plan de pruebas del
wizard contra la encuesta sandbox Demo) y Maria Mersan (cómo leer los
7 widgets de Mirada Empresa, k-anonimato, datos sintéticos vs reales).
Incluye también el seed de datos sintéticos de Fundación Solidaridad
ya aplicado en producción (versionado para trazabilidad).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 12:19:05 -03:00
markosbenitez
67042b0941 fix(auth): parsear error_code del fragmento de GoTrue en /auth/confirm
GoTrue redirige con #error=...&error_code=... cuando el token de
invitación/recovery ya fue usado o venció, en vez de emitir
PASSWORD_RECOVERY. Antes esto se descubría recién a los 8s (timeout
genérico); ahora se lee el fragmento al montar y se muestra un mensaje
específico (otp_expired, access_denied, etc.) al instante.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 11:18:37 -03:00
markosbenitez
7efab390da fix(admin): layout 3 columnas en invite form, banner de confirmación visible
Email/Organización/botón en una fila en desktop, apilado en mobile.
El mensaje de éxito/error ahora es un banner (no texto inline) visible
al menos 4s o hasta que el usuario vuelva a tocar el formulario.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:56:27 -03:00
markosbenitez
4611fb2363 feat(admin): onboarding por invitación — /admin/usuarios + /auth/confirm
Platform admin invita usuarios de empresa desde /admin/usuarios.
inviteUserByEmail + insert inmediato en user_roles (org_admin).
Rollback explícito si falla el insert. /auth/confirm maneja el link
de invitación con PASSWORD_RECOVERY event. Middleware no requirió
cambios — /auth/confirm ya queda fuera del matcher de rutas protegidas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 14:54:30 -03:00
markosbenitez
d211382e4d fix(usabilidad): required questions, prompt min-height, autocomplete max-width
Activa required=true en todas las preguntas excepto barrio (2.3) y secciones.
El motor ya valida solo preguntas visibles — las condicionales ocultas no se
validan. Elimina min-height fijo del prompt block para mejor UX mobile.
Agrega max-width de seguridad al dropdown autocomplete en viewports angostos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 09:26:10 -03:00
markosbenitez
e29752644a fix(wizard): tests e2e sprint6 — cache geo, usabilidad, draft
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 09:03:49 -03:00
markosbenitez
704d2407e8 fix(wizard): cache geo singleton, feedback carga, debounce draft
geo_paraguay_full.json se cargaba dos veces sin cache compartido.
Módulo lib/geo-cache.ts singleton: un fetch para toda la sesión.
Feedback visual mientras carga (disabled + placeholder) y si falla
(mensaje + texto libre). debounce 500ms en saveDraft para no escribir
en localStorage en cada keystroke.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 22:04:12 -03:00
markosbenitez
5472043a33 fix(ops): setup_first_admin.sql usa email_confirmed_at (GoTrue v2.189.0)
Esquema real verificado en producción (information_schema.columns):
auth.users tiene tanto confirmed_at como email_confirmed_at — ambas son
columnas reales, así que el INSERT con confirmed_at nunca falló por
columna inexistente (por eso el usuario test-e2e se creó sin error).

Pero el login seguía rechazando con "Email o contraseña incorrectos"
incluso después de confirmar que el usuario existía — GoTrue valida
email_confirmed_at para permitir login por password, no confirmed_at.
Dejar confirmed_at solo creaba un usuario que nunca queda "confirmado"
para efectos de auth.

Agregado comentario documentando la versión de GoTrue verificada y cómo
re-verificar el schema si esto vuelve a cambiar.
2026-06-28 02:04:36 -03:00
markosbenitez
0fdee073a6 en el medio del kilombo de la db 2026-06-28 01:36:45 -03:00
markosbenitez
6a09ce9ee5 fix(seed): consolidar en un único bloque DO $$ (DELETE+UPDATE adentro)
La versión anterior (af751d7) ya resolvía los question_id por code en
runtime, pero DELETE y el primer UPDATE quedaban como statements sueltos
ANTES del bloque DO $$ — si psql corre sin -v ON_ERROR_STOP=1, un fallo
dentro del DO no impide que esos statements sueltos ya se hayan
ejecutado, y deja el "único bloque" pedido incompleto.

Ahora DELETE, los dos UPDATE y los 50 PERFORM viven todos dentro de un
solo DO $$ ... END $$. Si algo falla, aborta el bloque completo (un
solo error, no cincuenta) — nada de hardcodeado salvo el uuid del
survey Demo (necesario para inicializar la variable s_id).

Mismos 50 datos, verificado byte a byte contra la versión anterior
(diff vacío salvo nombres de variable).
2026-06-28 00:43:05 -03:00
markosbenitez
af751d771d fix(seed): resolver question_id por code en runtime, no hardcodeado
Los UUIDs de questions cambian cada vez que la DB se reinicializa
(supabase/seed.sql usa gen_random_uuid()) — el seed con UUIDs literales
se desactualizaba cada vez, ya pasó dos veces. Reescrito como un bloque
DO $$ que resuelve los 13 question_id usados (4.1, 2.7, 6.1, 6.2, 6.4,
7.2, 7.3, 7.5, 7.6, 9.1, 9.2, 9.3, 9.4) por code + survey_id al momento
de ejecutarse, con RAISE EXCEPTION explícito si alguno no se encuentra
(en vez de fallar más abajo con invalid_answer_reference).

Mismas 50 respuestas, misma distribución (seed=42), mismo DELETE
idempotente acotado a ip_hash LIKE 'seed-prueba-%'. Generado a partir
del archivo anterior con un script de conversión (no transcripto a
mano) para no introducir errores de transcripción en 50 filas.
2026-06-28 00:30:57 -03:00
markosbenitez
cd87ddb18e fix(ops): setup_first_admin.sql usa confirmed_at, no email_confirmed_at
GoTrue en esta versión de producción no tiene la columna
email_confirmed_at en auth.users — falla con "column does not exist".
La columna correcta es confirmed_at.
2026-06-28 00:17:22 -03:00
markosbenitez
6d9cf918f9 feat(ops): scripts de recuperación tras reinicialización de DB
scripts/apply_all_migrations.sh: aplica todas las migraciones de
supabase/migrations/ en orden de timestamp, una por una, con
ON_ERROR_STOP=1 (sin esto psql sigue tras un error SQL y devuelve exit
0 igual — set -e nunca lo vería).

scripts/setup_all.sh: orquesta la recuperación completa — migraciones,
catálogo de encuestas (supabase/seed.sql, paso agregado: sin él
surveys/questions quedan vacías y seed_prueba_50.sql no tiene nada que
referenciar), usuario platform_admin vía ADMIN_EMAIL/ADMIN_PASSWORD, y
datos de prueba (docs/dashboard/seed_prueba_50.sql).

No ejecutado — solo los archivos, a la espera de uso manual en el VPS.
2026-06-27 23:51:51 -03:00
markosbenitez
a0db888f2e fix(seed): regenerar seed_prueba_50.sql con UUIDs reales de questions
dashboard-propuesta/seed_prueba_50.sql quedó con question_id de una
instancia anterior de la DB — rpc_submit_response rechazaba todo con
invalid_answer_reference. Mapeo code→question_id reconstruido por
vocabulario de opciones contra supabase/seed.sql (sin ambigüedad,
incluye desambiguar 6.1/6.2/6.4 por sus opciones únicas), UUIDs reales
tomados de la DB de producción. Mismas 50 filas, misma distribución
(seed=42), mismo formato de llamada — solo cambian los 13 question_id.

Idempotencia: DELETE acotado a survey_id + ip_hash LIKE 'seed-prueba-%'
en vez de TRUNCATE de las tablas completas (answers/responses pueden
tener datos reales de otras encuestas). Survey Demo queda en 'live' al
inicio y al final, explícito.

NO aplicado en VPS — solo el archivo, a la espera de aprobación.
2026-06-27 17:22:12 -03:00
markosbenitez
a0253b4f90 feat(admin): vista respuestas mejorada + vista detallada [admin]
/admin/responses: filtro survey dinámico (select, no hardcodeado),
paginación 50/página, link a vista detallada, conteos consistentes con
el filtro de preguntas permitidas (evita página fantasma al final).
/admin/responses/[id]: respuestas agrupadas por sección (derivado de
type='section', igual que el wizard — no existen section_number/
section_title, ver TECH-DEBT-011), instancias bajo "Familiar N".
RLS y exclusión de sensibles intactos en ambas vistas.

Fixes de revisión antes de cerrar: toggle expandir/colapsar movido a
un <button> real (evita anidar el link "Ver" dentro de un role=button),
formatDate/renderValue unificados en admin-format.ts, queries
independientes en paralelo (Promise.all).
2026-06-27 16:48:32 -03:00
markosbenitez
a660928085 fix(ui): título card talento en riesgo a carbón #494F4F 2026-06-27 15:46:48 -03:00
markosbenitez
30cd83ffc1 ETAPA_CALIDAD: Nota de discrepancia: las 5 clases de P1 usan var(--color-primary), no var(--color-re-teal) como decía el prompt — ambas resuelven al mismo hex #43BBC8. Hice el reemplazo por nombre de clase (igual intención: texto teal → carbón), no por el literal del grep. 2026-06-27 15:27:54 -03:00
markosbenitez
ca3af1a750 fix(quality): contraste teal, submit red, bottombar mobile, autocomplete touch
P1: color de texto teal -> #494F4F en 5 clases del wizard (WCAG AA).
P2: try/catch en handleSubmit - error de red muestra estado error del wizard.
P3: flex-wrap + column-reverse en bottombar <=480px.
P4: onTouchEnd fallback en autocomplete barrio y ciudad.
2026-06-27 15:25:16 -03:00
markosbenitez
b3f039fbd5 fix(ui): toggle password en login + fondo blanco card talento
Fix A: botón inline con ícono SVG (ojo/ojo tachado) en el campo
password de /login, alterna type password/text, aria-label según
estado. Sin librería nueva.
Fix B: .db-widget-star pierde el gradiente, hereda background blanco
de .db-widget igual que los demás cards. Borde y título teal intactos.
2026-06-27 15:18:22 -03:00
markosbenitez
430bf7a5da feat(compliance): audit_log append-only — user_roles y surveys.status [SEC-002]
Tabla audit_log con RLS (solo platform_admin), trigger BEFORE UPDATE/DELETE
que aborta (append-only enforcement), triggers AFTER en user_roles y
surveys.status. Patrón SECURITY DEFINER reutilizado de auth_user_roles.
Event trigger DDL incluido (confirmado viable sin extensiones, ver
diagnóstico en ETAPA_AUDIT_LOG_PROMPT.md §4.1). Gap SEC-003 (TRUNCATE por
superusuario) documentado y aceptado, no resuelto.

NO aplicada en VPS — pendiente de aprobación.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 14:51:07 -03:00
markosbenitez
10bb7299a2 docs: actualizar estado — cierre release v3.0.0 + ETAPA_AUTH + incidente VPS
Sprint 4 completo (widgets Mirada Empresa, tema claro, 4F, 4I/release ya
estaban hechos antes de esta sesión). Documenta el incidente de colisión de
container_name en el VPS y su recuperación, y el cierre de TECH-DEBT-008
(auth de staff) en esta sesión. Próximos pasos quedan abiertos a decisión
del director (dashboard País/Humana, sprint de compliance, o mantenimiento
de dependencias).
2026-06-27 13:38:10 -03:00
markosbenitez
dda4b5320a fix(auth): agregar email_change/email_change_token_new en setup_first_admin
GoTrue fallaba con "Database error querying schema" al hacer login porque
estas dos columnas de auth.users no tienen DEFAULT en este esquema y quedaban
NULL — el driver Go espera string, no NULL. Verificado en VPS: login
funciona después de este fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 13:18:32 -03:00
markosbenitez
44d1f4b9d9 fix(auth): corregir setup_first_admin — psql vars no funcionan en DO $$
Reescribir con CTE INSERT...RETURNING para encadenar auth.users + user_roles
en una sola sentencia SQL plana donde :variable sí funciona.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 09:47:38 -03:00
markosbenitez
a484ba7849 feat(auth): Supabase Auth email/password + middleware + login [auth]
Protege /admin/* con Supabase Auth. Tabla user_roles con custom claims
(app_role, org_id) en JWT. Middleware Next.js redirige a /login sin sesión.
Página /login con email/password. /admin/responses migrado de service_role
a sesión de usuario autenticado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 09:39:52 -03:00
markosbenitez
ddaba6866a fix(rpc): filtrar elementos individuales en carrera_congelada [BUG-001]
Mover el filtro 'Ninguna de las anteriores' del WHERE sobre a.value
al WHERE del subquery expandido, donde opt es ya un elemento individual
resultado de jsonb_array_elements_text. El operador #>> '{}' aplicado
sobre un array jsonb no filtraba elementos sino la representación textual
del array completo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:38:17 -03:00
47 changed files with 5876 additions and 212 deletions

5
.gitignore vendored
View File

@@ -14,6 +14,11 @@ supabase/.temp/
# Node
node_modules/
# Playwright — reportes/snapshots, pueden contener credenciales de .env.test en el DOM
test-results/
playwright-report/
blob-report/
# Next.js build output
.next/

View File

@@ -77,14 +77,15 @@
## [TECH-DEBT-008] /admin/responses sin autenticación
- **Estado:** ABIERTO.
- **Descripción:** la vista de control `/admin/responses` no tiene auth. Por diseño
explícito, excluye preguntas `is_sensitive=true` (14 preguntas de salud/
discapacidad) y `text_long`. Para mostrar esas preguntas en la vista, primero
implementar un gate de autenticación para staff (Basic Auth con env var como
mínimo viable).
- **Impacto actual:** ninguno en producción — la URL no está publicada.
- **Bloqueante para:** ampliar visibilidad de datos en la vista de control.
- **Estado:** RESUELTO (27 jun 2026, ETAPA_AUTH).
- **Descripción:** la vista de control `/admin/responses` no tenía auth. Se implementó
login con Supabase Auth (`@supabase/ssr`), `middleware.ts` protegiendo
`/admin/:path*` y `/dashboard/:path*` con redirect a `/login` sin sesión, y la página
migrada de `service_role` a la sesión del usuario (`anon` key + RLS). Sigue
excluyendo preguntas `is_sensitive=true` y `text_long` — ese diseño no cambió.
- **Nota:** la vista no distingue roles dentro de staff todavía (todo usuario con fila
en `user_roles` ve lo mismo). Diferenciar `platform_admin` de `org_admin` ahí sería
una etapa nueva, no parte de este cierre.
---
@@ -110,3 +111,61 @@
- **Trigger:** evaluar si la Mirada País/Humana también necesitan segmentar por
modalidad con frecuencia — si varias RPCs repiten el mismo JOIN, conviene
promoverla de una vez en lugar de cada RPC reimplementando el JOIN.
---
## [TECH-DEBT-011] Sección de pregunta inferida por posición, no modelada como dato
- **Estado:** ABIERTO — decisión consciente, no un descuido. Surgió en
ETAPA_ADMIN (vista detallada `/admin/responses/[id]`, 27 jun 2026): el prompt
de etapa asumía columnas `questions.section_number`/`section_title` que NO
existen. Verificado contra el esquema real (`supabase/migrations/
20260531000001_create_tables.sql` — única definición de `questions`,
`20260620000002_form_schema_v3.sql` solo agrega comentarios, ninguna
migración agrega esas columnas).
- **Descripción:** hoy `questions` no tiene ningún campo que declare "esta
pregunta pertenece a la sección X". La sección es una fila más en la misma
tabla, con `type='section'`, intercalada por `position` entre las preguntas
reales — el código (`lib/form-engine.ts:146-148` en el wizard, y la vista
detallada de admin) infiere el agrupamiento recorriendo las filas en orden
de `position` y "abriendo" una sección nueva cada vez que encuentra una fila
`type='section'`. El título de la sección es el `prompt` de esa fila — no
hay un campo `title` separado.
- **Por qué es deuda, no solo una curiosidad de modelado:** el motor está
pensado para ser un "motor de formularios de encuestas" genérico — no toda
encuesta futura va a tener secciones, y las que las tengan no necesariamente
van a querer modelarlas como filas-marcador en la misma tabla que las
preguntas. Hoy CUALQUIER consumidor que necesite agrupar por sección
(wizard, admin, y a futuro cualquier export/reporte) tiene que reimplementar
el mismo recorrido secuencial con el mismo supuesto implícito ("la fila
`type='section'` más cercana hacia atrás en `position` es la sección de esta
pregunta"). Si esa lógica se necesita en un tercer lugar, ya es momento de
promoverla a una función compartida — y si el motor crece a encuestas con
estructuras más ricas (subsecciones, secciones condicionales, secciones que
no son "preguntas" en absoluto), el modelo actual no escala sin reescribir
cada consumidor.
- **Alternativa más genérica y escalable (no implementada, propuesta para
evaluar cuando se priorice):** sacar la sección de `questions.type` y
modelarla como entidad propia — opción simple: `questions.section_id uuid
NULL REFERENCES sections(id)` + tabla `sections(id, survey_id, title,
position)`; opción más liviana sin tabla nueva: `questions.section_title
text NULL` + `questions.section_position int NULL` poblados directamente en
cada pregunta (denormalizado, sin fila-marcador `type='section'` separada).
Cualquiera de las dos deja "pertenece a la sección X" como un hecho
consultable con una columna, no como un cálculo derivado de recorrer
`position` — y ninguna asume que toda encuesta tiene secciones (el campo es
nullable). Migrar implicaría: (1) nueva(s) columna(s)/tabla, (2) backfill
desde las filas `type='section'` existentes, (3) actualizar
`lib/form-engine.ts` y la vista detallada de admin para leer el dato en vez
de inferirlo, (4) decidir si `type='section'` se deprecia o convive.
- **Acción tomada mientras tanto:** la vista detallada de admin
(`app/admin/responses/[id]/page.tsx`) replica la misma lógica de
`lib/form-engine.ts` (recorrido secuencial por `position`, abre sección en
cada `type='section'`) — no inventa columnas nuevas, no diverge del modelo
actual.
- **Trigger:** si una tercera feature necesita el mismo agrupamiento, extraer
la lógica a un helper compartido en `lib/` en vez de triplicarla. Si el
producto efectivamente evoluciona a soportar surveys con estructuras de
sección más complejas que el caso actual, evaluar la migración a columna/
tabla explícita antes de seguir apilando consumidores sobre el supuesto
implícito.

106
app/actions/invite-user.ts Normal file
View File

@@ -0,0 +1,106 @@
'use server'
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { getSupabaseAdmin } from '@/lib/supabase'
export type InviteUserResult = { success: true } | { success: false; error: string }
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
// Cliente ligado a la sesión (cookies) — anon key, respeta RLS. Se usa solo
// para identificar a quien llama y leer SU PROPIA fila de user_roles
// (policy user_roles_select_own: user_id = auth.uid()). Nunca para las
// escrituras de esta acción — esas requieren service_role (ver getSupabaseAdmin).
async function getSessionClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Action no siempre puede mutar cookies — se ignora
}
},
},
}
)
}
// Invita a un usuario de empresa y lo asocia a su organización con rol org_admin.
// Seguridad: quien llama debe ser platform_admin — se verifica leyendo user_roles
// en la DB con la sesión real (auth.uid()), nunca confiando en datos del formData.
// El rol del nuevo usuario es siempre 'org_admin', fijo en código — no es un
// valor que el formulario pueda controlar para escalar privilegios.
export async function inviteUser(formData: FormData): Promise<InviteUserResult> {
const sessionClient = await getSessionClient()
// auth.getUser() valida el token contra el servidor de Auth — no decodifica
// la cookie localmente (mismo patrón que middleware.ts).
const { data: { user: caller } } = await sessionClient.auth.getUser()
if (!caller) return { success: false, error: 'no_autenticado' }
// Verificación de autorización — lee la fila real de user_roles del llamante.
const { data: callerRole, error: roleErr } = await sessionClient
.from('user_roles')
.select('app_role')
.eq('user_id', caller.id)
.maybeSingle()
if (roleErr || callerRole?.app_role !== 'platform_admin') {
return { success: false, error: 'forbidden' }
}
const email = String(formData.get('email') ?? '').trim().toLowerCase()
const orgId = String(formData.get('orgId') ?? '').trim()
if (!EMAIL_RE.test(email)) return { success: false, error: 'email_invalido' }
if (!orgId) return { success: false, error: 'organizacion_requerida' }
const admin = getSupabaseAdmin()
// La organización debe existir — admin client porque ya está autorizado el
// llamante (paso anterior) y queremos evitar depender del claim app_role del
// JWT (puede estar stale si el rol cambió desde la última emisión de token).
const { data: org } = await admin
.from('organizations')
.select('id')
.eq('id', orgId)
.maybeSingle()
if (!org) return { success: false, error: 'organizacion_no_encontrada' }
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? ''
const { data: inviteData, error: inviteErr } = await admin.auth.admin.inviteUserByEmail(
email,
{ redirectTo: `${siteUrl}/auth/confirm` }
)
if (inviteErr || !inviteData?.user) {
return { success: false, error: `invite_failed: ${inviteErr?.message ?? 'sin detalle'}` }
}
const newUserId = inviteData.user.id
// Insert inmediato — antes de que el usuario confirme nada. user_roles no
// tiene policy de INSERT (RLS deniega por defecto), por eso requiere admin.
const { error: roleInsertErr } = await admin
.from('user_roles')
.insert({ user_id: newUserId, app_role: 'org_admin', org_id: orgId })
if (roleInsertErr) {
// Rollback — no dejar un usuario en auth.users sin rol asignado.
await admin.auth.admin.deleteUser(newUserId)
return { success: false, error: `role_insert_failed: ${roleInsertErr.message}` }
}
return { success: true }
}

View File

@@ -0,0 +1,274 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import Link from 'next/link'
import { formatDate, renderValue } from '../admin-format'
export const dynamic = 'force-dynamic'
interface QuestionRow {
id: string
code: string
prompt: string
type: string
position: number
instance_of: string | null
}
interface AnswerRow {
question_id: string
value: unknown
instance_id: string
}
interface ResponseMeta {
id: string
submitted_at: string
qi_zone: string | null
qi_sector: string | null
qi_age_bucket: string | null
survey_id: string
}
interface DetailRow {
code: string
prompt: string
rendered: string | null
}
interface InstanceGroup {
instanceId: string
rows: DetailRow[]
}
interface SectionGroup {
title: string | null
instances: InstanceGroup[]
}
async function getSupabaseSession() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Component — no puede mutar cookies, se ignora
}
},
},
}
)
}
function backHref(surveyId: string | undefined, page: string | undefined): string {
const params = new URLSearchParams()
if (surveyId) params.set('survey_id', surveyId)
if (page) params.set('page', page)
const qs = params.toString()
return `/admin/responses${qs ? `?${qs}` : ''}`
}
export default async function ResponseDetailPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ survey_id?: string; page?: string }>
}) {
const { id } = await params
const { survey_id: backSurveyId, page: backPage } = await searchParams
const back = backHref(backSurveyId, backPage)
const supabase = await getSupabaseSession()
const { data: response, error: responseError } = await supabase
.from('responses')
.select('id, submitted_at, qi_zone, qi_sector, qi_age_bucket, survey_id')
.eq('id', id)
.maybeSingle()
if (responseError || !response) {
return (
<div className="admin-page" role="main">
<Link href={back} className="admin-back-link"> Volver a la lista</Link>
<h1 className="admin-title">Respuesta no encontrada</h1>
<p className="admin-empty">
No existe esta respuesta o no tenés permiso para verla.
</p>
</div>
)
}
const meta = response as ResponseMeta
// Mismo filtro de sensibles que la lista (regla no negociable #4 CLAUDE.md) — NO diverge.
const { data: questionsData, error: questionsError } = await supabase
.from('questions')
.select('id, code, prompt, type, position, instance_of')
.eq('survey_id', meta.survey_id)
.eq('is_sensitive', false)
.neq('type', 'text_long')
.order('position', { ascending: true })
if (questionsError) {
return (
<div className="admin-page" role="main">
<Link href={back} className="admin-back-link"> Volver a la lista</Link>
<h1 className="admin-title">Control de respuestas</h1>
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
<pre className="admin-error-detail">{questionsError.message}</pre>
</div>
)
}
const questions = (questionsData ?? []) as QuestionRow[]
const answerQuestionIds = questions.filter((q) => q.type !== 'section').map((q) => q.id)
const { data: answersData, error: answersError } =
answerQuestionIds.length > 0
? await supabase
.from('answers')
.select('question_id, value, instance_id')
.eq('response_id', id)
.in('question_id', answerQuestionIds)
: { data: [] as AnswerRow[], error: null }
if (answersError) {
return (
<div className="admin-page" role="main">
<Link href={back} className="admin-back-link"> Volver a la lista</Link>
<h1 className="admin-title">Control de respuestas</h1>
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
<pre className="admin-error-detail">{answersError.message}</pre>
</div>
)
}
const answers = (answersData ?? []) as AnswerRow[]
const answersByQuestion = new Map<string, AnswerRow[]>()
answers.forEach((a) => {
const list = answersByQuestion.get(a.question_id) ?? []
list.push(a)
answersByQuestion.set(a.question_id, list)
})
// Agrupar por sección. No existe section_number/section_title en `questions`
// (verificado — ver TECH_DEBT.md). Las secciones hoy son filas type='section'
// intercaladas por position, mismo modelo que usa el wizard en
// lib/form-engine.ts:146-148 — se replica esa lógica acá, no se inventa una
// columna nueva.
const sections: SectionGroup[] = []
let current: SectionGroup = { title: null, instances: [] }
let hasContent = false
function instanceGroupFor(section: SectionGroup, instanceId: string): InstanceGroup {
let group = section.instances.find((g) => g.instanceId === instanceId)
if (!group) {
group = { instanceId, rows: [] }
section.instances.push(group)
}
return group
}
questions.forEach((q) => {
if (q.type === 'section') {
if (hasContent || current.title !== null) sections.push(current)
current = { title: q.prompt, instances: [] }
hasContent = false
return
}
const qAnswers = answersByQuestion.get(q.id)
if (qAnswers && qAnswers.length > 0) {
qAnswers.forEach((a) => {
const group = instanceGroupFor(current, a.instance_id)
group.rows.push({ code: q.code, prompt: q.prompt, rendered: renderValue(a.value, q.type) })
})
hasContent = true
} else if (!q.instance_of) {
// Pregunta normal sin respuesta — placeholder "sin respuesta" visible.
// Si es instance_of y no hay respuestas, no se generaron instancias
// (igual que el wizard no muestra pantallas de instancia sin generador
// disparado) — no se fabrica un grupo "Familiar" vacío.
const group = instanceGroupFor(current, 'default')
group.rows.push({ code: q.code, prompt: q.prompt, rendered: null })
hasContent = true
}
})
if (hasContent || current.title !== null) sections.push(current)
sections.forEach((s) => {
s.instances.sort((a, b) => {
if (a.instanceId === 'default') return -1
if (b.instanceId === 'default') return 1
return a.instanceId.localeCompare(b.instanceId, undefined, { numeric: true })
})
})
return (
<div className="admin-page" role="main">
<Link href={back} className="admin-back-link"> Volver a la lista</Link>
<header className="admin-header">
<div>
<h1 className="admin-title">Respuesta {meta.id.slice(0, 8)}</h1>
<p className="admin-subtitle">
{formatDate(meta.submitted_at)} · Departamento: {meta.qi_zone ?? '—'} · Sector:{' '}
{meta.qi_sector ?? '—'} · Edad: {meta.qi_age_bucket ?? '—'}
</p>
</div>
</header>
{sections.map((section, si) => (
<section key={si} className="admin-detail-section">
{section.title && <h2 className="admin-detail-section-title">{section.title}</h2>}
{section.instances.map((group) => (
<div key={group.instanceId} className="admin-detail-instance">
{group.instanceId !== 'default' && (
<span className="admin-instance-badge">
Familiar {group.instanceId.replace('familiar_', '')}
</span>
)}
<table className="admin-table">
<thead>
<tr>
<th>Código</th>
<th>Pregunta</th>
<th>Valor</th>
</tr>
</thead>
<tbody>
{group.rows.map((row, ri) => (
<tr key={ri}>
<td>{row.code}</td>
<td className="admin-prompt">{row.prompt}</td>
<td>
{row.rendered === null ? (
<span className="admin-nodata">sin respuesta</span>
) : (
row.rendered
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</section>
))}
{sections.length === 0 && (
<p className="admin-empty">Esta respuesta no tiene preguntas visibles para mostrar.</p>
)}
</div>
)
}

View File

@@ -0,0 +1,27 @@
// Compartido entre la lista (page.tsx) y la vista detallada ([id]/page.tsx) —
// deben renderizar fecha y valor exactamente igual; antes vivían duplicados
// en los dos archivos y ya habían divergido.
export function formatDate(iso: string): string {
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
// null/undefined → null (renderizado como badge "sin dato"/"sin respuesta" por el caller)
export function renderValue(value: unknown, type: string): string | null {
if (value === null || value === undefined) return null
switch (type) {
case 'single_choice':
case 'text_short':
return String(value).replace(/^"|"$/g, '')
case 'multiple_choice':
if (Array.isArray(value)) return value.join(', ')
return String(value)
case 'rating':
return String(value)
default:
return String(value)
}
}

View File

@@ -1,20 +1,15 @@
import { createClient } from '@supabase/supabase-js'
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { ResponsesTable, type DisplayRow } from './responses-table'
import { SurveySelect } from './survey-select'
import { AdminNavBar } from '@/components/admin-nav-bar'
import { formatDate, renderValue } from './admin-format'
// Server Component sin auth — herramienta interna de diagnóstico (Etapa 3V-B).
// Usa SUPABASE_SERVICE_ROLE_KEY: vive solo acá, server-side, nunca al cliente.
export const dynamic = 'force-dynamic'
const MAX_SUBMISSIONS = 500
const PAGE_SIZE = 50
const PROMPT_MAX_LEN = 60
const SURVEY_OPTIONS = [
{ label: 'Todos', id: null },
{ label: 'InQuality', id: '30000000-0000-0000-0000-000000000002' },
{ label: 'Fundación Solidaridad', id: '30000000-0000-0000-0000-000000000003' },
{ label: 'Demo', id: '30000000-0000-0000-0000-000000000001' },
] as const
interface AllowedQuestion {
id: string
code: string
@@ -36,56 +31,77 @@ interface ResponseRow {
answers: AnswerRow[]
}
function getSupabaseAdmin() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co'
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'placeholder'
return createClient(url, serviceKey, { auth: { persistSession: false } })
interface SurveyOption {
id: string
title: string
}
function formatDate(iso: string): string {
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
async function getSupabaseSession() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Component — no puede mutar cookies, se ignora
}
},
},
}
)
}
function truncate(text: string, max = PROMPT_MAX_LEN): string {
return text.length > max ? `${text.slice(0, max)}...` : text
}
// Spec 4.3 — null/undefined → null (renderizado como badge "sin dato")
function renderValue(value: unknown, type: string): string | null {
if (value === null || value === undefined) return null
switch (type) {
case 'single_choice':
case 'text_short':
return String(value).replace(/^"|"$/g, '')
case 'multiple_choice':
if (Array.isArray(value)) return value.join(', ')
return String(value)
case 'rating':
return String(value)
default:
return String(value)
}
function pageHref(surveyId: string | undefined, page: number): string {
const params = new URLSearchParams()
if (surveyId) params.set('survey_id', surveyId)
params.set('page', String(page))
return `/admin/responses?${params.toString()}`
}
export default async function AdminResponsesPage({
searchParams,
}: {
searchParams: Promise<{ survey_id?: string }>
searchParams: Promise<{ survey_id?: string; page?: string }>
}) {
const { survey_id: surveyId } = await searchParams
const supabase = getSupabaseAdmin()
const { survey_id: surveyId, page: pageParam } = await searchParams
const page = Math.max(1, Number(pageParam) || 1)
const supabase = await getSupabaseSession()
// 0 y 1 no dependen entre sí — en paralelo.
// 0. Surveys para el filtro — dinámico, no hardcodeado. Solo trae lo que
// RLS deja ver al usuario autenticado. Si falla, el filtro se degrada a
// "sin opciones" en vez de tirar abajo la página entera — la lista de
// respuestas no depende de esta query.
// 1. Preguntas permitidas — text_long e is_sensitive=true se excluyen ACÁ,
// antes de tocar la tabla answers. Sus valores nunca se consultan.
const { data: questionsData, error: questionsError } = await supabase
.from('questions')
.select('id, code, prompt, type, position')
.eq('is_sensitive', false)
.neq('type', 'text_long')
.order('position', { ascending: true })
const [
{ data: surveysData, error: surveysError },
{ data: questionsData, error: questionsError },
] = await Promise.all([
supabase.from('surveys').select('id, title').order('title'),
supabase
.from('questions')
.select('id, code, prompt, type, position')
.eq('is_sensitive', false)
.neq('type', 'text_long')
.order('position', { ascending: true }),
])
const surveys = (surveysData ?? []) as SurveyOption[]
const selectedSurvey = surveys.find((s) => s.id === surveyId)
if (questionsError) {
return (
@@ -103,6 +119,7 @@ export default async function AdminResponsesPage({
// 2. Submissions con sus respuestas — answers!inner + .in() filtra el ARRAY
// embebido a solo question_id permitidos (consulta, no post-filtro).
// Paginado con .range() — page size 50, no LIMIT fijo sin paginación.
let responsesQuery = supabase
.from('responses')
.select(
@@ -120,10 +137,44 @@ export default async function AdminResponsesPage({
responsesQuery = responsesQuery.eq('survey_id', surveyId)
}
const { data: responsesData, error: responsesError } =
// 3. Contadores totales (independientes de la página actual). submissionsCount
// usa el MISMO answers!inner + .in() que responsesQuery — si contara todas
// las responses sin ese filtro, una response sin ningún answer permitido
// inflaría el total y totalPages quedaría desalineado con lo que .range()
// realmente puede devolver (página "fantasma" al final).
let submissionsCountQuery = supabase
.from('responses')
.select('*, answers!inner(question_id)', { count: 'exact', head: true })
.in('answers.question_id', allowedIds)
if (surveyId) {
submissionsCountQuery = submissionsCountQuery.eq('survey_id', surveyId)
}
let answersCountQuery = supabase
.from('answers')
.select('*, responses!inner(survey_id)', { count: 'exact', head: true })
.in('question_id', allowedIds)
if (surveyId) {
answersCountQuery = answersCountQuery.eq('responses.survey_id', surveyId)
}
// Las tres son independientes entre sí — en paralelo.
const [
{ data: responsesData, error: responsesError },
{ count: totalSubmissions },
{ count: totalAnswers },
] =
allowedIds.length > 0
? await responsesQuery.order('submitted_at', { ascending: false }).limit(MAX_SUBMISSIONS)
: { data: [] as ResponseRow[], error: null }
? await Promise.all([
responsesQuery
.order('submitted_at', { ascending: false })
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1),
submissionsCountQuery,
answersCountQuery,
])
: [{ data: [] as ResponseRow[], error: null }, { count: 0 }, { count: 0 }]
if (responsesError) {
return (
@@ -136,30 +187,7 @@ export default async function AdminResponsesPage({
}
const responses = (responsesData ?? []) as unknown as ResponseRow[]
// 3. Contadores totales (independientes del límite de 500 submissions),
// filtrados por survey_id cuando hay filtro activo.
let submissionsCountQuery = supabase
.from('responses')
.select('*', { count: 'exact', head: true })
if (surveyId) {
submissionsCountQuery = submissionsCountQuery.eq('survey_id', surveyId)
}
const { count: totalSubmissions } = await submissionsCountQuery
let answersCountQuery = supabase
.from('answers')
.select('*, responses!inner(survey_id)', { count: 'exact', head: true })
.in('question_id', allowedIds)
if (surveyId) {
answersCountQuery = answersCountQuery.eq('responses.survey_id', surveyId)
}
const { count: totalAnswers } =
allowedIds.length > 0 ? await answersCountQuery : { count: 0 }
const totalPages = Math.max(1, Math.ceil((totalSubmissions ?? 0) / PAGE_SIZE))
// 4. Aplanar: una fila por answer, ordenadas por position dentro de cada submission
const rows: DisplayRow[] = []
@@ -176,7 +204,8 @@ export default async function AdminResponsesPage({
const isFirstInGroup = ai === 0
rows.push({
key: `${response.id}-${a.question_id}`,
submissionNum: idx + 1,
responseId: response.id,
submissionNum: (page - 1) * PAGE_SIZE + idx + 1,
isFirstInGroup,
date: isFirstInGroup ? formatDate(response.submitted_at) : '',
code: q.code,
@@ -189,40 +218,57 @@ export default async function AdminResponsesPage({
})
return (
<div className="admin-page" role="main">
<>
<AdminNavBar active="responses" />
<div className="admin-page" role="main">
<header className="admin-header">
<div>
<h1 className="admin-title">Control de respuestas</h1>
<p className="admin-subtitle">
{totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales ·
actualizado {formatDate(new Date().toISOString())}
{totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales ·{' '}
{selectedSurvey ? selectedSurvey.title : 'Todos los surveys'} · actualizado{' '}
{formatDate(new Date().toISOString())}
</p>
<nav className="admin-survey-nav" aria-label="Selección de empresa">
{SURVEY_OPTIONS.map((opt) => (
<a
key={opt.label}
href={opt.id ? `/admin/responses?survey_id=${opt.id}` : '/admin/responses'}
className={
opt.id === (surveyId ?? null)
? 'admin-survey-link admin-survey-link-active'
: 'admin-survey-link'
}
aria-current={opt.id === (surveyId ?? null) ? 'page' : undefined}
>
{opt.label}
</a>
))}
</nav>
<p className="admin-subtitle">
Página {page} de {totalPages} · {totalSubmissions ?? 0} submissions totales
</p>
<div className="admin-survey-nav">
<SurveySelect surveys={surveys} value={surveyId ?? ''} />
</div>
{surveysError && (
<p className="admin-error" role="alert">
No se pudo cargar la lista de surveys para el filtro se muestra "Todos".
</p>
)}
</div>
<div className="admin-header-actions">
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
</div>
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
</header>
<div className="admin-table-wrapper">
<ResponsesTable rows={rows} />
<ResponsesTable rows={rows} surveyId={surveyId} page={page} />
{rows.length === 0 && (
<p className="admin-empty">Sin respuestas registradas todavía.</p>
)}
</div>
</div>
{(totalSubmissions ?? 0) > PAGE_SIZE && (
<nav className="admin-pagination" aria-label="Paginación de respuestas">
{page > 1 ? (
<a href={pageHref(surveyId, page - 1)} className="admin-pagination-link"> Anterior</a>
) : (
<span className="admin-pagination-link admin-pagination-disabled" aria-disabled="true"> Anterior</span>
)}
<span className="admin-pagination-current">Página {page} de {totalPages}</span>
{page < totalPages ? (
<a href={pageHref(surveyId, page + 1)} className="admin-pagination-link">Siguiente </a>
) : (
<span className="admin-pagination-link admin-pagination-disabled" aria-disabled="true">Siguiente </span>
)}
</nav>
)}
</div>
</>
)
}

View File

@@ -1,9 +1,11 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
export interface DisplayRow {
key: string
responseId: string
submissionNum: number
isFirstInGroup: boolean
date: string
@@ -14,7 +16,15 @@ export interface DisplayRow {
sector: string
}
export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
export function ResponsesTable({
rows,
surveyId,
page,
}: {
rows: DisplayRow[]
surveyId?: string
page: number
}) {
const [expanded, setExpanded] = useState<Set<number>>(new Set())
function toggle(submissionNum: number) {
@@ -26,6 +36,13 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
})
}
function detailHref(responseId: string): string {
const params = new URLSearchParams()
if (surveyId) params.set('survey_id', surveyId)
params.set('page', String(page))
return `/admin/responses/${responseId}?${params.toString()}`
}
return (
<table className="admin-table">
<thead>
@@ -37,6 +54,7 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
<th>Valor</th>
<th className="admin-col-optional">Departamento</th>
<th className="admin-col-optional">Sector</th>
<th>Ver</th>
</tr>
</thead>
<tbody>
@@ -54,28 +72,32 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
<tr
key={row.key}
className={trClass}
// Conveniencia para mouse — sin semántica ARIA en la fila (evita
// anidar un control interactivo real, el Link "Ver", dentro de un
// elemento expuesto como role="button" a lectores de pantalla).
// El toggle accesible por teclado vive en el <button> de abajo.
onClick={row.isFirstInGroup ? () => toggle(row.submissionNum) : undefined}
onKeyDown={
row.isFirstInGroup
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggle(row.submissionNum)
}
}
: undefined
}
role={row.isFirstInGroup ? 'button' : undefined}
tabIndex={row.isFirstInGroup ? 0 : undefined}
aria-expanded={row.isFirstInGroup ? isExpanded : undefined}
>
<td>
{row.isFirstInGroup && (
<span className="admin-toggle-icon" aria-hidden="true">
{isExpanded ? '▼' : '▶'}
</span>
{row.isFirstInGroup ? (
<button
type="button"
className="admin-row-toggle-btn"
onClick={(e) => {
e.stopPropagation()
toggle(row.submissionNum)
}}
aria-expanded={isExpanded}
aria-label={isExpanded ? 'Colapsar respuesta' : 'Expandir respuesta'}
>
<span className="admin-toggle-icon" aria-hidden="true">
{isExpanded ? '▼' : '▶'}
</span>
{row.submissionNum}
</button>
) : (
row.submissionNum
)}
{row.submissionNum}
</td>
<td>{row.date}</td>
<td>{row.code}</td>
@@ -89,6 +111,13 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
</td>
<td className="admin-col-optional">{row.zone}</td>
<td className="admin-col-optional">{row.sector}</td>
<td onClick={(e) => e.stopPropagation()}>
{row.isFirstInGroup && (
<Link href={detailHref(row.responseId)} className="admin-action-link">
Ver
</Link>
)}
</td>
</tr>
)
})}

View File

@@ -0,0 +1,31 @@
'use client'
import { useRouter } from 'next/navigation'
interface SurveyOption {
id: string
title: string
}
export function SurveySelect({ surveys, value }: { surveys: SurveyOption[]; value: string }) {
const router = useRouter()
return (
<select
className="admin-survey-select"
aria-label="Selección de encuesta"
value={value}
onChange={(e) => {
const id = e.target.value
router.push(id ? `/admin/responses?survey_id=${id}` : '/admin/responses')
}}
>
<option value="">Todos los surveys</option>
{surveys.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
)
}

View File

@@ -0,0 +1,24 @@
'use client'
import { createBrowserClient } from '@supabase/ssr'
import { useRouter } from 'next/navigation'
export function SignOutButton() {
const router = useRouter()
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
async function handleSignOut() {
await supabase.auth.signOut()
router.push('/login')
}
return (
<button type="button" onClick={handleSignOut} className="admin-signout">
Cerrar sesión
</button>
)
}

View File

@@ -0,0 +1,89 @@
'use client'
import { useActionState, useEffect, useState } from 'react'
import { inviteUser, type InviteUserResult } from '@/app/actions/invite-user'
interface OrgOption {
id: string
name: string
}
const INITIAL_STATE: InviteUserResult = { success: false, error: '' }
const BANNER_VISIBLE_MS = 4000
const ERROR_MESSAGES: Record<string, string> = {
no_autenticado: 'Tu sesión expiró — volvé a iniciar sesión.',
forbidden: 'No tenés permisos para invitar usuarios.',
email_invalido: 'El email no es válido.',
organizacion_requerida: 'Elegí una organización.',
organizacion_no_encontrada: 'La organización seleccionada no existe.',
}
function describeError(code: string): string {
if (code.startsWith('invite_failed'))
return `No se pudo enviar la invitación: ${code.slice('invite_failed: '.length)}`
if (code.startsWith('role_insert_failed'))
return `Error interno al asignar el rol: ${code.slice('role_insert_failed: '.length)}`
return ERROR_MESSAGES[code] ?? 'Ocurrió un error inesperado.'
}
export function InviteForm({ organizations }: { organizations: OrgOption[] }) {
const [state, formAction, isPending] = useActionState(
async (_prev: InviteUserResult, formData: FormData) => inviteUser(formData),
INITIAL_STATE
)
const [bannerVisible, setBannerVisible] = useState(false)
// Banner visible al menos BANNER_VISIBLE_MS tras cada resultado nuevo, o
// hasta que el usuario vuelva a tocar el formulario (lo que pase primero).
useEffect(() => {
const hasMessage = state.success || (!state.success && state.error !== '')
setBannerVisible(hasMessage)
if (!hasMessage) return
const timer = setTimeout(() => setBannerVisible(false), BANNER_VISIBLE_MS)
return () => clearTimeout(timer)
}, [state])
return (
<form
action={formAction}
className="invite-form"
onChange={() => setBannerVisible(false)}
>
<div className="invite-form-row">
<div className="login-field">
<label htmlFor="email" className="login-label">Email</label>
<input
id="email"
name="email"
type="email"
required
disabled={isPending}
className="text-short-input"
/>
</div>
<div className="login-field">
<label htmlFor="orgId" className="login-label">Organización</label>
<select id="orgId" name="orgId" required disabled={isPending} className="admin-survey-select">
<option value="">Seleccioná una organización</option>
{organizations.map((org) => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
</div>
<button type="submit" disabled={isPending} className="login-submit">
{isPending ? 'Enviando…' : 'Enviar invitación'}
</button>
</div>
{bannerVisible && !state.success && state.error && (
<p role="alert" className="invite-banner invite-banner-error">
{describeError(state.error)}
</p>
)}
{bannerVisible && state.success && (
<p role="status" className="invite-banner invite-banner-success">
Invitación enviada correctamente.
</p>
)}
</form>
)
}

195
app/admin/usuarios/page.tsx Normal file
View File

@@ -0,0 +1,195 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { getSupabaseAdmin } from '@/lib/supabase'
import { formatDate } from '../responses/admin-format'
import { AdminNavBar } from '@/components/admin-nav-bar'
import { InviteForm } from './invite-form'
export const dynamic = 'force-dynamic'
interface OrgOption {
id: string
name: string
}
interface RoleRow {
user_id: string
app_role: string
org_id: string | null
organizations: { name: string } | null
}
interface UserDisplayRow {
userId: string
email: string
orgName: string
role: string
createdAt: string
}
async function getSupabaseSession() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Component — no puede mutar cookies, se ignora
}
},
},
}
)
}
// auth.users no está expuesto vía PostgREST (no es schema público) — no hay
// forma de hacer un .from('auth.users') ni un join SQL directo desde
// supabase-js. listUsers() es la API admin soportada para resolver el email;
// pagina hasta agotar resultados (tope defensivo de 2000 usuarios).
async function fetchAllUsersMap(admin: ReturnType<typeof getSupabaseAdmin>) {
const map = new Map<string, { email: string; createdAt: string }>()
const perPage = 200
for (let page = 1; page <= 10; page++) {
const { data, error } = await admin.auth.admin.listUsers({ page, perPage })
if (error || !data) break
data.users.forEach((u) => {
map.set(u.id, { email: u.email ?? '—', createdAt: u.created_at })
})
if (data.users.length < perPage) break
}
return map
}
export default async function AdminUsuariosPage() {
const supabase = await getSupabaseSession()
// 1. Identificar a quien llama — defensivo, el middleware ya debería haber
// redirigido a /login si no hay sesión.
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return (
<div className="admin-page" role="main">
<h1 className="admin-title">Acceso restringido</h1>
<p className="admin-error">No se encontró una sesión activa.</p>
<a href="/login" className="admin-back-link"> Iniciar sesión</a>
</div>
)
}
// 2. Verificar platform_admin leyendo la fila REAL en user_roles — esta
// página usa getSupabaseAdmin() más abajo (bypasa RLS por completo), así
// que el gate de autorización tiene que ir acá, no delegarse a las policies.
const { data: callerRole } = await supabase
.from('user_roles')
.select('app_role')
.eq('user_id', user.id)
.maybeSingle()
if (callerRole?.app_role !== 'platform_admin') {
return (
<div className="admin-page" role="main">
<h1 className="admin-title">Acceso restringido</h1>
<p className="admin-error">
Esta sección es exclusiva para administradores de plataforma.
</p>
<a href="/admin/responses" className="admin-back-link"> Volver</a>
</div>
)
}
// 3. Datos — admin client para todo a partir de acá. La autorización ya
// quedó probada en el paso 2 con una lectura directa a la DB; usar admin
// para el resto evita depender de policies/claims del JWT que podrían
// haber quedado stale.
const admin = getSupabaseAdmin()
const [{ data: orgsData, error: orgsError }, { data: rolesData, error: rolesError }] =
await Promise.all([
admin.from('organizations').select('id, name').order('name'),
admin.from('user_roles').select('user_id, app_role, org_id, organizations(name)'),
])
if (orgsError || rolesError) {
return (
<div className="admin-page" role="main">
<h1 className="admin-title">Usuarios</h1>
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
<pre className="admin-error-detail">
{(orgsError ?? rolesError)?.message}
</pre>
</div>
)
}
const organizations = (orgsData ?? []) as OrgOption[]
const roles = (rolesData ?? []) as unknown as RoleRow[]
const usersMap = await fetchAllUsersMap(admin)
const userRows: UserDisplayRow[] = roles.map((r) => {
const u = usersMap.get(r.user_id)
return {
userId: r.user_id,
email: u?.email ?? '—',
orgName: r.organizations?.name ?? '—',
role: r.app_role,
createdAt: u?.createdAt ? formatDate(u.createdAt) : '—',
}
})
return (
<>
<AdminNavBar active="usuarios" />
<div className="admin-page" role="main">
<header className="admin-header">
<div>
<h1 className="admin-title">Usuarios</h1>
<p className="admin-subtitle">{userRows.length} usuarios registrados</p>
</div>
</header>
<section className="admin-detail-section">
<h2 className="admin-detail-section-title">Invitar usuario</h2>
<InviteForm organizations={organizations} />
</section>
<section className="admin-detail-section">
<h2 className="admin-detail-section-title">Usuarios existentes</h2>
<div className="admin-table-wrapper">
<table className="admin-table">
<thead>
<tr>
<th>Email</th>
<th>Organización</th>
<th>Rol</th>
<th>Fecha de creación</th>
</tr>
</thead>
<tbody>
{userRows.map((row) => (
<tr key={row.userId}>
<td>{row.email}</td>
<td>{row.orgName}</td>
<td>{row.role}</td>
<td>{row.createdAt}</td>
</tr>
))}
</tbody>
</table>
{userRows.length === 0 && (
<p className="admin-empty">Todavía no hay usuarios invitados.</p>
)}
</div>
</section>
</div>
</>
)
}

191
app/auth/confirm/page.tsx Normal file
View File

@@ -0,0 +1,191 @@
'use client'
import { useEffect, useState } from 'react'
import { createBrowserClient } from '@supabase/ssr'
import { useRouter } from 'next/navigation'
type Stage = 'validating' | 'ready' | 'submitting' | 'error'
const DEFAULT_LINK_ERROR = 'Pedile al administrador que te envíe una nueva invitación.'
// GoTrue redirige con #error=...&error_code=...&error_description=... cuando
// el token ya fue usado, venció, o es inválido — en vez de emitir PASSWORD_RECOVERY.
// Mapeo a mensajes accionables; default cubre códigos no contemplados acá.
function describeLinkError(errorCode: string | null): string {
switch (errorCode) {
case 'otp_expired':
return 'Este link ya fue usado o venció. ' + DEFAULT_LINK_ERROR
case 'access_denied':
return 'El acceso fue denegado para este link. ' + DEFAULT_LINK_ERROR
default:
return 'El link de invitación no es válido. ' + DEFAULT_LINK_ERROR
}
}
export default function ConfirmPage() {
const [stage, setStage] = useState<Stage>('validating')
const [error, setError] = useState<string | null>(null)
const [linkErrorMessage, setLinkErrorMessage] = useState(DEFAULT_LINK_ERROR)
const [showPassword, setShowPassword] = useState(false)
const router = useRouter()
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
useEffect(() => {
// GoTrue ya nos dice en el fragmento si el token es inválido/vencido/usado
// (#error=...&error_code=...) — leerlo evita esperar el timeout genérico.
const hash = window.location.hash.startsWith('#')
? window.location.hash.slice(1)
: window.location.hash
const hashParams = new URLSearchParams(hash)
const errorCode = hashParams.get('error_code')
if (errorCode) {
setLinkErrorMessage(describeLinkError(errorCode))
setStage('error')
return
}
// Supabase emite PASSWORD_RECOVERY tanto para recovery como para invite
// cuando el token de la URL (#access_token) es válido.
const { data: listener } = supabase.auth.onAuthStateChange((event) => {
if (event === 'PASSWORD_RECOVERY') setStage('ready')
})
// Carrera posible: si el token ya se procesó antes de montar el listener,
// ya hay sesión activa aunque el evento no se vuelva a emitir.
supabase.auth.getSession().then(({ data: { session } }) => {
setStage((s) => (s === 'validating' && session ? 'ready' : s))
})
// Sin evento ni sesión tras unos segundos = token inválido/vencido/ausente
// sin que GoTrue haya mandado un error explícito en el fragmento.
const timeout = setTimeout(() => {
setStage((s) => (s === 'validating' ? 'error' : s))
}, 8000)
return () => {
listener.subscription.unsubscribe()
clearTimeout(timeout)
}
}, [supabase])
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const fd = new FormData(e.currentTarget)
const password = String(fd.get('password') ?? '')
const confirmPassword = String(fd.get('confirmPassword') ?? '')
if (password.length < 8) {
setError('La contraseña debe tener al menos 8 caracteres.')
return
}
if (password !== confirmPassword) {
setError('Las contraseñas no coinciden.')
return
}
setError(null)
setStage('submitting')
const { error: updateErr } = await supabase.auth.updateUser({ password })
if (updateErr) {
setError('No pudimos guardar tu contraseña. Probá de nuevo.')
setStage('ready')
return
}
router.push('/dashboard')
router.refresh()
}
if (stage === 'validating') {
return (
<div className="login-page">
<div className="login-card">
<h1 className="login-title">Validando tu invitación...</h1>
</div>
</div>
)
}
if (stage === 'error') {
return (
<div className="login-page">
<div className="login-card">
<h1 className="login-title">Link inválido o vencido</h1>
<p role="alert" className="field-error login-error">
{linkErrorMessage}
</p>
</div>
</div>
)
}
return (
<div className="login-page">
<div className="login-card">
<h1 className="login-title">Creá tu contraseña</h1>
<form onSubmit={handleSubmit} className="login-form" noValidate>
<div className="login-field">
<label htmlFor="password" className="login-label">Contraseña</label>
<div className="login-password-wrapper">
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
required
minLength={8}
disabled={stage === 'submitting'}
className="text-short-input"
/>
<button
type="button"
className="login-password-toggle"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
>
{showPassword ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
<path d="M3 3l18 18" />
</svg>
)}
</button>
</div>
</div>
<div className="login-field">
<label htmlFor="confirmPassword" className="login-label">Confirmar contraseña</label>
<input
id="confirmPassword"
name="confirmPassword"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
required
minLength={8}
disabled={stage === 'submitting'}
className="text-short-input"
/>
</div>
{error && (
<p role="alert" className="field-error login-error">{error}</p>
)}
<button type="submit" disabled={stage === 'submitting'} className="login-submit">
{stage === 'submitting' ? 'Guardando…' : 'Crear contraseña'}
</button>
</form>
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { AdminNavBar } from '@/components/admin-nav-bar'
import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
import { TalentoRiesgoCard } from '@/components/dashboard/talento-riesgo-card'
import { PrevalenciaCuidadoresCard } from '@/components/dashboard/prevalencia-cuidadores-card'
@@ -46,7 +47,9 @@ export default function DashboardPage() {
const [lens, setLens] = useState<Lens>('empresa')
return (
<div className="db-page">
<>
<AdminNavBar active="dashboard" />
<div className="db-page">
<FilterSidebar
filters={filters}
onChange={setFilters}
@@ -144,6 +147,7 @@ export default function DashboardPage() {
/>
)}
</div>
</div>
</div>
</>
)
}

View File

@@ -295,6 +295,7 @@ body {
/* position: fixed + top/left/width set by JS via getBoundingClientRect()
so the list escapes any overflow:auto ancestor (wizard-answer-area) */
position: fixed;
max-width: calc(100vw - 32px);
background: var(--color-surface);
border: 1.5px solid var(--color-border-focus);
border-radius: var(--radius);
@@ -316,7 +317,7 @@ body {
.barrio-autocomplete-item:hover,
.barrio-autocomplete-item--active {
background: var(--color-primary-light);
color: var(--color-primary);
color: #494F4F;
}
/* ── Textarea ── */
@@ -377,7 +378,7 @@ body {
.btn-primary:hover { background: var(--color-primary-hover); }
.btn-secondary {
background: transparent;
color: var(--color-primary);
color: #494F4F;
border-color: var(--color-primary);
}
.btn-secondary:hover { background: var(--color-primary-light); }
@@ -428,7 +429,7 @@ body {
}
.summary-code {
font-weight: 700;
color: var(--color-primary);
color: #494F4F;
margin-right: 4px;
}
.summary-answer {
@@ -487,7 +488,7 @@ body {
.consent-checkbox-label:has(input:checked) { border-color: var(--color-primary); background: var(--color-primary-light); }
.consent-checkbox-input { width: 20px; height: 20px; flex-shrink: 0; margin-top: 1px; accent-color: var(--color-primary); cursor: pointer; }
.consent-checkbox-text { flex: 1; }
.consent-required-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--color-primary); background: var(--color-primary-light); padding: 1px 6px; border-radius: 4px; }
.consent-required-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: #494F4F; background: var(--color-primary-light); padding: 1px 6px; border-radius: 4px; }
.consent-optional-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--color-text-muted); background: #f3f4f6; padding: 1px 6px; border-radius: 4px; }
.consent-footer { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }
.consent-required-hint { margin: 0; font-size: 0.875rem; color: var(--color-text-muted); }
@@ -566,7 +567,7 @@ body {
.wizard-section-number {
font-size: 0.9375rem;
font-weight: 700;
color: var(--color-primary);
color: #494F4F;
margin: 0 0 8px;
}
.wizard-section-title {
@@ -583,9 +584,8 @@ body {
margin: 0;
}
/* Enunciado: bloque con altura reservada constante, mismo lugar pantalla tras pantalla. */
/* Enunciado: crece orgánicamente con el contenido, sin altura mínima fija. */
.wizard-prompt-block {
min-height: 116px;
flex-shrink: 0;
margin-bottom: 20px;
}
@@ -659,6 +659,7 @@ body {
padding: 16px 0;
margin-top: 16px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 12px;
@@ -673,6 +674,11 @@ body {
.wizard-prompt-text { font-size: 1.1875rem; }
.wizard-section-title { font-size: 1.5rem; }
.wizard-answer-area { max-height: 400px; }
/* column-reverse: el botón primario (Siguiente/Enviar) queda arriba —
el pulgar llega más fácil al centro de la pantalla que a la base. */
.wizard-bottombar { flex-direction: column-reverse; }
.wizard-bottombar .btn { width: 100%; }
.wizard-bottombar-hint { order: 3; }
}
/* ── Loading skeleton ── */
@@ -964,9 +970,8 @@ body {
/* ── Card destacada (estrella) ── */
.db-widget-star {
border-color: var(--color-re-teal);
background: linear-gradient(135deg, #dff4f5, #ffffff);
}
.db-widget-star .db-widget-title { color: var(--color-re-teal); }
.db-widget-star .db-widget-title { color: #494F4F; }
/* ── Big stat (KPI de una sola cifra, dentro de WidgetShell) ── */
.db-big-stat { font-size: 2.375rem; font-weight: 700; color: var(--color-re-teal); line-height: 1; }
@@ -993,6 +998,21 @@ body {
.db-placeholder-title { font-size: 1rem; font-weight: 600; color: #494F4F; }
.db-placeholder-hint { font-size: 0.8125rem; max-width: 420px; line-height: 1.5; }
/* ── Nav compartido (dashboard / respuestas / usuarios) ── */
.admin-nav-bar {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding: 10px 24px; background: var(--color-surface);
border-bottom: 1px solid var(--color-border); flex-wrap: wrap;
}
.admin-nav { display: flex; gap: 4px; }
.admin-nav-link {
padding: 6px 14px; border-radius: var(--radius); font-size: 0.875rem;
font-weight: 600; color: var(--color-text-muted); text-decoration: none;
transition: background 0.15s, color 0.15s;
}
.admin-nav-link:hover { background: var(--color-primary-light); color: var(--color-text); }
.admin-nav-link-active { background: var(--color-primary-light); color: var(--color-primary-hover); }
/* ── Admin: control de respuestas (Etapa 3V) ── */
.admin-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
.admin-header {
@@ -1002,19 +1022,46 @@ body {
.admin-title { font-size: 1.375rem; font-weight: 700; color: var(--color-text); margin: 0 0 4px; }
.admin-subtitle { font-size: 0.8125rem; color: var(--color-text-muted); margin: 0; }
.admin-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.admin-survey-link {
font-size: 0.75rem; font-weight: 600; padding: 4px 12px; border-radius: 999px;
border: 1px solid var(--color-border); color: var(--color-text-muted); text-decoration: none;
transition: all 0.2s ease;
}
.admin-survey-link:hover { color: var(--color-text); border-color: var(--color-primary); }
.admin-survey-link-active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
.admin-refresh {
display: inline-block; padding: 8px 16px; border-radius: var(--radius);
background: var(--color-primary); color: #fff; font-size: 0.8125rem;
font-weight: 600; text-decoration: none; white-space: nowrap;
}
.admin-refresh:hover { background: var(--color-primary-hover); }
.admin-survey-select {
font-size: 0.8125rem; padding: 6px 10px; border-radius: var(--radius);
border: 1px solid var(--color-border); color: var(--color-text);
background: var(--color-surface);
}
.admin-action-link {
font-size: 0.75rem; font-weight: 600; color: var(--color-primary);
text-decoration: none;
}
.admin-action-link:hover { text-decoration: underline; }
.admin-pagination {
display: flex; align-items: center; justify-content: center; gap: 16px;
margin-top: 16px; font-size: 0.8125rem;
}
.admin-pagination-link { color: var(--color-primary); text-decoration: none; font-weight: 600; }
.admin-pagination-link:hover { text-decoration: underline; }
.admin-pagination-disabled { color: var(--color-text-muted); cursor: default; }
.admin-pagination-current { color: var(--color-text-muted); }
.admin-back-link {
display: inline-block; margin-bottom: 12px; font-size: 0.8125rem;
font-weight: 600; color: var(--color-primary); text-decoration: none;
}
.admin-back-link:hover { text-decoration: underline; }
.admin-detail-section { margin-bottom: 24px; }
.admin-detail-section-title {
font-size: 1.0625rem; font-weight: 700; color: var(--color-text);
margin: 0 0 10px;
}
.admin-detail-instance { margin-bottom: 14px; }
.admin-instance-badge {
display: inline-block; font-size: 0.75rem; font-weight: 600;
color: var(--color-primary-hover); background: var(--color-primary-light);
padding: 3px 10px; border-radius: 999px; margin-bottom: 6px;
}
.admin-table-wrapper {
overflow-x: auto; background: var(--color-surface); border: 1px solid var(--color-border);
border-radius: var(--radius); box-shadow: var(--shadow-card);
@@ -1035,6 +1082,10 @@ body {
display: inline-block; width: 14px; margin-right: 4px;
color: var(--color-text-muted); font-size: 0.7rem;
}
.admin-row-toggle-btn {
background: none; border: none; padding: 0; margin: 0;
font: inherit; color: inherit; cursor: pointer;
}
.admin-nodata {
display: inline-block; padding: 1px 8px; border-radius: 10px;
background: var(--color-re-light); color: var(--color-re-dark);
@@ -1060,3 +1111,124 @@ body {
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
/* ── Login ── */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg);
padding: 24px;
}
.login-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 40px 36px;
width: 100%;
max-width: 380px;
}
.login-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
margin: 0 0 28px;
}
.login-form { display: flex; flex-direction: column; gap: 16px; }
.login-field { display: flex; flex-direction: column; gap: 6px; }
.login-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.login-error { margin: 0; }
.login-password-wrapper { position: relative; }
.login-password-wrapper .text-short-input { padding-right: 40px; }
.login-password-toggle {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: none;
border-radius: var(--radius);
color: var(--color-text-muted);
cursor: pointer;
}
.login-password-toggle:hover { color: var(--color-text); }
.login-submit {
margin-top: 8px;
padding: 10px 20px;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.login-submit:hover:not(:disabled) { background: var(--color-primary-hover); }
.login-submit:disabled { opacity: 0.6; cursor: not-allowed; }
.login-back-button { margin-top: 16px; }
.login-forgot-link {
align-self: center;
margin-top: 4px;
padding: 4px;
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.8125rem;
cursor: pointer;
text-decoration: underline;
}
.login-forgot-link:hover { color: var(--color-primary-hover); }
/* ── Invite form (/admin/usuarios) ── */
.invite-form { display: flex; flex-direction: column; gap: 12px; }
.invite-form-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 12px;
align-items: end;
}
.invite-banner {
margin: 0;
padding: 10px 14px;
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 600;
}
.invite-banner-success {
background: var(--color-primary-light);
color: var(--color-primary-hover);
}
.invite-banner-error {
background: var(--color-error-bg);
color: var(--color-error);
}
@media (max-width: 768px) {
.invite-form-row { grid-template-columns: 1fr; }
}
/* ── Admin sign-out ── */
.admin-header-actions { display: flex; align-items: center; gap: 12px; }
.admin-signout {
font-size: 0.8125rem;
color: var(--color-text-muted);
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 5px 12px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.admin-signout:hover { color: var(--color-error); border-color: var(--color-error); }

197
app/login/page.tsx Normal file
View File

@@ -0,0 +1,197 @@
'use client'
import { useState } from 'react'
import { createBrowserClient } from '@supabase/ssr'
import { useRouter } from 'next/navigation'
type Mode = 'login' | 'reset' | 'reset-sent'
export default function LoginPage() {
const [mode, setMode] = useState<Mode>('login')
const [isPending, setIsPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const [resetEmail, setResetEmail] = useState('')
const router = useRouter()
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsPending(true)
setError(null)
const fd = new FormData(e.currentTarget)
const { error: authError } = await supabase.auth.signInWithPassword({
email: fd.get('email') as string,
password: fd.get('password') as string,
})
if (authError) {
setError('Email o contraseña incorrectos')
setIsPending(false)
return
}
router.push('/admin/responses')
router.refresh()
}
// Reusa /auth/confirm: ya escucha PASSWORD_RECOVERY (lo armamos para el
// flujo de invitación, pero Supabase emite el mismo evento acá).
async function handleReset(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsPending(true)
setError(null)
const fd = new FormData(e.currentTarget)
const email = String(fd.get('email') ?? '')
const { error: resetError } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/confirm`,
})
setIsPending(false)
if (resetError) {
setError('No pudimos enviar el link. Probá de nuevo.')
return
}
setResetEmail(email)
setMode('reset-sent')
}
if (mode === 'reset-sent') {
return (
<div className="login-page">
<div className="login-card">
<h1 className="login-title">Revisá tu email</h1>
<p className="admin-subtitle">
Si <strong>{resetEmail}</strong> tiene una cuenta, te enviamos un link para crear una
contraseña nueva.
</p>
<button
type="button"
className="login-submit login-back-button"
onClick={() => { setMode('login'); setError(null) }}
>
Volver al login
</button>
</div>
</div>
)
}
if (mode === 'reset') {
return (
<div className="login-page">
<div className="login-card">
<h1 className="login-title">Recuperar contraseña</h1>
<form onSubmit={handleReset} className="login-form" noValidate>
<div className="login-field">
<label htmlFor="reset-email" className="login-label">Email</label>
<input
id="reset-email"
name="email"
type="email"
autoComplete="email"
required
disabled={isPending}
className="text-short-input"
/>
</div>
{error && (
<p role="alert" className="field-error login-error">{error}</p>
)}
<button type="submit" disabled={isPending} className="login-submit">
{isPending ? 'Enviando…' : 'Enviar link de recuperación'}
</button>
<button
type="button"
className="login-forgot-link"
onClick={() => { setMode('login'); setError(null) }}
>
Volver al login
</button>
</form>
</div>
</div>
)
}
return (
<div className="login-page">
<div className="login-card">
<h1 className="login-title">Acceso interno</h1>
<form onSubmit={handleSubmit} className="login-form" noValidate>
<div className="login-field">
<label htmlFor="email" className="login-label">Email</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
disabled={isPending}
className="text-short-input"
/>
</div>
<div className="login-field">
<label htmlFor="password" className="login-label">Contraseña</label>
<div className="login-password-wrapper">
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
required
disabled={isPending}
className="text-short-input"
/>
<button
type="button"
className="login-password-toggle"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
>
{showPassword ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
<path d="M3 3l18 18" />
</svg>
)}
</button>
</div>
</div>
{error && (
<p role="alert" className="field-error login-error">{error}</p>
)}
<button
type="submit"
disabled={isPending}
className="login-submit"
>
{isPending ? 'Ingresando…' : 'Ingresar'}
</button>
<button
type="button"
className="login-forgot-link"
onClick={() => { setMode('reset'); setError(null) }}
>
¿Olvidaste tu contraseña?
</button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { SignOutButton } from '@/app/admin/sign-out-button'
interface AdminNavBarProps {
active: 'dashboard' | 'responses' | 'usuarios'
}
const LINKS = [
{ id: 'dashboard', href: '/dashboard', label: 'Dashboard' },
{ id: 'responses', href: '/admin/responses', label: 'Respuestas' },
{ id: 'usuarios', href: '/admin/usuarios', label: 'Usuarios' },
] as const
// Nav compartido entre /dashboard, /admin/responses y /admin/usuarios — las
// tres pantallas detrás del login. Sin esto, cada una era una isla y el único
// modo de llegar a otra era escribir la URL a mano.
export function AdminNavBar({ active }: AdminNavBarProps) {
return (
<div className="admin-nav-bar">
<nav className="admin-nav" aria-label="Navegación admin">
{LINKS.map((link) => (
<a
key={link.id}
href={link.href}
className={`admin-nav-link${active === link.id ? ' admin-nav-link-active' : ''}`}
aria-current={active === link.id ? 'page' : undefined}
>
{link.label}
</a>
))}
</nav>
<SignOutButton />
</div>
)
}

View File

@@ -1,7 +1,6 @@
'use client'
import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
type GeoData = Record<string, Record<string, string[]>>
import { getGeoData, type GeoData } from '@/lib/geo-cache'
interface BarrioAutocompleteFieldProps {
name: string
@@ -25,16 +24,16 @@ export function BarrioAutocompleteField({
error,
}: BarrioAutocompleteFieldProps) {
const [geoData, setGeoData] = useState<GeoData | null>(null)
const [geoStatus, setGeoStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [open, setOpen] = useState(false)
const [activeIdx, setActiveIdx] = useState(-1)
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({})
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
fetch('/data/geo_paraguay_full.json')
.then((r) => r.json())
.then(setGeoData)
.catch(() => {})
getGeoData()
.then((data) => { setGeoData(data); setGeoStatus('ready') })
.catch(() => setGeoStatus('error'))
}, [])
// Close dropdown on scroll so the fixed-position list doesn't drift from the input
@@ -126,6 +125,12 @@ export function BarrioAutocompleteField({
autoComplete="off"
className="text-short-input"
ref={inputRef}
disabled={geoStatus === 'loading'}
placeholder={
geoStatus === 'loading' ? 'Cargando opciones...' :
geoStatus === 'error' ? 'No se pudieron cargar las opciones. Podés escribir el nombre manualmente.' :
undefined
}
onChange={(e) => handleInput(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => { if (suggestions.length > 0) { computeDropdownStyle(); setOpen(true) } }}
@@ -147,6 +152,7 @@ export function BarrioAutocompleteField({
aria-selected={i === activeIdx}
className={`barrio-autocomplete-item${i === activeIdx ? ' barrio-autocomplete-item--active' : ''}`}
onMouseDown={() => select(barrio)}
onTouchEnd={(e) => { e.preventDefault(); select(barrio) }}
>
{barrio}
</li>

View File

@@ -1,7 +1,6 @@
'use client'
import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
type GeoData = Record<string, Record<string, string[]>>
import { getGeoData, type GeoData } from '@/lib/geo-cache'
interface CiudadSelectFieldProps {
name: string
@@ -23,6 +22,7 @@ export function CiudadSelectField({
error,
}: CiudadSelectFieldProps) {
const [geoData, setGeoData] = useState<GeoData | null>(null)
const [geoStatus, setGeoStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [open, setOpen] = useState(false)
const [activeIdx, setActiveIdx] = useState(-1)
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({})
@@ -35,10 +35,9 @@ export function CiudadSelectField({
const prevDeptoRef = useRef(departamento)
useEffect(() => {
fetch('/data/geo_paraguay_full.json')
.then((r) => r.json())
.then(setGeoData)
.catch(() => {})
getGeoData()
.then((data) => { setGeoData(data); setGeoStatus('ready') })
.catch(() => setGeoStatus('error'))
}, [])
// Clear city when departamento changes so no orphan value remains
@@ -130,6 +129,12 @@ export function CiudadSelectField({
autoComplete="off"
className="text-short-input"
ref={inputRef}
disabled={geoStatus === 'loading'}
placeholder={
geoStatus === 'loading' ? 'Cargando opciones...' :
geoStatus === 'error' ? 'No se pudieron cargar las opciones. Podés escribir el nombre manualmente.' :
undefined
}
onChange={(e) => handleInput(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => { if (suggestions.length > 0) { computeDropdownStyle(); setOpen(true) } }}
@@ -151,6 +156,7 @@ export function CiudadSelectField({
aria-selected={i === activeIdx}
className={`barrio-autocomplete-item${i === activeIdx ? ' barrio-autocomplete-item--active' : ''}`}
onMouseDown={() => select(ciudad)}
onTouchEnd={(e) => { e.preventDefault(); select(ciudad) }}
>
{ciudad}
</li>

View File

@@ -70,7 +70,8 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
useEffect(() => {
if (!hydrated) return
saveDraft(survey.id, state.answers)
const timer = setTimeout(() => saveDraft(survey.id, state.answers), 500)
return () => clearTimeout(timer)
}, [state.answers, hydrated, survey.id])
const screens = resolveScreens(schema, state.answers)
@@ -192,10 +193,12 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
setErrors((e) => ({ ...e, [screen.question!.code]: 'Este campo es obligatorio.' }))
return
}
saveDraft(survey.id, state.answers)
setState((s) => next(s, schema))
}
function handleBack() {
saveDraft(survey.id, state.answers)
setState((s) => back(s, schema))
}
@@ -210,17 +213,24 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
})
startTransition(async () => {
const result = await submitSurvey({
surveyId: survey.id,
answers,
consentOperativo,
consentMacroN1,
})
if (result.success) {
clearDraft(survey.id)
setStage('success')
} else {
setSubmitError(result.error)
try {
const result = await submitSurvey({
surveyId: survey.id,
answers,
consentOperativo,
consentMacroN1,
})
if (result.success) {
clearDraft(survey.id)
setStage('success')
} else {
setSubmitError(result.error)
setStage('error')
}
} catch {
// Falla de red al invocar el Server Action (fetch failed) — no es un
// error que submitSurvey pueda devolver como result, hay que capturarlo acá.
setSubmitError('No pudimos enviar tu respuesta. Verificá tu conexión e intentá de nuevo.')
setStage('error')
}
})

View File

@@ -0,0 +1,971 @@
-- SEED DE PRUEBA — 50 respuestas + modalidad (2.7). Determinista seed=42.
-- E1=72.0% E2=38.9% E3=36.1% E4=41.7% · estrella umbral2=30.6% umbral1=88.9%
--
-- Resuelve los question_id por code DENTRO de un bloque DO $$ — nunca
-- hardcodeados. Antes este archivo tenía los UUIDs literales y se
-- desactualizaba cada vez que la DB se reinicializaba (gen_random_uuid()
-- en supabase/seed.sql genera UUIDs nuevos en cada corrida). Con esto,
-- el seed funciona contra cualquier instancia sin importar cuándo se
-- recreó, siempre que el catálogo de preguntas (supabase/seed.sql) ya
-- esté aplicado.
--
-- Idempotente: limpia SOLO las filas que este script genera (ip_hash con el
-- prefijo 'seed-prueba-'), no un TRUNCATE de las tablas completas — answers/
-- responses/consent_records pueden tener datos reales de otras encuestas
-- (InQuality, Fundación Solidaridad) que este script no debe tocar.
-- Único bloque DO $$ — sin statements sueltos antes/después que psql
-- pueda seguir ejecutando como filas independientes si algo de acá adentro
-- falla. Si un PERFORM revienta, todo el bloque aborta y se ve UN error,
-- no cincuenta.
DO $$
DECLARE
s_id uuid := '30000000-0000-0000-0000-000000000001';
q_2_7 uuid;
q_4_1 uuid;
q_6_1 uuid;
q_6_2 uuid;
q_6_4 uuid;
q_7_2 uuid;
q_7_3 uuid;
q_7_5 uuid;
q_7_6 uuid;
q_9_1 uuid;
q_9_2 uuid;
q_9_3 uuid;
q_9_4 uuid;
BEGIN
-- Resolver UUIDs de questions por code, en el momento de ejecutarse.
SELECT id INTO q_2_7 FROM public.questions
WHERE code = '2.7' AND survey_id = s_id;
SELECT id INTO q_4_1 FROM public.questions
WHERE code = '4.1' AND survey_id = s_id;
SELECT id INTO q_6_1 FROM public.questions
WHERE code = '6.1' AND survey_id = s_id;
SELECT id INTO q_6_2 FROM public.questions
WHERE code = '6.2' AND survey_id = s_id;
SELECT id INTO q_6_4 FROM public.questions
WHERE code = '6.4' AND survey_id = s_id;
SELECT id INTO q_7_2 FROM public.questions
WHERE code = '7.2' AND survey_id = s_id;
SELECT id INTO q_7_3 FROM public.questions
WHERE code = '7.3' AND survey_id = s_id;
SELECT id INTO q_7_5 FROM public.questions
WHERE code = '7.5' AND survey_id = s_id;
SELECT id INTO q_7_6 FROM public.questions
WHERE code = '7.6' AND survey_id = s_id;
SELECT id INTO q_9_1 FROM public.questions
WHERE code = '9.1' AND survey_id = s_id;
SELECT id INTO q_9_2 FROM public.questions
WHERE code = '9.2' AND survey_id = s_id;
SELECT id INTO q_9_3 FROM public.questions
WHERE code = '9.3' AND survey_id = s_id;
SELECT id INTO q_9_4 FROM public.questions
WHERE code = '9.4' AND survey_id = s_id;
-- Si alguna no se resolvió, frenar acá con un mensaje claro en vez de
-- dejar que rpc_submit_response falle más abajo con invalid_answer_reference.
IF q_2_7 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '2.7';
END IF;
IF q_4_1 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '4.1';
END IF;
IF q_6_1 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '6.1';
END IF;
IF q_6_2 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '6.2';
END IF;
IF q_6_4 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '6.4';
END IF;
IF q_7_2 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '7.2';
END IF;
IF q_7_3 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '7.3';
END IF;
IF q_7_5 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '7.5';
END IF;
IF q_7_6 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '7.6';
END IF;
IF q_9_1 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '9.1';
END IF;
IF q_9_2 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '9.2';
END IF;
IF q_9_3 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '9.3';
END IF;
IF q_9_4 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '9.4';
END IF;
DELETE FROM public.responses
WHERE survey_id = s_id
AND ip_hash LIKE 'seed-prueba-%';
-- answers cae por ON DELETE CASCADE (responses.id). consent_records de
-- estas filas queda huérfano a propósito — es evidencia de consentimiento
-- (ADR-003), no se borra aunque la response que lo usaba se borre.
UPDATE surveys SET status='live' WHERE id = s_id;
-- seed-prueba-000
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-000', '55 años o más', 'Auxiliar', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
)
);
-- seed-prueba-001
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-001', '35 a 44 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo adulto con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-002
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-002', '35 a 44 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Apoyo económico o convenios/descuentos', 'Contención/apoyo emocional o psicológico']::text[])
)
);
-- seed-prueba-003
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-003', '25 a 34 años', 'Gerencial', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-004
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-004', '55 años o más', 'Jefatura', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad', 'Hijo adulto con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (410 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares']::text[])
)
);
-- seed-prueba-005
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-005', '55 años o más', 'Auxiliar', 'Alto Paraná',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Acceso a beneficios para terapias o salud']::text[])
)
);
-- seed-prueba-006
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-006', '25 a 34 años', 'Auxiliar', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar', 'Hijo menor sin discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Redes de apoyo / grupos de pares', 'Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-007
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-007', '35 a 44 años', 'Auxiliar', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-008
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-008', '35 a 44 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Familiar mayor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (410 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Información sobre recursos y servicios']::text[])
)
);
-- seed-prueba-009
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-009', 'Menos de 25 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Información sobre recursos y servicios', 'Capacitación y autocuidado']::text[])
)
);
-- seed-prueba-010
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-010', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Capacitación y autocuidado', 'Contención/apoyo emocional o psicológico']::text[])
)
);
-- seed-prueba-011
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-011', '35 a 44 años', 'Auxiliar', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Dejar un trabajo anterior']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
)
);
-- seed-prueba-012
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-012', '55 años o más', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad', 'Familiar mayor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Acceso a beneficios para terapias o salud', 'Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-013
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-013', '25 a 34 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud']::text[])
)
);
-- seed-prueba-014
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-014', '35 a 44 años', 'Gerencial', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-015
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-015', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (410 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Capacitación y autocuidado', 'Flexibilidad horaria o trabajo remoto']::text[])
)
);
-- seed-prueba-016
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-016', '25 a 34 años', 'Directivo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares']::text[])
)
);
-- seed-prueba-017
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-017', '55 años o más', 'Auxiliar', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (410 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud']::text[])
)
);
-- seed-prueba-018
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-018', '45 a 54 años', 'Auxiliar', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (410 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Flexibilidad horaria o trabajo remoto']::text[])
)
);
-- seed-prueba-019
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-019', '55 años o más', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Capacitación y autocuidado']::text[])
)
);
-- seed-prueba-020
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-020', '55 años o más', 'Jefatura', 'Alto Paraná',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad', 'Familiar mayor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Capacitación y autocuidado', 'Información sobre recursos y servicios']::text[])
)
);
-- seed-prueba-021
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-021', '45 a 54 años', 'Jefatura', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Redes de apoyo / grupos de pares', 'Acceso a beneficios para terapias o salud']::text[])
)
);
-- seed-prueba-022
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-022', '45 a 54 años', 'Jefatura', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-023
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-023', '25 a 34 años', 'Operativo', 'Itapúa',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad', 'Padre/madre con movilidad reducida o 100% dependiente']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
)
);
-- seed-prueba-024
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-024', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Redes de apoyo / grupos de pares']::text[])
)
);
-- seed-prueba-025
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-025', '25 a 34 años', 'Auxiliar', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Capacitación y autocuidado']::text[])
)
);
-- seed-prueba-026
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-026', '25 a 34 años', 'Jefatura', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Rechazar un ascenso o nueva responsabilidad']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Capacitación y autocuidado', 'Contención/apoyo emocional o psicológico']::text[])
)
);
-- seed-prueba-027
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-027', '35 a 44 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Pensar seriamente en dejar el trabajo']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado']::text[])
)
);
-- seed-prueba-028
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-028', '55 años o más', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Familiar menor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Flexibilidad horaria o trabajo remoto']::text[])
)
);
-- seed-prueba-029
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-029', '55 años o más', 'Auxiliar', 'Alto Paraná',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Capacitación y autocuidado', 'Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-030
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-030', '35 a 44 años', 'Operativo', 'Alto Paraná',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado']::text[])
)
);
-- seed-prueba-031
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-031', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo adulto con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Dejar un trabajo anterior']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Contención/apoyo emocional o psicológico']::text[])
)
);
-- seed-prueba-032
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-032', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos', 'Contención/apoyo emocional o psicológico']::text[])
)
);
-- seed-prueba-033
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-033', 'Menos de 25 años', 'Auxiliar', 'Alto Paraná',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Hijo adulto con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (410 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-034
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-034', '55 años o más', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad', 'Hijo menor sin discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Pensar seriamente en dejar el trabajo']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Redes de apoyo / grupos de pares']::text[])
)
);
-- seed-prueba-035
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-035', '45 a 54 años', 'Jefatura', 'Itapúa',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Capacitación y autocuidado']::text[])
)
);
-- seed-prueba-036
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-036', '25 a 34 años', 'Jefatura', 'Itapúa',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
)
);
-- seed-prueba-037
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-037', '35 a 44 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
)
);
-- seed-prueba-038
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-038', '35 a 44 años', 'Auxiliar', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
)
);
-- seed-prueba-039
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-039', '35 a 44 años', 'Auxiliar', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
)
);
-- seed-prueba-040
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-040', '45 a 54 años', 'Auxiliar', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
)
);
-- seed-prueba-041
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-041', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
)
);
-- seed-prueba-042
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-042', '25 a 34 años', 'Jefatura', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
)
);
-- seed-prueba-043
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-043', 'Menos de 25 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
)
);
-- seed-prueba-044
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-044', '55 años o más', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
)
);
-- seed-prueba-045
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-045', '25 a 34 años', 'Auxiliar', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
)
);
-- seed-prueba-046
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-046', '25 a 34 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
)
);
-- seed-prueba-047
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-047', '55 años o más', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
)
);
-- seed-prueba-048
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-048', '55 años o más', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
)
);
-- seed-prueba-049
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-049', '35 a 44 años', 'Auxiliar', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
)
);
-- Survey queda en 'live' (ya lo estaba desde el UPDATE de arriba; este
-- segundo UPDATE es explícito para que quede claro al releer el script,
-- no depende de que nada de arriba lo haya dejado sin tocar).
UPDATE surveys SET status='live' WHERE id = s_id;
END $$;
-- Para limpiar manualmente este lote de prueba sin tocar otras encuestas:
-- DELETE FROM public.responses WHERE survey_id = '30000000-0000-0000-0000-000000000001' AND ip_hash LIKE 'seed-prueba-%';

View File

@@ -0,0 +1,975 @@
-- SEED DE PRUEBA — Fundación Solidaridad — 50 respuestas + modalidad (2.7).
-- Clon de docs/dashboard/seed_prueba_50.sql (mismo patrón, mismas distribuciones
-- determinísticas seed=42) apuntado a survey_id de Fundación Solidaridad
-- ('30000000-0000-0000-0000-000000000003') en vez de Empresa Demo.
-- DATOS SINTÉTICOS — no son respuestas reales de cuidadores de la Fundación.
-- E1=72.0% E2=38.9% E3=36.1% E4=41.7% · estrella umbral2=30.6% umbral1=88.9%
--
-- Resuelve los question_id por code DENTRO de un bloque DO $$ — nunca
-- hardcodeados. Antes este archivo tenía los UUIDs literales y se
-- desactualizaba cada vez que la DB se reinicializaba (gen_random_uuid()
-- en supabase/seed.sql genera UUIDs nuevos en cada corrida). Con esto,
-- el seed funciona contra cualquier instancia sin importar cuándo se
-- recreó, siempre que el catálogo de preguntas (supabase/seed.sql) ya
-- esté aplicado.
--
-- Idempotente: limpia SOLO las filas que este script genera (ip_hash con el
-- prefijo 'seed-prueba-fs-'), no un TRUNCATE de las tablas completas — answers/
-- responses/consent_records pueden tener datos reales de otras encuestas
-- (Empresa Demo, InQuality) que este script no debe tocar.
-- Único bloque DO $$ — sin statements sueltos antes/después que psql
-- pueda seguir ejecutando como filas independientes si algo de acá adentro
-- falla. Si un PERFORM revienta, todo el bloque aborta y se ve UN error,
-- no cincuenta.
DO $$
DECLARE
s_id uuid := '30000000-0000-0000-0000-000000000003';
q_2_7 uuid;
q_4_1 uuid;
q_6_1 uuid;
q_6_2 uuid;
q_6_4 uuid;
q_7_2 uuid;
q_7_3 uuid;
q_7_5 uuid;
q_7_6 uuid;
q_9_1 uuid;
q_9_2 uuid;
q_9_3 uuid;
q_9_4 uuid;
BEGIN
-- Resolver UUIDs de questions por code, en el momento de ejecutarse.
SELECT id INTO q_2_7 FROM public.questions
WHERE code = '2.7' AND survey_id = s_id;
SELECT id INTO q_4_1 FROM public.questions
WHERE code = '4.1' AND survey_id = s_id;
SELECT id INTO q_6_1 FROM public.questions
WHERE code = '6.1' AND survey_id = s_id;
SELECT id INTO q_6_2 FROM public.questions
WHERE code = '6.2' AND survey_id = s_id;
SELECT id INTO q_6_4 FROM public.questions
WHERE code = '6.4' AND survey_id = s_id;
SELECT id INTO q_7_2 FROM public.questions
WHERE code = '7.2' AND survey_id = s_id;
SELECT id INTO q_7_3 FROM public.questions
WHERE code = '7.3' AND survey_id = s_id;
SELECT id INTO q_7_5 FROM public.questions
WHERE code = '7.5' AND survey_id = s_id;
SELECT id INTO q_7_6 FROM public.questions
WHERE code = '7.6' AND survey_id = s_id;
SELECT id INTO q_9_1 FROM public.questions
WHERE code = '9.1' AND survey_id = s_id;
SELECT id INTO q_9_2 FROM public.questions
WHERE code = '9.2' AND survey_id = s_id;
SELECT id INTO q_9_3 FROM public.questions
WHERE code = '9.3' AND survey_id = s_id;
SELECT id INTO q_9_4 FROM public.questions
WHERE code = '9.4' AND survey_id = s_id;
-- Si alguna no se resolvió, frenar acá con un mensaje claro en vez de
-- dejar que rpc_submit_response falle más abajo con invalid_answer_reference.
IF q_2_7 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '2.7';
END IF;
IF q_4_1 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '4.1';
END IF;
IF q_6_1 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '6.1';
END IF;
IF q_6_2 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '6.2';
END IF;
IF q_6_4 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '6.4';
END IF;
IF q_7_2 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '7.2';
END IF;
IF q_7_3 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '7.3';
END IF;
IF q_7_5 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '7.5';
END IF;
IF q_7_6 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '7.6';
END IF;
IF q_9_1 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '9.1';
END IF;
IF q_9_2 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '9.2';
END IF;
IF q_9_3 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '9.3';
END IF;
IF q_9_4 IS NULL THEN
RAISE EXCEPTION 'UUID no encontrado para code %', '9.4';
END IF;
DELETE FROM public.responses
WHERE survey_id = s_id
AND ip_hash LIKE 'seed-prueba-fs-%';
-- answers cae por ON DELETE CASCADE (responses.id). consent_records de
-- estas filas queda huérfano a propósito — es evidencia de consentimiento
-- (ADR-003), no se borra aunque la response que lo usaba se borre.
UPDATE surveys SET status='live' WHERE id = s_id;
-- seed-prueba-fs-000
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-000', '55 años o más', 'Auxiliar', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
)
);
-- seed-prueba-fs-001
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-001', '35 a 44 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo adulto con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-fs-002
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-002', '35 a 44 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Apoyo económico o convenios/descuentos', 'Contención/apoyo emocional o psicológico']::text[])
)
);
-- seed-prueba-fs-003
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-003', '25 a 34 años', 'Gerencial', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-fs-004
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-004', '55 años o más', 'Jefatura', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad', 'Hijo adulto con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (410 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares']::text[])
)
);
-- seed-prueba-fs-005
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-005', '55 años o más', 'Auxiliar', 'Alto Paraná',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Acceso a beneficios para terapias o salud']::text[])
)
);
-- seed-prueba-fs-006
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-006', '25 a 34 años', 'Auxiliar', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar', 'Hijo menor sin discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Redes de apoyo / grupos de pares', 'Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-fs-007
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-007', '35 a 44 años', 'Auxiliar', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-fs-008
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-008', '35 a 44 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Familiar mayor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (410 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Información sobre recursos y servicios']::text[])
)
);
-- seed-prueba-fs-009
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-009', 'Menos de 25 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Información sobre recursos y servicios', 'Capacitación y autocuidado']::text[])
)
);
-- seed-prueba-fs-010
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-010', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Capacitación y autocuidado', 'Contención/apoyo emocional o psicológico']::text[])
)
);
-- seed-prueba-fs-011
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-011', '35 a 44 años', 'Auxiliar', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Dejar un trabajo anterior']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
)
);
-- seed-prueba-fs-012
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-012', '55 años o más', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad', 'Familiar mayor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Acceso a beneficios para terapias o salud', 'Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-fs-013
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-013', '25 a 34 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud']::text[])
)
);
-- seed-prueba-fs-014
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-014', '35 a 44 años', 'Gerencial', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-fs-015
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-015', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (410 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Capacitación y autocuidado', 'Flexibilidad horaria o trabajo remoto']::text[])
)
);
-- seed-prueba-fs-016
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-016', '25 a 34 años', 'Directivo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares']::text[])
)
);
-- seed-prueba-fs-017
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-017', '55 años o más', 'Auxiliar', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (410 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud']::text[])
)
);
-- seed-prueba-fs-018
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-018', '45 a 54 años', 'Auxiliar', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (410 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Flexibilidad horaria o trabajo remoto']::text[])
)
);
-- seed-prueba-fs-019
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-019', '55 años o más', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Capacitación y autocuidado']::text[])
)
);
-- seed-prueba-fs-020
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-020', '55 años o más', 'Jefatura', 'Alto Paraná',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad', 'Familiar mayor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Capacitación y autocuidado', 'Información sobre recursos y servicios']::text[])
)
);
-- seed-prueba-fs-021
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-021', '45 a 54 años', 'Jefatura', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Redes de apoyo / grupos de pares', 'Acceso a beneficios para terapias o salud']::text[])
)
);
-- seed-prueba-fs-022
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-022', '45 a 54 años', 'Jefatura', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-fs-023
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-023', '25 a 34 años', 'Operativo', 'Itapúa',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad', 'Padre/madre con movilidad reducida o 100% dependiente']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
)
);
-- seed-prueba-fs-024
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-024', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Redes de apoyo / grupos de pares']::text[])
)
);
-- seed-prueba-fs-025
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-025', '25 a 34 años', 'Auxiliar', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Capacitación y autocuidado']::text[])
)
);
-- seed-prueba-fs-026
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-026', '25 a 34 años', 'Jefatura', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Rechazar un ascenso o nueva responsabilidad']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Capacitación y autocuidado', 'Contención/apoyo emocional o psicológico']::text[])
)
);
-- seed-prueba-fs-027
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-027', '35 a 44 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Pensar seriamente en dejar el trabajo']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado']::text[])
)
);
-- seed-prueba-fs-028
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-028', '55 años o más', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Familiar menor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Flexibilidad horaria o trabajo remoto']::text[])
)
);
-- seed-prueba-fs-029
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-029', '55 años o más', 'Auxiliar', 'Alto Paraná',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Capacitación y autocuidado', 'Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-fs-030
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-030', '35 a 44 años', 'Operativo', 'Alto Paraná',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado']::text[])
)
);
-- seed-prueba-fs-031
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-031', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo adulto con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Dejar un trabajo anterior']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Contención/apoyo emocional o psicológico']::text[])
)
);
-- seed-prueba-fs-032
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-032', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (13 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos', 'Contención/apoyo emocional o psicológico']::text[])
)
);
-- seed-prueba-fs-033
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-033', 'Menos de 25 años', 'Auxiliar', 'Alto Paraná',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Hijo adulto con discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (410 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
)
);
-- seed-prueba-fs-034
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-034', '55 años o más', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad', 'Hijo menor sin discapacidad']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Pensar seriamente en dejar el trabajo']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Redes de apoyo / grupos de pares']::text[])
)
);
-- seed-prueba-fs-035
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-035', '45 a 54 años', 'Jefatura', 'Itapúa',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', ''),
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Capacitación y autocuidado']::text[])
)
);
-- seed-prueba-fs-036
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-036', '25 a 34 años', 'Jefatura', 'Itapúa',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
)
);
-- seed-prueba-fs-037
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-037', '35 a 44 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
)
);
-- seed-prueba-fs-038
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-038', '35 a 44 años', 'Auxiliar', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
)
);
-- seed-prueba-fs-039
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-039', '35 a 44 años', 'Auxiliar', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
)
);
-- seed-prueba-fs-040
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-040', '45 a 54 años', 'Auxiliar', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
)
);
-- seed-prueba-fs-041
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-041', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
)
);
-- seed-prueba-fs-042
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-042', '25 a 34 años', 'Jefatura', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
)
);
-- seed-prueba-fs-043
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-043', 'Menos de 25 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
)
);
-- seed-prueba-fs-044
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-044', '55 años o más', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
)
);
-- seed-prueba-fs-045
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-045', '25 a 34 años', 'Auxiliar', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
)
);
-- seed-prueba-fs-046
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-046', '25 a 34 años', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
)
);
-- seed-prueba-fs-047
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-047', '55 años o más', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
)
);
-- seed-prueba-fs-048
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-048', '55 años o más', 'Operativo', 'Central',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
)
);
-- seed-prueba-fs-049
PERFORM public.rpc_submit_response(
s_id, 'seed-prueba-fs-049', '35 a 44 años', 'Auxiliar', 'Asunción (Capital)',
true, false,
jsonb_build_array(
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
)
);
-- Survey queda en 'live' (ya lo estaba desde el UPDATE de arriba; este
-- segundo UPDATE es explícito para que quede claro al releer el script,
-- no depende de que nada de arriba lo haya dejado sin tocar).
UPDATE surveys SET status='live' WHERE id = s_id;
END $$;
-- Para limpiar manualmente este lote de prueba sin tocar otras encuestas:
-- DELETE FROM public.responses WHERE survey_id = '30000000-0000-0000-0000-000000000003' AND ip_hash LIKE 'seed-prueba-fs-%';

View File

@@ -0,0 +1,169 @@
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado
## Documento de continuidad — 26 junio 2026
> Director (Product Owner): Marcos · Claude (Projects) = arquitecto/copiloto · Claude Code = implementación
> Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
---
## 1. Versionado y ramas
- **Producción:** **3.0.0** — wizard v3 + dashboard tres miradas desplegado · tag `v3.0.0` · commit `fcf93ea`
- **Preservación:** `v2.1.0-stable` + tag `v2.1.0`
- **3 surveys en `live`** — reactivados al desplegar v3 (26 jun 2026)
- SemVer + CHANGELOG + tags. ADRs en `docs/adr/`.
---
## 2. Sprint 4 — Wizard v3 — COMPLETO Y EN PRODUCCIÓN
| Etapa | Estado |
|---|---|
| 4.0 Preservación v2 | ✅ |
| 4A instance_id | ✅ |
| 4B esquema declarativo + seed v3 | ✅ |
| 4C motor de flujo (`lib/form-engine.ts`, puro, 20+ tests) | ✅ |
| 4D wizard renderer (layout estable, header/footer fijos) | ✅ |
| 4E instancias familiares (mini-wizard emergente del motor) | ✅ |
| 4F autocomplete barrios + filtro ciudad por departamento | ✅ |
| 4G submit + validación server-side (RPC `rpc_submit_response`) | ✅ |
| 4I validación e2e + tag v3.0.0 + deploy + surveys live | ✅ |
### Decisiones arquitectónicas clave (no reabrir)
- **ADR-005:** lógica de flujo en datos (esquema), nunca en frontend.
- Motor puro en cliente; reusado server-side para validación de submit.
- Único camino de escritura: RPC `rpc_submit_response` (SECURITY DEFINER).
- `instance_id` por answer — instancias familiares persistidas correctamente.
- Validación server-side: 7 checks (preguntas visibles, valores válidos, instancias legítimas,
consentimiento, requeridas presentes). Rechaza el submit completo si algo falla.
### Autocomplete geo (4F)
- `public/data/geo_paraguay_full.json` — 8152 barrios, jerarquía depto → ciudad → barrios.
- Pregunta 2.3 (barrio): autocomplete con `position: fixed` + `getBoundingClientRect()`.
- Pregunta 2.2 (ciudad): filtra por departamento (2.1) usando el mismo JSON. Limpia valor
al cambiar departamento. Texto libre siempre permitido como fallback.
---
## 3. Dashboard v3 — Mirada Empresa en producción
**Decisión vigente:** NO migrar RPCs v2. Dashboard nuevo de tres miradas
(OIT 5R, CEPAL, PNCUPA Paraguay).
### Estado
- **Mirada Empresa:** ✅ COMPLETA EN PRODUCCIÓN
- 11 RPCs (`rpc_me_*`) + helper `_dash_es_cuidador` verificados contra 50 respuestas reales.
- 9 widgets funcionando: Talento en riesgo, Prevalencia, Punto ciego, Señales de impacto
(presentismo 41.7%, ausentismo 36.1%, riesgo 38.9%, carrera congelada 47.2%),
Apoyos demandados (ordenados por demanda), Segmentación (nivel y modalidad),
Disposición a red de apoyo.
- Tema claro marca Re: fondo `#E7E6E5`, cards `#FFFFFF`, texto `#494F4F`.
- Migraciones aplicadas: `20260625000001` (fix `_dash_responses`) +
`20260625000002` (11 RPCs) + `20260626000001` (fix bug `cnt` Opción B).
- **Mirada País y Mirada Humana:** propuesta + mockup listos, implementación DIFERIDA
a sprint de dashboard post-deadline.
### Bug conocido abierto
- `rpc_me_carrera_congelada`: "Ninguna de las anteriores" aparece en `top_opciones`
a pesar del filtro en el subquery. El filtro opera sobre el array completo, no sobre
los elementos individuales del `jsonb_array_elements_text`. No bloqueante para el
release — registrado para próximo sprint.
### Documentos (en `docs/dashboard/`)
- `PROPUESTA_DASHBOARD_v3.md` — las 3 miradas, ~30 métricas, marcos citados
- `dashboard_tres_miradas_mockup.html` — mockup funcional 3 pestañas
- `SPEC_MIRADA_EMPRESA.md` — contrato técnico 10 métricas + estrella
### Decisiones del dashboard (no reabrir)
- CUIDADOR_ACTIVO incluye "hijo menor sin discapacidad" (criterio UNFPA, validado por Maria jun 2026).
- Mirada Empresa NO expone datos sensibles (3.x, 4.2, 4.3).
- k-anonimato k≥5 sobre `count(DISTINCT response_id)` — también en métricas por instancia.
- 2 gaps de datos para sprint dashboard: horas cuidado no remunerado (País),
escolarización niñez (Humana).
---
## 4. Datos de prueba en VPS
- **51 respuestas** en survey Demo (`30000000-0000-0000-0000-000000000001`) — 50 del seed
determinista (seed=42) + 1 del e2e de 4I.
- Survey Demo en `live`. Datos NO limpiados — el director los necesita para mostrar al equipo.
- Script de limpieza cuando corresponda:
```bash
docker exec supabase-db psql -U postgres -d postgres -c "
TRUNCATE answers, responses, consent_records CASCADE;
UPDATE surveys SET status='draft' WHERE id='30000000-0000-0000-0000-000000000001';"
```
- ⚠️ El seed corrió dos veces en sesión anterior (detectado y corregido). Agregar guard
anti-duplicación al `seed_prueba_50.sql` — anotado en BACKLOG.
---
## 5. Deuda técnica y seguridad
| ID | Descripción | Prioridad |
|---|---|---|
| BUG-001 | "Ninguna de las anteriores" aparece en `rpc_me_carrera_congelada` | Próximo sprint dashboard |
| TECH-DEBT-006 | Backup remoto (S3 o VPS alternativo o Google Drive) | Antes de datos reales no desechables |
| TECH-DEBT-009 | Rotar secrets post-CVE | Antes de datos reales no desechables |
| TECH-DEBT-010 | E9 lee modalidad por JOIN a answers (2.7) en vez de `qi_modalidad` | Baja |
| SEC-002 | Sprint compliance (audit log, hashing, cifrado en reposo) | Sprint dedicado |
| SEC-003 | TRUNCATE evade inmutabilidad ADR-003 | Antes de datos reales no desechables |
| SEC-004 | RPC bypass REST (coherencia de flujo en Server Action) | Sprint futuro |
| BACKLOG-001 | `seed_prueba_50.sql` idempotente (guard anti-duplicación) | Baja |
| BACKLOG-002 | Fase 2: motor genérico de formularios | Post-deadline |
**Riesgo aceptado explícitamente (26 jun 2026):** TECH-DEBT-006, TECH-DEBT-009 y SEC-003
aplican cuando haya datos reales no desechables. Los datos actuales son de empresas voluntarias
y grupos de control — desechables si es necesario.
---
## 6. Infraestructura
- Next.js 15.3.8 · Supabase self-hosted · Dokploy v0.29.4 · VPS inq-mart-2 (172.20.5.11)
- App: `economia-cuidado.inqualityhq.com` · API: `db.inqualityhq.com` (Kong + SSL)
- Repo: `git.inquality.com.py/marcos/motor-de-encuestas` (rama `main`)
- Repo local: `/Users/marcos/InQ/Motor de encuestas`
- Migraciones SIEMPRE vía CLI del VPS:
`docker exec -i supabase-db psql -U postgres -d postgres < archivo.sql`
- Deploy: Dokploy → Rebuild + "Clean cache"
- Anon key Kong (diagnóstico):
`eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjIwMDAwMDAwMDB9.6D79NWu1KfeOzEZXT6Amf-NmG0T9uk8SK9xNd32oLaY`
---
## 7. Contexto comercial
- 2 clientes inminentes. Mirada Empresa es la que le habla al cliente que paga.
- InQuality y Fundación Solidaridad en joint venture.
- Datos actuales: empresas voluntarias y grupos de control (desechables).
- Datos reales no desechables: avisará el director — en ese momento activar deuda de seguridad.
---
## 8. Próximos sprints sugeridos
1. **Sprint dashboard** — Miradas País y Humana, fix BUG-001 carrera congelada,
preguntas faltantes (horas no remuneradas, escolarización), decisión `qi_modalidad`.
2. **Sprint compliance** — SEC-002 (audit log, hashing, cifrado), SEC-003, TECH-DEBT-006/009.
3. **Sprint motor genérico** — BACKLOG-002 Fase 2, extracción del intérprete.
---
## 9. Prompt para nueva sesión
```
Sos el arquitecto/copiloto técnico de "Datos País sobre el Cuidado", motor de formularios
soberano (Paraguay). Yo soy el product owner (Marcos).
Claude Code (VS Code, contexto separado) hace la implementación; vos planificás,
especificás y auditás.
Estado completo: docs/estado/ESTADO_PROYECTO_26JUN2026.md
Producción: v3.0.0 desplegada · 3 surveys en live · tag v3.0.0 (commit fcf93ea)
Formulario v3: COMPLETO (motor, wizard, instancias, submit server-side, autocomplete geo)
Dashboard: Mirada Empresa funcional en producción · País/Humana diferidas
Datos en VPS: 51 respuestas en survey Demo (no limpiar — para mostrar al equipo)
Bug abierto: "Ninguna de las anteriores" en rpc_me_carrera_congelada (no bloqueante)
Próximo paso: [COMPLETAR]
```

View File

@@ -1,5 +1,5 @@
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado
## Documento de continuidad — 25 junio 2026
## Documento de continuidad — 27 junio 2026
> Director (Product Owner): Marcos · Claude (Projects) = arquitecto/copiloto · Claude Code = implementación
> Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
@@ -7,17 +7,14 @@
---
## 1. Versionado y ramas
- **Producción:** 2.1.0 (formulario lineal) — los 3 surveys en `draft`
- **Desarrollo:** 3.0.0 (wizard + dashboard nuevo) en `main`, NO desplegado aún
- **Producción:** 3.0.0 (wizard + dashboard tres miradas) — tag `v3.0.0` pusheado, los 3 surveys
en `live`. Release v3 completado (etapa 4I).
- **Preservación:** `v2.1.0-stable` + tag `v2.1.0`
- SemVer + CHANGELOG + tags. ADRs en `docs/adr/`.
⚠️ Los 3 surveys están en `draft` a propósito (el frontend v2 desplegado no entiende seed v3).
Se reactivan a `live` al desplegar v3 completo.
---
## 2. Sprint 4 — Wizard v3 — Formulario COMPLETO, falta release
## 2. Sprint 4 — Wizard v3 — COMPLETO, release hecho
| Etapa | Estado |
|---|---|
@@ -28,26 +25,41 @@ Se reactivan a `live` al desplegar v3 completo.
| 4D wizard renderer (layout estable) | ✅ |
| 4E instancias (emergente del motor) | ✅ |
| 4G submit + validación server-side (RPC transaccional) | ✅ verificado vs Postgres real |
| 4F autocomplete barrios (UX) | ⏳ pendiente |
| 4I validación e2e + tag v3.0.0 + desplegar + reactivar surveys | ⏳ pendiente |
| 4F autocomplete barrios (UX) | |
| Widgets Mirada Empresa (frontend) | ✅ |
| Tema claro dashboard (marca Re) | ✅ |
| 4I validación e2e + tag v3.0.0 + desplegar + reactivar surveys | ✅ |
| ETAPA_AUTH — login/middleware/sign-out para `/admin` y `/dashboard` | ✅ (27 jun) |
El formulario v3 funciona end-to-end: motor, wizard, instancias, submit con validación
server-side (RPC `rpc_submit_response`, único camino de escritura, verificado).
server-side (RPC `rpc_submit_response`, único camino de escritura, verificado). Dashboard
Mirada Empresa con sus 13 widgets, consumiendo las RPCs `rpc_me_*` reales. `/admin/responses`
y `/dashboard` ahora requieren sesión autenticada (antes sin auth — cierra TECH-DEBT-008).
---
## 3. Dashboard v3 — DECISIÓN MAYOR: rediseño de "tres miradas"
## 2.1 Incidente VPS (27 jun) — recuperado
Una segunda instancia de Dokploy desplegada desde el mismo repo (con `container_name` fijos en
docker-compose.yml) colisionó con la producción y vació el esquema `public` de `supabase-db`.
**Sin pérdida de datos reales** (solo seed demo). Se restauró: migraciones completas + seed +
surveys reactivados a `live` + usuario `platform_admin` creado. Verificado end-to-end: login,
sign-out, redirect sin sesión, formulario público (3 links `?s=<survey_id>`) — todo OK.
Detalle completo en memoria `project_vps_container_name_incident`. El director ya separó la
segunda instancia a un repo Gitea propio sin `container_name` fijos, para que no se repita.
---
## 3. Dashboard v3 — "tres miradas"
**Decisión:** NO migrar las RPCs v2 (referencian códigos inexistentes en v3). En su lugar,
dashboard nuevo de tres miradas, fundamentado en marcos reales (OIT 5R, CEPAL, PNCUPA Paraguay).
### Estado
- **Mirada Empresa:** ✅ RPCs implementadas y VERIFICADAS contra 50 respuestas reales.
E1-E4 exactos, estrella "Talento en riesgo" con umbral parametrizable, E9 segmentación
con k≥5. Migraciones `20260625000001` (fix helper) + `20260625000002` (11 RPCs). Commiteado.
Widgets del frontend: PENDIENTE (etapa siguiente).
- **Mirada Empresa:** ✅ RPCs + widgets de frontend completos y en producción. E1-E10 + estrella
"Talento en riesgo", k≥5 en segmentación. Tema claro aplicado.
- **Mirada País y Mirada Humana:** propuesta + mockup listos, implementación DIFERIDA a
sprint de dashboard post-deadline.
sprint de dashboard post-deadline (sin cambios desde el último estado).
### Documentos (en docs/dashboard/)
- `PROPUESTA_DASHBOARD_v3.md` — las 3 miradas, ~30 métricas, marcos citados honestamente
@@ -65,16 +77,25 @@ dashboard nuevo de tres miradas, fundamentado en marcos reales (OIT 5R, CEPAL, P
---
## 4. Deuda técnica y seguridad
~~TECH-DEBT-008 (`/admin/responses` sin autenticación)~~**resuelto** por ETAPA_AUTH (27 jun).
| ID | Descripción | Prioridad |
|---|---|---|
| TECH-DEBT-006 | Backup remoto S3 | Antes de datos reales |
| TECH-DEBT-009 | Rotar secrets post-CVE | Antes de datos reales |
| TECH-DEBT-010 | E9 lee modalidad por JOIN a answers (2.7) en vez de qi_modalidad. Promover si se usa seguido | Baja |
| SEC-001 | Next.js — vuln "Cache Key Confusion" vigente en 15.3.8; última mayor es 16.2.9 (breaking) | Media |
| TECH-001 | `npm audit`: 4 vulns (1 moderate, 3 high) — `form-data`, `next`, `undici` | Pendiente revisión |
| SEC-002 | Sprint compliance (audit log, hashing, cifrado en reposo) | Sprint dedicado |
| SEC-003 | Agujero TRUNCATE evade ADR-003 | Antes de datos reales |
| SEC-004 | RPC validable por bypass REST (coherencia de flujo en Server Action) | Sprint futuro |
| BACKLOG-002 | Fase 2: motor genérico | Post-deadline |
*(SEC-001/TECH-001 verificados 27 jun vía `npm view next version` / `npm audit --json`
los números en BACKLOG.md/TECH_DEBT.md estaban desactualizados, ej. citaban Next 15.3.3
y "2 vulnerabilidades" cuando ya corre 15.3.8 con 4.)*
---
## 5. Infraestructura
@@ -87,14 +108,19 @@ dashboard nuevo de tres miradas, fundamentado en marcos reales (OIT 5R, CEPAL, P
---
## 6. Próximos pasos
1. **Widgets de Mirada Empresa** — el frontend del dashboard que consume las RPCs ya verificadas.
La spec (SPEC_MIRADA_EMPRESA.md) define shapes de retorno; el mockup define el aspecto.
2. **4F autocomplete barrios** — subir `geo_paraguay_full.json` (8152 barrios) a public/data/.
3. **4I release** — validación e2e, tag v3.0.0, desplegar v3, reactivar surveys a live.
4. **Sprint de dashboard (post-deadline):** Miradas País y Humana, preguntas faltantes
(bienestar, horas no remuneradas, escolarización), decisión qi_modalidad.
Orden sugerido para go-live: widgets Mirada Empresa → 4F → 4I.
**Go-live (widgets → 4F → 4I) está cerrado.** v3.0.0 en producción, surveys `live`, auth para
staff funcionando. No queda ninguna etapa de Sprint 4 pendiente — el roadmap pre-escrito se
agotó. El próximo paso requiere decisión del director entre:
1. **Sprint de dashboard (post-deadline):** Miradas País y Humana, preguntas faltantes
(bienestar, horas no remuneradas, escolarización), decisión qi_modalidad (TECH-DEBT-010).
2. **Sprint de compliance/seguridad (SEC-002):** audit log append-only, hashing de integridad,
cifrado en reposo — bloquea escalar a más empresas/datos reales.
3. **Mantenimiento de dependencias:** SEC-001 (Next.js) + TECH-001 (`npm audit`, 4 vulns).
4. Otro ítem de BACKLOG.md/TECH_DEBT.md no listado arriba.
Ninguno de estos se decide unilateralmente (Nivel 3 — tocan planificación/seguridad).
---

View File

@@ -0,0 +1,142 @@
# Guía — Probar el formulario y leer el dashboard
## Datos País sobre el Cuidado
> Dos públicos en un documento: **Sección 1** es para el equipo técnico que va a
> probar el formulario. **Sección 2** es para Maria Mersan (metodología) — cómo
> leer el dashboard de Mirada Empresa.
---
## Sección 1 — Plan de pruebas del formulario (equipo técnico)
### El link
```
https://economia-cuidado.inqualityhq.com/?s=30000000-0000-0000-0000-000000000001
```
Esta es la encuesta **"Empresa Demo S.A."** — un sandbox pensado exactamente para
esto. **Pueden responderla las veces que quieran.** No es la encuesta real de
ningún cliente: nada de lo que carguen ahí se mezcla con datos reales.
> ⚠️ No usen el link de Fundación Solidaridad ni el de InQuality para pruebas.
> El sistema es **anónimo por diseño** (no guarda qué respuesta es de quién), así
> que una respuesta de prueba enviada a una encuesta real queda mezclada para
> siempre con datos reales, sin forma de distinguirla ni borrarla después.
### Qué van a ver al entrar
1. **Pantalla de consentimiento** — checkbox operativo (obligatorio) + checkbox de
estudio país (opcional). Sin esto no se entra al formulario.
2. **El wizard** — 10 secciones, 40 preguntas. La mayoría son **obligatorias**
(recién activado) excepto la pregunta de barrio (2.3), que es texto libre
opcional.
3. **Pantalla final** — botón "Enviar encuesta".
### Checklist de cosas a probar
- [ ] Completar el formulario entero y enviarlo — confirmar que llega la pantalla
de agradecimiento.
- [ ] Intentar avanzar sin responder una pregunta obligatoria — debe bloquear con
mensaje "Este campo es obligatorio."
- [ ] Dejar el campo de barrio (2.3) vacío y avanzar — **no** debe bloquear (es
opcional).
- [ ] Escribir un barrio que no exista en la lista — debe aceptarse como texto
libre, sin error.
- [ ] Seleccionar Departamento → confirmar que Ciudad se filtra por ese
departamento.
- [ ] Ir hacia atrás con "Anterior" y adelante de nuevo — los valores que ya
cargaron deben seguir ahí.
- [ ] Cerrar la pestaña a mitad de formulario, volver a abrir el link — debe
ofrecer retomar donde quedaron (guardado automático).
- [ ] Probar en el celular (o achicando la ventana del navegador) — los botones y
el texto no se deben cortar ni superponer.
- [ ] Si pueden, prueben con el wifi cortado a mitad de un envío — debe mostrar
un mensaje de error claro, no una pantalla rota.
Cualquier cosa rara (texto que no carga, botón que no responde, algo que se ve
mal en el celular) — repórtenlo con captura de pantalla y en qué pregunta pasó.
### Dónde ver lo que enviaron
- **Lista de respuestas:** `/admin/responses` (filtro por encuesta "Empresa Demo
S.A."). Necesita login.
- **Dashboard:** `/dashboard?survey_id=30000000-0000-0000-0000-000000000001`
(ver Sección 2 para cómo leerlo). Con pocas respuestas de prueba, varios
números van a aparecer suprimidos — es el umbral de privacidad k≥5 funcionando
como debe (ver más abajo), no un bug.
---
## Sección 2 — Cómo leer el dashboard (para Maria)
### Acceso
`https://economia-cuidado.inqualityhq.com/dashboard` — requiere login.
**Importante sobre roles, antes de entrar:** hoy el sistema tiene dos roles
posibles — `platform_admin` (ve todas las organizaciones, incluido el selector
"Todos") y `org_admin` (ve solo la organización a la que está asociado). El
flujo de invitación que ya existe (`/admin/usuarios`) **solo puede crear
usuarios `org_admin`** — no hay forma, hoy, de autoinvitarse como
`platform_admin`. Si necesitás ver **varias** organizaciones desde tu propia
cuenta (no solo Fundación Solidaridad), avisale a Marcos para que te dé acceso
`platform_admin` directo en la base — el self-service todavía no lo cubre.
### Qué mirar primero
Arriba del dashboard hay un selector de organización. Para ver los datos que
acabamos de cargar:
```
/dashboard?survey_id=30000000-0000-0000-0000-000000000003
```
(o click en "Fundación Solidaridad" en la barra superior).
> 🧪 **Estos son datos sintéticos, no respuestas reales de cuidadores de la
> Fundación.** Se generaron para que el dashboard tenga algo que mostrar
> mientras se valida el diseño — no son representativos de la población real
> de la Fundación todavía. Cuando empecemos a recolectar datos reales, te
> avisamos explícitamente (así lo acordamos con Marcos).
Las pestañas **Mirada País** y **Mirada Humana** todavía no están construidas
(dicen "Disponible próximamente") — hoy solo **Mirada Empresa** tiene datos.
### Los 7 indicadores de Mirada Empresa
Cada tarjeta está anclada a una o más preguntas del instrumento (el número de
pregunta aparece como subtítulo en cada tarjeta, para que puedan rastrear de
dónde sale el número).
| Tarjeta (como aparece en pantalla) | Pregunta(s) | Qué responde | Cómo leerla |
|---|---|---|---|
| **Talento en riesgo** | 7.6 + 7.3 + 7.5 | Métrica compuesta: % de la plantilla cuidadora con señales combinadas de fuga (probabilidad de irse/reducir + ausentismo + carrera frenada). | Es el número "traductor a negocio" — cuanto más alto, más costo de reemplazo en riesgo. Pensado para abrir la conversación con RRHH/dirección, no como diagnóstico aislado. |
| **Cuidadores en la plantilla** | 4.1 | % de empleados con algún vínculo de cuidado activo (incluye hijo/a menor sin discapacidad, criterio UNFPA). | Es la prevalencia base — el "cuánta gente de mi plantilla está en esto" antes de mirar cualquier otro indicador. |
| **Punto ciego organizacional** | 9.1 vs. 4.1 | Brecha entre la carga real de cuidado (prevalencia) y la percepción de que la empresa lo nota/apoya. | Si la brecha es grande, hay gente cuidando sin que la organización lo sepa — es el hallazgo central que justifica el dashboard (ver marco conceptual en `docs/dashboard/PROPUESTA_DASHBOARD_v3.md`). |
| **Señales de impacto laboral** | 7.2, 7.3, 7.6, 7.5 | Desglose de presentismo, ausentismo, probabilidad de salida y carrera frenada — los componentes de "Talento en riesgo" mostrados por separado. | Útil para ver CUÁL señal pesa más en este segmento (¿es ausentismo o es que están rechazando ascensos?). |
| **Apoyos más demandados** | 9.2 | Qué beneficios pedirían (hasta 3 opciones por persona). | Es el insumo directo para diseñar beneficios — léelo como un ranking, no como un % que suma 100%. |
| **Dónde se concentra la carga** | 4.1 cruzado con 2.6 (nivel) y 2.7 (modalidad) | % de cuidadores por segmento (jerarquía × modalidad de trabajo). | Mirar si la carga se concentra en niveles operativos vs. gerenciales, y presencial vs. remoto — relevante para política de flexibilidad. |
| **Disposición a red de apoyo** | 9.3 + 9.4 | % dispuesto a sumarse a un grupo de apoyo interno / hacer voluntariado. | Mide apetito para iniciativas comunitarias internas, no necesidad — alguien puede necesitar apoyo y no querer un grupo. |
### El umbral de privacidad (k≥5)
Todo el dashboard aplica **k-anonimato k≥5**: ningún segmento con menos de 5
personas se muestra desagregado (aparece suprimido). Esto es una regla de
diseño no negociable del proyecto (protege a quien respondió, especialmente
tratándose de datos de salud/discapacidad) — si ves un segmento "vacío" o con
guion, probablemente es esto, no un error de carga. Con el lote de 50
respuestas sintéticas la mayoría de los cortes generales van a tener datos,
pero cruces muy específicos (ej. un departamento + un nivel jerárquico puntual)
pueden quedar por debajo del umbral.
### Decisión metodológica pendiente (marcada para vos en la propuesta original)
El documento `docs/dashboard/PROPUESTA_DASHBOARD_v3.md` (sección "Decisión
metodológica pendiente — para Maria") deja abierta una definición: las métricas
de vínculo de cuidado (4.1) se pueden leer **por persona** ("% de empleados con
al menos un familiar con discapacidad X") o **por instancia** ("del total de
familiares cuidados, % con discapacidad X"). Hoy el dashboard usa la lectura
por persona. Si para tu análisis necesitás la lectura por instancia, es un
cambio de método de cálculo a definir y construir — avisame cuando lo hayas
decidido.

16
lib/geo-cache.ts Normal file
View File

@@ -0,0 +1,16 @@
// departamento → ciudad → barrios[]
export type GeoData = Record<string, Record<string, string[]>>
let geoData: GeoData | null = null
let geoPromise: Promise<GeoData> | null = null
export async function getGeoData(): Promise<GeoData> {
if (geoData) return geoData
if (!geoPromise) {
geoPromise = fetch('/data/geo_paraguay_full.json')
.then((r) => r.json())
.then((data: GeoData) => { geoData = data; return data })
.catch((err) => { geoPromise = null; throw err })
}
return geoPromise
}

View File

@@ -11,3 +11,14 @@ export function createClient() {
return createSupabaseClient(url, anonKey)
}
// service_role — bypasa RLS por completo (regla 9 CLAUDE.md). Solo se importa
// desde Server Components / Server Actions ('use server'), nunca desde código
// que se bundlea al cliente. No exportar este cliente ni su resultado hacia props
// de un Client Component.
export function getSupabaseAdmin() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? 'placeholder'
return createSupabaseClient(url, serviceKey)
}

43
middleware.ts Normal file
View File

@@ -0,0 +1,43 @@
import { createServerClient } from '@supabase/ssr'
import { NextRequest, NextResponse } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// getUser() valida el token contra el servidor — no confiar solo en la cookie local
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
const loginUrl = new URL('/login', request.url)
return NextResponse.redirect(loginUrl)
}
return supabaseResponse
}
export const config = {
matcher: ['/admin/:path*', '/dashboard/:path*'],
}

141
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@hookform/resolvers": "^5.4.0",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.49.8",
"next": "15.3.8",
"react": "^19.0.0",
@@ -18,6 +19,7 @@
},
"devDependencies": {
"@axe-core/cli": "^4.11.3",
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.1.8",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
@@ -1040,6 +1042,22 @@
"node": ">= 10"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@@ -1047,9 +1065,9 @@
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.107.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.107.0.tgz",
"integrity": "sha512-XA7x+WIeIvuC3GTZ2ey67QcBbGw4n+o5B7M+dMm9KT1lL3wX1B52DfEWW00WuPt/LnniJLLIn1WIm9YPtuxzKQ==",
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz",
"integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
@@ -1183,9 +1201,9 @@
]
},
"node_modules/@supabase/functions-js": {
"version": "2.107.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.107.0.tgz",
"integrity": "sha512-iMtRUmEj1KOgQd/a3MR4hnBlPnZc62DW8+z8aPpnzbxWkexEZUVL2fSgvvp15gqFg1V55e2yMGqgK+yhSQxp5w==",
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz",
"integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
@@ -1195,15 +1213,15 @@
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz",
"integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.107.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.107.0.tgz",
"integrity": "sha512-7ARs47/tyIjX7T0Ive20d4NY8zQYXsP5/P07jJWxffSIM2gpnSnGRnL/Fe15GPbdjsW2sTYeckHcyaoKbM6yWQ==",
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz",
"integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
@@ -1213,9 +1231,9 @@
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.107.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.107.0.tgz",
"integrity": "sha512-cF2KYdR3JIn9YlWGeluY9S0G+otqTdL6hB8GzpatlEIY6fZudCcyFo6Dc3+X9tjeb+x9XcIyNAk9qhNAknjH1A==",
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz",
"integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "^0.4.2",
@@ -1225,10 +1243,22 @@
"node": ">=20.0.0"
}
},
"node_modules/@supabase/ssr": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.0.tgz",
"integrity": "sha512-d9XV5XzJvzzZbeAIM7fWTCUYxQJZ2Ru6ny3dJHmHGp/LIrJ+o9FpD7N9Rf/UhhWEvHXSoDe8SI32Z2ouOdMjBg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.2"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.108.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.107.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.107.0.tgz",
"integrity": "sha512-/X8OOVwKBn8aVKuHAGOz2yLA0d2OauqhVuy4mNtN+o7wttHOgx1/j+pqOzlsjmhOHrYykF6AJNZhs3gKZzcMUw==",
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz",
"integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
@@ -1239,16 +1269,16 @@
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.107.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.107.0.tgz",
"integrity": "sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==",
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz",
"integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.107.0",
"@supabase/functions-js": "2.107.0",
"@supabase/postgrest-js": "2.107.0",
"@supabase/realtime-js": "2.107.0",
"@supabase/storage-js": "2.107.0"
"@supabase/auth-js": "2.108.2",
"@supabase/functions-js": "2.108.2",
"@supabase/postgrest-js": "2.108.2",
"@supabase/realtime-js": "2.108.2",
"@supabase/storage-js": "2.108.2"
},
"engines": {
"node": ">=20.0.0"
@@ -1789,6 +1819,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -2109,6 +2152,20 @@
"node": ">= 6"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -2976,6 +3033,38 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",

View File

@@ -16,6 +16,7 @@
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.49.8",
"next": "15.3.8",
"react": "^19.0.0",
@@ -25,6 +26,7 @@
},
"devDependencies": {
"@axe-core/cli": "^4.11.3",
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.1.8",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",

14
playwright.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from '@playwright/test'
// .env.test nunca se commitea (ver .gitignore) — credenciales de un usuario
// dedicado de e2e, nunca las del director.
process.loadEnvFile('.env.test')
export default defineConfig({
testDir: 'tests/e2e',
use: {
baseURL: process.env.E2E_BASE_URL,
},
// Local contra producción — no corre en CI (backlog).
reporter: 'list',
})

20
scripts/apply_all_migrations.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
# Aplica todas las migraciones de supabase/migrations/ en orden de timestamp.
# Uso: scripts/apply_all_migrations.sh
set -e
MIGRATIONS_DIR="$(dirname "$0")/../supabase/migrations"
for f in $(ls "$MIGRATIONS_DIR"/*.sql | sort); do
echo "Applying: $f"
# -v ON_ERROR_STOP=1: sin esto, psql sigue ejecutando statements después de un
# error SQL y termina con exit code 0 igual — set -e nunca vería el fallo.
if docker exec -i supabase-db psql -v ON_ERROR_STOP=1 -U postgres -d postgres < "$f"; then
echo "Done: $f"
else
echo "ERROR applying: $f" >&2
exit 1
fi
done
echo "All migrations applied."

45
scripts/setup_all.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/bin/bash
# Recuperación completa de la DB tras una reinicialización (deploy que vació
# supabase-db). Orden:
# 1. Todas las migraciones (scripts/apply_all_migrations.sh)
# 2. Catálogo de encuestas (supabase/seed.sql) — sin esto, las migraciones
# dejan organizations/surveys/questions vacías y nada de lo que sigue
# tiene contra qué referenciar (setup_first_admin.sql sí funcionaría,
# pero seed_prueba_50.sql fallaría: rpc_submit_response necesita
# questions.id reales, que solo existen después de este paso).
# 3. Usuario platform_admin (scripts/setup_first_admin.sql)
# 4. Datos de prueba (docs/dashboard/seed_prueba_50.sql)
#
# Uso:
# ADMIN_EMAIL='admin@inquality.com.py' ADMIN_PASSWORD='...' scripts/setup_all.sh
set -e
SCRIPT_DIR="$(dirname "$0")"
ROOT_DIR="$SCRIPT_DIR/.."
if [ -z "$ADMIN_EMAIL" ] || [ -z "$ADMIN_PASSWORD" ]; then
echo "ERROR: ADMIN_EMAIL y ADMIN_PASSWORD son obligatorios (variables de entorno)." >&2
exit 1
fi
echo "== 1/4: Aplicando todas las migraciones =="
"$SCRIPT_DIR/apply_all_migrations.sh"
echo "== 2/4: Sembrando catálogo de encuestas (supabase/seed.sql) =="
# ADVERTENCIA propia del archivo: "SOLO ENTORNOS DE DESARROLLO — nunca ejecutar
# en producción". Esa advertencia es para NO re-correrlo contra una DB con
# datos reales (DELETE+INSERT de questions con UUIDs nuevos puede chocar con
# answers existentes via FK). En este escenario de recuperación la DB está
# vacía (reinicializada por el deploy) — no hay answers/responses que puedan
# quedar huérfanas. Si la DB NO está realmente vacía, frenar antes de este paso.
docker exec -i supabase-db psql -v ON_ERROR_STOP=1 -U postgres -d postgres < "$ROOT_DIR/supabase/seed.sql"
echo "== 3/4: Creando usuario platform_admin =="
docker exec -i supabase-db psql -v ON_ERROR_STOP=1 -U postgres -d postgres \
-v email="$ADMIN_EMAIL" -v password="$ADMIN_PASSWORD" \
< "$ROOT_DIR/scripts/setup_first_admin.sql"
echo "== 4/4: Sembrando datos de prueba (seed_prueba_50.sql) =="
docker exec -i supabase-db psql -v ON_ERROR_STOP=1 -U postgres -d postgres < "$ROOT_DIR/docs/dashboard/seed_prueba_50.sql"
echo "Setup completo."

View File

@@ -0,0 +1,45 @@
-- ============================================================
-- Script: crear primer usuario platform_admin
--
-- Verificado contra GoTrue v2.189.0 (supabase/gotrue:v2.189.0)
-- Schema de auth.users puede variar entre versiones de GoTrue.
-- Si falla con "column does not exist", verificar el schema real:
-- SELECT column_name FROM information_schema.columns
-- WHERE table_schema='auth' AND table_name='users'
-- ORDER BY ordinal_position;
--
-- USO:
-- docker exec -i supabase-db psql -U postgres -d postgres \
-- -v email='admin@ejemplo.com' -v password='CAMBIAR' \
-- < scripts/setup_first_admin.sql
--
-- NOTA: las variables psql solo funcionan en SQL plano, no en DO $$.
-- Este script usa un CTE INSERT...RETURNING para encadenar ambos inserts.
--
-- CAMBIAR CONTRASEÑA LUEGO DEL PRIMER LOGIN:
-- docker exec -i supabase-db psql -U postgres -d postgres \
-- -c "UPDATE auth.users SET encrypted_password = extensions.crypt('NuevaContraseña', extensions.gen_salt('bf')) WHERE email = 'admin@ejemplo.com';"
-- ============================================================
WITH new_user AS (
INSERT INTO auth.users (
id, instance_id, aud, role, email, encrypted_password,
email_confirmed_at, created_at, updated_at,
raw_app_meta_data, raw_user_meta_data,
confirmation_token, recovery_token,
email_change_token_new, email_change,
is_super_admin
) VALUES (
gen_random_uuid(),
'00000000-0000-0000-0000-000000000000',
'authenticated', 'authenticated',
:'email',
extensions.crypt(:'password', extensions.gen_salt('bf')),
now(), now(), now(),
'{"provider":"email","providers":["email"]}',
'{}', '', '', '', '', false
) RETURNING id
)
INSERT INTO public.user_roles (user_id, app_role, org_id)
SELECT id, 'platform_admin', '10000000-0000-0000-0000-000000000002'
FROM new_user;

View File

@@ -0,0 +1,113 @@
# ETAPA 4I — Validación e2e + Release v3.0.0
## 1. Objetivo
Validar el wizard v3 end-to-end y hacer el release a producción. Los 3 surveys
pasan de `draft` a `live`. Esta etapa es irreversible en varios pasos — seguir
el orden estricto de fases y detenerse si algo falla.
## 2. Estado de entrada
- Último commit: `3e7109a0` (4F ciudad-select)
- Commits no desplegados desde último deploy:
`8dea04c` fix cnt RPCs · `dbcc60a` tema claro · `3ec5da0` autocomplete barrios ·
`3e7109a0` ciudad-select
- VPS: seed de 50 respuestas activo (Demo en `live`) — NO limpiar, el director
quiere mostrar el dashboard al equipo con esos datos
- Los 3 surveys están en `draft`
## 3. DECISIONES YA TOMADAS — no consultar
- Deploy vía Dokploy (rebuild + clean cache)
- Migraciones SIEMPRE vía CLI del VPS — ninguna migración pendiente en esta etapa
- Los 3 surveys se reactivan a `live` solo DESPUÉS de verificar producción
- Tag `v3.0.0` solo DESPUÉS de reactivar surveys
- No hay migración de datos v2 → v3 (arranque limpio)
## 4. Fases — orden estricto, detenerse si algo falla
### FASE 1 — Validación e2e local (antes de tocar producción)
Correr el wizard completo localmente y verificar:
- [ ] Consentimiento → flujo completo → submit exitoso
- [ ] Una respuesta con al menos 1 familiar (instancia) persiste con instance_id
- [ ] El motor no muestra preguntas que el flujo oculta
- [ ] El autocomplete de barrio funciona (dropdown visible, no tapado)
- [ ] El filtro ciudad-departamento funciona (2.1 → 2.2 filtra, cambiar 2.1 limpia 2.2)
- [ ] Submit devuelve confirmación, no error
Si CUALQUIER item falla: DETENER. Reportar el fallo y esperar instrucción.
### FASE 2 — Deploy en Dokploy
- Trigger rebuild en Dokploy con "Clean cache"
- Verificar que el BUILD_ID nuevo aparece en los logs
- Verificar que la app responde en `economia-cuidado.inqualityhq.com`
- Verificar que el dashboard sigue mostrando los 50 datos sembrados
- Verificar que el wizard abre y llega a la pregunta 2.3 sin errores de consola
Si CUALQUIER item falla: DETENER. Reportar el fallo y esperar instrucción.
### FASE 3 — Reactivar surveys + Tag
Solo ejecutar si Fase 1 y Fase 2 están completas y verificadas.
Reactivar los 3 surveys en el VPS:
```sql
UPDATE surveys SET status = 'live'
WHERE id IN (
SELECT id FROM surveys WHERE status = 'draft'
);
```
Verificar que quedaron en `live`:
```sql
SELECT id, name, status FROM surveys ORDER BY name;
```
Tag de release en el repo local y push:
```bash
git tag -a v3.0.0 -m "Release v3.0.0 — wizard declarativo + dashboard tres miradas"
git push origin v3.0.0
```
Actualizar CHANGELOG.md con las etapas 4C→4I completadas.
## 5. DoD completo
- [ ] `git log --name-only -1` antes de empezar — confirmar commit de entrada
- [ ] Fase 1 completa sin fallos
- [ ] Fase 2 completa — app en producción respondiendo
- [ ] Surveys en `live` — verificado con SELECT
- [ ] Tag `v3.0.0` pusheado a Gitea
- [ ] CHANGELOG.md actualizado
- [ ] `git log --name-only -1` del commit de CHANGELOG
## 6. Qué reportar
Por fase, en orden:
FASE 1 — e2e local:
Submit con familiar: [response_id y instance_id del registro insertado]
Items del checklist: ✓/✗ cada uno
FASE 2 — Deploy:
BUILD_ID nuevo: [valor]
App responde: ✓/✗
Dashboard con datos: ✓/✗
Wizard sin errores de consola: ✓/✗
FASE 3 — Release:
Surveys en live: [output del SELECT]
Tag v3.0.0: [output de git tag]
CHANGELOG: [secciones agregadas]
## 7. Qué NO hacer
- ❌ No reactivar surveys antes de verificar producción (Fase 2 primero)
- ❌ No taggear antes de reactivar surveys (Fase 3 es la última)
- ❌ No limpiar el seed de 50 respuestas — el director los necesita para mostrar al equipo
- ❌ No aplicar migraciones — no hay ninguna pendiente en esta etapa
- ❌ No tocar `v2.1.0-stable`
- ❌ No continuar si algo falla — detenerse y reportar
## 8. Versionado
chore(release): CHANGELOG v3.0.0 — wizard declarativo + dashboard tres miradas
Etapas 4C→4I completadas. Motor de flujo puro, wizard renderer,
instancias familiares, submit con validación server-side, dashboard
tres miradas (Mirada Empresa funcional), autocomplete barrios/ciudad.

View File

@@ -0,0 +1,148 @@
# ETAPA AUTH — Supabase Auth + protección /admin
## 1. Objetivo
Implementar autenticación email/password con Supabase Auth y proteger
la ruta /admin/*. El dashboard y las vistas de admin quedan detrás de login.
Sin esta etapa no se avanza a vista admin mejorada ni vista detallada.
## 2. Contexto
- `current_app_role()` y `current_org_id()` ya leen del JWT — el esquema está listo.
- No hay middleware.ts ni sistema de auth hoy — /admin/responses está abierto.
- Organizations existentes: Empresa Demo (…0001), InQuality (…0002),
Fundación Solidaridad (…0003).
- No hay usuarios en auth.users — hay que crear el primero como parte de esta etapa.
- La app usa anon key para el formulario público — eso NO cambia.
- /admin/* usa service_role hoy — eso SÍ cambia: pasa a usar sesión del usuario.
## 3. DECISIONES YA TOMADAS — no consultar
- Supabase Auth nativo, email/password. Sin OAuth por ahora.
- Dos roles: `platform_admin` (acceso total) y `org_admin` (acceso a su org).
- Roles van como custom claims en el JWT: `app_role` y `org_id`.
- Middleware Next.js protege /admin/* — redirige a /login si no hay sesión válida.
- Página de login en /login — simple, solo email/password, sin registro público.
- El formulario público (/encuesta/*) NO se toca — sigue con anon key.
- Las RPCs del dashboard usan current_app_role() y current_org_id() del JWT —
cuando el usuario esté autenticado, RLS funciona automáticamente.
## 4. Tareas
### 4.1 Diagnóstico previo — mecanismo de custom claims
ANTES de implementar, reportar qué mecanismo de JWT customization está disponible
en esta versión de Supabase self-hosted. Puede ser:
- Hook SQL en `auth.hooks`
- Override de función `auth.jwt()`
- Edge Function hook
No implementar nada hasta confirmar el mecanismo y reportarlo.
### 4.2 Migración — tabla user_roles + custom claims hook
Migración `20260627000001_auth_user_roles.sql`:
```sql
-- Tabla de roles
CREATE TABLE public.user_roles (
user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
app_role text NOT NULL CHECK (app_role IN ('platform_admin','org_admin')),
org_id uuid REFERENCES public.organizations(id)
);
-- RLS: solo el propio usuario puede ver su rol (y platform_admin ve todos)
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
```
Implementar el hook de custom claims según el mecanismo disponible confirmado en 4.1.
El JWT resultante debe incluir:
```json
{ "app_role": "platform_admin", "org_id": "10000000-0000-0000-0000-000000000002" }
```
### 4.3 Script de setup — primer usuario admin
Script operativo (NO migración) en `scripts/setup_first_admin.sql`:
- Crea el usuario en auth.users vía INSERT directo o Supabase admin API
- Inserta en user_roles con app_role='platform_admin' y org_id de InQuality
- NO hardcodear credenciales — usar variables `:email` y `:password` con instrucciones
de cómo ejecutarlo:
```bash
docker exec -i supabase-db psql -U postgres -d postgres \
-v email='admin@ejemplo.com' -v password='CAMBIAR' \
< scripts/setup_first_admin.sql
```
- Documentar en el script cómo cambiar la contraseña después del primer login
### 4.4 Middleware Next.js
`middleware.ts` en la raíz del proyecto:
- Protege `/admin/*` y `/dashboard/*`
- Si no hay sesión válida → redirect a `/login`
- Si hay sesión → deja pasar
- `/`, `/encuesta/*`, `/login` — sin restricción
### 4.5 Página de login
`app/login/page.tsx` — Client Component:
- Formulario email/password
- Llama `supabase.auth.signInWithPassword()`
- En éxito: redirect a `/admin/responses`
- En error: mensaje claro ("Email o contraseña incorrectos"), nunca trace crudo
- Sin registro público
- Usar clases Tailwind existentes — sin nuevo sistema de diseño
### 4.6 Actualizar /admin/responses
- Reemplazar `getSupabaseAdmin()` (service_role) por cliente de sesión del usuario
- RLS de Postgres filtra según app_role y org_id del JWT automáticamente
- Agregar botón "Cerrar sesión" (`supabase.auth.signOut()` → redirect a `/login`)
## 5. DoD antes de reportar
- [ ] `git log --name-only -1` — listar todos los archivos tocados
- [ ] Diagnóstico de mecanismo de custom claims reportado (4.1) antes de implementar
- [ ] Migración `20260627000001` aplicada en VPS — verificar con:
```bash
docker exec -i supabase-db psql -U postgres -d postgres -c "\d user_roles"
```
- [ ] Script `scripts/setup_first_admin.sql` existe y está documentado
- [ ] Hook JWT verificado — el token incluye `app_role` (cómo verificarlo: reportar el método)
- [ ] `/login` funciona — email/password → sesión activa → redirect a /admin/responses
- [ ] `/admin/responses` sin sesión → redirect a `/login`
- [ ] `/admin/responses` con sesión → carga datos correctamente
- [ ] Formulario público sin cambios (verificar que /encuesta/* sigue funcionando)
- [ ] Build sin errores: `npm run build`
- [ ] Commit + push a Gitea
## 6. Qué reportar
```
Diagnóstico previo (4.1):
- Mecanismo de custom claims disponible: [cuál]
- Alternativas descartadas: [por qué]
Verificado:
- Migración user_roles aplicada: ✓ — output de \d user_roles
- Hook JWT: app_role presente en token ✓/✗ — método de verificación usado
- /login funciona: ✓/✗
- /admin sin sesión → /login: ✓/✗
- /admin con sesión → datos: ✓/✗
- Formulario público sin cambios: ✓/✗
- Build: ✓/✗
- git log --name-only -1: [lista completa de archivos]
Asumido / decisiones:
- [mecanismo de hook y razonamiento]
Hallazgos:
- [lo que aparezca]
```
## 7. Qué NO hacer
- ❌ No tocar el formulario público ni la anon key
- ❌ No hardcodear credenciales en ningún archivo commiteado
- ❌ No implementar registro público de usuarios
- ❌ No usar service_role en rutas que ya tienen sesión de usuario
- ❌ No avanzar a vista admin mejorada — eso es la etapa siguiente
- ❌ No hacer deploy — solo commit y push a Gitea
- ❌ No implementar el hook sin reportar primero el diagnóstico de 4.1
## 8. Versionado
```
feat(auth): Supabase Auth email/password + middleware + login [auth]
Protege /admin/* con Supabase Auth. Tabla user_roles con custom claims
(app_role, org_id) en JWT. Middleware Next.js redirige a /login sin sesión.
Página /login con email/password. /admin/responses migrado de service_role
a sesión de usuario autenticado.
```

View File

@@ -0,0 +1,154 @@
# ETAPA ADMIN — Vista de respuestas mejorada + vista detallada
## 1. Objetivo
Mejorar `/admin/responses` para que sea una interfaz de administración real,
y crear `/admin/responses/[id]` como vista detallada de una respuesta individual.
Todo detrás del middleware de auth existente.
## 2. Contexto
- Estado actual: `app/admin/responses/page.tsx` (Server Component, 249 líneas) +
`app/admin/responses/responses-table.tsx` (Client Component, 98 líneas).
- Auth: ya implementada — getSupabaseSession() con anon key + cookies, respeta RLS.
- Exclusión de sensibles: `is_sensitive=false` y `type != 'text_long'` en query de
questions — NO tocar, mantener exactamente igual.
- SURVEY_OPTIONS hardcodeadas con UUIDs literales — reemplazar por query dinámica.
- LIMIT 500 sin paginación real — reemplazar por paginación con page size de 50.
## 3. DECISIONES YA TOMADAS — no consultar
- Paginación: page size 50, parámetro `?page=N` en la URL (Server Component,
recarga completa — no client-side infinite scroll).
- Filtro de survey: `<select>` que carga surveys desde la DB (solo las que el
usuario puede ver según RLS), no hardcodeado.
- Vista detallada: ruta `/admin/responses/[id]` — Server Component, muestra todas
las respuestas no sensibles de esa submission, agrupadas por sección.
- Diseño: usar clases Tailwind o clases `.admin-*` existentes — sin nuevo sistema.
- Sin librería de tablas externa.
- El expand/collapse por submission en la tabla se conserva.
## 4. Tareas
### 4.1 Vista lista mejorada — `app/admin/responses/page.tsx`
**Filtro de survey dinámico:**
Reemplazar SURVEY_OPTIONS hardcodeadas por query a `surveys` table:
```ts
const { data: surveys } = await supabase
.from('surveys')
.select('id, title')
.order('title')
```
Renderizar como `<select>` con onChange que navega a `?survey_id=<id>`.
Opción "Todos los surveys" con value=''.
**Paginación:**
- Leer `searchParams.page` (default 1).
- Query de responses: `.range((page-1)*50, page*50-1)` en vez de `.limit(500)`.
- Mostrar: "Página N de M · X respuestas totales".
- Links Anterior / Siguiente que modifican `?page=N` en la URL.
- Si hay menos de 50 resultados, no mostrar paginación.
**Columna de acciones:**
Agregar columna "Ver" en la fila cabecera de cada submission (isFirstInGroup=true)
con link a `/admin/responses/[response_id]`.
**Header informativo:**
Mostrar claramente: total de submissions, total de answers, survey seleccionado.
### 4.2 Vista detallada — `app/admin/responses/[id]/page.tsx`
Server Component. Ruta: `/admin/responses/[id]` donde `id` = `response_id` (uuid).
**Query principal:**
```ts
// 1. Metadata de la respuesta
const { data: response } = await supabase
.from('responses')
.select('id, submitted_at, qi_zone, qi_sector, qi_age_bucket, survey_id')
.eq('id', id)
.single()
// 2. Preguntas permitidas (mismo filtro que la lista)
const { data: questions } = await supabase
.from('questions')
.select('id, code, prompt, type, position, section_number, section_title')
.eq('is_sensitive', false)
.neq('type', 'text_long')
.eq('survey_id', response.survey_id)
.order('position')
// 3. Answers de esta respuesta
const { data: answers } = await supabase
.from('answers')
.select('question_id, value, instance_id')
.eq('response_id', id)
.in('question_id', questions.map(q => q.id))
```
**Layout:**
- Header: fecha, zona, sector, edad — datos de qi_* de la respuesta.
- Link "← Volver a la lista" que preserva el `?survey_id` y `?page` anteriores
(pasar como searchParam o usar `referrer`).
- Respuestas agrupadas por sección (section_number + section_title).
- Dentro de cada sección: tabla código | pregunta | valor.
- Instancias (instance_id != 'default'): mostrar agrupadas bajo "Familiar N"
con badge visual.
- Valores nulos: badge "sin respuesta" (igual que la lista).
- Si `id` no existe o RLS bloquea: mostrar "Respuesta no encontrada" —
no crashear.
### 4.3 Tabla de respuestas — `app/admin/responses/responses-table.tsx`
Agregar la columna "Ver" en las filas isFirstInGroup con `<Link>` a
`/admin/responses/[submissionId]`. Pasar `surveyId` y `page` como props
para construir el link de "← Volver" en la vista detallada.
## 5. DoD antes de reportar
- [ ] `git log --name-only -1` — listar todos los archivos tocados
- [ ] Filtro de survey carga desde DB, no hardcodeado
- [ ] Paginación funciona: page=1 muestra primeros 50, page=2 los siguientes
- [ ] Link "Ver" en cada submission lleva a `/admin/responses/[id]`
- [ ] Vista detallada muestra respuestas agrupadas por sección
- [ ] Instancias agrupadas bajo "Familiar N"
- [ ] RLS respetado: misma exclusión de sensibles que la lista
- [ ] "Respuesta no encontrada" si el id no existe
- [ ] Build sin errores: `npm run build`
- [ ] `git log --name-only -1`
- [ ] Commit + push a Gitea
## 6. Qué reportar
```
Verificado:
- Filtro survey: ✓ — query a tabla surveys, no hardcodeado
- Paginación: ✓ — page size 50, range correcto
- Link "Ver" → /admin/responses/[id]: ✓
- Vista detallada: ✓ — agrupada por sección, instancias bajo "Familiar N"
- RLS/exclusión sensibles: ✓ — mismo filtro que antes
- "No encontrada" si id inválido: ✓
- Build: ✓
- git log --name-only -1: [lista completa]
- Commit: [hash]
Decisiones de diseño tomadas:
- [lo que haya]
Hallazgos:
- [lo que aparezca, especialmente sobre questions.section_number/section_title
— si esas columnas no existen, reportarlo antes de inventar una solución]
```
## 7. Qué NO hacer
- ❌ No tocar el filtro `is_sensitive=false` ni `type != 'text_long'`
- ❌ No usar service_role — solo getSupabaseSession() con RLS
- ❌ No agregar librerías de tablas o UI externas
- ❌ No implementar búsqueda por texto (fuera de alcance)
- ❌ No hacer deploy — solo commit y push a Gitea
- ❌ Si `section_number` o `section_title` no existen en questions,
reportarlo y NO inventar columnas — esperar instrucción
## 8. Versionado
```
feat(admin): vista respuestas mejorada + vista detallada [admin]
/admin/responses: filtro survey dinámico, paginación 50/página,
link a vista detallada. /admin/responses/[id]: respuestas por sección,
instancias bajo "Familiar N". RLS y exclusión de sensibles intactos.
```

View File

@@ -0,0 +1,124 @@
# ETAPA AUDIT LOG — Registro de auditoría append-only (Sprint 5, SEC-002)
## 1. Objetivo
Implementar un registro de auditoría append-only en Postgres que loggee eventos
de configuración crítica: cambios en `user_roles` (altas/bajas/modificaciones de
acceso) y cambios de `status` en `surveys` (activación/desactivación de encuestas).
Opcionalmente, si la implementación es simple y sin fricción, agregar también un
trigger DDL para loggear migraciones aplicadas.
## 2. Contexto
- Migración de referencia para patrones: `20260627000001_auth_user_roles.sql`
(patrón SECURITY DEFINER + GRANT puntual a reutilizar).
- Helpers RLS existentes a reusar: `public.current_app_role()` y
`public.current_org_id()` — no reinventar.
- Gap conocido y aceptado (SEC-003): esto NO protege contra TRUNCATE/DROP ejecutado
por superusuario directo en el VPS. Documentar en comentario de la migración, no resolver.
- Acceso a datos sensibles: NO loggear en esta etapa — hoy no existe código que
lea columnas `is_sensitive=true`. Deferido a cuando ese código exista.
- Naming de migración: después de `20260627000001` — usar timestamp del día de
ejecución, formato `YYYYMMDDHHMMSS`.
## 3. DECISIONES YA TOMADAS — no consultar
- Tabla nueva: `public.audit_log`, append-only real.
- Ningún rol (`anon`, `authenticated`, `service_role` vía API) tiene INSERT/UPDATE/DELETE
directo en `audit_log`. Solo escriben funciones SECURITY DEFINER de los triggers.
- Segunda barrera: trigger `BEFORE UPDATE OR DELETE ON audit_log` que aborta con
excepción — defensa en profundidad.
- Lectura de audit_log: RLS, solo `platform_admin` via `current_app_role()`.
- Patrón de función trigger: SECURITY DEFINER, propiedad de `postgres`,
GRANT EXECUTE solo al rol que lo necesita (no a PUBLIC).
- Columnas de audit_log:
```sql
id bigserial PRIMARY KEY,
occurred_at timestamptz NOT NULL DEFAULT now(),
actor_role text,
actor_user_id uuid,
action text NOT NULL,
target_table text NOT NULL,
target_id text,
detail jsonb
```
- Triggers obligatorios (Prioridad 1):
- AFTER INSERT/UPDATE/DELETE en `user_roles`
- AFTER UPDATE en `surveys` cuando cambia `status`
- Trigger DDL (Prioridad 2 — opcional): solo implementar si es simple y sin fricción.
Si requiere extensión nueva o configuración de infraestructura, NO incluir y reportarlo.
## 4. Tareas
### 4.1 Diagnóstico previo — DDL trigger
Antes de implementar, verificar si el DDL trigger (`ddl_command_end`) está disponible
sin extensiones adicionales en esta versión de Postgres self-hosted:
```bash
docker exec -i supabase-db psql -U postgres -d postgres -c "
SELECT event_object_schema, trigger_name
FROM information_schema.triggers
WHERE trigger_name LIKE '%ddl%' LIMIT 5;"
```
Reportar si es viable sin fricción. Si requiere configuración extra, documentarlo
y omitirlo de esta etapa.
### 4.2 Migración principal
Archivo: `supabase/migrations/YYYYMMDDHHMMSS_audit_log.sql`
Contenido en orden:
1. Crear tabla `audit_log` con columnas definidas arriba.
2. RLS en `audit_log`: solo `platform_admin` puede SELECT.
3. Trigger `BEFORE UPDATE OR DELETE ON audit_log` → función que lanza excepción
(append-only enforcement).
4. Función `log_user_roles_change()` SECURITY DEFINER → INSERT en audit_log.
5. Trigger AFTER INSERT/UPDATE/DELETE en `user_roles` → llama a la función.
6. Función `log_survey_status_change()` SECURITY DEFINER → INSERT en audit_log
solo cuando `NEW.status IS DISTINCT FROM OLD.status`.
7. Trigger AFTER UPDATE en `surveys` → llama a la función.
8. Si DDL trigger es viable (4.1): función + trigger DDL.
9. Comentario al final documentando el gap SEC-003.
### 4.3 Verificación en VPS
NO aplicar la migración — solo generar el archivo y reportar el SQL completo para
revisión del director. El director aplica manualmente después de aprobar.
## 5. DoD antes de reportar
- [ ] `git log --name-only -1` — solo el archivo de migración
- [ ] SQL completo de la migración en el reporte (no resumido — el director
necesita leerlo antes de aplicar)
- [ ] Diagnóstico DDL trigger reportado (4.1)
- [ ] Build sin errores: `npm run build`
- [ ] Commit + push a Gitea
- [ ] NO aplicado en VPS — esperar aprobación del director
## 6. Qué reportar
```
Diagnóstico DDL trigger (4.1):
- ¿Viable sin extensiones? ✓/✗
- Si no: razón y alternativa
SQL completo de la migración:
[pegar el SQL íntegro — no resumido]
Verificado:
- Build: ✓/✗
- git log --name-only -1: [lista]
- Commit: [hash]
NO aplicado en VPS — pendiente aprobación del director.
```
## 7. Qué NO hacer
- ❌ No aplicar la migración en el VPS sin aprobación explícita del director
- ❌ No implementar logging de accesos a datos sensibles (deferido)
- ❌ No agregar extensiones nuevas a Postgres
- ❌ No tocar el formulario público, las RPCs de dashboard, ni el middleware de auth
- ❌ No usar GRANT EXECUTE a PUBLIC — solo al rol específico que necesita cada función
- ❌ No omitir el comentario del gap SEC-003 en la migración
## 8. Versionado
```
feat(compliance): audit_log append-only — user_roles y surveys.status [SEC-002]
Tabla audit_log con RLS (solo platform_admin), trigger BEFORE UPDATE/DELETE
que aborta (append-only enforcement), triggers AFTER en user_roles y
surveys.status. Patrón SECURITY DEFINER reutilizado de auth_user_roles.
Gap SEC-003 (TRUNCATE por superusuario) documentado y aceptado.
```

View File

@@ -0,0 +1,123 @@
# ETAPA CALIDAD — Contraste, mobile, conectividad, touch (Sprint 5)
## 1. Objetivo
Resolver los 4 problemas de calidad identificados en la auditoría del 27 jun 2026,
en orden de impacto. Un solo commit al terminar todo.
## 2. Contexto
Diagnóstico base: auditoría de solidez/usabilidad (27 jun 2026, sin modificaciones).
Todos los problemas tienen archivo y línea exacta identificados.
## 3. DECISIONES YA TOMADAS — no consultar
- **Contraste teal:** Opción B — `#43BBC8` queda SOLO para decoración (fondos,
bordes, barras, elementos gráficos). Cuando se usa como COLOR DE TEXTO, reemplazar
por `#494F4F` (carbón de marca). No crear variante oscura del teal.
- **Mobile bottombar:** flex-wrap en el contenedor, no width fijo en los botones.
- **Submit sin try/catch:** agregar manejo de error de red que muestre el estado
'error' propio del wizard, no la pantalla cruda de React.
- **Autocomplete touch:** agregar `onTouchEnd` como fallback junto al `onMouseDown`
existente — no reemplazarlo.
- Sin librerías nuevas en ningún fix.
## 4. Tareas en orden
### P1 — Contraste teal como texto (CRÍTICO — sistemático)
En `app/globals.css`, reemplazar `color: var(--color-re-teal)` por
`color: #494F4F` en estas clases específicas:
- `.btn-secondary` (botón "Anterior") — línea ~380
- `.wizard-section-number` ("Sección X de Y") — línea ~569
- `.summary-code` — línea ~431
- `.barrio-autocomplete-item--active` (texto del ítem activo) — línea ~319
- `.consent-required-badge` — línea ~490
NO tocar usos de `var(--color-re-teal)` como `background-color`, `border-color`,
`fill`, o en las clases `.db-*` del dashboard — esos son usos decorativos correctos.
Verificar después del cambio que no quedó ningún `color: var(--color-re-teal)` en
clases del wizard (grep en globals.css sección wizard):
```bash
grep -n "color: var(--color-re-teal)" app/globals.css
```
Los únicos resultados aceptables son en sección `.db-*` del dashboard.
### P2 — Submit sin try/catch (red se cae al enviar)
En `components/wizard-form.tsx`, función `handleSubmit` (líneas ~212-226):
Envolver el `await submitSurvey(...)` en try/catch. Si el catch recibe un error
de red (fetch failed, network error), setear el estado del wizard a 'error' con
mensaje accionable: "No pudimos enviar tu respuesta. Verificá tu conexión e
intentá de nuevo." El botón de reintento ya existe en el estado 'error'
(wizard-form.tsx:136-152) — solo hay que llegar a ese estado correctamente.
No crear `app/error.tsx` — el estado de error propio del wizard es suficiente.
### P3 — Botones bottombar en mobile ≤480px
En `app/globals.css`, sección `.wizard-bottombar` (~línea 653):
Agregar `flex-wrap: wrap` al contenedor `.wizard-bottombar`.
En el breakpoint `@media (max-width: 480px)`:
- `.wizard-bottombar``flex-direction: column-reverse` (Siguiente arriba, Anterior abajo)
- `.btn` dentro del bottombar → `width: 100%`
- El span hint entre los botones → `order: 3` para que quede al final
`column-reverse` porque el botón primario (Siguiente/Enviar) debe estar arriba
en mobile — el pulgar llega más fácil al centro que a la base.
### P4 — Autocomplete touch frágil
En `components/widgets/barrio-autocomplete-field.tsx` y
`components/widgets/ciudad-select-field.tsx`:
En el handler de selección de ítem del dropdown, agregar `onTouchEnd` junto
al `onMouseDown` existente (no reemplazarlo). El `onTouchEnd` debe llamar
`e.preventDefault()` antes de ejecutar la selección para evitar el blur
sintético que cierra la lista antes de que se procese la selección.
## 5. DoD antes de reportar
- [ ] `git log --name-only -1` — listar todos los archivos tocados
- [ ] grep de verificación P1:
```bash
grep -n "color: var(--color-re-teal)" app/globals.css
```
Solo resultados en sección `.db-*`
- [ ] Build sin errores: `npm run build`
- [ ] P2 verificado: en `handleSubmit`, el catch existe y setea estado 'error'
con mensaje accionable (pegar el bloque try/catch completo en el reporte)
- [ ] P3 verificado: describir el CSS resultante del bottombar en mobile
- [ ] P4 verificado: describir el handler resultante de selección en autocomplete
- [ ] Commit: `fix(quality): contraste teal, submit red, bottombar mobile, autocomplete touch`
- [ ] Push a Gitea
## 6. Qué reportar
```
P1 — Contraste:
- Clases modificadas: [lista con línea antes → después]
- grep verificación: [output]
P2 — Submit try/catch:
- Bloque handleSubmit completo después del fix: [código]
P3 — Bottombar mobile:
- CSS resultante de .wizard-bottombar y el breakpoint: [código]
P4 — Autocomplete touch:
- Handler de selección resultante (onMouseDown + onTouchEnd): [código]
Verificado:
- Build: ✓/✗
- git log --name-only -1: [lista]
- Commit: [hash]
```
## 7. Qué NO hacer
- ❌ No tocar `var(--color-re-teal)` en usos decorativos (background, border, fill)
- ❌ No tocar clases `.db-*` del dashboard
- ❌ No crear `app/error.tsx`
- ❌ No reemplazar `onMouseDown` — agregar `onTouchEnd` junto a él
- ❌ No agregar librerías nuevas
- ❌ No hacer deploy — solo commit y push a Gitea
- ❌ No mezclar con los fixes rápidos de la etapa anterior
## 8. Versionado
```
fix(quality): contraste teal, submit red, bottombar mobile, autocomplete touch
P1: color de texto teal → #494F4F en 5 clases del wizard (WCAG AA).
P2: try/catch en handleSubmit — error de red muestra estado error del wizard.
P3: flex-wrap + column-reverse en bottombar ≤480px.
P4: onTouchEnd fallback en autocomplete barrio y ciudad.
```

View File

@@ -0,0 +1,89 @@
# ETAPA E2E — Tests Playwright: verificación deploy Sprint 5
## Antes de tocar código, leé:
1. `CLAUDE.md`
2. `docs/estado/ESTADO_PROYECTO_26JUN2026.md` — estado general del proyecto
Confirmame el plan antes de implementar.
## Credenciales de test (usuario dedicado — nunca las de Marcos)
- URL: `https://economia-cuidado.inqualityhq.com`
- Email: `test-e2e@inquality.com.py`
- Password: `TestE2E2026!#Cuidado`
- Rol: `platform_admin`, org: `InQuality`
Estas credenciales van en `.env.test` en la raíz (agregar a `.gitignore` si no está).
**Nunca hardcodeadas en los specs.**
## Verificar Playwright antes de instalar nada
```bash
npx playwright --version 2>/dev/null && echo "instalado" || echo "no instalado"
ls ~/.cache/ms-playwright 2>/dev/null && echo "browsers presentes" || echo "browsers ausentes"
```
Reportar resultado antes de continuar. Si ya está instalado con browsers, agregar solo
`@playwright/test` como devDependency sin correr `npx playwright install`.
Si no está: `npx playwright install --with-deps chromium` (solo Chromium).
## Los 5 tests a implementar
**Archivo:** `tests/e2e/sprint5-verificacion.spec.ts`
### Test 1 — Login y redirect
- Abrir `/admin/responses` sin sesión
- Verificar redirect a `/login`
- El DOM de `/login` contiene el campo `email`, el campo `password`, y el botón de toggle
de contraseña (buscar `aria-label` que contenga "contraseña")
### Test 2 — Login funcional
- Hacer login con las credenciales de test en `/login`
- Verificar redirect a `/admin/responses`
- El DOM contiene `<select>` para filtro de survey (no links hardcodeados)
- El DOM contiene al menos una fila de respuesta (hay 50 sembradas)
### Test 3 — Vista detallada de respuesta
- Desde `/admin/responses`, hacer click en el primer link "Ver" disponible
- Verificar que la URL cambia a `/admin/responses/[uuid]`
- El DOM contiene al menos una sección (buscar texto que indique agrupación por sección)
- No hay error 404 ni texto "Respuesta no encontrada"
### Test 4 — Dashboard con datos
- Navegar a `/dashboard` (o la ruta del dashboard — verificar cuál es)
- Verificar que al menos un widget muestra un porcentaje (buscar `%` en el DOM)
- No aparece "Datos insuficientes" en todos los widgets (puede aparecer en algunos
segmentos con k<5, pero no en todos)
### Test 5 — Formulario público accesible sin auth
- Navegar a `/` o `/encuesta` (verificar ruta correcta del formulario)
- Verificar que NO redirige a `/login` (el formulario es público)
- El DOM contiene el wizard o la pantalla de consentimiento
## Decisiones ya tomadas
- Playwright en local apuntando a producción, no en CI (backlog)
- `playwright.config.ts` en raíz, `testDir: 'tests/e2e'`
- `.env.test` para credenciales, en `.gitignore`
- Si un test falla, reportar el error exacto y consultarme — no intentar workarounds
## Archivos autorizados
`playwright.config.ts`, `tests/e2e/sprint5-verificacion.spec.ts`, `.env.test`
(no se commitea), `package.json` (devDependency), `package-lock.json`.
Cualquier otro en `git diff --name-only HEAD` → revertir y reportar.
## DoD antes de reportar
```bash
npx playwright test tests/e2e/sprint5-verificacion.spec.ts --reporter=list
git log --name-only -1
git diff --name-only HEAD # solo archivos autorizados, sin .env.test
grep -r "TestE2E2026" . # debe dar vacío — contraseña no commiteada
```
## Qué reportar
- Resultado de checks de Playwright previos (versión, browsers)
- Resultado de cada test: pass/fail con mensaje exacto si falla
- Ruta real del dashboard y del formulario público (las que encontró en el código)
- `git log --name-only -1` y `git diff --name-only HEAD`
- Hallazgos no esperados
**No hacer deploy ni modificar nada en producción. No continuar sin mi OK si algo falla.**

View File

@@ -0,0 +1,47 @@
# ETAPA — Fixes rápidos: toggle password + card Talento en riesgo
## 1. Objetivo
Dos fixes visuales independientes y quirúrgicos. Scope estricto.
## 2. DECISIONES YA TOMADAS — no consultar
- Toggle de contraseña: solo en el campo password del login, sin librería nueva.
- Card Talento en riesgo: eliminar el gradiente, quedar igual que los demás cards
(background blanco, sin distinción especial de fondo).
## 3. Tareas
### Fix A — Toggle "ver contraseña" en /login
En `app/login/page.tsx`:
- Agregar estado `showPassword` (useState).
- El input password alterna entre `type="password"` y `type="text"`.
- Botón inline dentro del campo (position absolute o flex) con ícono SVG
simple de ojo (inline, sin librería). Accesible: `aria-label="Mostrar contraseña"` /
`aria-label="Ocultar contraseña"` según estado. `type="button"` para no submitear.
- Estilo consistente con el formulario existente.
### Fix B — Card Talento en riesgo sin gradiente
En `app/globals.css`, clase `.db-widget-star`:
- Eliminar la propiedad `background` (el gradiente actual).
- El card hereda `background: #FFFFFF` de `.db-widget` — mismo que los demás.
- NO tocar `border-color: var(--color-re-teal)` — mantener el borde distintivo.
- NO tocar `.db-widget-star .db-widget-title` — mantener el título en teal.
## 4. DoD antes de reportar
- [ ] `git log --name-only -1` — solo `app/login/page.tsx` y `app/globals.css`
- [ ] Toggle password funciona: click muestra/oculta, aria-label cambia
- [ ] Card Talento en riesgo: fondo blanco igual que los demás cards
- [ ] Build sin errores: `npm run build`
- [ ] Commit: `fix(ui): toggle password en login + fondo blanco card talento`
- [ ] Push a Gitea
## 5. Qué reportar
- `git log --name-only -1`
- Línea exacta eliminada en `.db-widget-star`
- Build: ✓/✗
## 6. Qué NO hacer
- ❌ No tocar otros campos del formulario de login
- ❌ No tocar otros widgets del dashboard
- ❌ No agregar librerías de iconos
- ❌ No modificar el borde teal ni el título teal del card estrella
- ❌ No hacer deploy — solo commit y push

View File

@@ -0,0 +1,106 @@
# INSUMOS — Etapa Audit Log append-only (Sprint compliance, SEC-002)
> Material de contexto para que Claude (Projects, arquitecto) redacte el ETAPA_PROMPT.md
> de esta etapa. No es el prompt en sí — es lo que el arquitecto necesita para escribirlo
> bien, sin tener que releer todo el repo.
---
## 1. Por qué esta etapa, y por qué este alcance
`BACKLOG.md` (SEC-002) y `COMPLIANCE.md` §5.1 piden, dentro del sprint de compliance,
un "registro de auditoría append-only (log inmutable de operaciones sensibles)". El
director priorizó esta pieza primero, antes de hashing de integridad, trazabilidad de
cambios o cifrado en reposo (esas quedan para etapas futuras del mismo sprint).
COMPLIANCE.md §5.1 pide loggear 3 cosas: accesos a datos sensibles, cambios de
configuración, y ejecución de migraciones. Evalué las 3 y propongo **acotar el alcance
de esta etapa a 2 de las 3**:
1. **Migraciones (DDL)** — factible con un event trigger nativo de Postgres
(`ddl_command_end`), sin tocar el flujo de deploy.
2. **Cambios de configuración** — triggers `AFTER INSERT/UPDATE/DELETE` en
`user_roles` (altas/bajas/cambios de `app_role`/`org_id`) y en `surveys` cuando
cambia `status` (activación/desactivación de encuestas).
3. **Acceso a datos sensibles — propongo DEFERIR explícitamente.** Verificado en
COMPLIANCE.md §3.2: hoy ningún camino de código (ni `/admin/responses` ni las RPCs
`rpc_me_*`) lee columnas `is_sensitive=true`. Instrumentar "acceso" sin que exista
un acceso real sería simular cobertura. Cuando se construya el primer camino que sí
lea sensibles, ESE PR debe agregar su propio log de acceso junto con el código que
lo necesita — no antes.
El arquitecto puede confirmar o ajustar este recorte; lo presento como punto de partida,
no como cerrado (esto todavía no se aprobó con el director, se está armando el prompt).
---
## 2. Decisiones de diseño que ya tienen forma (para que el prompt las fije)
- **Tabla nueva:** `public.audit_log` — append-only real:
- Ningún rol (`anon`, `authenticated`, ni `service_role` vía API) tiene
`INSERT`/`UPDATE`/`DELETE` directo. Solo escriben funciones `SECURITY DEFINER`
de los triggers, propiedad de `postgres` (mismo patrón que
`custom_access_token_hook`, ver `supabase/migrations/20260627000001_auth_user_roles.sql`).
- Trigger `BEFORE UPDATE OR DELETE ON audit_log` que aborta con excepción — segunda
barrera, defensa en profundidad (no a prueba de un operador con `psql -U postgres`
en el VPS — ver gap conocido abajo).
- Lectura: RLS, solo `platform_admin` (reusar `current_app_role()`, ver
`supabase/migrations/20260531000003_rls_policies.sql:17`).
- **Columnas sugeridas (a validar por el arquitecto):** `id bigserial pk`,
`occurred_at timestamptz default now()`, `actor_role text`, `actor_user_id uuid`,
`action text`, `target_table text`, `target_id text`, `detail jsonb`.
- **Gap conocido a documentar, no a resolver en esta etapa:** esto no protege contra
`TRUNCATE`/`DROP` ejecutado por un superusuario directo en el VPS — mismo gap ya
registrado en `SEC-003` (BACKLOG.md). Append-only a nivel de aplicación/RLS, no a
prueba de acceso root a la base.
---
## 3. Convenciones del repo que el prompt debe respetar
- **Migraciones:** `supabase/migrations/`, naming `YYYYMMDDHHMMSS_descripcion.sql`.
Última migración aplicada: `20260627000001_auth_user_roles.sql`. La próxima debe
fecharse después de esa.
- **Patrón SECURITY DEFINER + GRANT EXECUTE puntual:** ver
`custom_access_token_hook` en `20260627000001_auth_user_roles.sql` — función
`SECURITY DEFINER STABLE`, `GRANT EXECUTE ... TO supabase_auth_admin` (rol
específico, no `PUBLIC`). Mismo patrón a replicar para las funciones de trigger
de `audit_log`.
- **Helpers RLS existentes a reusar, no reinventar:** `public.current_app_role()`
y `public.current_org_id()` (`20260531000003_rls_policies.sql:17-30`).
- **Verificación obligatoria antes de aplicar en VPS:** mostrar el SQL final completo
y esperar OK explícito del director antes de correrlo en producción (ya es práctica
establecida en este proyecto — ver incidente VPS del 27 jun, recuperación documentada
en `docs/estado/ESTADO_PROYECTO_JUNIO_2026.md` §2.1).
- **Formato esperado del archivo de etapa:** seguir la estructura de
`sprints/sprint-4/ETAPA_AUTH_PROMPT.md` o `ETAPA_4G_PROMPT.md` — Objetivo,
Decisiones ya tomadas (no consultar), Tareas, DoD, Qué reportar (datos concretos,
no "funcionó"), Qué NO hacer, Versionado (mensaje de commit sugerido con tag
`[compliance]` o similar).
---
## 4. Qué NO debería pedir el prompt (fuera de alcance de esta etapa)
- Hashing de integridad SHA-256 (siguiente pieza del mismo sprint, etapa separada).
- Cifrado en reposo (es trabajo de VPS/infraestructura, no de migración SQL).
- Trazabilidad histórica completa de cambios de datos (triggers de historial / WAL) —
etapa separada también.
- Logging de acceso a datos sensibles (ver §1, punto 3 — deferido a cuando exista
código que lea sensibles).
- pgaudit o cualquier extensión nueva — no está en el Supabase self-hosted actual,
agregar una extensión es Nivel 3 aparte (afecta infraestructura/dependencias).
---
## 5. Referencias
- `COMPLIANCE.md` §5 (roadmap auditabilidad), §5.2 (por qué no blockchain), §3.2
(qué es sensible y cómo se protege hoy).
- `BACKLOG.md` → SEC-002, SEC-003 (gap de TRUNCATE), SEC-004 (bypass REST, contexto
de otro riesgo residual ya aceptado, mismo espíritu de "documentar en vez de
sobre-construir").
- `TECH_DEBT.md` → TECH-DEBT-008 (resuelto 27 jun, ejemplo de cómo se cierra un ítem).
- `docs/estado/ESTADO_PROYECTO_JUNIO_2026.md` — estado general post-release v3.0.0
+ incidente VPS, para que el arquitecto tenga el panorama completo antes de escribir
el prompt.

View File

@@ -0,0 +1,73 @@
-- BUG-001 — Fix filtro "Ninguna de las anteriores" en rpc_me_carrera_congelada
--
-- PROBLEMA: la migración anterior (20260626000001) dejó en el subquery interno:
-- AND a.value #>> '{}' != 'Ninguna de las anteriores'
-- El operador `#>> '{}'` aplicado sobre un ARRAY jsonb no extrae elementos;
-- convierte todo el array a su representación textual (ej. '["opcion","Ninguna..."]'),
-- por lo que la comparación nunca descarta el elemento deseado.
--
-- SOLUCIÓN: mover el filtro al WHERE del subquery expandido, donde `opt` ya existe
-- como elemento individual resultado de jsonb_array_elements_text.
CREATE OR REPLACE FUNCTION public.rpc_me_carrera_congelada(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_n bigint;
v_cong bigint;
v_rows jsonb;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT
count(*),
count(*) FILTER (
WHERE CASE WHEN jsonb_typeof(a.value) = 'array'
THEN jsonb_array_length(a.value - 'Ninguna de las anteriores') > 0
ELSE false
END
)
INTO v_n, v_cong
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.5'
WHERE public._dash_es_cuidador(dr.response_id);
IF v_n < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
END IF;
SELECT jsonb_agg(row ORDER BY cnt DESC) INTO v_rows FROM (
SELECT jsonb_build_object('opcion', opt, 'n', cnt, 'pct', ROUND(cnt::numeric / v_n * 100, 1)) AS row,
cnt
FROM (
SELECT opt, count(DISTINCT response_id) AS cnt
FROM (
SELECT jsonb_array_elements_text(a.value) AS opt,
dr.response_id
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.5'
WHERE public._dash_es_cuidador(dr.response_id)
AND jsonb_typeof(a.value) = 'array'
) expanded
WHERE opt != 'Ninguna de las anteriores'
GROUP BY opt
) sub
WHERE cnt >= 5
) t;
RETURN jsonb_build_object(
'status', 'ok',
'n', v_n,
'pct_carrera_congelada', ROUND(v_cong::numeric / v_n * 100, 1),
'top_opciones', COALESCE(v_rows, '[]'::jsonb)
);
END;
$$;

View File

@@ -0,0 +1,129 @@
-- ============================================================
-- Migration 004 / ETAPA_AUTH 4.2
-- Tabla user_roles + hook de custom claims para JWT
--
-- Después de aplicar esta migración, el director debe:
-- 1. Agregar en Dokploy (servicio supabase-auth):
-- GOTRUE_HOOKS_CUSTOM_ACCESS_TOKEN_ENABLED=true
-- GOTRUE_HOOKS_CUSTOM_ACCESS_TOKEN_URI=pg-functions://postgres/public/custom_access_token_hook
-- 2. Reiniciar el servicio supabase-auth (NO la app Next.js).
-- ============================================================
-- ── 1. Tabla user_roles ──────────────────────────────────────
CREATE TABLE public.user_roles (
user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
app_role text NOT NULL CHECK (app_role IN ('platform_admin', 'org_admin')),
org_id uuid REFERENCES public.organizations(id)
);
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
-- El usuario ve su propio rol; platform_admin ve todos
CREATE POLICY "user_roles_select_own"
ON public.user_roles
FOR SELECT
TO authenticated
USING (
user_id = auth.uid()
OR public.current_app_role() = 'platform_admin'
);
-- ── 2. Extender policies existentes para incluir platform_admin ──
-- organizations
DROP POLICY IF EXISTS "org_select_own" ON public.organizations;
CREATE POLICY "org_select_own"
ON public.organizations
FOR SELECT
TO authenticated
USING (
public.current_app_role() IN ('analyst', 'platform_admin')
OR (
public.current_app_role() = 'org_admin'
AND id = public.current_org_id()
)
);
-- surveys (no tocar surveys_select_live_anon — sigue igual)
DROP POLICY IF EXISTS "surveys_select_auth" ON public.surveys;
CREATE POLICY "surveys_select_auth"
ON public.surveys
FOR SELECT
TO authenticated
USING (
public.current_app_role() IN ('analyst', 'platform_admin')
OR (
public.current_app_role() = 'org_admin'
AND org_id = public.current_org_id()
)
);
-- questions (no tocar questions_select_live — sigue igual)
DROP POLICY IF EXISTS "questions_select_auth" ON public.questions;
CREATE POLICY "questions_select_auth"
ON public.questions
FOR SELECT
TO authenticated
USING (
public.current_app_role() IN ('analyst', 'platform_admin')
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()
)
)
);
-- ── 3. Nuevas policies SELECT para platform_admin (responses/answers no tenían) ──
CREATE POLICY "responses_select_platform_admin"
ON public.responses
FOR SELECT
TO authenticated
USING (public.current_app_role() IN ('analyst', 'platform_admin'));
CREATE POLICY "answers_select_platform_admin"
ON public.answers
FOR SELECT
TO authenticated
USING (public.current_app_role() IN ('analyst', 'platform_admin'));
-- ── 4. Hook de custom claims ─────────────────────────────────
-- Inyecta app_role y org_id en el JWT en cada emisión de token.
-- SECURITY DEFINER + search_path garantiza que bypasea RLS para leer user_roles.
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
claims jsonb;
v_role text;
v_org uuid;
BEGIN
claims := event -> 'claims';
SELECT app_role, org_id
INTO v_role, v_org
FROM public.user_roles
WHERE user_id = (event ->> 'user_id')::uuid;
IF v_role IS NOT NULL THEN
claims := jsonb_set(claims, '{app_role}', to_jsonb(v_role));
IF v_org IS NOT NULL THEN
claims := jsonb_set(claims, '{org_id}', to_jsonb(v_org::text));
END IF;
END IF;
RETURN jsonb_set(event, '{claims}', claims);
END;
$$;
-- GoTrue necesita EXECUTE para llamar la función y SELECT para que el SECURITY DEFINER
-- pueda leer user_roles internamente (aunque SECURITY DEFINER corre como postgres,
-- el grant explicito documenta la intención y protege contra cambios de ownership).
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook(jsonb) TO supabase_auth_admin;
GRANT SELECT ON public.user_roles TO supabase_auth_admin;

View File

@@ -0,0 +1,190 @@
-- ============================================================
-- Migration: audit_log append-only — SEC-002 (Sprint 5, compliance)
--
-- Loggea cambios de configuración crítica:
-- - user_roles: altas/bajas/cambios de app_role u org_id (quién es admin)
-- - surveys.status: activación/desactivación de encuestas
-- - DDL en schema public: toda migración aplicada (CREATE/ALTER/DROP),
-- vía event trigger ddl_command_end. Confirmado viable en este Postgres
-- self-hosted sin extensiones adicionales: el rol `postgres` tiene
-- rolsuper=false pero pudo crear el event trigger sin error
-- (diagnóstico ETAPA_AUDIT_LOG_PROMPT.md §4.1, 2026-06-27).
--
-- NO loggea acceso a datos sensibles (is_sensitive=true): hoy ningún
-- camino de código (ni /admin/responses ni las RPCs rpc_me_*) lee esas
-- columnas (COMPLIANCE.md §3.2). Deferido a cuando ese código exista —
-- ese mismo cambio debe agregar su propio log de acceso, no esta migración.
--
-- GAP CONOCIDO (SEC-003, aceptado, NO resuelto aquí): un operador con
-- acceso directo a `psql -U postgres` en el VPS puede TRUNCATE/DROP esta
-- tabla, o saltear los triggers con `SET session_replication_role =
-- replica` / `ALTER TABLE ... DISABLE TRIGGER ALL`. El append-only de
-- abajo cierra la escritura vía API (anon/authenticated/PostgREST) y
-- bloquea UPDATE/DELETE para cualquier caller normal (incluido
-- service_role, que bypasa RLS pero no bypasa triggers) — no protege
-- contra un operador con shell en la base.
-- ============================================================
CREATE TABLE public.audit_log (
id bigserial PRIMARY KEY,
occurred_at timestamptz NOT NULL DEFAULT now(),
actor_role text,
actor_user_id uuid,
action text NOT NULL,
target_table text NOT NULL,
target_id text,
detail jsonb
);
COMMENT ON TABLE public.audit_log IS
'Append-only. INSERT solo vía funciones SECURITY DEFINER de los triggers de esta migración. UPDATE/DELETE bloqueados por trigger audit_log_block_mutation. Gap conocido: no protege contra TRUNCATE/DROP por superusuario directo en el VPS (SEC-003).';
COMMENT ON COLUMN public.audit_log.actor_role IS
'current_app_role() del que disparó el cambio (user_roles/surveys), o session_user para eventos DDL.';
ALTER TABLE public.audit_log ENABLE ROW LEVEL SECURITY;
-- Solo platform_admin puede leer. No hay policies de INSERT/UPDATE/DELETE
-- para ningún rol vía API — el único camino de escritura son las
-- funciones SECURITY DEFINER (corren como propietario de la tabla,
-- `postgres`, que por default de Postgres bypasa RLS sobre objetos
-- propios sin necesitar FORCE ROW LEVEL SECURITY).
CREATE POLICY "audit_log_select_platform_admin"
ON public.audit_log FOR SELECT
TO authenticated
USING (public.current_app_role() = 'platform_admin');
REVOKE INSERT, UPDATE, DELETE ON public.audit_log FROM PUBLIC, anon, authenticated;
GRANT SELECT ON public.audit_log TO authenticated;
-- ============================================================
-- Barrera 2: append-only enforcement a nivel de trigger.
-- Se aplica a CUALQUIER caller (incluido service_role) salvo que alguien
-- deshabilite triggers con privilegios de superusuario real en el VPS
-- (gap documentado arriba, SEC-003).
-- ============================================================
CREATE OR REPLACE FUNCTION public.audit_log_block_mutation()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
RAISE EXCEPTION 'audit_log es append-only: % no permitido', TG_OP;
END;
$$;
REVOKE ALL ON FUNCTION public.audit_log_block_mutation() FROM PUBLIC;
CREATE TRIGGER audit_log_no_update
BEFORE UPDATE ON public.audit_log
FOR EACH ROW EXECUTE FUNCTION public.audit_log_block_mutation();
CREATE TRIGGER audit_log_no_delete
BEFORE DELETE ON public.audit_log
FOR EACH ROW EXECUTE FUNCTION public.audit_log_block_mutation();
-- ============================================================
-- Trigger 1: cambios en user_roles (altas/bajas/cambios de acceso)
-- Función disparada automáticamente por el trigger — no requiere GRANT
-- EXECUTE a ningún rol (el mecanismo de triggers no pasa por una llamada
-- SQL directa del caller).
-- ============================================================
CREATE OR REPLACE FUNCTION public.log_user_roles_change()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.audit_log (actor_role, actor_user_id, action, target_table, target_id, detail)
VALUES (
public.current_app_role(),
auth.uid(),
'user_roles_' || lower(TG_OP),
'user_roles',
COALESCE(NEW.user_id, OLD.user_id)::text,
jsonb_build_object(
'old', CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN to_jsonb(OLD) ELSE NULL END,
'new', CASE WHEN TG_OP IN ('UPDATE','INSERT') THEN to_jsonb(NEW) ELSE NULL END
)
);
RETURN COALESCE(NEW, OLD);
END;
$$;
REVOKE ALL ON FUNCTION public.log_user_roles_change() FROM PUBLIC;
CREATE TRIGGER user_roles_audit
AFTER INSERT OR UPDATE OR DELETE ON public.user_roles
FOR EACH ROW EXECUTE FUNCTION public.log_user_roles_change();
-- ============================================================
-- Trigger 2: cambios de status en surveys (activación/desactivación)
-- ============================================================
CREATE OR REPLACE FUNCTION public.log_survey_status_change()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF NEW.status IS DISTINCT FROM OLD.status THEN
INSERT INTO public.audit_log (actor_role, actor_user_id, action, target_table, target_id, detail)
VALUES (
public.current_app_role(),
auth.uid(),
'survey_status_change',
'surveys',
NEW.id::text,
jsonb_build_object('old_status', OLD.status, 'new_status', NEW.status, 'title', NEW.title)
);
END IF;
RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION public.log_survey_status_change() FROM PUBLIC;
CREATE TRIGGER surveys_status_audit
AFTER UPDATE ON public.surveys
FOR EACH ROW EXECUTE FUNCTION public.log_survey_status_change();
-- ============================================================
-- Trigger 3 (Prioridad 2, confirmado viable — ver §4.1 del prompt de etapa):
-- DDL en schema public → loggea toda migración aplicada.
-- ============================================================
CREATE OR REPLACE FUNCTION public.log_ddl_command()
RETURNS event_trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
obj record;
BEGIN
FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands() LOOP
IF obj.schema_name = 'public' OR obj.schema_name IS NULL THEN
INSERT INTO public.audit_log (actor_role, actor_user_id, action, target_table, target_id, detail)
VALUES (
session_user,
NULL,
'ddl_' || obj.command_tag,
COALESCE(obj.object_identity, obj.schema_name, 'unknown'),
obj.objid::text,
jsonb_build_object('command_tag', obj.command_tag, 'object_type', obj.object_type)
);
END IF;
END LOOP;
END;
$$;
REVOKE ALL ON FUNCTION public.log_ddl_command() FROM PUBLIC;
CREATE EVENT TRIGGER audit_ddl_public
ON ddl_command_end
EXECUTE FUNCTION public.log_ddl_command();
-- ============================================================
-- Fin de migración. NO aplicar en el VPS sin aprobación explícita del
-- director — ver ETAPA_AUDIT_LOG_PROMPT.md §4.3.
-- ============================================================

View File

@@ -0,0 +1,14 @@
-- Activar required en todas las preguntas del formulario de cuidadores
-- excepto barrio (2.3) y secciones (type = 'section')
UPDATE public.questions
SET required = true
WHERE survey_id = '30000000-0000-0000-0000-000000000001'
AND code != '2.3'
AND type != 'section';
-- Verificar: debe mostrar todas las preguntas con required=true excepto 2.3
SELECT code, type, required
FROM public.questions
WHERE survey_id = '30000000-0000-0000-0000-000000000001'
AND type != 'section'
ORDER BY position;

View File

@@ -0,0 +1,70 @@
import { test, expect, type Page, type BrowserContext } from '@playwright/test'
// Credenciales SIEMPRE desde .env.test (playwright.config.ts las carga) — nunca acá.
const EMAIL = process.env.E2E_EMAIL!
const PASSWORD = process.env.E2E_PASSWORD!
// Survey Demo — la única con datos sembrados (51 respuestas, ver
// docs/estado/ESTADO_PROYECTO_26JUN2026.md §4). Necesaria como ?s= en el
// formulario público: con 3 surveys en `live`, `/` sin parámetro es ambiguo
// (app/page.tsx usa .maybeSingle() sobre status='live').
const DEMO_SURVEY_ID = '30000000-0000-0000-0000-000000000001'
test.describe.serial('Sprint 5 — verificación post-deploy', () => {
let context: BrowserContext
let page: Page
test.beforeAll(async ({ browser }) => {
context = await browser.newContext()
page = await context.newPage()
})
test.afterAll(async () => {
await context.close()
})
test('1. /admin/responses sin sesión redirige a /login', async () => {
await page.goto('/admin/responses')
await expect(page).toHaveURL(/\/login/)
await expect(page.getByRole('textbox', { name: 'Email' })).toBeVisible()
await expect(page.getByRole('textbox', { name: 'Contraseña' })).toBeVisible()
await expect(page.getByRole('button', { name: /contraseña/i })).toBeVisible()
})
test('2. login funcional redirige a /admin/responses con filtro y datos', async () => {
await page.getByRole('textbox', { name: 'Email' }).fill(EMAIL)
await page.getByRole('textbox', { name: 'Contraseña' }).fill(PASSWORD)
await page.getByRole('button', { name: 'Ingresar' }).click()
await expect(page).toHaveURL(/\/admin\/responses/)
await expect(page.locator('select.admin-survey-select')).toBeVisible()
await expect(page.locator('table.admin-table tbody tr').first()).toBeVisible()
})
test('3. vista detallada de una respuesta', async () => {
await page.locator('a.admin-action-link', { hasText: 'Ver' }).first().click()
await expect(page).toHaveURL(/\/admin\/responses\/[0-9a-f-]{36}/)
await expect(page.locator('.admin-detail-section').first()).toBeVisible()
await expect(page.getByText('Respuesta no encontrada')).toHaveCount(0)
})
test('4. dashboard muestra datos reales (no todo suprimido por k<5)', async () => {
await page.goto('/dashboard')
await expect(page.locator('.db-widget').first()).toBeVisible()
await expect(page.locator('body')).toContainText('%')
const totalWidgets = await page.locator('.db-widget').count()
const suppressed = await page.locator('.db-suppressed').count()
expect(totalWidgets).toBeGreaterThan(0)
expect(suppressed).toBeLessThan(totalWidgets)
})
test('5. formulario público accesible sin auth', async ({ browser }) => {
const anonContext = await browser.newContext()
const anonPage = await anonContext.newPage()
await anonPage.goto(`/?s=${DEMO_SURVEY_ID}`)
await expect(anonPage).not.toHaveURL(/\/login/)
await expect(
anonPage.locator('.consent-container, .wizard-shell').first()
).toBeVisible()
await anonContext.close()
})
})

View File

@@ -0,0 +1,211 @@
import { test, expect, type BrowserContext, type Page } from '@playwright/test'
const DEMO_SURVEY_ID = '30000000-0000-0000-0000-000000000001'
const SURVEY_URL = `/?s=${DEMO_SURVEY_ID}`
// Devuelve el código de la pregunta visible (ej: '2.3'), o null si estamos en una sección.
async function currentCode(page: Page): Promise<string | null> {
const el = page.locator('.wizard-prompt-code')
if (await el.count() === 0) return null
return (await el.textContent())?.trim() ?? null
}
// Navega hacia adelante hasta que la pregunta con `targetCode` sea visible.
// Maneja preguntas required (Q1.1) y screens de sección intermedias.
async function navigateUntil(page: Page, targetCode: string, maxSteps = 30): Promise<void> {
for (let i = 0; i < maxSteps; i++) {
if (await currentCode(page) === targetCode) return
// Si hay error de validación (required sin respuesta), responder primero
if (await page.locator('.field-error').count() > 0) {
await page.locator('.option-label').first().click()
await page.waitForTimeout(150)
continue
}
await page.getByRole('button', { name: 'Siguiente' }).click()
// Esperar a que React aplique el state update y re-renderice
await page.waitForTimeout(150)
}
const code = await currentCode(page)
if (code !== targetCode) throw new Error(`navigateUntil: llegué a ${code}, esperaba ${targetCode}`)
}
// Acepta el consent y espera a que el wizard esté visible.
async function acceptConsent(page: Page): Promise<void> {
await page.check('#consent-operativo')
await page.getByRole('button', { name: 'Comenzar la encuesta' }).click()
await page.locator('.wizard-shell').waitFor()
}
test.describe.serial('Sprint 6 — usabilidad y cache geo', () => {
let context: BrowserContext
let page: Page
const geoRequests: string[] = []
test.beforeAll(async ({ browser }) => {
context = await browser.newContext()
page = await context.newPage()
// Interceptar requests geo antes de navegar (para Test 1)
page.on('request', (req) => {
if (req.url().includes('geo_paraguay_full.json')) geoRequests.push(req.url())
})
})
test.afterAll(async () => {
await context.close()
})
// ─────────────────────────────────────────────────────────────
// Test 1 — Cache geo: un solo fetch para barrio y ciudad
// ─────────────────────────────────────────────────────────────
test('1. cache geo: un solo fetch para barrio y ciudad', async () => {
await page.goto(SURVEY_URL)
await acceptConsent(page)
// Navegar hasta Q2.2 (CiudadSelectField monta → geo request se dispara aquí)
await navigateUntil(page, '2.2')
expect(await currentCode(page)).toBe('2.2')
// Avanzar a Q2.3 (BarrioAutocompleteField monta → debe usar cache, no nueva request)
await page.getByRole('button', { name: 'Siguiente' }).click()
await page.waitForTimeout(200)
expect(await currentCode(page)).toBe('2.3')
// Solo debe haber habido 1 request al JSON de geo (Q2.2 lo cargó, Q2.3 usó el cache)
expect(geoRequests.length).toBe(1)
})
// ─────────────────────────────────────────────────────────────
// Test 2 — Estado loading del campo geo
// ─────────────────────────────────────────────────────────────
test('2. estado loading del campo geo', async () => {
// Estamos en Q2.3 (del test anterior). Volver a Q2.2 para observar el estado del campo.
await page.getByRole('button', { name: 'Anterior' }).click()
await page.waitForTimeout(200)
expect(await currentCode(page)).toBe('2.2')
// CiudadSelectField: input con clase .text-short-input
const input = page.locator('input.text-short-input').first()
await expect(input).toBeVisible()
const isDisabled = await input.isDisabled()
if (isDisabled) {
// Estado loading capturado — verificar placeholder correcto
const placeholder = await input.getAttribute('placeholder')
expect(placeholder).toBe('Cargando opciones...')
}
// En cualquier caso, el campo debe estar (o volverse) habilitado
await expect(input).toBeEnabled({ timeout: 5000 })
// Volver a Q2.3 para el siguiente test
await page.getByRole('button', { name: 'Siguiente' }).click()
await page.waitForTimeout(200)
expect(await currentCode(page)).toBe('2.3')
})
// ─────────────────────────────────────────────────────────────
// Test 3 — Texto libre funciona en campo barrio
// ─────────────────────────────────────────────────────────────
test('3. texto libre funciona en campo barrio', async () => {
// Estamos en Q2.3 (BarrioAutocompleteField)
expect(await currentCode(page)).toBe('2.3')
const barrioCampo = page.locator('input.text-short-input')
await expect(barrioCampo).toBeVisible()
await expect(barrioCampo).toBeEnabled()
// Escribir un barrio que no existe en el JSON (texto libre)
await barrioCampo.fill('Barrio Inexistente XYZ')
await expect(barrioCampo).toHaveValue('Barrio Inexistente XYZ')
// Avanzar — campo es opcional, texto libre debe aceptarse sin error
await page.getByRole('button', { name: 'Siguiente' }).click()
await page.waitForTimeout(200)
await expect(page.locator('.field-error')).toHaveCount(0)
await expect(page.locator('.wizard-shell')).toBeVisible()
})
// ─────────────────────────────────────────────────────────────
// Test 4 — Draft persiste tras recarga
// ─────────────────────────────────────────────────────────────
test('4. draft persiste tras recarga', async () => {
// Volver a Q2.3 para sobreescribir el draft con un valor reconocible
await page.getByRole('button', { name: 'Anterior' }).click()
await page.waitForTimeout(200)
expect(await currentCode(page)).toBe('2.3')
const barrioCampo = page.locator('input.text-short-input')
await barrioCampo.fill('Barrio Test Draft')
await expect(barrioCampo).toHaveValue('Barrio Test Draft')
// Avanzar — handleNext guarda el draft inmediatamente (no debounce)
await page.getByRole('button', { name: 'Siguiente' }).click()
await page.waitForTimeout(300)
// Verificar que el draft fue guardado antes de recargar
const draftRaw = await page.evaluate(
(id) => localStorage.getItem(`survey_draft_${id}`),
DEMO_SURVEY_ID,
)
expect(draftRaw, 'draft debe existir en localStorage antes del reload').not.toBeNull()
expect(draftRaw, 'draft debe contener el valor del barrio').toContain('Barrio Test Draft')
// Recargar
await page.reload()
// Verificar que el draft sobrevive el reload
const draftAfterReload = await page.evaluate(
(id) => localStorage.getItem(`survey_draft_${id}`),
DEMO_SURVEY_ID,
)
expect(draftAfterReload, 'draft debe persistir después del reload').not.toBeNull()
await acceptConsent(page)
// El indicador de restauración es el valor de Q2.3 — navegamos hasta ahí
// (navigateUntil funciona desde cualquier pantalla de inicio, incluyendo S1)
await navigateUntil(page, '2.3')
await expect(page.locator('input.text-short-input')).toHaveValue('Barrio Test Draft')
})
// ─────────────────────────────────────────────────────────────
// Test 5 — Submit con error de red muestra estado error del wizard
// ─────────────────────────────────────────────────────────────
test('5. submit con error de red muestra estado error del wizard', async ({ browser }) => {
const ctx5 = await browser.newContext()
const page5 = await ctx5.newPage()
// Pre-rellenar Q1.1 (única pregunta required) para no bloquearnos al avanzar
await page5.goto(SURVEY_URL)
await page5.evaluate((id) => {
localStorage.setItem(`survey_draft_${id}`, JSON.stringify({ '1.1:default': 'true' }))
}, DEMO_SURVEY_ID)
await acceptConsent(page5)
// Navegar hasta el end screen: todas las preguntas restantes son opcionales
let iters = 0
while (
(await page5.locator('button', { hasText: 'Enviar encuesta' }).count()) === 0 &&
iters < 70
) {
await page5.getByRole('button', { name: 'Siguiente' }).click()
await page5.waitForTimeout(100)
iters++
}
await expect(page5.getByRole('button', { name: 'Enviar encuesta' })).toBeVisible()
// Poner el contexto offline para simular error de red en el submit
await page5.context().setOffline(true)
await page5.getByRole('button', { name: 'Enviar encuesta' }).click()
// El wizard debe mostrar su propio estado de error (no un React error boundary)
await expect(page5.locator('.error-container')).toBeVisible({ timeout: 15000 })
await expect(page5.getByText(/Application error/i)).toHaveCount(0)
await expect(page5.getByRole('button', { name: /Volver e intentar/i })).toBeVisible()
await page5.context().setOffline(false)
await ctx5.close()
})
})