feat(gallery): reveal mode — hide gallery from guests until reveal (#838) (#856)

* feat(gallery): reveal mode — hide gallery from guests until reveal (#838)

Guests can upload during the event but see no photos until the host
reveals the gallery, manually ("Reveal now") or at a scheduled time.

- migration 165: events.reveal_mode / reveal_at / revealed_at. Effective
  visibility is computed at REQUEST time (reveal_at <= now opens the
  gate exactly on schedule); the minutely scheduler only stamps
  revealed_at durably and emits a gallery.revealed workflow trigger
- server-side enforcement in gallery.js: /photos returns the event
  shell with photos: [] + hidden_until_reveal for plain guests;
  image/download/stats endpoints 403 with GALLERY_HIDDEN (photo IDs are
  sequential — listing-only gating would be probeable); feedback-summary
  gated too. Slideshow tokens (surprise beamer), client access and the
  admin preview bypass; the guest upload route stays open
- admin: reveal toggle + optional scheduled datetime next to the guest
  upload settings, status line and "Reveal now" button on the overview;
  re-enabling the toggle clears revealed_at so a gallery can re-hide
- guest UI: upload-only view (hero, friendly message, scheduled time,
  upload button) for every layout; i18n for all 8 locales
- timestamps written as ISO strings — the SQLite driver stringifies raw
  Date objects into garbage; ISO round-trips on both engines
- 14 integration tests over minted gallery/slideshow/client/admin tokens

* fix(gallery): reveal/re-arm semantics + upload button i18n key (#838)

- "Reveal now" also clears a pending reveal_at: the schedule is
  consumed, so the full-form admin save can't accidentally re-hide a
  revealed gallery with a stale future date
- setting a FUTURE reveal_at on a revealed gallery re-arms hiding —
  the one intentional way to re-hide without double-toggling the mode
- guest upload button uses the existing upload.uploadPhotos key
  (gallery.uploadPhotos never existed; the button showed EN everywhere)

* fix(gallery): close reveal bypasses from review round 1 (#838)

- the hero-derivative route and the secure-images token-mint +
  secure-download routes are now reveal-gated: hero serves a 1920px
  derivative of ANY sequential photo id and secure tokens fetch
  originals — both were open bypasses while hidden. blockHiddenGallery
  moved to utils/revealMode.js and shared
- customer-portal tokens (via:'customer', no accessLevel) now bypass
  reveal mode — they are the host/customer, not a guest, and were
  getting the upload-only view
- an open hidden guest view refetches exactly at reveal_at plus a 60s
  fallback poll, so the gallery appears without a manual reload
- gallery.revealed added to the workflow editor's trigger picker so
  the advertised notification hook is reachable in the UI
- migration 165 guards each column independently (partial-state safe)

* fix(gallery): reveal round 2 — remaining bypass surfaces + lifecycle edges (#838)

- legacy /api/images router reveal-gated (view, secure-token + signed-url
  minting), and the signed-URL SERVE path re-checks hidden state via a
  backward-compatible bypass flag in the token payload
- secure-image tokens record revealBypass at mint and are re-validated
  at serve time — a re-hide kills in-flight guest tokens within the
  request, while slideshow/client tokens keep working
- OG metadata and the unauthenticated /og cover fall back to the brand
  logo / 404 while hidden — no hero-photo spoiler for social crawlers
- photo-feedback GET/POST reveal-gated (sequential ids were enumerable);
  /my-feedback returns the empty back-compat shape (rows leak filename +
  storage path)
- the reveal scheduler skips drafts — no premature stamp/notification
  for unpublished galleries
- emitWorkflowEvent gains an additive dedupSuffix; both reveal emitters
  pass the reveal timestamp so a re-hidden gallery's second reveal
  fires workflows again instead of deduping into silence

* fix(gallery): reveal round 3 — schedule consumption + two-way client sync (#838)

- the scheduler now consumes reveal_at when stamping (matching "Reveal
  now"), and re-arming via a partial API update clears a stale PAST
  schedule — previously {reveal_mode:true} without reveal_at could
  instantly re-open the gate through the leftover date
- /photos exposes reveal_armed so an open VISIBLE gallery keeps a 60s
  poll while the mode is on — a re-hide now propagates to open clients
  in both directions, not just hidden→visible

Codex round-3 claim about timestamp-without-timezone drift on non-UTC
Postgres was verified FALSE: knex's table.timestamp() creates
timestamptz on PG (confirmed via information_schema on a live install),
which stores absolute instants regardless of server TZ.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-07-22 20:59:59 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 3d6c9848dc
commit 2f05fcc39d
29 changed files with 1108 additions and 24 deletions
+15
View File
@@ -813,6 +813,10 @@
}
},
"gallery": {
"revealPendingTitle": "Die Fotos sind noch eine Überraschung",
"revealPendingMessage": "Der Gastgeber gibt die Galerie später frei — schauen Sie bald wieder vorbei!",
"revealScheduledFor": "Freigabe geplant für {{date}}",
"revealUploadHint": "Sie können schon jetzt eigene Fotos zur Sammlung beitragen:",
"expires": "Läuft ab",
"expired": "Abgelaufen",
"contactOrganizer": "Bitte kontaktieren Sie den Veranstalter, wenn Sie Zugriff auf diese Fotos benötigen.",
@@ -964,6 +968,17 @@
"failedToToggleDownloads": "Aktualisierung der Download-Berechtigung fehlgeschlagen"
},
"events": {
"revealMode": "Reveal-Modus (Galerie bis zur Freigabe verbergen)",
"revealModeHelp": "Gäste können hochladen, sehen aber keine Fotos, bis Sie die Galerie freigeben — manuell oder zum geplanten Zeitpunkt. Diashow und Kundenzugang funktionieren weiter.",
"revealAt": "Geplante Freigabe (optional)",
"revealAtHelp": "Leer lassen, um manuell mit „Jetzt freigeben\" freizugeben.",
"revealModeStatus": "Reveal-Modus",
"revealed": "Freigegeben",
"hiddenUntilReveal": "Für Gäste verborgen",
"revealScheduled": "Geplant: {{date}}",
"revealNow": "Jetzt freigeben",
"revealedToast": "Galerie freigegeben — Gäste sehen die Fotos jetzt",
"revealError": "Galerie konnte nicht freigegeben werden",
"totalPhotos": "Gesamtfotos",
"totalViews": "Gesamtaufrufe",
"totalDownloads": "Gesamte Downloads",
+15
View File
@@ -360,6 +360,10 @@
}
},
"gallery": {
"revealPendingTitle": "The photos are still a surprise",
"revealPendingMessage": "The host will reveal the gallery later — check back soon!",
"revealScheduledFor": "Reveal scheduled for {{date}}",
"revealUploadHint": "You can already add your own photos to the collection:",
"expires": "Expires",
"expired": "Expired",
"contactOrganizer": "Please contact the event organizer if you need access to these photos.",
@@ -511,6 +515,17 @@
"failedToToggleDownloads": "Failed to update download permission"
},
"events": {
"revealMode": "Reveal mode (hide gallery until reveal)",
"revealModeHelp": "Guests can upload but see no photos until you reveal the gallery — manually or at the scheduled time. Slideshow and client access keep working.",
"revealAt": "Scheduled reveal (optional)",
"revealAtHelp": "Leave empty to reveal manually with the \"Reveal now\" button.",
"revealModeStatus": "Reveal mode",
"revealed": "Revealed",
"hiddenUntilReveal": "Hidden from guests",
"revealScheduled": "Scheduled: {{date}}",
"revealNow": "Reveal now",
"revealedToast": "Gallery revealed — guests can see the photos now",
"revealError": "Failed to reveal the gallery",
"title": "Events",
"create": "Create",
"createEvent": "Create Event",
+15
View File
@@ -223,6 +223,10 @@
"passwordHint": "La contraseña la proporcionó el organizador del evento. Contacta con ellos si no la tienes."
},
"gallery": {
"revealPendingTitle": "Las fotos siguen siendo una sorpresa",
"revealPendingMessage": "El anfitrión revelará la galería más tarde — ¡vuelve pronto!",
"revealScheduledFor": "Revelación programada para {{date}}",
"revealUploadHint": "Ya puedes añadir tus propias fotos a la colección:",
"title": "Galería de fotos",
"welcomeMessage": "Mensaje de bienvenida",
"expiresOn": "Expira el",
@@ -342,6 +346,17 @@
"categoryHeroHint": "Si no se establece una foto de portada para una categoría, se usará la foto hero por defecto."
},
"events": {
"revealMode": "Modo revelación (ocultar la galería hasta revelarla)",
"revealModeHelp": "Los invitados pueden subir fotos pero no verlas hasta que reveles la galería — manualmente o a la hora programada. La presentación y el acceso de clientes siguen funcionando.",
"revealAt": "Revelación programada (opcional)",
"revealAtHelp": "Déjalo vacío para revelar manualmente con el botón \"Revelar ahora\".",
"revealModeStatus": "Modo revelación",
"revealed": "Revelada",
"hiddenUntilReveal": "Oculta para los invitados",
"revealScheduled": "Programada: {{date}}",
"revealNow": "Revelar ahora",
"revealedToast": "Galería revelada — los invitados ya pueden ver las fotos",
"revealError": "No se pudo revelar la galería",
"title": "Eventos",
"create": "Crear",
"createEvent": "Crear evento",
+15
View File
@@ -221,6 +221,10 @@
"passwordHint": "Le mot de passe a été fourni par l'organisateur de l'événement. Contactez-le si vous ne l'avez pas."
},
"gallery": {
"revealPendingTitle": "Les photos sont encore une surprise",
"revealPendingMessage": "L'hôte dévoilera la galerie plus tard — revenez bientôt !",
"revealScheduledFor": "Révélation prévue le {{date}}",
"revealUploadHint": "Vous pouvez déjà ajouter vos propres photos à la collection :",
"expires": "Expire",
"expired": "Expiré",
"contactOrganizer": "Veuillez contacter l'organisateur de l'événement si vous avez besoin d'accéder à ces photos.",
@@ -358,6 +362,17 @@
"categoryHeroHint": "Si aucune photo de couverture n'est définie pour une catégorie, la photo par défaut sera utilisée."
},
"events": {
"revealMode": "Mode révélation (masquer la galerie jusqu'à la révélation)",
"revealModeHelp": "Les invités peuvent téléverser mais ne voient aucune photo tant que vous ne révélez pas la galerie — manuellement ou à l'heure programmée. Le diaporama et l'accès client continuent de fonctionner.",
"revealAt": "Révélation programmée (optionnel)",
"revealAtHelp": "Laissez vide pour révéler manuellement avec le bouton « Révéler maintenant ».",
"revealModeStatus": "Mode révélation",
"revealed": "Révélée",
"hiddenUntilReveal": "Masquée pour les invités",
"revealScheduled": "Programmée : {{date}}",
"revealNow": "Révéler maintenant",
"revealedToast": "Galerie révélée — les invités peuvent maintenant voir les photos",
"revealError": "Impossible de révéler la galerie",
"title": "Événements",
"create": "Créer",
"createEvent": "Créer un événement",
+15
View File
@@ -221,6 +221,10 @@
"passwordHint": "Het wachtwoord is verstrekt door de organisator van het evenement. Neem contact op als u het niet heeft."
},
"gallery": {
"revealPendingTitle": "De foto's zijn nog een verrassing",
"revealPendingMessage": "De gastheer onthult de galerij later — kom snel terug!",
"revealScheduledFor": "Onthulling gepland voor {{date}}",
"revealUploadHint": "Je kunt nu al je eigen foto's aan de collectie toevoegen:",
"expires": "Verloopt",
"expired": "Verlopen",
"contactOrganizer": "Neem contact op met de organisator als u toegang tot deze foto's nodig heeft.",
@@ -358,6 +362,17 @@
"categoryHeroHint": "Als er geen omslagfoto is ingesteld voor een categorie, wordt de standaard hero-foto gebruikt."
},
"events": {
"revealMode": "Onthullingsmodus (galerij verbergen tot de onthulling)",
"revealModeHelp": "Gasten kunnen uploaden maar zien geen foto's totdat je de galerij onthult — handmatig of op het geplande tijdstip. Diavoorstelling en klanttoegang blijven werken.",
"revealAt": "Geplande onthulling (optioneel)",
"revealAtHelp": "Laat leeg om handmatig te onthullen met de knop \"Nu onthullen\".",
"revealModeStatus": "Onthullingsmodus",
"revealed": "Onthuld",
"hiddenUntilReveal": "Verborgen voor gasten",
"revealScheduled": "Gepland: {{date}}",
"revealNow": "Nu onthullen",
"revealedToast": "Galerij onthuld — gasten kunnen de foto's nu zien",
"revealError": "Galerij onthullen mislukt",
"title": "Evenementen",
"create": "Aanmaken",
"createEvent": "Evenement aanmaken",
+15
View File
@@ -224,6 +224,10 @@
"passwordHint": "A senha foi fornecida pelo organizador do evento. Entre em contato se não a tiver."
},
"gallery": {
"revealPendingTitle": "As fotos ainda são uma surpresa",
"revealPendingMessage": "O anfitrião revelará a galeria mais tarde — volte em breve!",
"revealScheduledFor": "Revelação agendada para {{date}}",
"revealUploadHint": "Você já pode adicionar suas próprias fotos à coleção:",
"expires": "Expira",
"expired": "Expirada",
"contactOrganizer": "Entre em contato com o organizador do evento se precisar de acesso a estas fotos",
@@ -366,6 +370,17 @@
"categoryHeroHint": "Se nenhuma foto de capa for definida, a foto de destaque padrão será usada."
},
"events": {
"revealMode": "Modo revelação (ocultar a galeria até a revelação)",
"revealModeHelp": "Os convidados podem enviar fotos, mas não as veem até você revelar a galeria — manualmente ou no horário agendado. A apresentação de slides e o acesso do cliente continuam funcionando.",
"revealAt": "Revelação agendada (opcional)",
"revealAtHelp": "Deixe vazio para revelar manualmente com o botão \"Revelar agora\".",
"revealModeStatus": "Modo revelação",
"revealed": "Revelada",
"hiddenUntilReveal": "Oculta para os convidados",
"revealScheduled": "Agendada: {{date}}",
"revealNow": "Revelar agora",
"revealedToast": "Galeria revelada — os convidados já podem ver as fotos",
"revealError": "Falha ao revelar a galeria",
"title": "Eventos",
"create": "Criar",
"createEvent": "Criar Evento",
+15
View File
@@ -227,6 +227,10 @@
"passwordHint": "Пароль предоставлен организатором события. Свяжитесь с ним, если у вас его нет."
},
"gallery": {
"revealPendingTitle": "Фотографии пока остаются сюрпризом",
"revealPendingMessage": "Организатор откроет галерею позже — загляните ещё раз!",
"revealScheduledFor": "Открытие запланировано на {{date}}",
"revealUploadHint": "Вы уже можете добавить свои фотографии в коллекцию:",
"expires": "Истекает",
"expired": "Истекла",
"contactOrganizer": "Свяжитесь с организатором события, если вам нужен доступ к этим фотографиям",
@@ -374,6 +378,17 @@
"categoryHeroHint": "Если обложка для категории не задана, будет использоваться стандартное главное фото."
},
"events": {
"revealMode": "Режим сюрприза (скрыть галерею до открытия)",
"revealModeHelp": "Гости могут загружать фото, но не видят их, пока вы не откроете галерею — вручную или в запланированное время. Слайд-шоу и клиентский доступ продолжают работать.",
"revealAt": "Запланированное открытие (необязательно)",
"revealAtHelp": "Оставьте пустым, чтобы открыть вручную кнопкой «Открыть сейчас».",
"revealModeStatus": "Режим сюрприза",
"revealed": "Открыта",
"hiddenUntilReveal": "Скрыта от гостей",
"revealScheduled": "Запланировано: {{date}}",
"revealNow": "Открыть сейчас",
"revealedToast": "Галерея открыта — гости теперь видят фотографии",
"revealError": "Не удалось открыть галерею",
"title": "События",
"create": "Создать",
"createEvent": "Создать событие",
+15
View File
@@ -221,6 +221,10 @@
"passwordHint": "Geslo vam je posredoval organizator dogodka. Če ga nimate, se obrnite nanj."
},
"gallery": {
"revealPendingTitle": "Fotografije so še presenečenje",
"revealPendingMessage": "Gostitelj bo galerijo razkril pozneje — kmalu preverite znova!",
"revealScheduledFor": "Razkritje načrtovano za {{date}}",
"revealUploadHint": "Svoje fotografije lahko dodate v zbirko že zdaj:",
"expires": "Poteče",
"expired": "Poteklo",
"contactOrganizer": "Če potrebujete dostop do teh fotografij, se obrnite na organizatorja dogodka.",
@@ -358,6 +362,17 @@
"categoryHeroHint": "Če za kategorijo ni nastavljena naslovna fotografija, bo uporabljena privzeta hero fotografija."
},
"events": {
"revealMode": "Način razkritja (skrij galerijo do razkritja)",
"revealModeHelp": "Gostje lahko nalagajo fotografije, vendar jih ne vidijo, dokler galerije ne razkrijete — ročno ali ob načrtovanem času. Diaprojekcija in dostop za stranke delujeta naprej.",
"revealAt": "Načrtovano razkritje (neobvezno)",
"revealAtHelp": "Pustite prazno za ročno razkritje z gumbom »Razkrij zdaj«.",
"revealModeStatus": "Način razkritja",
"revealed": "Razkrita",
"hiddenUntilReveal": "Skrita za goste",
"revealScheduled": "Načrtovano: {{date}}",
"revealNow": "Razkrij zdaj",
"revealedToast": "Galerija razkrita — gostje zdaj vidijo fotografije",
"revealError": "Galerije ni bilo mogoče razkriti",
"title": "Dogodki",
"create": "Ustvari",
"createEvent": "Ustvari dogodek",