From 7c28bbd09f1ac986c0d97883ad3b8ff5e8d4911b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C3=BAl=20G=C3=B3mez=20Jim=C3=A9nez?= Date: Sun, 6 Sep 2026 15:33:45 +0200 Subject: [PATCH 01/36] =?UTF-8?q?W-01:=20el=20proxy=20deja=20pasar=20rutas?= =?UTF-8?q?=20admin=20con=20cookie=20de=20sesi=C3=B3n=20(SPEC=20v3=20?= =?UTF-8?q?=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/events/* ya no responde 404 a los paths con segmento `admin` cuando la petición trae cookie nan_session: la autorización (staff o no) la decide el backend y el proxy propaga su 401/403. Sin cookie sigue siendo 404 sin tocar el backend. La admin key nunca se reenvía (esa vía es solo directa contra cloud-api). Para el panel: el content-type text/csv se conserva en ambos sentidos (import y export de participantes) y Content-Disposition también (export?download=1). Todo lo demás sigue normalizado a JSON. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7 --- src/lib/events.ts | 15 ++++++--- src/pages/api/events/[...path].ts | 26 ++++++++++----- src/tests/api/events.test.ts | 53 +++++++++++++++++++++++++++++-- 3 files changed, 79 insertions(+), 15 deletions(-) diff --git a/src/lib/events.ts b/src/lib/events.ts index 5cd5f9f..ed2a4ab 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 { @@ -78,12 +80,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. diff --git a/src/pages/api/events/[...path].ts b/src/pages/api/events/[...path].ts index 20d6391..169f541 100644 --- a/src/pages/api/events/[...path].ts +++ b/src/pages/api/events/[...path].ts @@ -1,5 +1,5 @@ import type { APIRoute } from 'astro'; -import { backendURL, forwardHeaders, isAdminPath } from '../../../lib/events'; +import { backendURL, forwardHeaders, hasSessionCookie, isAdminPath } from '../../../lib/events'; export const prerender = false; @@ -15,15 +15,17 @@ function json(body: unknown, status = 200): Response { * * 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,9 +52,17 @@ 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`. 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 headers = new Headers({ + 'content-type': /^text\/csv\b/i.test(upstreamType) ? upstreamType : 'application/json', + '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); diff --git a/src/tests/api/events.test.ts b/src/tests/api/events.test.ts index d6de49a..a39b968 100644 --- a/src/tests/api/events.test.ts +++ b/src/tests/api/events.test.ts @@ -8,7 +8,7 @@ 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('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 +98,62 @@ 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); 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('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. From ca8e30879657f9e9fcb18ef1226d0543509fe9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C3=BAl=20G=C3=B3mez=20Jim=C3=A9nez?= Date: Sun, 6 Sep 2026 15:39:46 +0200 Subject: [PATCH 02/36] =?UTF-8?q?W-02:=20guardia=20SSR=20de=20staff=20y=20?= =?UTF-8?q?layout=20base=20de=20/events/admin=20(SPEC=20v3=20=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `src/lib/eventsAdmin.ts`: `fetchStaffSession` (cookie → /api/auth/me, solo `role: staff`), `resolveAdminRoute` (404 por rewrite a quien no es staff, nunca 403), `adminFetch` (cliente SSR de la API de administración con el envelope de §6: warnings, dry_run, fields, detail; nunca lanza), `adminHref`, `ADMIN_SCREENS`, `STATUS_LABELS`, `fmtAdminDate`. - `src/layouts/EventsAdmin.astro`: shell del panel (NanPage noindex/nofollow, enlace atrás, email del staff, título y navegación por pantallas del evento). - `src/pages/events/admin/{index,_index}.astro`: portada del panel con el patrón envoltorio + cuerpo `_`; la guardia se resuelve en el fichero de ruta. - Tests: `eventsAdmin.test.ts` (sesión, guardia, cliente) y `adminRoutes.test.ts` (toda ruta del panel llama a la guardia y es SSR; los cuerpos no; no hay /es/). Solo español, sin variante /es/. Sin cambios de comportamiento fuera de /events/admin. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7 --- src/layouts/EventsAdmin.astro | 60 ++++++++++ src/lib/eventsAdmin.ts | 175 ++++++++++++++++++++++++++++ src/pages/events/admin/_index.astro | 24 ++++ src/pages/events/admin/index.astro | 14 +++ src/tests/lib/adminRoutes.test.ts | 58 +++++++++ src/tests/lib/eventsAdmin.test.ts | 146 +++++++++++++++++++++++ 6 files changed, 477 insertions(+) create mode 100644 src/layouts/EventsAdmin.astro create mode 100644 src/lib/eventsAdmin.ts create mode 100644 src/pages/events/admin/_index.astro create mode 100644 src/pages/events/admin/index.astro create mode 100644 src/tests/lib/adminRoutes.test.ts create mode 100644 src/tests/lib/eventsAdmin.test.ts diff --git a/src/layouts/EventsAdmin.astro b/src/layouts/EventsAdmin.astro new file mode 100644 index 0000000..45b213d --- /dev/null +++ b/src/layouts/EventsAdmin.astro @@ -0,0 +1,60 @@ +--- +/** + * 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 NanPage from './NanPage.astro'; +import { ADMIN_SCREENS, adminHref, STATUS_LABELS, type AdminScreen, type StaffSession } from '../lib/eventsAdmin'; + +interface Props { + title: string; + staff: StaffSession; + /** Dentro de un evento: activa la navegación por pantallas. */ + event?: { slug: string; name: string; status: string } | null; + current?: AdminScreen; + /** Texto del enlace atrás (por defecto, a la lista o al evento). */ + back?: { href: string; label: string }; +} +const { title, staff, event = null, current = 'evento', back } = Astro.props; +const backLink = back ?? (event && current !== 'evento' + ? { href: adminHref(event.slug), label: '← Volver al evento' } + : { href: adminHref(), label: '← Todos los eventos' }); +const showBack = !(event === null && !back && Astro.url.pathname.replace(/\/$/, '') === adminHref()); +--- + + +
+
+ {showBack + ? {backLink.label} + : } + {staff.email} +
+
+

