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é.
This commit is contained in:
2026-09-18 12:16:25 +02:00
parent d0131331a6
commit 8568b48461
11 changed files with 1038 additions and 92 deletions
+195
View File
@@ -0,0 +1,195 @@
// 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)}`,
},
});
});
@@ -0,0 +1,154 @@
-- Migration : fonction `create_event` (public, service_role uniquement)
-- Réf. : docs/decisions.md ("2026-09-18 (bis)", création d'événement via un
-- formulaire n8n + Edge Function `create-event`), docs/modele-donnees.md
-- (table `events`), 20260916090400_create_public_functions.sql (pattern
-- des fonctions SECURITY DEFINER réservées à service_role).
--
-- Avant cette migration, la création d'un event se faisait uniquement en SQL
-- manuel (cf. docs/cahier-des-charges.md, livrable non coché "Documentation
-- courte : comment créer un nouvel événement manuellement"). Cette fonction
-- devient le point d'entrée unique, appelée par la nouvelle Edge Function
-- `supabase/functions/create-event/index.ts` (elle-même appelée par un
-- workflow n8n serveur-à-serveur, jamais depuis le front).
--
-- ⚠️ Ne pas appliquer depuis cet agent (Kévin applique lui-même).
-- unaccent est nécessaire pour dériver un slug lisible à partir de `nom`
-- (ex: "Mariage Julie & Marc" -> "mariage-julie-marc"), même logique que
-- `sanitizeFilenamePart` côté admin-gallery/index.ts mais en SQL (extension
-- contrib standard, pas de dépendance tierce).
create extension if not exists unaccent with schema extensions;
-- =============================================================================
-- photobooth.generate_unique_slug — interne (pas un point de contrat public)
-- =============================================================================
-- Dérive un slug lisible à partir d'un texte libre, et garantit son unicité
-- contre photobooth.events.slug en ajoutant un suffixe numérique en cas de
-- collision ("mariage-julie-marc", puis "mariage-julie-marc-2", etc.).
-- Fonction interne au schéma photobooth : ce n'est pas un point de contrat
-- public (comme photobooth.events_set_expire_at()), pas besoin d'être dans
-- public ni d'avoir des grants dédiés (appelée uniquement par
-- public.create_event, SECURITY DEFINER, propriétaire postgres).
create or replace function photobooth.generate_unique_slug(p_source text)
returns text
language plpgsql
as $$
declare
v_base_slug text;
v_candidate text;
v_suffix integer := 1;
-- Garde-fou défensif : en pratique la boucle se termine toujours au
-- premier ou deuxième tour (collision rarissime), mais on évite une
-- boucle infinie en cas de bug/donnée pathologique.
v_max_attempts constant integer := 1000;
begin
v_base_slug := lower(trim(both '-' from
regexp_replace(extensions.unaccent(coalesce(p_source, '')), '[^a-zA-Z0-9]+', '-', 'g')
));
if v_base_slug = '' then
v_base_slug := 'evenement';
end if;
v_candidate := v_base_slug;
while exists (select 1 from photobooth.events where slug = v_candidate) loop
v_suffix := v_suffix + 1;
v_candidate := v_base_slug || '-' || v_suffix;
if v_suffix > v_max_attempts then
raise exception 'slug_generation_failed'
using errcode = 'P0001',
detail = format('Impossible de générer un slug unique pour "%s" après %s tentatives.', p_source, v_max_attempts);
end if;
end loop;
return v_candidate;
end;
$$;
comment on function photobooth.generate_unique_slug(text) is
'Dérive un slug lisible et unique (photobooth.events.slug) à partir d''un texte libre (typiquement events.nom) : normalise via unaccent, remplace tout ce qui n''est pas [a-zA-Z0-9] par un tiret, ajoute un suffixe numérique en cas de collision. Fonction interne, appelée uniquement par public.create_event.';
-- =============================================================================
-- create_event — service_role UNIQUEMENT (NOUVEAU)
-- =============================================================================
-- Point d'entrée unique pour créer un événement. Réservée à service_role :
-- appelée par l'Edge Function `create-event`, elle-même appelée uniquement
-- par le workflow n8n de création d'événement (jamais depuis le front, pas
-- de formulaire de création dans l'appli photobooth-qr, cf. décision
-- "2026-09-18 (bis)").
--
-- Ne calcule NI admin_token NI expire_at explicitement : ces deux valeurs
-- sont déjà garanties par la table elle-même (default
-- `encode(extensions.gen_random_bytes(32), 'hex')` pour admin_token, trigger
-- `trg_events_set_expire_at` pour expire_at, cf.
-- 20260916090100_create_events_table.sql) — dupliquer cette logique ici
-- créerait deux sources de vérité pour le même comportement.
create or replace function public.create_event(
p_nom text,
p_date_evenement date,
p_client_nom text default null,
p_max_photos_per_guest integer default null
)
returns table (
id uuid,
slug text,
nom text,
date_evenement date,
expire_at timestamptz,
admin_token text,
client_nom text,
max_photos_per_guest integer
)
language plpgsql
security definer
set search_path = public, photobooth, extensions, pg_temp
as $$
declare
v_slug text;
begin
-- Revalidation défensive côté SQL (l'Edge Function valide déjà ces mêmes
-- règles avant l'appel RPC, cf. supabase/functions/create-event/index.ts) :
-- même principe que public.insert_public_photo(), qui ne fait pas confiance
-- à un appelant "déjà propre".
if p_nom is null or trim(p_nom) = '' then
raise exception 'nom_required'
using errcode = 'P0001',
detail = 'Le nom de l''événement est requis.';
end if;
if p_date_evenement is null then
raise exception 'date_evenement_required'
using errcode = 'P0001',
detail = 'La date de l''événement est requise.';
end if;
if p_max_photos_per_guest is not null and p_max_photos_per_guest <= 0 then
raise exception 'max_photos_per_guest_invalid'
using errcode = 'P0001',
detail = 'max_photos_per_guest doit être un entier strictement positif si fourni.';
end if;
v_slug := photobooth.generate_unique_slug(p_nom);
return query
insert into photobooth.events (slug, nom, date_evenement, client_nom, max_photos_per_guest)
values (v_slug, p_nom, p_date_evenement, p_client_nom, p_max_photos_per_guest)
returning
events.id,
events.slug,
events.nom,
events.date_evenement,
events.expire_at,
events.admin_token,
events.client_nom,
events.max_photos_per_guest;
end;
$$;
comment on function public.create_event(text, date, text, integer) is
'Crée un événement (photobooth.events) : génère un slug unique à partir de `nom`, laisse la table calculer admin_token (default) et expire_at (trigger). Réservée à service_role (Edge Function create-event) : ne JAMAIS accorder EXECUTE à anon/authenticated.';
revoke all on function public.create_event(text, date, text, integer) from public, anon, authenticated;
grant execute on function public.create_event(text, date, text, integer) to service_role;
@@ -0,0 +1,42 @@
-- Durcit photobooth.generate_unique_slug : le linter de sécurité Supabase
-- signale un search_path mutable (absent) sur cette fonction, introduite par
-- 20260918100000_create_event_rpc.sql. Même remède que le reste du projet
-- (public.create_event fixe déjà search_path) : figer explicitement, pour
-- empêcher qu'un search_path attaquant (ex: schéma temporaire malveillant)
-- ne fasse résoudre unaccent() ou photobooth.events vers autre chose que
-- prévu.
create or replace function photobooth.generate_unique_slug(p_source text)
returns text
language plpgsql
set search_path = photobooth, extensions, pg_temp
as $$
declare
v_base_slug text;
v_candidate text;
v_suffix integer := 1;
v_max_attempts constant integer := 1000;
begin
v_base_slug := lower(trim(both '-' from
regexp_replace(extensions.unaccent(coalesce(p_source, '')), '[^a-zA-Z0-9]+', '-', 'g')
));
if v_base_slug = '' then
v_base_slug := 'evenement';
end if;
v_candidate := v_base_slug;
while exists (select 1 from photobooth.events where slug = v_candidate) loop
v_suffix := v_suffix + 1;
v_candidate := v_base_slug || '-' || v_suffix;
if v_suffix > v_max_attempts then
raise exception 'slug_generation_failed'
using errcode = 'P0001',
detail = format('Impossible de générer un slug unique pour "%s" après %s tentatives.', p_source, v_max_attempts);
end if;
end loop;
return v_candidate;
end;
$$;