Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf09346f29 | ||
|
|
9461ee1e2e | ||
|
|
3bf92f015c | ||
|
|
67042b0941 | ||
|
|
7efab390da | ||
|
|
4611fb2363 | ||
|
|
d211382e4d | ||
|
|
e29752644a | ||
|
|
704d2407e8 | ||
|
|
5472043a33 | ||
|
|
0fdee073a6 | ||
|
|
6a09ce9ee5 | ||
|
|
af751d771d | ||
|
|
cd87ddb18e | ||
|
|
6d9cf918f9 | ||
|
|
a0db888f2e | ||
|
|
a0253b4f90 | ||
|
|
a660928085 | ||
|
|
30cd83ffc1 | ||
|
|
ca3af1a750 | ||
|
|
b3f039fbd5 | ||
|
|
430bf7a5da | ||
|
|
10bb7299a2 | ||
|
|
dda4b5320a | ||
|
|
44d1f4b9d9 | ||
|
|
a484ba7849 | ||
|
|
ddaba6866a |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -14,6 +14,11 @@ supabase/.temp/
|
|||||||
# Node
|
# Node
|
||||||
node_modules/
|
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.js build output
|
||||||
.next/
|
.next/
|
||||||
|
|
||||||
|
|||||||
75
TECH_DEBT.md
75
TECH_DEBT.md
@@ -77,14 +77,15 @@
|
|||||||
|
|
||||||
## [TECH-DEBT-008] /admin/responses sin autenticación
|
## [TECH-DEBT-008] /admin/responses sin autenticación
|
||||||
|
|
||||||
- **Estado:** ABIERTO.
|
- **Estado:** RESUELTO (27 jun 2026, ETAPA_AUTH).
|
||||||
- **Descripción:** la vista de control `/admin/responses` no tiene auth. Por diseño
|
- **Descripción:** la vista de control `/admin/responses` no tenía auth. Se implementó
|
||||||
explícito, excluye preguntas `is_sensitive=true` (14 preguntas de salud/
|
login con Supabase Auth (`@supabase/ssr`), `middleware.ts` protegiendo
|
||||||
discapacidad) y `text_long`. Para mostrar esas preguntas en la vista, primero
|
`/admin/:path*` y `/dashboard/:path*` con redirect a `/login` sin sesión, y la página
|
||||||
implementar un gate de autenticación para staff (Basic Auth con env var como
|
migrada de `service_role` a la sesión del usuario (`anon` key + RLS). Sigue
|
||||||
mínimo viable).
|
excluyendo preguntas `is_sensitive=true` y `text_long` — ese diseño no cambió.
|
||||||
- **Impacto actual:** ninguno en producción — la URL no está publicada.
|
- **Nota:** la vista no distingue roles dentro de staff todavía (todo usuario con fila
|
||||||
- **Bloqueante para:** ampliar visibilidad de datos en la vista de control.
|
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
|
- **Trigger:** evaluar si la Mirada País/Humana también necesitan segmentar por
|
||||||
modalidad con frecuencia — si varias RPCs repiten el mismo JOIN, conviene
|
modalidad con frecuencia — si varias RPCs repiten el mismo JOIN, conviene
|
||||||
promoverla de una vez en lugar de cada RPC reimplementando el JOIN.
|
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
106
app/actions/invite-user.ts
Normal 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 }
|
||||||
|
}
|
||||||
274
app/admin/responses/[id]/page.tsx
Normal file
274
app/admin/responses/[id]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
app/admin/responses/admin-format.ts
Normal file
27
app/admin/responses/admin-format.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { 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'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
const MAX_SUBMISSIONS = 500
|
const PAGE_SIZE = 50
|
||||||
const PROMPT_MAX_LEN = 60
|
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 {
|
interface AllowedQuestion {
|
||||||
id: string
|
id: string
|
||||||
code: string
|
code: string
|
||||||
@@ -36,56 +31,77 @@ interface ResponseRow {
|
|||||||
answers: AnswerRow[]
|
answers: AnswerRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSupabaseAdmin() {
|
interface SurveyOption {
|
||||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co'
|
id: string
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'placeholder'
|
title: string
|
||||||
return createClient(url, serviceKey, { auth: { persistSession: false } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
async function getSupabaseSession() {
|
||||||
const d = new Date(iso)
|
const cookieStore = await cookies()
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
return createServerClient(
|
||||||
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
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 {
|
function truncate(text: string, max = PROMPT_MAX_LEN): string {
|
||||||
return text.length > max ? `${text.slice(0, max)}...` : text
|
return text.length > max ? `${text.slice(0, max)}...` : text
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spec 4.3 — null/undefined → null (renderizado como badge "sin dato")
|
function pageHref(surveyId: string | undefined, page: number): string {
|
||||||
function renderValue(value: unknown, type: string): string | null {
|
const params = new URLSearchParams()
|
||||||
if (value === null || value === undefined) return null
|
if (surveyId) params.set('survey_id', surveyId)
|
||||||
|
params.set('page', String(page))
|
||||||
switch (type) {
|
return `/admin/responses?${params.toString()}`
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminResponsesPage({
|
export default async function AdminResponsesPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ survey_id?: string }>
|
searchParams: Promise<{ survey_id?: string; page?: string }>
|
||||||
}) {
|
}) {
|
||||||
const { survey_id: surveyId } = await searchParams
|
const { survey_id: surveyId, page: pageParam } = await searchParams
|
||||||
const supabase = getSupabaseAdmin()
|
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Á,
|
// 1. Preguntas permitidas — text_long e is_sensitive=true se excluyen ACÁ,
|
||||||
// antes de tocar la tabla answers. Sus valores nunca se consultan.
|
// antes de tocar la tabla answers. Sus valores nunca se consultan.
|
||||||
const { data: questionsData, error: questionsError } = await supabase
|
const [
|
||||||
.from('questions')
|
{ data: surveysData, error: surveysError },
|
||||||
.select('id, code, prompt, type, position')
|
{ data: questionsData, error: questionsError },
|
||||||
.eq('is_sensitive', false)
|
] = await Promise.all([
|
||||||
.neq('type', 'text_long')
|
supabase.from('surveys').select('id, title').order('title'),
|
||||||
.order('position', { ascending: true })
|
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) {
|
if (questionsError) {
|
||||||
return (
|
return (
|
||||||
@@ -103,6 +119,7 @@ export default async function AdminResponsesPage({
|
|||||||
|
|
||||||
// 2. Submissions con sus respuestas — answers!inner + .in() filtra el ARRAY
|
// 2. Submissions con sus respuestas — answers!inner + .in() filtra el ARRAY
|
||||||
// embebido a solo question_id permitidos (consulta, no post-filtro).
|
// 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
|
let responsesQuery = supabase
|
||||||
.from('responses')
|
.from('responses')
|
||||||
.select(
|
.select(
|
||||||
@@ -120,10 +137,44 @@ export default async function AdminResponsesPage({
|
|||||||
responsesQuery = responsesQuery.eq('survey_id', surveyId)
|
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
|
allowedIds.length > 0
|
||||||
? await responsesQuery.order('submitted_at', { ascending: false }).limit(MAX_SUBMISSIONS)
|
? await Promise.all([
|
||||||
: { data: [] as ResponseRow[], error: null }
|
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) {
|
if (responsesError) {
|
||||||
return (
|
return (
|
||||||
@@ -136,30 +187,7 @@ export default async function AdminResponsesPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const responses = (responsesData ?? []) as unknown as ResponseRow[]
|
const responses = (responsesData ?? []) as unknown as ResponseRow[]
|
||||||
|
const totalPages = Math.max(1, Math.ceil((totalSubmissions ?? 0) / PAGE_SIZE))
|
||||||
// 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 }
|
|
||||||
|
|
||||||
// 4. Aplanar: una fila por answer, ordenadas por position dentro de cada submission
|
// 4. Aplanar: una fila por answer, ordenadas por position dentro de cada submission
|
||||||
const rows: DisplayRow[] = []
|
const rows: DisplayRow[] = []
|
||||||
@@ -176,7 +204,8 @@ export default async function AdminResponsesPage({
|
|||||||
const isFirstInGroup = ai === 0
|
const isFirstInGroup = ai === 0
|
||||||
rows.push({
|
rows.push({
|
||||||
key: `${response.id}-${a.question_id}`,
|
key: `${response.id}-${a.question_id}`,
|
||||||
submissionNum: idx + 1,
|
responseId: response.id,
|
||||||
|
submissionNum: (page - 1) * PAGE_SIZE + idx + 1,
|
||||||
isFirstInGroup,
|
isFirstInGroup,
|
||||||
date: isFirstInGroup ? formatDate(response.submitted_at) : '',
|
date: isFirstInGroup ? formatDate(response.submitted_at) : '',
|
||||||
code: q.code,
|
code: q.code,
|
||||||
@@ -189,40 +218,57 @@ export default async function AdminResponsesPage({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-page" role="main">
|
<>
|
||||||
|
<AdminNavBar active="responses" />
|
||||||
|
<div className="admin-page" role="main">
|
||||||
<header className="admin-header">
|
<header className="admin-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="admin-title">Control de respuestas</h1>
|
<h1 className="admin-title">Control de respuestas</h1>
|
||||||
<p className="admin-subtitle">
|
<p className="admin-subtitle">
|
||||||
{totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales ·
|
{totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales ·{' '}
|
||||||
actualizado {formatDate(new Date().toISOString())}
|
{selectedSurvey ? selectedSurvey.title : 'Todos los surveys'} · actualizado{' '}
|
||||||
|
{formatDate(new Date().toISOString())}
|
||||||
</p>
|
</p>
|
||||||
<nav className="admin-survey-nav" aria-label="Selección de empresa">
|
<p className="admin-subtitle">
|
||||||
{SURVEY_OPTIONS.map((opt) => (
|
Página {page} de {totalPages} · {totalSubmissions ?? 0} submissions totales
|
||||||
<a
|
</p>
|
||||||
key={opt.label}
|
<div className="admin-survey-nav">
|
||||||
href={opt.id ? `/admin/responses?survey_id=${opt.id}` : '/admin/responses'}
|
<SurveySelect surveys={surveys} value={surveyId ?? ''} />
|
||||||
className={
|
</div>
|
||||||
opt.id === (surveyId ?? null)
|
{surveysError && (
|
||||||
? 'admin-survey-link admin-survey-link-active'
|
<p className="admin-error" role="alert">
|
||||||
: 'admin-survey-link'
|
No se pudo cargar la lista de surveys para el filtro — se muestra "Todos".
|
||||||
}
|
</p>
|
||||||
aria-current={opt.id === (surveyId ?? null) ? 'page' : undefined}
|
)}
|
||||||
>
|
</div>
|
||||||
{opt.label}
|
<div className="admin-header-actions">
|
||||||
</a>
|
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="admin-table-wrapper">
|
<div className="admin-table-wrapper">
|
||||||
<ResponsesTable rows={rows} />
|
<ResponsesTable rows={rows} surveyId={surveyId} page={page} />
|
||||||
{rows.length === 0 && (
|
{rows.length === 0 && (
|
||||||
<p className="admin-empty">Sin respuestas registradas todavía.</p>
|
<p className="admin-empty">Sin respuestas registradas todavía.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
export interface DisplayRow {
|
export interface DisplayRow {
|
||||||
key: string
|
key: string
|
||||||
|
responseId: string
|
||||||
submissionNum: number
|
submissionNum: number
|
||||||
isFirstInGroup: boolean
|
isFirstInGroup: boolean
|
||||||
date: string
|
date: string
|
||||||
@@ -14,7 +16,15 @@ export interface DisplayRow {
|
|||||||
sector: string
|
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())
|
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
function toggle(submissionNum: number) {
|
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 (
|
return (
|
||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -37,6 +54,7 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
|
|||||||
<th>Valor</th>
|
<th>Valor</th>
|
||||||
<th className="admin-col-optional">Departamento</th>
|
<th className="admin-col-optional">Departamento</th>
|
||||||
<th className="admin-col-optional">Sector</th>
|
<th className="admin-col-optional">Sector</th>
|
||||||
|
<th>Ver</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -54,28 +72,32 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
|
|||||||
<tr
|
<tr
|
||||||
key={row.key}
|
key={row.key}
|
||||||
className={trClass}
|
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}
|
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>
|
<td>
|
||||||
{row.isFirstInGroup && (
|
{row.isFirstInGroup ? (
|
||||||
<span className="admin-toggle-icon" aria-hidden="true">
|
<button
|
||||||
{isExpanded ? '▼' : '▶'}
|
type="button"
|
||||||
</span>
|
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>
|
||||||
<td>{row.date}</td>
|
<td>{row.date}</td>
|
||||||
<td>{row.code}</td>
|
<td>{row.code}</td>
|
||||||
@@ -89,6 +111,13 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="admin-col-optional">{row.zone}</td>
|
<td className="admin-col-optional">{row.zone}</td>
|
||||||
<td className="admin-col-optional">{row.sector}</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>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
31
app/admin/responses/survey-select.tsx
Normal file
31
app/admin/responses/survey-select.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
app/admin/sign-out-button.tsx
Normal file
24
app/admin/sign-out-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
89
app/admin/usuarios/invite-form.tsx
Normal file
89
app/admin/usuarios/invite-form.tsx
Normal 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
195
app/admin/usuarios/page.tsx
Normal 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
191
app/auth/confirm/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
import { AdminNavBar } from '@/components/admin-nav-bar'
|
||||||
import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
|
import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
|
||||||
import { TalentoRiesgoCard } from '@/components/dashboard/talento-riesgo-card'
|
import { TalentoRiesgoCard } from '@/components/dashboard/talento-riesgo-card'
|
||||||
import { PrevalenciaCuidadoresCard } from '@/components/dashboard/prevalencia-cuidadores-card'
|
import { PrevalenciaCuidadoresCard } from '@/components/dashboard/prevalencia-cuidadores-card'
|
||||||
@@ -46,7 +47,9 @@ export default function DashboardPage() {
|
|||||||
const [lens, setLens] = useState<Lens>('empresa')
|
const [lens, setLens] = useState<Lens>('empresa')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="db-page">
|
<>
|
||||||
|
<AdminNavBar active="dashboard" />
|
||||||
|
<div className="db-page">
|
||||||
<FilterSidebar
|
<FilterSidebar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={setFilters}
|
onChange={setFilters}
|
||||||
@@ -144,6 +147,7 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
204
app/globals.css
204
app/globals.css
@@ -295,6 +295,7 @@ body {
|
|||||||
/* position: fixed + top/left/width set by JS via getBoundingClientRect()
|
/* position: fixed + top/left/width set by JS via getBoundingClientRect()
|
||||||
so the list escapes any overflow:auto ancestor (wizard-answer-area) */
|
so the list escapes any overflow:auto ancestor (wizard-answer-area) */
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1.5px solid var(--color-border-focus);
|
border: 1.5px solid var(--color-border-focus);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -316,7 +317,7 @@ body {
|
|||||||
.barrio-autocomplete-item:hover,
|
.barrio-autocomplete-item:hover,
|
||||||
.barrio-autocomplete-item--active {
|
.barrio-autocomplete-item--active {
|
||||||
background: var(--color-primary-light);
|
background: var(--color-primary-light);
|
||||||
color: var(--color-primary);
|
color: #494F4F;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Textarea ── */
|
/* ── Textarea ── */
|
||||||
@@ -377,7 +378,7 @@ body {
|
|||||||
.btn-primary:hover { background: var(--color-primary-hover); }
|
.btn-primary:hover { background: var(--color-primary-hover); }
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-primary);
|
color: #494F4F;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.btn-secondary:hover { background: var(--color-primary-light); }
|
.btn-secondary:hover { background: var(--color-primary-light); }
|
||||||
@@ -428,7 +429,7 @@ body {
|
|||||||
}
|
}
|
||||||
.summary-code {
|
.summary-code {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-primary);
|
color: #494F4F;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
.summary-answer {
|
.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-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-input { width: 20px; height: 20px; flex-shrink: 0; margin-top: 1px; accent-color: var(--color-primary); cursor: pointer; }
|
||||||
.consent-checkbox-text { flex: 1; }
|
.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-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-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); }
|
.consent-required-hint { margin: 0; font-size: 0.875rem; color: var(--color-text-muted); }
|
||||||
@@ -566,7 +567,7 @@ body {
|
|||||||
.wizard-section-number {
|
.wizard-section-number {
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-primary);
|
color: #494F4F;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
.wizard-section-title {
|
.wizard-section-title {
|
||||||
@@ -583,9 +584,8 @@ body {
|
|||||||
margin: 0;
|
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 {
|
.wizard-prompt-block {
|
||||||
min-height: 116px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
@@ -659,6 +659,7 @@ body {
|
|||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -673,6 +674,11 @@ body {
|
|||||||
.wizard-prompt-text { font-size: 1.1875rem; }
|
.wizard-prompt-text { font-size: 1.1875rem; }
|
||||||
.wizard-section-title { font-size: 1.5rem; }
|
.wizard-section-title { font-size: 1.5rem; }
|
||||||
.wizard-answer-area { max-height: 400px; }
|
.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 ── */
|
/* ── Loading skeleton ── */
|
||||||
@@ -964,9 +970,8 @@ body {
|
|||||||
/* ── Card destacada (estrella) ── */
|
/* ── Card destacada (estrella) ── */
|
||||||
.db-widget-star {
|
.db-widget-star {
|
||||||
border-color: var(--color-re-teal);
|
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) ── */
|
/* ── 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; }
|
.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-title { font-size: 1rem; font-weight: 600; color: #494F4F; }
|
||||||
.db-placeholder-hint { font-size: 0.8125rem; max-width: 420px; line-height: 1.5; }
|
.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: control de respuestas (Etapa 3V) ── */
|
||||||
.admin-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
.admin-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
||||||
.admin-header {
|
.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-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-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-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 {
|
.admin-refresh {
|
||||||
display: inline-block; padding: 8px 16px; border-radius: var(--radius);
|
display: inline-block; padding: 8px 16px; border-radius: var(--radius);
|
||||||
background: var(--color-primary); color: #fff; font-size: 0.8125rem;
|
background: var(--color-primary); color: #fff; font-size: 0.8125rem;
|
||||||
font-weight: 600; text-decoration: none; white-space: nowrap;
|
font-weight: 600; text-decoration: none; white-space: nowrap;
|
||||||
}
|
}
|
||||||
.admin-refresh:hover { background: var(--color-primary-hover); }
|
.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 {
|
.admin-table-wrapper {
|
||||||
overflow-x: auto; background: var(--color-surface); border: 1px solid var(--color-border);
|
overflow-x: auto; background: var(--color-surface); border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius); box-shadow: var(--shadow-card);
|
border-radius: var(--radius); box-shadow: var(--shadow-card);
|
||||||
@@ -1035,6 +1082,10 @@ body {
|
|||||||
display: inline-block; width: 14px; margin-right: 4px;
|
display: inline-block; width: 14px; margin-right: 4px;
|
||||||
color: var(--color-text-muted); font-size: 0.7rem;
|
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 {
|
.admin-nodata {
|
||||||
display: inline-block; padding: 1px 8px; border-radius: 10px;
|
display: inline-block; padding: 1px 8px; border-radius: 10px;
|
||||||
background: var(--color-re-light); color: var(--color-re-dark);
|
background: var(--color-re-light); color: var(--color-re-dark);
|
||||||
@@ -1060,3 +1111,124 @@ body {
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
*, *::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
197
app/login/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
components/admin-nav-bar.tsx
Normal file
34
components/admin-nav-bar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
|
import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
|
||||||
|
import { getGeoData, type GeoData } from '@/lib/geo-cache'
|
||||||
type GeoData = Record<string, Record<string, string[]>>
|
|
||||||
|
|
||||||
interface BarrioAutocompleteFieldProps {
|
interface BarrioAutocompleteFieldProps {
|
||||||
name: string
|
name: string
|
||||||
@@ -25,16 +24,16 @@ export function BarrioAutocompleteField({
|
|||||||
error,
|
error,
|
||||||
}: BarrioAutocompleteFieldProps) {
|
}: BarrioAutocompleteFieldProps) {
|
||||||
const [geoData, setGeoData] = useState<GeoData | null>(null)
|
const [geoData, setGeoData] = useState<GeoData | null>(null)
|
||||||
|
const [geoStatus, setGeoStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [activeIdx, setActiveIdx] = useState(-1)
|
const [activeIdx, setActiveIdx] = useState(-1)
|
||||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({})
|
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({})
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/data/geo_paraguay_full.json')
|
getGeoData()
|
||||||
.then((r) => r.json())
|
.then((data) => { setGeoData(data); setGeoStatus('ready') })
|
||||||
.then(setGeoData)
|
.catch(() => setGeoStatus('error'))
|
||||||
.catch(() => {})
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Close dropdown on scroll so the fixed-position list doesn't drift from the input
|
// Close dropdown on scroll so the fixed-position list doesn't drift from the input
|
||||||
@@ -126,6 +125,12 @@ export function BarrioAutocompleteField({
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="text-short-input"
|
className="text-short-input"
|
||||||
ref={inputRef}
|
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)}
|
onChange={(e) => handleInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => { if (suggestions.length > 0) { computeDropdownStyle(); setOpen(true) } }}
|
onFocus={() => { if (suggestions.length > 0) { computeDropdownStyle(); setOpen(true) } }}
|
||||||
@@ -147,6 +152,7 @@ export function BarrioAutocompleteField({
|
|||||||
aria-selected={i === activeIdx}
|
aria-selected={i === activeIdx}
|
||||||
className={`barrio-autocomplete-item${i === activeIdx ? ' barrio-autocomplete-item--active' : ''}`}
|
className={`barrio-autocomplete-item${i === activeIdx ? ' barrio-autocomplete-item--active' : ''}`}
|
||||||
onMouseDown={() => select(barrio)}
|
onMouseDown={() => select(barrio)}
|
||||||
|
onTouchEnd={(e) => { e.preventDefault(); select(barrio) }}
|
||||||
>
|
>
|
||||||
{barrio}
|
{barrio}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
|
import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
|
||||||
|
import { getGeoData, type GeoData } from '@/lib/geo-cache'
|
||||||
type GeoData = Record<string, Record<string, string[]>>
|
|
||||||
|
|
||||||
interface CiudadSelectFieldProps {
|
interface CiudadSelectFieldProps {
|
||||||
name: string
|
name: string
|
||||||
@@ -23,6 +22,7 @@ export function CiudadSelectField({
|
|||||||
error,
|
error,
|
||||||
}: CiudadSelectFieldProps) {
|
}: CiudadSelectFieldProps) {
|
||||||
const [geoData, setGeoData] = useState<GeoData | null>(null)
|
const [geoData, setGeoData] = useState<GeoData | null>(null)
|
||||||
|
const [geoStatus, setGeoStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [activeIdx, setActiveIdx] = useState(-1)
|
const [activeIdx, setActiveIdx] = useState(-1)
|
||||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({})
|
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({})
|
||||||
@@ -35,10 +35,9 @@ export function CiudadSelectField({
|
|||||||
const prevDeptoRef = useRef(departamento)
|
const prevDeptoRef = useRef(departamento)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/data/geo_paraguay_full.json')
|
getGeoData()
|
||||||
.then((r) => r.json())
|
.then((data) => { setGeoData(data); setGeoStatus('ready') })
|
||||||
.then(setGeoData)
|
.catch(() => setGeoStatus('error'))
|
||||||
.catch(() => {})
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Clear city when departamento changes so no orphan value remains
|
// Clear city when departamento changes so no orphan value remains
|
||||||
@@ -130,6 +129,12 @@ export function CiudadSelectField({
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="text-short-input"
|
className="text-short-input"
|
||||||
ref={inputRef}
|
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)}
|
onChange={(e) => handleInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => { if (suggestions.length > 0) { computeDropdownStyle(); setOpen(true) } }}
|
onFocus={() => { if (suggestions.length > 0) { computeDropdownStyle(); setOpen(true) } }}
|
||||||
@@ -151,6 +156,7 @@ export function CiudadSelectField({
|
|||||||
aria-selected={i === activeIdx}
|
aria-selected={i === activeIdx}
|
||||||
className={`barrio-autocomplete-item${i === activeIdx ? ' barrio-autocomplete-item--active' : ''}`}
|
className={`barrio-autocomplete-item${i === activeIdx ? ' barrio-autocomplete-item--active' : ''}`}
|
||||||
onMouseDown={() => select(ciudad)}
|
onMouseDown={() => select(ciudad)}
|
||||||
|
onTouchEnd={(e) => { e.preventDefault(); select(ciudad) }}
|
||||||
>
|
>
|
||||||
{ciudad}
|
{ciudad}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hydrated) return
|
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])
|
}, [state.answers, hydrated, survey.id])
|
||||||
|
|
||||||
const screens = resolveScreens(schema, state.answers)
|
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.' }))
|
setErrors((e) => ({ ...e, [screen.question!.code]: 'Este campo es obligatorio.' }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
saveDraft(survey.id, state.answers)
|
||||||
setState((s) => next(s, schema))
|
setState((s) => next(s, schema))
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
|
saveDraft(survey.id, state.answers)
|
||||||
setState((s) => back(s, schema))
|
setState((s) => back(s, schema))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,17 +213,24 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await submitSurvey({
|
try {
|
||||||
surveyId: survey.id,
|
const result = await submitSurvey({
|
||||||
answers,
|
surveyId: survey.id,
|
||||||
consentOperativo,
|
answers,
|
||||||
consentMacroN1,
|
consentOperativo,
|
||||||
})
|
consentMacroN1,
|
||||||
if (result.success) {
|
})
|
||||||
clearDraft(survey.id)
|
if (result.success) {
|
||||||
setStage('success')
|
clearDraft(survey.id)
|
||||||
} else {
|
setStage('success')
|
||||||
setSubmitError(result.error)
|
} 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')
|
setStage('error')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
971
docs/dashboard/seed_prueba_50.sql
Normal file
971
docs/dashboard/seed_prueba_50.sql
Normal 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', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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 (1–3 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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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 (1–3 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', '5–6'),
|
||||||
|
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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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 (4–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', 'No por ahora'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
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', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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 (1–3 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', 'Sí'),
|
||||||
|
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', '3–4'),
|
||||||
|
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', '3–4'),
|
||||||
|
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 (4–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', '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', 'Sí'),
|
||||||
|
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', '3–4'),
|
||||||
|
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', '3–4'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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 (1–3 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', 'Sí'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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 (1–3 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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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 (1–3 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', 'Sí'),
|
||||||
|
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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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 (4–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', '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', 'Sí'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
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 (4–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', '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', 'Sí'),
|
||||||
|
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', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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 (4–10 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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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 (1–3 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', '3–4'),
|
||||||
|
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 (1–3 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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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 (1–3 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', 'Sí'),
|
||||||
|
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', '3–4'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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', '1–2'),
|
||||||
|
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', '3–4'),
|
||||||
|
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 (1–3 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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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', '5–6'),
|
||||||
|
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 (1–3 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', 'Sí'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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 (1–3 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', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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 (4–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', '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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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-%';
|
||||||
975
docs/dashboard/seed_prueba_fundacion_solidaridad.sql
Normal file
975
docs/dashboard/seed_prueba_fundacion_solidaridad.sql
Normal 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', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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 (1–3 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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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 (1–3 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', '5–6'),
|
||||||
|
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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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 (4–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', 'No por ahora'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
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', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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 (1–3 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', 'Sí'),
|
||||||
|
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', '3–4'),
|
||||||
|
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', '3–4'),
|
||||||
|
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 (4–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', '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', 'Sí'),
|
||||||
|
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', '3–4'),
|
||||||
|
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', '3–4'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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 (1–3 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', 'Sí'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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 (1–3 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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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 (1–3 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', 'Sí'),
|
||||||
|
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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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 (4–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', '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', 'Sí'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
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 (4–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', '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', 'Sí'),
|
||||||
|
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', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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 (4–10 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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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 (1–3 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', '3–4'),
|
||||||
|
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 (1–3 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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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 (1–3 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', 'Sí'),
|
||||||
|
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', '3–4'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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', '1–2'),
|
||||||
|
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', '3–4'),
|
||||||
|
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 (1–3 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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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', '5–6'),
|
||||||
|
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 (1–3 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', 'Sí'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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 (1–3 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', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
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 (4–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', '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', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
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', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
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', 'Sí'),
|
||||||
|
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-%';
|
||||||
169
docs/estado/ESTADO_PROYECTO_26JUN2026.md
Normal file
169
docs/estado/ESTADO_PROYECTO_26JUN2026.md
Normal 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]
|
||||||
|
```
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado
|
# 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
|
> Director (Product Owner): Marcos · Claude (Projects) = arquitecto/copiloto · Claude Code = implementación
|
||||||
> Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
|
> Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
|
||||||
@@ -7,17 +7,14 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 1. Versionado y ramas
|
## 1. Versionado y ramas
|
||||||
- **Producción:** 2.1.0 (formulario lineal) — los 3 surveys en `draft`
|
- **Producción:** 3.0.0 (wizard + dashboard tres miradas) — tag `v3.0.0` pusheado, los 3 surveys
|
||||||
- **Desarrollo:** 3.0.0 (wizard + dashboard nuevo) en `main`, NO desplegado aún
|
en `live`. Release v3 completado (etapa 4I).
|
||||||
- **Preservación:** `v2.1.0-stable` + tag `v2.1.0`
|
- **Preservación:** `v2.1.0-stable` + tag `v2.1.0`
|
||||||
- SemVer + CHANGELOG + tags. ADRs en `docs/adr/`.
|
- 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 |
|
| Etapa | Estado |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -28,26 +25,41 @@ Se reactivan a `live` al desplegar v3 completo.
|
|||||||
| 4D wizard renderer (layout estable) | ✅ |
|
| 4D wizard renderer (layout estable) | ✅ |
|
||||||
| 4E instancias (emergente del motor) | ✅ |
|
| 4E instancias (emergente del motor) | ✅ |
|
||||||
| 4G submit + validación server-side (RPC transaccional) | ✅ verificado vs Postgres real |
|
| 4G submit + validación server-side (RPC transaccional) | ✅ verificado vs Postgres real |
|
||||||
| 4F autocomplete barrios (UX) | ⏳ pendiente |
|
| 4F autocomplete barrios (UX) | ✅ |
|
||||||
| 4I validación e2e + tag v3.0.0 + desplegar + reactivar surveys | ⏳ pendiente |
|
| 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
|
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,
|
**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).
|
dashboard nuevo de tres miradas, fundamentado en marcos reales (OIT 5R, CEPAL, PNCUPA Paraguay).
|
||||||
|
|
||||||
### Estado
|
### Estado
|
||||||
- **Mirada Empresa:** ✅ RPCs implementadas y VERIFICADAS contra 50 respuestas reales.
|
- **Mirada Empresa:** ✅ RPCs + widgets de frontend completos y en producción. E1-E10 + estrella
|
||||||
E1-E4 exactos, estrella "Talento en riesgo" con umbral parametrizable, E9 segmentación
|
"Talento en riesgo", k≥5 en segmentación. Tema claro aplicado.
|
||||||
con k≥5. Migraciones `20260625000001` (fix helper) + `20260625000002` (11 RPCs). Commiteado.
|
|
||||||
Widgets del frontend: PENDIENTE (etapa siguiente).
|
|
||||||
- **Mirada País y Mirada Humana:** propuesta + mockup listos, implementación DIFERIDA a
|
- **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/)
|
### Documentos (en docs/dashboard/)
|
||||||
- `PROPUESTA_DASHBOARD_v3.md` — las 3 miradas, ~30 métricas, marcos citados honestamente
|
- `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
|
## 4. Deuda técnica y seguridad
|
||||||
|
|
||||||
|
~~TECH-DEBT-008 (`/admin/responses` sin autenticación)~~ — **resuelto** por ETAPA_AUTH (27 jun).
|
||||||
|
|
||||||
| ID | Descripción | Prioridad |
|
| ID | Descripción | Prioridad |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| TECH-DEBT-006 | Backup remoto S3 | Antes de datos reales |
|
| TECH-DEBT-006 | Backup remoto S3 | Antes de datos reales |
|
||||||
| TECH-DEBT-009 | Rotar secrets post-CVE | 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 |
|
| 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-002 | Sprint compliance (audit log, hashing, cifrado en reposo) | Sprint dedicado |
|
||||||
| SEC-003 | Agujero TRUNCATE evade ADR-003 | Antes de datos reales |
|
| 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 |
|
| SEC-004 | RPC validable por bypass REST (coherencia de flujo en Server Action) | Sprint futuro |
|
||||||
| BACKLOG-002 | Fase 2: motor genérico | Post-deadline |
|
| 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
|
## 5. Infraestructura
|
||||||
@@ -87,14 +108,19 @@ dashboard nuevo de tres miradas, fundamentado en marcos reales (OIT 5R, CEPAL, P
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 6. Próximos pasos
|
## 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
142
docs/guias/GUIA_PRUEBA_FORMULARIO_Y_DASHBOARD.md
Normal file
142
docs/guias/GUIA_PRUEBA_FORMULARIO_Y_DASHBOARD.md
Normal 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
16
lib/geo-cache.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -11,3 +11,14 @@ export function createClient() {
|
|||||||
|
|
||||||
return createSupabaseClient(url, anonKey)
|
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
43
middleware.ts
Normal 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
141
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@supabase/ssr": "^0.12.0",
|
||||||
"@supabase/supabase-js": "^2.49.8",
|
"@supabase/supabase-js": "^2.49.8",
|
||||||
"next": "15.3.8",
|
"next": "15.3.8",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/cli": "^4.11.3",
|
"@axe-core/cli": "^4.11.3",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/postcss": "^4.1.8",
|
"@tailwindcss/postcss": "^4.1.8",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
@@ -1040,6 +1042,22 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@standard-schema/utils": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
@@ -1047,9 +1065,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/auth-js": {
|
"node_modules/@supabase/auth-js": {
|
||||||
"version": "2.107.0",
|
"version": "2.108.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.107.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz",
|
||||||
"integrity": "sha512-XA7x+WIeIvuC3GTZ2ey67QcBbGw4n+o5B7M+dMm9KT1lL3wX1B52DfEWW00WuPt/LnniJLLIn1WIm9YPtuxzKQ==",
|
"integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
@@ -1183,9 +1201,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/functions-js": {
|
"node_modules/@supabase/functions-js": {
|
||||||
"version": "2.107.0",
|
"version": "2.108.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.107.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz",
|
||||||
"integrity": "sha512-iMtRUmEj1KOgQd/a3MR4hnBlPnZc62DW8+z8aPpnzbxWkexEZUVL2fSgvvp15gqFg1V55e2yMGqgK+yhSQxp5w==",
|
"integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
@@ -1195,15 +1213,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/phoenix": {
|
"node_modules/@supabase/phoenix": {
|
||||||
"version": "0.4.2",
|
"version": "0.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz",
|
||||||
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
|
"integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/postgrest-js": {
|
"node_modules/@supabase/postgrest-js": {
|
||||||
"version": "2.107.0",
|
"version": "2.108.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.107.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz",
|
||||||
"integrity": "sha512-7ARs47/tyIjX7T0Ive20d4NY8zQYXsP5/P07jJWxffSIM2gpnSnGRnL/Fe15GPbdjsW2sTYeckHcyaoKbM6yWQ==",
|
"integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
@@ -1213,9 +1231,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/realtime-js": {
|
"node_modules/@supabase/realtime-js": {
|
||||||
"version": "2.107.0",
|
"version": "2.108.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.107.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz",
|
||||||
"integrity": "sha512-cF2KYdR3JIn9YlWGeluY9S0G+otqTdL6hB8GzpatlEIY6fZudCcyFo6Dc3+X9tjeb+x9XcIyNAk9qhNAknjH1A==",
|
"integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/phoenix": "^0.4.2",
|
"@supabase/phoenix": "^0.4.2",
|
||||||
@@ -1225,10 +1243,22 @@
|
|||||||
"node": ">=20.0.0"
|
"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": {
|
"node_modules/@supabase/storage-js": {
|
||||||
"version": "2.107.0",
|
"version": "2.108.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.107.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz",
|
||||||
"integrity": "sha512-/X8OOVwKBn8aVKuHAGOz2yLA0d2OauqhVuy4mNtN+o7wttHOgx1/j+pqOzlsjmhOHrYykF6AJNZhs3gKZzcMUw==",
|
"integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"iceberg-js": "^0.8.1",
|
"iceberg-js": "^0.8.1",
|
||||||
@@ -1239,16 +1269,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/supabase-js": {
|
"node_modules/@supabase/supabase-js": {
|
||||||
"version": "2.107.0",
|
"version": "2.108.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.107.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz",
|
||||||
"integrity": "sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==",
|
"integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/auth-js": "2.107.0",
|
"@supabase/auth-js": "2.108.2",
|
||||||
"@supabase/functions-js": "2.107.0",
|
"@supabase/functions-js": "2.108.2",
|
||||||
"@supabase/postgrest-js": "2.107.0",
|
"@supabase/postgrest-js": "2.108.2",
|
||||||
"@supabase/realtime-js": "2.107.0",
|
"@supabase/realtime-js": "2.108.2",
|
||||||
"@supabase/storage-js": "2.107.0"
|
"@supabase/storage-js": "2.108.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -1789,6 +1819,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/core-util-is": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
@@ -2109,6 +2152,20 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -2976,6 +3033,38 @@
|
|||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@supabase/ssr": "^0.12.0",
|
||||||
"@supabase/supabase-js": "^2.49.8",
|
"@supabase/supabase-js": "^2.49.8",
|
||||||
"next": "15.3.8",
|
"next": "15.3.8",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/cli": "^4.11.3",
|
"@axe-core/cli": "^4.11.3",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/postcss": "^4.1.8",
|
"@tailwindcss/postcss": "^4.1.8",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|||||||
14
playwright.config.ts
Normal file
14
playwright.config.ts
Normal 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
20
scripts/apply_all_migrations.sh
Executable 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
45
scripts/setup_all.sh
Executable 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."
|
||||||
45
scripts/setup_first_admin.sql
Normal file
45
scripts/setup_first_admin.sql
Normal 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;
|
||||||
113
sprints/sprint-4/ETAPA_4I_PROMPT.md
Normal file
113
sprints/sprint-4/ETAPA_4I_PROMPT.md
Normal 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.
|
||||||
148
sprints/sprint-4/ETAPA_AUTH_PROMPT.md
Normal file
148
sprints/sprint-4/ETAPA_AUTH_PROMPT.md
Normal 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.
|
||||||
|
```
|
||||||
154
sprints/sprint-5/ETAPA_ADMIN_PROMPT.md
Normal file
154
sprints/sprint-5/ETAPA_ADMIN_PROMPT.md
Normal 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.
|
||||||
|
```
|
||||||
124
sprints/sprint-5/ETAPA_AUDIT_LOG_PROMPT.md
Normal file
124
sprints/sprint-5/ETAPA_AUDIT_LOG_PROMPT.md
Normal 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.
|
||||||
|
```
|
||||||
123
sprints/sprint-5/ETAPA_CALIDAD_PROMPT.md
Normal file
123
sprints/sprint-5/ETAPA_CALIDAD_PROMPT.md
Normal 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.
|
||||||
|
```
|
||||||
89
sprints/sprint-5/ETAPA_E2E_PROMPT.md
Normal file
89
sprints/sprint-5/ETAPA_E2E_PROMPT.md
Normal 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.**
|
||||||
47
sprints/sprint-5/ETAPA_FIXES_RAPIDOS_PROMPT.md
Normal file
47
sprints/sprint-5/ETAPA_FIXES_RAPIDOS_PROMPT.md
Normal 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
|
||||||
106
sprints/sprint-5/INSUMOS_AUDIT_LOG.md
Normal file
106
sprints/sprint-5/INSUMOS_AUDIT_LOG.md
Normal 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.
|
||||||
@@ -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;
|
||||||
|
$$;
|
||||||
129
supabase/migrations/20260627000001_auth_user_roles.sql
Normal file
129
supabase/migrations/20260627000001_auth_user_roles.sql
Normal 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;
|
||||||
190
supabase/migrations/20260627000002_audit_log.sql
Normal file
190
supabase/migrations/20260627000002_audit_log.sql
Normal 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.
|
||||||
|
-- ============================================================
|
||||||
@@ -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;
|
||||||
70
tests/e2e/sprint5-verificacion.spec.ts
Normal file
70
tests/e2e/sprint5-verificacion.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
211
tests/e2e/sprint6-usabilidad.spec.ts
Normal file
211
tests/e2e/sprint6-usabilidad.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user