// Administración de eventos

+

+ {event ? event.name : title} +

+ {event && ( +

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

+ )} + {event && ( + + )} +
+ +
+
diff --git a/src/lib/eventsAdmin.ts b/src/lib/eventsAdmin.ts new file mode 100644 index 0000000..6e68089 --- /dev/null +++ b/src/lib/eventsAdmin.ts @@ -0,0 +1,175 @@ +import { env } from 'cloudflare:workers'; + +/** + * 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. + */ + +const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/; + +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; +} + +function apiBase(): string { + return env.CLOUD_API_URL.replace(/\/$/, ''); +} + +function ssrHeaders(cookie: string, extra?: Record): Record { + return { origin: 'https://nan.builders', cookie, ...extra }; +} + +/** + * 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 (!cookie || !cookie.includes('nan_session')) 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') }; +} + +/** 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 = path.split('/').filter((s) => s !== ''); + if (segments.length === 0 || !segments.every((s) => SAFE_SEGMENT.test(s) && s !== '.' && s !== '..')) { + return fail(404, 'not_found'); + } + const url = `${apiBase()}/api/events/${segments.join('/')}${init?.search ?? ''}`; + const req: RequestInit = { 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 as Record)['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'); + } + const errData = (body.data ?? {}) as { fields?: unknown; detail?: unknown }; + 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 && Array.isArray(errData.fields) ? (errData.fields as string[]) : [], + detail: !ok && typeof errData.detail === 'string' ? errData.detail : undefined, + }; +} + +/** Fecha ISO → "12 feb 2027, 18:00 UTC" (UTC, como el backend); vacío si no hay fecha. */ +export function fmtAdminDate(iso?: string | null, withTime = true): string { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }; + if (withTime) { opts.hour = '2-digit'; opts.minute = '2-digit'; } + return d.toLocaleString('es-ES', opts) + (withTime ? ' UTC' : ''); +} + +/** Etiquetas en español de los estados del evento (SPEC v3 §4). */ +export const STATUS_LABELS: Record = { + draft: 'Borrador', + registration: 'Inscripción', + building: 'Construcción', + submission: 'Entrega', + voting: 'Votación', + closed: 'Cerrado', + cancelled: 'Cancelado', +}; diff --git a/src/pages/events/admin/_index.astro b/src/pages/events/admin/_index.astro new file mode 100644 index 0000000..cef48f2 --- /dev/null +++ b/src/pages/events/admin/_index.astro @@ -0,0 +1,24 @@ +--- +// Portada del panel (SPEC v3 §8). La guardia y la cookie llegan del +// envoltorio de ruta; este cuerpo no hace rewrite ni comprueba la sesión. +import '../../../styles/global.css'; +import EventsAdmin from '../../../layouts/EventsAdmin.astro'; +import type { StaffSession } from '../../../lib/eventsAdmin'; + +interface Props { + staff: StaffSession; + cookie: string; +} +const { staff } = Astro.props; +--- + + +
+

+ Desde aquí se gestionan los eventos de NaN: crear y editar, cambiar de estado, + participantes, equipos, entregas, votos y auditoría. Todo lo que hace el panel + se puede hacer también con la API (docs/API.md + en el repositorio del servidor de eventos). +

