diff --git a/i18n/en.json b/i18n/en.json index 07f4dd8..e216210 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -58,6 +58,13 @@ "voting": "Voting", "demoDay": "Demo Day" }, + "where": { + "label": "Where", + "link": "Link", + "calendar": "Add to my calendar", + "calendarTitle": "Add this event to Google Calendar", + "ics": "or download the .ics" + }, "login": { "prompt": "Sign in with your NaN account", "description": "Enter your NaN account email and we'll send you a link to sign in. It's the same access as the platform.", @@ -75,15 +82,18 @@ "notOpenTitle": "Not open yet", "notOpen": "Registration for this event isn't open yet. Check back soon to sign up.", "submissionNotOpen": "Submissions open on {date}. Come back then to submit your project.", + "submissionNotOpenNoDate": "Submissions aren't open yet. Come back soon to submit your project.", "closedTitle": "Registration closed", "closed": "The registration window has closed. We hope to see you at the next NaN event!", "inProgressTitle": "The event is underway", "inProgress": "Registration is closed, but you're already in. Check your status and your team.", "buildingTitle": "Submissions are open", "building": "Submit your project before {date}. You can edit it as many times as you want until then.", + "buildingNoDate": "Submit your project. You can edit it as many times as you want while the window is open.", "buildingCta": "Submit my project", "submittedTitle": "Submission received ✓", "submitted": "Your project is in. You can edit it until {date}.", + "submittedNoDate": "Your project is in. You can edit it while the window is open.", "submittedCta": "Edit my submission", "submissionClosedTitle": "Submissions closed", "submissionClosed": "The submission window has closed. Voting opens soon.", @@ -96,7 +106,10 @@ "results": "The event has ended. Check out the final leaderboard.", "resultsCta": "View leaderboard", "closedEventTitle": "Event closed", - "closedEvent": "This event has ended. Thanks to everyone who took part!" + "closedEvent": "This event has ended. Thanks to everyone who took part!", + "publishedTitle": "Open to everyone", + "published": "No registration needed. Add it to your calendar and join through the link on the day.", + "publishedCta": "Join the event" }, "alreadyRegistered": { "title": "You're already registered", @@ -165,11 +178,12 @@ "reassignCta": "Request reassignment", "reassignPending": "Request registered. Another teammate must confirm it ({have}/{need}).", "reassignFilled": "Reassignment complete: a member from the reserve pool has joined.", - "reassignNoPool": "Reassignment complete: the pool was empty, your team continues with the minimum size.", + "reassignNoPool": "Reassignment complete: the pool was empty, your team continues with the remaining members.", "reassignAlready": "You've already requested this reassignment. Another teammate still needs to confirm it.", "reassignSelectAbsent": "Select absent member", "reassignErrorSubmit": "Couldn't register.", "reassignSending": "Sending…", + "reassignRefresh": "See the updated team", "reassignInfo": { "label": "Reassign absent member", "toggleAria": "More information about reassignment", @@ -179,7 +193,7 @@ "It takes TWO team members: the request isn't executed until a second teammate confirms the same reassignment.", "Once that agreement is reached, that person is marked absent and removed from your team and from the hackathon. The action is immediate and irreversible.", "The system automatically picks the first candidate from the reserve pool (by registration order) and adds them to your team in their place.", - "If the pool is empty, your team continues with 3 members (the allowed minimum) and the organizers are notified.", + "If the pool is empty, your team continues with the remaining members and the organizers are notified.", "It's only available during the building phase." ] } @@ -224,8 +238,10 @@ "errorEligible": "Your NaN account is not eligible for this event.", "notOpenTitle": "Submissions not open yet", "notOpen": "Submissions open on {date}.", + "notOpenNoDate": "Submissions aren't open yet.", "closedTitle": "Submission window closed", "closedNote": "Submissions closed on {date}. They can no longer be created or edited.", + "closedNoteNoDate": "The submission window is closed. Submissions can no longer be created or edited.", "notSubmitted": "Nothing was submitted before the deadline closed.", "checks": "Automatic checks", "notPrizeEligible": "Prize requirements not met (see the event rules)." @@ -578,8 +594,18 @@ "types": { "workshop": "workshop", "event": "event", - "hackathon": "hackathon" + "hackathon": "hackathon", + "challenge": "challenge", + "other": "event" }, + "calendarTitle": "Take the agenda to your calendar.", + "calendarBody": "Add the NaN calendar once and the published events, plus the ones we announce later, show up on their own.", + "calendarGoogle": "Add to Google Calendar", + "calendarIcs": "Apple, Outlook or other (.ics)", + "calendarFeedLabel": "Calendar URL", + "calendarAdd": "+ calendar", + "calendarAddTitle": "Add this event to Google Calendar", + "calendarTz": "Times in Europe/Madrid.", "workshopsLabel": "workshops", "workshopsTitle": "Workshops.", "workshopsSub": "Hands-on sessions on agents, tooling and shipping with open models. Recorded for members.", diff --git a/i18n/es.json b/i18n/es.json index 718051d..e7bb7cf 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -58,6 +58,13 @@ "voting": "Votación", "demoDay": "Demo Day" }, + "where": { + "label": "Dónde", + "link": "Enlace", + "calendar": "Añadir a mi calendario", + "calendarTitle": "Añadir este evento a Google Calendar", + "ics": "o descarga el .ics" + }, "login": { "prompt": "Entra con tu cuenta NaN", "description": "Introduce el email de tu cuenta NaN y te enviaremos un enlace para iniciar sesión. Es el mismo acceso que la plataforma.", @@ -75,15 +82,18 @@ "notOpenTitle": "Todavía no está abierto", "notOpen": "La inscripción a este evento aún no está abierta. Vuelve pronto para apuntarte.", "submissionNotOpen": "Las entregas abren el {date}. Vuelve entonces para entregar tu proyecto.", + "submissionNotOpenNoDate": "Las entregas todavía no están abiertas. Vuelve pronto para entregar tu proyecto.", "closedTitle": "Inscripción cerrada", "closed": "El plazo de inscripción ha terminado. ¡Esperamos verte en el próximo evento de NaN!", "inProgressTitle": "El evento está en marcha", "inProgress": "La inscripción está cerrada, pero tú ya estás dentro. Consulta tu estado y tu equipo.", "buildingTitle": "Entregas abiertas", "building": "Entrega tu proyecto antes del {date}. Puedes editarlo tantas veces como quieras hasta entonces.", + "buildingNoDate": "Entrega tu proyecto. Puedes editarlo tantas veces como quieras mientras el plazo siga abierto.", "buildingCta": "Entregar mi proyecto", "submittedTitle": "Entrega recibida ✓", "submitted": "Tu proyecto está dentro. Puedes editarlo hasta el {date}.", + "submittedNoDate": "Tu proyecto está dentro. Puedes editarlo mientras el plazo siga abierto.", "submittedCta": "Editar mi entrega", "submissionClosedTitle": "Entregas cerradas", "submissionClosed": "El plazo de entrega ha terminado. La votación abre pronto.", @@ -96,7 +106,10 @@ "results": "El evento ha terminado. Consulta la clasificación final.", "resultsCta": "Ver clasificación", "closedEventTitle": "Evento cerrado", - "closedEvent": "Este evento ha terminado. ¡Gracias a todos los que habéis participado!" + "closedEvent": "Este evento ha terminado. ¡Gracias a todos los que habéis participado!", + "publishedTitle": "Abierto a todos", + "published": "Este evento no necesita inscripción. Apúntalo en tu calendario y entra por el enlace el día del evento.", + "publishedCta": "Ir al evento" }, "alreadyRegistered": { "title": "Ya estás inscrito", @@ -165,11 +178,12 @@ "reassignCta": "Convocar reasignación", "reassignPending": "Convocatoria registrada. Hace falta que otro compañero la confirme ({have}/{need}).", "reassignFilled": "Reasignación completada: se ha incorporado un miembro de la pool de reservas.", - "reassignNoPool": "Reasignación completada: no quedaba nadie en la pool, el equipo continúa con 3 miembros.", + "reassignNoPool": "Reasignación completada: no quedaba nadie en la pool, el equipo sigue con los miembros que quedan.", "reassignAlready": "Ya has convocado esta reasignación. Falta que otro compañero la confirme.", "reassignSelectAbsent": "Selecciona ausente", "reassignErrorSubmit": "No se pudo registrar.", "reassignSending": "Enviando…", + "reassignRefresh": "Ver el equipo actualizado", "reassignInfo": { "label": "Reasignar miembro ausente", "toggleAria": "Más información sobre la reasignación", @@ -179,7 +193,7 @@ "Hacen falta DOS miembros del equipo: la convocatoria no se ejecuta hasta que un segundo compañero confirma la misma reasignación.", "Cuando se alcanza ese acuerdo, esa persona se marca como ausente y sale de tu equipo y del hackatón. La acción es inmediata e irreversible.", "El sistema busca automáticamente al primer candidato de la pool de reservas (por orden de inscripción) y lo añade a tu equipo en su lugar.", - "Si no queda nadie en la pool, tu equipo continúa con 3 miembros (el mínimo permitido) y se avisa a la organización.", + "Si no queda nadie en la pool, tu equipo sigue con los miembros que quedan y se avisa a la organización.", "Solo está disponible durante la fase de construcción." ] } @@ -224,8 +238,10 @@ "errorEligible": "Tu cuenta de NaN no puede participar en este evento.", "notOpenTitle": "Entregas aún no abiertas", "notOpen": "Las entregas abren el {date}.", + "notOpenNoDate": "Las entregas todavía no están abiertas.", "closedTitle": "Plazo de entrega cerrado", "closedNote": "Las entregas cerraron el {date}. Ya no se pueden crear ni editar.", + "closedNoteNoDate": "El plazo de entrega está cerrado. Ya no se pueden crear ni editar.", "notSubmitted": "No se entregó nada antes de que cerrara el plazo.", "checks": "Comprobaciones automáticas", "notPrizeEligible": "No cumple los requisitos del premio (ver reglas del evento)." @@ -578,8 +594,18 @@ "types": { "workshop": "workshop", "event": "evento", - "hackathon": "hackathon" + "hackathon": "hackathon", + "challenge": "reto", + "other": "evento" }, + "calendarTitle": "Llévate la agenda a tu calendario.", + "calendarBody": "Añade el calendario de NaN una vez y te aparecerán los eventos publicados y los que vayamos anunciando, sin volver aquí.", + "calendarGoogle": "Añadir a Google Calendar", + "calendarIcs": "Apple, Outlook u otro (.ics)", + "calendarFeedLabel": "URL del calendario", + "calendarAdd": "+ calendario", + "calendarAddTitle": "Añadir este evento a Google Calendar", + "calendarTz": "Horas en Europe/Madrid.", "workshopsLabel": "workshops", "workshopsTitle": "Workshops.", "workshopsSub": "Sesiones prácticas sobre agentes, tooling y lanzar con modelos abiertos. Grabadas para miembros.", diff --git a/package-lock.json b/package-lock.json index 021faee..4e204e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@astrojs/check": "^0.9.8", "@cloudflare/workers-types": "^4.20260317.1", "playwright": "^1.59.1", + "preact-render-to-string": "^6.6.7", "typescript": "^5.9.3", "vitest": "^4.1.0", "wrangler": "^4.76.0" diff --git a/package.json b/package.json index d459bba..39a7ef4 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@astrojs/check": "^0.9.8", "@cloudflare/workers-types": "^4.20260317.1", "playwright": "^1.59.1", + "preact-render-to-string": "^6.6.7", "typescript": "^5.9.3", "vitest": "^4.1.0", "wrangler": "^4.76.0" diff --git a/public/img/events/gentleman-programming.webp b/public/img/events/gentleman-programming.webp new file mode 100644 index 0000000..556e9b3 Binary files /dev/null and b/public/img/events/gentleman-programming.webp differ diff --git a/src/components/events/EventHeader.astro b/src/components/events/EventHeader.astro index 725c9e4..ce9e295 100644 --- a/src/components/events/EventHeader.astro +++ b/src/components/events/EventHeader.astro @@ -2,7 +2,9 @@ /** * Cabecera común de las pantallas de un evento: enlace atrás, etiqueta del * tipo de evento, nombre y navegación entre pantallas (solo las que aplican - * al formato/fase). + * al formato/fase). El titular usa la Archivo expandida del sistema, pero con + * un tamaño propio: estas pantallas montan el shell con scale={false} porque + * la escala de 96px parte las palabras en columnas estrechas. */ import { t, withLang } from '../../lib/i18n'; import type { EventInfo } from '../../lib/events'; @@ -28,19 +30,32 @@ if (event.windows.leaderboard.visible) nav.push({ key: 'leaderboard', href: `${b --- + class="inline-block font-mono text-sm text-neutral-500 transition-colors hover:text-violet-400"> {current === 'index' ? t('events.backToEvents', locale) : t('events.backToEvent', locale)} -
-

// {kindLabel}

-

- {event.name} -

+
+

// {kindLabel}

+

{event.name}

{nav.length > 0 && ( -
+ + diff --git a/src/components/events/LoginForm.tsx b/src/components/events/LoginForm.tsx index 5363712..b212f9c 100644 --- a/src/components/events/LoginForm.tsx +++ b/src/components/events/LoginForm.tsx @@ -66,34 +66,33 @@ export default function LoginForm({ t }: { t: LoginStrings }) { if (status.kind === 'success') { return ( -
-

{t.success}

+
+

{t.success}

); } const submitting = status.kind === 'submitting'; const err = status.kind === 'error' ? status.message : null; - const inputCls = - 'w-full bg-neutral-950 border border-neutral-800 rounded-lg px-4 py-3 text-sm text-white font-mono placeholder-neutral-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/40 transition-colors'; + // .field y .btn son los controles de la casa (styles/nan-system.css). + const inputCls = 'field w-full'; return ( -
+
-

{t.prompt}

-

{t.description}

+

{t.prompt}

+

{t.description}

-
- {err && } -
diff --git a/src/components/events/ReassignForm.tsx b/src/components/events/ReassignForm.tsx index 27766e3..600ff17 100644 --- a/src/components/events/ReassignForm.tsx +++ b/src/components/events/ReassignForm.tsx @@ -21,6 +21,7 @@ interface ReassignLabels { selectAbsent: string; errorSubmit: string; sending: string; + refresh: string; } export default function ReassignForm({ @@ -39,6 +40,10 @@ export default function ReassignForm({ const [ghostId, setGhostId] = useState(''); const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error'>('idle'); const [result, setResult] = useState(''); + // La convocatoria que se ejecuta cambia el equipo (sale el ausente, entra la + // reserva), y la lista de compañeros viene del servidor: sin recargar seguiría + // enseñando el equipo de antes justo debajo del aviso de que ha cambiado. + const [resolved, setResolved] = useState(false); const [showInfo, setShowInfo] = useState(false); async function submit() { @@ -62,8 +67,8 @@ export default function ReassignForm({ return; } const r = body?.data; - if (r?.status === 'filled') setResult(labels.filled); - else if (r?.status === 'no_pool') setResult(labels.noPool); + if (r?.status === 'filled') { setResult(labels.filled); setResolved(true); } + else if (r?.status === 'no_pool') { setResult(labels.noPool); setResolved(true); } else { const have = String(r?.requested_by?.length ?? 1); const need = String(r?.quorum ?? 2); @@ -76,7 +81,17 @@ export default function ReassignForm({ } if (state === 'done') { - return

{result}

; + return ( +
+

{result}

+ {resolved && ( + + )} +
+ ); } return ( diff --git a/src/components/events/RegisterForm.tsx b/src/components/events/RegisterForm.tsx index 39fe940..0bc68e7 100644 --- a/src/components/events/RegisterForm.tsx +++ b/src/components/events/RegisterForm.tsx @@ -1,5 +1,6 @@ import { useState } from 'preact/hooks'; import type { TargetedSubmitEvent } from 'preact'; +import { optionLabel } from '../../lib/i18n'; export interface FormStrings { name: string; discord: string; discordHelp: string; discordOptional: string; @@ -20,7 +21,8 @@ type Status = * Inscripción a un evento (SPEC §6.3 `POST /api/events/{slug}/register`). * Los campos se muestran según la configuración del evento (§3.2): * `discordMode` (`required|optional|hidden`), `specialties` y `levels` (vacío = - * no se pregunta). `options` traduce cada valor; si falta, se muestra el valor. + * no se pregunta). `options` traduce cada valor; los que no conoce (v3 los deja + * escribir a quien organiza) salen tal cual, con la inicial en mayúscula. */ export default function RegisterForm({ slug, t, meHref, discordMode, specialties, levels, options }: { slug: string; @@ -38,7 +40,7 @@ export default function RegisterForm({ slug, t, meHref, discordMode, specialties const [status, setStatus] = useState({ kind: 'idle' }); const askSpecialty = specialties.length > 0; const askLevel = levels.length > 0; - const label = (v: string) => options[v] ?? v; + const label = (v: string) => optionLabel(options, v); async function onSubmit(e: TargetedSubmitEvent) { e.preventDefault(); @@ -84,12 +86,12 @@ export default function RegisterForm({ slug, t, meHref, discordMode, specialties if (status.kind === 'success') { return ( -
-

+

+

{status.reserve ? t.successReserve : t.success}

- - → {t.successCta} + + {t.successCta}
); @@ -97,12 +99,12 @@ export default function RegisterForm({ slug, t, meHref, discordMode, specialties const submitting = status.kind === 'submitting'; const err = status.kind === 'error' ? status.message : null; - const inputCls = - 'w-full bg-neutral-950 border border-neutral-800 rounded-lg px-4 py-3 text-sm text-white font-mono placeholder-neutral-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/40 transition-colors'; - const labelCls = 'block font-mono text-[10px] uppercase tracking-widest text-violet-400 mb-2'; + // .field y .btn son los controles de la casa (styles/nan-system.css). + const inputCls = 'field w-full'; + const labelCls = 'mb-2 block font-mono text-xs uppercase tracking-[0.2em] text-violet-400'; return ( -
+
setDiscord((e.currentTarget as HTMLInputElement).value)} class={inputCls} /> -

+

{discordMode === 'required' ? t.discordHelp : t.discordOptional}

@@ -140,9 +142,8 @@ export default function RegisterForm({ slug, t, meHref, discordMode, specialties
)} - {err && } - diff --git a/src/components/events/RegistrationBar.astro b/src/components/events/RegistrationBar.astro index 23b80f5..7a41798 100644 --- a/src/components/events/RegistrationBar.astro +++ b/src/components/events/RegistrationBar.astro @@ -43,43 +43,43 @@ const remainingLabel = lowSeats const poolNote = fill(cap.poolNote, { cap: capacity, taken: poolTaken, res: reserve }); --- -
-
- {takenLabel} - +
+
+ {takenLabel} + {!inPool && remainingLabel}
- {/* Barra principal: 40 plazas */} -
+ {/* Barra principal: las plazas del evento */} +
{inPool && ( -
-
- {cap.poolBarLabel} - {poolTaken} / {reserve} +
+
+ {cap.poolBarLabel} + {poolTaken} / {reserve}
-
+
-
-

+

+

{cap.poolTitle}

-

{poolNote}

-

{cap.poolExplain}

+

{poolNote}

+

{cap.poolExplain}

)} diff --git a/src/components/events/SubmissionForm.tsx b/src/components/events/SubmissionForm.tsx index bb47678..cb844c3 100644 --- a/src/components/events/SubmissionForm.tsx +++ b/src/components/events/SubmissionForm.tsx @@ -1,5 +1,7 @@ import { useState } from 'preact/hooks'; import type { TargetedSubmitEvent } from 'preact'; +import { optionLabel } from '../../lib/i18n'; +import type { Check } from '../../lib/events'; type FieldMode = 'required' | 'optional' | 'hidden'; interface Fields { @@ -9,7 +11,6 @@ interface Fields { image_url: FieldMode; video_url: FieldMode; } -interface CheckItem { pass: boolean } interface Existing { title?: string; description?: string; @@ -20,12 +21,12 @@ interface Existing { video_url?: string; auto_points?: number; not_prize_eligible?: boolean; - checks?: Record; + checks?: Record; } interface Result { auto_points: number; not_prize_eligible: boolean; - checks: Record; + checks: Record; } // Convierte la respuesta del backend en el resumen de checks que pinta la UI. @@ -55,7 +56,11 @@ interface Props { discordMode: 'required' | 'optional' | 'none'; specialties: string[]; levels: string[]; - /** Etiquetas traducidas de especialidades/niveles y de los checks. */ + /** + * Etiquetas traducidas de especialidades/niveles y de los checks. Solo + * traduce el vocabulario fijo de v2: el de especialidades y niveles lo + * escribe quien organiza (§3.2) y lo que no conoce pasa por `optionLabel`. + */ options: Record; } @@ -91,7 +96,11 @@ export default function SubmissionForm({ setF({ ...f, [k]: (e.currentTarget as HTMLInputElement | HTMLTextAreaElement).value }); const setPart = (k: keyof typeof p) => (e: Event) => setP({ ...p, [k]: (e.currentTarget as HTMLInputElement | HTMLSelectElement).value }); - const label = (v: string) => options[v] ?? v; + // Especialidades y niveles: vocabulario libre del evento, igual que en el + // alta, así que lo que no está en el diccionario sale con la inicial en + // mayúscula. Los checks no: ese vocabulario lo fija el backend. + const label = (v: string) => optionLabel(options, v); + const checkLabel = (v: string) => options[v] ?? v; const fieldLabel: Record = { title: t.fTitle, description: t.description, public_url: t.publicUrl, space_url: t.spaceUrl, repo_url: t.repoUrl, image_url: t.imageUrl, video_url: t.videoUrl, @@ -293,7 +302,7 @@ export default function SubmissionForm({

{t.checks}: {result.auto_points}/{autoMax}

    {checks.map((k) => ( -
  • {result.checks[k]?.pass ? '✓' : '✗'} {label(k)}
  • +
  • {result.checks[k]?.pass ? '✓' : '✗'} {checkLabel(k)}
  • ))}
{result.not_prize_eligible &&

{t.notPrizeEligible}

} diff --git a/src/components/events/admin/AdminNotices.astro b/src/components/events/admin/AdminNotices.astro new file mode 100644 index 0000000..f240f04 --- /dev/null +++ b/src/components/events/admin/AdminNotices.astro @@ -0,0 +1,43 @@ +--- +/** + * Avisos del panel: confirmación tras redirección (`?ok=`), avisos del + * backend (§8, traducidos) y error de la última respuesta. Cada bloque solo + * se pinta si tiene contenido. + */ +import type { ApiResult } from '../../../lib/eventsAdmin'; +import { warningLabel } from '../../../lib/eventsAdminForms'; + +interface Props { + ok?: string | null; + warnings?: string[]; + result?: ApiResult; + /** Texto que precede a la lista de avisos (p. ej. "Simulación: no se ha guardado nada"). */ + warningsTitle?: string; + forbidden?: boolean; +} +const { ok = null, warnings = [], result, warningsTitle, forbidden = false } = Astro.props; +const errorText = result && !result.ok + ? [result.message ?? result.error ?? 'error', result.fields.length ? `campos: ${result.fields.join(', ')}` : '', result.detail ?? ''] + .filter(Boolean) + .join(' · ') + : ''; +const showWarnings = warnings.length > 0 || (result?.ok && (result.warnings.length > 0 || warningsTitle)); +--- + +{forbidden &&

La petición no venía de nan.builders y no se ha procesado.

} +{ok &&

{ok}

} +{errorText && ( +

+ Error {result?.status || ''}: {errorText} +

+)} +{showWarnings && ( +
+ {warningsTitle &&

{warningsTitle}

} + {(warnings.length ? warnings : result?.warnings ?? []).length > 0 && ( +
    + {(warnings.length ? warnings : result?.warnings ?? []).map((w) =>
  • {w} — {warningLabel(w)}
  • )} +
+ )} +
+)} diff --git a/src/components/events/admin/EventForm.astro b/src/components/events/admin/EventForm.astro new file mode 100644 index 0000000..6a3547d --- /dev/null +++ b/src/components/events/admin/EventForm.astro @@ -0,0 +1,191 @@ +--- +/** + * Formulario del `event.json` (SPEC v3 §3.1, API.md §5.1), compartido por + * el alta (`/events/admin/nuevo`) y la ficha (`/events/admin/{slug}`). Sin + * JavaScript: los botones llevan `name="action"` y el fichero de ruta decide + * qué hacer (`create`/`save`, o `simulate` para un `dry_run`). + */ +import type { EventFormValues } from '../../../lib/eventsAdminForms'; +import { CHECKS, DATE_FIELDS, EVENT_KINDS, FIELD_MODES, SUBMISSION_FIELDS } from '../../../lib/eventsAdminForms'; + +interface Props { + values: EventFormValues; + mode: 'new' | 'edit'; + /** Solo lectura (evento archivado). */ + disabled?: boolean; + /** Fuera de `draft` el slug no se puede cambiar (409 slug_immutable). */ + slugLocked?: boolean; +} +const { values, mode, disabled = false, slugLocked = false } = Astro.props; +const v = (k: string) => (typeof values[k] === 'string' ? (values[k] as string) : ''); +const has = (k: string, item: string) => Array.isArray(values[k]) && (values[k] as string[]).includes(item); +const checked = (k: string) => v(k) !== ''; +--- + +
+
+

Datos básicos

+
+ + + + + + + +
+
+ + + + +

El módulo de equipos va con el formato: por equipos lo activa, individual lo apaga.

+
+
+ +
+

Fechas (UTC)

+
+ {DATE_FIELDS.map((d) => ( + + ))} +
+
+ +
+

Inscripción

+
+ + + + + +
+
+ +
+

Equipos (solo formato por equipos)

+
+ + + +
+
+ +
+

Entregas

+
+ {SUBMISSION_FIELDS.map((f) => ( + + ))} + +
+
+
+ Checks automáticos +
+ {CHECKS.map((c) => ( + + ))} +
+
+
+ El premio exige +
+ {CHECKS.map((c) => ( + + ))} +
+

Solo cuenta si el check también está activo.

+
+
+
+ +
+

Votación

+
+ +

Los puntos automáticos máximos se derivan de los checks que puntúan.

+
+
+ + {!disabled && ( +
+ + +
+ )} +
diff --git a/src/components/events/admin/StateControl.astro b/src/components/events/admin/StateControl.astro new file mode 100644 index 0000000..f795050 --- /dev/null +++ b/src/components/events/admin/StateControl.astro @@ -0,0 +1,106 @@ +--- +/** + * Control de estado del evento (SPEC v3 §4.2 y §8, W-04): estado actual, + * destinos permitidos, previsualización de avisos con `dry_run` y + * confirmación explícita; además el sweep manual (§4.3). El POST lo procesa + * el envoltorio de ruta; aquí solo se pinta lo que devolvió. + */ +import { STATUS_LABELS, type AdminEventView } from '../../../lib/eventsAdmin'; +import { STATE_MOVE_LABELS, stateTargets, warningLabel, type FormOutcome } from '../../../lib/eventsAdminForms'; + +interface Props { + view: AdminEventView; + outcome: FormOutcome; + disabled?: boolean; +} +const { view, outcome, disabled = false } = Astro.props; +const ev = view.event; +const label = (s: string) => STATUS_LABELS[s] ?? s; +const targets = stateTargets(ev.status, ev.modules, ev.previous_status); +const chosen = outcome.values ? String(outcome.values.status ?? '') : ''; + +const preview = outcome.action === 'state_preview' && outcome.result?.ok ? outcome.result : null; +const previewWarnings = preview?.warnings ?? []; +const previewNoChange = previewWarnings.includes('no_change'); +const previewTarget = targets.find((t) => t.status === chosen); + +const sweep = outcome.action === 'sweep' && outcome.result?.ok ? outcome.result : null; +const automation = ev.automation?.date_transitions ?? false; +--- + +
+

Estado

+
+
Estado actual
{label(ev.status)}
+ {ev.status === 'cancelled' && ev.previous_status &&
Estado previo
{label(ev.previous_status)}
} +
Automatización por fechas
{automation ? 'activa' : 'apagada'}
+
+ + {disabled ? ( +

Evento archivado: no se puede cambiar de estado.

+ ) : targets.length === 0 ? ( +

No hay transiciones posibles desde este estado.

+ ) : ( +
+ + + +
+ )} +

+ Avanzar puede saltar pasos; retroceder solo un paso. Nunca se borran datos: votos, entregas y equipos se conservan. + Cancelar guarda el estado actual para poder volver a él. +

+ + {preview && ( +
+ {previewNoChange ? ( +

El evento ya está en {label(chosen)}: no hay nada que cambiar.

+ ) : ( +

+ Previsualización de {label(ev.status)} → {label(chosen)}{previewWarnings.length ? ': consecuencias.' : ': sin avisos.'} No se ha cambiado nada todavía. +

+ )} + {previewWarnings.length > 0 && !previewNoChange && ( +
    {previewWarnings.map((w) =>
  • {w} — {warningLabel(w)}
  • )}
+ )} + {!previewNoChange && previewTarget && ( +
+ + + +
+ )} +
+ )} + + {sweep && ( +
+

El sweep no ha cambiado el estado.

+ {sweep.warnings.length > 0 &&
    {sweep.warnings.map((w) =>
  • {w} — {warningLabel(w)}
  • )}
} +
+ )} + + {!disabled && ( +
+ +

+ {automation + ? 'El sweep aplica ahora las transiciones cuyas fechas ya han pasado (inscripción → construcción, construcción → entrega, votación → cerrado).' + : 'Con la automatización apagada el sweep no cambia nada; actívala en el editor si quieres transiciones por fecha.'} +

+ +
+ )} +
diff --git a/src/layouts/EventsAdmin.astro b/src/layouts/EventsAdmin.astro new file mode 100644 index 0000000..3b160fa --- /dev/null +++ b/src/layouts/EventsAdmin.astro @@ -0,0 +1,61 @@ +--- +/** + * Shell del panel de administración de eventos (SPEC v3 §8): NanPage con + * noindex, cabecera común (enlace atrás, etiqueta, título) y, dentro de un + * evento, la navegación entre sus pantallas. Solo español: el panel no + * tiene variante `/es/`. + * + * La guardia de staff NO vive aquí: un layout es un componente y su `return` + * se ignora; la hace `resolveAdminRoute` desde cada fichero de ruta. + */ +import '../styles/events-admin.css'; +import NanPage from './NanPage.astro'; +import { ADMIN_SCREENS, adminHref, STATUS_LABELS, type AdminScreen, type StaffSession } from '../lib/eventsAdmin'; + +interface Props { + title: string; + staff: StaffSession; + /** Dentro de un evento: activa la navegación por pantallas. */ + event?: { slug: string; name: string; status: string } | null; + current?: AdminScreen; + /** Texto del enlace atrás (por defecto, a la lista o al evento). */ + back?: { href: string; label: string }; +} +const { title, staff, event = null, current = 'evento', back } = Astro.props; +const backLink = back ?? (event && current !== 'evento' + ? { href: adminHref(event.slug), label: '← Volver al evento' } + : { href: adminHref(), label: '← Todos los eventos' }); +const showBack = !(event === null && !back && Astro.url.pathname.replace(/\/$/, '') === adminHref()); +--- + + +
+
+ {showBack + ? {backLink.label} + : } + {staff.email} +
+
+

// Administración de eventos

+

+ {event ? event.name : title} +

+ {event && ( +

+ {event.slug} + · + {STATUS_LABELS[event.status] ?? event.status} +

+ )} + {event && ( + + )} +
+ +
+
diff --git a/src/layouts/NanBase.astro b/src/layouts/NanBase.astro index 40d5c8b..002fa0d 100644 --- a/src/layouts/NanBase.astro +++ b/src/layouts/NanBase.astro @@ -26,6 +26,11 @@ interface Props { * Por defecto va apagado, que es lo que quieren el 404 y el hackatón. */ nofollow?: boolean; + /** + * Social preview image (absolute URL). Pages with their own cover, like an + * event page, pass it here; the rest keep the generic NaN card. + */ + image?: string; /** * Escala de titulares del sistema (H1 gigante en mayúsculas). Se apaga en * las pantallas que todavía se escriben con utilidades de Tailwind y @@ -41,6 +46,7 @@ const { description = t('nan.meta.description', locale), noindex = false, nofollow = false, + image, scale = true, } = Astro.props; @@ -55,7 +61,10 @@ const abs = (p: string) => new URL(normalize(p), site).href; const absAsset = (p: string) => new URL(p, site).href; const canonical = abs(path); -const ogImage = absAsset(`/og/og-${locale}.png`); +// The generic card is 1200×630; a custom cover has whatever size it has, so +// the width/height hints only go out with the generic one. +const ogImage = image || absAsset(`/og/og-${locale}.png`); +const ogImageGeneric = !image; const alternates = LOCALES.map((l) => ({ lang: l, href: abs(switchLocalePath(path, l)) })); const xDefault = abs(switchLocalePath(path, 'en')); const ogLocale = locale === 'es' ? 'es_ES' : 'en_US'; @@ -120,8 +129,8 @@ const csp = [ - - + {ogImageGeneric && } + {ogImageGeneric && } diff --git a/src/layouts/NanPage.astro b/src/layouts/NanPage.astro index b419fd9..e86de4d 100644 --- a/src/layouts/NanPage.astro +++ b/src/layouts/NanPage.astro @@ -14,9 +14,10 @@ interface Props { description?: string; noindex?: boolean; nofollow?: boolean; + image?: string; scale?: boolean; } -const { title, description, noindex, nofollow, scale } = Astro.props; +const { title, description, noindex, nofollow, image, scale } = Astro.props; --- diff --git a/src/lib/agenda.ts b/src/lib/agenda.ts index 7b5e340..64e9b70 100644 --- a/src/lib/agenda.ts +++ b/src/lib/agenda.ts @@ -27,6 +27,10 @@ export type AgendaItem = { * tienen página propia y siguen siendo texto. */ href?: string; + /** Hora de inicio `HH:MM` (Europe/Madrid). Solo las entradas que vienen de la API la tienen. */ + time?: string; + /** Enlace "añadir a Google Calendar" de esta entrada (W-10). */ + calendar?: string; }; export const loc = (v: Localized, lang: Locale): string => (typeof v === 'string' ? v : v[lang]); diff --git a/src/lib/apiResponse.ts b/src/lib/apiResponse.ts new file mode 100644 index 0000000..cc41dde --- /dev/null +++ b/src/lib/apiResponse.ts @@ -0,0 +1,7 @@ +/** Respuesta JSON sin caché, la forma que devuelven los endpoints `/api/*` de la web. */ +export function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }, + }); +} diff --git a/src/lib/events.ts b/src/lib/events.ts index 5cd5f9f..fa96b99 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -1,8 +1,10 @@ import { env } from 'cloudflare:workers'; import { getLocale, withLang } from './i18n'; -// Paths admin nunca deben atravesar el proxy público (SPEC §8.1): ni -// `admin/reload` (global) ni `{slug}/admin/*` (por evento). +// Paths admin (`admin/*` global y `{slug}/admin/*` por evento) solo +// atraviesan el proxy con cookie de sesión (SPEC v3 §8): la autorización +// (staff o no) la decide el backend. Sin cookie no hay nada que autorizar y +// el proxy responde 404 sin tocar el backend, como antes de la v3. const ADMIN_SEGMENT = 'admin'; export function isAdminPath(path: string): boolean { @@ -26,6 +28,18 @@ export function isAdminPath(path: string): boolean { */ const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/; +/** + * Parte una ruta relativa al backend en segmentos y devuelve `null` si está + * vacía o algún segmento no es llano (ver SAFE_SEGMENT). Lo usan el proxy + * y `adminFetch`. + */ +export function safeSegments(path: string): string[] | null { + const segments = (path ?? '').split('/').filter((s) => s !== ''); + if (segments.length === 0) return null; + if (!segments.every((s) => SAFE_SEGMENT.test(s) && s !== '.' && s !== '..')) return null; + return segments; +} + /** * Construye la URL destino en el backend conservando query string. * @@ -52,12 +66,10 @@ const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/; * codificaciones de `..`. */ export function backendURL(path: string, search: string): string | null { - const base = env.CLOUD_API_URL.replace(/\/$/, ''); - const prefix = `${base}/api/events/`; + const prefix = `${apiBase()}/api/events/`; - const segments = (path ?? '').split('/').filter((s) => s !== ''); - if (segments.length === 0) return null; - if (!segments.every((s) => SAFE_SEGMENT.test(s) && s !== '.' && s !== '..')) return null; + const segments = safeSegments(path); + if (!segments) return null; let target: URL; let expected: URL; @@ -78,12 +90,17 @@ export function backendURL(path: string, search: string): string | null { } // Cabeceras a reenviar al backend: preserva cookie/sesión NaN; nunca reenvía la -// admin key (§9.2). Fija Origin para la política CORS del backend (§13). +// admin key (§9.2): esa vía es solo directa contra cloud-api. Fija Origin para +// la política CORS del backend (§13). Siempre JSON salvo el CSV que sube el +// panel de admin (`participants/import`): un fetch con cuerpo de texto y sin +// content-type explícito llega como text/plain, y eso no debe cambiar lo que +// recibe el backend. export function forwardHeaders(request: Request): Headers { const out = new Headers(); const cookie = request.headers.get('cookie'); if (cookie) out.set('cookie', cookie); - out.set('content-type', 'application/json'); + const contentType = request.headers.get('content-type') ?? ''; + out.set('content-type', /^text\/csv\b/i.test(contentType) ? contentType : 'application/json'); out.set('origin', 'https://nan.builders'); // Backend de eventos lee CF-Connecting-IP; otros endpoints X-Forwarded-For. // Reenviamos ambos para que el rate-limit por IP funcione en cualquier ruta. @@ -96,7 +113,7 @@ export function forwardHeaders(request: Request): Headers { // que consume el SSR; el backend es la fuente de verdad. export type EventFormat = 'solo' | 'team'; export type EventPhase = - | 'draft' | 'registration' | 'building_pending' | 'building' + | 'draft' | 'published' | 'registration' | 'building_pending' | 'building' | 'submission' | 'voting' | 'closed_pending' | 'closed'; export type FieldMode = 'required' | 'optional' | 'hidden'; /** registration.discord_user (SPEC §3.1): `none` = no se pide. */ @@ -110,6 +127,8 @@ export interface EventDates { voting_open?: string | null; voting_close?: string | null; demo_day?: string | null; + /** Fin del demo day (W-10); sin él, el calendario asume dos horas. */ + demo_day_end?: string | null; } export interface SubmissionFields { description: FieldMode; @@ -132,6 +151,11 @@ export interface EventInfo { description: string; rules: string; prize: string; + /** Dónde se celebra (texto libre) y enlace de acceso; opcionales (SPEC v3 §3.1, B-25). */ + location: string; + url: string; + /** Cover image (http/https, optional; B-27): shown on the public page and used as og:image. */ + image_url: string; format: EventFormat; status: string; dates: EventDates; @@ -139,15 +163,25 @@ export interface EventInfo { capacity: number; reserve_capacity: number; discord_user: DiscordMode; - specialties: string[]; - levels: string[]; + /** + * Listas que el backend serializa como `null` cuando el slice va nil + * (event.json escrito a mano, o un evento anterior al saneo de + * `applyCreateDefaults`). El `| null` no es defensivo: es lo que llega por + * el cable, y tiparlo así hace que `astro check` marque en rojo cualquier + * lectura sin `?? []`, incluidas las que pasan por una desestructuración + * y que ningún barrido de texto puede ver. + */ + specialties: string[] | null; + levels: string[] | null; }; /** Solo presente en formato `team` (SPEC §6.1). */ team?: { size: number; min_size: number; max_teams: number }; submission: { + /** Struct por valor en el backend: siempre llega como objeto, nunca null. */ fields: SubmissionFields; - checks: string[]; - prize_requires: string[]; + /** Ver el comentario de `registration.specialties`. */ + checks: string[] | null; + prize_requires: string[] | null; gallery_visibility: string; }; /** Sin los interruptores de admin (open, leaderboard_public): lo que manda es `windows`. */ @@ -171,7 +205,15 @@ export interface Team { name?: string; members?: Participant[]; } -export interface Check { pass: boolean; checked_at?: string | null; http_status?: number; host?: string } +/** Un check de la entrega (`checks[name]`). `forced`/`reason` solo los pone el admin (§6.4). */ +export interface Check { + pass: boolean; + checked_at?: string | null; + http_status?: number; + host?: string; + forced?: boolean; + reason?: string; +} export interface Submission { id: string; title: string; @@ -199,6 +241,11 @@ export interface LeaderboardRow { total: number; not_prize_eligible: boolean; } +/** Respuesta del ranking (público y admin): filas y si ya es público. */ +export interface LeaderboardView { + rows: LeaderboardRow[]; + public: boolean; +} export interface MeData { participant?: Participant | null; team?: Team | null; @@ -214,14 +261,15 @@ export async function jsonData(res: Response): Promise { catch { return null; } } -// Cabeceras para las llamadas SSR al backend (mismo Origin que el proxy). -function ssrHeaders(cookie?: string): HeadersInit { +/** Cabeceras para las llamadas SSR al backend (mismo Origin que el proxy). */ +export function ssrHeaders(cookie?: string): Record { const h: Record = { origin: 'https://nan.builders' }; if (cookie) h.cookie = cookie; return h; } -function apiBase(): string { +/** Base del backend sin barra final, para las llamadas SSR. */ +export function apiBase(): string { return env.CLOUD_API_URL.replace(/\/$/, ''); } @@ -280,6 +328,16 @@ export async function fetchMe(slug: string, cookie: string): Promise<{ me: MeDat } } +/** + * Sesión del visitante en la landing de un evento: `me` si hay cookie y el + * backend la acepta; `sessionOk` es false sin cookie o con cookie caducada. + */ +export async function eventSession(request: Request, slug: string): Promise<{ me: MeData | null; sessionOk: boolean }> { + if (!hasSessionCookie(request)) return { me: null, sessionOk: false }; + const { me, unauthorized } = await fetchMe(slug, request.headers.get('cookie') ?? ''); + return { me, sessionOk: !unauthorized }; +} + /** Recurso público de un evento (`submissions`, `leaderboard`). */ export async function fetchPublic(slug: string, resource: string): Promise { if (!SAFE_SEGMENT.test(slug) || !SAFE_SEGMENT.test(resource)) return null; @@ -292,19 +350,39 @@ export async function fetchPublic(slug: string, resource: string): Promise { + const eq = c.indexOf('='); + return (eq === -1 ? c : c.slice(0, eq)).trim() === SESSION_COOKIE; + }); +} + export function hasSessionCookie(request: Request): boolean { - return (request.headers.get('cookie') ?? '').includes('nan_session'); + return cookieHeaderHasSession(request.headers.get('cookie') ?? ''); } -/** Fecha ISO → texto corto en el idioma del visitante (UTC, como el backend). */ -export function fmtDate(iso?: string | null, locale = 'en', withTime = false): string { +/** Fecha ISO formateada en UTC (como el backend); `''` si no hay fecha o no es válida. */ +export function fmtUTC(iso: string | null | undefined, opts: { locale?: string; withTime?: boolean; withYear?: boolean } = {}): string { if (!iso) return ''; const d = new Date(iso); if (Number.isNaN(d.getTime())) return ''; - const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', timeZone: 'UTC' }; - if (withTime) { opts.hour = '2-digit'; opts.minute = '2-digit'; } - return d.toLocaleString(locale === 'es' ? 'es-ES' : 'en-GB', opts) + (withTime ? ' UTC' : ''); + const o: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', timeZone: 'UTC' }; + if (opts.withYear) o.year = 'numeric'; + if (opts.withTime) { o.hour = '2-digit'; o.minute = '2-digit'; } + return d.toLocaleString(opts.locale === 'es' ? 'es-ES' : 'en-GB', o) + (opts.withTime ? ' UTC' : ''); +} + +/** Fecha ISO → texto corto en el idioma del visitante. */ +export function fmtDate(iso?: string | null, locale = 'en', withTime = false): string { + return fmtUTC(iso, { locale, withTime }); } /** Rango "1 sept – 3 sept"; si falta un extremo, muestra el que haya. */ diff --git a/src/lib/eventsAdmin.ts b/src/lib/eventsAdmin.ts new file mode 100644 index 0000000..01c5f9f --- /dev/null +++ b/src/lib/eventsAdmin.ts @@ -0,0 +1,280 @@ +import { apiBase, cookieHeaderHasSession, fmtUTC, safeSegments, ssrHeaders, type Windows } from './events'; +import type { FormOutcome } from './eventsAdminForms'; + +/** + * Panel de administración de eventos (SPEC v3 §8): guardia SSR de staff y + * cliente de la API de administración del servidor de eventos. + * + * El panel vive en `/events/admin/*`, solo en español y sin variante `/es/`. + * La autorización real la hace el backend con la cookie (§2.3); esta guardia + * existe para que un visitante sin sesión de staff vea un 404 en vez del + * esqueleto de una pantalla que después falla en cada llamada. + */ + +export const ADMIN_BASE = '/events/admin'; + +/** Pantallas del panel por evento, en el orden de la navegación (§8). */ +export const ADMIN_SCREENS = [ + { key: 'evento', path: '', label: 'Evento' }, + { key: 'participantes', path: '/participantes', label: 'Participantes' }, + { key: 'equipos', path: '/equipos', label: 'Equipos' }, + { key: 'entregas', path: '/entregas', label: 'Entregas' }, + { key: 'votos', path: '/votos', label: 'Votos' }, + { key: 'auditoria', path: '/auditoria', label: 'Auditoría' }, +] as const; +export type AdminScreen = (typeof ADMIN_SCREENS)[number]['key']; + +/** URL de una pantalla del panel: `adminHref()` es la lista, `adminHref(slug, 'equipos')` el tablero. */ +export function adminHref(slug?: string, screen: AdminScreen = 'evento'): string { + if (!slug) return ADMIN_BASE; + const s = ADMIN_SCREENS.find((x) => x.key === screen) ?? ADMIN_SCREENS[0]; + return `${ADMIN_BASE}/${slug}${s.path}`; +} + +/** Lo que el panel necesita saber de la sesión (subconjunto de GET /api/auth/me). */ +export interface StaffSession { + email: string; + userUUID: string; +} + +/** + * Sesión de staff a partir de la cookie de la petición. `null` si no hay + * cookie, la sesión no vale, la cuenta no es staff o la plataforma no + * responde: en todos los casos el panel no existe para ese visitante. + */ +export async function fetchStaffSession(cookie: string): Promise { + if (!cookieHeaderHasSession(cookie)) return null; + try { + const res = await fetch(`${apiBase()}/api/auth/me`, { headers: ssrHeaders(cookie) }); + if (!res.ok) return null; + const me = (await res.json()) as { role?: string; email?: string; userUUID?: string } | null; + if (me?.role !== 'staff') return null; + return { email: me.email ?? '', userUUID: me.userUUID ?? '' }; + } catch { + return null; + } +} + +/** Resultado de `resolveAdminRoute`: sesión de staff o Response 404 a devolver. */ +export type AdminRoute = + | { staff: StaffSession; cookie: string; notFound: null } + | { staff: null; cookie: string; notFound: Response }; + +/** + * Guardia de las rutas `/events/admin/*`. Se llama desde el FICHERO DE RUTA + * (el envoltorio), nunca desde el cuerpo `_x.astro`: `Astro.rewrite` solo + * surte efecto en páginas, endpoints y middleware. El envoltorio devuelve + * `notFound` si toca y pasa `staff` y `cookie` al cuerpo como props. + * + * 404 y no 403 a propósito (SPEC v3 §8): el panel no se anuncia a quien no + * es staff, igual que los eventos en `draft` no existen de cara al exterior. + */ +export async function resolveAdminRoute( + astro: { request: Request; rewrite: (to: string) => Promise }, +): Promise { + const cookie = astro.request.headers.get('cookie') ?? ''; + const staff = await fetchStaffSession(cookie); + if (staff) return { staff, cookie, notFound: null }; + return { staff: null, cookie, notFound: await astro.rewrite('/404') }; +} + +/** Lo que devuelve `GET /{slug}/admin` (API.md §6.1); `event` es el `event.json` íntegro. */ +export interface AdminEventView { + event: { + slug: string; + kind: string; + name: string; + description: string; + rules: string; + prize: string; + format: string; + status: string; + /** Solo en `cancelled`: estado al que se puede volver (SPEC v3 §4.2). */ + previous_status?: string; + archived_at: string | null; + modules: { registration: boolean; teams: boolean; submissions: boolean; voting: boolean }; + automation: { date_transitions: boolean }; + dates: Record; + /** Las cuatro listas pueden llegar como `null` (mismo contrato que `EventInfo` en `events.ts`). */ + registration: { capacity: number; reserve_capacity: number; discord_user: string; specialties: string[] | null; levels: string[] | null }; + team?: { size: number; min_size: number; max_teams: number } | null; + submission: { fields: Record; checks: string[] | null; prize_requires: string[] | null; gallery_visibility: string }; + voting: { enabled: boolean; open: boolean; leaderboard_public: boolean; vote_weight: number; auto_max: number }; + created_at: string; + updated_at: string; + [k: string]: unknown; + }; + phase: string; + windows: Windows; + counts: { registered: number; reserve: number; withdrawn: number; teams: number; submissions: number; votes: number }; + warnings: string[]; +} + +/** Props que reciben los seis cuerpos del panel de un evento (`admin/[slug]/_*.astro`). */ +export interface AdminScreenProps { + staff: StaffSession; + cookie: string; + view: AdminEventView; + outcome: FormOutcome; +} + +/** Tipo de dueño de una entrega o un voto, para mostrarlo en el panel. */ +export const ownerKind = (t: string) => (t === 'team' ? 'equipo' : 'participante'); + +/** Fila de `GET /admin/events` (API.md §5). */ +export interface AdminEventSummary { + slug: string; + kind: string; + name: string; + format: string; + status: string; + phase: string; + archived_at: string | null; + dates: Record; + counts: AdminEventView['counts']; + warnings: string[]; + updated_at: string; +} + +/** Resultado de `resolveAdminEventRoute`: staff + ficha del evento, o Response 404. */ +export type AdminEventRoute = + | { staff: StaffSession; cookie: string; slug: string; view: AdminEventView; notFound: null } + | { staff: StaffSession | null; cookie: string; slug: string; view: null; notFound: Response }; + +/** + * Guardia de las rutas `/events/admin/{slug}/*`: además del staff, carga la + * ficha del evento (`GET /{slug}/admin`, que ve `draft` y archivados) y + * responde 404 si no existe. Igual que `resolveAdminRoute`, solo desde el + * fichero de ruta. + */ +export async function resolveAdminEventRoute( + astro: { request: Request; rewrite: (to: string) => Promise; params: Record }, +): Promise { + const slug = astro.params.slug ?? ''; + const route = await resolveAdminRoute(astro); + if (route.notFound) return { ...route, slug, view: null }; + const res = await adminFetch(route.cookie, `${slug}/admin`); + if (!res.ok || !res.data?.event) { + return { staff: route.staff, cookie: route.cookie, slug, view: null, notFound: await astro.rewrite('/404') }; + } + return { staff: route.staff, cookie: route.cookie, slug: res.data.event.slug, view: res.data, notFound: null }; +} + +/** + * Recarga la ficha después de un POST que escribe y pinta el resultado en la + * misma respuesta, sin redirigir (la importación CSV): `resolveAdminEventRoute` + * la cargó antes de la escritura, así que sus contadores irían atrasados. Si + * la recarga falla se queda la anterior: un contador viejo es mejor que un 500. + */ +export async function reloadAdminEventView(cookie: string, slug: string, previous: AdminEventView): Promise { + const res = await adminFetch(cookie, `${slug}/admin`); + return res.ok && res.data?.event ? res.data : previous; +} + +/** Envelope de la API de eventos (SPEC v3 §6), tal cual lo devuelve el backend. */ +export interface ApiResult { + ok: boolean; + status: number; + data: T | null; + error?: string; + message?: string; + warnings: string[]; + dryRun: boolean; + /** Campos que fallan en un `validation_failed`. */ + fields: string[]; + detail?: string; +} + +/** + * Llamada SSR a la API de administración con la cookie del staff. `path` es + * relativo a `/api/events/` (`admin/events`, `${slug}/admin/participants`) y + * cada segmento tiene que ser llano, como en el proxy. Nunca lanza: un fallo + * de red es `{ ok: false, status: 0, error: 'server_error' }`. + */ +export async function adminFetch( + cookie: string, + path: string, + init?: { method?: string; body?: unknown; search?: string }, +): Promise> { + const fail = (status: number, error: string, message?: string): ApiResult => + ({ ok: false, status, data: null, error, message, warnings: [], dryRun: false, fields: [] }); + + const segments = safeSegments(path); + if (!segments) return fail(404, 'not_found'); + const url = `${apiBase()}/api/events/${segments.join('/')}${init?.search ?? ''}`; + const req: RequestInit & { headers: Record } = { method: init?.method ?? 'GET', headers: ssrHeaders(cookie) }; + if (init?.body !== undefined) { + req.body = typeof init.body === 'string' ? init.body : JSON.stringify(init.body); + req.headers['content-type'] = typeof init.body === 'string' ? 'text/csv' : 'application/json'; + } + + let res: Response; + try { + res = await fetch(url, req); + } catch { + return fail(0, 'server_error'); + } + let body: Record | null = null; + try { body = (await res.json()) as Record; } catch { body = null; } + if (!body || typeof body !== 'object') { + return fail(res.status, res.ok ? 'invalid_response' : 'server_error'); + } + // `invalid_url` señala un solo campo en `data.field` (SPEC v3 §6.4 paso 6); + // el resto de errores mandan la lista en `data.fields`. Aquí se unifican para + // que el aviso pueda decir cuál es la URL que no vale. + const errData = (body.data ?? {}) as { fields?: unknown; field?: unknown; detail?: unknown }; + const errFields = Array.isArray(errData.fields) + ? (errData.fields as string[]) + : typeof errData.field === 'string' && errData.field !== '' + ? [errData.field] + : []; + const ok = body.ok === true; + return { + ok, + status: res.status, + data: ok ? ((body.data as T) ?? null) : null, + error: ok ? undefined : String(body.error ?? 'server_error'), + message: typeof body.message === 'string' ? body.message : undefined, + warnings: Array.isArray(body.warnings) ? (body.warnings as string[]) : [], + dryRun: body.dry_run === true, + fields: ok ? [] : errFields, + detail: !ok && typeof errData.detail === 'string' ? errData.detail : undefined, + }; +} + +/** Fecha del panel → "12 feb 2027, 18:00 UTC": siempre en español y con año. */ +export function fmtAdminDate(iso?: string | null, withTime = true): string { + return fmtUTC(iso, { locale: 'es', withTime, withYear: true }); +} + +/** Etiquetas en español de los estados del evento (SPEC v3 §4). */ +export const STATUS_LABELS: Record = { + draft: 'Borrador', + published: 'Publicado', + registration: 'Inscripción', + building: 'Construcción', + submission: 'Entrega', + voting: 'Votación', + closed: 'Cerrado', + cancelled: 'Cancelado', +}; + +/** + * Etiquetas de la fase efectiva (estado + fechas, SPEC v3 §5). Solo dicen + * "abierta" las fases que garantizan la ventana: `registration` y `voting` + * no la garantizan (la inscripción depende además de sus fechas y la + * votación del interruptor de la votación), y decirlo ahí contradecía a la + * lista de ventanas de al lado, que ponía "Inscripción: cerrada". + */ +export const PHASE_LABELS: Record = { + draft: 'borrador', + published: 'publicado, sin inscripción', + registration: 'inscripción', + building_pending: 'construcción, entregas aún cerradas', + building: 'construcción, entregas abiertas', + submission: 'entregas congeladas', + voting: 'votación', + closed_pending: 'votación vencida, pendiente de cerrar', + closed: 'cerrado', + cancelled: 'cancelado', +}; diff --git a/src/lib/eventsAdminAudit.ts b/src/lib/eventsAdminAudit.ts new file mode 100644 index 0000000..5d26fb7 --- /dev/null +++ b/src/lib/eventsAdminAudit.ts @@ -0,0 +1,151 @@ +import { adminFetch } from './eventsAdmin'; +import { badForm, beginForm, str, type FormOutcome } from './eventsAdminForms'; + +/** + * Pantalla de auditoría y backups del panel (SPEC v3 §6.6, §6.7 y §8, W-09). + * La auditoría es solo lectura (`GET …/admin/audit` con filtros); lo único + * que se escribe es restaurar un backup (`POST …/admin/backups/restore`), + * con previsualización `dry_run` y confirmación. + */ + +/** Quién hizo la acción: staff con sesión o clave de admin (scripts, curl). */ +export interface AuditActor { + kind: string; + user_uuid?: string; + email?: string; + label?: string; +} + +/** Línea de `audit.jsonl` tal como la devuelve `GET …/admin/audit` (B-03). */ +export interface AuditEntry { + ts: string; + actor: AuditActor | null; + action: string; + target?: string; + before: unknown; + after: unknown; + warnings: string[]; +} + +/** Fila de `GET …/admin/backups`: `timestamp` es el sufijo del fichero (`20260905T100000Z[-n]`). */ +export interface BackupInfo { + file: string; + timestamp: string; + size: number; +} + +/** Respuesta de restaurar: `previous_backup` solo en la restauración real. */ +export interface RestoreResult { + file: string; + timestamp: string; + previous_backup?: string; +} + +/** Filtros de la lista de auditoría, tal como los acepta el backend. */ +export interface AuditFilters { + since: string; + action: string; + limit: number; +} + +/** Límite por defecto del backend (DefaultAuditLimit); el máximo es 1000. */ +export const AUDIT_DEFAULT_LIMIT = 100; +export const AUDIT_MAX_LIMIT = 1000; + +/** Grupos de acciones para el desplegable de filtro (prefijo con punto final = "todas las de ese grupo"). */ +export const AUDIT_ACTION_GROUPS: { key: string; label: string }[] = [ + { key: '', label: 'Todas' }, + { key: 'event.', label: 'Evento (crear, editar, clonar, archivar)' }, + { key: 'state.', label: 'Cambios de estado' }, + { key: 'modules.', label: 'Módulos' }, + { key: 'participant.', label: 'Participantes' }, + { key: 'participants.', label: 'Importaciones' }, + { key: 'team.', label: 'Equipos' }, + { key: 'teams.', label: 'Generación de equipos' }, + { key: 'submission.', label: 'Entregas' }, + { key: 'voting.', label: 'Votación' }, + { key: 'backup.', label: 'Backups' }, +]; + +/** + * Lee `?desde=`, `?accion=` y `?limite=` de la URL de la pantalla. Lo que no + * vale se ignora (sin filtro) para no provocar un 400 del backend; el límite + * se acota a [1, 1000]. + */ +export function readAuditFilters(url: URL): AuditFilters { + const rawSince = url.searchParams.get('desde') ?? ''; + const since = isoDate(rawSince); + const action = (url.searchParams.get('accion') ?? '').trim(); + const rawLimit = Number.parseInt(url.searchParams.get('limite') ?? '', 10); + const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, AUDIT_MAX_LIMIT) : AUDIT_DEFAULT_LIMIT; + return { since, action: /^[a-z_.]*$/.test(action) ? action : '', limit }; +} + +/** Convierte un `datetime-local` o una fecha `AAAA-MM-DD` (UTC) en RFC 3339; vacío si no es una fecha. */ +function isoDate(v: string): string { + const s = v.trim(); + if (!s) return ''; + // Un datetime-local o una fecha suelta se interpretan en UTC, como el resto de fechas del panel. + const withZone = /[zZ]$|[+-]\d\d:\d\d$/.test(s) ? s : `${s}${/T/.test(s) ? '' : 'T00:00'}Z`; + const d = new Date(withZone); + return Number.isNaN(d.getTime()) ? '' : d.toISOString(); +} + +/** Query string para `GET …/admin/audit` a partir de los filtros. */ +export function auditSearch(f: AuditFilters): string { + const q = new URLSearchParams(); + if (f.since) q.set('since', f.since); + if (f.action) q.set('action', f.action); + q.set('limit', String(f.limit)); + return `?${q.toString()}`; +} + +/** Texto corto del actor: email del staff, etiqueta del script o "clave de admin". */ +export function actorLabel(a: AuditActor | null | undefined): string { + if (!a) return 'sistema'; + if (a.kind === 'staff') return a.email || a.user_uuid || 'staff'; + return a.label ? `${a.label} (clave de admin)` : 'clave de admin'; +} + +/** Bytes en formato legible (KB con un decimal a partir de 1024). */ +export function fmtBytes(n: number): string { + if (!Number.isFinite(n) || n < 0) return ''; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +/** Fecha ISO a partir del sufijo de backup `20260905T100000Z[-n]`; vacío si no encaja. */ +export function backupDate(timestamp: string): string { + const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/.exec(timestamp); + if (!m) return ''; + return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`; +} + +/** + * `/events/admin/{slug}/auditoria`: procesa el POST según `action` + * (`restore_preview`, `restore`) con `file` y `timestamp` del backup. La + * previsualización (`dry_run`) solo comprueba que el backup validaría y se + * queda en la página; la restauración real redirige con el flash. + */ +export async function handleAuditForm(request: Request, cookie: string, slug: string): Promise { + const f = await beginForm(request, { slug, screen: 'auditoria' }); + if (f.done) return f.done; + const { values, action, back } = f; + const m = /^restore(_preview)?$/.exec(action); + if (!m) { + return badForm(action, values, ['action'], 'Acción desconocida.'); + } + const preview = Boolean(m[1]); + const file = str(values, 'file'); + const timestamp = str(values, 'timestamp'); + if (!file || !timestamp) { + return badForm(action, values, [!file ? 'file' : 'timestamp'], 'Falta el backup a restaurar.'); + } + const result = await adminFetch(cookie, `${slug}/admin/backups/restore`, { + method: 'POST', + body: { file, timestamp, dry_run: preview }, + }); + if (result.ok && !preview) return { redirect: back('restaurado', result.warnings) }; + return { action, result, values }; +} diff --git a/src/lib/eventsAdminForms.ts b/src/lib/eventsAdminForms.ts new file mode 100644 index 0000000..b2ef157 --- /dev/null +++ b/src/lib/eventsAdminForms.ts @@ -0,0 +1,537 @@ +import { adminFetch, adminHref, type AdminScreen, type ApiResult } from './eventsAdmin'; + +/** + * Formularios del panel de eventos (SPEC v3 §8, W-03): lista, alta, edición, + * clonado y archivado. Sin JavaScript en el cliente: cada pantalla es un + * `
` que el fichero de ruta procesa en SSR y que acaba + * en una redirección (patrón POST → redirect → GET) o en el mismo + * formulario con el error del backend. + * + * El cuerpo que se manda al backend es el `event.json` de §5.1: aquí solo + * se traduce el formulario a ese JSON (y a la inversa para rellenarlo). + * La validación real la hace el servidor; el panel solo enseña su respuesta. + */ + +export const EVENT_KINDS = [ + { value: 'hackathon', label: 'Hackatón' }, + { value: 'challenge', label: 'Reto' }, + { value: 'workshop', label: 'Taller' }, + { value: 'other', label: 'Otro' }, +] as const; + +export const FIELD_MODES = [ + { value: 'required', label: 'Obligatorio' }, + { value: 'optional', label: 'Opcional' }, + { value: 'hidden', label: 'Oculto' }, +] as const; + +export const SUBMISSION_FIELDS = [ + { key: 'description', label: 'Descripción' }, + { key: 'repo_url', label: 'URL del repositorio' }, + { key: 'space_url', label: 'URL del space' }, + { key: 'image_url', label: 'URL de imagen' }, + { key: 'video_url', label: 'URL de vídeo' }, +] as const; + +/** Los checks que conoce el panel: clave, etiqueta y si suman puntos automáticos. */ +export const CHECK_DEFS = [ + { value: 'url_live', label: 'URL viva', scores: true }, + { value: 'in_nan_space', label: 'Desplegado en un space de NaN', scores: true }, + { value: 'repo_public', label: 'Repositorio público', scores: false }, +] as const; + +/** Etiqueta corta por clave (la pantalla de entregas). */ +export const CHECK_LABELS: Record = Object.fromEntries(CHECK_DEFS.map((c) => [c.value, c.label])); + +/** Opciones del formulario de configuración, con la nota de si puntúan. */ +export const CHECKS = CHECK_DEFS.map((c) => ({ + value: c.value, + label: `${c.label} (${c.scores ? 'puntúa' : 'no puntúa; puede condicionar el premio'})`, +})); + +/** Checks que suman puntos automáticos: `voting.auto_max` se deriva de ellos (el backend lo exige). */ +const SCORING_CHECKS: string[] = CHECK_DEFS.filter((c) => c.scores).map((c) => c.value); + +/** Identificador que emite el backend (participante, equipo, entrega): va en la URL sin codificar. */ +export const SAFE_ID = /^[A-Za-z0-9_-]{1,64}$/; +/** Nombre de un check (`url_live`, `in_nan_space`, `repo_public`…). */ +export const SAFE_CHECK = /^[a-z_]{1,32}$/; + +export const DATE_FIELDS = [ + { key: 'registration_open', label: 'Apertura de inscripción' }, + { key: 'registration_close', label: 'Cierre de inscripción' }, + { key: 'submission_open', label: 'Apertura de entregas' }, + { key: 'submission_close', label: 'Cierre de entregas' }, + { key: 'voting_open', label: 'Apertura de votación' }, + { key: 'voting_close', label: 'Cierre de votación' }, + { key: 'demo_day', label: 'Demo day' }, +] as const; + +/** Etiquetas en español de los avisos del backend (API.md §8). */ +export const WARNING_LABELS: Record = { + no_change: 'La operación no cambia nada.', + dates_changed_registration: 'Cambia una fecha con la inscripción abierta.', + dates_changed_submission: 'Cambia una fecha con las entregas abiertas.', + dates_changed_voting: 'Cambia una fecha con la votación abierta.', + team_size_changed: 'Cambia el tamaño de equipo con equipos ya creados.', + modules_changed: 'Cambian los módulos con datos ya creados o fuera de borrador.', + format_changed: 'Cambia el formato con datos ya creados o fuera de borrador.', + capacity_exceeded: 'Hay más inscritos activos que aforo.', + automation_dates_missing: 'La automatización por fechas está activa pero faltan fechas.', + automation_off: 'La automatización por fechas está apagada.', + slug_changed: 'El evento se ha renombrado: cambian las URLs.', + votes_kept: 'Se conservan los votos.', + submissions_kept: 'Se conservan las entregas.', + leaderboard_hidden: 'El ranking deja de ser público.', + leaderboard_not_public: 'Se cierra sin publicar el ranking.', + teams_missing: 'Formato por equipos sin equipos creados.', + voting_not_open: 'Se entra en votación sin abrir la votación.', + voting_closed: 'La votación abierta se cierra con este cambio.', + archive_active: 'El evento no está cerrado ni cancelado: solo se archiva forzando.', + event_archived: 'El evento está archivado.', + seat_free: 'Se libera una plaza y nadie sube de reserva automáticamente.', + member_not_found: 'Ese email no tiene cuenta de NaN.', + invalid_email: 'Email mal formado.', + participant_exists: 'Esa cuenta ya está inscrita.', + team_under_min: 'Equipo por debajo del mínimo.', + team_over_size: 'Equipo por encima del tamaño.', + team_empty: 'Equipo vacío.', + participant_restored: 'El alta reincorporó a alguien que estaba de baja.', + teams_replaced: 'Se han sustituido los equipos existentes.', + checks_rerun: 'Se han vuelto a ejecutar los checks.', +}; + +export function warningLabel(code: string): string { + return WARNING_LABELS[code] ?? code; +} + +/** Mensajes de confirmación tras una redirección (`?ok=`); solo claves conocidas. */ +export const FLASH_LABELS: Record = { + creado: 'Evento creado en borrador.', + guardado: 'Cambios guardados.', + clonado: 'Evento clonado en borrador, sin participantes ni fechas.', + archivado: 'Evento archivado: queda en solo lectura.', + desarchivado: 'Evento desarchivado.', + estado: 'Estado cambiado.', + sweep: 'Sweep ejecutado: el estado ha avanzado por fecha.', + alta: 'Participante dado de alta.', + editado: 'Participante actualizado.', + baja: 'Participante dado de baja (se conserva el historial).', + reincorporado: 'Participante reincorporado.', + promovido: 'Participante promovido de reserva a inscrito.', + reserva: 'Participante pasado a reserva.', + importado: 'Importación aplicada.', + equipo_creado: 'Equipo creado.', + renombrado: 'Equipo renombrado.', + disuelto: 'Equipo disuelto: sus miembros siguen inscritos, sin equipo.', + movido: 'Participante movido de equipo.', + quitado: 'Participante sacado del equipo (sigue inscrito).', + generado: 'Equipos generados.', + entrega_editada: 'Entrega actualizada.', + entrega_retirada: 'Entrega retirada (se conserva; se puede restaurar).', + entrega_restaurada: 'Entrega restaurada.', + check_forzado: 'Check fijado a mano: verify no lo pisará.', + check_reiniciado: 'Check devuelto al resultado automático.', + premio: 'Elegibilidad de premio fijada a mano.', + premio_automatico: 'Elegibilidad de premio devuelta al cálculo automático.', + verificado: 'Checks verificados en todas las entregas activas.', + restaurado: 'Backup restaurado: el fichero anterior queda guardado como backup nuevo.', + votacion_abierta: 'Votación abierta.', + votacion_cerrada: 'Votación cerrada: el ranking queda congelado y publicado.', +}; + +/** + * Orden canónico de estados según los módulos (SPEC v3 §4.1; espejo de + * `EventModules.StatusSequence` del backend). `cancelled` no está en la + * secuencia. + */ +export function statusSequence(m: { registration: boolean; teams: boolean; submissions: boolean; voting: boolean }): string[] { + const seq = ['draft']; + if (m.registration && m.teams) seq.push('registration'); + if (m.submissions) { + seq.push('building', 'submission'); + if (m.voting) seq.push('voting'); + } else if (m.registration && !m.teams) { + seq.push('registration'); // workshop: solo inscripción + } else if (!m.registration) { + seq.push('published'); // informativo: se anuncia y se cierra (B-26) + } + seq.push('closed'); + return seq; +} + +export type StateMove = 'forward' | 'back' | 'cancel' | 'restore'; + +/** Un destino de transición permitido por §4.2 y cómo se llega a él. */ +export interface StateTarget { + status: string; + move: StateMove; +} + +/** + * Destinos a los que se puede pasar desde `status` (SPEC v3 §4.2): avanzar + * a cualquier estado posterior, retroceder solo uno, cancelar salvo desde + * `closed`, y desde `cancelled` solo volver a `previous_status`. Es el mismo + * cálculo que hace el backend; el panel lo usa para no ofrecer botones que + * fallarían con `invalid_transition`. + */ +export function stateTargets( + status: string, + modules: Parameters[0], + previousStatus?: string | null, +): StateTarget[] { + if (status === 'cancelled') { + return previousStatus ? [{ status: previousStatus, move: 'restore' }] : []; + } + const seq = statusSequence(modules); + const at = seq.indexOf(status); + const out: StateTarget[] = []; + if (at >= 0) { + if (at > 0) out.push({ status: seq[at - 1], move: 'back' }); + for (const s of seq.slice(at + 1)) out.push({ status: s, move: 'forward' }); + } + if (status !== 'closed') out.push({ status: 'cancelled', move: 'cancel' }); + return out; +} + +export const STATE_MOVE_LABELS: Record = { + forward: 'avanzar a', + back: 'retroceder a', + cancel: 'cancelar el evento', + restore: 'volver a', +}; + +/** Fecha RFC 3339 → valor de `` en UTC (`2027-01-10T00:00`). */ +export function isoToLocal(iso?: string | null): string { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return d.toISOString().slice(0, 16); +} + +/** Valor de `datetime-local` (interpretado en UTC) → RFC 3339, o `null` si está vacío. */ +export function localToIso(local?: string | null): string | null { + const v = (local ?? '').trim(); + if (!v) return null; + const d = new Date(/Z$|[+-]\d\d:\d\d$/.test(v) ? v : `${v}Z`); + if (Number.isNaN(d.getTime())) return null; + return d.toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +/** Lista separada por comas o saltos de línea, sin vacíos ni duplicados. */ +export function splitList(raw?: string | null): string[] { + const out: string[] = []; + for (const item of (raw ?? '').split(/[,\n]/)) { + const v = item.trim(); + if (v && !out.includes(v)) out.push(v); + } + return out; +} + +/** Valores del formulario, siempre como texto (lo que Astro recibe y lo que se pinta). */ +export type EventFormValues = Record; + +/** Lo que hace falta del `event.json` para rellenar el formulario. */ +export interface EventLike { + slug?: string; + kind?: string; + name?: string; + description?: string; + rules?: string; + prize?: string; + format?: string; + modules?: { registration?: boolean; teams?: boolean; submissions?: boolean; voting?: boolean }; + automation?: { date_transitions?: boolean }; + dates?: Record; + registration?: { capacity?: number; reserve_capacity?: number; discord_user?: string; specialties?: string[] | null; levels?: string[] | null }; + team?: { size?: number; min_size?: number; max_teams?: number } | null; + submission?: { fields?: Record; checks?: string[] | null; prize_requires?: string[] | null; gallery_visibility?: string }; + voting?: { enabled?: boolean; vote_weight?: number; auto_max?: number }; +} + +/** Valores por defecto de un evento nuevo (los del ejemplo de `docs/examples/evento-v3`). */ +export const NEW_EVENT_DEFAULTS: EventLike = { + kind: 'hackathon', + format: 'team', + modules: { registration: true, teams: true, submissions: true, voting: true }, + automation: { date_transitions: true }, + registration: { capacity: 40, reserve_capacity: 10, discord_user: 'required', specialties: ['backend', 'frontend', 'devops', 'data'], levels: ['junior', 'mid', 'senior'] }, + team: { size: 4, min_size: 3, max_teams: 10 }, + submission: { + fields: { description: 'optional', repo_url: 'required', space_url: 'optional', image_url: 'optional', video_url: 'optional' }, + checks: ['url_live', 'repo_public'], + prize_requires: ['repo_public'], + gallery_visibility: 'from_voting', + }, + voting: { enabled: true, vote_weight: 8 }, +}; + +/** `event.json` → valores del formulario. */ +export function eventToForm(ev: EventLike): EventFormValues { + const v: EventFormValues = { + slug: ev.slug ?? '', + kind: ev.kind ?? 'hackathon', + name: ev.name ?? '', + description: ev.description ?? '', + rules: ev.rules ?? '', + prize: ev.prize ?? '', + format: ev.format ?? 'team', + module_registration: ev.modules?.registration ? 'on' : '', + module_submissions: ev.modules?.submissions ? 'on' : '', + voting_enabled: (ev.voting?.enabled ?? ev.modules?.voting) ? 'on' : '', + date_transitions: ev.automation?.date_transitions ? 'on' : '', + capacity: String(ev.registration?.capacity ?? 0), + reserve_capacity: String(ev.registration?.reserve_capacity ?? 0), + discord_user: ev.registration?.discord_user ?? 'optional', + specialties: (ev.registration?.specialties ?? []).join(', '), + levels: (ev.registration?.levels ?? []).join(', '), + team_size: String(ev.team?.size ?? 4), + team_min_size: String(ev.team?.min_size ?? 3), + team_max_teams: String(ev.team?.max_teams ?? 10), + checks: ev.submission?.checks ?? [], + prize_requires: ev.submission?.prize_requires ?? [], + gallery_visibility: ev.submission?.gallery_visibility ?? 'from_voting', + vote_weight: String(ev.voting?.vote_weight ?? 0), + }; + for (const d of DATE_FIELDS) v[`date_${d.key}`] = isoToLocal(ev.dates?.[d.key]); + for (const f of SUBMISSION_FIELDS) v[`field_${f.key}`] = ev.submission?.fields?.[f.key] ?? 'optional'; + return v; +} + +/** FormData → valores del formulario (los checkboxes múltiples como lista). */ +export function formValues(fd: FormData): EventFormValues { + const v: EventFormValues = {}; + for (const [k, raw] of fd.entries()) { + if (typeof raw !== 'string') continue; + if (k === 'checks' || k === 'prize_requires') { + v[k] = [...((v[k] as string[] | undefined) ?? []), raw]; + } else { + v[k] = raw; + } + } + v.checks ??= []; + v.prize_requires ??= []; + return v; +} + +export const str = (v: EventFormValues, k: string) => (typeof v[k] === 'string' ? (v[k] as string).trim() : ''); +const list = (v: EventFormValues, k: string) => (Array.isArray(v[k]) ? (v[k] as string[]) : []); +const num = (v: EventFormValues, k: string) => { + const n = Number(str(v, k)); + return Number.isFinite(n) ? Math.trunc(n) : 0; +}; +export const on = (v: EventFormValues, k: string) => str(v, k) !== ''; + +/** + * Valores del formulario → cuerpo de `POST admin/events` / `PUT {slug}/admin` + * (§5.1). Se mandan siempre `modules`, `format` y `voting.enabled` + * coherentes entre sí y `voting.auto_max` derivado de los checks puntuables. + */ +export function formToEventBody(v: EventFormValues): Record { + const format = str(v, 'format') === 'solo' ? 'solo' : 'team'; + const checks = list(v, 'checks'); + const prizeRequires = list(v, 'prize_requires').filter((c) => checks.includes(c)); + const dates: Record = {}; + for (const d of DATE_FIELDS) dates[d.key] = localToIso(str(v, `date_${d.key}`)); + const fields: Record = {}; + for (const f of SUBMISSION_FIELDS) fields[f.key] = str(v, `field_${f.key}`) || 'optional'; + const votingEnabled = on(v, 'voting_enabled'); + + const body: Record = { + slug: str(v, 'slug'), + kind: str(v, 'kind') || 'hackathon', + name: str(v, 'name'), + description: str(v, 'description'), + rules: str(v, 'rules'), + prize: str(v, 'prize'), + format, + modules: { + registration: on(v, 'module_registration'), + teams: format === 'team', + submissions: on(v, 'module_submissions'), + voting: votingEnabled, + }, + automation: { date_transitions: on(v, 'date_transitions') }, + dates, + registration: { + capacity: num(v, 'capacity'), + reserve_capacity: num(v, 'reserve_capacity'), + discord_user: str(v, 'discord_user') || 'optional', + specialties: splitList(str(v, 'specialties')), + levels: splitList(str(v, 'levels')), + }, + submission: { + fields, + checks, + prize_requires: prizeRequires, + gallery_visibility: str(v, 'gallery_visibility') || 'from_voting', + }, + voting: { + enabled: votingEnabled, + vote_weight: num(v, 'vote_weight'), + auto_max: checks.filter((c) => SCORING_CHECKS.includes(c)).length, + }, + }; + if (format === 'team') { + body.team = { size: num(v, 'team_size'), min_size: num(v, 'team_min_size'), max_teams: num(v, 'team_max_teams') }; + } + return body; +} + +/** + * Un POST del panel tiene que venir de nan.builders: con `Origin` (o + * `Sec-Fetch-Site`) de otro sitio no se procesa. Es la defensa CSRF del + * panel, por si la cookie llegara a viajar en una navegación de terceros. + */ +export function sameOrigin(request: Request): boolean { + const site = request.headers.get('sec-fetch-site'); + if (site && site !== 'same-origin' && site !== 'none') return false; + const origin = request.headers.get('origin'); + if (!origin) return true; + try { + return new URL(origin).origin === new URL(request.url).origin; + } catch { + return false; + } +} + +/** Resultado de procesar un formulario: redirigir o volver a pintar con lo que dijo el backend. */ +export interface FormOutcome { + /** Adónde ir (303) tras un cambio real. */ + redirect?: string; + /** Qué botón se pulsó (`save`, `simulate`, `create`, `clone`, `archive`, `unarchive`, `state_preview`, `state`, `sweep`). */ + action?: string; + /** Respuesta del backend cuando no hay redirección (error o simulación). */ + result?: ApiResult; + /** Lo que el usuario había escrito, para no perderlo al re-pintar. */ + values?: EventFormValues; + /** El POST no venía de nan.builders. */ + forbidden?: boolean; +} + +export async function readForm(request: Request): Promise<{ fd: FormData | null; forbidden: boolean }> { + if (request.method !== 'POST') return { fd: null, forbidden: false }; + if (!sameOrigin(request)) return { fd: null, forbidden: true }; + try { + return { fd: await request.formData(), forbidden: false }; + } catch { + return { fd: null, forbidden: true }; + } +} + +/** + * Arranque común de los formularios del panel: lee el POST (o devuelve en + * `done` el resultado ya decidido si no es POST o viene de otro origen), + * saca los valores y la acción, y prepara la URL de vuelta de esa pantalla. + */ +export async function beginForm(request: Request, opts: { slug?: string; screen?: AdminScreen; defaultAction?: string } = {}): Promise< + | { done: FormOutcome } + | { done?: undefined; fd: FormData; values: EventFormValues; action: string; back: (ok: string, warnings?: string[]) => string } +> { + const { fd, forbidden } = await readForm(request); + if (forbidden) return { done: { forbidden: true } }; + if (!fd) return { done: {} }; + const values = formValues(fd); + return { + fd, + values, + action: str(values, 'action') || (opts.defaultAction ?? ''), + back: (ok, warnings = []) => doneHref(opts.slug ?? '', ok, warnings, opts.screen), + }; +} + +/** + * Resultado de un formulario rechazado antes de llamar al backend (falta un + * campo, acción desconocida…), con la misma forma que un 400 del backend + * para que la pantalla lo pinte igual. + */ +export function badForm(action: string, values: EventFormValues | undefined, fields: string[], message: string, error = 'validation_failed'): FormOutcome { + return { action, values, result: { ok: false, status: 400, data: null, error, message, warnings: [], dryRun: false, fields } }; +} + +/** URL de vuelta tras un cambio: `/events/admin/{slug}[/pantalla]?ok=…&warn=a,b`. */ +export function doneHref(slug: string, ok: string, warnings: string[] = [], screen: AdminScreen = 'evento'): string { + const q = new URLSearchParams({ ok }); + // Sin repetidos: al mover entre equipos el backend puede avisar lo mismo del origen y del destino. + const warn = [...new Set(warnings)].filter((w) => w !== 'no_change'); + if (warn.length) q.set('warn', warn.join(',')); + return `${adminHref(slug, screen)}?${q.toString()}`; +} + +/** + * Lee `?ok=` y `?warn=` de una URL y los convierte en textos; ignora lo + * desconocido. Si en este mismo render se ha atendido un POST (`outcome`), + * el flash se descarta: viene del cambio anterior (el formulario envía a la + * URL actual, `?ok=` incluido) y contarlo otra vez encima del resultado + * nuevo miente sobre lo que acaba de pasar. + */ +export function readFlash(url: URL, outcome?: FormOutcome): { ok: string | null; warnings: string[] } { + if (outcome && (outcome.action || outcome.forbidden)) return { ok: null, warnings: [] }; + const okKey = url.searchParams.get('ok') ?? ''; + const ok = FLASH_LABELS[okKey] ?? null; + const warnings = (url.searchParams.get('warn') ?? '') + .split(',') + .map((w) => w.trim()) + .filter((w) => /^[a-z_]+$/.test(w)); + return { ok, warnings }; +} + +/** `/events/admin/nuevo`: crear (o simular) un evento. */ +export async function handleNewEventForm(request: Request, cookie: string): Promise { + const f = await beginForm(request, { defaultAction: 'create' }); + if (f.done) return f.done; + const { values, action } = f; + const body = formToEventBody(values); + if (action === 'simulate') body.dry_run = true; + const result = await adminFetch<{ event?: { slug?: string } }>(cookie, 'admin/events', { method: 'POST', body }); + if (result.ok && action !== 'simulate') { + return { redirect: doneHref(result.data?.event?.slug || String(body.slug), 'creado', result.warnings) }; + } + return { action, result, values }; +} + +/** `/events/admin/{slug}`: guardar, simular, clonar, archivar, desarchivar, cambiar de estado (con previsualización) o forzar el sweep. */ +export async function handleEventForm(request: Request, cookie: string, slug: string): Promise { + const f = await beginForm(request, { defaultAction: 'save' }); + if (f.done) return f.done; + const { values, action } = f; + + if (action === 'clone') { + const to = str(values, 'clone_slug'); + const result = await adminFetch<{ event?: { slug?: string } }>(cookie, `admin/events/${slug}/clone`, { method: 'POST', body: { slug: to } }); + if (result.ok) return { redirect: doneHref(result.data?.event?.slug || to, 'clonado', result.warnings) }; + return { action, result, values }; + } + if (action === 'archive' || action === 'unarchive') { + const body = action === 'archive' ? { force: on(values, 'force') } : {}; + const result = await adminFetch<{ archived?: boolean }>(cookie, `${slug}/admin/${action}`, { method: 'POST', body }); + // El backend responde ok aunque no archive (aviso archive_active sin force): lo que manda es `archived`. + if (result.ok && result.data?.archived === (action === 'archive')) { + return { redirect: doneHref(slug, action === 'archive' ? 'archivado' : 'desarchivado', result.warnings) }; + } + return { action, result, values }; + } + if (action === 'state_preview' || action === 'state') { + // Control de estado (SPEC v3 §4.2): primero se previsualizan los avisos + // con dry_run y solo después se confirma la transición. + const body = { status: str(values, 'status'), dry_run: action === 'state_preview' }; + const result = await adminFetch<{ status?: string }>(cookie, `${slug}/admin/state`, { method: 'POST', body }); + if (result.ok && action === 'state') return { redirect: doneHref(slug, 'estado', result.warnings) }; + return { action, result, values }; + } + if (action === 'sweep') { + // Solo redirige si el sweep ha movido el estado; si no, se enseña el + // resultado (automation_off o nada que hacer) sin salir de la página. + const result = await adminFetch<{ transitions?: string[] }>(cookie, `${slug}/admin/sweep`, { method: 'POST', body: {} }); + if (result.ok && (result.data?.transitions?.length ?? 0) > 0) return { redirect: doneHref(slug, 'sweep', result.warnings) }; + return { action, result, values }; + } + + const body = formToEventBody(values); + if (action === 'simulate') body.dry_run = true; + const result = await adminFetch<{ event?: { slug?: string } }>(cookie, `${slug}/admin`, { method: 'PUT', body }); + if (result.ok && action !== 'simulate') { + return { redirect: doneHref(result.data?.event?.slug || slug, 'guardado', result.warnings) }; + } + return { action, result, values }; +} diff --git a/src/lib/eventsAdminParticipants.ts b/src/lib/eventsAdminParticipants.ts new file mode 100644 index 0000000..d887615 --- /dev/null +++ b/src/lib/eventsAdminParticipants.ts @@ -0,0 +1,195 @@ +import { adminFetch } from './eventsAdmin'; +import { badForm, beginForm, on, SAFE_ID, str, type FormOutcome } from './eventsAdminForms'; +import { optionLabel, tObj } from './i18n'; + +/** + * Pantalla de participantes del panel (SPEC v3 §6.2 y §8, W-05): tabla con + * filtros, alta manual, edición, baja, reincorporación, promoción, paso a + * reserva, importación CSV (con previsualización) y exportación. Igual que + * el resto del panel: formularios sin JavaScript que procesa el fichero de + * ruta y acaban en redirección o en la misma página con la respuesta. + */ + +/** Fila de `GET /{slug}/admin/participants` (participante completo más equipo y entrega). */ +export interface AdminParticipantRow { + id: string; + position: number; + member_uuid: string; + name: string; + email: string; + discord_user: string; + specialty: string | null; + level: string | null; + status: string; + is_reserve: boolean; + team_id: string | null; + team_name: string | null; + submission_id: string | null; + source: string; + added_by: { actor: string; email?: string } | null; + notes: string; + withdrawn_reason: string | null; + created_at: string; + updated_at: string; +} + +/** + * Etiquetas de especialidad y nivel dentro del panel. + * + * El vocabulario lo escribe quien organiza el evento (SPEC §3.2), así que + * `events.options` solo traduce el fijo de v2 y el resto sale con la inicial + * en mayúscula: sin esto, "devops" aparecía en minúscula al lado de + * "Frontend" en la misma lista. La parte pública ya lo hacía; el panel no, y + * el mismo dato se veía de dos formas según la pantalla. + * + * El panel es solo español (no tiene variante `/es/`), de ahí el locale fijo. + * El diccionario se resuelve una vez: es constante en todo el proceso. + */ +const ADMIN_OPTIONS = tObj>('events.options', 'es'); + +export function adminOptionLabel(value?: string | null): string { + return optionLabel(ADMIN_OPTIONS, value); +} + +/** + * Especialidad y nivel de un participante, ya etiquetados, para las fichas + * del panel. Devuelve cadena vacía si no tiene ninguno de los dos: quien + * llama decide el relleno ("—", el email…). + */ +export function participantProfile(m: { specialty?: string | null; level?: string | null }): string { + return [adminOptionLabel(m.specialty), adminOptionLabel(m.level)].filter(Boolean).join(' · '); +} + +export const PARTICIPANT_STATUS_LABELS: Record = { + registered: 'Inscrito', + reserve: 'Reserva', + promoted: 'Promovido', + withdrawn: 'Baja', +}; + +export const PARTICIPANT_SOURCE_LABELS: Record = { + register: 'inscripción', + submission: 'entrega', + import: 'importación', + admin: 'alta manual', +}; + +/** Informe de `POST …/participants/import` (SPEC v3 §6.2, B-12). */ +export interface ImportReport { + rows: { line: number; email: string; action: string; error?: string | null; participant_id?: string | null; warnings?: string[] }[]; + created: number; + updated: number; + restored: number; + unchanged: number; + rejected: number; +} + +export const IMPORT_ACTION_LABELS: Record = { + created: 'creada', + updated: 'actualizada', + restored: 'reincorporada', + unchanged: 'sin cambios', + rejected: 'rechazada', +}; + +/** Filtros de la tabla leídos de la URL (`?estado=&equipo=&reserva=&q=`). */ +export interface ParticipantFilters { + status: string; + team: string; + reserve: string; + q: string; +} + +export function readParticipantFilters(url: URL): ParticipantFilters { + const p = url.searchParams; + const status = p.get('estado') ?? ''; + const reserve = p.get('reserva') ?? ''; + return { + status: status in PARTICIPANT_STATUS_LABELS ? status : '', + team: (p.get('equipo') ?? '').trim().slice(0, 64), + reserve: reserve === 'si' ? 'si' : reserve === 'no' ? 'no' : '', + q: (p.get('q') ?? '').trim().slice(0, 100), + }; +} + +/** Filtros → query string de `GET …/admin/participants` (`?status=&team=&reserve=&q=`). */ +export function participantsSearch(f: ParticipantFilters): string { + const q = new URLSearchParams(); + if (f.status) q.set('status', f.status); + if (f.team) q.set('team', f.team); + if (f.reserve) q.set('reserve', f.reserve === 'si' ? 'true' : 'false'); + if (f.q) q.set('q', f.q); + const s = q.toString(); + return s ? `?${s}` : ''; +} + +/** + * `/events/admin/{slug}/participantes`: procesa el POST según `action` + * (`add`, `update`, `withdraw`, `reinstate`, `promote`, `demote`, + * `import_preview`, `import`). Las acciones que escriben redirigen a la + * pantalla con `?ok=`; la previsualización de la importación y los errores + * vuelven a pintar la página con la respuesta. + */ +export async function handleParticipantsForm(request: Request, cookie: string, slug: string): Promise { + const f = await beginForm(request, { slug, screen: 'participantes' }); + if (f.done) return f.done; + const { fd, values, action, back } = f; + + if (action === 'add') { + const body = { + email: str(values, 'email'), + name: str(values, 'name'), + discord_user: str(values, 'discord_user'), + specialty: str(values, 'specialty'), + level: str(values, 'level'), + reserve: on(values, 'reserve'), + notes: str(values, 'notes'), + }; + const result = await adminFetch(cookie, `${slug}/admin/participants`, { method: 'POST', body }); + if (result.ok) return { redirect: back('alta', result.warnings) }; + return { action, result, values }; + } + + if (action === 'import_preview' || action === 'import') { + const file = fd.get('csv'); + const csv = file instanceof File ? await file.text() : typeof file === 'string' ? file : ''; + if (!csv.trim()) { + return badForm(action, values, ['csv'], 'Falta el fichero CSV.', 'csv_invalid'); + } + const result = await adminFetch(cookie, `${slug}/admin/participants/import`, { + method: 'POST', + body: csv, + search: action === 'import_preview' ? '?dry_run=true' : '', + }); + // La importación real también se queda en la página: el informe fila a fila es lo útil. + return { action, result, values }; + } + + const id = str(values, 'id'); + if (!SAFE_ID.test(id)) { + return badForm(action, values, ['id'], 'Falta el participante.'); + } + + if (action === 'update') { + const body = { + name: str(values, 'name'), + discord_user: str(values, 'discord_user'), + specialty: str(values, 'specialty'), + level: str(values, 'level'), + notes: str(values, 'notes'), + }; + const result = await adminFetch(cookie, `${slug}/admin/participants/${id}`, { method: 'PUT', body }); + if (result.ok) return { redirect: back('editado', result.warnings) }; + return { action, result, values }; + } + + const STATUS_ACTIONS: Record = { withdraw: 'baja', reinstate: 'reincorporado', promote: 'promovido', demote: 'reserva' }; + if (action in STATUS_ACTIONS) { + const body = action === 'reinstate' ? { reserve: on(values, 'reserve'), restore_submission: on(values, 'restore_submission') } : {}; + const result = await adminFetch(cookie, `${slug}/admin/participants/${id}/${action}`, { method: 'POST', body }); + if (result.ok) return { redirect: back(STATUS_ACTIONS[action], result.warnings) }; + return { action, result, values }; + } + + return badForm(action, values, ['action'], 'Acción desconocida.'); +} diff --git a/src/lib/eventsAdminSubmissions.ts b/src/lib/eventsAdminSubmissions.ts new file mode 100644 index 0000000..b142a29 --- /dev/null +++ b/src/lib/eventsAdminSubmissions.ts @@ -0,0 +1,116 @@ +import type { Check, Owner } from './events'; +import { adminFetch } from './eventsAdmin'; +import { badForm, beginForm, SAFE_CHECK, SAFE_ID, str, type FormOutcome } from './eventsAdminForms'; + +/** + * Pantalla de entregas del panel (SPEC v3 §6.4 y §8, W-07): listado con + * propietario, URLs, checks y premio; edición de campos, retirada y + * restauración, forzar o reiniciar un check, fijar o reiniciar la + * elegibilidad de premio y "verificar todas". Sin JavaScript: cada acción es + * un formulario POST que procesa el fichero de ruta y acaba en redirección + * con `?ok=`, o en la misma página con el error del backend. + */ + +/** Un check de la entrega: el mismo `Check` de la web pública (con `forced`/`reason`). */ +export type SubmissionCheck = Check; + +/** Fila de `GET /{slug}/admin/submissions` (entrega más `owner` y `owner_emails`). */ +export interface AdminSubmissionRow { + id: string; + participant_id: string | null; + team_id: string | null; + title: string; + description: string; + public_url: string; + space_url: string; + repo_url: string; + image_url: string; + video_url: string; + submitted_by: string; + submitted_at: string; + updated_at: string; + withdrawn_at: string | null; + checks: Record | null; + auto_points: number; + not_prize_eligible: boolean; + /** Solo si el admin la ha fijado a mano (§6.4); sin ella manda `prize_requires`. */ + prize_eligible?: boolean; + prize_reason?: string; + owner: Owner; + owner_emails: string[]; +} + +export { CHECK_LABELS } from './eventsAdminForms'; + +/** Campos editables de una entrega, en el orden del formulario. */ +export const SUBMISSION_EDIT_FIELDS = [ + { key: 'title', label: 'Título', kind: 'text' }, + { key: 'public_url', label: 'URL pública', kind: 'url' }, + { key: 'description', label: 'Descripción', kind: 'textarea' }, + { key: 'repo_url', label: 'URL del repositorio', kind: 'url' }, + { key: 'space_url', label: 'URL del space', kind: 'url' }, + { key: 'image_url', label: 'URL de imagen', kind: 'url' }, + { key: 'video_url', label: 'URL de vídeo', kind: 'url' }, +] as const; + +/** + * `/events/admin/{slug}/entregas`: procesa el POST según `action` + * (`update`, `withdraw`, `restore`, `check`, `prize`, `verify`). Todas menos + * `verify` llevan `id`. `check` lleva `name` y `pass` (`true`/`false`) o + * `reset=on`; `prize` lleva `eligible` (`true`/`false`) o `reset=on`; ambas + * admiten `reason`. + */ +export async function handleSubmissionsForm(request: Request, cookie: string, slug: string): Promise { + const f = await beginForm(request, { slug, screen: 'entregas' }); + if (f.done) return f.done; + const { fd, values, action, back } = f; + + if (action === 'verify') { + const result = await adminFetch<{ verified?: number }>(cookie, `${slug}/admin/verify`, { method: 'POST', body: {} }); + if (result.ok) return { redirect: back('verificado', result.warnings) }; + return { action, result, values }; + } + + const id = str(values, 'id'); + if (!SAFE_ID.test(id)) return badForm(action, values, ['id'], 'Falta la entrega.'); + const base = `${slug}/admin/submissions/${id}`; + + if (action === 'update') { + // Parcial: se mandan solo los campos presentes en el formulario (§6.4, B-18). + const body: Record = {}; + for (const f of SUBMISSION_EDIT_FIELDS) if (fd.has(f.key)) body[f.key] = str(values, f.key); + const result = await adminFetch(cookie, base, { method: 'PUT', body }); + if (result.ok) return { redirect: back('entrega_editada', result.warnings) }; + return { action, result, values }; + } + + if (action === 'withdraw' || action === 'restore') { + const result = await adminFetch(cookie, `${base}/${action}`, { method: 'POST', body: {} }); + if (result.ok) return { redirect: back(action === 'withdraw' ? 'entrega_retirada' : 'entrega_restaurada', result.warnings) }; + return { action, result, values }; + } + + if (action === 'check') { + const name = str(values, 'name'); + if (!SAFE_CHECK.test(name)) return badForm(action, values, ['name'], 'Falta el check.'); + const reset = str(values, 'reset') !== ''; + const pass = str(values, 'pass'); + if (!reset && pass !== 'true' && pass !== 'false') return badForm(action, values, ['pass'], 'Indica si el check pasa o no.'); + const body = reset ? { reset: true } : { pass: pass === 'true', reason: str(values, 'reason') }; + const result = await adminFetch(cookie, `${base}/checks/${name}`, { method: 'PUT', body }); + if (result.ok) return { redirect: back(reset ? 'check_reiniciado' : 'check_forzado', result.warnings) }; + return { action, result, values }; + } + + if (action === 'prize') { + const reset = str(values, 'reset') !== ''; + const eligible = str(values, 'eligible'); + if (!reset && eligible !== 'true' && eligible !== 'false') return badForm(action, values, ['eligible'], 'Indica si opta al premio o no.'); + const body = reset ? { reset: true } : { eligible: eligible === 'true', reason: str(values, 'reason') }; + const result = await adminFetch(cookie, `${base}/prize-eligibility`, { method: 'PUT', body }); + if (result.ok) return { redirect: back(reset ? 'premio_automatico' : 'premio', result.warnings) }; + return { action, result, values }; + } + + return badForm(action, values, ['action'], 'Acción desconocida.'); +} diff --git a/src/lib/eventsAdminTeams.ts b/src/lib/eventsAdminTeams.ts new file mode 100644 index 0000000..dc2f6ef --- /dev/null +++ b/src/lib/eventsAdminTeams.ts @@ -0,0 +1,112 @@ +import { adminFetch } from './eventsAdmin'; +import { badForm, beginForm, on, SAFE_ID, str, type FormOutcome } from './eventsAdminForms'; + +/** + * Pantalla de equipos del panel (SPEC v3 §6.3 y §8, W-06): tablero con una + * columna por equipo y otra de inscritos sin equipo; mover entre columnas, + * crear, renombrar, disolver y generar (con previsualización). Sin + * JavaScript: cada movimiento es un formulario POST que procesa el fichero de + * ruta y acaba en redirección con `?ok=`, o en la misma página con el error. + */ + +export interface TeamMemberRow { + id: string; + name: string; + email: string; + discord_user: string; + specialty: string | null; + level: string | null; + status: string; +} + +/** Fila de `GET /{slug}/admin/teams` (equipo más `locked`, entrega, miembros y avisos). */ +export interface AdminTeamRow { + id: string; + name: string; + origin: string; + member_ids: string[]; + size: number; + balance_score: { avg_level: number; specialties: Record | null }; + created_at: string; + updated_at: string; + locked: boolean; + submission_id: string | null; + members: TeamMemberRow[]; + warnings: string[]; +} + +/** Respuesta de `POST …/teams/generate` (B-16). */ +export interface GenerateReport { + teams: number; + kept: number; + created: number; + warnings: string[]; +} + +export const TEAM_ORIGIN_LABELS: Record = { + auto: 'automático', + manual: 'manual', +}; + +/** Valor del destino "sin equipo" en el selector de mover. */ +export const NO_TEAM = 'none'; + +/** + * `/events/admin/{slug}/equipos`: procesa el POST según `action` + * (`create`, `rename`, `delete`, `move`, `generate_preview`, `generate`). + * `move` lleva `participant_id` y `to` (id de equipo o `none` para sacarlo + * del suyo, que en ese caso también exige `from`). + */ +export async function handleTeamsForm(request: Request, cookie: string, slug: string): Promise { + const f = await beginForm(request, { slug, screen: 'equipos' }); + if (f.done) return f.done; + const { fd, values, action, back } = f; + + if (action === 'create') { + const memberIds = fd.getAll('member_ids').map((m) => String(m).trim()).filter((m) => SAFE_ID.test(m)); + const result = await adminFetch(cookie, `${slug}/admin/teams`, { method: 'POST', body: { name: str(values, 'name'), member_ids: memberIds } }); + if (result.ok) return { redirect: back('equipo_creado', result.warnings) }; + return { action, result, values }; + } + + if (action === 'generate_preview' || action === 'generate') { + const body = { keep_manual: on(values, 'keep_manual'), dry_run: action === 'generate_preview' }; + const result = await adminFetch(cookie, `${slug}/admin/teams/generate`, { method: 'POST', body }); + if (result.ok && action === 'generate') return { redirect: back('generado', result.warnings) }; + return { action, result, values }; + } + + if (action === 'move') { + const pid = str(values, 'participant_id'); + const to = str(values, 'to'); + if (!SAFE_ID.test(pid)) return badForm(action, values, ['participant_id'], 'Falta el participante.'); + if (to === NO_TEAM) { + const from = str(values, 'from'); + if (!SAFE_ID.test(from)) return badForm(action, values, ['from'], 'Falta el equipo de origen.'); + const result = await adminFetch(cookie, `${slug}/admin/teams/${from}/members/${pid}`, { method: 'DELETE' }); + if (result.ok) return { redirect: back('quitado', result.warnings) }; + return { action, result, values }; + } + if (!SAFE_ID.test(to)) return badForm(action, values, ['to'], 'Falta el equipo de destino.'); + const result = await adminFetch(cookie, `${slug}/admin/teams/${to}/members`, { method: 'POST', body: { participant_id: pid } }); + if (result.ok) return { redirect: back('movido', result.warnings) }; + return { action, result, values }; + } + + const id = str(values, 'id'); + if (!SAFE_ID.test(id)) return badForm(action, values, ['id'], 'Falta el equipo.'); + + if (action === 'rename') { + const result = await adminFetch(cookie, `${slug}/admin/teams/${id}`, { method: 'PUT', body: { name: str(values, 'name') } }); + if (result.ok) return { redirect: back('renombrado', result.warnings) }; + return { action, result, values }; + } + + if (action === 'delete') { + const result = await adminFetch(cookie, `${slug}/admin/teams/${id}`, { method: 'DELETE' }); + if (result.ok) return { redirect: back('disuelto', result.warnings) }; + return { action, result, values }; + } + + return badForm(action, values, ['action'], 'Acción desconocida.'); +} diff --git a/src/lib/eventsAdminVotes.ts b/src/lib/eventsAdminVotes.ts new file mode 100644 index 0000000..090c65c --- /dev/null +++ b/src/lib/eventsAdminVotes.ts @@ -0,0 +1,52 @@ +import type { LeaderboardRow, LeaderboardView, Owner } from './events'; +import { adminFetch } from './eventsAdmin'; +import { badForm, beginForm, type FormOutcome } from './eventsAdminForms'; + +/** + * Pantalla de votos y ranking del panel (SPEC v3 §6.5, B-23 y §8, W-08). + * Los votos son solo lectura (decisión 23: no se anulan ni se editan). Lo + * único que se escribe son los atajos de §4.4: abrir la votación y cerrarla + * (que congela y publica el ranking), ambos con previsualización `dry_run`. + */ + +/** Fila de `GET /{slug}/admin/votes` (B-20). */ +export interface AdminVoteRow { + id: string; + voter_email: string; + voter_member_uuid: string; + submission_id: string; + /** Vacío si la entrega ya no existe. */ + submission_title: string; + /** La entrega votada está retirada: el voto no cuenta en el ranking. */ + withdrawn: boolean; + owner: Owner | null; + created_at: string; +} + +/** `GET /{slug}/admin/leaderboard` (B-23) tiene el mismo formato que el público: se re-exporta de `events.ts`. */ +export type { LeaderboardRow, LeaderboardView }; + +/** Resumen de los votos: cuántos cuentan y cuántos no (entrega retirada o desaparecida). */ +export function voteStats(votes: AdminVoteRow[]): { total: number; counted: number; discarded: number; voters: number } { + const counted = votes.filter((v) => v.owner && !v.withdrawn).length; + return { total: votes.length, counted, discarded: votes.length - counted, voters: new Set(votes.map((v) => v.voter_member_uuid || v.voter_email)).size }; +} + +/** + * `/events/admin/{slug}/votos`: procesa el POST según `action` + * (`open_preview`, `open`, `close_preview`, `close`). Las previsualizaciones + * van con `?dry_run=true` y se quedan en la página; las reales redirigen. + */ +export async function handleVotesForm(request: Request, cookie: string, slug: string): Promise { + const f = await beginForm(request, { slug, screen: 'votos' }); + if (f.done) return f.done; + const { values, action, back } = f; + const m = /^(open|close)(_preview)?$/.exec(action); + if (!m) { + return badForm(action, values, ['action'], 'Acción desconocida.'); + } + const [, verb, preview] = m; + const result = await adminFetch(cookie, `${slug}/admin/voting/${verb}`, { method: 'POST', search: preview ? '?dry_run=true' : '' }); + if (result.ok && !preview) return { redirect: back(verb === 'open' ? 'votacion_abierta' : 'votacion_cerrada', result.warnings) }; + return { action, result, values }; +} diff --git a/src/lib/eventsCalendar.ts b/src/lib/eventsCalendar.ts new file mode 100644 index 0000000..680bacd --- /dev/null +++ b/src/lib/eventsCalendar.ts @@ -0,0 +1,179 @@ +import type { AgendaItem } from './agenda'; +import { apiBase, jsonData, ssrHeaders, type EventInfo } from './events'; + +/** + * Eventos publicados en la página pública `/events` y "añadir a tu + * calendario" (SPEC v3 §6.1 bis y §8, W-10). + * + * El backend sirve un feed iCalendar (`/api/events/calendar.ics`) con todos + * los eventos publicados que tienen fecha. Quien lo añade una vez a Google + * Calendar, Apple Calendar u Outlook ve los eventos actuales y los que se + * publiquen después sin volver a la web: el cliente relee el feed solo. + * Aquí se construyen los enlaces y se convierten los eventos de la API en + * filas de la agenda de "este mes". + */ + +/** Resumen de `GET /api/events`: los campos de la ficha que usa la página. */ +export type PublicEventSummary = Pick< + EventInfo, + 'slug' | 'kind' | 'name' | 'description' | 'location' | 'url' | 'image_url' | 'status' | 'dates' +>; + +/** Ruta del feed dentro del sitio: pasa por el proxy same-origin. */ +export const CALENDAR_PATH = '/api/events/calendar.ics'; + +/** Zona horaria en la que se anuncian los eventos de la comunidad. */ +export const EVENTS_TZ = 'Europe/Madrid'; + +/** Hasta cuándo dura un evento sin `demo_day_end`: lo mismo que asume el feed. */ +const DEFAULT_DURATION_MS = 2 * 60 * 60 * 1000; + +/** + * Tope del texto que va en la URL de Google: la descripción de un evento + * puede ser larga y las URLs muy largas fallan en algunos navegadores. + */ +const DETAILS_MAX = 800; + +/** La página no espera más que esto por la lista: sale con lo estático. */ +const TIMEOUT_MS = 2500; + +/** + * Eventos publicados, para el SSR de `/events`. Lista vacía si la API no + * responde o tarda: la página sigue saliendo con la agenda estática. + */ +export async function fetchEvents(): Promise { + try { + const res = await fetch(`${apiBase()}/api/events`, { + headers: ssrHeaders(), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (!res.ok) return []; + return (await jsonData(res)) ?? []; + } catch { + return []; + } +} + +/** URL absoluta del feed a partir del origen público del sitio. */ +export function calendarFeedURL(site: string | URL | undefined): string { + const origin = new URL(site ?? 'https://nan.builders').origin; + return `${origin}${CALENDAR_PATH}`; +} + +/** Ruta del `.ics` de un solo evento (`GET /api/events/{slug}/calendar.ics`). */ +export const eventCalendarPath = (ev: Pick): string => + `/api/events/${ev.slug}/calendar.ics`; + +/** + * Suscripción en Google Calendar: `cid` con la URL del feed. Google lo añade + * como calendario "de URL" y lo relee él mismo a lo largo del día. + */ +export function googleSubscribeURL(feed: string): string { + return `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(feed)}`; +} + +/** `webcal://`: Apple Calendar y Outlook lo abren como suscripción, no como descarga. */ +export function webcalURL(feed: string): string { + return feed.replace(/^https?:/, 'webcal:'); +} + +/** Fecha en el formato compacto que piden los enlaces de Google (`20260916T170000Z`). */ +export function compactUTC(d: Date): string { + return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); +} + +/** Enlace de la ficha pública del evento, sin prefijo de idioma. */ +export const eventHref = (ev: Pick): string => `/events/${ev.slug}`; + +/** + * Enlace "añadir a Google Calendar" de UN evento (plantilla, sin suscripción): + * para quien quiera solo ese. `null` si el evento no tiene fecha. + */ +export function googleEventURL(ev: PublicEventSummary, site: string | URL | undefined): string | null { + const start = parseDate(ev.dates.demo_day); + if (!start) return null; + const end = parseDate(ev.dates.demo_day_end); + const q = new URLSearchParams({ + action: 'TEMPLATE', + text: ev.name, + dates: `${compactUTC(start)}/${compactUTC(end && end > start ? end : new Date(start.getTime() + DEFAULT_DURATION_MS))}`, + details: eventDetails(ev, site), + ctz: EVENTS_TZ, + }); + if (ev.location) q.set('location', ev.location); + return `https://calendar.google.com/calendar/render?${q.toString()}`; +} + +/** + * Texto del evento para el calendario: descripción (recortada si es muy + * larga) y, siempre, el enlace: la url propia si la tiene y la ficha pública. + */ +export function eventDetails(ev: PublicEventSummary, site: string | URL | undefined): string { + const page = new URL(eventHref(ev), site ?? 'https://nan.builders').href; + const links = ev.url && ev.url !== page ? `${ev.url}\n${page}` : page; + if (!ev.description) return links; + const room = DETAILS_MAX - links.length - 2; + const desc = ev.description.length > room ? `${ev.description.slice(0, Math.max(room - 1, 0)).trimEnd()}…` : ev.description; + return `${desc}\n\n${links}`; +} + +/** Día `YYYY-MM-DD` de un instante en la zona de los eventos. */ +export function localDay(d: Date, tz = EVENTS_TZ): string { + const parts = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }) + .formatToParts(d); + const get = (t: string) => parts.find((p) => p.type === t)?.value ?? ''; + return `${get('year')}-${get('month')}-${get('day')}`; +} + +/** Hora `HH:MM` de un instante en la zona de los eventos. */ +export function localTime(d: Date, tz = EVENTS_TZ): string { + return new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(d); +} + +/** + * Filas de agenda a partir de los eventos publicados con fecha. Los + * cancelados no entran: la agenda es lo que va a pasar (en el feed sí van, + * como cancelados, para que desaparezcan del calendario de quien los tenía). + * El día se calcula en la zona de los eventos, no en UTC: un demo day a las + * 23:30 de Madrid no debe aparecer al día siguiente. + */ +export function toAgendaItems(events: PublicEventSummary[], site: string | URL | undefined): AgendaItem[] { + const out: AgendaItem[] = []; + for (const ev of events) { + const start = parseDate(ev.dates.demo_day); + if (!start || ev.status === 'cancelled') continue; + const end = parseDate(ev.dates.demo_day_end); + const date = localDay(start); + const item: AgendaItem = { + date, + type: ev.kind, + title: ev.name, + by: ev.location || undefined, + href: eventHref(ev), + time: localTime(start), + calendar: googleEventURL(ev, site) ?? undefined, + }; + if (end) { + const until = localDay(end); + if (until > date) item.until = until; + } + out.push(item); + } + return out; +} + +/** + * Agenda estática + eventos de la API. Si una entrada estática apunta a la + * ficha de un evento que ya viene de la API, gana la API (tiene la fecha + * real y el enlace al calendario). + */ +export function mergeAgenda(base: AgendaItem[], fromAPI: AgendaItem[]): AgendaItem[] { + const hrefs = new Set(fromAPI.map((a) => a.href).filter(Boolean)); + return [...base.filter((a) => !a.href || !hrefs.has(a.href)), ...fromAPI]; +} + +function parseDate(iso?: string | null): Date | null { + if (!iso) return null; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d; +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 0e1cfae..69de10d 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -96,3 +96,17 @@ export function useT(locale: string): NanDict { /** Alias del nombre que usaban los componentes de nan-site. */ export const getLang = getLocale; + +/** + * Etiqueta de una especialidad o un nivel. En v3 las especialidades y los + * niveles los escribe quien organiza el evento, así que `events.options` solo + * traduce los de siempre; el resto se enseña tal cual, pero con la inicial en + * mayúscula para que "devops" no desentone al lado de "Frontend" en la misma + * lista. Los valores que ya vienen con mayúscula ("ML/IA") no se tocan. + */ +export function optionLabel(options: Record, value?: string | null): string { + if (!value) return ''; + const known = options[value]; + if (known) return known; + return value.charAt(0).toUpperCase() + value.slice(1); +} diff --git a/src/pages/_events.astro b/src/pages/_events.astro index 7c152f3..d4fd758 100644 --- a/src/pages/_events.astro +++ b/src/pages/_events.astro @@ -10,6 +10,15 @@ import { type AgendaItem, type Localized, } from '../lib/agenda'; +import { + CALENDAR_PATH, + calendarFeedURL, + fetchEvents, + googleSubscribeURL, + mergeAgenda, + toAgendaItems, + webcalURL, +} from '../lib/eventsCalendar'; const lang = getLang(Astro.url); const tt = useT(lang).events; @@ -32,8 +41,17 @@ const events: VideoCard[] = eventos.events; // para quien esté en Canarias o LATAM cerca de medianoche, "hoy" puede ir un // día adelantado. Si algún día se prerenderiza, el calendario sí se congelaría // en el build y habría que moverlo a cliente o a una isla. -const agenda: AgendaItem[] = eventos.agenda ?? []; +// +// La agenda mezcla lo estático de eventos.json con los eventos publicados en +// la API (SPEC v3 §8, W-10). Si la API no responde, sale solo lo estático. +const fromAPI = toAgendaItems(await fetchEvents(), Astro.site); +const agenda: AgendaItem[] = mergeAgenda(eventos.agenda ?? [], fromAPI); const today = new Date(); + +// Feed iCalendar (§6.1 bis): suscripción, no descarga. Google necesita la URL +// pública absoluta; el enlace `.ics` es relativo para que funcione en cualquier +// despliegue. Astro.site viene de astro.config.mjs. +const feed = calendarFeedURL(Astro.site); const days = calendarDays(agenda, today); const upcoming = upcomingIn(agenda, today); @@ -77,7 +95,10 @@ const hack = eventos.hackathons[0];
    {upcoming.map((a) => (
  • - {spanLabel(a, lang)} + + {spanLabel(a, lang)} + {a.time && {a.time}} + {tt.types[a.type as keyof typeof tt.types]} {/* Solo las entradas con landing propia son clicables; el resto sigue siendo texto, para no prometer una página que no existe. */} @@ -86,13 +107,33 @@ const hack = eventos.hackathons[0]; ? {loc(a.title)} : loc(a.title)} - {a.by} + + {a.by} + {a.calendar && ( + {tt.calendarAdd} + )} +
  • ))}
) : (

{tt.monthEmpty}

)} + + +
+

