diff --git a/.DS_Store b/.DS_Store index 8b8d9ba..af40c5e 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.env.example b/.env.example index 94bae55..0872df1 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY= # NUNCA agregar SUPABASE_SERVICE_ROLE_KEY como variable NEXT_PUBLIC_ ni en el cliente # La service_role bypasa RLS — solo para Edge Functions / backend server-side + +# IP hash — variable server-only (NUNCA NEXT_PUBLIC_) +# Usada para deduplicación anónima. Generá un string aleatorio largo. +IP_HASH_SALT= diff --git a/app/actions/submit-survey.ts b/app/actions/submit-survey.ts new file mode 100644 index 0000000..a595078 --- /dev/null +++ b/app/actions/submit-survey.ts @@ -0,0 +1,109 @@ +'use server' +import { headers } from 'next/headers' +import { createHash, randomUUID } from 'crypto' +import { createClient as createSupabaseClient } from '@supabase/supabase-js' + +export type SubmitResult = + | { success: true } + | { success: false; error: string } + +export async function submitSurvey(payload: { + surveyId: string + answers: Record + questionMap: Record // código → question_id UUID + consentOperativo: boolean + consentMacroN1: boolean +}): Promise { + // 1. ip_hash: server-only, nunca en texto plano + const headersList = await headers() + const rawIp = + headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown' + const salt = process.env.IP_HASH_SALT ?? '' + const ipHash = createHash('sha256').update(rawIp + salt).digest('hex') + + // 2. Cliente Supabase con anon key (RLS policies permiten INSERT a anon) + // NUNCA usar service_role — el anon key respeta RLS + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? '' + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '' + const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey) + + // 3. Leer survey para obtener org_id y consent_version_id + const { data: survey, error: sErr } = await supabase + .from('surveys') + .select('org_id, consent_version_id') + .eq('id', payload.surveyId) + .single() + + if (sErr || !survey) return { success: false, error: 'survey_not_found' } + + // 4. Pre-generar UUIDs server-side para evitar .select().single() post-INSERT. + // + // DIAGNÓSTICO: el Supabase JS client usa RETURNING * en .select().single(), lo que + // requiere SELECT policy para anon en consent_records/responses. Esa policy no existe + // (correcto — anon no debe leer consent_records de otros respondentes). + // Solución: pre-generar UUIDs con randomUUID() y NO hacer SELECT after INSERT. + const consentId = randomUUID() + const responseId = randomUUID() + + // 5. INSERT consent_record (inmutable, ADR-003) + // Sin .select() para no activar RETURNING y evitar el error RLS 42501. + const { error: cErr } = await supabase + .from('consent_records') + .insert({ + id: consentId, + consent_version_id: survey.consent_version_id, + granted_scopes: { + operativo: payload.consentOperativo, + macro_n1: payload.consentMacroN1, + }, + granted_at: new Date().toISOString(), + }) + + if (cErr) return { success: false, error: `consent_insert_failed: ${cErr.message}` } + + // 6. Extraer qi_* de las respuestas + const qi_age_bucket = + typeof payload.answers['A5'] === 'string' ? payload.answers['A5'] : null + const qi_sector = + typeof payload.answers['A4'] === 'string' ? payload.answers['A4'] : null + + // 7. INSERT response (respondent_id=null — encuesta anónima, regla 2 CLAUDE.md) + const { error: rErr } = await supabase + .from('responses') + .insert({ + id: responseId, + survey_id: payload.surveyId, + company_id: survey.org_id, + respondent_id: null, + ip_hash: ipHash, + consent_record_id: consentId, + qi_age_bucket: qi_age_bucket || null, + qi_sector: qi_sector || null, + qi_zone: null, + }) + + if (rErr) return { success: false, error: `response_insert_failed: ${rErr.message}` } + + // 8. INSERT answers — una fila por pregunta respondida + const answerRows = Object.entries(payload.answers) + .map(([code, value]) => ({ + response_id: responseId, + question_id: payload.questionMap[code], + value, + })) + .filter( + (row) => + row.question_id && + row.value !== null && + row.value !== undefined && + row.value !== '' && + !(Array.isArray(row.value) && row.value.length === 0) + ) + + if (answerRows.length > 0) { + const { error: aErr } = await supabase.from('answers').insert(answerRows) + if (aErr) return { success: false, error: `answers_insert_failed: ${aErr.message}` } + } + + return { success: true } +} diff --git a/app/globals.css b/app/globals.css index 03945db..842f4c4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -398,6 +398,61 @@ body { justify-content: flex-start; } +/* ── Geo select widget ── */ +.geo-select-wrapper { display: flex; flex-direction: column; gap: 16px; } +.geo-select-group { display: flex; flex-direction: column; gap: 8px; } +.geo-select { + display: block; width: 100%; + padding: 10px 12px; + border: 1.5px solid var(--color-border); border-radius: var(--radius); + font-size: 1rem; font-family: inherit; background: var(--color-surface); + color: var(--color-text); cursor: pointer; appearance: auto; + transition: border-color 0.15s; +} +.geo-select:focus { outline: none; border-color: var(--color-border-focus); box-shadow: 0 0 0 3px rgba(37,99,235,0.15); } +.geo-select-disabled { opacity: 0.5; cursor: not-allowed; background: #f3f4f6; } +.geo-label-disabled { color: var(--color-text-muted); } +.geo-disabled-hint { font-size: 0.8125rem; font-weight: 400; color: var(--color-text-muted); } + +/* ── Brand header ── */ +.brand-header { + background: var(--color-primary-light); border-left: 4px solid var(--color-primary); + border-radius: 0 var(--radius) var(--radius) 0; + padding: 10px 14px; margin: 16px 0 0; +} +.brand-text { margin: 0; font-size: 0.875rem; color: var(--color-primary-hover); line-height: 1.5; } + +/* ── Consent screen ── */ +.consent-container { max-width: 720px; margin: 0 auto; padding: 32px 16px 80px; } +.consent-title { font-size: 1.5rem; font-weight: 700; margin: 0 0 24px; } +.consent-section { margin-bottom: 24px; } +.consent-section-title { font-size: 1.0625rem; font-weight: 700; margin: 0 0 10px; } +.consent-list { margin: 8px 0 0; padding-left: 20px; line-height: 1.7; display: flex; flex-direction: column; gap: 8px; } +.consent-anon-box { + background: var(--color-sensitive); border: 1px solid var(--color-sensitive-border); + border-radius: var(--radius); padding: 14px 16px; margin-bottom: 24px; + font-size: 0.9375rem; line-height: 1.6; +} +.consent-checkboxes { margin-bottom: 28px; display: flex; flex-direction: column; gap: 14px; } +.consent-checkbox-label { + display: flex; align-items: flex-start; gap: 12px; + padding: 14px 16px; border: 1.5px solid var(--color-border); border-radius: var(--radius); + cursor: pointer; transition: border-color 0.15s, background 0.15s; line-height: 1.5; +} +.consent-checkbox-label:has(input:checked) { border-color: var(--color-primary); background: var(--color-primary-light); } +.consent-checkbox-input { width: 20px; height: 20px; flex-shrink: 0; margin-top: 1px; accent-color: var(--color-primary); cursor: pointer; } +.consent-checkbox-text { flex: 1; } +.consent-required-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--color-primary); background: var(--color-primary-light); padding: 1px 6px; border-radius: 4px; } +.consent-optional-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--color-text-muted); background: #f3f4f6; padding: 1px 6px; border-radius: 4px; } +.consent-footer { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; } +.consent-required-hint { margin: 0; font-size: 0.875rem; color: var(--color-text-muted); } +.btn-disabled { opacity: 0.5; cursor: not-allowed; } + +/* ── Thank you / error screens ── */ +.thank-you-container, .error-container { max-width: 560px; margin: 80px auto; padding: 0 16px; } +.thank-you-title, .error-title { font-size: 1.5rem; font-weight: 700; margin: 0 0 16px; } +.thank-you-body { color: var(--color-text-muted); line-height: 1.7; margin: 0 0 12px; } + /* ── No survey state ── */ .no-survey { max-width: 480px; diff --git a/app/page.tsx b/app/page.tsx index 30d8f2c..6c10757 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,21 +1,27 @@ import { createClient } from '@/lib/supabase' -import type { Question, Survey } from '@/lib/types' +import type { BrandConfig, Question, Survey } from '@/lib/types' import { SurveyForm } from '@/components/survey-form' // Force dynamic rendering — page fetches from Supabase at request time. -// Never statically generated: credentials live in .env.local (runtime only). export const dynamic = 'force-dynamic' -// Server Component — fetches survey with anon key; RLS ensures only status='live' is returned. -export default async function Page() { +// In Next.js 15, searchParams is a Promise and must be awaited. +interface PageProps { + searchParams: Promise<{ s?: string }> +} + +export default async function Page({ searchParams }: PageProps) { + const { s: surveyId } = await searchParams const supabase = createClient() - // 1. Fetch the live survey - const { data: surveyRow, error: surveyError } = await supabase + // 1. Fetch survey — by ?s=UUID if provided, otherwise first live survey + const surveyQuery = supabase .from('surveys') - .select('id, title, status, is_anonymous, k_threshold') - .eq('status', 'live') - .maybeSingle() + .select('id, org_id, title, status, is_anonymous, k_threshold, consent_version_id, brand_config') + + const { data: surveyRow, error: surveyError } = surveyId + ? await surveyQuery.eq('id', surveyId).maybeSingle() + : await surveyQuery.eq('status', 'live').maybeSingle() if (surveyError) { return ( @@ -32,10 +38,11 @@ export default async function Page() {