+
+
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/tests/lib/adminRoutes.test.ts b/src/tests/lib/adminRoutes.test.ts new file mode 100644 index 0000000..485a705 --- /dev/null +++ b/src/tests/lib/adminRoutes.test.ts @@ -0,0 +1,58 @@ +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'); + expect(src).toMatch(/const route = await resolveAdminRoute\(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(/resolveAdminRoute/); + expect(src).not.toMatch(/fetchStaffSession/); + }); + + 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..d54b1a7 --- /dev/null +++ b/src/tests/lib/eventsAdmin.test.ts @@ -0,0 +1,146 @@ +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, 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(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('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'); + }); +}); From 337dfe736e82fd431bc70cb35e40f2dfe72317ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C3=BAl=20G=C3=B3mez=20Jim=C3=A9nez?= Date: Sun, 6 Sep 2026 15:55:15 +0200 Subject: [PATCH 03/36] =?UTF-8?q?W-03:=20lista=20de=20eventos=20y=20editor?= =?UTF-8?q?=20(crear,=20editar,=20clonar,=20archivar)=20en=20/events/admin?= =?UTF-8?q?=20(SPEC=20v3=20=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lista de eventos con filtro activos/archivados/todos, contadores, avisos y flash. - /events/admin/nuevo: formulario completo del event.json con botón "simular" (dry_run). - /events/admin/{slug}: ficha (fase, ventanas, contadores, avisos de coherencia), editor, clonado y archivado/desarchivado (con "forzar" si no está cerrado ni cancelado). - Patrón POST → 303 con ?ok=&warn= (readFlash); el envoltorio de ruta procesa el formulario y el cuerpo _x.astro solo pinta. - resolveAdminEventRoute: guardia de staff + ficha del evento + 404 de slug. - Estilos del panel en src/styles/events-admin.css (CSS plano, sin @apply). - Pruebas de formularios (eventsAdminForms) y guardia de rutas actualizada. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014ToscGMqPkLEBZ6x8MhcP7 --- .../events/admin/AdminNotices.astro | 43 ++ src/components/events/admin/EventForm.astro | 191 +++++++++ src/layouts/EventsAdmin.astro | 1 + src/lib/eventsAdmin.ts | 81 ++++ src/lib/eventsAdminForms.ts | 383 ++++++++++++++++++ src/pages/events/admin/[slug]/_index.astro | 116 ++++++ src/pages/events/admin/[slug]/index.astro | 17 + src/pages/events/admin/_index.astro | 88 +++- src/pages/events/admin/_nuevo.astro | 31 ++ src/pages/events/admin/nuevo.astro | 16 + src/styles/events-admin.css | 145 +++++++ src/tests/lib/adminRoutes.test.ts | 6 +- src/tests/lib/eventsAdminForms.test.ts | 224 ++++++++++ 13 files changed, 1328 insertions(+), 14 deletions(-) create mode 100644 src/components/events/admin/AdminNotices.astro create mode 100644 src/components/events/admin/EventForm.astro create mode 100644 src/lib/eventsAdminForms.ts create mode 100644 src/pages/events/admin/[slug]/_index.astro create mode 100644 src/pages/events/admin/[slug]/index.astro create mode 100644 src/pages/events/admin/_nuevo.astro create mode 100644 src/pages/events/admin/nuevo.astro create mode 100644 src/styles/events-admin.css create mode 100644 src/tests/lib/eventsAdminForms.test.ts diff --git a/src/components/events/admin/AdminNotices.astro b/src/components/events/admin/AdminNotices.astro new file mode 100644 index 0000000..f240f04 --- /dev/null +++ b/src/components/events/admin/AdminNotices.astro @@ -0,0 +1,43 @@ +--- +/** + * Avisos del panel: confirmación tras redirección (`?ok=`), avisos del + * backend (§8, traducidos) y error de la última respuesta. Cada bloque solo + * se pinta si tiene contenido. + */ +import type { ApiResult } from '../../../lib/eventsAdmin'; +import { warningLabel } from '../../../lib/eventsAdminForms'; + +interface Props { + ok?: string | null; + warnings?: string[]; + result?: ApiResult; + /** Texto que precede a la lista de avisos (p. ej. "Simulación: no se ha guardado nada"). */ + warningsTitle?: string; + forbidden?: boolean; +} +const { ok = null, warnings = [], result, warningsTitle, forbidden = false } = Astro.props; +const errorText = result && !result.ok + ? [result.message ?? result.error ?? 'error', result.fields.length ? `campos: ${result.fields.join(', ')}` : '', result.detail ?? ''] + .filter(Boolean) + .join(' · ') + : ''; +const showWarnings = warnings.length > 0 || (result?.ok && (result.warnings.length > 0 || warningsTitle)); +--- + +{forbidden &&

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

} +{ok &&

{ok}

} +{errorText && ( +

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

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

{warningsTitle}

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

Datos básicos

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

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

+
+
+ +
+

Fechas (UTC)

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

Inscripción

+
+ + + + + +
+
+ +
+

Equipos (solo formato por equipos)

+
+ + + +
+
+ +
+

Entregas

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

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

+
+
+
+ +
+

Votación

+
+ +

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

+
+
+ + {!disabled && ( +
+ + +
+ )} +
diff --git a/src/layouts/EventsAdmin.astro b/src/layouts/EventsAdmin.astro index 45b213d..3b160fa 100644 --- a/src/layouts/EventsAdmin.astro +++ b/src/layouts/EventsAdmin.astro @@ -8,6 +8,7 @@ * 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'; diff --git a/src/lib/eventsAdmin.ts b/src/lib/eventsAdmin.ts index 6e68089..f4ca7dc 100644 --- a/src/lib/eventsAdmin.ts +++ b/src/lib/eventsAdmin.ts @@ -87,6 +87,74 @@ export async function resolveAdminRoute( 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; + archived_at: string | null; + 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[]; levels: string[] }; + team?: { size: number; min_size: number; max_teams: number } | null; + submission: { fields: Record; checks: string[]; prize_requires: string[]; 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: { registration: { open: boolean }; submission: { open: boolean }; voting: { open: boolean }; gallery: { visible: boolean }; leaderboard: { visible: boolean } }; + counts: { registered: number; reserve: number; withdrawn: number; teams: number; submissions: number; votes: number }; + warnings: string[]; +} + +/** 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 }; +} + /** Envelope de la API de eventos (SPEC v3 §6), tal cual lo devuelve el backend. */ export interface ApiResult { ok: boolean; @@ -173,3 +241,16 @@ export const STATUS_LABELS: Record = { closed: 'Cerrado', cancelled: 'Cancelado', }; + +/** Etiquetas de la fase efectiva (estado + fechas, SPEC v3 §5). */ +export const PHASE_LABELS: Record = { + draft: 'borrador', + registration: 'inscripción abierta', + building_pending: 'construcción, entregas aún cerradas', + building: 'construcción, entregas abiertas', + submission: 'entregas congeladas', + voting: 'votación abierta', + closed_pending: 'votación vencida, pendiente de cerrar', + closed: 'cerrado', + cancelled: 'cancelado', +}; diff --git a/src/lib/eventsAdminForms.ts b/src/lib/eventsAdminForms.ts new file mode 100644 index 0000000..eb87ce1 --- /dev/null +++ b/src/lib/eventsAdminForms.ts @@ -0,0 +1,383 @@ +import { adminFetch, adminHref, 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; + +export const CHECKS = [ + { value: 'url_live', label: 'URL viva (puntúa)' }, + { value: 'in_nan_space', label: 'Desplegado en un space de NaN (puntúa)' }, + { value: 'repo_public', label: 'Repositorio público (no puntúa; puede condicionar el premio)' }, +] as const; + +/** Checks que suman puntos automáticos: `voting.auto_max` se deriva de ellos (el backend lo exige). */ +const SCORING_CHECKS = ['url_live', 'in_nan_space']; + +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.', + 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.', +}; + +/** 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[]; levels?: string[] }; + team?: { size?: number; min_size?: number; max_teams?: number } | null; + submission?: { fields?: Record; checks?: string[]; prize_requires?: string[]; 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; +} + +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; +}; +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`). */ + 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; +} + +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 }; + } +} + +/** URL de vuelta tras un cambio: `/events/admin/{slug}?ok=…&warn=a,b`. */ +export function doneHref(slug: string, ok: string, warnings: string[] = []): string { + const q = new URLSearchParams({ ok }); + const warn = warnings.filter((w) => w !== 'no_change'); + if (warn.length) q.set('warn', warn.join(',')); + return `${adminHref(slug)}?${q.toString()}`; +} + +/** Lee `?ok=` y `?warn=` de una URL y los convierte en textos; ignora lo desconocido. */ +export function readFlash(url: URL): { ok: string | null; warnings: string[] } { + 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 { fd, forbidden } = await readForm(request); + if (forbidden) return { forbidden: true }; + if (!fd) return {}; + const values = formValues(fd); + const action = str(values, 'action') || 'create'; + 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 o desarchivar. */ +export async function handleEventForm(request: Request, cookie: string, slug: string): Promise { + const { fd, forbidden } = await readForm(request); + if (forbidden) return { forbidden: true }; + if (!fd) return {}; + const values = formValues(fd); + const action = str(values, 'action') || 'save'; + + 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 }; + } + + 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/pages/events/admin/[slug]/_index.astro b/src/pages/events/admin/[slug]/_index.astro new file mode 100644 index 0000000..36cf031 --- /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), editor del event.json, clonado y archivado. El +// control de estado llega en W-04. 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 { adminHref, fmtAdminDate, PHASE_LABELS, type AdminEventView, type StaffSession } from '../../../../lib/eventsAdmin'; +import { eventToForm, readFlash, warningLabel, type FormOutcome } from '../../../../lib/eventsAdminForms'; + +interface Props { + staff: StaffSession; + cookie: string; + view: AdminEventView; + outcome: FormOutcome; +} +const { staff, view, outcome } = Astro.props; +const ev = view.event; +const archived = Boolean(ev.archived_at); +const flash = readFlash(Astro.url); +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'; +const windows = [ + { label: 'Inscripción', open: view.windows.registration.open }, + { label: 'Entregas', open: view.windows.submission.open }, + { label: 'Votación', open: view.windows.voting.open }, + { label: 'Galería', open: view.windows.gallery.visible }, + { label: 'Ranking', open: view.windows.leaderboard.visible }, +]; +--- + + + + +
+

Ahora mismo

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

Avisos de coherencia:

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

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

+
+ + {archived &&

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

} + + + +
+

Clonar

+

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

+ + + + + +
+ +
+

{archived ? 'Desarchivar' : 'Archivar'}

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

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

+ {!canArchiveDirect && } + +
+ )} +
+
diff --git a/src/pages/events/admin/[slug]/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/_index.astro b/src/pages/events/admin/_index.astro index cef48f2..0210b84 100644 --- a/src/pages/events/admin/_index.astro +++ b/src/pages/events/admin/_index.astro @@ -1,24 +1,88 @@ --- -// Portada del panel (SPEC v3 §8). La guardia y la cookie llegan del -// envoltorio de ruta; este cuerpo no hace rewrite ni comprueba la sesión. +// 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 type { StaffSession } from '../../../lib/eventsAdmin'; +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 } = Astro.props; +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' }, +]; --- -
-