{tt.calendarTitle}

+

{tt.calendarBody}

+ +

+ {tt.calendarFeedLabel} + {feed} +

+

{tt.calendarTz}

+
@@ -275,7 +316,31 @@ const hack = eventos.hackathons[0]; transition: color var(--dur-micro) var(--ease), border-color var(--dur-micro) var(--ease); } .agenda__link:hover { color: var(--color-link); border-bottom-color: var(--color-link); } - .agenda__by { font-size: 13px; color: var(--color-muted); } + .agenda__time { display: block; font-size: 11px; color: var(--color-muted); letter-spacing: 0.04em; } + .agenda__by { font-size: 13px; color: var(--color-muted); display: inline-flex; gap: var(--space-3); align-items: baseline; } + .agenda__cal { + font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; + color: var(--color-violet-2); text-decoration: none; white-space: nowrap; + border-bottom: 1px solid color-mix(in srgb, var(--color-violet) 45%, transparent); + } + .agenda__cal:hover { color: var(--color-link); border-bottom-color: var(--color-link); } + + /* suscripción al calendario */ + .calsub { + margin-top: var(--space-8); border: 1px solid var(--color-line); padding: clamp(20px, 3vw, 36px); + background: var(--color-surface); + } + .calsub__title { + font-family: var(--font-serif); font-weight: 400; color: var(--color-text); + font-size: clamp(22px, 2.2vw, 30px); line-height: 1.1; + } + .calsub__body { margin-top: var(--space-3); max-width: 56ch; color: var(--color-muted); font-size: 15px; line-height: 1.6; } + .calsub__actions { margin-top: var(--space-5); display: flex; flex-wrap: wrap; gap: var(--space-3); } + .calsub__feed { margin-top: var(--space-5); display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-3); font-size: 12px; color: var(--color-muted); } + .calsub__feed-l { text-transform: uppercase; letter-spacing: 0.08em; } + .calsub__feed-u { color: var(--color-violet-2); text-decoration: none; word-break: break-all; } + .calsub__feed-u:hover { color: var(--color-link); } + .calsub__tz { margin-top: var(--space-2); font-size: 11px; color: var(--color-muted); } .agenda__empty { margin-top: var(--space-6); color: var(--color-muted); font-family: var(--font-serif); font-size: 17px; } @media (max-width: 620px) { diff --git a/src/pages/api/auth/login-request.ts b/src/pages/api/auth/login-request.ts index 472d668..bfbe43c 100644 --- a/src/pages/api/auth/login-request.ts +++ b/src/pages/api/auth/login-request.ts @@ -1,15 +1,9 @@ import type { APIRoute } from 'astro'; -import { env } from 'cloudflare:workers'; +import { apiBase, ssrHeaders } from '../../../lib/events'; +import { json } from '../../../lib/apiResponse'; export const prerender = false; -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }, - }); -} - // Proxy same-origin para iniciar el login por magic link de NaN desde la landing // de eventos (/events/{slug}). El formulario de email vive ahí (no tenemos el frontend // de cloud), pero usa el MISMO endpoint de auth que la plataforma: reenvía a @@ -19,13 +13,9 @@ function json(body: unknown, status = 200): Response { // Es necesario un proxy porque la CSP de la landing es `connect-src 'self'`: el // navegador solo puede hacer fetch a este mismo origen, no a cloud-api. export const POST: APIRoute = async ({ request }) => { - const base = env.CLOUD_API_URL.replace(/\/$/, ''); - const target = `${base}/api/auth/login/request`; + const target = `${apiBase()}/api/auth/login/request`; - const headers = new Headers({ - 'content-type': 'application/json', - origin: 'https://nan.builders', - }); + const headers = new Headers({ ...ssrHeaders(), 'content-type': 'application/json' }); // El backend limita por IP usando X-Forwarded-For; sin esto, todas las // peticiones compartirían la IP del worker. Propagamos la IP real del cliente. const ip = request.headers.get('cf-connecting-ip'); diff --git a/src/pages/api/events/[...path].ts b/src/pages/api/events/[...path].ts index 20d6391..23e6546 100644 --- a/src/pages/api/events/[...path].ts +++ b/src/pages/api/events/[...path].ts @@ -1,29 +1,25 @@ import type { APIRoute } from 'astro'; -import { backendURL, forwardHeaders, isAdminPath } from '../../../lib/events'; +import { backendURL, forwardHeaders, hasSessionCookie, isAdminPath } from '../../../lib/events'; +import { json } from '../../../lib/apiResponse'; export const prerender = false; -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }, - }); -} - /** * Proxy same-origin `/api/events/*` → `${CLOUD_API_URL}/api/events/*` (SPEC §8.1). * * Existe porque la CSP de la landing es `connect-src 'self'`: las islas solo * pueden hacer fetch a este origen. Reenvía cookie de sesión e IP real; nunca - * la admin key, y nunca rutas con un segmento `admin`. + * la admin key. Las rutas con un segmento `admin` solo pasan con cookie de + * sesión (SPEC v3 §8): el backend decide si esa sesión es de staff y responde + * 401/403 si no; el panel `/events/admin` es el que las usa. */ const handler: APIRoute = async ({ params, request, url }) => { const path = (params.path ?? '').toString(); - // No exponer endpoints de operación desde el navegador público. Se mantiene - // como primera barrera aunque `backendURL` ya acote la ruta: dice - // explícitamente qué es lo que no debe atravesar el proxy. - if (isAdminPath(path)) { + // Sin sesión, los endpoints de operación no existen de cara al exterior: + // 404 sin tocar el backend. Se mantiene como primera barrera aunque + // `backendURL` ya acote la ruta. + if (isAdminPath(path) && !hasSessionCookie(request)) { return json({ ok: false, error: 'not_found' }, 404); } @@ -50,13 +46,28 @@ const handler: APIRoute = async ({ params, request, url }) => { return json({ ok: false, error: 'server_error' }, 500); } - // Reenviar cuerpo y status; normalizar a JSON. Propagar Set-Cookie si lo hubiera. + // Reenviar cuerpo y status; normalizar a JSON salvo el CSV de + // `participants/export.csv` y el feed iCalendar (`calendar.ics`, W-10). + // Content-Disposition se conserva para las descargas del panel + // (`export?download=1`). Propagar Set-Cookie. const text = await resp.text(); - const headers = new Headers({ 'content-type': 'application/json', 'cache-control': 'no-store' }); + const upstreamType = resp.headers.get('content-type') ?? ''; + const passthrough = /^text\/(csv|calendar)\b/i.test(upstreamType); + const headers = new Headers({ + 'content-type': passthrough ? upstreamType : 'application/json', + // El feed es público y lo releen los clientes de calendario: se respeta + // la caché corta que fija el backend. Todo lo demás, sin caché. + 'cache-control': (/^text\/calendar\b/i.test(upstreamType) && resp.headers.get('cache-control')) || 'no-store', + }); + const disposition = resp.headers.get('content-disposition'); + if (disposition) headers.set('content-disposition', disposition); // getSetCookie() devuelve un array sin colapsar comas (WHATWG); preserva // múltiples cookies (login + refresh, handoff de onboarding, etc.). for (const cookie of resp.headers.getSetCookie()) headers.append('set-cookie', cookie); - return new Response(text || '{}', { status: resp.status, headers }); + // A CSV or iCalendar body is forwarded as is, even when empty: padding it + // with `{}` would hand the operator a download with `{}` inside. The `{}` + // fallback only applies to the JSON responses, so callers can always parse. + return new Response(passthrough ? text : text || '{}', { status: resp.status, headers }); }; export const GET = handler; diff --git a/src/pages/events/[slug]/_index.astro b/src/pages/events/[slug]/_index.astro index 0486f44..4fb3f8e 100644 --- a/src/pages/events/[slug]/_index.astro +++ b/src/pages/events/[slug]/_index.astro @@ -8,7 +8,9 @@ import RegisterForm, { type FormStrings } from '../../../components/events/Regis import LoginForm, { type LoginStrings } from '../../../components/events/LoginForm.tsx'; import RegistrationBar from '../../../components/events/RegistrationBar.astro'; import { getLocale, t, tObj, withLang } from '../../../lib/i18n'; -import { fetchMe, fmtDate, fmtRange, hasSessionCookie, type EventInfo } from '../../../lib/events'; +import { eventSession, fmtDate, fmtRange, type EventInfo } from '../../../lib/events'; +import { eventCalendarPath, googleEventURL } from '../../../lib/eventsCalendar'; +import { safeImage, safeUrl } from '../../../lib/projects'; const locale = getLocale(Astro.url); // El evento lo resuelve el envoltorio de ruta (resolveEventRoute) y llega por props. @@ -18,12 +20,8 @@ interface Props { const ev = Astro.props.event; const slug = ev.slug; -// Sesión: /me solo si hay cookie. Si el backend devuelve 401, la sesión caducó. -const hasSession = hasSessionCookie(Astro.request); -const { me, unauthorized } = hasSession - ? await fetchMe(slug, Astro.request.headers.get('cookie') ?? '') - : { me: null, unauthorized: false }; -const sessionOk = hasSession && !unauthorized; +// Sesión: /me solo si hay cookie; si el backend devuelve 401, la sesión caducó. +const { me, sessionOk } = await eventSession(Astro.request, slug); const participant = me?.participant ?? null; const registered = !!participant && participant.status !== 'withdrawn'; const hasSubmission = !!me?.submission; @@ -58,25 +56,48 @@ if (ev.dates.submission_open || ev.dates.submission_close) dates.push({ label: dl.submission, value: fmtRange(ev.dates.submission_open, ev.dates.submission_close, locale) }); if (ev.voting.enabled && (ev.dates.voting_open || ev.dates.voting_close)) dates.push({ label: dl.voting, value: fmtRange(ev.dates.voting_open, ev.dates.voting_close, locale) }); -if (ev.dates.demo_day) dates.push({ label: dl.demoDay, value: fmtDate(ev.dates.demo_day, locale) }); +if (ev.dates.demo_day) + dates.push({ + label: dl.demoDay, + // Con hora (y fin si lo tiene): es la cita a la que hay que ir. + value: ev.dates.demo_day_end + ? `${fmtDate(ev.dates.demo_day, locale, true)} – ${fmtDate(ev.dates.demo_day_end, locale, true)}` + : fmtDate(ev.dates.demo_day, locale, true), + }); + +// Dónde, enlace y "añadir a mi calendario" (W-10): solo si el evento tiene fecha. +const where = tObj>('events.where', locale); +const calendarLink = googleEventURL(ev, Astro.site); +const icsHref = eventCalendarPath(ev); // Panel principal por fase (§8.2). `null` → se pinta el bloque de inscripción. type Panel = { title: string; body: string; href?: string; cta?: string; tone?: 'muted' | 'accent' }; let panel: Panel | null = null; const closeAt = fmtDate(ev.dates.submission_close, locale, true); const openAt = fmtDate(ev.dates.submission_open, locale, true); +// En v3 todas las fechas son opcionales, así que la copia que las nombra tiene +// gemela sin fecha: si no, la frase queda coja ("entrega antes del ."). +const notOpenBody = openAt ? fill(phase.submissionNotOpen, { date: openAt }) : phase.submissionNotOpenNoDate; +const buildingBody = closeAt ? fill(phase.building, { date: closeAt }) : phase.buildingNoDate; +const submittedBody = closeAt ? fill(phase.submitted, { date: closeAt }) : phase.submittedNoDate; switch (ev.phase) { + case 'published': + // Informativo anunciado (B-26): no hay inscripción; el CTA es el enlace del evento si lo tiene. + panel = ev.url + ? { title: phase.publishedTitle, body: phase.published, href: ev.url, cta: phase.publishedCta, tone: 'accent' } + : { title: phase.publishedTitle, body: phase.published, tone: 'accent' }; + break; case 'draft': case 'registration': if (isTeam) panel = null; // bloque de inscripción - else panel = { title: phase.notOpenTitle, body: fill(phase.submissionNotOpen, { date: openAt }), tone: 'muted' }; + else panel = { title: phase.notOpenTitle, body: notOpenBody, tone: 'muted' }; break; case 'building_pending': panel = isTeam && registered ? { title: phase.inProgressTitle, body: phase.inProgress, href: meUrl, cta: already.cta, tone: 'accent' } : isTeam ? { title: phase.closedTitle, body: phase.closed, tone: 'muted' } - : { title: phase.notOpenTitle, body: fill(phase.submissionNotOpen, { date: openAt }), tone: 'muted' }; + : { title: phase.notOpenTitle, body: notOpenBody, tone: 'muted' }; break; case 'building': if (isTeam) { @@ -84,9 +105,9 @@ switch (ev.phase) { ? { title: phase.inProgressTitle, body: phase.inProgress, href: meUrl, cta: already.cta, tone: 'accent' } : { title: phase.closedTitle, body: phase.closed, tone: 'muted' }; } else if (hasSubmission) { - panel = { title: phase.submittedTitle, body: fill(phase.submitted, { date: closeAt }), href: submissionUrl, cta: phase.submittedCta, tone: 'accent' }; + panel = { title: phase.submittedTitle, body: submittedBody, href: submissionUrl, cta: phase.submittedCta, tone: 'accent' }; } else { - panel = { title: phase.buildingTitle, body: fill(phase.building, { date: closeAt }), href: submissionUrl, cta: phase.buildingCta, tone: 'accent' }; + panel = { title: phase.buildingTitle, body: buildingBody, href: submissionUrl, cta: phase.buildingCta, tone: 'accent' }; } break; case 'submission': @@ -107,93 +128,137 @@ switch (ev.phase) { break; } +// Cover (B-27). The backend already validates http/https, but the CSP only +// allows https images and the browser blocks mixed content, so an http cover +// would render as a broken icon in production. In dev the covers point at +// localhost, hence the looser check there. +const cover = import.meta.env.DEV ? safeUrl(ev.image_url) : safeImage(ev.image_url); + const pageTitle = `${ev.name} — nan.builders`; const closedNote = fill(cap.closedNote, { cap: capacity, res: reserve }); --- - -
-
+{/* Ficha: una columna centrada (max-w-3xl) con aire entre bloques. Las tarjetas + siguen la casa: esquina recta (la impone nan-system.css), hairline y tinte + violeta para lo que importa (fechas, premio, CTA). Botones = .btn de la casa. */} + +
+
-
+ {/* Decorative: the name is already the H1 right above. */} + {cover && ( + + )} + +
{ev.description && ( -

{ev.description}

+

{ev.description}

)} + {dates.length > 0 && ( -
+
{dates.map((d) => ( -
-
{d.label}
-
{d.value}
+
+
{d.label}
+
{d.value}
))}
)} + + {(ev.location || ev.url) && ( +
+ {ev.location && ( +
+
{where.label}
+
{ev.location}
+
+ )} + {ev.url && ( +
+
{where.link}
+
+ {ev.url} +
+
+ )} +
+ )} + + {calendarLink && ( + + )} + {ev.prize && ( -

{t('events.prize', locale)} · {ev.prize}

+
+

{t('events.prize', locale)}

+

{ev.prize}

+
)} + {ev.rules && ( -

{ev.rules}

+

{ev.rules}

)}
-
+
{panel ? (
-

+ ? 'border border-violet-500/40 bg-violet-500/10 p-8 md:p-10' + : 'border border-neutral-800 bg-neutral-900/40 p-8 md:p-10'}> +

{panel.title}

-

{panel.body}

+

{panel.body}

{panel.href && panel.cta && ( /* En solo, entregar requiere sesión: si no la hay, enseñamos el login en vez del CTA. */ !isTeam && ev.phase === 'building' && !sessionOk ? ( -
-

{t('events.submission.loginNote', locale)}

+
+

{t('events.submission.loginNote', locale)}

) : ( - - {panel.cta} - + {panel.cta} ) )}
) : !ev.windows.registration.open ? ( -
-

{phase.notOpenTitle}

-

{phase.notOpen}

+
+

{phase.notOpenTitle}

+

{phase.notOpen}

) : ( -
+
{hasCapacity && ( )} {registered ? ( -
-

{already.title}

-

+

+

{already.title}

+

{participant?.is_reserve ? already.reserve : already.confirmed}

- - {already.cta} - + {already.cta}
) : full ? ( -
-

{cap.closedTitle}

-

{closedNote}

+
+

{cap.closedTitle}

+

{closedNote}

) : !sessionOk ? ( ) : ( )}
diff --git a/src/pages/events/[slug]/_leaderboard.astro b/src/pages/events/[slug]/_leaderboard.astro index ee8a116..e9a7bc8 100644 --- a/src/pages/events/[slug]/_leaderboard.astro +++ b/src/pages/events/[slug]/_leaderboard.astro @@ -4,7 +4,7 @@ import '../../../styles/global.css'; import NanPage from '../../../layouts/NanPage.astro'; import EventHeader from '../../../components/events/EventHeader.astro'; import { getLocale, tObj } from '../../../lib/i18n'; -import { fetchMe, fetchPublic, hasSessionCookie, type LeaderboardRow, type EventInfo } from '../../../lib/events'; +import { eventSession, fetchPublic, type LeaderboardView, type EventInfo } from '../../../lib/events'; const locale = getLocale(Astro.url); // El evento lo resuelve el envoltorio de ruta (resolveEventRoute) y llega por props. @@ -14,16 +14,17 @@ interface Props { const ev = Astro.props.event; const slug = ev.slug; -const hasSession = hasSessionCookie(Astro.request); // Clasificación + validación de la sesión en paralelo, como en el resto de // páginas: la cabecera solo muestra "mi zona" si la cookie sigue siendo válida. -const [lbRes, meRes] = await Promise.all([ - fetchPublic<{ rows: LeaderboardRow[]; public: boolean }>(slug, 'leaderboard'), - hasSession ? fetchMe(slug, Astro.request.headers.get('cookie') ?? '') : Promise.resolve({ me: null, unauthorized: false }), +const [lbRes, { sessionOk }] = await Promise.all([ + fetchPublic(slug, 'leaderboard'), + eventSession(Astro.request, slug), ]); const lb = lbRes ?? { rows: [], public: false }; const rows = lb.rows ?? []; -const sessionOk = hasSession && !meRes.unauthorized; +// La API devuelve lista, pero un event.json editado a mano (o una versión +// vieja del backend) puede traer null: nunca reventamos la pantalla por eso. +const checks = ev.submission.checks ?? []; const l = tObj>('events.leaderboard', locale); --- @@ -40,7 +41,7 @@ const l = tObj>('events.leaderboard', locale); {ev.format === 'team' ? l.team : l.participant} {l.project} {l.votes} - {ev.submission.checks.length > 0 && {l.auto}} + {checks.length > 0 && {l.auto}} {l.total} @@ -50,7 +51,7 @@ const l = tObj>('events.leaderboard', locale); {r.owner.name}{r.not_prize_eligible ? ' *' : ''} {r.title} {r.votes} - {ev.submission.checks.length > 0 && {r.auto_points}} + {checks.length > 0 && {r.auto_points}} {r.total} ))} diff --git a/src/pages/events/[slug]/_me.astro b/src/pages/events/[slug]/_me.astro index 4b5405e..ef440ae 100644 --- a/src/pages/events/[slug]/_me.astro +++ b/src/pages/events/[slug]/_me.astro @@ -5,8 +5,8 @@ import NanPage from '../../../layouts/NanPage.astro'; import EventHeader from '../../../components/events/EventHeader.astro'; import ReassignForm from '../../../components/events/ReassignForm.tsx'; import LoginForm, { type LoginStrings } from '../../../components/events/LoginForm.tsx'; -import { getLocale, t, tObj, withLang } from '../../../lib/i18n'; -import { fetchMe, hasSessionCookie, type EventInfo } from '../../../lib/events'; +import { getLocale, optionLabel, t, tObj, withLang } from '../../../lib/i18n'; +import { eventSession, type EventInfo } from '../../../lib/events'; const locale = getLocale(Astro.url); // El evento lo resuelve el envoltorio de ruta (resolveEventRoute) y llega por props. @@ -16,17 +16,13 @@ interface Props { const ev = Astro.props.event; const slug = ev.slug; -const hasSession = hasSessionCookie(Astro.request); -const { me, unauthorized } = hasSession - ? await fetchMe(slug, Astro.request.headers.get('cookie') ?? '') - : { me: null, unauthorized: false }; -const sessionOk = hasSession && !unauthorized; +const { me, sessionOk } = await eventSession(Astro.request, slug); const participant = me?.participant ?? null; const team = me?.team ?? null; const submission = me?.submission ?? null; const isTeam = ev.format === 'team'; const options = tObj>('events.options', locale); -const label = (v?: string | null) => (v ? options[v] ?? v : ''); +const label = (v?: string | null) => optionLabel(options, v); const m = tObj>('events.me', locale); const login = tObj('events.login', locale); const submissionUrl = withLang(`/events/${slug}/submission`, locale); @@ -34,6 +30,11 @@ const title = isTeam ? m.title : m.titleSolo; const statusLabel = participant?.status === 'withdrawn' ? m.statusWithdrawn : participant?.is_reserve ? m.statusReserve : m.statusRegistered; +// Candidatos a ausente: los compañeros, nunca uno mismo. El backend rechaza +// señalarse a sí mismo (§7.6.3) con un error genérico, así que la opción no +// llega a ofrecerse; si no queda nadie más en el equipo, no hay a quién +// reasignar y el formulario sobra. +const absent = (team?.members ?? []).filter((x) => x.id !== participant?.id); --- {/* Detrás de sesión: fuera del sitemap y también fuera del índice. */} @@ -82,11 +83,11 @@ const statusLabel = participant?.status === 'withdrawn' {submission ? m.submissionCta : m.submissionCreateCta}
- {ev.phase === 'building' && team.members && ( + {ev.phase === 'building' && absent.length > 0 && (
- ({ id: x.id, name: x.name, discord_user: x.discord_user ?? undefined }))} + ({ id: x.id, name: x.name, discord_user: x.discord_user ?? undefined }))} ctaLabel={m.reassignCta} info={tObj('events.me.reassignInfo', locale)} - labels={{ pending: m.reassignPending, filled: m.reassignFilled, noPool: m.reassignNoPool, already: m.reassignAlready, selectAbsent: m.reassignSelectAbsent, errorSubmit: m.reassignErrorSubmit, sending: m.reassignSending }} /> + labels={{ pending: m.reassignPending, filled: m.reassignFilled, noPool: m.reassignNoPool, already: m.reassignAlready, selectAbsent: m.reassignSelectAbsent, errorSubmit: m.reassignErrorSubmit, sending: m.reassignSending, refresh: m.reassignRefresh }} />
)}
diff --git a/src/pages/events/[slug]/_projects.astro b/src/pages/events/[slug]/_projects.astro index 09ef42e..1c7118c 100644 --- a/src/pages/events/[slug]/_projects.astro +++ b/src/pages/events/[slug]/_projects.astro @@ -5,7 +5,7 @@ import NanPage from '../../../layouts/NanPage.astro'; import EventHeader from '../../../components/events/EventHeader.astro'; import VoteButton from '../../../components/events/VoteButton.tsx'; import { getLocale, tObj, withLang } from '../../../lib/i18n'; -import { fetchMe, fetchPublic, hasSessionCookie, type PublicSubmission, type EventInfo } from '../../../lib/events'; +import { eventSession, fetchPublic, type PublicSubmission, type EventInfo } from '../../../lib/events'; const locale = getLocale(Astro.url); // El evento lo resuelve el envoltorio de ruta (resolveEventRoute) y llega por props. @@ -15,16 +15,14 @@ interface Props { const ev = Astro.props.event; const slug = ev.slug; -const hasSession = hasSessionCookie(Astro.request); // Entregas + voto actual del visitante en paralelo (el voto permite marcar la // tarjeta votada y ofrecer cambiarlo; sin sesión queda null). -const [projects, meRes] = await Promise.all([ +const [projects, { me, sessionOk }] = await Promise.all([ ev.windows.gallery.visible ? fetchPublic(slug, 'submissions') : Promise.resolve([]), - hasSession ? fetchMe(slug, Astro.request.headers.get('cookie') ?? '') : Promise.resolve({ me: null, unauthorized: false }), + eventSession(Astro.request, slug), ]); const list = projects ?? []; -const sessionOk = hasSession && !meRes.unauthorized; -const myVote = meRes.me?.my_vote ?? null; +const myVote = me?.my_vote ?? null; const canVote = ev.windows.voting.open; const pr = tObj>('events.projects', locale); diff --git a/src/pages/events/[slug]/_submission.astro b/src/pages/events/[slug]/_submission.astro index ce6add3..ccd8468 100644 --- a/src/pages/events/[slug]/_submission.astro +++ b/src/pages/events/[slug]/_submission.astro @@ -6,7 +6,7 @@ import EventHeader from '../../../components/events/EventHeader.astro'; import SubmissionForm from '../../../components/events/SubmissionForm.tsx'; import LoginForm, { type LoginStrings } from '../../../components/events/LoginForm.tsx'; import { getLocale, tObj } from '../../../lib/i18n'; -import { fetchMe, fmtDate, hasSessionCookie, type EventInfo } from '../../../lib/events'; +import { eventSession, fmtDate, type EventInfo, type SubmissionFields } from '../../../lib/events'; const locale = getLocale(Astro.url); // El evento lo resuelve el envoltorio de ruta (resolveEventRoute) y llega por props. @@ -16,24 +16,34 @@ interface Props { const ev = Astro.props.event; const slug = ev.slug; -const hasSession = hasSessionCookie(Astro.request); -const { me, unauthorized } = hasSession - ? await fetchMe(slug, Astro.request.headers.get('cookie') ?? '') - : { me: null, unauthorized: false }; -const sessionOk = hasSession && !unauthorized; +const { me, sessionOk } = await eventSession(Astro.request, slug); const isTeam = ev.format === 'team'; const hasTeam = Boolean(me?.team?.id); const hasParticipant = Boolean(me?.participant && me.participant.status !== 'withdrawn'); const existing = me?.submission ?? null; const canEdit = ev.windows.submission.open; -const beforeOpen = ev.phase === 'draft' || ev.phase === 'registration' || ev.phase === 'building_pending'; +const beforeOpen = ev.phase === 'draft' || ev.phase === 'published' || ev.phase === 'registration' || ev.phase === 'building_pending'; const s = tObj>('events.submission', locale); const options = tObj>('events.options', locale); const login = tObj('events.login', locale); const fill = (str: string, vars: Record) => Object.entries(vars).reduce((acc, [k, v]) => acc.replaceAll(`{${k}}`, v), str); -const fields = ev.submission.fields; +// Las fechas son opcionales (v3): sin ellas la frase que las nombra queda coja, +// así que cada aviso tiene gemelo sin fecha. +const openAt = fmtDate(ev.dates.submission_open, locale, true); +const closeAt = fmtDate(ev.dates.submission_close, locale, true); +const windowNotOpen = openAt ? fill(s.notOpen, { date: openAt }) : s.notOpenNoDate; +const windowClosed = closeAt ? fill(s.closedNote, { date: closeAt }) : s.closedNoteNoDate; +// Las listas del evento llegan como null cuando el slice va nil en el backend +// (ver EventInfo): nunca reventamos la pantalla por eso, y el tipo obliga. +const specialties = ev.registration.specialties ?? []; +const levels = ev.registration.levels ?? []; +const checks = ev.submission.checks ?? []; +// fields NO necesita guarda: en el backend es un struct por valor, así que +// siempre se serializa como objeto. Un modo en cadena vacía (event.json v2) +// cae en "visible y opcional", que es el trato correcto. +const fields: SubmissionFields = ev.submission.fields; const readonlyRows = existing ? [ { label: s.fTitle, value: existing.title }, @@ -63,18 +73,16 @@ const readonlyRows = existing ) : canEdit ? ( ) : (