No hay encuesta activa

- Para ver el formulario, la encuesta debe estar en estado live. + Para ver el formulario, la encuesta debe estar en estado live, + o pasá el UUID por parámetro: ?s=UUID.

-          {`-- Activar la encuesta en Supabase Cloud:\nUPDATE surveys\nSET status = 'live'\nWHERE id = '30000000-0000-0000-0000-000000000001';`}
+          {`-- Activar en Supabase Cloud:\nUPDATE surveys SET status = 'live'\nWHERE id = '30000000-0000-0000-0000-000000000001';`}
         
) @@ -61,20 +68,19 @@ export default async function Page() { const questions = questionRows as Question[] - // 3. Log question count for validation (visible in server logs and browser Network tab) - console.log( - `[2A] Survey loaded: id=${surveyRow.id} | questions=${questions.length}` - ) + console.log(`[2B] Survey loaded: id=${surveyRow.id} | questions=${questions.length}`) const survey: Survey = { - ...(surveyRow as Omit), + id: surveyRow.id, + org_id: surveyRow.org_id, + title: surveyRow.title, + status: surveyRow.status, + is_anonymous: surveyRow.is_anonymous, + k_threshold: surveyRow.k_threshold, + consent_version_id: surveyRow.consent_version_id, + brand_config: (surveyRow.brand_config as BrandConfig) ?? null, questions, } - return ( - - ) + return } diff --git a/assets/.DS_Store b/assets/.DS_Store new file mode 100644 index 0000000..e256d3d Binary files /dev/null and b/assets/.DS_Store differ diff --git a/assets/fundacion/.DS_Store b/assets/fundacion/.DS_Store new file mode 100644 index 0000000..429b927 Binary files /dev/null and b/assets/fundacion/.DS_Store differ diff --git a/assets/fundacion/ISOLOGO HORIZONATAL-FS.png b/assets/fundacion/ISOLOGO HORIZONATAL-FS.png new file mode 100644 index 0000000..4d732e4 Binary files /dev/null and b/assets/fundacion/ISOLOGO HORIZONATAL-FS.png differ diff --git a/assets/fundacion/ISOLOGO VERTICAL TAGLINE-FS-28.png b/assets/fundacion/ISOLOGO VERTICAL TAGLINE-FS-28.png new file mode 100644 index 0000000..c306294 Binary files /dev/null and b/assets/fundacion/ISOLOGO VERTICAL TAGLINE-FS-28.png differ diff --git a/assets/fundacion/ISOLOGO VERTICAL-FS.png b/assets/fundacion/ISOLOGO VERTICAL-FS.png new file mode 100644 index 0000000..d5c6c44 Binary files /dev/null and b/assets/fundacion/ISOLOGO VERTICAL-FS.png differ diff --git a/assets/fundacion/ISOTIPO_FS.png b/assets/fundacion/ISOTIPO_FS.png new file mode 100644 index 0000000..72dec19 Binary files /dev/null and b/assets/fundacion/ISOTIPO_FS.png differ diff --git a/assets/fundacion/LOGOTIPO-FS.png b/assets/fundacion/LOGOTIPO-FS.png new file mode 100644 index 0000000..26dbf37 Binary files /dev/null and b/assets/fundacion/LOGOTIPO-FS.png differ diff --git a/assets/fundacion/Manual de identidad visual corporativa-Fundación Solidaridad_Rebranding Institucional_.pdf b/assets/fundacion/Manual de identidad visual corporativa-Fundación Solidaridad_Rebranding Institucional_.pdf new file mode 100644 index 0000000..110d50b Binary files /dev/null and b/assets/fundacion/Manual de identidad visual corporativa-Fundación Solidaridad_Rebranding Institucional_.pdf differ diff --git a/assets/fundacion/Versión Horiz_Isologotipo FS-17.png b/assets/fundacion/Versión Horiz_Isologotipo FS-17.png new file mode 100644 index 0000000..995cbcc Binary files /dev/null and b/assets/fundacion/Versión Horiz_Isologotipo FS-17.png differ diff --git a/assets/fundacion/Versión Horiz_Isologotipo FS-17.svg b/assets/fundacion/Versión Horiz_Isologotipo FS-17.svg new file mode 100644 index 0000000..131cc98 --- /dev/null +++ b/assets/fundacion/Versión Horiz_Isologotipo FS-17.svg @@ -0,0 +1,380 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/fundacion/Versión Vertical_Isologotipo FS-26.png b/assets/fundacion/Versión Vertical_Isologotipo FS-26.png new file mode 100644 index 0000000..87a6130 Binary files /dev/null and b/assets/fundacion/Versión Vertical_Isologotipo FS-26.png differ diff --git a/assets/re/Paleta de colores RE-04.jpg b/assets/re/Paleta de colores RE-04.jpg new file mode 100644 index 0000000..dd93687 Binary files /dev/null and b/assets/re/Paleta de colores RE-04.jpg differ diff --git a/assets/re/Re-Monocromático.png b/assets/re/Re-Monocromático.png new file mode 100644 index 0000000..f387fc0 Binary files /dev/null and b/assets/re/Re-Monocromático.png differ diff --git a/assets/re/Re-con fondo color secundario 1.png b/assets/re/Re-con fondo color secundario 1.png new file mode 100644 index 0000000..d02e78c Binary files /dev/null and b/assets/re/Re-con fondo color secundario 1.png differ diff --git a/assets/re/Re-con fondo color secundario 2.png b/assets/re/Re-con fondo color secundario 2.png new file mode 100644 index 0000000..4bb6732 Binary files /dev/null and b/assets/re/Re-con fondo color secundario 2.png differ diff --git a/assets/re/Re-con fondo color secundario 3.png b/assets/re/Re-con fondo color secundario 3.png new file mode 100644 index 0000000..2f9cef7 Binary files /dev/null and b/assets/re/Re-con fondo color secundario 3.png differ diff --git a/assets/re/Re-fondo color primario.png b/assets/re/Re-fondo color primario.png new file mode 100644 index 0000000..93749cf Binary files /dev/null and b/assets/re/Re-fondo color primario.png differ diff --git a/assets/re/Tipografías-20260604T192900Z-3-001.zip b/assets/re/Tipografías-20260604T192900Z-3-001.zip new file mode 100644 index 0000000..c16ce5e Binary files /dev/null and b/assets/re/Tipografías-20260604T192900Z-3-001.zip differ diff --git a/components/consent-screen.tsx b/components/consent-screen.tsx new file mode 100644 index 0000000..daace19 --- /dev/null +++ b/components/consent-screen.tsx @@ -0,0 +1,109 @@ +'use client' +import { useState } from 'react' +import type { BrandConfig } from '@/lib/types' + +interface ConsentScreenProps { + brandConfig: BrandConfig | null + onAccept: (consentOperativo: boolean, consentMacroN1: boolean) => void +} + +export function ConsentScreen({ brandConfig, onAccept }: ConsentScreenProps) { + const [operativo, setOperativo] = useState(false) + const [macroN1, setMacroN1] = useState(false) + + const orgName = brandConfig?.org_name ?? 'tu empresa' + + return ( +
+

Antes de comenzar

+ +
+ +

+ Esta encuesta tiene dos propósitos que podés autorizar por separado: +

+
    +
  1. + Diagnóstico interno de {orgName}: tus respuestas ayudan a la empresa + a entender la realidad de las personas que cuidan a familiares con discapacidad o adultos + mayores, para diseñar apoyos más adecuados. Solo se trabaja con{' '} + datos agregados — nunca accede a respuestas individuales. +
  2. +
  3. + Estudio país (opcional): tus respuestas anonimizadas contribuyen a un + mapeo nacional sobre la economía del cuidado en Paraguay. Los datos se procesan de + forma irreversiblemente desvinculada de cualquier empresa o persona. +
  4. +
+
+ + {/* Párrafo OBLIGATORIO: anonimato y no-revocación (ADR-003) */} +
+

+ Esta encuesta es completamente anónima. No guardamos ningún dato que + te identifique (nombre, número de documento, correo ni ningún otro identificador personal). + Por ser anónima, no es posible revocar ni eliminar tu respuesta una vez enviada, + ya que no habría forma de encontrarla entre las demás. +

+
+ +
+ + + {/* Checkbox 1: operativo — REQUERIDO */} + + + {/* Checkbox 2: macro_n1 — OPCIONAL */} + +
+ +
+ + {!operativo && ( +

+ Necesitás marcar el primer checkbox para continuar. +

+ )} +
+
+ ) +} diff --git a/components/question-renderer.tsx b/components/question-renderer.tsx index 3c0279b..65ce2f0 100644 --- a/components/question-renderer.tsx +++ b/components/question-renderer.tsx @@ -5,6 +5,7 @@ import { CheckboxGroup } from './widgets/checkbox-group' import { RatingScale } from './widgets/rating-scale' import { TextAreaField } from './widgets/text-area-field' import { TextShortField } from './widgets/text-short-field' +import { GeoSelectWidget } from './widgets/geo-select-widget' interface QuestionRendererProps { question: Question @@ -30,6 +31,22 @@ export function QuestionRenderer({ const { code, type, prompt, required, max_select, options } = question const error = errors[code] + // A1 (Departamento) renders a combined geo widget that also handles A2 (Ciudad) + if (code === 'A1') { + return ( + onChange('A1', v)} + onCityChange={(v) => onChange('A2', v)} + departmentError={errors['A1']} + cityError={errors['A2']} + /> + ) + } + // A2 is rendered inside the GeoSelectWidget above — skip here to avoid duplication + if (code === 'A2') return null + if (type === 'single_choice' && isArrayOptions(options)) { return ( ('consent') + const [consentOperativo, setConsentOperativo] = useState(false) + const [consentMacroN1, setConsentMacroN1] = useState(false) const [answers, setAnswers] = useState({}) const [errors, setErrors] = useState>({}) - const [submitted, setSubmitted] = useState(false) - const [hydrated, setHydrated] = useState(false) - - // Announce when conditional blocks appear/disappear + const [submitError, setSubmitError] = useState(null) + const [isPending, startTransition] = useTransition() const [a11yAnnounce, setA11yAnnounce] = useState('') - - // Track which trigger codes had "Sí" on prev render to detect toggle - const prevVisibility = useRef>({}) + const [hydrated, setHydrated] = useState(false) + const prevAnswers = useRef({}) // Hydrate from localStorage on mount useEffect(() => { @@ -51,43 +51,34 @@ export function SurveyForm({ survey, questions }: SurveyFormProps) { setHydrated(true) }, [survey.id]) - // Persist to localStorage on every change (after hydration) + // Persist to localStorage after hydration useEffect(() => { if (!hydrated) return saveDraft(survey.id, answers) + prevAnswers.current = answers }, [answers, hydrated, survey.id]) const handleChange = useCallback( (code: string, value: FormAnswers[string]) => { setAnswers((prev) => { const updated = { ...prev, [code]: value } - - // Clear any dependent questions that become hidden const toHide = getHiddenCodes(code, value, questions, prev) for (const hiddenCode of toHide) { updated[hiddenCode] = undefined as unknown as FormAnswers[string] } - - // Announce block visibility change to screen readers if (toHide.length > 0) { setA11yAnnounce('Se ocultaron preguntas relacionadas y se limpiaron sus respuestas.') } else { - // Check if a conditional block just became visible const appeared = questions.some( (q) => q.conditional_rules?.some((r) => r.if_question === code) && isQuestionVisible(q, updated) && !isQuestionVisible(q, prev) ) - if (appeared) { - setA11yAnnounce('Aparecieron nuevas preguntas según tu respuesta.') - } + if (appeared) setA11yAnnounce('Aparecieron nuevas preguntas según tu respuesta.') } - return updated }) - - // Clear validation error for this field setErrors((prev) => { if (!prev[code]) return prev const { [code]: _, ...rest } = prev @@ -103,12 +94,7 @@ export function SurveyForm({ survey, questions }: SurveyFormProps) { if (!q.required) continue if (!isQuestionVisible(q, answers)) continue const val = answers[q.code] - if ( - val === null || - val === undefined || - val === '' || - (Array.isArray(val) && val.length === 0) - ) { + if (val === null || val === undefined || val === '' || (Array.isArray(val) && val.length === 0)) { newErrors[q.code] = 'Este campo es obligatorio.' } } @@ -118,82 +104,129 @@ export function SurveyForm({ survey, questions }: SurveyFormProps) { function handleSubmit(e: React.FormEvent) { e.preventDefault() + if (isPending) return if (!validate()) { - // Move focus to first error const firstErrorCode = Object.keys(errors)[0] - if (firstErrorCode) { - document.getElementById(firstErrorCode)?.focus() - } + if (firstErrorCode) document.getElementById(firstErrorCode)?.focus() return } - // 2A: no write to DB — just show summary - clearDraft(survey.id) - setSubmitted(true) + + const questionMap = Object.fromEntries(questions.map((q) => [q.code, q.id])) + + startTransition(async () => { + const result = await submitSurvey({ + surveyId: survey.id, + answers: answers as Record, + questionMap, + consentOperativo, + consentMacroN1, + }) + if (result.success) { + clearDraft(survey.id) + setStage('success') + } else { + setSubmitError(result.error) + setStage('error') + } + }) } - function handleStartOver() { - setAnswers({}) - setSubmitted(false) - setErrors({}) + // ── Stage: consent ────────────────────────────────────────── + if (stage === 'consent') { + return ( + { + setConsentOperativo(op) + setConsentMacroN1(macro) + setStage('form') + }} + /> + ) } - // Visible questions driven by conditional_rules + // ── Stage: success ────────────────────────────────────────── + if (stage === 'success') { + return ( +
+
+

¡Gracias por participar!

+

+ Tu respuesta fue registrada correctamente. Recordá que la encuesta es anónima: no hay + forma de vincular tus respuestas con tu identidad. +

+

+ Los resultados agregados ayudarán a {survey.brand_config?.org_name ?? 'tu empresa'} a + diseñar mejores apoyos para las personas cuidadoras. +

+
+
+ ) + } + + // ── Stage: error ──────────────────────────────────────────── + if (stage === 'error') { + return ( +
+
+

No se pudo enviar la encuesta

+

Hubo un error al guardar tus respuestas ({submitError}). Podés intentarlo de nuevo.

+ +
+
+ ) + } + + // ── Stage: form ───────────────────────────────────────────── const visibleQuestions = questions.filter((q) => isQuestionVisible(q, answers)) const answeredCount = visibleQuestions.filter((q) => { const v = answers[q.code] return v !== null && v !== undefined && v !== '' && !(Array.isArray(v) && v.length === 0) }).length - if (submitted) { - return ( - - ) - } - - // Group visible questions by section prefix, in order type Section = { prefix: string; title: string; sensitive?: boolean; questions: Question[] } const sectionMap = new Map() for (const q of visibleQuestions) { const prefix = getSectionPrefix(q.code) if (!sectionMap.has(prefix)) { const meta = SECTIONS.find((s) => s.prefix === prefix) - sectionMap.set(prefix, { - prefix, - title: meta?.title ?? prefix, - sensitive: meta?.sensitive, - questions: [], - }) + sectionMap.set(prefix, { prefix, title: meta?.title ?? prefix, sensitive: meta?.sensitive, questions: [] }) } sectionMap.get(prefix)!.questions.push(q) } + const bc = survey.brand_config + return (
- {/* Skip link for keyboard users */} - - Ir al formulario - + Ir al formulario - {/* Screen-reader live region for conditional announcements */} -
+
{a11yAnnounce}
+ {/* Header contextual con org/branch si está configurado */} + {bc?.org_name && ( +
+

+ Estás respondiendo esto porque pertenecés a {bc.org_name} + {bc.branch_name && ( + <> y participás en las oficinas de {bc.branch_name} + {bc.branch_city && <> en {bc.branch_city}}. + )} +

+
+ )} +