- Desde aquí se gestionan los eventos de NaN: crear y editar, cambiar de estado, - participantes, equipos, entregas, votos y auditoría. Todo lo que hace el panel - se puede hacer también con la API (docs/API.md - en el repositorio del servidor de eventos). -

-
+ + +
+ + Nuevo evento +
+ + {res.ok && events.length === 0 && ( +

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

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

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

+ + +
diff --git a/src/pages/events/admin/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..551bbee --- /dev/null +++ b/src/styles/events-admin.css @@ -0,0 +1,145 @@ +/* + * 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; } diff --git a/src/tests/lib/adminRoutes.test.ts b/src/tests/lib/adminRoutes.test.ts index 485a705..750bd57 100644 --- a/src/tests/lib/adminRoutes.test.ts +++ b/src/tests/lib/adminRoutes.test.ts @@ -38,7 +38,8 @@ describe('rutas de /events/admin', () => { test.each(routes)('%s resuelve la guardia de staff y es SSR', (route) => { const src = readFileSync(route, 'utf-8'); - expect(src).toMatch(/const route = await resolveAdminRoute\(Astro\);/); + // 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\}/); @@ -48,8 +49,9 @@ describe('rutas de /events/admin', () => { 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(/resolveAdminRoute/); + 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/', () => { diff --git a/src/tests/lib/eventsAdminForms.test.ts b/src/tests/lib/eventsAdminForms.test.ts new file mode 100644 index 0000000..38cadd6 --- /dev/null +++ b/src/tests/lib/eventsAdminForms.test.ts @@ -0,0 +1,224 @@ +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, +} 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', + 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 }, + 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.dates).toEqual(ev.dates); + expect(body.submission.checks).toEqual(ev.submission.checks); + expect(body.voting).toEqual(ev.voting); + expect(body.description).toBe('d'); + }); +}); + +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= diff --git a/src/components/docs/DocsTopBar.astro b/src/components/docs/DocsTopBar.astro index 032c3e6..8b4f168 100644 --- a/src/components/docs/DocsTopBar.astro +++ b/src/components/docs/DocsTopBar.astro @@ -1,24 +1,24 @@ --- /* - * Top bar of the documentation, shared by the guides layout (Docs.astro) and - * the API reference (ApiReference.astro). + * Barra superior de la documentación, compartida por el layout de las guías + * (Docs.astro) y la referencia de la API (ApiReference.astro). * - * It exists because otherwise the two surfaces drift apart: the reference got - * its own header and the guides another, and they already looked different - * from each other one click apart. Ported from helmcode's DocsLayout. + * Existe porque si no las dos superficies se separan: la referencia tenía su + * propio header y las guías otro, y ya se veían distintas entre sí a un clic + * de distancia. Portada del DocsLayout de helmcode. * - * The EN/ES switcher is only rendered where the page has a Spanish version, - * which today is just /docs/api: the guides come from a collection that is not - * translated, and offering a language that leads to a 404 is worse than not - * offering it. + * El selector EN/ES solo se renderiza donde la página tiene versión en + * español, que hoy es solo /docs/api: las guías salen de una colección que no + * está traducida, y ofrecer un idioma que lleva a un 404 es peor que no + * ofrecerlo. */ import { switchLocalePath, type Locale } from '../../lib/i18n'; interface Props { lang?: Locale; - /** With `false` the language switcher is not rendered (English-only pages). */ + /** Con `false` no se renderiza el selector de idioma (páginas solo en inglés). */ bilingual?: boolean; - /** Marks the active link in the bar. */ + /** Marca el enlace activo de la barra. */ current?: 'guides' | 'reference'; } @@ -51,16 +51,16 @@ const t = {
- {/* Only shown where there is a sidebar to open: the reference has none. */} + {/* Solo se muestra donde hay un sidebar que abrir: la referencia no tiene. */} {/* - The wordmark goes to the site, not to the docs index. It is the brand - mark: clicking a logo is how you get out to the home page, and "Guides" - right next to it already covers going to the docs index. + El wordmark lleva al sitio, no al índice de docs. Es la marca: hacer clic + en un logo es la forma de salir a la home, y "Guides" justo al lado ya + cubre ir al índice de docs. */} {t.platform} {/* - Absolute rather than `/` as in helmcode: both links carrying ↗ leave the - site, and a relative path keeps this one on whatever domain you happen to - be browsing. On a Cloudflare preview that means "nan.builders" leaves you - on *.workers.dev. The previous layout already had it absolute. + Absoluto en vez de `/` como en helmcode: los dos enlaces con ↗ salen del + sitio, y una ruta relativa deja este en el dominio que estés navegando. + En una preview de Cloudflare eso significa que "nan.builders" te deja en + *.workers.dev. El layout anterior ya lo tenía absoluto. */} {t.site} { diff --git a/src/components/docs/RateLimits.astro b/src/components/docs/RateLimits.astro index b0e9fa2..98e382c 100644 --- a/src/components/docs/RateLimits.astro +++ b/src/components/docs/RateLimits.astro @@ -11,9 +11,9 @@ import { const { perKey, tokensPerMinuteByModel, requestsPerMinuteByModel, windowedModels } = getRateLimitsConfig(env); -// This card is embedded from both the English and the Spanish guides, and MDX -// content cannot pass props down from the layout, so the locale is read off the -// route the page was rendered for. +// Esta tarjeta se incrusta desde las guías en inglés y en español, y el +// contenido MDX no puede pasar props desde el layout, así que el locale se lee +// de la ruta para la que se renderizó la página. const lang = Astro.url.pathname.startsWith('/es/') ? 'es' : 'en'; const T = rateLimitsLabels(lang); @@ -36,10 +36,10 @@ const T = rateLimitsLabels(lang);
{ - /* The premium models are gated by a sliding window, not by a per-minute - rate. The window is the limit an intensive coding-agent session reaches - first, so it gets its own highlighted block above the per-minute tables - instead of a line of small print underneath them. */ + /* Los modelos premium van limitados por una sliding window, no por una tasa + por minuto. La ventana es el límite al que primero llega una sesión + intensiva de coding agent, así que tiene su propio bloque destacado encima + de las tablas por minuto en vez de una línea de letra pequeña debajo. */ } { windowedModels.map((m) => ( diff --git a/src/components/landing/CommunitySignupForm.tsx b/src/components/landing/CommunitySignupForm.tsx index d24ab2d..68cb368 100644 --- a/src/components/landing/CommunitySignupForm.tsx +++ b/src/components/landing/CommunitySignupForm.tsx @@ -88,9 +88,10 @@ export default function CommunitySignupForm({ t }: Props) { async function onSubmit(e: TargetedSubmitEvent) { e.preventDefault(); - // No client-side honeypot field: a hidden _hp risks autofill by a - // password manager, and the server rate-limiter + the server-side honeypot - // (when _hp is sent) are the real bot defence. The form sends no _hp. + // Sin campo honeypot en el cliente: un _hp oculto corre el riesgo de que lo + // rellene un gestor de contraseñas, y el rate limiter del servidor + el + // honeypot del servidor (cuando se envía _hp) son la defensa real contra + // bots. El formulario no envía _hp. if (!isValidEmail(email)) { setStatus({ kind: 'error', message: t.errorInvalidEmail }); diff --git a/src/components/nan/home/Models.astro b/src/components/nan/home/Models.astro index 997f511..82fa022 100644 --- a/src/components/nan/home/Models.astro +++ b/src/components/nan/home/Models.astro @@ -135,8 +135,8 @@ const total = modelos.categorias.reduce((n, c) => n + c.modelos.length, 0); } .badge--used { color: #fff; background: var(--color-violet); } .badge--frontier { color: var(--color-violet-2); border: 1px solid rgba(125, 57, 235, 0.5); } - /* Filled in white, not in a second accent: it has to read louder than - `frontier` because it is the one row a member cannot call by default. */ + /* Relleno en blanco, no en un segundo acento: tiene que leerse más fuerte que + `frontier` porque es la única fila que un miembro no puede llamar por defecto. */ .badge--premium { color: var(--color-bg); background: var(--color-text); } .mrow__meta { diff --git a/src/components/nan/home/Pricing.astro b/src/components/nan/home/Pricing.astro index 55bdf44..bdf3557 100644 --- a/src/components/nan/home/Pricing.astro +++ b/src/components/nan/home/Pricing.astro @@ -1,15 +1,15 @@ --- -// Prices verified against nan.builders: Member 70€ · GLM 5.3 premium 200€ · Community 14,99€. +// Precios verificados contra nan.builders: Member 70€ · GLM 5.3 premium 200€ · Community 14,99€. // -// There is no per-region tier and no per-region currency any more. Every new -// signup is charged in EUR from any region, on BOTH funnels: cloud-api's -// inferencePriceForNewCustomer and communityPriceForNewCustomer each always -// return the EU price. So the `nan_member · usa / latam — $75` tier was removed -// AND community is published in euros here, for the same reason: a prospect -// from outside the EU read a $ amount and landed on a EUR Checkout, a different -// price in a different currency, at the payment step. Legacy USD subscriptions -// still exist and keep their price; that is explained in the payment-methods -// FAQ entry, not here. +// Ya no hay tier por región ni moneda por región. Toda alta nueva se cobra en +// EUR desde cualquier región, en LOS DOS funnels: inferencePriceForNewCustomer +// y communityPriceForNewCustomer de cloud-api devuelven siempre el precio EU. +// Por eso se quitó el tier `nan_member · usa / latam — $75` Y community se +// publica aquí en euros, por el mismo motivo: un prospecto de fuera de la UE +// leía un importe en $ y aterrizaba en un Checkout en EUR, otro precio en otra +// moneda, en el paso del pago. Las suscripciones legacy en USD siguen +// existiendo y conservan su precio; eso se explica en la entrada de la FAQ de +// métodos de pago, no aquí. import { getLang, useT } from '../../../lib/i18n'; const lang = getLang(Astro.url); @@ -17,8 +17,8 @@ const tt = useT(lang).pricing; const home = lang === 'es' ? '/es' : '/'; const tiers = [ - // GLM 5.3 premium — price swap on the existing nan_member - // subscription (200€/mo, EUR only). Shown first as the flagship tier. + // GLM 5.3 premium: cambio de precio sobre la suscripción nan_member + // existente (200€/mes, solo EUR). Va primero como tier estrella. { name: 'nan_member · glm 5.3 premium', amount: '200€', diff --git a/src/content.config.ts b/src/content.config.ts index 2b09dd6..5b87457 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -7,13 +7,13 @@ const docsSchema = z.object({ description: z.string(), order: z.number().int().min(0), /* - * The heading the page appears under in the docs navigation. + * El encabezado bajo el que aparece la página en la navegación de docs. * - * helmcode's nav writes the groups by hand in the layout; here they live in - * the data so adding a guide stays a matter of creating a file rather than - * also editing the layout, which is how these things drift apart. The order - * between groups comes from the lowest `order` in each, so there is no - * second list to maintain either. + * La navegación de helmcode escribe los grupos a mano en el layout; aquí + * viven en los datos para que añadir una guía siga siendo cuestión de crear + * un fichero y no de editar también el layout, que es como estas cosas se + * desincronizan. El orden entre grupos sale del `order` más bajo de cada + * uno, así que tampoco hay una segunda lista que mantener. */ group: z.string().default('Guides'), locale: z.string().default('es'), @@ -25,14 +25,14 @@ const docs = defineCollection({ }); /* - * The Spanish guides live in their own directory rather than under a locale - * subfolder of `docs`. + * Las guías en español viven en su propio directorio y no en una subcarpeta + * de locale dentro de `docs`. * - * A `docs/en/…` + `docs/es/…` layout would turn every entry id into `en/intro` - * and the like, and SAFE_SLUG in src/lib/docsApi.ts rejects slashes: the - * manifest route throws on the first one, so /api/docs/manifest.json would - * answer 500 and the Discord bot would lose everything. Keeping English where - * it is leaves those slugs untouched. + * Una estructura `docs/en/…` + `docs/es/…` convertiría cada id de entrada en + * `en/intro` y similares, y SAFE_SLUG en src/lib/docsApi.ts rechaza las + * barras: la ruta del manifest lanza en la primera, así que + * /api/docs/manifest.json respondería 500 y el bot de Discord lo perdería + * todo. Dejar el inglés donde está deja esos slugs intactos. */ const docsEs = defineCollection({ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/docs-es' }), diff --git a/src/env.d.ts b/src/env.d.ts index 4addc10..7b856a3 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,16 +1,16 @@ /// -// Mirrors the shape generated by `wrangler types` into -// `worker-configuration.d.ts` (which is gitignored). Declaring it here keeps -// CI type-checking green even when the wrangler-generated file is absent, -// and TS will merge both declarations when it IS present locally. +// Replica la forma que `wrangler types` genera en +// `worker-configuration.d.ts` (que está en el gitignore). Declararla aquí +// mantiene en verde la comprobación de tipos en CI aunque falte el fichero +// generado por wrangler, y TS fusiona las dos declaraciones cuando SÍ está en local. declare namespace Cloudflare { interface Env { RESEND_API_KEY: string; RESEND_FROM_EMAIL: string; CLOUD_API_URL: string; CLOUD_API_WAITLIST_KEY: string; - // Optional: both fall back to the defaults in src/lib/rateLimits.ts. + // Opcionales: las dos caen a los valores por defecto de src/lib/rateLimits.ts. RATE_LIMIT_RPM?: string; RATE_LIMIT_PARALLEL?: string; } diff --git a/src/layouts/Docs.astro b/src/layouts/Docs.astro index 73576df..51e0d15 100644 --- a/src/layouts/Docs.astro +++ b/src/layouts/Docs.astro @@ -2,8 +2,8 @@ import '../styles/global.css'; import '../styles/docs-shell.css'; -// Self-hosted fonts, as in NanBase. They used to come from Google Fonts: that -// blocked rendering and sent every visitor's IP to a third party. +// Fuentes autoalojadas, como en NanBase. Antes venían de Google Fonts: eso +// bloqueaba el renderizado y mandaba la IP de cada visitante a un tercero. import '@fontsource-variable/archivo/wdth.css'; import '@fontsource/jetbrains-mono/400.css'; import '@fontsource/jetbrains-mono/500.css'; @@ -26,8 +26,8 @@ interface Props { lang?: 'en' | 'es'; } -// The guides exist in both languages, each in its own collection, so the page -// language, the default copy and the social card follow the `lang` prop. +// Las guías existen en los dos idiomas, cada una en su colección, así que el +// idioma de la página, el copy por defecto y la tarjeta social siguen la prop `lang`. const { title, lang = 'en' } = Astro.props; const description = @@ -60,8 +60,8 @@ const T = { const siteUrl = 'https://nan.builders'; -// No trailing slash, matching the canonical NanBase emits, so the two variants -// of each URL do not compete with each other. +// Sin barra final, igual que la canonical que emite NanBase, para que las dos +// variantes de cada URL no compitan entre sí. const canonical = `${siteUrl}${Astro.url.pathname.replace(/\/+$/, '') || '/'}`; const ogImage = `${siteUrl}/og/og-en.png`; const socialTitle = `${title} · NaN Docs`; @@ -69,10 +69,10 @@ const socialTitle = `${title} · NaN Docs`; const entries = await getCollection(lang === 'es' ? 'docsEs' : 'docs'); /* - * The API reference does not come from the collection: Scalar serves /docs/api - * from the spec, so there is no entry to take a title, order or group from. It - * is added by hand from apiDoc.ts, the same module /api/docs reads them from, - * so the page and the manifest cannot disagree. + * La referencia de la API no sale de la colección: Scalar sirve /docs/api desde + * la spec, así que no hay entrada de la que sacar título, orden o grupo. Se + * añade a mano desde apiDoc.ts, el mismo módulo del que los lee /api/docs, + * para que la página y el manifiesto no puedan discrepar. */ const navItems: DocsNavItem[] = [ ...entries.map((entry) => ({ @@ -100,16 +100,16 @@ const here = Astro.url.pathname.replace(/\/+$/, '') || `${pfx}/docs`; const isActive = (slug: string) => isActiveDocPath(here, slug); /* - * Breadcrumbs. The previous layout had them and helmcode does not, so porting - * its structure dropped them; they are restored because under /docs they are - * the only clue to where you are when you arrive from a search engine. + * Breadcrumbs. El layout anterior los tenía y helmcode no, así que al portar su + * estructura se perdieron; se restauran porque bajo /docs son la única pista + * de dónde estás cuando llegas desde un buscador. */ const breadcrumb = enPath .split('/') .filter(Boolean) .map((part, i, parts) => { - // Built from the locale-stripped path: counting `/es` as a segment made the - // Spanish pages show an extra crumb for the docs index. + // Se construye desde la ruta sin locale: contar `/es` como segmento hacía + // que las páginas en español mostraran una miga de más para el índice de docs. const path = `${pfx}/` + parts.slice(0, i + 1).join('/'); const item = navItems.find((n) => n.slug === path); return { @@ -125,12 +125,12 @@ const nextPage = currentIndex >= 0 && currentIndex < navItems.length - 1 ? navItems[currentIndex + 1] : null; /* - * Search index. + * Índice de búsqueda. * - * helmcode builds it with import.meta.glob over .md pages; here the guides are - * a content collection, so the headings come from render(entry), and the API - * reference's come from the spec, which is its only textual representation - * (Scalar renders on the client). + * helmcode lo construye con import.meta.glob sobre páginas .md; aquí las guías + * son una colección de contenido, así que los encabezados salen de + * render(entry), y los de la referencia de la API salen de la spec, que es su + * única representación textual (Scalar renderiza en el cliente). */ const collectionIndex = await Promise.all( entries.map(async (entry) => { @@ -163,12 +163,12 @@ const searchData = [ - {/* The same icon set as NanBase, so /docs does not change favicon. */} + {/* El mismo juego de iconos que NanBase, para que /docs no cambie de favicon. */} - {/* Every guide exists at both paths, so each declares the other. */} + {/* Cada guía existe en las dos rutas, así que cada una declara la otra. */} @@ -187,10 +187,10 @@ const searchData = [ {/* - `font-src` needs `data:`: Vite turns the font subsets that fall under the - inline limit into data URIs (several Archivo and JetBrains Mono subsets), - and with a bare `'self'` the browser blocked them. This predates the port; - it only showed up in the console. + `font-src` necesita `data:`: Vite convierte en data URIs los subconjuntos + de fuentes que quedan por debajo del límite de inline (varios de Archivo y + JetBrains Mono), y con un `'self'` a secas el navegador los bloqueaba. + Viene de antes del port; solo se veía en la consola. */}