{beforeOpen ? s.notOpenTitle : s.closedTitle}

- {beforeOpen - ? fill(s.notOpen, { date: fmtDate(ev.dates.submission_open, locale, true) }) - : fill(s.closedNote, { date: fmtDate(ev.dates.submission_close, locale, true) })} + {beforeOpen ? windowNotOpen : windowClosed}

{existing ? ( @@ -88,11 +96,11 @@ const readonlyRows = existing
))} - {ev.submission.checks.length > 0 && ( + {checks.length > 0 && (

{s.checks}: {existing.auto_points ?? 0}/{ev.voting.auto_max}

    - {ev.submission.checks.map((k) => ( + {checks.map((k) => (
  • {existing.checks?.[k]?.pass ? '✓' : '✗'} {options[k] ?? k}
  • ))}
diff --git a/src/pages/events/admin/[slug]/_auditoria.astro b/src/pages/events/admin/[slug]/_auditoria.astro new file mode 100644 index 0000000..33c0745 --- /dev/null +++ b/src/pages/events/admin/[slug]/_auditoria.astro @@ -0,0 +1,157 @@ +--- +// Auditoría y backups del evento (SPEC v3 §6.6, §6.7 y §8, W-09): las +// líneas de audit.jsonl con filtros (desde, acción, límite), más recientes +// primero, con antes/después desplegable; y la lista de backups con +// restaurar en dos pasos (previsualización dry_run y confirmación). El POST +// lo procesa el envoltorio de ruta; aquí solo se consulta y se pinta. +import '../../../../styles/global.css'; +import EventsAdmin from '../../../../layouts/EventsAdmin.astro'; +import AdminNotices from '../../../../components/events/admin/AdminNotices.astro'; +import { adminFetch, adminHref, fmtAdminDate, type AdminScreenProps } from '../../../../lib/eventsAdmin'; +import { isoToLocal, readFlash } from '../../../../lib/eventsAdminForms'; +import { + actorLabel, auditSearch, backupDate, fmtBytes, readAuditFilters, + AUDIT_ACTION_GROUPS, AUDIT_DEFAULT_LIMIT, AUDIT_MAX_LIMIT, + type AuditEntry, type BackupInfo, type RestoreResult, +} from '../../../../lib/eventsAdminAudit'; + +type Props = AdminScreenProps; +const { staff, cookie, view, outcome } = Astro.props; +const ev = view.event; +const archived = Boolean(ev.archived_at); +const flash = readFlash(Astro.url, outcome); +const filters = readAuditFilters(Astro.url); + +const [auditRes, backupsRes] = await Promise.all([ + adminFetch(cookie, `${ev.slug}/admin/audit`, { search: auditSearch(filters) }), + adminFetch(cookie, `${ev.slug}/admin/backups`), +]); +const entries = auditRes.data ?? []; +const backups = backupsRes.data ?? []; + +// Previsualización de restaurar: la respuesta de dry_run se queda en la página con el botón de confirmar. +const preview = outcome.result?.ok && outcome.action === 'restore_preview' ? (outcome.result.data as RestoreResult | null) : null; +const previewWarnings = outcome.action === 'restore_preview' && outcome.result?.ok ? outcome.result.warnings : []; + +const json = (v: unknown) => (v === null || v === undefined ? '—' : JSON.stringify(v, null, 2)); +const fileLabel = (f: string) => f.replace(/\.json$/, ''); +// Las acciones con sujeto van con el id del sujeto en `target`; las del evento llevan el slug. +const targetText = (e: AuditEntry) => (e.target && e.target !== ev.slug ? e.target : ''); +--- + + + + + {preview && ( +
+

+ Simulación de restaurar {fileLabel(preview.file)} desde el backup {preview.timestamp}: el fichero valida y no se ha cambiado nada todavía. + Al confirmar, el fichero actual se guarda como backup nuevo y se sustituye por el elegido; si el resultado no valida, se deshace. +

+ {previewWarnings.length > 0 && } + + + + + + Cancelar + +
+ )} + +
+

Auditoría ({entries.length}{entries.length >= filters.limit ? `, límite ${filters.limit}` : ''})

+
+ + + + + {(filters.since || filters.action || filters.limit !== AUDIT_DEFAULT_LIMIT) && Quitar filtros} +
+

+ Cada línea es un cambio hecho por la API o el panel: quién, qué y el antes y después. Más recientes primero. También se puede leer con + GET /api/events/{ev.slug}/admin/audit?since=&action=&limit=. +

+
+ + {!auditRes.ok && } + + {auditRes.ok && entries.length === 0 &&

No hay líneas de auditoría{filters.since || filters.action ? ' con estos filtros' : ''}.

} + + {entries.length > 0 && ( +
+ + + + {entries.map((e) => ( + + + + + + + + + ))} + +
FechaActorAcciónSujetoAvisosCambio
{fmtAdminDate(e.ts)}{actorLabel(e.actor)}{e.action}{targetText(e) ? {targetText(e)} : }{e.warnings.length ? {e.warnings.join(', ')} : } +
+ antes / después +
+
{json(e.before)}
+
{json(e.after)}
+
+
+
+
+ )} + +
+

Backups ({backups.length})

+

+ Antes de cada escritura el backend guarda una copia del fichero que va a cambiar. Restaurar hace backup del fichero actual, + sustituye, recarga y valida; si no valida, lo deshace. {archived && 'Evento archivado: no se puede restaurar.'} +

+ {!backupsRes.ok && } + {backupsRes.ok && backups.length === 0 &&

Todavía no hay backups.

} + {backups.length > 0 && ( +
+ + {!archived && } + + {backups.map((b) => ( + + + + + + {!archived && ( + + )} + + ))} + +
FicheroCopiaFechaTamaño
{fileLabel(b.file)}{b.timestamp}{fmtAdminDate(backupDate(b.timestamp)) || '—'}{fmtBytes(b.size)} +
+ + + + +
+
+
+ )} +
+
diff --git a/src/pages/events/admin/[slug]/_entregas.astro b/src/pages/events/admin/[slug]/_entregas.astro new file mode 100644 index 0000000..6589a9f --- /dev/null +++ b/src/pages/events/admin/[slug]/_entregas.astro @@ -0,0 +1,238 @@ +--- +// Entregas del evento (SPEC v3 §6.4 y §8, W-07): tabla con propietario, +// URLs, checks y premio; retirar/restaurar por fila; tarjeta de edición +// (?editar=) con los campos, el forzado de checks y la elegibilidad de +// premio; y "verificar todas". El POST lo procesa el envoltorio de ruta; +// aquí solo se consulta y se pinta. +import '../../../../styles/global.css'; +import EventsAdmin from '../../../../layouts/EventsAdmin.astro'; +import AdminNotices from '../../../../components/events/admin/AdminNotices.astro'; +import { adminFetch, adminHref, fmtAdminDate, ownerKind, type AdminScreenProps } from '../../../../lib/eventsAdmin'; +import { readFlash } from '../../../../lib/eventsAdminForms'; +import { CHECK_LABELS, SUBMISSION_EDIT_FIELDS, type AdminSubmissionRow } from '../../../../lib/eventsAdminSubmissions'; + +type Props = AdminScreenProps; +const { staff, cookie, view, outcome } = Astro.props; +const ev = view.event; +const archived = Boolean(ev.archived_at); +const enabled = ev.modules.submissions; +const flash = readFlash(Astro.url, outcome); +const here = adminHref(ev.slug, 'entregas'); + +const res = enabled ? await adminFetch(cookie, `${ev.slug}/admin/submissions`) : null; +// Activas primero, y dentro de cada grupo por fecha de entrega. +const rows = (res?.data ?? []).slice().sort((a, b) => + Number(Boolean(a.withdrawn_at)) - Number(Boolean(b.withdrawn_at)) || a.submitted_at.localeCompare(b.submitted_at)); +const active = rows.filter((r) => !r.withdrawn_at); +// Las listas del evento llegan como null cuando el slice va nil en el backend +// (ver EventInfo): nunca reventamos la pantalla por eso, y el tipo obliga. +const checks = ev.submission.checks ?? []; +const prizeRequires = ev.submission.prize_requires ?? []; +// fields NO necesita guarda: es un struct por valor en el backend y siempre +// llega como objeto. El `?? 'optional'` de fieldOn cubre el modo vacío. +const fieldModes: Record = ev.submission.fields; + +// Edición: ?editar= abre la tarjeta con los datos de esa fila (o los que se escribieron si falló). +const editId = outcome.action && outcome.action !== 'verify' && outcome.values ? String(outcome.values.id ?? '') : (Astro.url.searchParams.get('editar') ?? ''); +const editRow = rows.find((r) => r.id === editId) ?? null; +const ev2 = (k: string, fallback: string) => (outcome.action === 'update' && outcome.values ? String(outcome.values[k] ?? '') : fallback); +const rv = (action: string, k: string) => (outcome.action === action && outcome.values ? String(outcome.values[k] ?? '') : ''); + +// Campos que el evento muestra (título y URL pública siempre; el resto según `submission.fields`). +const fieldOn = (key: string) => key === 'title' || key === 'public_url' || (fieldModes[key] ?? 'optional') !== 'hidden'; +const fields = SUBMISSION_EDIT_FIELDS.filter((f) => fieldOn(f.key)); +const urlKeys = ['public_url', 'space_url', 'repo_url', 'image_url', 'video_url'] as const; +const URL_LABELS: Record = { public_url: 'pública', space_url: 'space', repo_url: 'repo', image_url: 'imagen', video_url: 'vídeo' }; + +const checkLabel = (name: string) => CHECK_LABELS[name] ?? name; +const prizeText = (r: AdminSubmissionRow) => { + if (r.prize_eligible === undefined) return r.not_prize_eligible ? 'no opta (automático)' : prizeRequires.length ? 'opta (automático)' : 'opta'; + return r.prize_eligible ? 'opta (fijado)' : 'no opta (fijado)'; +}; +--- + + + + + {!enabled && ( +

Este evento no tiene el módulo de entregas. Se activa en la ficha del evento.

+ )} + + {enabled && ( +
+
+
Entregas activas
{active.length}
+
Retiradas
{rows.length - active.length}
+
Ventana
{view.windows.submission.open ? 'abierta' : 'cerrada'}
+
Checks
{checks.length ? checks.map(checkLabel).join(', ') : ninguno}
+
Premio exige
{prizeRequires.length ? prizeRequires.map(checkLabel).join(', ') : nada}
+
+

+ Los checks se ejecutan al entregar y al cambiar una URL. "Verificar todas" los repite en las entregas activas sin pisar los fijados a mano. + Retirar no borra: la entrega se puede restaurar mientras su propietario siga en el evento. +

+ {!archived && checks.length > 0 && ( +
+ + +
+ )} +
+ )} + + {res && !res.ok && } + {res?.ok && rows.length === 0 &&

Todavía no hay entregas.

} + + {rows.length > 0 && ( +
+ + + + + + {checks.length > 0 && } + + + + + + + + {rows.map((r) => ( + + + + {checks.length > 0 && ( + + )} + + + + + + ))} + +
PropietarioEntregaChecksPuntosPremioEstadoAcciones
+
{r.owner.name}
+
{ownerKind(r.owner.type)} · {r.owner_emails.join(', ') || '—'}
+
+ {r.title} +
+ {urlKeys.filter((k) => r[k]).map((k) => ( + <>{URL_LABELS[k]}{' '} + ))} +
+
+ {checks.map((name) => { + const c = r.checks?.[name]; + return ( + + {c ? (c.pass ? '✓' : '✗') : '·'} {checkLabel(name)}{c?.forced ? ' *' : ''} + + ); + })} + {r.auto_points} / {ev.voting.auto_max} + {prizeText(r)} + {r.prize_reason &&
motivo: {r.prize_reason}
} +
+ {r.withdrawn_at + ? <>retirada
{fmtAdminDate(r.withdrawn_at)}
+ : <>activa
{fmtAdminDate(r.submitted_at)}
} +
+ {!archived && Editar} + {!archived && !r.withdrawn_at && ( +
+ + + +
+ )} + {!archived && r.withdrawn_at && ( +
+ + + +
+ )} + {archived && solo lectura} +
+ {checks.length > 0 &&

* check fijado a mano por un admin (verify no lo pisa).

} +
+ )} + + {editRow && !archived && ( +
+

Editar: {editRow.title}

+

+ {ownerKind(editRow.owner.type)} {editRow.owner.name} · entregada el {fmtAdminDate(editRow.submitted_at)} + {editRow.withdrawn_at ? ` · retirada el ${fmtAdminDate(editRow.withdrawn_at)}` : ''} · {editRow.id} +

+
+ + +
+ {fields.map((f) => f.kind === 'textarea' + ? + : )} +
+

Cambiar la URL pública, la del space o la del repo vuelve a ejecutar los checks (salvo los fijados a mano).

+
+ + Cancelar +
+
+ + {checks.length > 0 && ( + <> +

Checks

+
+ + + + {checks.map((name) => { + const c = editRow.checks?.[name]; + return ( + + + + + + ); + })} + +
CheckResultadoFijar a mano
{checkLabel(name)} + {c ? {c.pass ? 'pasa' : 'falla'}{c.forced ? ' (fijado)' : ''} : sin ejecutar} +
+ {c?.checked_at ? fmtAdminDate(c.checked_at) : ''}{c?.http_status ? ` · HTTP ${c.http_status}` : ''}{c?.host ? ` · ${c.host}` : ''}{c?.reason ? ` · ${c.reason}` : ''} +
+
+
+ + + + + + + {c?.forced && } +
+
+
+ + )} + +

Premio

+

+ Ahora: {prizeText(editRow)}{editRow.prize_reason ? ` · ${editRow.prize_reason}` : ''}. + {prizeRequires.length ? ` En automático exige: ${prizeRequires.map(checkLabel).join(', ')}.` : ' En automático todas optan.'} +

+
+ + + + + + {editRow.prize_eligible !== undefined && } +
+
+ )} +
diff --git a/src/pages/events/admin/[slug]/_equipos.astro b/src/pages/events/admin/[slug]/_equipos.astro new file mode 100644 index 0000000..ae6d34d --- /dev/null +++ b/src/pages/events/admin/[slug]/_equipos.astro @@ -0,0 +1,200 @@ +--- +// Equipos del evento (SPEC v3 §6.3 y §8, W-06): tablero con una columna por +// equipo y otra con los inscritos sin equipo; mover entre columnas, crear, +// renombrar, disolver y generar con previsualización. El POST lo procesa el +// envoltorio de ruta; aquí solo se consulta y se pinta. +import '../../../../styles/global.css'; +import EventsAdmin from '../../../../layouts/EventsAdmin.astro'; +import AdminNotices from '../../../../components/events/admin/AdminNotices.astro'; +import { adminFetch, adminHref, type AdminScreenProps } from '../../../../lib/eventsAdmin'; +import { readFlash, warningLabel } from '../../../../lib/eventsAdminForms'; +import { adminOptionLabel, participantProfile, type AdminParticipantRow } from '../../../../lib/eventsAdminParticipants'; +import { NO_TEAM, TEAM_ORIGIN_LABELS, type AdminTeamRow, type GenerateReport } from '../../../../lib/eventsAdminTeams'; + +type Props = AdminScreenProps; +const { staff, cookie, view, outcome } = Astro.props; +const ev = view.event; +const archived = Boolean(ev.archived_at); +const isTeam = ev.format === 'team'; +const flash = readFlash(Astro.url, outcome); +const limits = ev.team ?? null; + +const teamsRes = isTeam ? await adminFetch(cookie, `${ev.slug}/admin/teams`) : null; +const freeRes = isTeam ? await adminFetch(cookie, `${ev.slug}/admin/participants`, { search: '?team=none' }) : null; +const teams = (teamsRes?.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)); +// Solo los inscritos activos pueden entrar en un equipo (reserva y bajas no). +const free = (freeRes?.data ?? []).filter((p) => p.status !== 'withdrawn' && !p.is_reserve).sort((a, b) => a.position - b.position); +const locked = teams.filter((t) => t.locked).length; + +const previewing = outcome.action === 'generate_preview' && outcome.result?.ok; +const report = previewing ? (outcome.result?.data as GenerateReport | null) : null; +const topResult = previewing ? undefined : outcome.result; +const v = (k: string, action: string, fallback = '') => (outcome.action === action && outcome.values ? String(outcome.values[k] ?? '') : fallback); + +const SIZE_PILLS: Record = { team_empty: 'vacío', team_under_min: 'bajo mínimo', team_over_size: 'sobre tamaño' }; +const profile = participantProfile; +const balance = (t: AdminTeamRow) => { + const parts = Object.entries(t.balance_score.specialties ?? {}).map(([k, n]) => `${adminOptionLabel(k)}×${n}`); + return [t.balance_score.avg_level ? `nivel medio ${t.balance_score.avg_level.toFixed(1)}` : '', ...parts].filter(Boolean).join(' · '); +}; +--- + + + + + {!isTeam && ( +

Este evento es individual: no tiene equipos. El formato se cambia en la ficha del evento (solo en borrador).

+ )} + + {isTeam && ( +
+
+
Equipos
{teams.length} / máx {limits?.max_teams || '∞'}
+
Tamaño
{limits?.min_size ?? '—'}–{limits?.size ?? '—'} (mín–objetivo)
+
Sin equipo
{free.length}
+
Con entrega
{locked} bloqueados
+
Inscritos activos
{view.counts.registered}
+
+

+ Los límites de tamaño solo avisan. Un equipo con entrega activa queda bloqueado: no se mueve a nadie ni se disuelve (decisión 19). + A los de reserva o de baja primero hay que promoverlos o reincorporarlos. +

+
+ )} + + {teamsRes && !teamsRes.ok && } + {freeRes && !freeRes.ok && } + + {isTeam && ( +
+
+

Sin equipo ({free.length})

+ {free.length === 0 &&

Todos los inscritos activos tienen equipo.

} +
    + {free.map((p) => ( +
  • +
    {p.name}
    +
    {profile(p) || p.email}
    + {!archived && teams.some((t) => !t.locked) && ( +
    + + + + +
    + )} +
  • + ))} +
+
+ + {teams.map((t) => ( +
+

+ {t.name} · {t.size} +

+
+ {TEAM_ORIGIN_LABELS[t.origin] ?? t.origin} + {t.locked && con entrega} + {t.warnings.map((w) => {SIZE_PILLS[w] ?? w})} +
+ {balance(t) &&

{balance(t)}

} +
    + {t.members.length === 0 &&
  • Sin miembros.
  • } + {t.members.map((m) => ( +
  • +
    {m.name}
    +
    {profile(m) || m.email}
    + {!archived && !t.locked && ( +
    + + + + + +
    + )} +
  • + ))} +
+ {!archived && ( +
+
+ + + + +
+ {t.submission_id && Ver la entrega} + {!t.locked && ( +
+ + + + los miembros siguen inscritos +
+ )} +
+ )} +
+ ))} +
+ )} + + {isTeam && !archived && ( +
+ Crear equipo +
+
+ +
+ +
+ {free.length > 0 && ( + <> +

Miembros iniciales (inscritos sin equipo)

+
+ {free.map((p) => ( + + ))} +
+ + )} +
+
+
+
+ )} + + {isTeam && !archived && ( +
+ Generar equipos automáticamente +
+

+ Reparte a los inscritos activos equilibrando especialidad y nivel. Sin "conservar manuales" descarta todos los equipos actuales; + con ella conserva los manuales y reparte solo al resto. No se puede generar con entregas hechas. +

+
+ + + +
+ {report && ( +
+

+ Previsualización (no se ha escrito nada): quedarían {report.teams} equipos, {report.kept} conservados y {report.created} nuevos. +

+ {(outcome.result?.warnings ?? []).length > 0 && ( +
    {(outcome.result?.warnings ?? []).map((w) =>
  • {w} — {warningLabel(w)}
  • )}
+ )} +
+ )} +
+
+ )} +
diff --git a/src/pages/events/admin/[slug]/_index.astro b/src/pages/events/admin/[slug]/_index.astro new file mode 100644 index 0000000..5a766c8 --- /dev/null +++ b/src/pages/events/admin/[slug]/_index.astro @@ -0,0 +1,116 @@ +--- +// Ficha del evento (SPEC v3 §8, W-03): resumen (fase, ventanas, contadores, +// avisos de coherencia), control de estado (W-04), editor del event.json, +// clonado y archivado. El POST lo procesa el envoltorio de ruta. +import '../../../../styles/global.css'; +import EventsAdmin from '../../../../layouts/EventsAdmin.astro'; +import AdminNotices from '../../../../components/events/admin/AdminNotices.astro'; +import EventForm from '../../../../components/events/admin/EventForm.astro'; +import StateControl from '../../../../components/events/admin/StateControl.astro'; +import { adminHref, fmtAdminDate, PHASE_LABELS, type AdminScreenProps } from '../../../../lib/eventsAdmin'; +import { eventToForm, readFlash, warningLabel } from '../../../../lib/eventsAdminForms'; + +type Props = AdminScreenProps; +const { staff, view, outcome } = Astro.props; +const ev = view.event; +const archived = Boolean(ev.archived_at); +const flash = readFlash(Astro.url, outcome); +const values = outcome.values && ['save', 'simulate'].includes(outcome.action ?? '') ? outcome.values : eventToForm(ev); +const cloneSlug = outcome.action === 'clone' && outcome.values ? String(outcome.values.clone_slug ?? '') : ''; +const canArchiveDirect = ev.status === 'closed' || ev.status === 'cancelled'; +// La previsualización de estado y el sweep sin transición los pinta StateControl; arriba solo van los errores. +const topResult = ['state_preview', 'sweep'].includes(outcome.action ?? '') && outcome.result?.ok ? undefined : outcome.result; +const windows = [ + { label: 'Inscripción', open: view.windows.registration.open }, + { label: 'Entregas', open: view.windows.submission.open }, + { label: 'Votación', open: view.windows.voting.open }, + { label: 'Galería', open: view.windows.gallery.visible }, + { label: 'Ranking', open: view.windows.leaderboard.visible }, +]; +--- + + + + +
+

Ahora mismo

+
+
Fase
{PHASE_LABELS[view.phase] ?? view.phase}
+
Inscritos
{view.counts.registered} / {ev.registration.capacity || '∞'}
+
Reserva
{view.counts.reserve}
+
Bajas
{view.counts.withdrawn}
+
Equipos
{view.counts.teams}
+
Entregas
{view.counts.submissions}
+
Votos
{view.counts.votes}
+
Actualizado
{fmtAdminDate(ev.updated_at)}
+ {archived &&
Archivado
{fmtAdminDate(ev.archived_at)}
} +
+
+ {windows.map((w) => {w.label}: {w.open ? 'abierta' : 'cerrada'})} +
+ {view.warnings.length > 0 && ( +
+

Avisos de coherencia:

+
    {view.warnings.map((w) =>
  • {w} — {warningLabel(w)}
  • )}
+
+ )} +

+ Público: /events/{ev.slug} (solo a partir de inscripción). + Pantallas: participantes, + equipos, + entregas, + votos y + auditoría. +

+
+ + {archived &&

Evento archivado: solo lectura. Para editarlo hay que desarchivarlo.

} + + + + + +
+

Clonar

+

+ Copia la configuración a un evento nuevo en borrador, sin participantes, equipos, entregas ni votos y con las fechas vacías. +

+
+ + + +
+
+ +
+

{archived ? 'Desarchivar' : 'Archivar'}

+ {archived ? ( +
+ + +
+ ) : ( +
+ +

+ {canArchiveDirect + ? 'El evento está cerrado o cancelado: se puede archivar. Queda en solo lectura y desaparece de la lista de activos.' + : 'El evento no está cerrado ni cancelado. Archivarlo ahora lo saca de circulación tal como está; marca "forzar" si es lo que quieres.'} +

+ {!canArchiveDirect && } + +
+ )} +
+
diff --git a/src/pages/events/admin/[slug]/_participantes.astro b/src/pages/events/admin/[slug]/_participantes.astro new file mode 100644 index 0000000..0c23fe1 --- /dev/null +++ b/src/pages/events/admin/[slug]/_participantes.astro @@ -0,0 +1,292 @@ +--- +// Participantes del evento (SPEC v3 §6.2 y §8, W-05): tabla con filtros, +// acciones por fila (baja, reincorporar, promover, reserva, editar), alta +// manual, importación CSV con previsualización y exportación. El POST lo +// procesa el envoltorio de ruta; aquí solo se consulta y se pinta. +import '../../../../styles/global.css'; +import EventsAdmin from '../../../../layouts/EventsAdmin.astro'; +import AdminNotices from '../../../../components/events/admin/AdminNotices.astro'; +import { adminFetch, adminHref, fmtAdminDate, type AdminScreenProps } from '../../../../lib/eventsAdmin'; +import { readFlash, warningLabel } from '../../../../lib/eventsAdminForms'; +import { + adminOptionLabel, IMPORT_ACTION_LABELS, participantProfile, PARTICIPANT_SOURCE_LABELS, PARTICIPANT_STATUS_LABELS, + participantsSearch, readParticipantFilters, type AdminParticipantRow, type ImportReport, +} from '../../../../lib/eventsAdminParticipants'; + +type Props = AdminScreenProps; +const { staff, cookie, view, outcome } = Astro.props; +const ev = view.event; +const archived = Boolean(ev.archived_at); +const isTeam = ev.format === 'team'; +const flash = readFlash(Astro.url, outcome); +const filters = readParticipantFilters(Astro.url); +const here = adminHref(ev.slug, 'participantes'); + +const res = await adminFetch(cookie, `${ev.slug}/admin/participants`, { search: participantsSearch(filters) }); +const rows = (res.data ?? []).slice().sort((a, b) => a.position - b.position); +const teams = [...new Map(rows.filter((r) => r.team_id).map((r) => [r.team_id as string, r.team_name ?? r.team_id])).entries()]; + +// Edición: ?editar= abre la tarjeta con los datos de esa fila (o los que se escribieron si falló). +const editId = outcome.action === 'update' && outcome.values ? String(outcome.values.id ?? '') : (Astro.url.searchParams.get('editar') ?? ''); +const editRow = rows.find((r) => r.id === editId) ?? null; +const ev2 = (k: string, fallback: string) => (outcome.action === 'update' && outcome.values ? String(outcome.values[k] ?? '') : fallback); +const addV = (k: string) => (outcome.action === 'add' && outcome.values ? String(outcome.values[k] ?? '') : ''); + +const importing = outcome.action === 'import_preview' || outcome.action === 'import'; +const report = importing && outcome.result?.ok ? (outcome.result.data as ImportReport | null) : null; +// Un error de importación (csv_invalid…) y el informe los pinta esta página; arriba van el resto de errores. +const topResult = importing && outcome.result?.ok ? undefined : outcome.result; +// La API devuelve listas, pero un event.json editado a mano (o una versión +// vieja del backend) puede traer null: nunca reventamos la pantalla por eso. +const specialties = ev.registration.specialties ?? []; +const levels = ev.registration.levels ?? []; +const hasSpecialties = specialties.length > 0; +const hasLevels = levels.length > 0; +const statusOf = (r: AdminParticipantRow) => PARTICIPANT_STATUS_LABELS[r.status] ?? r.status; +--- + + + + +
+
+
Inscritos
{view.counts.registered} / {ev.registration.capacity || '∞'}
+
Reserva
{view.counts.reserve} / {ev.registration.reserve_capacity || '∞'}
+
Bajas
{view.counts.withdrawn}
+
Inscripción
{view.windows.registration.open ? 'abierta' : 'cerrada'}
+
Formato
{isTeam ? 'equipos' : 'individual'}
+
+
+ + + {isTeam && ( + + )} + + + {(filters.status || filters.reserve || filters.team || filters.q) && Quitar filtros} + Exportar CSV +
+
+ + {!res.ok && } + {res.ok && rows.length === 0 &&

No hay participantes con ese filtro.

} + + {rows.length > 0 && ( +
+ + + + + + {(hasSpecialties || hasLevels) && } + + {isTeam && } + + + + + + + {rows.map((r) => ( + + + + {(hasSpecialties || hasLevels) && } + + {isTeam && } + + + + + ))} + +
#ParticipantePerfilEstadoEquipoEntregaOrigenAcciones
{r.position} + {r.name} +
{r.email}{r.discord_user ? ` · ${r.discord_user}` : ''}
+ {r.notes &&
✎ {r.notes}
} +
{participantProfile(r) || '—'} + {statusOf(r)} + {r.is_reserve && r.status !== 'withdrawn' && reserva} + {r.status === 'withdrawn' && r.withdrawn_reason &&
motivo: {r.withdrawn_reason}
} +
{r.team_name ?? }{r.submission_id ? : no}{PARTICIPANT_SOURCE_LABELS[r.source] ?? r.source}
{fmtAdminDate(r.created_at)}
+ {!archived && r.status !== 'withdrawn' && ( +
+ + + +
+ )} + {!archived && r.is_reserve && r.status !== 'withdrawn' && ( +
+ + + +
+ )} + {!archived && !r.is_reserve && r.status !== 'withdrawn' && !r.team_id && !r.submission_id && ( +
+ + + +
+ )} + {!archived && r.status === 'withdrawn' && ( +
+ + + + + {!isTeam && } +
+ )} + {archived && solo lectura} +
+
+ )} + + {editRow && !archived && ( +
+

Editar: {editRow.name}

+

{editRow.email} · {statusOf(editRow)} · inscrito el {fmtAdminDate(editRow.created_at)}

+
+ + +
+ + + {hasSpecialties && ( + + )} + {hasLevels && ( + + )} + +
+
+ + Cancelar +
+
+
+ )} + + {!archived && ( +
+ Alta manual +
+

+ Solo cuentas de NaN: el email tiene que existir en la plataforma. No hace falta que la inscripción esté abierta; por encima del aforo se crea con aviso. +

+
+ +
+ + + + {hasSpecialties && ( + + )} + {hasLevels && ( + + )} + + +
+
+
+
+
+ )} + + {!archived && ( +
+ Importar CSV +
+

+ Cabecera obligatoria; columnas email,name,discord_user,specialty,level,reserve en cualquier orden (solo email es imprescindible). + Emails sin cuenta de NaN se rechazan; los ya inscritos se actualizan. Previsualiza primero: no escribe nada. +

+
+ + + +
+ {report && ( +
+

+ {outcome.action === 'import' ? 'Importación aplicada' : 'Previsualización (no se ha escrito nada)'}: + {' '}{report.created} nuevas, {report.updated} actualizadas, {report.restored} reincorporadas, {report.unchanged} sin cambios, {report.rejected} rechazadas. +

+ {report.rows.length > 0 && ( +
+ + + + {report.rows.map((row) => ( + + + + + + + ))} + +
LíneaEmailResultadoDetalle
{row.line}{row.email}{IMPORT_ACTION_LABELS[row.action] ?? row.action} + {row.error ? warningLabel(row.error) : ''} + {(row.warnings ?? []).map((w) => {warningLabel(w)})} +
+
+ )} +
+ )} +
+
+ )} +
diff --git a/src/pages/events/admin/[slug]/_votos.astro b/src/pages/events/admin/[slug]/_votos.astro new file mode 100644 index 0000000..adc5d19 --- /dev/null +++ b/src/pages/events/admin/[slug]/_votos.astro @@ -0,0 +1,163 @@ +--- +// Votos y ranking del evento (SPEC v3 §6.5 y §8, W-08): resumen de la +// votación, ranking calculado al momento (aunque no sea público), lista de +// votos solo lectura (decisión 23: no se anulan ni se editan) y los atajos +// de abrir y cerrar la votación con previsualización. El POST lo procesa el +// envoltorio de ruta; aquí solo se consulta y se pinta. +import '../../../../styles/global.css'; +import EventsAdmin from '../../../../layouts/EventsAdmin.astro'; +import AdminNotices from '../../../../components/events/admin/AdminNotices.astro'; +import { adminFetch, adminHref, fmtAdminDate, ownerKind, STATUS_LABELS, type AdminScreenProps } from '../../../../lib/eventsAdmin'; +import { readFlash } from '../../../../lib/eventsAdminForms'; +import { voteStats, type AdminVoteRow, type LeaderboardView } from '../../../../lib/eventsAdminVotes'; + +type Props = AdminScreenProps; +const { staff, cookie, view, outcome } = Astro.props; +const ev = view.event; +const archived = Boolean(ev.archived_at); +const enabled = ev.modules.voting; +const flash = readFlash(Astro.url, outcome); + +const [votesRes, boardRes] = enabled + ? await Promise.all([ + adminFetch(cookie, `${ev.slug}/admin/votes`), + adminFetch(cookie, `${ev.slug}/admin/leaderboard`), + ]) + : [null, null]; +// Más recientes primero. +const votes = (votesRes?.data ?? []).slice().sort((a, b) => b.created_at.localeCompare(a.created_at)); +const stats = voteStats(votes); +const board = boardRes?.data ?? { rows: [], public: ev.voting.leaderboard_public }; + +// Atajos de §4.4: abrir solo en building/submission/voting con la votación cerrada; cerrar solo en voting. +const canOpen = !archived && enabled && !ev.voting.open && ['building', 'submission', 'voting'].includes(ev.status); +const canClose = !archived && enabled && ev.status === 'voting'; + +// Previsualización: la respuesta de `?dry_run=true` se queda en la página con el botón de confirmar. +const preview = outcome.result?.ok && (outcome.action === 'open_preview' || outcome.action === 'close_preview') ? outcome.result : null; +const previewVerb = outcome.action === 'close_preview' ? 'close' : 'open'; +const previewBoard = previewVerb === 'close' ? (preview?.data as LeaderboardView | null) : null; + +const voteState = (v: AdminVoteRow) => (!v.owner ? 'entrega eliminada' : v.withdrawn ? 'entrega retirada' : 'cuenta'); +const num = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(2)); +--- + + + + + {!enabled && ( +

Este evento no tiene el módulo de votación. Se activa en la ficha del evento.

+ )} + + {enabled && ( +
+
+
Estado
{STATUS_LABELS[ev.status] ?? ev.status}
+
Votación
{ev.voting.open ? 'abierta' : 'cerrada'}{view.windows.voting.open ? '' : ev.voting.open ? (fuera de fecha) : ''}
+
Ranking
{board.public ? 'público' : solo admin}
+
Votos
{stats.counted}{stats.discarded ? + {stats.discarded} que no cuentan : ''}
+
Votantes
{stats.voters}
+
Peso del voto
{ev.voting.vote_weight} + hasta {ev.voting.auto_max} por checks
+
+

+ Total = peso · votos / máximo de votos + puntos por checks. Los votos a entregas retiradas no cuentan ni fijan el máximo. + Los votos no se anulan ni se editan (decisión 23). Cerrar la votación congela el ranking y lo publica. +

+ {/* + Los dos atajos pueden convivir: en `voting` con la votación todavía + cerrada se puede abrir Y se puede cerrar (que congela el ranking). Antes + se pintaba solo uno y ganaba el de cerrar, así que desde ese estado —el + que deja `voting_not_open`— no había forma de abrir la votación. + */} + {(canOpen || canClose) && ( +
+ {canOpen && } + {canClose && } +
+ )} + {!archived && enabled && !canOpen && !canClose && ( +

+ La votación se abre desde construcción, entrega o votación, y se cierra desde votación. El estado se cambia en la ficha del evento. +

+ )} +
+ )} + + {preview && ( +
+

+ Simulación de {previewVerb === 'close' ? 'cerrar' : 'abrir'} la votación: no se ha cambiado nada todavía. + {previewVerb === 'close' ? ' El evento pasaría a Cerrado y este ranking quedaría congelado y público.' : ' El evento pasaría a Votación si no lo estaba.'} +

+ {preview.warnings.length > 0 && } + {previewBoard && previewBoard.rows.length > 0 && ( +
    + {previewBoard.rows.map((r) =>
  1. {r.title} ({r.owner.name}) · {num(r.total)} puntos{r.not_prize_eligible ? ' · no opta a premio' : ''}
  2. )} +
+ )} +
+ + + Cancelar +
+
+ )} + + {boardRes && !boardRes.ok && } + {votesRes && !votesRes.ok && } + + {enabled && boardRes?.ok && ( +
+

Ranking {board.public ? '' : (no publicado)}

+ {board.rows.length === 0 &&

Todavía no hay entregas activas que clasificar.

} + {board.rows.length > 0 && ( +
+ + + + + + {board.rows.map((r) => ( + + + + + + + + + + + ))} + +
#EntregaPropietarioVotosPuntos votoChecksTotalPremio
{r.rank}{r.title}{r.owner.name} ({ownerKind(r.owner.type)}){r.votes}{num(r.vote_points)}{r.auto_points} / {ev.voting.auto_max}{num(r.total)}{r.not_prize_eligible ? 'no opta' : 'opta'}
+
+ )} +
+ )} + + {enabled && votesRes?.ok && ( +
+

Votos ({votes.length})

+ {votes.length === 0 &&

Todavía no hay votos.

} + {votes.length > 0 && ( +
+ + + + {votes.map((v) => ( + + + + + + + + ))} + +
VotanteEntregaPropietarioFechaEstado
{v.voter_email}{v.submission_title || }{v.owner ? <>{v.owner.name} ({ownerKind(v.owner.type)}) : }{fmtAdminDate(v.created_at)}{voteState(v)}
+
+ )} +
+ )} +
diff --git a/src/pages/events/admin/[slug]/auditoria.astro b/src/pages/events/admin/[slug]/auditoria.astro new file mode 100644 index 0000000..8e7bf14 --- /dev/null +++ b/src/pages/events/admin/[slug]/auditoria.astro @@ -0,0 +1,13 @@ +--- +export const prerender = false; +import { resolveAdminEventRoute } from '../../../../lib/eventsAdmin'; +import { handleAuditForm } from '../../../../lib/eventsAdminAudit'; +import Page from './_auditoria.astro'; + +const route = await resolveAdminEventRoute(Astro); +if (route.notFound) return route.notFound; +const outcome = await handleAuditForm(Astro.request, route.cookie, route.slug); +if (outcome.redirect) return Astro.redirect(outcome.redirect, 303); +--- + + diff --git a/src/pages/events/admin/[slug]/entregas.astro b/src/pages/events/admin/[slug]/entregas.astro new file mode 100644 index 0000000..b05c1d4 --- /dev/null +++ b/src/pages/events/admin/[slug]/entregas.astro @@ -0,0 +1,13 @@ +--- +export const prerender = false; +import { resolveAdminEventRoute } from '../../../../lib/eventsAdmin'; +import { handleSubmissionsForm } from '../../../../lib/eventsAdminSubmissions'; +import Page from './_entregas.astro'; + +const route = await resolveAdminEventRoute(Astro); +if (route.notFound) return route.notFound; +const outcome = await handleSubmissionsForm(Astro.request, route.cookie, route.slug); +if (outcome.redirect) return Astro.redirect(outcome.redirect, 303); +--- + + diff --git a/src/pages/events/admin/[slug]/equipos.astro b/src/pages/events/admin/[slug]/equipos.astro new file mode 100644 index 0000000..63fd17f --- /dev/null +++ b/src/pages/events/admin/[slug]/equipos.astro @@ -0,0 +1,13 @@ +--- +export const prerender = false; +import { resolveAdminEventRoute } from '../../../../lib/eventsAdmin'; +import { handleTeamsForm } from '../../../../lib/eventsAdminTeams'; +import Page from './_equipos.astro'; + +const route = await resolveAdminEventRoute(Astro); +if (route.notFound) return route.notFound; +const outcome = await handleTeamsForm(Astro.request, route.cookie, route.slug); +if (outcome.redirect) return Astro.redirect(outcome.redirect, 303); +--- + + diff --git a/src/pages/events/admin/[slug]/index.astro b/src/pages/events/admin/[slug]/index.astro new file mode 100644 index 0000000..d86605d --- /dev/null +++ b/src/pages/events/admin/[slug]/index.astro @@ -0,0 +1,17 @@ +--- +// Envoltorio de ruta de la ficha del evento (SPEC v3 §8, W-03): guardia de +// staff + carga del evento (404 si no existe) y procesado del POST aquí; +// el cuerpo vive en _index.astro. +import Page from './_index.astro'; +import { resolveAdminEventRoute } from '../../../../lib/eventsAdmin'; +import { handleEventForm } from '../../../../lib/eventsAdminForms'; + +export const prerender = false; + +const route = await resolveAdminEventRoute(Astro); +if (route.notFound) return route.notFound; +const outcome = await handleEventForm(Astro.request, route.cookie, route.slug); +if (outcome.redirect) return Astro.redirect(outcome.redirect, 303); +--- + + diff --git a/src/pages/events/admin/[slug]/participantes.astro b/src/pages/events/admin/[slug]/participantes.astro new file mode 100644 index 0000000..bd9d94c --- /dev/null +++ b/src/pages/events/admin/[slug]/participantes.astro @@ -0,0 +1,18 @@ +--- +export const prerender = false; +import { reloadAdminEventView, resolveAdminEventRoute } from '../../../../lib/eventsAdmin'; +import { handleParticipantsForm } from '../../../../lib/eventsAdminParticipants'; +import Page from './_participantes.astro'; + +const route = await resolveAdminEventRoute(Astro); +if (route.notFound) return route.notFound; +const outcome = await handleParticipantsForm(Astro.request, route.cookie, route.slug); +if (outcome.redirect) return Astro.redirect(outcome.redirect, 303); +// La importación es el único POST que escribe y se queda aquí (hay que pintar +// el informe): la ficha es anterior a esa escritura, así que se recarga. +const view = outcome.action === 'import' && outcome.result?.ok + ? await reloadAdminEventView(route.cookie, route.slug, route.view) + : route.view; +--- + + diff --git a/src/pages/events/admin/[slug]/votos.astro b/src/pages/events/admin/[slug]/votos.astro new file mode 100644 index 0000000..b231ab4 --- /dev/null +++ b/src/pages/events/admin/[slug]/votos.astro @@ -0,0 +1,13 @@ +--- +export const prerender = false; +import { resolveAdminEventRoute } from '../../../../lib/eventsAdmin'; +import { handleVotesForm } from '../../../../lib/eventsAdminVotes'; +import Page from './_votos.astro'; + +const route = await resolveAdminEventRoute(Astro); +if (route.notFound) return route.notFound; +const outcome = await handleVotesForm(Astro.request, route.cookie, route.slug); +if (outcome.redirect) return Astro.redirect(outcome.redirect, 303); +--- + + diff --git a/src/pages/events/admin/_index.astro b/src/pages/events/admin/_index.astro new file mode 100644 index 0000000..0210b84 --- /dev/null +++ b/src/pages/events/admin/_index.astro @@ -0,0 +1,88 @@ +--- +// Lista de eventos del panel (SPEC v3 §8, W-03). La guardia y la cookie +// llegan del envoltorio de ruta; este cuerpo no hace rewrite ni comprueba +// la sesión. Datos: GET admin/events (todos, incluidos draft y archivados). +import '../../../styles/global.css'; +import EventsAdmin from '../../../layouts/EventsAdmin.astro'; +import AdminNotices from '../../../components/events/admin/AdminNotices.astro'; +import { + adminFetch, adminHref, fmtAdminDate, PHASE_LABELS, STATUS_LABELS, + type AdminEventSummary, type StaffSession, +} from '../../../lib/eventsAdmin'; +import { readFlash } from '../../../lib/eventsAdminForms'; + +interface Props { + staff: StaffSession; + cookie: string; +} +const { staff, cookie } = Astro.props; + +const filtro = Astro.url.searchParams.get('filtro') === 'archivados' ? 'archivados' + : Astro.url.searchParams.get('filtro') === 'todos' ? 'todos' : 'activos'; +const search = filtro === 'todos' ? '' : `?archived=${filtro === 'archivados'}`; +const res = await adminFetch(cookie, 'admin/events', { search }); +const events = (res.data ?? []).slice().sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1)); +const flash = readFlash(Astro.url); +const FILTERS = [ + { key: 'activos', label: 'Activos' }, + { key: 'archivados', label: 'Archivados' }, + { key: 'todos', label: 'Todos' }, +]; +--- + + + + +
+ + Nuevo evento +
+ + {res.ok && events.length === 0 && ( +

No hay eventos {filtro === 'activos' ? 'activos' : filtro === 'archivados' ? 'archivados' : ''}.

+ )} + + {events.length > 0 && ( +
+ + + + + + + + + + + + + + + {events.map((e) => ( + + + + + + + + + + + ))} + +
EventoEstadoInscritosEquiposEntregasVotosAvisosActualizado
+ {e.name} +
{e.slug} · {e.kind} · {e.format === 'team' ? 'equipos' : 'individual'}
+
+ {STATUS_LABELS[e.status] ?? e.status} + {e.archived_at && archivado} +
{PHASE_LABELS[e.phase] ?? e.phase}
+
{e.counts.registered}{e.counts.reserve ? +{e.counts.reserve} reserva : null}{e.counts.teams}{e.counts.submissions}{e.counts.votes}{e.warnings.length ? {e.warnings.length} : }{fmtAdminDate(e.updated_at)}
+
+ )} +
diff --git a/src/pages/events/admin/_nuevo.astro b/src/pages/events/admin/_nuevo.astro new file mode 100644 index 0000000..0b13cc8 --- /dev/null +++ b/src/pages/events/admin/_nuevo.astro @@ -0,0 +1,31 @@ +--- +// Alta de un evento (SPEC v3 §8, W-03): formulario del event.json con los +// valores del ejemplo v3; "Simular" valida sin crear. El POST lo procesa +// el envoltorio de ruta y llega aquí como `outcome`. +import '../../../styles/global.css'; +import EventsAdmin from '../../../layouts/EventsAdmin.astro'; +import AdminNotices from '../../../components/events/admin/AdminNotices.astro'; +import EventForm from '../../../components/events/admin/EventForm.astro'; +import type { StaffSession } from '../../../lib/eventsAdmin'; +import { eventToForm, NEW_EVENT_DEFAULTS, type FormOutcome } from '../../../lib/eventsAdminForms'; + +interface Props { + staff: StaffSession; + cookie: string; + outcome: FormOutcome; +} +const { staff, outcome } = Astro.props; +const values = outcome.values ?? eventToForm(NEW_EVENT_DEFAULTS); +--- + + +

