+
+ {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) !== '';
+---
+
+
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.
+ ) : (
+
+
+
+ Transición
+
+ {targets.map((t) => (
+
+ {t.move === 'cancel' ? STATE_MOVE_LABELS.cancel : `${STATE_MOVE_LABELS[t.move]} ${label(t.status)}`}
+
+ ))}
+
+
+ Previsualizar avisos
+
+ )}
+
+ 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 && (
+
+
+
+
+ Confirmar: {previewTarget.move === 'cancel' ? 'cancelar el evento' : `pasar a ${label(chosen)}`}
+
+
+ )}
+
+ )}
+
+ {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.'}
+
+ Ejecutar sweep ahora
+
+ )}
+
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());
+---
+
+
+
+
+
+
+
+
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 && (
+
+ )}
+
+ )}
+
+ {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}
-
+
) : 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 &&
}
+
+
+
+
+ Restaurar {fileLabel(preview.file)}
+ Cancelar
+
+
+ )}
+
+
+
+ {!auditRes.ok && }
+
+ {auditRes.ok && entries.length === 0 && No hay líneas de auditoría{filters.since || filters.action ? ' con estos filtros' : ''}.
}
+
+ {entries.length > 0 && (
+
+
+ Fecha Actor Acción Sujeto Avisos Cambio
+
+ {entries.map((e) => (
+
+ {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 && (
+
+
+ Fichero Copia Fecha Tamaño {!archived && }
+
+ {backups.map((b) => (
+
+ {fileLabel(b.file)}
+ {b.timestamp}
+ {fmtAdminDate(backupDate(b.timestamp)) || '—'}
+ {fmtBytes(b.size)}
+ {!archived && (
+
+
+
+
+
+ Previsualizar restauración
+
+
+ )}
+
+ ))}
+
+
+
+ )}
+
+
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 && (
+
+
+ Verificar todas
+
+ )}
+
+ )}
+
+ {res && !res.ok && }
+ {res?.ok && rows.length === 0 && Todavía no hay entregas.
}
+
+ {rows.length > 0 && (
+
+
+
+
+ Propietario
+ Entrega
+ {checks.length > 0 && Checks }
+ Puntos
+ Premio
+ Estado
+ Acciones
+
+
+
+ {rows.map((r) => (
+
+
+ {r.owner.name}
+ {ownerKind(r.owner.type)} · {r.owner_emails.join(', ') || '—'}
+
+
+ {r.title}
+
+ {urlKeys.filter((k) => r[k]).map((k) => (
+ <>
{URL_LABELS[k]} {' '}>
+ ))}
+
+
+ {checks.length > 0 && (
+
+ {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 && (
+
+
+
+ Retirar
+
+ )}
+ {!archived && r.withdrawn_at && (
+
+
+
+ Restaurar
+
+ )}
+ {archived && solo lectura }
+
+
+ ))}
+
+
+ {checks.length > 0 &&
* check fijado a mano por un admin (verify no lo pisa).
}
+
+ )}
+
+ {editRow && !archived && (
+
+ )}
+
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.filter((t) => !t.locked).map((t) => {t.name} )}
+
+ Añadir
+
+ )}
+
+ ))}
+
+
+
+ {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 && (
+
+
+
+
+
+ sin equipo
+ {teams.filter((o) => o.id !== t.id && !o.locked).map((o) => {o.name} )}
+
+ Mover
+
+ )}
+
+ ))}
+
+ {!archived && (
+
+ )}
+
+ ))}
+
+ )}
+
+ {isTeam && !archived && (
+
+ Crear equipo
+
+
+
+
+ Nombre (vacío: "Equipo N")
+
+ {free.length > 0 && (
+ <>
+ Miembros iniciales (inscritos sin equipo)
+
+ {free.map((p) => (
+ {p.name} {profile(p)}
+ ))}
+
+ >
+ )}
+ Crear equipo
+
+
+
+ )}
+
+ {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.
+
+
+ conservar los equipos manuales
+ Previsualizar
+ Generar
+
+ {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.
}
+
+
+
+
+
+
+
+
+
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'}
+
+
+
+ Estado
+
+ todos
+ {Object.entries(PARTICIPANT_STATUS_LABELS).map(([k, l]) => {l} )}
+
+
+
+ Reserva
+
+ indiferente
+ solo reserva
+ sin reserva
+
+
+ {isTeam && (
+
+ Equipo
+
+ todos
+ sin equipo
+ {teams.map(([id, name]) => {name} )}
+
+
+ )}
+
+ Buscar
+
+
+ Filtrar
+ {(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 && (
+
+ )}
+
+ {editRow && !archived && (
+
+ )}
+
+ {!archived && (
+
+ Alta manual
+
+
+ )}
+
+ {!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.
+
+
+
+ Fichero CSV
+
+
+ Previsualizar
+ Importar
+
+ {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 && (
+
+
+ Línea Email Resultado Detalle
+
+ {report.rows.map((row) => (
+
+ {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 && Previsualizar apertura de la votación }
+ {canClose && Previsualizar cierre de la votación }
+
+ )}
+ {!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) => {r.title} ({r.owner.name}) · {num(r.total)} puntos{r.not_prize_eligible ? ' · no opta a premio' : ''} )}
+
+ )}
+
+
+ {previewVerb === 'close' ? 'Cerrar la votación' : 'Abrir la votación'}
+ 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 && (
+
+
+
+ # Entrega Propietario Votos Puntos voto Checks Total Premio
+
+
+ {board.rows.map((r) => (
+
+ {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 && (
+
+
+ Votante Entrega Propietario Fecha Estado
+
+ {votes.map((v) => (
+
+ {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' },
+];
+---
+
+
+
+
+
+
+ {res.ok && events.length === 0 && (
+ No hay eventos {filtro === 'activos' ? 'activos' : filtro === 'archivados' ? 'archivados' : ''}.
+ )}
+
+ {events.length > 0 && (
+
+
+
+
+ Evento
+ Estado
+ Inscritos
+ Equipos
+ Entregas
+ Votos
+ Avisos
+ Actualizado
+
+
+
+ {events.map((e) => (
+
+
+ {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=