Files
photobooth-qr/supabase/functions/create-event/index.ts
T
KevinLecou 8568b48461 Ajoute la création d'événement via n8n et refond la DA en cartes papier
- Nouvelle Edge Function create-event + RPC public.create_event : génère
  slug/admin_token/expire_at et insère l'event, réservée à service_role,
  appelée par le workflow n8n "Créer un événement" (Form Trigger, publié).
- Passe DA "polaroid poussé à fond" : les cartes d'état (chargement/erreur/
  succès), la modale de bienvenue et le formulaire passent en papier clair
  avec ruban washi tape et léger tilt, plutôt qu'en carte sombre sur fond
  sombre (ombre invisible dans l'ancienne version). Admin resté sobre/sombre
  mais cartes plus lisibles (liseré coloré, contraste renforcé).
- Générateur QR et carton imprimable : lisent maintenant ?slug= et
  ?baseUrl= pour se pré-remplir depuis le lien renvoyé par create-event.
- Correctifs : icône manquante sur "Télécharger" (admin), bug [hidden]
  anticipé sur l'état vide de la galerie invité.
2026-09-18 12:16:25 +02:00

196 lines
7.3 KiB
TypeScript

// Edge Function `create-event`
//
// Contrat :
// POST {SUPABASE_URL}/functions/v1/create-event
// Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>
// 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)}`,
},
});
});