+ El evento se crea en borrador: no es visible hasta pasarlo a inscripción. Todo se puede cambiar después desde su ficha. +

+ + +
diff --git a/src/pages/events/admin/index.astro b/src/pages/events/admin/index.astro new file mode 100644 index 0000000..b660f60 --- /dev/null +++ b/src/pages/events/admin/index.astro @@ -0,0 +1,14 @@ +--- +// Envoltorio de ruta del panel (SPEC v3 §8): la guardia de staff se resuelve +// AQUÍ (fichero de ruta) para que el 404 funcione; el cuerpo vive en el +// fichero _ vecino, fuera del árbol de rutas (ver routeWrappers.test.ts). +import Page from './_index.astro'; +import { resolveAdminRoute } from '../../../lib/eventsAdmin'; + +export const prerender = false; + +const route = await resolveAdminRoute(Astro); +if (route.notFound) return route.notFound; +--- + + diff --git a/src/pages/events/admin/nuevo.astro b/src/pages/events/admin/nuevo.astro new file mode 100644 index 0000000..4c31e06 --- /dev/null +++ b/src/pages/events/admin/nuevo.astro @@ -0,0 +1,16 @@ +--- +// Envoltorio de ruta del alta de eventos (SPEC v3 §8, W-03): guardia de +// staff y procesado del POST aquí (fichero de ruta), cuerpo en _nuevo.astro. +import Page from './_nuevo.astro'; +import { resolveAdminRoute } from '../../../lib/eventsAdmin'; +import { handleNewEventForm } from '../../../lib/eventsAdminForms'; + +export const prerender = false; + +const route = await resolveAdminRoute(Astro); +if (route.notFound) return route.notFound; +const outcome = await handleNewEventForm(Astro.request, route.cookie); +if (outcome.redirect) return Astro.redirect(outcome.redirect, 303); +--- + + diff --git a/src/styles/events-admin.css b/src/styles/events-admin.css new file mode 100644 index 0000000..d9de0e4 --- /dev/null +++ b/src/styles/events-admin.css @@ -0,0 +1,167 @@ +/* + * Piezas comunes del panel de administración de eventos (SPEC v3 §8). + * CSS plano (sin @apply) con la paleta neutral/violeta del resto de la web; + * el contenido de cada pantalla llega por slot, así que las clases son + * globales con prefijo `adm-`. + */ + +.adm-card { + margin-top: 2rem; + border: 1px solid #262626; + border-radius: 0.75rem; + background: rgb(23 23 23 / 0.3); + padding: 1.5rem; +} +.adm-h2 { + font-family: var(--font-mono); + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #737373; +} +.adm-h2 + p { margin-top: 0.5rem; } +.adm-grid { + margin-top: 1rem; + display: grid; + gap: 1rem; +} +@media (min-width: 640px) { + .adm-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .adm-grid > .adm-span { grid-column: 1 / -1; } +} +.adm-label { + display: block; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #737373; +} +.adm-input { + margin-top: 0.25rem; + width: 100%; + border: 1px solid #404040; + border-radius: 0.375rem; + background: #0a0a0a; + padding: 0.5rem 0.75rem; + font-family: var(--font-mono); + font-size: 0.875rem; + color: #f5f5f5; +} +.adm-input:focus { outline: none; border-color: #a78bfa; } +.adm-input:disabled { opacity: 0.5; } +textarea.adm-input { min-height: 5.5rem; resize: vertical; } +.adm-check { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: var(--font-mono); + font-size: 0.875rem; + color: #d4d4d4; +} +.adm-check input { width: 1rem; height: 1rem; accent-color: #8b5cf6; } +.adm-checks { margin-top: 0.5rem; display: grid; gap: 0.5rem; } +.adm-actions { + margin-top: 1.5rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} +.adm-btn { + border: 1px solid #404040; + border-radius: 0.375rem; + padding: 0.5rem 1rem; + font-family: var(--font-mono); + font-size: 0.875rem; + color: #e5e5e5; + background: transparent; + cursor: pointer; + transition: border-color 150ms, color 150ms, background 150ms; +} +.adm-btn:hover { border-color: #a78bfa; color: #c4b5fd; } +.adm-btn:disabled { opacity: 0.5; cursor: default; } +.adm-btn-primary { border-color: #8b5cf6; background: #7c3aed; color: #fff; } +.adm-btn-primary:hover { background: #8b5cf6; color: #fff; } +.adm-btn-danger { border-color: #7f1d1d; color: #fca5a5; } +.adm-btn-danger:hover { border-color: #ef4444; color: #fecaca; } +.adm-msg { + margin-top: 1.5rem; + border: 1px solid #404040; + border-radius: 0.375rem; + padding: 0.75rem 1rem; + font-family: var(--font-mono); + font-size: 0.875rem; + color: #d4d4d4; +} +.adm-msg ul { margin: 0.25rem 0 0 1rem; list-style: disc; } +.adm-msg-ok { border-color: #065f46; background: rgb(2 44 34 / 0.4); color: #a7f3d0; } +.adm-msg-warn { border-color: #92400e; background: rgb(69 26 3 / 0.4); color: #fde68a; } +.adm-msg-error { border-color: #7f1d1d; background: rgb(69 10 10 / 0.4); color: #fecaca; } +.adm-table-wrap { margin-top: 1rem; overflow-x: auto; } +.adm-table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-mono); + font-size: 0.875rem; + font-variant-numeric: tabular-nums; +} +.adm-table th { + border-bottom: 1px solid #262626; + padding: 0.5rem 1rem 0.5rem 0; + text-align: left; + font-size: 11px; + font-weight: 400; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #737373; + white-space: nowrap; +} +.adm-table td { + border-bottom: 1px solid #171717; + padding: 0.5rem 1rem 0.5rem 0; + vertical-align: top; + color: #d4d4d4; +} +.adm-table a { color: #f5f5f5; } +.adm-table a:hover { color: #a78bfa; } +.adm-pill { + display: inline-block; + border: 1px solid #404040; + border-radius: 9999px; + padding: 0.125rem 0.5rem; + font-size: 11px; + color: #d4d4d4; + white-space: nowrap; +} +.adm-pill-warn { border-color: #92400e; color: #fde68a; } +.adm-muted { color: #737373; } +.adm-filters { display: flex; flex-wrap: wrap; gap: 1rem; font-family: var(--font-mono); font-size: 0.75rem; } +.adm-filters a { color: #a3a3a3; } +.adm-filters a:hover { color: #a78bfa; } +.adm-filters [aria-current="page"] { color: #fff; } +.adm-facts { margin-top: 1rem; display: grid; gap: 0.75rem 1.5rem; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); font-family: var(--font-mono); font-size: 0.875rem; } +.adm-facts dt { font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: #737373; } +.adm-facts dd { margin: 0.125rem 0 0; color: #f5f5f5; } + +/* Bloques plegables (alta, importación) y formularios en línea de la tabla */ +.adm-details { margin-top: 1.5rem; border: 1px solid #262626; border-radius: 0.5rem; background: #0a0a0a; } +.adm-details > summary { cursor: pointer; padding: 0.875rem 1.25rem; font-family: var(--font-mono); font-size: 0.875rem; color: #e5e5e5; list-style: none; } +.adm-details > summary::-webkit-details-marker { display: none; } +.adm-details > summary::before { content: '▸ '; color: #8b5cf6; } +.adm-details[open] > summary::before { content: '▾ '; } +.adm-details > .adm-details-body { padding: 0 1.25rem 1.25rem; } +.adm-inline { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 0.375rem; margin: 0; } +.adm-btn-sm { padding: 0.25rem 0.625rem; font-size: 12px; } +.adm-table td.adm-actions-cell { white-space: normal; min-width: 14rem; } + +/* Tablero de equipos (W-06): una columna por equipo más la de "sin equipo". */ +.adm-board { margin-top: 1.5rem; display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); align-items: start; } +.adm-col { display: flex; flex-direction: column; gap: 0.75rem; padding: 1rem; border: 1px solid #262626; border-radius: 0.5rem; background: #0a0a0a; } +.adm-col-free { border-style: dashed; } +.adm-col h3 { margin: 0; font-family: var(--font-mono); font-size: 0.875rem; color: #f5f5f5; } +.adm-members { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.5rem; font-size: 13px; color: #e5e5e5; } +.adm-members li { padding-top: 0.5rem; border-top: 1px solid #1f1f1f; } +.adm-members li form { margin-top: 0.25rem; } +.adm-col-foot { margin-top: auto; padding-top: 0.75rem; border-top: 1px solid #262626; display: grid; gap: 0.5rem; } +.adm-input-sm { padding: 0.25rem 0.5rem; font-size: 12px; } diff --git a/src/styles/nan-system.css b/src/styles/nan-system.css index 70abd79..0d02779 100644 --- a/src/styles/nan-system.css +++ b/src/styles/nan-system.css @@ -21,12 +21,28 @@ Quedan sueltos algunos 520/720/760/900 de la primera iteración; migrarlos pide un repaso visual ancho a ancho, no un buscar-y-reemplazar. */ +/* BASE EN CAPA. El reset y los estilos de elemento (p, a, button…) van dentro + de @layer base para que las utilidades de Tailwind (que viven en la capa + utilities, siempre posterior) puedan pisarlos: sin la capa, una regla sin + capa como `* { margin: 0 }` o `p { color: … }` gana a cualquier utilidad por + muy específica que sea, y las pantallas escritas con utilidades (eventos, + community) pierden márgenes, centrado, colores y monoespaciada. Para el + resto del CSS (scoped, sin capa) no cambia nada: ya ganaba a estas reglas. + La esquina recta se queda FUERA de la capa a propósito: es regla de la casa + y debe ganar también a cualquier rounded-* que quede por ahí. */ +*, +*::before, +*::after { + border-radius: var(--radius-ui); +} + +@layer base { + *, *::before, *::after { box-sizing: border-box; margin: 0; - border-radius: var(--radius-ui); } html { @@ -66,6 +82,8 @@ body { } main, footer { position: relative; z-index: 1; } +} /* @layer base (sigue más abajo con p, a, button…) */ + /* Titulares: Archivo variable EXPANDIDO (wdth+wght) y en mayúsculas. Sistema UNIFORME en toda la web (mismos tamaños). El !important gana a los estilos scoped de cada componente (clase + atributo de Astro > h1 pelado). */ @@ -105,6 +123,8 @@ main, footer { position: relative; z-index: 1; } /* segunda línea de un titular en violeta (como "Start burning tokens") */ .hl { color: var(--color-violet-2); } +@layer base { + strong, b { font-weight: 700; } /* párrafo de la casa: Archivo, anchura por defecto, blanco */ p { color: var(--color-text); font-family: var(--font-serif); font-variation-settings: normal; } @@ -115,6 +135,8 @@ code, pre, kbd { font-family: var(--font-mono); font-size: 0.9em; } img, svg { max-width: 100%; display: block; } button { font: inherit; cursor: pointer; border: none; background: none; color: inherit; } +} /* @layer base */ + ::selection { background: var(--color-violet); color: #fff; } /* Anillo de foco en violeta CLARO: el violeta base sobre off-black se queda en 3.4:1 y encima desaparece sobre los paneles con tinte violeta. */ diff --git a/src/tests/api/events.test.ts b/src/tests/api/events.test.ts index d6de49a..8a1e70e 100644 --- a/src/tests/api/events.test.ts +++ b/src/tests/api/events.test.ts @@ -3,12 +3,31 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; // Mock de cloudflare:workers env (patrón del repo). vi.mock('cloudflare:workers', () => ({ env: { CLOUD_API_URL: 'https://api.test' } })); -import { isAdminPath, backendURL } from '../../lib/events'; +import { isAdminPath, backendURL, cookieHeaderHasSession, hasSessionCookie } from '../../lib/events'; import { GET, POST } from '../../pages/api/events/[...path]'; import { POST as LOGIN_POST } from '../../pages/api/auth/login-request'; describe('events proxy lib', () => { - it('bloquea paths admin, globales y por evento (SPEC §8.1)', () => { + it('cookieHeaderHasSession compares the cookie name, not a substring', () => { + expect(cookieHeaderHasSession('basura=xx-nan_session-xx')).toBe(false); + expect(cookieHeaderHasSession('no_es_nan_session_de_verdad=1')).toBe(false); + expect(cookieHeaderHasSession('a=1; nan_session=abc')).toBe(true); + expect(cookieHeaderHasSession('')).toBe(false); + }); + + it('hasSessionCookie mira el nombre de la cookie, no la subcadena', () => { + const req = (cookie?: string) => new Request('https://nan.builders/api/events/admin', { headers: cookie ? { cookie } : {} }); + expect(hasSessionCookie(req())).toBe(false); + expect(hasSessionCookie(req('nan_session=abc'))).toBe(true); + expect(hasSessionCookie(req('otra=1; nan_session=abc; mas=2'))).toBe(true); + expect(hasSessionCookie(req(' nan_session=abc'))).toBe(true); + // Señuelos: el texto aparece, la cookie no. + expect(hasSessionCookie(req('basura=xx-nan_session-xx'))).toBe(false); + expect(hasSessionCookie(req('no_es_nan_session_de_verdad=1'))).toBe(false); + expect(hasSessionCookie(req('nan_session_old=1'))).toBe(false); + }); + + it('detecta paths admin, globales y por evento (SPEC v3 §8)', () => { expect(isAdminPath('admin')).toBe(true); expect(isAdminPath('admin/reload')).toBe(true); expect(isAdminPath('gauntlet-2026-08/admin')).toBe(true); @@ -98,15 +117,95 @@ function ctx(path: string, init?: { method?: string; cookie?: string; ip?: strin describe('events proxy handler', () => { afterEach(() => vi.restoreAllMocks()); - it('responde 404 a paths admin sin llamar al backend', async () => { + it('responde 404 a paths admin sin cookie de sesión, sin llamar al backend', async () => { const spy = vi.spyOn(globalThis, 'fetch'); expect((await GET(ctx('admin/reload'))).status).toBe(404); - const resp = await GET(ctx('gauntlet-2026-08/admin/state')); + const resp = await GET(ctx('gauntlet-2026-08/admin/state', { cookie: 'otra=1' })); expect(resp.status).toBe(404); + // El texto `nan_session` dentro de otra cookie no es la cookie de sesión. + expect((await GET(ctx('admin/events', { cookie: 'basura=xx-nan_session-xx' }))).status).toBe(404); expect(spy).not.toHaveBeenCalled(); expect(await resp.json()).toEqual({ ok: false, error: 'not_found' }); }); + it('deja pasar paths admin con cookie de sesión y nunca reenvía la admin key (SPEC v3 §8)', async () => { + const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{"ok":false,"error":"forbidden"}', { status: 403 })); + const headers = new Headers({ cookie: 'nan_session=xyz', 'x-hackaton-admin-key': 'no-debe-pasar', 'x-hackaton-actor': 'x' }); + const request = new Request('https://nan.builders/api/events/gauntlet-2026-08/admin/state', { + method: 'POST', headers, body: '{"status":"registration","dry_run":true}', + }); + const resp = await POST({ params: { path: 'gauntlet-2026-08/admin/state' }, request, url: new URL(request.url) } as never); + // La autorización la decide el backend: el proxy propaga su respuesta tal cual. + expect(resp.status).toBe(403); + expect(spy).toHaveBeenCalledOnce(); + const [target, reqInit] = spy.mock.calls[0] as [string, RequestInit]; + expect(target).toBe('https://api.test/api/events/gauntlet-2026-08/admin/state'); + const h = reqInit.headers as Headers; + expect(h.get('cookie')).toBe('nan_session=xyz'); + expect(h.get('origin')).toBe('https://nan.builders'); + expect(h.get('x-hackaton-admin-key')).toBeNull(); + expect(h.get('x-hackaton-actor')).toBeNull(); + expect(reqInit.body).toBe('{"status":"registration","dry_run":true}'); + }); + + it('conserva content-type y Content-Disposition (CSV de import/export del panel)', async () => { + const upstream = new Response('email,name\n', { + status: 200, + headers: { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': 'attachment; filename="p.csv"' }, + }); + const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(upstream); + const headers = new Headers({ cookie: 'nan_session=xyz', 'content-type': 'text/csv' }); + const request = new Request('https://nan.builders/api/events/gauntlet-2026-08/admin/participants/import?dry_run=true', { + method: 'POST', headers, body: 'email\na@b.c\n', + }); + const resp = await POST({ params: { path: 'gauntlet-2026-08/admin/participants/import' }, request, url: new URL(request.url) } as never); + const [target, reqInit] = spy.mock.calls[0] as [string, RequestInit]; + expect(target).toBe('https://api.test/api/events/gauntlet-2026-08/admin/participants/import?dry_run=true'); + expect((reqInit.headers as Headers).get('content-type')).toBe('text/csv'); + expect(resp.headers.get('content-type')).toBe('text/csv; charset=utf-8'); + expect(resp.headers.get('content-disposition')).toBe('attachment; filename="p.csv"'); + expect(await resp.text()).toBe('email,name\n'); + }); + + it('forwards an empty CSV body as is instead of padding it with {}', async () => { + const upstream = new Response('', { + status: 200, + headers: { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': 'attachment; filename="p.csv"' }, + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(upstream); + const resp = await GET(ctx('gauntlet-2026-08/admin/participants/export.csv', { cookie: 'nan_session=xyz', search: '?download=1' })); + expect(resp.headers.get('content-type')).toBe('text/csv; charset=utf-8'); + expect(await resp.text()).toBe(''); + // The JSON fallback is untouched: an empty JSON upstream still parses. + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 200 })); + const empty = await GET(ctx('gauntlet-2026-08/me', { cookie: 'nan_session=xyz' })); + expect(await empty.text()).toBe('{}'); + }); + + it('deja pasar el feed iCalendar con su content-type y su caché (W-10)', async () => { + const upstream = new Response('BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n', { + status: 200, + headers: { 'content-type': 'text/calendar; charset=utf-8', 'cache-control': 'public, max-age=300', 'content-disposition': 'inline; filename="nan-eventos.ics"' }, + }); + const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(upstream); + const resp = await GET(ctx('calendar.ics')); + expect(spy.mock.calls[0][0]).toBe('https://api.test/api/events/calendar.ics'); + expect(resp.headers.get('content-type')).toBe('text/calendar; charset=utf-8'); + expect(resp.headers.get('cache-control')).toBe('public, max-age=300'); + expect(resp.headers.get('content-disposition')).toBe('inline; filename="nan-eventos.ics"'); + expect(await resp.text()).toBe('BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n'); + // Por evento, mismo camino. + expect(backendURL('taller-agentes/calendar.ics', '')).toBe('https://api.test/api/events/taller-agentes/calendar.ics'); + }); + + it('sigue enviando JSON por defecto a las rutas públicas', async () => { + const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{"ok":true}', { status: 200 })); + const resp = await POST(ctx('gauntlet-2026-08/register', { method: 'POST', body: '{}' })); + expect((spy.mock.calls[0][1] as RequestInit).headers as Headers).toBeInstanceOf(Headers); + expect(((spy.mock.calls[0][1] as RequestInit).headers as Headers).get('content-type')).toBe('application/json'); + expect(resp.headers.get('content-type')).toBe('application/json'); + }); + it('responde 404 a una ruta fuera del prefijo sin llamar al backend', async () => { const spy = vi.spyOn(globalThis, 'fetch'); // Importa que no llegue a llamar: el proxy adjunta la cookie del visitante. diff --git a/src/tests/layouts/NanBase.test.ts b/src/tests/layouts/NanBase.test.ts index 45feeaa..fa4eb5d 100644 --- a/src/tests/layouts/NanBase.test.ts +++ b/src/tests/layouts/NanBase.test.ts @@ -63,6 +63,15 @@ describe('NanBase — accesibilidad y SEO que no deben desaparecer', () => { expect(nanBase).toContain('id="main"'); }); + it('lets a page replace the social image and NanPage forwards it (event covers, B-27)', () => { + expect(nanBase).toContain('image?: string'); + expect(nanBase).toContain('const ogImage = image || absAsset('); + expect(nanBase).toContain(''); + expect(nanBase).toContain(''); + expect(nanPage).toContain('image?: string'); + expect(nanPage).toContain('image={image}'); + }); + it('emite canonical y alternates hreflang', () => { expect(nanBase).toContain('rel="canonical"'); expect(nanBase).toContain('hreflang'); diff --git a/src/tests/lib/adminRoutes.test.ts b/src/tests/lib/adminRoutes.test.ts new file mode 100644 index 0000000..750bd57 --- /dev/null +++ b/src/tests/lib/adminRoutes.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'vitest'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { basename, dirname, join, resolve } from 'node:path'; + +/** + * Guarda del panel de administración (SPEC v3 §8). + * + * Cada FICHERO DE RUTA bajo `src/pages/events/admin` resuelve la guardia de + * staff con `resolveAdminRoute(Astro)` y devuelve el 404 si toca; el cuerpo + * (`_x.astro`) recibe `staff` y `cookie` como props y no toca la sesión. Si + * una ruta nueva se olvida de la guardia, cualquier visitante vería el + * esqueleto de la pantalla. El panel es SSR (`prerender = false`) y solo + * existe en español: no hay `src/pages/es/events/admin`. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const adminDir = resolve(here, '../../pages/events/admin'); + +function astroFiles(dir: string, found: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) astroFiles(full, found); + else if (entry.endsWith('.astro')) found.push(full); + } + return found; +} + +const all = astroFiles(adminDir); +const routes = all.filter((f) => !basename(f).startsWith('_')); +const bodies = all.filter((f) => basename(f).startsWith('_')); + +describe('rutas de /events/admin', () => { + test('hay rutas y cuerpos que revisar', () => { + expect(routes.length).toBeGreaterThan(0); + expect(bodies.length).toBeGreaterThan(0); + }); + + test.each(routes)('%s resuelve la guardia de staff y es SSR', (route) => { + const src = readFileSync(route, 'utf-8'); + // Rutas globales: resolveAdminRoute; rutas por evento: resolveAdminEventRoute (añade la ficha y el 404 de slug). + expect(src).toMatch(/const route = await resolveAdmin(Event)?Route\(Astro\);/); + expect(src).toMatch(/if \(route\.notFound\) return route\.notFound;/); + expect(src).toMatch(/export const prerender = false;/); + expect(src).toMatch(/staff=\{route\.staff\}/); + expect(src).toMatch(/cookie=\{route\.cookie\}/); + }); + + test.each(bodies)('%s no hace rewrite ni consulta la sesión', (body) => { + const src = readFileSync(body, 'utf-8'); + expect(src).not.toMatch(/Astro\.rewrite/); + expect(src).not.toMatch(/resolveAdmin(Event)?Route/); + expect(src).not.toMatch(/fetchStaffSession/); + expect(src).not.toMatch(/handle\w+Form\(/); + }); + + test('el panel no tiene variante /es/', () => { + expect(existsSync(resolve(here, '../../pages/es/events/admin'))).toBe(false); + }); +}); diff --git a/src/tests/lib/eventsAdmin.test.ts b/src/tests/lib/eventsAdmin.test.ts new file mode 100644 index 0000000..5f2c1f8 --- /dev/null +++ b/src/tests/lib/eventsAdmin.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +// Mock de cloudflare:workers env (patrón del repo). +vi.mock('cloudflare:workers', () => ({ env: { CLOUD_API_URL: 'https://api.test/' } })); + +import { adminFetch, adminHref, fetchStaffSession, reloadAdminEventView, resolveAdminEventRoute, resolveAdminRoute } from '../../lib/eventsAdmin'; + +const me = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + +describe('fetchStaffSession (guardia SSR, SPEC v3 §8)', () => { + afterEach(() => vi.restoreAllMocks()); + + it('sin cookie de sesión no llama a la plataforma', async () => { + const spy = vi.spyOn(globalThis, 'fetch'); + expect(await fetchStaffSession('')).toBeNull(); + expect(await fetchStaffSession('otra=1')).toBeNull(); + expect(await fetchStaffSession('basura=xx-nan_session-xx')).toBeNull(); + expect(spy).not.toHaveBeenCalled(); + }); + + it('staff → sesión; reenvía la cookie y el Origin a /api/auth/me', async () => { + const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(me({ role: 'staff', email: 'a@nan.builders', userUUID: 'u1' })); + expect(await fetchStaffSession('nan_session=xyz')).toEqual({ email: 'a@nan.builders', userUUID: 'u1' }); + const [target, init] = spy.mock.calls[0] as [string, RequestInit]; + expect(target).toBe('https://api.test/api/auth/me'); + const h = init.headers as Record; + expect(h.cookie).toBe('nan_session=xyz'); + expect(h.origin).toBe('https://nan.builders'); + }); + + it('miembro, sesión caducada, respuesta rara o fallo de red → null', async () => { + const cases: Array<() => Promise> = [ + () => Promise.resolve(me({ role: 'member', email: 'm@x.y' })), + () => Promise.resolve(me({ ok: false }, 401)), + () => Promise.resolve(new Response('no json', { status: 200 })), + () => Promise.reject(new Error('boom')), + ]; + for (const impl of cases) { + vi.spyOn(globalThis, 'fetch').mockImplementation(impl as never); + expect(await fetchStaffSession('nan_session=xyz')).toBeNull(); + vi.restoreAllMocks(); + } + }); +}); + +describe('resolveAdminRoute', () => { + afterEach(() => vi.restoreAllMocks()); + + const ctx = (cookie?: string) => { + const headers = new Headers(); + if (cookie) headers.set('cookie', cookie); + const rewrite = vi.fn(async (to: string) => new Response(`rewrite:${to}`, { status: 404 })); + return { astro: { request: new Request('https://nan.builders/events/admin', { headers }), rewrite }, rewrite }; + }; + + it('staff: devuelve la sesión y la cookie sin rewrite', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(me({ role: 'staff', email: 's@nan.builders', userUUID: 'u' })); + const { astro, rewrite } = ctx('nan_session=abc'); + const route = await resolveAdminRoute(astro); + expect(route.notFound).toBeNull(); + expect(route.staff?.email).toBe('s@nan.builders'); + expect(route.cookie).toBe('nan_session=abc'); + expect(rewrite).not.toHaveBeenCalled(); + }); + + it('no staff: 404 por rewrite, nunca 403 (el panel no se anuncia)', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(me({ role: 'member' })); + const { astro, rewrite } = ctx('nan_session=abc'); + const route = await resolveAdminRoute(astro); + expect(route.staff).toBeNull(); + expect(rewrite).toHaveBeenCalledWith('/404'); + expect(await route.notFound?.text()).toBe('rewrite:/404'); + }); + + it('sin cookie: 404 sin llamar a la plataforma', async () => { + const spy = vi.spyOn(globalThis, 'fetch'); + const { astro, rewrite } = ctx(); + const route = await resolveAdminRoute(astro); + expect(route.staff).toBeNull(); + expect(spy).not.toHaveBeenCalled(); + expect(rewrite).toHaveBeenCalledWith('/404'); + }); +}); + +describe('resolveAdminEventRoute / reloadAdminEventView', () => { + afterEach(() => vi.restoreAllMocks()); + + const staff = { role: 'staff', email: 's@nan.builders', userUUID: 'u' }; + const ficha = { event: { slug: 'gauntlet-2026-08', name: 'Gauntlet' }, phase: 'building', windows: {}, counts: { registered: 3 }, warnings: [] }; + + // fetch simulado: /api/auth/me y /api/events/{slug}/admin, por URL. + const plataforma = (opts: { me?: unknown; meStatus?: number; admin?: unknown; adminStatus?: number }) => + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + if (url.endsWith('/api/auth/me')) return me(opts.me ?? staff, opts.meStatus ?? 200); + if (url.includes('/admin')) return me(opts.admin ?? { ok: true, data: ficha }, opts.adminStatus ?? 200); + throw new Error(`fetch inesperado: ${url}`); + }); + + const ctx = (slug: string, cookie = 'nan_session=abc') => { + const rewrite = vi.fn(async (to: string) => new Response(`rewrite:${to}`, { status: 404 })); + return { + astro: { request: new Request(`https://nan.builders/events/admin/${slug}`, { headers: { cookie } }), rewrite, params: { slug } }, + rewrite, + }; + }; + + it('staff y evento existente → ficha, slug del backend y sin rewrite', async () => { + const spy = plataforma({}); + const { astro, rewrite } = ctx('gauntlet-2026-08'); + const route = await resolveAdminEventRoute(astro); + expect(route.notFound).toBeNull(); + expect(route.view?.counts.registered).toBe(3); + expect(route.slug).toBe('gauntlet-2026-08'); + expect(rewrite).not.toHaveBeenCalled(); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('staff y evento inexistente → 404 por rewrite', async () => { + plataforma({ admin: { ok: false, error: 'event_not_found' }, adminStatus: 404 }); + const { astro, rewrite } = ctx('nope'); + const route = await resolveAdminEventRoute(astro); + expect(route.view).toBeNull(); + expect(rewrite).toHaveBeenCalledWith('/404'); + expect(route.notFound?.status).toBe(404); + }); + + it('sin staff → 404 sin pedir la ficha', async () => { + const spy = plataforma({ me: { role: 'member' } }); + const { astro, rewrite } = ctx('gauntlet-2026-08'); + const route = await resolveAdminEventRoute(astro); + expect(route.view).toBeNull(); + expect(rewrite).toHaveBeenCalledWith('/404'); + expect(spy).toHaveBeenCalledTimes(1); // solo /api/auth/me + }); + + it('reloadAdminEventView devuelve la ficha nueva, o la anterior si la recarga falla', async () => { + const nueva = { ...ficha, counts: { registered: 4 } }; + plataforma({ admin: { ok: true, data: nueva } }); + expect((await reloadAdminEventView('nan_session=abc', 'gauntlet-2026-08', ficha as never)).counts.registered).toBe(4); + vi.restoreAllMocks(); + plataforma({ admin: { ok: false, error: 'server_error' }, adminStatus: 500 }); + expect((await reloadAdminEventView('nan_session=abc', 'gauntlet-2026-08', ficha as never)).counts.registered).toBe(3); + }); +}); + +describe('adminFetch (cliente SSR de la API de administración)', () => { + afterEach(() => vi.restoreAllMocks()); + + it('lee el envelope de éxito con warnings y dry_run', async () => { + const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + me({ ok: true, data: { status: 'registration' }, warnings: ['teams_missing'], dry_run: true }), + ); + const res = await adminFetch<{ status: string }>('nan_session=x', 'demo-2027/admin/state', { + method: 'POST', body: { status: 'registration', dry_run: true }, + }); + expect(res).toMatchObject({ ok: true, status: 200, data: { status: 'registration' }, warnings: ['teams_missing'], dryRun: true, fields: [] }); + const [target, init] = spy.mock.calls[0] as [string, RequestInit]; + expect(target).toBe('https://api.test/api/events/demo-2027/admin/state'); + expect(init.method).toBe('POST'); + expect(init.body).toBe('{"status":"registration","dry_run":true}'); + const h = init.headers as Record; + expect(h['content-type']).toBe('application/json'); + expect(h.cookie).toBe('nan_session=x'); + expect(h.origin).toBe('https://nan.builders'); + }); + + it('lee el envelope de error con message, fields y detail', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + me({ ok: false, error: 'validation_failed', message: 'datos inválidos', data: { fields: ['slug'], detail: 'slug ocupado' } }, 400), + ); + const res = await adminFetch('nan_session=x', 'admin/events', { method: 'POST', body: { slug: 'X' } }); + expect(res).toMatchObject({ ok: false, status: 400, data: null, error: 'validation_failed', message: 'datos inválidos', fields: ['slug'], detail: 'slug ocupado' }); + }); + + it('un cuerpo de texto va como text/csv (import de participantes) y conserva la query', async () => { + const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(me({ ok: true, data: { rows: [] }, warnings: [], dry_run: true })); + await adminFetch('nan_session=x', 'demo-2027/admin/participants/import', { method: 'POST', body: 'email\na@b.c\n', search: '?dry_run=true' }); + const [target, init] = spy.mock.calls[0] as [string, RequestInit]; + expect(target).toBe('https://api.test/api/events/demo-2027/admin/participants/import?dry_run=true'); + expect((init.headers as Record)['content-type']).toBe('text/csv'); + expect(init.body).toBe('email\na@b.c\n'); + }); + + it('no sale del prefijo ni llama al backend con una ruta rara', async () => { + const spy = vi.spyOn(globalThis, 'fetch'); + expect((await adminFetch('nan_session=x', '../auth/me')).error).toBe('not_found'); + expect((await adminFetch('nan_session=x', 'demo%2fadmin')).error).toBe('not_found'); + expect((await adminFetch('nan_session=x', '')).error).toBe('not_found'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('fallo de red y respuesta no JSON no lanzan', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('boom')); + expect(await adminFetch('nan_session=x', 'admin/events')).toMatchObject({ ok: false, status: 0, error: 'server_error' }); + vi.restoreAllMocks(); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 502 })); + expect(await adminFetch('nan_session=x', 'admin/events')).toMatchObject({ ok: false, status: 502, error: 'server_error' }); + }); +}); + +describe('adminHref', () => { + it('lista, ficha y pantallas por evento', () => { + expect(adminHref()).toBe('/events/admin'); + expect(adminHref('demo-2027')).toBe('/events/admin/demo-2027'); + expect(adminHref('demo-2027', 'equipos')).toBe('/events/admin/demo-2027/equipos'); + expect(adminHref('demo-2027', 'auditoria')).toBe('/events/admin/demo-2027/auditoria'); + }); +}); diff --git a/src/tests/lib/eventsAdminAudit.test.ts b/src/tests/lib/eventsAdminAudit.test.ts new file mode 100644 index 0000000..bc7af5a --- /dev/null +++ b/src/tests/lib/eventsAdminAudit.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +vi.mock('cloudflare:workers', () => ({ env: { CLOUD_API_URL: 'https://api.test' } })); + +import { + actorLabel, auditSearch, backupDate, fmtBytes, handleAuditForm, readAuditFilters, + AUDIT_DEFAULT_LIMIT, AUDIT_MAX_LIMIT, +} from '../../lib/eventsAdminAudit'; + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); +const PAGE = 'https://nan.builders/events/admin/demo/auditoria'; + +function post(fields: Record): Request { + return new Request(PAGE, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://nan.builders' }, + body: new URLSearchParams(fields).toString(), + }); +} + +function lastCall(spy: ReturnType) { + const [url, init] = spy.mock.calls.at(-1) as [string, RequestInit]; + return { url, init, body: JSON.parse(String(init.body)) as Record }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('filtros de auditoría (W-09)', () => { + it('sin parámetros: sin since ni action y el límite por defecto del backend', () => { + const f = readAuditFilters(new URL(PAGE)); + expect(f).toEqual({ since: '', action: '', limit: AUDIT_DEFAULT_LIMIT }); + expect(auditSearch(f)).toBe(`?limit=${AUDIT_DEFAULT_LIMIT}`); + }); + + it('desde acepta datetime-local (UTC) y fecha suelta; acción y límite se acotan', () => { + const f = readAuditFilters(new URL(`${PAGE}?desde=2026-09-05T10:30&accion=state.&limite=5000`)); + expect(f).toEqual({ since: '2026-09-05T10:30:00.000Z', action: 'state.', limit: AUDIT_MAX_LIMIT }); + expect(auditSearch(f)).toBe(`?since=2026-09-05T10%3A30%3A00.000Z&action=state.&limit=${AUDIT_MAX_LIMIT}`); + expect(readAuditFilters(new URL(`${PAGE}?desde=2026-09-05`)).since).toBe('2026-09-05T00:00:00.000Z'); + }); + + it('lo que no vale se ignora en vez de provocar un 400', () => { + const f = readAuditFilters(new URL(`${PAGE}?desde=ayer&accion=DROP%20TABLE&limite=-3`)); + expect(f).toEqual({ since: '', action: '', limit: AUDIT_DEFAULT_LIMIT }); + }); +}); + +describe('textos auxiliares', () => { + it('actor, tamaño y fecha del backup', () => { + expect(actorLabel(null)).toBe('sistema'); + expect(actorLabel({ kind: 'staff', email: 'a@nan.builders', user_uuid: 'u1' })).toBe('a@nan.builders'); + expect(actorLabel({ kind: 'admin_key', label: 'saul-curl' })).toBe('saul-curl (clave de admin)'); + expect(actorLabel({ kind: 'admin_key' })).toBe('clave de admin'); + expect(fmtBytes(512)).toBe('512 B'); + expect(fmtBytes(1616)).toBe('1.6 KB'); + expect(fmtBytes(3 * 1024 * 1024)).toBe('3.0 MB'); + expect(backupDate('20260905T100000Z-2')).toBe('2026-09-05T10:00:00Z'); + expect(backupDate('raro')).toBe(''); + }); +}); + +describe('handleAuditForm (W-09)', () => { + it('ignora el GET y rechaza otro origen', async () => { + expect(await handleAuditForm(new Request(PAGE), 'c', 'demo')).toEqual({}); + const foreign = new Request(PAGE, { method: 'POST', headers: { origin: 'https://evil.test', 'content-type': 'application/x-www-form-urlencoded' }, body: 'action=restore' }); + expect(await handleAuditForm(foreign, 'c', 'demo')).toEqual({ forbidden: true }); + }); + + it('acción desconocida o backup sin indicar: no llama al backend', async () => { + const spy = vi.spyOn(globalThis, 'fetch'); + const unknown = await handleAuditForm(post({ action: 'delete' }), 'c', 'demo'); + expect(unknown.result?.fields).toEqual(['action']); + const missing = await handleAuditForm(post({ action: 'restore', file: 'event.json' }), 'c', 'demo'); + expect(missing.result?.ok).toBe(false); + expect(missing.result?.fields).toEqual(['timestamp']); + expect(spy).not.toHaveBeenCalled(); + }); + + it('previsualizar: POST …/backups/restore con dry_run y se queda en la página', async () => { + const spy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => json({ ok: true, data: { file: 'participants.json', timestamp: '20260905T100000Z' }, warnings: [], dry_run: true })); + const out = await handleAuditForm(post({ action: 'restore_preview', file: 'participants.json', timestamp: '20260905T100000Z' }), 'c', 'demo'); + expect(out.redirect).toBeUndefined(); + expect(out.action).toBe('restore_preview'); + expect(out.result?.ok).toBe(true); + const { url, init, body } = lastCall(spy); + expect(url).toBe('https://api.test/api/events/demo/admin/backups/restore'); + expect(init.method).toBe('POST'); + expect(body).toEqual({ file: 'participants.json', timestamp: '20260905T100000Z', dry_run: true }); + }); + + it('restaurar de verdad redirige con el flash y los avisos', async () => { + const spy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => json({ ok: true, data: { file: 'event.json', timestamp: '20260905T100000Z', previous_backup: '20260906T120000Z' }, warnings: ['automation_dates_missing'] })); + const out = await handleAuditForm(post({ action: 'restore', file: 'event.json', timestamp: '20260905T100000Z' }), 'c', 'demo'); + expect(out.redirect).toBe('/events/admin/demo/auditoria?ok=restaurado&warn=automation_dates_missing'); + expect(lastCall(spy).body).toEqual({ file: 'event.json', timestamp: '20260905T100000Z', dry_run: false }); + }); + + it('un backup que no valida (409) se queda en la página sin redirigir', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => json({ ok: false, error: 'backup_invalid', message: 'el backup no valida' }, 409)); + const out = await handleAuditForm(post({ action: 'restore', file: 'event.json', timestamp: 'x' }), 'c', 'demo'); + expect(out.redirect).toBeUndefined(); + expect(out.result?.error).toBe('backup_invalid'); + }); +}); diff --git a/src/tests/lib/eventsAdminForms.test.ts b/src/tests/lib/eventsAdminForms.test.ts new file mode 100644 index 0000000..f8905c5 --- /dev/null +++ b/src/tests/lib/eventsAdminForms.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +vi.mock('cloudflare:workers', () => ({ env: { CLOUD_API_URL: 'https://api.test' } })); + +import { + doneHref, eventToForm, formToEventBody, formValues, handleEventForm, handleNewEventForm, + isoToLocal, localToIso, NEW_EVENT_DEFAULTS, readFlash, sameOrigin, splitList, stateTargets, statusSequence, +} from '../../lib/eventsAdminForms'; + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + +function post(fields: Record, headers: Record = {}): Request { + const fd = new URLSearchParams(); + for (const [k, v] of Object.entries(fields)) { + for (const item of Array.isArray(v) ? v : [v]) fd.append(k, item); + } + return new Request('https://nan.builders/events/admin/nuevo', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://nan.builders', ...headers }, + body: fd.toString(), + }); +} + +describe('fechas y listas', () => { + it('convierte RFC 3339 ⇄ datetime-local en UTC', () => { + expect(isoToLocal('2027-01-10T00:00:00Z')).toBe('2027-01-10T00:00'); + expect(isoToLocal(null)).toBe(''); + expect(isoToLocal('no es fecha')).toBe(''); + expect(localToIso('2027-01-10T00:00')).toBe('2027-01-10T00:00:00Z'); + expect(localToIso('2027-02-12T18:30')).toBe('2027-02-12T18:30:00Z'); + expect(localToIso('')).toBeNull(); + expect(localToIso('basura')).toBeNull(); + }); + + it('parte listas por comas o saltos de línea sin vacíos ni duplicados', () => { + expect(splitList(' backend, frontend ,\ndevops,backend,, ')).toEqual(['backend', 'frontend', 'devops']); + expect(splitList('')).toEqual([]); + }); +}); + +describe('formToEventBody', () => { + it('produce el event.json de §5.1 a partir de los valores por defecto', () => { + const body = formToEventBody(eventToForm({ ...NEW_EVENT_DEFAULTS, slug: 'demo-2027', name: 'Demo' })) as Record; + expect(body.slug).toBe('demo-2027'); + expect(body.name).toBe('Demo'); + expect(body.kind).toBe('hackathon'); + expect(body.format).toBe('team'); + expect(body.modules).toEqual({ registration: true, teams: true, submissions: true, voting: true }); + expect(body.automation).toEqual({ date_transitions: true }); + expect(body.dates).toEqual({ + registration_open: null, registration_close: null, submission_open: null, submission_close: null, + voting_open: null, voting_close: null, demo_day: null, + }); + expect(body.registration).toEqual({ + capacity: 40, reserve_capacity: 10, discord_user: 'required', + specialties: ['backend', 'frontend', 'devops', 'data'], levels: ['junior', 'mid', 'senior'], + }); + expect(body.team).toEqual({ size: 4, min_size: 3, max_teams: 10 }); + expect(body.submission).toEqual({ + fields: { description: 'optional', repo_url: 'required', space_url: 'optional', image_url: 'optional', video_url: 'optional' }, + checks: ['url_live', 'repo_public'], prize_requires: ['repo_public'], gallery_visibility: 'from_voting', + }); + // auto_max se deriva de los checks que puntúan (url_live sí, repo_public no). + expect(body.voting).toEqual({ enabled: true, vote_weight: 8, auto_max: 1 }); + }); + + it('formato individual: sin bloque team y modules.teams=false; votación apagada apaga modules.voting', () => { + const body = formToEventBody({ + slug: 's', name: 'S', format: 'solo', module_registration: 'on', module_submissions: 'on', + checks: ['url_live', 'in_nan_space'], prize_requires: ['repo_public'], + }) as Record; + expect(body.team).toBeUndefined(); + expect(body.modules).toEqual({ registration: true, teams: false, submissions: true, voting: false }); + expect(body.voting.enabled).toBe(false); + expect(body.voting.auto_max).toBe(2); + // prize_requires solo con checks activos. + expect(body.submission.prize_requires).toEqual([]); + }); + + it('las fechas del formulario van en UTC y los números se truncan', () => { + const body = formToEventBody({ + slug: 's', name: 'S', format: 'team', date_registration_open: '2027-01-10T00:00', date_demo_day: '2027-02-12T18:00', + capacity: '12.7', team_size: '5', team_min_size: '2', team_max_teams: 'x', checks: [], prize_requires: [], + }) as Record; + expect(body.dates.registration_open).toBe('2027-01-10T00:00:00Z'); + expect(body.dates.demo_day).toBe('2027-02-12T18:00:00Z'); + expect(body.dates.voting_close).toBeNull(); + expect(body.registration.capacity).toBe(12); + expect(body.team).toEqual({ size: 5, min_size: 2, max_teams: 0 }); + }); + + it('eventToForm → formToEventBody conserva un event.json real', () => { + const ev = { + ...NEW_EVENT_DEFAULTS, slug: 'gauntlet-2026-08', name: 'Gauntlet', description: 'd', rules: 'r', prize: 'p', + location: 'Madrid', url: 'https://nan.builders/gauntlet', image_url: 'https://nan.builders/img/events/gauntlet.webp', + dates: { registration_open: '2026-08-01T00:00:00Z', registration_close: '2026-08-10T00:00:00Z', submission_open: null, submission_close: '2026-08-20T00:00:00Z', voting_open: null, voting_close: null, demo_day: null, demo_day_end: '2026-08-25T18:00:00Z' }, + submission: { ...NEW_EVENT_DEFAULTS.submission!, checks: ['url_live', 'in_nan_space', 'repo_public'], prize_requires: ['repo_public'] }, + voting: { enabled: true, vote_weight: 8, auto_max: 2 }, + }; + const body = formToEventBody(eventToForm(ev)) as Record; + expect(body.submission.checks).toEqual(ev.submission.checks); + expect(body.voting).toEqual(ev.voting); + expect(body.description).toBe('d'); + // El formulario no expone location, url, image_url ni demo_day_end: NO van en el + // cuerpo, y sobreviven al guardar porque el PUT del backend es un merge + // por claves (UpdateEvent hace json.Unmarshal sobre el evento actual). + // Si algún día el formulario mandara location/url/image_url como cadena + // vacía (null no toca un string en Go) o demo_day_end como null, se + // borrarían; este test es el que lo detectaría. + expect(body).not.toHaveProperty('location'); + expect(body).not.toHaveProperty('url'); + expect(body).not.toHaveProperty('image_url'); + expect(body.dates).not.toHaveProperty('demo_day_end'); + const { demo_day_end: _omitido, ...fechasDelFormulario } = ev.dates; + expect(body.dates).toEqual(fechasDelFormulario); + }); +}); + +describe('formValues', () => { + it('agrupa los checkboxes múltiples y deja el resto como texto', () => { + const fd = new FormData(); + fd.append('name', 'X'); + fd.append('checks', 'url_live'); + fd.append('checks', 'repo_public'); + expect(formValues(fd)).toEqual({ name: 'X', checks: ['url_live', 'repo_public'], prize_requires: [] }); + }); +}); + +describe('sameOrigin y flash', () => { + it('acepta same-origin y rechaza otro sitio', () => { + expect(sameOrigin(new Request('https://nan.builders/x', { method: 'POST' }))).toBe(true); + expect(sameOrigin(new Request('https://nan.builders/x', { method: 'POST', headers: { origin: 'https://nan.builders' } }))).toBe(true); + expect(sameOrigin(new Request('https://nan.builders/x', { method: 'POST', headers: { origin: 'https://evil.test' } }))).toBe(false); + expect(sameOrigin(new Request('https://nan.builders/x', { method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } }))).toBe(false); + expect(sameOrigin(new Request('https://nan.builders/x', { method: 'POST', headers: { 'sec-fetch-site': 'same-origin' } }))).toBe(true); + }); + + it('doneHref y readFlash solo usan claves conocidas', () => { + expect(doneHref('demo', 'creado')).toBe('/events/admin/demo?ok=creado'); + expect(doneHref('demo', 'guardado', ['no_change', 'slug_changed'])).toBe('/events/admin/demo?ok=guardado&warn=slug_changed'); + expect(readFlash(new URL('https://nan.builders/events/admin/demo?ok=guardado&warn=slug_changed,x-y'))).toEqual({ ok: 'Cambios guardados.', warnings: ['slug_changed'] }); + expect(readFlash(new URL('https://nan.builders/events/admin/demo?ok=