{survey.title}

-

- Esta encuesta es anónima. Tus respuestas no te identifican. -

+

Esta encuesta es anónima. Tus respuestas no te identifican.

@@ -201,11 +234,7 @@ export function SurveyForm({ survey, questions }: SurveyFormProps) {
-
+ {Array.from(sectionMap.values()).map((section) => (
-

- {section.title} -

+

{section.title}

{section.sensitive && (

Esta sección contiene preguntas sensibles sobre salud o discapacidad. @@ -223,13 +250,8 @@ export function SurveyForm({ survey, questions }: SurveyFormProps) {

)}
- {section.questions.map((q) => ( -
+
-
diff --git a/components/widgets/geo-select-widget.tsx b/components/widgets/geo-select-widget.tsx new file mode 100644 index 0000000..d4d3141 --- /dev/null +++ b/components/widgets/geo-select-widget.tsx @@ -0,0 +1,90 @@ +'use client' +import { DEPARTMENTS, getCities } from '@/lib/paraguay-geo' + +interface GeoSelectWidgetProps { + departmentValue: string + cityValue: string + onDepartmentChange: (value: string) => void + onCityChange: (value: string) => void + departmentError?: string + cityError?: string +} + +export function GeoSelectWidget({ + departmentValue, + cityValue, + onDepartmentChange, + onCityChange, + departmentError, + cityError, +}: GeoSelectWidgetProps) { + const cities = departmentValue ? getCities(departmentValue) : [] + const cityDisabled = !departmentValue + + function handleDepartmentChange(val: string) { + onDepartmentChange(val) + onCityChange('') // limpiar ciudad al cambiar departamento + } + + return ( +
+ {/* Departamento */} +
+ + + {departmentError && ( + + )} +
+ + {/* Ciudad */} +
+ + + {cityError && ( + + )} +
+
+ ) +} diff --git a/lib/paraguay-geo.ts b/lib/paraguay-geo.ts new file mode 100644 index 0000000..008b944 --- /dev/null +++ b/lib/paraguay-geo.ts @@ -0,0 +1,84 @@ +// Datos geográficos de Paraguay — departamentos y sus ciudades/municipios +// Fuente: INE Paraguay / Wikipedia. Actualizaciones: agregar al objeto GEO_DATA sin cambio de esquema. + +export const GEO_DATA: Record = { + 'Asunción (Capital)': ['Asunción'], + 'Alto Paraguay': ['Fuerte Olimpo', 'Bahía Negra', 'Carmelo Peralta', 'La Victoria'], + 'Alto Paraná': [ + 'Ciudad del Este', 'Hernandarias', 'Minga Guazú', 'Presidente Franco', + 'Minga Porã', 'Naranjal', 'Ñacunday', 'San Alberto', 'Santa Rita', 'Tavapy', + 'Yguazú', "Juan E. O'Leary", 'San Cristóbal', 'Santa Fe del Paraná', + 'Dr. Juan León Mallorquín', 'Itakyry', 'Domingo Martínez de Irala', + ], + 'Amambay': ['Pedro Juan Caballero', 'Bella Vista Norte', 'Capitán Bado', 'Zanja Pytã'], + 'Boquerón': ['Filadelfia', 'Loma Plata', 'Mariscal Estigarribia', 'Neuland'], + 'Caaguazú': [ + 'Coronel Oviedo', 'Caaguazú', 'Carayaó', 'Dr. Juan Manuel Frutos', 'Mbutuy', + 'Nueva Londres', 'Raúl Arsenio Oviedo', 'Repatriación', 'San Joaquín', + 'San José de los Arroyos', 'Simón Bolívar', 'Vaquería', 'Yhú', + 'José Domingo Ocampos', '3 de Febrero', 'Mcal. Francisco Solano López', 'Los Cedrales', + ], + 'Caazapá': [ + 'Caazapá', 'Abai', 'Buena Vista', 'General Higinio Morínigo', 'Huber Duré', + 'Maciel', 'San Juan Nepomuceno', 'Tavaí', 'Yegros', 'Yuty', + ], + 'Canindeyú': [ + 'Salto del Guairá', 'Corpus Christi', 'Curuguaty', 'General Francisco Caballero Álvarez', + 'Itanará', 'Katueté', 'La Paloma', 'Nueva Esperanza', 'Yasy Kañy', 'Ypejhú', + ], + 'Central': [ + 'Areguá', 'Capiatá', 'Fernando de la Mora', 'Guarambaré', 'Itá', 'Itauguá', + 'Juan Augusto Saldívar', 'Lambaré', 'Limpio', 'Luque', 'Mariano Roque Alonso', + 'Nueva Italia', 'Ñemby', 'San Antonio', 'San Lorenzo', 'Villa Elisa', 'Villeta', 'Ypané', 'Ypacaraí', + ], + 'Concepción': [ + 'Concepción', 'Belén', 'Horqueta', 'Loreto', 'San Lázaro', 'San Carlos del Apa', + "Yby Yaú", "Azote'y", 'Sgto. José Félix López', + ], + 'Cordillera': [ + 'Caacupé', 'Altos', 'Arroyos y Esteros', 'Caraguatay', 'Emboscada', 'Eusebio Ayala', + 'Isla Pucú', 'Itacurubí de la Cordillera', 'Juan de Mena', 'Loma Grande', + 'Mbocayaty del Yhaguy', 'Nueva Colombia', 'Pirayú', 'Primero de Marzo', 'Tobatí', 'Valenzuela', + ], + 'Guairá': [ + 'Villarrica', 'Borja', 'Capitán Mauricio José Troche', 'Coronel Martínez', + 'Félix Pérez Cardozo', 'General Eugenio A. Garay', 'Independencia', 'Itapé', + 'Iturbe', 'José Fassardi', 'Mbocayaty', 'Natalicio Talavera', 'Ñumí', 'San Salvador', 'Yataity', + ], + 'Itapúa': [ + 'Encarnación', 'Alto Verá', 'Bella Vista', 'Cambyretá', 'Capitán Meza', 'Capitán Miranda', + 'Carlos A. López', 'Carmen del Paraná', 'Coronel Bogado', 'Cosme', 'Edelira', 'Fram', + 'General Artigas', 'Hohenau', 'Itapúa Poty', 'Jesús', 'La Paz', 'Leandro Oviedo', + 'Mayor Otaño', 'Natalio', 'Nueva Alborada', 'Obligado', 'Pirapó', 'San Cosme y Damián', + 'San Juan del Paraná', 'San Pedro del Paraná', 'San Rafael del Paraná', + 'Trinidad', 'Tomás Romero Pereira', + ], + 'Misiones': [ + 'San Juan Bautista', 'Ayolas', 'San Ignacio', 'San Miguel', 'San Patricio', + 'Santa María', 'Santa Rosa', 'Santiago', 'Villa Florida', 'Yabebyry', + ], + 'Ñeembucú': [ + 'Pilar', 'Alberdi', 'Cerrito', 'Desmochados', 'General José Eduvigis Díaz', 'Guazú Cuá', + 'Humaitá', 'Isla Umbú', 'Laureles', 'Mayor José de J. Martínez', 'Paso de Patria', + 'San Juan Bautista del Ñeembucú', 'Tacuaras', 'Villa Franca', 'Villa Oliva', 'Villalba', + ], + 'Paraguarí': [ + 'Paraguarí', 'Acahay', 'Carapeguá', 'Escobar', 'General Bernardino Caballero', + 'La Colmena', 'Mbuyapey', 'Quiindy', 'Quyquyhó', 'San Roque González de Santa Cruz', + 'Sapucaí', 'Tebicuarymí', 'Ybycuí', 'Ybytymí', 'Yaguarón', + ], + 'Presidente Hayes': [ + 'Villa Hayes', 'Benjamín Aceval', 'José Falcón', 'Nanawa', 'Nueva Asunción', + 'Pozo Colorado', 'Puerto Pinasco', 'Teniente Primero Manuel Irala Fernández', 'Villa del Río', + ], + 'San Pedro': [ + 'San Pedro del Ycuamandiyú', 'Antequera', 'Choré', 'General Elizardo Aquino', + 'Guayaibí', 'Itacurubí del Rosario', 'Lima', 'Nueva Germania', 'Río Verde', + 'San Estanislao (Santaní)', 'Tacuatí', 'Unión', 'Villa del Rosario', 'Yataity del Norte', + ], +} + +export const DEPARTMENTS = Object.keys(GEO_DATA) + +export const getCities = (department: string): string[] => + GEO_DATA[department] ?? [] diff --git a/lib/types.ts b/lib/types.ts index d0970eb..6d9fcc2 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -39,12 +39,21 @@ export interface Question { conditional_rules: ConditionalRule[] | null } +export interface BrandConfig { + org_name?: string + branch_name?: string + branch_city?: string +} + export interface Survey { id: string + org_id: string title: string status: string is_anonymous: boolean k_threshold: number + consent_version_id: string + brand_config: BrandConfig | null questions: Question[] } diff --git a/sprints/sprint-2/ETAPA_2B_PROMPT.md b/sprints/sprint-2/ETAPA_2B_PROMPT.md new file mode 100644 index 0000000..dc192ac --- /dev/null +++ b/sprints/sprint-2/ETAPA_2B_PROMPT.md @@ -0,0 +1,258 @@ +# ETAPA 2B — Consentimiento + persistencia real + dropdowns geográficos + +> Leé: `CLAUDE.md`, `DATA_MODEL.md`, `SEED_SPEC.md`, `docs/adr/ADR-001`, `docs/adr/ADR-003`, +> `sprints/sprint-2/BRIEF.md` y este prompt. La app Next.js ya existe (Etapa 2A). + +## 1. Objetivo +Cuatro cosas en una etapa: +1. **Geo dropdowns:** A1=Departamento, A2=Ciudad (dependiente de A1), A3=Barrio (texto, opcional). +2. **Header contextual:** mostrar organización y sucursal pre-configurados en la encuesta. +3. **URL por parámetro:** `?s=UUID` para cargar una encuesta específica. +4. **Persistencia real (2B):** pantalla de consentimiento + Server Action que escribe + `consent_record → response → answers` en Supabase Cloud. + +## 2. Contexto previo +La app renderiza el formulario desde Supabase (anon key), tiene lógica condicional data-driven, +widget `text_short`, resiliencia lite. El esquema Sprint 1 tiene RLS, k-anonimato, consent inmutable. +`responses.consent_record_id` es `NOT NULL` → la escritura requiere consentimiento primero. + +## 3. Decisiones ya tomadas +- **Geo:** A1=Departamento y A2=Ciudad son `single_choice` en el DB, renderizados con un widget + especial `GeoSelectWidget` (dos dropdowns enlazados). A3=Barrio es `text_short`, opcional. + Los datos geográficos viven en una constante TypeScript en el app (ver sección 5). +- **Header:** `survey.brand_config` tiene `{org_name, branch_name, branch_city}`. El formulario + lo muestra arriba: *"Estás respondiendo esto porque pertenecés a [org_name] y participás en las + oficinas de [branch_name] en [branch_city]."* — solo-lectura, no editable por el respondente. +- **URL:** `?s=` — la página lee `searchParams.s`. Si no hay parámetro, carga la primera + encuesta `live` (backwards compat para desarrollo). +- **Consentimiento:** pantalla ANTES del formulario. Checkbox 1 (requerido): acepta uso operativo. + Checkbox 2 (opcional): acepta macro anónimo. Sin checkbox 1, no puede avanzar. El texto debe ser + honesto sobre el anonimato y la NO revocación (ADR-003 — la encuesta es anónima; no hay forma de + revocar después). Los valores de ambos checkboxes se almacenan en `consent_record.granted_scopes`. +- **ip_hash:** se computa SERVER-SIDE en el Server Action con SHA-256 + `process.env.IP_HASH_SALT` + (variable server-only, **nunca** `NEXT_PUBLIC_`). Usar `x-forwarded-for` como IP (Traefik/Dokploy). +- **Orden de inserts:** consentimiento → response → answers, dentro del Server Action. + Si algo falla, el usuario ve un error y puede reintentar. +- **`service_role` NUNCA en el cliente.** Los inserts usan el cliente Supabase inicializado con la + `anon` key server-side (en el Server Action) — las RLS policies de INSERT lo permiten. + +## 4. Tareas + +### 4A — Seed: actualizar questions y brand_config +1. En `supabase/seed.sql`, actualizar: + - **A1** (Departamento): `type='single_choice'`, opciones = los 18 valores del listado geo (sección 5). + - **A2** (Ciudad): `type='single_choice'`, opciones = TODAS las ciudades del listado geo + concatenadas con el nombre de su departamento como prefijo para que la DB las almacene + correctamente (e.g. `{label:"Asunción", value:"Asunción"}`). El filtrado por departamento + es client-side en el widget. + - **A3** (Barrio): `type='text_short'`, prompt="¿En qué barrio vivís? (opcional)", required=false. + - **A4** (Área): agregar a las opciones existentes: `"Directorio"` y `"Gerencia"`. + - **1.3** (Elementos de apoyo): agregar opción `"Tecnología asistiva"`. + - **survey.brand_config**: actualizar el registro semilla con + `{"org_name": "Empresa Demo", "branch_name": "Oficina Central", "branch_city": "Asunción"}`. + (El director actualizará esto con los datos reales de la primera empresa directamente en + Supabase Studio antes de enviar el link.) +2. Aplicar a Cloud: `supabase db push --include-seed` o actualizar las questions directamente + via script SQL si el seed ya no es idempotente (usar ON CONFLICT UPDATE para questions). + +### 4B — App: GeoSelectWidget + header + URL +3. Crear `components/widgets/geo-select-widget.tsx`: + - Primer dropdown: Departamento (opciones de A1). + - Segundo dropdown: Ciudad — opciones filtradas del `GEO_DATA` const según el departamento + seleccionado. Deshabilitado hasta que se seleccione departamento. + - Cuando cambia el departamento, limpiar la ciudad seleccionada. + - Ambos dropdowns son accesibles (label, aria, teclado). +4. En `question-renderer.tsx`: detectar `code === 'A1'` → renderizar `GeoSelectWidget` (que + maneja A1 y A2 juntos). Detectar `code === 'A2'` → skip (ya lo maneja el widget de A1). +5. En `app/page.tsx`: + - Leer `searchParams.s` como `surveyId`. Si existe, cargar esa encuesta por ID; si no, cargar + la primera `live`. + - Leer `survey.brand_config` y pasarlo al formulario. +6. Crear el **header contextual** (component o inline en `survey-form.tsx`): si `brand_config` + tiene `org_name`, mostrar el banner: + *"Estás respondiendo esto porque pertenecés a [org_name] y participás en las oficinas de + [branch_name] en [branch_city]."* — estilo discreto, no editable. + +### 4C — Consentimiento (pantalla previa al formulario) +7. Crear `components/consent-screen.tsx`: + - Sección de explicación del tratamiento (qué se recolecta, para qué, que es anónimo). + - Párrafo OBLIGATORIO sobre anonimato: *"Esta encuesta es completamente anónima. No guardamos + ningún dato que te identifique. Por ser anónima, no es posible revocar ni eliminar tu + respuesta una vez enviada."* + - Checkbox 1 (required): *"Acepto que mis respuestas se usen para el diagnóstico interno de + [org_name] sobre cuidado y discapacidad."* + - Checkbox 2 (optional): *"También acepto que mis respuestas anonimizadas contribuyan al + estudio país sobre economía del cuidado (sin identificarme en ningún caso)."* + - Botón "Comenzar la encuesta" — deshabilitado hasta que checkbox 1 esté marcado. + - Al hacer clic en "Comenzar", guarda los valores de los checkboxes en el estado del formulario + y muestra el formulario (NO escribe en la base todavía — el insert va al enviar). + +### 4D — Server Action: persistencia real +8. Crear `app/actions/submit-survey.ts` (Server Action): +```typescript +'use server' +import { headers } from 'next/headers' +import { createHash } from 'crypto' +import { createClient } from '@supabase/supabase-js' + +export async function submitSurvey(payload: { + surveyId: string + answers: Record // código → valor + questionMap: Record // código → question_id (UUID) + consentOperativo: boolean + consentMacroN1: boolean + consentVersionId: string +}) { + // 1. IP hash (server-only) + const headersList = await headers() + const rawIp = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown' + const salt = process.env.IP_HASH_SALT ?? '' + const ipHash = createHash('sha256').update(rawIp + salt).digest('hex') + + // 2. Cliente Supabase con anon key (las RLS policies permiten INSERT a anon/authenticated) + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) + + // 3. INSERT consent_record + const { data: consent, error: cErr } = await supabase + .from('consent_records') + .insert({ + consent_version_id: payload.consentVersionId, + granted_scopes: { + operativo: payload.consentOperativo, + macro_n1: payload.consentMacroN1 + }, + granted_at: new Date().toISOString() + }) + .select('id') + .single() + if (cErr || !consent) return { success: false, error: 'consent_insert_failed' } + + // 4. Extraer qi_* de las respuestas + const qi_age_bucket = String(payload.answers['A5'] ?? '') + const qi_sector = String(payload.answers['A4'] ?? '') + + // 5. INSERT response + const { data: response, error: rErr } = await supabase + .from('responses') + .insert({ + survey_id: payload.surveyId, + company_id: null, // se setea automáticamente por la survey.org_id en el trigger o app + respondent_id: null, + ip_hash: ipHash, + consent_record_id: consent.id, + qi_age_bucket: qi_age_bucket || null, + qi_sector: qi_sector || null, + qi_zone: null + }) + .select('id') + .single() + if (rErr || !response) return { success: false, error: 'response_insert_failed' } + + // 6. INSERT answers (bulk, excluir text_long/A2 si están vacíos, siempre excluir A2 separado + // ya que va incluido en A1 via GeoWidget) + const answerRows = Object.entries(payload.answers) + .filter(([code]) => code !== '__consent__') // filtrar meta-campos + .map(([code, value]) => ({ + response_id: response.id, + question_id: payload.questionMap[code], + value: JSON.stringify(value) + })) + .filter(row => row.question_id && row.value !== 'null' && row.value !== '""' && row.value !== '[]') + + const { error: aErr } = await supabase.from('answers').insert(answerRows) + if (aErr) return { success: false, error: 'answers_insert_failed' } + + return { success: true } +} +``` +9. En `survey-form.tsx`: al enviar, llamar `submitSurvey(...)`. Si success → mostrar pantalla de + agradecimiento/confirmación (reemplaza el resumen actual). Si error → mostrar mensaje de error + con opción de reintentar. +10. Agregar `IP_HASH_SALT` a `.env.example` (sin valor, solo el nombre). + +## 5. Datos geográficos de Paraguay — constante TypeScript +Crear `lib/paraguay-geo.ts` con esta estructura y datos: +```typescript +export const GEO_DATA: Record = { + "Asunción (Capital)": ["Asunción"], + "Alto Paraguay": ["Fuerte Olimpo","Bahía Negra","Carmelo Peralta","La Victoria"], + "Alto Paraná": ["Ciudad del Este","Hernandarias","Minga Guazú","Presidente Franco","Minga Porã","Naranjal","Ñacunday","San Alberto","Santa Rita","Tavapy","Yguazú","Juan E. O'Leary","San Cristóbal","Santa Fe del Paraná","Dr. Juan León Mallorquín","Itakyry","Domingo Martínez de Irala"], + "Amambay": ["Pedro Juan Caballero","Bella Vista Norte","Capitán Bado","Zanja Pytã"], + "Boquerón": ["Filadelfia","Loma Plata","Mariscal Estigarribia","Neuland"], + "Caaguazú": ["Coronel Oviedo","Caaguazú","Carayaó","Dr. Juan Manuel Frutos","Mbutuy","Nueva Londres","Raúl Arsenio Oviedo","Repatriación","San Joaquín","San José de los Arroyos","Simón Bolívar","Vaquería","Yhú","José Domingo Ocampos","3 de Febrero","Mcal. Francisco Solano López","Los Cedrales"], + "Caazapá": ["Caazapá","Abai","Buena Vista","General Higinio Morínigo","Huber Duré","Maciel","San Juan Nepomuceno","Tavaí","Yegros","Yuty"], + "Canindeyú": ["Salto del Guairá","Corpus Christi","Curuguaty","General Francisco Caballero Álvarez","Itanará","Katueté","La Paloma","Nueva Esperanza","Yasy Kañy","Ypejhú"], + "Central": ["Areguá","Capiatá","Fernando de la Mora","Guarambaré","Itá","Itauguá","Juan Augusto Saldívar","Lambaré","Limpio","Luque","Mariano Roque Alonso","Nueva Italia","Ñemby","San Antonio","San Lorenzo","Villa Elisa","Villeta","Ypané","Ypacaraí"], + "Concepción": ["Concepción","Belén","Horqueta","Loreto","San Lázaro","San Carlos del Apa","Yby Yaú","Azote'y","Sgto. José Félix López"], + "Cordillera": ["Caacupé","Altos","Arroyos y Esteros","Caraguatay","Emboscada","Eusebio Ayala","Isla Pucú","Itacurubí de la Cordillera","Juan de Mena","Loma Grande","Mbocayaty del Yhaguy","Nueva Colombia","Pirayú","Primero de Marzo","Tobatí","Valenzuela"], + "Guairá": ["Villarrica","Borja","Capitán Mauricio José Troche","Coronel Martínez","Félix Pérez Cardozo","General Eugenio A. Garay","Independencia","Itapé","Iturbe","José Fassardi","Mbocayaty","Natalicio Talavera","Ñumí","San Salvador","Yataity"], + "Itapúa": ["Encarnación","Alto Verá","Bella Vista","Cambyretá","Capitán Meza","Capitán Miranda","Carlos A. López","Carmen del Paraná","Coronel Bogado","Cosme","Edelira","Fram","General Artigas","Hohenau","Itapúa Poty","Jesús","La Paz","Leandro Oviedo","Mayor Otaño","Natalio","Nueva Alborada","Obligado","Pirapó","San Cosme y Damián","San Juan del Paraná","San Pedro del Paraná","San Rafael del Paraná","Trinidad","Tomás Romero Pereira"], + "Misiones": ["San Juan Bautista","Ayolas","San Ignacio","San Miguel","San Patricio","Santa María","Santa Rosa","Santiago","Villa Florida","Yabebyry"], + "Ñeembucú": ["Pilar","Alberdi","Cerrito","Desmochados","General José Eduvigis Díaz","Guazú Cuá","Humaitá","Isla Umbú","Laureles","Mayor José de J. Martínez","Paso de Patria","San Juan Bautista del Ñeembucú","Tacuaras","Villa Franca","Villa Oliva","Villalba"], + "Paraguarí": ["Paraguarí","Acahay","Carapeguá","Escobar","General Bernardino Caballero","La Colmena","Mbuyapey","Quiindy","Quyquyhó","San Roque González de Santa Cruz","Sapucaí","Tebicuarymí","Ybycuí","Ybytymí","Yaguarón"], + "Presidente Hayes": ["Villa Hayes","Benjamín Aceval","José Falcón","Nanawa","Nueva Asunción","Pozo Colorado","Puerto Pinasco","Teniente Primero Manuel Irala Fernández","Villa del Río"], + "San Pedro": ["San Pedro del Ycuamandiyú","Antequera","Choré","General Elizardo Aquino","Guayaibí","Itacurubí del Rosario","Lima","Nueva Germania","Río Verde","San Estanislao (Santaní)","Tacuatí","Unión","Villa del Rosario","Yataity del Norte"] +} + +export const DEPARTMENTS = Object.keys(GEO_DATA).sort() +export const getCities = (department: string): string[] => + (GEO_DATA[department] ?? []).sort() +``` +Notas sobre los datos: lista basada en fuentes oficiales (INE Paraguay / Wikipedia). Si el director +identifica municipios faltantes, se agregan al objeto `GEO_DATA` sin cambio de esquema. + +## 6. company_id en la response +El seed tiene `org_id` en la survey. En el Server Action, obtener el `org_id` de la survey para +setearlo como `company_id` en el `responses`: +```typescript +const { data: survey } = await supabase + .from('surveys') + .select('org_id, consent_version_id, brand_config') + .eq('id', payload.surveyId) + .single() +// Usar survey.org_id como company_id en el INSERT de responses +``` + +## 7. Definition of Done +- [ ] Dropdowns A1→A2 funcionan en el browser: seleccionar departamento filtra las ciudades. +- [ ] A3 (barrio) renderiza como campo de texto. +- [ ] A4 tiene opciones Directorio y Gerencia. +- [ ] 1.3 tiene opción Tecnología asistiva. +- [ ] Header muestra org_name/branch_name/branch_city de brand_config cuando existe. +- [ ] `?s=UUID` carga la encuesta correcta; sin parámetro carga la primera live. +- [ ] Pantalla de consentimiento aparece antes del formulario con texto honesto (anonimato + no revocación). +- [ ] Completar y enviar el formulario escribe en Supabase: 1 consent_record + 1 response + N answers. +- [ ] Verificar en Supabase Studio que los registros aparecen. +- [ ] `response.ip_hash` no es null ni la IP en texto plano (es el hash). +- [ ] `response.company_id` = el org_id de la survey. +- [ ] `response.consent_record_id` = el id del consent_record recién creado. +- [ ] `answers` tiene una fila por cada pregunta respondida. +- [ ] `npm run build` limpio. +- [ ] Sin `service_role` ni `IP_HASH_SALT` en código cliente ni en `NEXT_PUBLIC_*`. +- [ ] `git diff --name-only HEAD` solo con los archivos autorizados. + +## 8. Validaciones mandatorias +- Enviar el formulario → verificar en Supabase Studio (Table Editor) que aparecen filas en + `consent_records`, `responses`, `answers`. Si alguna tabla queda vacía → error, no declarar lista. +- Enviar dos veces desde el mismo browser → verificar que `ip_hash` es el mismo (deduplicación visible). +- El texto del consentimiento menciona explícitamente que la encuesta es anónima y no se puede revocar. +- Si CUALQUIER validación falla: NO declarar lista. Reportar y esperar. + +## 9. Qué reportar +- Verificado: captura de las filas escritas en consent_records + responses + answers (IDs concretos). +- Verificado: ip_hash no es null y es un hash (64 chars hex). +- Verificado: company_id del response = org_id de la survey. +- Asumidos, hallazgos, decisiones por iniciativa (con nivel). +- `git diff --name-only HEAD`. + +## 10. Qué NO hacer +- NO exponer `IP_HASH_SALT` ni `service_role` al cliente. +- NO poner la IP en texto plano en la base. +- NO saltar la pantalla de consentimiento. +- NO escribir en la base si el checkbox 1 no fue marcado. +- NO modificar las migraciones 001–007 ni las policies de RLS. +- NO hardcodear el survey_id en el código (siempre leer del parámetro URL). +- Ante conflicto entre restricción y "que funcione": detenerse y reportar. \ No newline at end of file diff --git a/supabase/migrations/20260604200932_fix_answers_insert_policy.sql b/supabase/migrations/20260604200932_fix_answers_insert_policy.sql new file mode 100644 index 0000000..dcc1a13 --- /dev/null +++ b/supabase/migrations/20260604200932_fix_answers_insert_policy.sql @@ -0,0 +1,8 @@ +-- Migration 008: fix answers_insert policy +-- Root cause: WITH CHECK con EXISTS en responses +-- bloqueaba INSERT de anon porque anon no tiene SELECT en responses. +-- El FK response_id → responses(id) ya garantiza la integridad referencial. +DROP POLICY "answers_insert" ON public.answers; +CREATE POLICY "answers_insert" ON public.answers + FOR INSERT TO anon, authenticated + WITH CHECK (true); diff --git a/supabase/seed.sql b/supabase/seed.sql index b794887..3a1e5b7 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -48,6 +48,11 @@ INSERT INTO public.surveys ( '20000000-0000-0000-0000-000000000001', 5 ) ON CONFLICT (id) DO NOTHING; +-- brand_config: org demo (el director actualiza con datos reales antes de enviar el link) +UPDATE public.surveys +SET brand_config = '{"org_name":"Empresa Demo","branch_name":"Oficina Central","branch_city":"Asunción"}'::jsonb +WHERE id = '30000000-0000-0000-0000-000000000001'; + -- ── Preguntas: DELETE + INSERT para idempotencia total ──────── DELETE FROM public.questions WHERE survey_id = '30000000-0000-0000-0000-000000000001'; @@ -55,27 +60,69 @@ WHERE survey_id = '30000000-0000-0000-0000-000000000001'; -- ══════════════════════════════════════════════════════════════ -- SECCIÓN A — Datos Generales (top-level, cuasi-identificadores) --- A1/A2/A3: text_short (texto libre, sin opciones) --- qi_sector ← A4 · qi_age_bucket ← A5 --- qi_city/qi_country ← A2/A1: se agregan como columnas qi en Sprint 3 +-- A1=Departamento (single_choice, GeoSelectWidget), A2=Ciudad (single_choice, filtrado client-side) +-- A3=Barrio (text_short, opcional) +-- qi_sector ← A4 · qi_age_bucket ← A5 · qi_city/qi_country ← Sprint 3 -- ══════════════════════════════════════════════════════════════ -INSERT INTO public.questions (id,survey_id,code,type,prompt,position,is_sensitive,required) +-- A1: Departamento (18 opciones — los 18 departamentos de Paraguay + Capital) +INSERT INTO public.questions (id,survey_id,code,type,prompt,position,is_sensitive,required,options) VALUES ('40000000-0000-0000-0000-000000000001','30000000-0000-0000-0000-000000000001', - 'A1','text_short','País',1,false,false); + 'A1','single_choice','Departamento',1,false,false, + '[{"value":"Asunción (Capital)","label":"Asunción (Capital)"}, + {"value":"Alto Paraguay","label":"Alto Paraguay"}, + {"value":"Alto Paraná","label":"Alto Paraná"}, + {"value":"Amambay","label":"Amambay"}, + {"value":"Boquerón","label":"Boquerón"}, + {"value":"Caaguazú","label":"Caaguazú"}, + {"value":"Caazapá","label":"Caazapá"}, + {"value":"Canindeyú","label":"Canindeyú"}, + {"value":"Central","label":"Central"}, + {"value":"Concepción","label":"Concepción"}, + {"value":"Cordillera","label":"Cordillera"}, + {"value":"Guairá","label":"Guairá"}, + {"value":"Itapúa","label":"Itapúa"}, + {"value":"Misiones","label":"Misiones"}, + {"value":"Ñeembucú","label":"Ñeembucú"}, + {"value":"Paraguarí","label":"Paraguarí"}, + {"value":"Presidente Hayes","label":"Presidente Hayes"}, + {"value":"San Pedro","label":"San Pedro"}]'::jsonb); -INSERT INTO public.questions (id,survey_id,code,type,prompt,position,is_sensitive,required) +-- A2: Ciudad (todas las ciudades; el GeoSelectWidget filtra client-side por departamento) +INSERT INTO public.questions (id,survey_id,code,type,prompt,position,is_sensitive,required,options) VALUES ('40000000-0000-0000-0000-000000000002','30000000-0000-0000-0000-000000000001', - 'A2','text_short','Ciudad',2,false,false); + 'A2','single_choice','Ciudad',2,false,false, + '[{"value":"Asunción","label":"Asunción"}, + {"value":"Fuerte Olimpo","label":"Fuerte Olimpo"},{"value":"Bahía Negra","label":"Bahía Negra"},{"value":"Carmelo Peralta","label":"Carmelo Peralta"},{"value":"La Victoria","label":"La Victoria"}, + {"value":"Ciudad del Este","label":"Ciudad del Este"},{"value":"Hernandarias","label":"Hernandarias"},{"value":"Minga Guazú","label":"Minga Guazú"},{"value":"Presidente Franco","label":"Presidente Franco"},{"value":"Minga Porã","label":"Minga Porã"},{"value":"Naranjal","label":"Naranjal"},{"value":"Ñacunday","label":"Ñacunday"},{"value":"San Alberto","label":"San Alberto"},{"value":"Santa Rita","label":"Santa Rita"},{"value":"Tavapy","label":"Tavapy"},{"value":"Yguazú","label":"Yguazú"},{"value":"Juan E. O''Leary","label":"Juan E. O''Leary"},{"value":"San Cristóbal","label":"San Cristóbal"},{"value":"Santa Fe del Paraná","label":"Santa Fe del Paraná"},{"value":"Dr. Juan León Mallorquín","label":"Dr. Juan León Mallorquín"},{"value":"Itakyry","label":"Itakyry"},{"value":"Domingo Martínez de Irala","label":"Domingo Martínez de Irala"}, + {"value":"Pedro Juan Caballero","label":"Pedro Juan Caballero"},{"value":"Bella Vista Norte","label":"Bella Vista Norte"},{"value":"Capitán Bado","label":"Capitán Bado"},{"value":"Zanja Pytã","label":"Zanja Pytã"}, + {"value":"Filadelfia","label":"Filadelfia"},{"value":"Loma Plata","label":"Loma Plata"},{"value":"Mariscal Estigarribia","label":"Mariscal Estigarribia"},{"value":"Neuland","label":"Neuland"}, + {"value":"Coronel Oviedo","label":"Coronel Oviedo"},{"value":"Caaguazú","label":"Caaguazú"},{"value":"Carayaó","label":"Carayaó"},{"value":"Dr. Juan Manuel Frutos","label":"Dr. Juan Manuel Frutos"},{"value":"Mbutuy","label":"Mbutuy"},{"value":"Nueva Londres","label":"Nueva Londres"},{"value":"Raúl Arsenio Oviedo","label":"Raúl Arsenio Oviedo"},{"value":"Repatriación","label":"Repatriación"},{"value":"San Joaquín","label":"San Joaquín"},{"value":"San José de los Arroyos","label":"San José de los Arroyos"},{"value":"Simón Bolívar","label":"Simón Bolívar"},{"value":"Vaquería","label":"Vaquería"},{"value":"Yhú","label":"Yhú"},{"value":"José Domingo Ocampos","label":"José Domingo Ocampos"},{"value":"3 de Febrero","label":"3 de Febrero"},{"value":"Mcal. Francisco Solano López","label":"Mcal. Francisco Solano López"},{"value":"Los Cedrales","label":"Los Cedrales"}, + {"value":"Caazapá","label":"Caazapá"},{"value":"Abai","label":"Abai"},{"value":"Buena Vista","label":"Buena Vista"},{"value":"General Higinio Morínigo","label":"General Higinio Morínigo"},{"value":"Huber Duré","label":"Huber Duré"},{"value":"Maciel","label":"Maciel"},{"value":"San Juan Nepomuceno","label":"San Juan Nepomuceno"},{"value":"Tavaí","label":"Tavaí"},{"value":"Yegros","label":"Yegros"},{"value":"Yuty","label":"Yuty"}, + {"value":"Salto del Guairá","label":"Salto del Guairá"},{"value":"Corpus Christi","label":"Corpus Christi"},{"value":"Curuguaty","label":"Curuguaty"},{"value":"General Francisco Caballero Álvarez","label":"General Francisco Caballero Álvarez"},{"value":"Itanará","label":"Itanará"},{"value":"Katueté","label":"Katueté"},{"value":"La Paloma","label":"La Paloma"},{"value":"Nueva Esperanza","label":"Nueva Esperanza"},{"value":"Yasy Kañy","label":"Yasy Kañy"},{"value":"Ypejhú","label":"Ypejhú"}, + {"value":"Areguá","label":"Areguá"},{"value":"Capiatá","label":"Capiatá"},{"value":"Fernando de la Mora","label":"Fernando de la Mora"},{"value":"Guarambaré","label":"Guarambaré"},{"value":"Itá","label":"Itá"},{"value":"Itauguá","label":"Itauguá"},{"value":"Juan Augusto Saldívar","label":"Juan Augusto Saldívar"},{"value":"Lambaré","label":"Lambaré"},{"value":"Limpio","label":"Limpio"},{"value":"Luque","label":"Luque"},{"value":"Mariano Roque Alonso","label":"Mariano Roque Alonso"},{"value":"Nueva Italia","label":"Nueva Italia"},{"value":"Ñemby","label":"Ñemby"},{"value":"San Antonio","label":"San Antonio"},{"value":"San Lorenzo","label":"San Lorenzo"},{"value":"Villa Elisa","label":"Villa Elisa"},{"value":"Villeta","label":"Villeta"},{"value":"Ypané","label":"Ypané"},{"value":"Ypacaraí","label":"Ypacaraí"}, + {"value":"Concepción","label":"Concepción"},{"value":"Belén","label":"Belén"},{"value":"Horqueta","label":"Horqueta"},{"value":"Loreto","label":"Loreto"},{"value":"San Lázaro","label":"San Lázaro"},{"value":"San Carlos del Apa","label":"San Carlos del Apa"},{"value":"Yby Yaú","label":"Yby Yaú"},{"value":"Azote''y","label":"Azote''y"},{"value":"Sgto. José Félix López","label":"Sgto. José Félix López"}, + {"value":"Caacupé","label":"Caacupé"},{"value":"Altos","label":"Altos"},{"value":"Arroyos y Esteros","label":"Arroyos y Esteros"},{"value":"Caraguatay","label":"Caraguatay"},{"value":"Emboscada","label":"Emboscada"},{"value":"Eusebio Ayala","label":"Eusebio Ayala"},{"value":"Isla Pucú","label":"Isla Pucú"},{"value":"Itacurubí de la Cordillera","label":"Itacurubí de la Cordillera"},{"value":"Juan de Mena","label":"Juan de Mena"},{"value":"Loma Grande","label":"Loma Grande"},{"value":"Mbocayaty del Yhaguy","label":"Mbocayaty del Yhaguy"},{"value":"Nueva Colombia","label":"Nueva Colombia"},{"value":"Pirayú","label":"Pirayú"},{"value":"Primero de Marzo","label":"Primero de Marzo"},{"value":"Tobatí","label":"Tobatí"},{"value":"Valenzuela","label":"Valenzuela"}, + {"value":"Villarrica","label":"Villarrica"},{"value":"Borja","label":"Borja"},{"value":"Capitán Mauricio José Troche","label":"Capitán Mauricio José Troche"},{"value":"Coronel Martínez","label":"Coronel Martínez"},{"value":"Félix Pérez Cardozo","label":"Félix Pérez Cardozo"},{"value":"General Eugenio A. Garay","label":"General Eugenio A. Garay"},{"value":"Independencia","label":"Independencia"},{"value":"Itapé","label":"Itapé"},{"value":"Iturbe","label":"Iturbe"},{"value":"José Fassardi","label":"José Fassardi"},{"value":"Mbocayaty","label":"Mbocayaty"},{"value":"Natalicio Talavera","label":"Natalicio Talavera"},{"value":"Ñumí","label":"Ñumí"},{"value":"San Salvador","label":"San Salvador"},{"value":"Yataity","label":"Yataity"}, + {"value":"Encarnación","label":"Encarnación"},{"value":"Alto Verá","label":"Alto Verá"},{"value":"Bella Vista","label":"Bella Vista"},{"value":"Cambyretá","label":"Cambyretá"},{"value":"Capitán Meza","label":"Capitán Meza"},{"value":"Capitán Miranda","label":"Capitán Miranda"},{"value":"Carlos A. López","label":"Carlos A. López"},{"value":"Carmen del Paraná","label":"Carmen del Paraná"},{"value":"Coronel Bogado","label":"Coronel Bogado"},{"value":"Cosme","label":"Cosme"},{"value":"Edelira","label":"Edelira"},{"value":"Fram","label":"Fram"},{"value":"General Artigas","label":"General Artigas"},{"value":"Hohenau","label":"Hohenau"},{"value":"Itapúa Poty","label":"Itapúa Poty"},{"value":"Jesús","label":"Jesús"},{"value":"La Paz","label":"La Paz"},{"value":"Leandro Oviedo","label":"Leandro Oviedo"},{"value":"Mayor Otaño","label":"Mayor Otaño"},{"value":"Natalio","label":"Natalio"},{"value":"Nueva Alborada","label":"Nueva Alborada"},{"value":"Obligado","label":"Obligado"},{"value":"Pirapó","label":"Pirapó"},{"value":"San Cosme y Damián","label":"San Cosme y Damián"},{"value":"San Juan del Paraná","label":"San Juan del Paraná"},{"value":"San Pedro del Paraná","label":"San Pedro del Paraná"},{"value":"San Rafael del Paraná","label":"San Rafael del Paraná"},{"value":"Trinidad","label":"Trinidad"},{"value":"Tomás Romero Pereira","label":"Tomás Romero Pereira"}, + {"value":"San Juan Bautista","label":"San Juan Bautista"},{"value":"Ayolas","label":"Ayolas"},{"value":"San Ignacio","label":"San Ignacio"},{"value":"San Miguel","label":"San Miguel"},{"value":"San Patricio","label":"San Patricio"},{"value":"Santa María","label":"Santa María"},{"value":"Santa Rosa","label":"Santa Rosa"},{"value":"Santiago","label":"Santiago"},{"value":"Villa Florida","label":"Villa Florida"},{"value":"Yabebyry","label":"Yabebyry"}, + {"value":"Pilar","label":"Pilar"},{"value":"Alberdi","label":"Alberdi"},{"value":"Cerrito","label":"Cerrito"},{"value":"Desmochados","label":"Desmochados"},{"value":"General José Eduvigis Díaz","label":"General José Eduvigis Díaz"},{"value":"Guazú Cuá","label":"Guazú Cuá"},{"value":"Humaitá","label":"Humaitá"},{"value":"Isla Umbú","label":"Isla Umbú"},{"value":"Laureles","label":"Laureles"},{"value":"Mayor José de J. Martínez","label":"Mayor José de J. Martínez"},{"value":"Paso de Patria","label":"Paso de Patria"},{"value":"San Juan Bautista del Ñeembucú","label":"San Juan Bautista del Ñeembucú"},{"value":"Tacuaras","label":"Tacuaras"},{"value":"Villa Franca","label":"Villa Franca"},{"value":"Villa Oliva","label":"Villa Oliva"},{"value":"Villalba","label":"Villalba"}, + {"value":"Paraguarí","label":"Paraguarí"},{"value":"Acahay","label":"Acahay"},{"value":"Carapeguá","label":"Carapeguá"},{"value":"Escobar","label":"Escobar"},{"value":"General Bernardino Caballero","label":"General Bernardino Caballero"},{"value":"La Colmena","label":"La Colmena"},{"value":"Mbuyapey","label":"Mbuyapey"},{"value":"Quiindy","label":"Quiindy"},{"value":"Quyquyhó","label":"Quyquyhó"},{"value":"San Roque González de Santa Cruz","label":"San Roque González de Santa Cruz"},{"value":"Sapucaí","label":"Sapucaí"},{"value":"Tebicuarymí","label":"Tebicuarymí"},{"value":"Ybycuí","label":"Ybycuí"},{"value":"Ybytymí","label":"Ybytymí"},{"value":"Yaguarón","label":"Yaguarón"}, + {"value":"Villa Hayes","label":"Villa Hayes"},{"value":"Benjamín Aceval","label":"Benjamín Aceval"},{"value":"José Falcón","label":"José Falcón"},{"value":"Nanawa","label":"Nanawa"},{"value":"Nueva Asunción","label":"Nueva Asunción"},{"value":"Pozo Colorado","label":"Pozo Colorado"},{"value":"Puerto Pinasco","label":"Puerto Pinasco"},{"value":"Teniente Primero Manuel Irala Fernández","label":"Teniente Primero Manuel Irala Fernández"},{"value":"Villa del Río","label":"Villa del Río"}, + {"value":"San Pedro del Ycuamandiyú","label":"San Pedro del Ycuamandiyú"},{"value":"Antequera","label":"Antequera"},{"value":"Choré","label":"Choré"},{"value":"General Elizardo Aquino","label":"General Elizardo Aquino"},{"value":"Guayaibí","label":"Guayaibí"},{"value":"Itacurubí del Rosario","label":"Itacurubí del Rosario"},{"value":"Lima","label":"Lima"},{"value":"Nueva Germania","label":"Nueva Germania"},{"value":"Río Verde","label":"Río Verde"},{"value":"San Estanislao (Santaní)","label":"San Estanislao (Santaní)"},{"value":"Tacuatí","label":"Tacuatí"},{"value":"Unión","label":"Unión"},{"value":"Villa del Rosario","label":"Villa del Rosario"},{"value":"Yataity del Norte","label":"Yataity del Norte"} + ]'::jsonb); +-- A3: Barrio (text_short, opcional — antes era "Sucursal o sede") INSERT INTO public.questions (id,survey_id,code,type,prompt,position,is_sensitive,required) VALUES ('40000000-0000-0000-0000-000000000003','30000000-0000-0000-0000-000000000001', - 'A3','text_short','Sucursal o sede',3,false,false); + 'A3','text_short','¿En qué barrio vivís? (opcional)',3,false,false); INSERT INTO public.questions (id,survey_id,code,type,prompt,position,is_sensitive,required,options) VALUES ('40000000-0000-0000-0000-000000000004','30000000-0000-0000-0000-000000000001', 'A4','single_choice','¿En qué área o gerencia trabajás?',4,false,false, - '[{"value":"Operaciones/Producción","label":"Operaciones/Producción"}, + '[{"value":"Directorio","label":"Directorio"}, + {"value":"Gerencia","label":"Gerencia"}, + {"value":"Operaciones/Producción","label":"Operaciones/Producción"}, {"value":"Comercial/Ventas","label":"Comercial/Ventas"}, {"value":"Administración/Finanzas","label":"Administración/Finanzas"}, {"value":"Recursos Humanos/Cultura","label":"Recursos Humanos/Cultura"}, @@ -146,6 +193,7 @@ VALUES ('40000000-0000-0000-0000-000000000010','30000000-0000-0000-0000-00000000 {"value":"Andador","label":"Andador"}, {"value":"Muletas","label":"Muletas"}, {"value":"Prótesis","label":"Prótesis"}, + {"value":"Tecnología asistiva","label":"Tecnología asistiva"}, {"value":"No utilizo","label":"No utilizo"}]'::jsonb, '[{"if_question":"1.1","op":"eq","value":"Sí","action":"show"}]'::jsonb);