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:
@@ -48,6 +48,18 @@ par Kévin.
|
|||||||
adapté. Risque jugé faible : les slugs sont partagés via QR code, donc
|
adapté. Risque jugé faible : les slugs sont partagés via QR code, donc
|
||||||
non secrets par nature.
|
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)
|
## 2026-09-16 (ter)
|
||||||
|
|
||||||
- **Domaine de production (V1)** : `photobooth.assistantaikev.eu`, sous-domaine
|
- **Domaine de production (V1)** : `photobooth.assistantaikev.eu`, sous-domaine
|
||||||
|
|||||||
+83
-10
@@ -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.",
|
"Cet événement n'existe pas, ou le lien utilisé est incorrect. Vérifie le QR code ou le lien.",
|
||||||
"event-closed":
|
"event-closed":
|
||||||
"Cet événement n'accepte plus de photos (il est clos). Merci quand même d'avoir essayé !",
|
"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-missing": "Choisis d'abord une photo avant d'envoyer.",
|
||||||
"file-not-image":
|
"file-not-image":
|
||||||
"Ce fichier n'est pas une photo. Choisis une image (JPEG, PNG, HEIC…).",
|
"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;
|
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) {
|
function isHeicFile(file) {
|
||||||
const type = (file.type || "").toLowerCase();
|
const type = (file.type || "").toLowerCase();
|
||||||
if (type === "image/heic" || type === "image/heif") return true;
|
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 bloquantes de chargement d'événement : on masque le formulaire.
|
||||||
// Erreurs liées à une tentative d'envoi : le formulaire reste visible.
|
// 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;
|
form.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +349,12 @@ async function initEventCheck() {
|
|||||||
try {
|
try {
|
||||||
const event = await fetchEventPublicInfo(slug);
|
const event = await fetchEventPublicInfo(slug);
|
||||||
if (!isEventActive(event)) throw appError("event-closed");
|
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;
|
state.event = event;
|
||||||
showForm(event);
|
showForm(event);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -418,6 +475,7 @@ async function insertPhotoRow(eventId, path, nom, message) {
|
|||||||
p_url_storage: path,
|
p_url_storage: path,
|
||||||
p_nom_invite: nom || null,
|
p_nom_invite: nom || null,
|
||||||
p_message: message || null,
|
p_message: message || null,
|
||||||
|
p_guest_id: getGuestId(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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.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…");
|
setProgress("Dernière étape…");
|
||||||
await insertPhotoRow(state.event.id, path, nomInput.value.trim(), messageInput.value.trim());
|
await insertPhotoRow(state.event.id, path, nomInput.value.trim(), messageInput.value.trim());
|
||||||
|
|
||||||
|
const slug = getSlugFromPath();
|
||||||
|
if (slug) incrementLocalGuestPhotoCount(slug);
|
||||||
|
|
||||||
setProgress("");
|
setProgress("");
|
||||||
showSuccess();
|
showSuccess();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -529,15 +599,18 @@ retryBtn.addEventListener("click", () => {
|
|||||||
|
|
||||||
addAnotherBtn.addEventListener("click", () => {
|
addAnotherBtn.addEventListener("click", () => {
|
||||||
form.reset();
|
form.reset();
|
||||||
state.selectedFile = null;
|
resetPhotoFields();
|
||||||
state.compressedBlob = null;
|
|
||||||
fileChosenLabel.textContent = "Aucun fichier choisi";
|
|
||||||
previewImg.hidden = true;
|
|
||||||
if (previewObjectUrl) {
|
|
||||||
URL.revokeObjectURL(previewObjectUrl);
|
|
||||||
previewObjectUrl = null;
|
|
||||||
}
|
|
||||||
successBlock.hidden = true;
|
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;
|
form.hidden = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 "<event_id>/".';
|
||||||
|
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;
|
||||||
Reference in New Issue
Block a user