Files
KevinLecou f4322850d6 Branche la galerie invité au vrai backend (Edge Function public-gallery)
Nouvelle Edge Function publique (aucun token requis — même modèle de
confiance que l'upload, le slug n'est pas un secret) qui résout un slug
via get_public_event puis renvoie les photos de get_event_photos avec
des URLs signées. Déployée et testée en conditions réelles sur
kevin-lecou-hub (slug inconnu -> 404, event sans photo -> 200 vide,
event avec photo -> signed_url fonctionnelle), CORS vérifié.

Front (src/galerie/) : remplace les données factices par un vrai fetch +
sondage (polling 25s, pas de Supabase Realtime — le schéma photobooth
reste volontairement jamais exposé, l'activer aurait demandé de rouvrir
ce verrou RLS). Rotation des cartes figée par photo (pas de "tremblement"
au rafraîchissement), seules les nouvelles photos jouent l'animation de
chute. États chargement/erreur/vide ajoutés (absents de la maquette).
Testé en réel : une photo uploadée apparaît bien sur le mur.

Suppression des données de maquette (gallery-mock.js, mock-photos/).
2026-09-18 09:21:31 +02:00

178 lines
6.7 KiB
TypeScript

// Edge Function `public-gallery`
//
// Contrat : GET {SUPABASE_URL}/functions/v1/public-gallery?slug=<slug>
//
// Tourne avec la service_role key (cf. _shared/supabase-admin.ts), MAIS
// sans aucune vérification de token — c'est le point central de cette
// fonction, à la différence de `admin-gallery`. L'accès est intentionnellement
// ouvert à quiconque connaît le `slug` : même modèle de confiance que
// l'upload invité (le slug circule via QR code, ce n'est pas un secret).
//
// Origine : revirement produit du 2026-09-18 (cf. docs/decisions.md) — les
// invités peuvent désormais voir toutes les photos d'un événement, pas
// seulement envoyer la leur. Ça inverse la règle "lecture des photos jamais
// publique" du cahier des charges initial / docs/modele-donnees.md : ce
// revirement est acté comme décision explicite du 2026-09-18, pas tranché
// par cet agent.
//
// Sécurité : on résout le slug via `public.get_public_event` (PAS
// `get_event_for_admin`, qui expose admin_token) — cette fonction ne
// renvoie que {id, nom, statut, date_evenement, expire_at,
// max_photos_per_guest}, jamais admin_token / client_nom / facture_montant.
// Les photos elles-mêmes viennent de `public.get_event_photos`
// (service_role uniquement), qui ne renvoie que {id, url_storage,
// nom_invite, message, uploaded_at} — jamais l'email/téléphone d'un invité
// (ces colonnes n'existent d'ailleurs pas dans photobooth.photos, cf.
// docs/modele-donnees.md).
//
// `get_public_event` renvoie l'événement même si son statut n'est pas
// `actif` (expiré/archivé) — comportement hérité, inchangé ici. Cette
// fonction renvoie donc telles quelles les photos d'un événement expiré si
// elles existent encore en base (la purge Storage/DB à J+7 est un job
// séparé, cf. expire-events) : pas de filtre `statut = 'actif'` ajouté ici,
// le front peut décider d'afficher un message adapté à partir de
// `event.statut`, cohérent avec le comportement déjà choisi pour
// `get_public_event` côté page d'upload.
import { getSupabaseAdmin, getStorageBucket } from "../_shared/supabase-admin.ts";
import { corsHeaders, handleCorsPreflight } from "../_shared/cors.ts";
// URLs signées affichées dans la galerie invité : courte durée, c'est pour
// un affichage immédiat dans le navigateur de l'invité, pas pour un lien à
// partager. Même valeur que `admin-gallery` (cf. GALLERY_SIGNED_URL_TTL_SECONDS
// dans supabase/functions/admin-gallery/index.ts).
const GALLERY_SIGNED_URL_TTL_SECONDS = 10 * 60; // 10 minutes
type PhotoRow = {
id: string;
nom_invite: string | null;
message: string | null;
uploaded_at: string;
url_storage: string;
};
type PublicEventRow = {
id: string;
nom: string;
statut: string;
date_evenement: string;
expire_at: string;
max_photos_per_guest: number | null;
};
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders(), "Content-Type": "application/json" },
});
}
// 404 générique : même statut/corps que le "slug inconnu" de admin-gallery,
// pour ne pas distinguer un slug qui n'existe pas d'un autre cas d'erreur
// silencieuse côté appelant.
function notFound(): Response {
return jsonResponse(404, { error: "not_found" });
}
Deno.serve(async (req) => {
const preflight = handleCorsPreflight(req);
if (preflight) return preflight;
if (req.method !== "GET") {
return jsonResponse(405, { error: "method_not_allowed" });
}
const url = new URL(req.url);
const slug = url.searchParams.get("slug");
if (!slug) {
return jsonResponse(400, { error: "missing_parameters" });
}
const supabaseAdmin = getSupabaseAdmin();
// 1. Résout l'event par slug via public.get_public_event (anon-safe,
// n'expose jamais admin_token/client_nom/facture_montant) — pas
// get_event_for_admin, réservée à admin-gallery.
const { data: event, error: eventError } = await supabaseAdmin
.rpc("get_public_event", { p_slug: slug })
.maybeSingle();
if (eventError) {
console.error("[public-gallery] get_public_event failed", eventError);
return jsonResponse(500, { error: "internal_error" });
}
if (!event) {
return notFound();
}
const typedEvent = event as PublicEventRow;
// 2. Photos de l'event, triées chronologiquement (get_event_photos renvoie
// déjà uploaded_at ASC) — pas de tri inversé ici contrairement à
// admin-gallery : une galerie invité affiche naturellement l'ordre
// d'arrivée des photos, pas de raison produit de l'inverser pour l'instant.
let photos: PhotoRow[];
try {
const { data, error } = await supabaseAdmin.rpc("get_event_photos", {
p_event_id: typedEvent.id,
});
if (error) throw error;
photos = (data ?? []) as PhotoRow[];
} catch (err) {
console.error("[public-gallery] get_event_photos failed", err);
return jsonResponse(500, { error: "internal_error" });
}
// Event sans photo : état normal (pas d'upload pour l'instant), pas une
// erreur — 200 avec photos: [], contrairement à admin-gallery?action=zip
// qui renvoie 404/no_photos (là, l'absence de photo empêche l'action
// demandée "générer un ZIP" ; ici il n'y a pas d'action, juste un état
// d'affichage à rendre).
const signedUrlByPath = new Map<string, string>();
if (photos.length > 0) {
const paths = photos.map((p) => p.url_storage);
const bucket = getStorageBucket();
const { data: signedUrls, error: signError } = await supabaseAdmin.storage
.from(bucket)
.createSignedUrls(paths, GALLERY_SIGNED_URL_TTL_SECONDS);
if (signError) {
console.error("[public-gallery] createSignedUrls failed", signError);
return jsonResponse(500, { error: "internal_error" });
}
for (let i = 0; i < paths.length; i++) {
const entry = signedUrls?.[i];
if (entry?.signedUrl) {
signedUrlByPath.set(paths[i], entry.signedUrl);
} else if (entry?.error) {
// Une photo dont l'URL n'a pas pu être signée (ex. fichier absent du
// Storage alors que la ligne existe encore côté DB) ne doit pas
// faire échouer toute la galerie : on la renvoie avec
// signed_url: null, au front de gérer l'affichage.
console.warn(
`[public-gallery] signed url failed for ${paths[i]}`,
entry.error
);
}
}
}
return jsonResponse(200, {
event: {
nom: typedEvent.nom,
date_evenement: typedEvent.date_evenement,
statut: typedEvent.statut,
photo_count: photos.length,
},
photos: photos.map((p) => ({
id: p.id,
nom_invite: p.nom_invite,
message: p.message,
uploaded_at: p.uploaded_at,
signed_url: signedUrlByPath.get(p.url_storage) ?? null,
})),
});
});