// Edge Function `expire-events` // // Rôle : purge les fichiers Supabase Storage des événements déjà marqués // `statut = 'expire'` par le job SQL `public.expire_events()` (cf. // supabase/migrations/20260916090600_expire_events_job.sql). // // Changement d'architecture (2026-09-16) : la table photobooth.events n'est // plus accessible via une requête `.from(...)`, même en service_role (le // schéma `photobooth` n'est jamais exposé via PostgREST, cf. // supabase/migrations/20260916090000_create_photobooth_schema.sql). La // liste des events expirés est obtenue via la RPC // `public.get_expired_event_ids()` (réservée à service_role côté SQL). // // Pourquoi une Edge Function séparée plutôt que de tout faire en SQL : // l'API Storage (list/remove d'objets) n'est pas appelable depuis une // fonction Postgres standard (ni via l'extension pg_cron elle-même). La // fonction SQL public.expire_events() gère donc la partie "base de données" // (statut + suppression des lignes photobooth.photos), et CETTE fonction // gère la partie "fichiers" (Storage), séparément. // // Design volontairement DÉCOUPLÉ et IDEMPOTENT : // - Cette fonction ne dépend PAS des lignes photobooth.photos (qui // peuvent déjà avoir été supprimées par public.expire_events() au // moment où elle s'exécute) : elle liste directement le contenu du // dossier Storage `{event_id}/` pour chaque event_id renvoyé par // get_expired_event_ids(), et supprime tout ce qui s'y trouve. // - Rejouer cette fonction plusieurs fois sur le même event ne pose aucun // problème : un dossier déjà vide ne produit aucune suppression // supplémentaire (`list()` renvoie un tableau vide). // // ⚠️ Mécanisme de déclenchement NON TRANCHÉ définitivement — cf. rapport de // tâche du 2026-09-15 pour les deux options (pg_cron + pg_net, vs cron n8n) // et pourquoi n8n est recommandé par défaut ici, à valider avec Kévin. // Inchangé par le passage au schéma photobooth. // // ⚠️ 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). // // Sécurité : PAS de vérification via admin_token (cette fonction n'agit pas // au nom d'un event particulier mais purge TOUS les events expirés en une // passe). Le contrôle d'accès Supabase par défaut (vérification JWT côté // gateway) accepterait n'importe quel JWT valide — y compris l'anon key, // qui est PUBLIQUE (embarquée dans le front). Ça ne suffit donc PAS à // protéger un endpoint destructeur comme celui-ci : on vérifie ici EN PLUS, // explicitement, que le Bearer fourni est exactement la service_role key // (comparaison en temps constant), pour s'assurer que seul un appelant // serveur-à-serveur de confiance (cron pg_net, ou workflow n8n avec la clé // stockée dans le Credentials Store, jamais côté client) peut déclencher // cette purge. import { getSupabaseAdmin, getStorageBucket } from "../_shared/supabase-admin.ts"; import { timingSafeEqual } from "../_shared/timing-safe.ts"; const STORAGE_LIST_PAGE_SIZE = 100; 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; } /** * Supprime tous les objets du bucket sous le préfixe `{eventId}/`, par * pages, jusqu'à ce qu'il n'en reste plus. Ne suppose PAS l'existence de * lignes photobooth.photos correspondantes (cf. commentaire en tête de * fichier). */ async function purgeEventStorage( // deno-lint-ignore no-explicit-any supabaseAdmin: any, bucket: string, eventId: string ): Promise { const prefix = eventId; let purgedCount = 0; // On relit systématiquement la "première page" après chaque suppression // (plutôt que de paginer avec un offset) : comme on vient de supprimer ce // qu'on avait listé, ce qui reste devient naturellement la nouvelle // première page. while (true) { const { data: files, error: listError } = await supabaseAdmin.storage .from(bucket) .list(prefix, { limit: STORAGE_LIST_PAGE_SIZE }); if (listError) { throw new Error( `list() failed for prefix "${prefix}": ${listError.message ?? listError}` ); } if (!files || files.length === 0) break; const paths = files.map((f: { name: string }) => `${prefix}/${f.name}`); const { error: removeError } = await supabaseAdmin.storage .from(bucket) .remove(paths); if (removeError) { throw new Error( `remove() failed for prefix "${prefix}": ${removeError.message ?? removeError}` ); } purgedCount += paths.length; if (files.length < STORAGE_LIST_PAGE_SIZE) break; // dernière page } return purgedCount; } 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" }); } const supabaseAdmin = getSupabaseAdmin(); const bucket = getStorageBucket(); // public.get_expired_event_ids() renvoie `setof uuid` : PostgREST expose // ça comme un tableau de scalaires bruts (["uuid1", "uuid2", ...]), pas // un tableau d'objets — donc `expiredEventIds` est déjà une liste d'id. const { data: expiredEventIds, error: listEventsError } = await supabaseAdmin.rpc("get_expired_event_ids"); if (listEventsError) { console.error( "[expire-events] get_expired_event_ids failed", listEventsError ); return jsonResponse(500, { error: "internal_error" }); } const results: Array<{ event_id: string; ok: boolean; purged_files?: number; error?: string; }> = []; for (const eventId of (expiredEventIds ?? []) as string[]) { try { const purgedFiles = await purgeEventStorage(supabaseAdmin, bucket, eventId); results.push({ event_id: eventId, ok: true, purged_files: purgedFiles }); } catch (err) { console.error(`[expire-events] purge failed for event ${eventId}`, err); results.push({ event_id: eventId, ok: false, error: err instanceof Error ? err.message : String(err), }); } } const hasErrors = results.some((r) => !r.ok); // 200 même en cas d'erreurs partielles (le détail est dans `results`) : // un mécanisme de cron/n8n externe peut avoir besoin de lire le corps // pour décider s'il notifie (ex. node Telegram côté n8n sur // `hasErrors === true`), plutôt que de se baser uniquement sur le code // HTTP. return jsonResponse(200, { processed: results.length, has_errors: hasErrors, results, }); });