* 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]>
77 lines
3.1 KiB
JavaScript
77 lines
3.1 KiB
JavaScript
/**
|
|
* Reveal scheduler (#838). Runs every minute (the expiration checker's
|
|
* hourly cadence is too coarse for a party reveal) and stamps revealed_at on
|
|
* events whose scheduled reveal_at has passed.
|
|
*
|
|
* The stamp is bookkeeping, not the gate: the gallery routes compute
|
|
* effective visibility from reveal_at at request time, so the reveal happens
|
|
* exactly on schedule even if this job lags. The scheduler makes the state
|
|
* durable, writes the activity log entry, and emits the `gallery.revealed`
|
|
* workflow trigger so hosts can hook a notification email onto it.
|
|
*/
|
|
|
|
const cron = require('node-cron');
|
|
const { db, logActivity } = require('../database/db');
|
|
const { formatBoolean } = require('../utils/dbCompat');
|
|
const logger = require('../utils/logger');
|
|
|
|
async function checkScheduledReveals() {
|
|
try {
|
|
const now = new Date().toISOString();
|
|
const due = await db('events')
|
|
.where('reveal_mode', formatBoolean(true))
|
|
.whereNull('revealed_at')
|
|
.whereNotNull('reveal_at')
|
|
.where('reveal_at', '<=', now)
|
|
.where('is_active', formatBoolean(true))
|
|
.where('is_archived', formatBoolean(false))
|
|
// Drafts aren't guest-reachable — stamping/notifying would burn the
|
|
// reveal before publication and fire workflows for a dead link.
|
|
.where('is_draft', formatBoolean(false));
|
|
|
|
for (const event of due) {
|
|
// Conditional update: another worker (multi-replica) may have stamped
|
|
// it between the select and here — exactly one emits the events.
|
|
// Consume the schedule like "Reveal now" does — a stale past
|
|
// reveal_at would otherwise instantly re-open the gate when the
|
|
// gallery is later re-armed without a fresh schedule.
|
|
const stamped = await db('events')
|
|
.where('id', event.id)
|
|
.whereNull('revealed_at')
|
|
.update({ revealed_at: event.reveal_at, reveal_at: null });
|
|
if (stamped !== 1) continue;
|
|
|
|
logger.info('Reveal mode: scheduled reveal fired', { eventId: event.id, slug: event.slug });
|
|
await logActivity('gallery_revealed', { scheduled: true, reveal_at: event.reveal_at }, event.id);
|
|
|
|
// Best-effort trigger for custom notification flows — never throws
|
|
// into the scheduler and no-ops when nothing subscribes.
|
|
try {
|
|
await require('./workflows').emitWorkflowEvent('gallery.revealed', {
|
|
entityType: 'event',
|
|
entityId: event.id,
|
|
dedupSuffix: String(new Date(event.reveal_at).getTime()),
|
|
payload: {
|
|
eventId: event.id,
|
|
slug: event.slug,
|
|
eventName: event.event_name,
|
|
revealedAt: event.reveal_at,
|
|
scheduled: true,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
logger.warn('Failed to emit gallery.revealed workflow event', { eventId: event.id, error: err.message });
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('Reveal scheduler pass failed:', error);
|
|
}
|
|
}
|
|
|
|
function startRevealScheduler() {
|
|
cron.schedule('* * * * *', checkScheduledReveals);
|
|
logger.info('Reveal scheduler started');
|
|
}
|
|
|
|
module.exports = { startRevealScheduler, checkScheduledReveals };
|