Ajoute une limite de photos par invité, réglable par événement

Nouvelle colonne max_photos_per_guest (NULL = illimité). Appliquée via un
guest_id anonyme persisté en localStorage — pas une identité vérifiée,
contournable en changeant d'appareil, mais fait respecter côté serveur
(source de vérité) et bloque l'UI proactivement côté client. Ancienne
signature de insert_public_photo (sans guest_id) supprimée pour empêcher
tout contournement.

Testé en conditions réelles sur kevin-lecou-hub (2 photos acceptées,
3e refusée avec max_photos_per_guest=2).
This commit is contained in:
2026-09-16 15:27:05 +02:00
parent 73143f0ec2
commit 66435b0705
3 changed files with 239 additions and 10 deletions
+83 -10
View File
@@ -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;
});