Protege /admin/* con Supabase Auth. Tabla user_roles con custom claims (app_role, org_id) en JWT. Middleware Next.js redirige a /login sin sesión. Página /login con email/password. /admin/responses migrado de service_role a sesión de usuario autenticado. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
250 lines
7.7 KiB
TypeScript
250 lines
7.7 KiB
TypeScript
import { createServerClient } from '@supabase/ssr'
|
|
import { cookies } from 'next/headers'
|
|
import { ResponsesTable, type DisplayRow } from './responses-table'
|
|
import { SignOutButton } from '../sign-out-button'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
const MAX_SUBMISSIONS = 500
|
|
const PROMPT_MAX_LEN = 60
|
|
|
|
const SURVEY_OPTIONS = [
|
|
{ label: 'Todos', id: null },
|
|
{ label: 'InQuality', id: '30000000-0000-0000-0000-000000000002' },
|
|
{ label: 'Fundación Solidaridad', id: '30000000-0000-0000-0000-000000000003' },
|
|
{ label: 'Demo', id: '30000000-0000-0000-0000-000000000001' },
|
|
] as const
|
|
|
|
interface AllowedQuestion {
|
|
id: string
|
|
code: string
|
|
prompt: string
|
|
type: string
|
|
position: number
|
|
}
|
|
|
|
interface AnswerRow {
|
|
value: unknown
|
|
question_id: string
|
|
}
|
|
|
|
interface ResponseRow {
|
|
id: string
|
|
submitted_at: string
|
|
qi_zone: string | null
|
|
qi_sector: string | null
|
|
answers: AnswerRow[]
|
|
}
|
|
|
|
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 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())}`
|
|
}
|
|
|
|
function truncate(text: string, max = PROMPT_MAX_LEN): string {
|
|
return text.length > max ? `${text.slice(0, max)}...` : text
|
|
}
|
|
|
|
// Spec 4.3 — null/undefined → null (renderizado como badge "sin dato")
|
|
function renderValue(value: unknown, type: string): string | null {
|
|
if (value === null || value === undefined) return null
|
|
|
|
switch (type) {
|
|
case 'single_choice':
|
|
case 'text_short':
|
|
return String(value).replace(/^"|"$/g, '')
|
|
case 'multiple_choice':
|
|
if (Array.isArray(value)) return value.join(', ')
|
|
return String(value)
|
|
case 'rating':
|
|
return String(value)
|
|
default:
|
|
return String(value)
|
|
}
|
|
}
|
|
|
|
export default async function AdminResponsesPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ survey_id?: string }>
|
|
}) {
|
|
const { survey_id: surveyId } = await searchParams
|
|
const supabase = await getSupabaseSession()
|
|
|
|
// 1. Preguntas permitidas — text_long e is_sensitive=true se excluyen ACÁ,
|
|
// antes de tocar la tabla answers. Sus valores nunca se consultan.
|
|
const { data: questionsData, error: questionsError } = await supabase
|
|
.from('questions')
|
|
.select('id, code, prompt, type, position')
|
|
.eq('is_sensitive', false)
|
|
.neq('type', 'text_long')
|
|
.order('position', { ascending: true })
|
|
|
|
if (questionsError) {
|
|
return (
|
|
<div className="admin-page" role="main">
|
|
<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 allowedQuestions = (questionsData ?? []) as AllowedQuestion[]
|
|
const allowedIds = allowedQuestions.map((q) => q.id)
|
|
const questionMap = new Map(allowedQuestions.map((q) => [q.id, q]))
|
|
|
|
// 2. Submissions con sus respuestas — answers!inner + .in() filtra el ARRAY
|
|
// embebido a solo question_id permitidos (consulta, no post-filtro).
|
|
let responsesQuery = supabase
|
|
.from('responses')
|
|
.select(
|
|
`
|
|
id,
|
|
submitted_at,
|
|
qi_zone,
|
|
qi_sector,
|
|
answers!inner ( value, question_id )
|
|
`
|
|
)
|
|
.in('answers.question_id', allowedIds)
|
|
|
|
if (surveyId) {
|
|
responsesQuery = responsesQuery.eq('survey_id', surveyId)
|
|
}
|
|
|
|
const { data: responsesData, error: responsesError } =
|
|
allowedIds.length > 0
|
|
? await responsesQuery.order('submitted_at', { ascending: false }).limit(MAX_SUBMISSIONS)
|
|
: { data: [] as ResponseRow[], error: null }
|
|
|
|
if (responsesError) {
|
|
return (
|
|
<div className="admin-page" role="main">
|
|
<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">{responsesError.message}</pre>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const responses = (responsesData ?? []) as unknown as ResponseRow[]
|
|
|
|
// 3. Contadores totales (independientes del límite de 500 submissions),
|
|
// filtrados por survey_id cuando hay filtro activo.
|
|
let submissionsCountQuery = supabase
|
|
.from('responses')
|
|
.select('*', { count: 'exact', head: true })
|
|
|
|
if (surveyId) {
|
|
submissionsCountQuery = submissionsCountQuery.eq('survey_id', surveyId)
|
|
}
|
|
|
|
const { count: totalSubmissions } = await submissionsCountQuery
|
|
|
|
let answersCountQuery = supabase
|
|
.from('answers')
|
|
.select('*, responses!inner(survey_id)', { count: 'exact', head: true })
|
|
.in('question_id', allowedIds)
|
|
|
|
if (surveyId) {
|
|
answersCountQuery = answersCountQuery.eq('responses.survey_id', surveyId)
|
|
}
|
|
|
|
const { count: totalAnswers } =
|
|
allowedIds.length > 0 ? await answersCountQuery : { count: 0 }
|
|
|
|
// 4. Aplanar: una fila por answer, ordenadas por position dentro de cada submission
|
|
const rows: DisplayRow[] = []
|
|
responses.forEach((response, idx) => {
|
|
const answers = response.answers
|
|
.filter((a) => questionMap.has(a.question_id))
|
|
.sort(
|
|
(a, b) =>
|
|
questionMap.get(a.question_id)!.position - questionMap.get(b.question_id)!.position
|
|
)
|
|
|
|
answers.forEach((a, ai) => {
|
|
const q = questionMap.get(a.question_id)!
|
|
const isFirstInGroup = ai === 0
|
|
rows.push({
|
|
key: `${response.id}-${a.question_id}`,
|
|
submissionNum: idx + 1,
|
|
isFirstInGroup,
|
|
date: isFirstInGroup ? formatDate(response.submitted_at) : '',
|
|
code: q.code,
|
|
prompt: truncate(q.prompt),
|
|
rendered: renderValue(a.value, q.type),
|
|
zone: isFirstInGroup ? (response.qi_zone ?? '—') : '',
|
|
sector: isFirstInGroup ? (response.qi_sector ?? '—') : '',
|
|
})
|
|
})
|
|
})
|
|
|
|
return (
|
|
<div className="admin-page" role="main">
|
|
<header className="admin-header">
|
|
<div>
|
|
<h1 className="admin-title">Control de respuestas</h1>
|
|
<p className="admin-subtitle">
|
|
{totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales ·
|
|
actualizado {formatDate(new Date().toISOString())}
|
|
</p>
|
|
<nav className="admin-survey-nav" aria-label="Selección de empresa">
|
|
{SURVEY_OPTIONS.map((opt) => (
|
|
<a
|
|
key={opt.label}
|
|
href={opt.id ? `/admin/responses?survey_id=${opt.id}` : '/admin/responses'}
|
|
className={
|
|
opt.id === (surveyId ?? null)
|
|
? 'admin-survey-link admin-survey-link-active'
|
|
: 'admin-survey-link'
|
|
}
|
|
aria-current={opt.id === (surveyId ?? null) ? 'page' : undefined}
|
|
>
|
|
{opt.label}
|
|
</a>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
<div className="admin-header-actions">
|
|
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
|
|
<SignOutButton />
|
|
</div>
|
|
</header>
|
|
|
|
<div className="admin-table-wrapper">
|
|
<ResponsesTable rows={rows} />
|
|
{rows.length === 0 && (
|
|
<p className="admin-empty">Sin respuestas registradas todavía.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|