// Edge Function `create-event` // // Contrat : // POST {SUPABASE_URL}/functions/v1/create-event // Authorization: Bearer // Body JSON : { nom: string, date_evenement: "YYYY-MM-DD", // client_nom?: string, max_photos_per_guest?: number } // // Appelée uniquement par le workflow n8n de création d'événement (Form // Trigger -> cet endpoint), jamais depuis le front photobooth-qr : il // n'existe aucun formulaire de création d'event côté appli (cf. // docs/decisions.md, "2026-09-18 (bis)"). Avant cette fonction, la création // d'un event se faisait uniquement en SQL manuel. // // Tourne avec la service_role key (cf. _shared/supabase-admin.ts). Appelle // la RPC `public.create_event` (cf. // supabase/migrations/20260918100000_create_event_rpc.sql), qui génère // slug + admin_token + expire_at côté SQL et insère la ligne // photobooth.events. // // Sécurité : même pattern que expire-events/index.ts — pas d'auth Supabase // standard (le JWT anon suffirait à passer la gateway, ce qui ne protège // rien pour un endpoint qui crée des events), donc comparaison EXPLICITE en // temps constant du Bearer fourni contre la service_role key, pour // s'assurer que seul un appelant serveur-à-serveur de confiance (le // workflow n8n, avec la clé dans son Credentials Store, jamais côté client) // peut créer un événement. // // Pas de CORS (écart assumé par rapport à admin-gallery/public-gallery, qui // importent _shared/cors.ts) : cette fonction n'est appelée qu'en // serveur-à-serveur depuis n8n, jamais depuis un navigateur — un // Access-Control-Allow-Origin n'aurait aucun effet utile ici, et l'ajouter // suggérerait à tort qu'un appel cross-origin navigateur est un usage // prévu. // // ⚠️ Ne pas déployer depuis cet agent (pas de `deploy_edge_function`) : // fichier fourni pour relecture humaine avant déploiement (Kévin applique // lui-même via son accès MCP Supabase direct). import { getSupabaseAdmin } from "../_shared/supabase-admin.ts"; import { timingSafeEqual } from "../_shared/timing-safe.ts"; function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); } function extractBearerToken(req: Request): string | null { const header = req.headers.get("authorization") ?? ""; const match = /^Bearer\s+(.+)$/i.exec(header); return match ? match[1] : null; } type CreateEventBody = { nom?: unknown; date_evenement?: unknown; client_nom?: unknown; max_photos_per_guest?: unknown; }; type CreateEventRow = { id: string; slug: string; nom: string; date_evenement: string; expire_at: string; admin_token: string; client_nom: string | null; max_photos_per_guest: number | null; }; // YYYY-MM-DD strict : évite qu'une chaîne ambiguë ("01/10/2026") ou une // date-heure complète soit acceptée silencieusement puis mal interprétée // par la colonne `date` de photobooth.events. Pas de vérification // passé/futur (aucune règle produit ne l'exige à ce jour). const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; function isValidDateString(value: string): boolean { if (!DATE_RE.test(value)) return false; const parsed = new Date(`${value}T00:00:00Z`); return !Number.isNaN(parsed.getTime()); } Deno.serve(async (req) => { if (req.method !== "POST") { return jsonResponse(405, { error: "method_not_allowed" }); } const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); const bearer = extractBearerToken(req); if (!serviceRoleKey || !(await timingSafeEqual(bearer, serviceRoleKey))) { return jsonResponse(401, { error: "unauthorized" }); } // Même secret que admin-gallery/public-gallery (cf. _shared/cors.ts), réutilisé // ici pour construire les liens, pas pour du CORS. const appBaseUrl = Deno.env.get("APP_BASE_URL"); if (!appBaseUrl) { console.error("[create-event] APP_BASE_URL manquante dans l'environnement."); return jsonResponse(500, { error: "internal_error" }); } let body: CreateEventBody; try { body = await req.json(); } catch { return jsonResponse(400, { error: "invalid_json_body" }); } const nom = typeof body.nom === "string" ? body.nom.trim() : ""; if (!nom) { return jsonResponse(400, { error: "nom_required" }); } const dateEvenement = typeof body.date_evenement === "string" ? body.date_evenement.trim() : ""; if (!dateEvenement || !isValidDateString(dateEvenement)) { return jsonResponse(400, { error: "date_evenement_invalid" }); } let clientNom: string | null = null; if (body.client_nom !== undefined && body.client_nom !== null) { if (typeof body.client_nom !== "string") { return jsonResponse(400, { error: "client_nom_invalid" }); } const trimmed = body.client_nom.trim(); clientNom = trimmed.length > 0 ? trimmed : null; } let maxPhotosPerGuest: number | null = null; if (body.max_photos_per_guest !== undefined && body.max_photos_per_guest !== null) { const value = body.max_photos_per_guest; if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { return jsonResponse(400, { error: "max_photos_per_guest_invalid" }); } maxPhotosPerGuest = value; } const supabaseAdmin = getSupabaseAdmin(); // public.create_event (service_role uniquement) génère slug + admin_token // + expire_at côté SQL et insère la ligne — cf. // 20260918100000_create_event_rpc.sql. Revalide elle-même nom/date/max // (ne fait pas confiance à cette validation faite en amont, même principe // que insert_public_photo côté RPC anon). const { data: event, error } = await supabaseAdmin .rpc("create_event", { p_nom: nom, p_date_evenement: dateEvenement, p_client_nom: clientNom, p_max_photos_per_guest: maxPhotosPerGuest, }) .maybeSingle(); if (error) { console.error("[create-event] create_event RPC failed", error); return jsonResponse(500, { error: "internal_error" }); } if (!event) { console.error("[create-event] create_event RPC returned no row"); return jsonResponse(500, { error: "internal_error" }); } const typedEvent = event as CreateEventRow; const cleanBaseUrl = appBaseUrl.trim().replace(/\/+$/, ""); // 201 (plutôt que 200 comme les autres fonctions, toutes des GET) : seule // fonction du projet qui crée une ressource. return jsonResponse(201, { event: { nom: typedEvent.nom, slug: typedEvent.slug, date_evenement: typedEvent.date_evenement, expire_at: typedEvent.expire_at, client_nom: typedEvent.client_nom, max_photos_per_guest: typedEvent.max_photos_per_guest, }, links: { invite: `${cleanBaseUrl}/e/${encodeURIComponent(typedEvent.slug)}`, admin: `${cleanBaseUrl}/admin/${encodeURIComponent(typedEvent.slug)}?token=${encodeURIComponent( typedEvent.admin_token )}`, // src/admin/qrcode/index.html lit désormais `?slug=` ET `?baseUrl=` // pour se pré-remplir entièrement (corrigé le 2026-09-18, cf. // docs/decisions.md — ce lien ne servait à rien tant que la page ne // lisait aucun des deux paramètres). qrcode: `${cleanBaseUrl}/admin/qrcode/?slug=${encodeURIComponent( typedEvent.slug )}&baseUrl=${encodeURIComponent(cleanBaseUrl)}`, }, }); });