diff --git a/docs/decisions.md b/docs/decisions.md index 96f3ead..4ce9040 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -48,6 +48,18 @@ par Kévin. adapté. Risque jugé faible : les slugs sont partagés via QR code, donc non secrets par nature. +## 2026-09-16 (quater) + +- **Limite de photos par invité** : nouvelle colonne + `photobooth.events.max_photos_per_guest` (NULL = illimité par défaut), + réglable uniquement en SQL à la création de l'event pour l'instant (pas + d'UI admin dédiée). Appliquée via un `guest_id` anonyme généré et + persisté côté client (localStorage) — **pas une identité vérifiée**, + contournable en changeant d'appareil ou en vidant le storage. Fait + respecter côté serveur (RPC `insert_public_photo`, source de vérité) et + côté client de façon proactive (évite un envoi pour se le faire refuser + à la dernière étape). + ## 2026-09-16 (ter) - **Domaine de production (V1)** : `photobooth.assistantaikev.eu`, sous-domaine diff --git a/src/e/upload.js b/src/e/upload.js index 19f7479..816f682 100644 --- a/src/e/upload.js +++ b/src/e/upload.js @@ -71,6 +71,8 @@ const ERROR_MESSAGES = { "Cet événement n'existe pas, ou le lien utilisé est incorrect. Vérifie le QR code ou le lien.", "event-closed": "Cet événement n'accepte plus de photos (il est clos). Merci quand même d'avoir essayé !", + "guest-limit-reached": + "Tu as déjà envoyé le nombre maximum de photos autorisé pour cet événement. Merci pour ta participation !", "file-missing": "Choisis d'abord une photo avant d'envoyer.", "file-not-image": "Ce fichier n'est pas une photo. Choisis une image (JPEG, PNG, HEIC…).", @@ -149,6 +151,55 @@ function getSlugFromPath() { return match ? decodeURIComponent(match[1]) : null; } +// --------------------------------------------------------------------------- +// Identité invité (anonyme) + compteur local — pour faire respecter +// max_photos_per_guest. guest_id est généré une fois par appareil/navigateur +// (localStorage), envoyé au serveur qui fait foi pour la limite réelle ; +// le compteur local sert uniquement à bloquer l'UI de façon proactive +// (éviter de faire recompresser+uploader une photo pour se la faire +// refuser à la toute dernière étape). Contournable (autre appareil, +// navigation privée, storage vidé) — ce n'est pas une garantie forte, +// juste un frein raisonnable pour un usage V1. +const GUEST_ID_STORAGE_KEY = "photobooth_guest_id"; + +function getGuestId() { + try { + let id = window.localStorage.getItem(GUEST_ID_STORAGE_KEY); + if (!id) { + id = generateId(); + window.localStorage.setItem(GUEST_ID_STORAGE_KEY, id); + } + return id; + } catch { + // localStorage indisponible (navigation privée stricte, etc.) : on + // régénère un id éphémère, la limite sera simplement moins efficace + // pour cet invité plutôt que de bloquer tout envoi. + return generateId(); + } +} + +function guestCountStorageKey(slug) { + return `photobooth_guest_count_${slug}`; +} + +function getLocalGuestPhotoCount(slug) { + try { + return Number(window.localStorage.getItem(guestCountStorageKey(slug))) || 0; + } catch { + return 0; + } +} + +function incrementLocalGuestPhotoCount(slug) { + try { + const next = getLocalGuestPhotoCount(slug) + 1; + window.localStorage.setItem(guestCountStorageKey(slug), String(next)); + } catch { + // Pas grave : au pire l'invité pourra tenter un envoi de plus que prévu + // et se le fera refuser côté serveur (source de vérité réelle). + } +} + function isHeicFile(file) { const type = (file.type || "").toLowerCase(); if (type === "image/heic" || type === "image/heif") return true; @@ -195,7 +246,7 @@ function showError(rawErr, retryAction) { // Erreurs bloquantes de chargement d'événement : on masque le formulaire. // Erreurs liées à une tentative d'envoi : le formulaire reste visible. - if (["event-not-found", "event-closed", "config-missing"].includes(err.code)) { + if (["event-not-found", "event-closed", "config-missing", "guest-limit-reached"].includes(err.code)) { form.hidden = true; } @@ -298,6 +349,12 @@ async function initEventCheck() { try { const event = await fetchEventPublicInfo(slug); if (!isEventActive(event)) throw appError("event-closed"); + if ( + event.max_photos_per_guest != null && + getLocalGuestPhotoCount(slug) >= event.max_photos_per_guest + ) { + throw appError("guest-limit-reached"); + } state.event = event; showForm(event); } catch (err) { @@ -418,6 +475,7 @@ async function insertPhotoRow(eventId, path, nom, message) { p_url_storage: path, p_nom_invite: nom || null, p_message: message || null, + p_guest_id: getGuestId(), }), }); } catch (err) { @@ -425,7 +483,16 @@ async function insertPhotoRow(eventId, path, nom, message) { } if (res.status >= 500) throw appError("server-overloaded", await safeReadText(res)); - if (!res.ok) throw appError("db-insert-failed", await safeReadText(res)); + if (!res.ok) { + const bodyText = await safeReadText(res); + // La RPC renvoie un message métier explicite (ex. "guest_photo_limit_reached") + // dans le corps PostgREST -- on le distingue d'une erreur technique + // générique pour afficher le bon message (et ne pas proposer "réessayer"). + if (bodyText && bodyText.includes("guest_photo_limit_reached")) { + throw appError("guest-limit-reached", bodyText); + } + throw appError("db-insert-failed", bodyText); + } } // --------------------------------------------------------------------------- @@ -502,6 +569,9 @@ async function submitPhoto() { setProgress("Dernière étape…"); await insertPhotoRow(state.event.id, path, nomInput.value.trim(), messageInput.value.trim()); + const slug = getSlugFromPath(); + if (slug) incrementLocalGuestPhotoCount(slug); + setProgress(""); showSuccess(); } catch (err) { @@ -529,15 +599,18 @@ retryBtn.addEventListener("click", () => { addAnotherBtn.addEventListener("click", () => { form.reset(); - state.selectedFile = null; - state.compressedBlob = null; - fileChosenLabel.textContent = "Aucun fichier choisi"; - previewImg.hidden = true; - if (previewObjectUrl) { - URL.revokeObjectURL(previewObjectUrl); - previewObjectUrl = null; - } + resetPhotoFields(); successBlock.hidden = true; + + // Revérifie la limite par invité : le dernier envoi peut avoir atteint + // exactement le max, auquel cas on ne rouvre pas le formulaire. + const slug = getSlugFromPath(); + const limit = state.event ? state.event.max_photos_per_guest : null; + if (slug && limit != null && getLocalGuestPhotoCount(slug) >= limit) { + showError(appError("guest-limit-reached")); + return; + } + form.hidden = false; }); diff --git a/supabase/migrations/20260916140000_guest_photo_limit.sql b/supabase/migrations/20260916140000_guest_photo_limit.sql new file mode 100644 index 0000000..2b31bc4 --- /dev/null +++ b/supabase/migrations/20260916140000_guest_photo_limit.sql @@ -0,0 +1,144 @@ +-- Migration : limite de photos par invité, réglable par événement +-- Réf. : demande explicite de Kévin le 2026-09-16. +-- +-- max_photos_per_guest (photobooth.events) : NULL = illimité (comportement +-- par défaut, inchangé). Réglable uniquement en SQL à la création de l'event +-- pour l'instant (pas d'UI admin dédiée en V1, cohérent avec le reste du +-- produit). +-- +-- guest_id (photobooth.photos) : identifiant anonyme généré et persisté +-- côté client (localStorage), envoyé à chaque insert_public_photo. Sert +-- uniquement à faire respecter max_photos_per_guest — contournable si +-- l'invité change d'appareil ou vide son storage, ce n'est pas une +-- identité vérifiée. +-- +-- Déjà appliqué et testé en conditions réelles sur kevin-lecou-hub le +-- 2026-09-16 (2 photos acceptées, 3e refusée avec max_photos_per_guest=2 ; +-- ancienne signature sans guest_id supprimée pour éviter tout contournement). + +alter table photobooth.events + add column if not exists max_photos_per_guest integer + check (max_photos_per_guest is null or max_photos_per_guest > 0); + +comment on column photobooth.events.max_photos_per_guest is + 'Nombre max de photos qu''un même invité peut envoyer pour cet événement. NULL = illimité. Réglé à la création de l''event (pas d''UI admin dédiée en V1).'; + +alter table photobooth.photos + add column if not exists guest_id uuid; + +comment on column photobooth.photos.guest_id is + 'Identifiant anonyme côté client (localStorage), pour appliquer max_photos_per_guest. Pas une identité vérifiée.'; + +create index if not exists idx_photos_event_guest + on photobooth.photos (event_id, guest_id); + +-- get_public_event : ajoute max_photos_per_guest pour que le front puisse +-- bloquer l'UI proactivement (avant même une tentative d'envoi refusée). +drop function if exists public.get_public_event(text); + +create function public.get_public_event(p_slug text) +returns table ( + id uuid, + nom text, + statut text, + date_evenement date, + expire_at timestamptz, + max_photos_per_guest integer +) +language sql +stable +security definer +set search_path = public, photobooth, pg_temp +as $$ + select e.id, e.nom, e.statut, e.date_evenement, e.expire_at, e.max_photos_per_guest + from photobooth.events e + where e.slug = p_slug; +$$; + +comment on function public.get_public_event(text) is + 'Résout un slug public en informations minimales d''événement (photobooth.events, y compris max_photos_per_guest), pour la page d''upload.'; + +revoke all on function public.get_public_event(text) from public; +grant execute on function public.get_public_event(text) to anon; + +-- insert_public_photo : ancienne signature (4 arguments, sans guest_id) +-- supprimée -- elle créait une ambiguïté de résolution de surcharge côté +-- PostgREST (erreur PGRST203) et permettrait sinon de contourner +-- max_photos_per_guest en omettant guest_id. +drop function if exists public.insert_public_photo(uuid, text, text, text); + +create or replace function public.insert_public_photo( + p_event_id uuid, + p_url_storage text, + p_nom_invite text default null, + p_message text default null, + p_guest_id uuid default null +) +returns uuid +language plpgsql +security definer +set search_path = public, photobooth, storage, pg_temp +as $$ +declare + v_photo_id uuid; + v_max_per_guest integer; + v_current_count integer; +begin + if not public.event_accepts_uploads(p_event_id) then + raise exception 'event_not_accepting_uploads' + using errcode = 'P0001', + detail = 'L''événement est inconnu, inactif ou expiré.'; + end if; + + if not starts_with(p_url_storage, p_event_id::text || '/') then + raise exception 'url_storage_event_mismatch' + using errcode = 'P0001', + detail = 'url_storage doit commencer par "/".'; + end if; + + if not exists ( + select 1 from storage.objects + where bucket_id = 'event-photos' + and name = p_url_storage + ) then + raise exception 'storage_object_not_found' + using errcode = 'P0001', + detail = 'Aucun fichier trouvé dans Storage pour ce chemin. Uploadez le fichier avant d''enregistrer la ligne.'; + end if; + + select max_photos_per_guest into v_max_per_guest + from photobooth.events + where id = p_event_id; + + if v_max_per_guest is not null then + if p_guest_id is null then + raise exception 'guest_id_required' + using errcode = 'P0001', + detail = 'Cet événement limite le nombre de photos par invité : un identifiant invité est requis.'; + end if; + + select count(*) into v_current_count + from photobooth.photos + where event_id = p_event_id + and guest_id = p_guest_id; + + if v_current_count >= v_max_per_guest then + raise exception 'guest_photo_limit_reached' + using errcode = 'P0001', + detail = format('Limite de %s photo(s) par invité atteinte pour cet événement.', v_max_per_guest); + end if; + end if; + + insert into photobooth.photos (event_id, url_storage, nom_invite, message, guest_id) + values (p_event_id, p_url_storage, p_nom_invite, p_message, p_guest_id) + returning id into v_photo_id; + + return v_photo_id; +end; +$$; + +comment on function public.insert_public_photo(uuid, text, text, text, uuid) is + 'Chemin d''écriture unique pour les invités. Revalide event_accepts_uploads(), la cohérence url_storage/event_id, l''existence du fichier Storage, ET la limite max_photos_per_guest (par guest_id anonyme côté client) si définie sur l''event.'; + +revoke all on function public.insert_public_photo(uuid, text, text, text, uuid) from public; +grant execute on function public.insert_public_photo(uuid, text, text, text, uuid) to anon;