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 <paul@MacStudio-von-Paul.local>
This commit is contained in:
Paul Nothaft
2026-07-22 20:59:59 +02:00
committed by GitHub
parent 3d6c9848dc
commit 2f05fcc39d
29 changed files with 1108 additions and 24 deletions
@@ -0,0 +1,402 @@
/**
* Reveal mode integration tests (#838).
*
* Pins the contract:
* - effective visibility is computed at request time (isGalleryHidden):
* reveal_at in the past opens the gate even before the scheduler stamps
* - /photos returns the event shell with photos: [] + hidden_until_reveal
* for plain guests; slideshow / client / admin-preview see everything
* - image + download endpoints 403 with GALLERY_HIDDEN for plain guests
* - the guest upload route is NOT gated (uploading while hidden is the point)
* - the scheduler stamps revealed_at for due events, exactly once
* - POST /events/:id/reveal stamps revealed_at (idempotent, 400 when the
* mode is off); re-enabling reveal_mode clears revealed_at (re-hide)
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'reveal-test-secret';
const SLUG = 'reveal-test-event';
describe('Reveal mode (#838)', () => {
let db;
let cleanup;
let app;
let eventId;
let photoIds;
let adminToken;
const { isGalleryHidden } = require('../../src/utils/revealMode');
const galleryToken = (extra = {}) => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery', ...extra },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Reveal Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'reveal-test-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
allow_user_uploads: 1,
reveal_mode: 1,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
photoIds = [];
for (let i = 0; i < 2; i++) {
const p = await db('photos').insert({
event_id: eventId,
filename: `photo-${i}.jpg`,
path: `events/reveal/${i}.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoIds.push(p[0]?.id ?? p[0]);
}
// Super admin for the admin routes.
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'reveal-admin',
email: 'reveal-admin@example.com',
password_hash: await bcrypt.hash('RevealAdmin123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'reveal-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/secure-images', require('../../src/routes/secureImages'));
app.use('/api/images', require('../../src/routes/protectedImages'));
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('effective visibility math (isGalleryHidden)', () => {
const base = { reveal_mode: true, revealed_at: null, reveal_at: null };
it('is hidden while armed and unrevealed, visible otherwise', () => {
expect(isGalleryHidden({ ...base })).toBe(true);
expect(isGalleryHidden({ ...base, reveal_mode: false })).toBe(false);
expect(isGalleryHidden({ ...base, revealed_at: new Date() })).toBe(false);
// reveal_at in the past opens the gate WITHOUT any stamp — time-exact.
expect(isGalleryHidden({ ...base, reveal_at: new Date(Date.now() - 60_000) })).toBe(false);
expect(isGalleryHidden({ ...base, reveal_at: new Date(Date.now() + 60_000) })).toBe(true);
// SQLite 0/1 booleans
expect(isGalleryHidden({ reveal_mode: 1, revealed_at: null, reveal_at: null })).toBe(true);
expect(isGalleryHidden({ reveal_mode: 0, revealed_at: null, reveal_at: null })).toBe(false);
});
});
describe('gallery routes while hidden', () => {
it('/photos gives plain guests the shell with no photos and the flag', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(true);
expect(res.body.photos).toEqual([]);
expect(res.body.categories).toEqual([]);
expect(res.body.event.event_name).toBe('Reveal Test');
});
it('/photos serves the slideshow token everything (surprise beamer)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken({ accessLevel: 'slideshow' })}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
});
it('/photos serves client access everything (host review)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken({ accessLevel: 'client' })}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
});
it('/photos serves the admin preview everything', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos?preview=${encodeURIComponent(adminToken)}`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
});
it('image and download endpoints 403 with GALLERY_HIDDEN for plain guests', async () => {
for (const url of [
`/api/gallery/${SLUG}/thumbnail/${photoIds[0]}`,
`/api/gallery/${SLUG}/photo/${photoIds[0]}`,
`/api/gallery/${SLUG}/download/${photoIds[0]}`,
`/api/gallery/${SLUG}/download-all`,
`/api/gallery/${SLUG}/stats`,
`/api/gallery/${SLUG}/hero/${photoIds[0]}`,
]) {
const res = await request(app).get(url).set('Authorization', `Bearer ${galleryToken()}`);
expect(`${url}:${res.status}`).toBe(`${url}:403`);
expect(res.body.code).toBe('GALLERY_HIDDEN');
}
});
it('image endpoints are NOT reveal-blocked for the slideshow token', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/thumbnail/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken({ accessLevel: 'slideshow' })}`);
// The seeded file doesn't exist on disk, so anything but the reveal
// gate's 403 is fine here.
expect(res.body.code).not.toBe('GALLERY_HIDDEN');
});
it('/info exposes the effective hidden state without auth', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/info`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(true);
});
it('the guest upload route is not gated', async () => {
const res = await request(app)
.post(`/api/gallery/${eventId}/upload`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({});
// Fails later for other reasons (no multipart body) — but never on the
// reveal gate.
expect(res.body.code).not.toBe('GALLERY_HIDDEN');
});
it('legacy protected-image routes are reveal-gated for plain guests', async () => {
for (const [method, url] of [
['get', `/api/images/${SLUG}/photo/${photoIds[0]}/view`],
['post', `/api/images/${SLUG}/photo/${photoIds[0]}/generate-secure-token`],
['post', `/api/images/${SLUG}/photo/${photoIds[0]}/generate-url`],
]) {
const res = await request(app)[method](url).set('Authorization', `Bearer ${galleryToken()}`);
expect(`${url}:${res.status}`).toBe(`${url}:403`);
expect(res.body.code).toBe('GALLERY_HIDDEN');
}
});
it('feedback endpoints are reveal-gated; my-feedback degrades to empty', async () => {
// Feedback must be enabled for the routes to get past their own gate.
await db('event_feedback_settings').insert({
event_id: eventId, feedback_enabled: 1, allow_likes: 1,
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
});
const getRes = await request(app)
.get(`/api/gallery/${SLUG}/photos/${photoIds[0]}/feedback`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(getRes.status).toBe(403);
expect(getRes.body.code).toBe('GALLERY_HIDDEN');
const postRes = await request(app)
.post(`/api/gallery/${SLUG}/photos/${photoIds[0]}/feedback`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({ feedback_type: 'like' });
expect(postRes.status).toBe(403);
expect(postRes.body.code).toBe('GALLERY_HIDDEN');
const mine = await request(app)
.get(`/api/gallery/${SLUG}/my-feedback`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(mine.status).toBe(200);
expect(mine.body).toEqual([]);
});
it('secure-image token minting is reveal-gated for plain guests', async () => {
const res = await request(app)
.post(`/api/secure-images/${SLUG}/generate-token`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({ photoId: photoIds[0] });
expect(res.status).toBe(403);
expect(res.body.code).toBe('GALLERY_HIDDEN');
});
it('customer-portal tokens (via:customer, no accessLevel) bypass reveal mode', async () => {
const acct = await db('customer_accounts').insert({
email: 'portal-customer@example.com',
password_hash: 'x',
is_active: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id');
const customerId = acct[0]?.id ?? acct[0];
await db('event_customer_assignments').insert({
event_id: eventId,
customer_account_id: customerId,
});
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken({ via: 'customer', customerId })}`);
expect(res.status).toBe(200);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
});
it('a reveal_at in the past opens the gate without any stamp', async () => {
await db('events').where('id', eventId).update({ reveal_at: new Date(Date.now() - 60_000).toISOString() });
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.body.hidden_until_reveal).toBe(false);
expect(res.body.photos).toHaveLength(2);
await db('events').where('id', eventId).update({ reveal_at: null });
});
});
describe('scheduler and admin reveal', () => {
it('the scheduler stamps revealed_at for due events exactly once', async () => {
const revealAt = new Date(Date.now() - 5 * 60_000);
await db('events').where('id', eventId).update({ reveal_at: revealAt.toISOString(), revealed_at: null });
const { checkScheduledReveals } = require('../../src/services/revealScheduler');
await checkScheduledReveals();
const asMs = (v) => new Date(v).getTime();
const row = await db('events').where('id', eventId).first();
expect(row.revealed_at).not.toBeNull();
expect(asMs(row.revealed_at)).toBe(revealAt.getTime());
expect(row.reveal_at).toBeNull(); // schedule consumed, like "Reveal now"
// Second pass no-ops (revealed_at already set).
await checkScheduledReveals();
const again = await db('events').where('id', eventId).first();
expect(asMs(again.revealed_at)).toBe(revealAt.getTime());
await db('events').where('id', eventId).update({ reveal_at: null, revealed_at: null });
});
it('POST /:id/reveal stamps revealed_at, clears the schedule, and is idempotent', async () => {
await db('events').where('id', eventId).update({ reveal_at: new Date(Date.now() + 3600_000).toISOString() });
const res = await request(app)
.post(`/api/admin/events/${eventId}/reveal`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.body.revealed_at).toBeTruthy();
// "Reveal now" consumes the pending schedule.
const cleared = await db('events').where('id', eventId).first();
expect(cleared.reveal_at).toBeNull();
const first = res.body.revealed_at;
const res2 = await request(app)
.post(`/api/admin/events/${eventId}/reveal`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res2.status).toBe(200);
expect(res2.body.revealed_at).toBe(first);
// Guests see photos now.
const gallery = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(gallery.body.hidden_until_reveal).toBe(false);
expect(gallery.body.photos).toHaveLength(2);
});
it('re-enabling reveal_mode clears revealed_at (re-hide)', async () => {
await db('events').where('id', eventId).update({ reveal_mode: 0 });
const res = await request(app)
.put(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ reveal_mode: true });
expect(res.status).toBe(200);
const row = await db('events').where('id', eventId).first();
expect(row.revealed_at).toBeNull();
const gallery = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(gallery.body.hidden_until_reveal).toBe(true);
});
it('scheduling a FUTURE reveal on a revealed gallery re-arms hiding', async () => {
// State: revealed (previous tests). Saving a future schedule re-hides.
await db('events').where('id', eventId).update({ revealed_at: new Date().toISOString() });
const res = await request(app)
.put(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ reveal_mode: true, reveal_at: new Date(Date.now() + 3600_000).toISOString() });
expect(res.status).toBe(200);
const row = await db('events').where('id', eventId).first();
expect(row.revealed_at).toBeNull();
const gallery = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(gallery.body.hidden_until_reveal).toBe(true);
await db('events').where('id', eventId).update({ reveal_at: null });
});
it('re-arming without a schedule clears a stale PAST reveal_at', async () => {
// Legacy/partial-API state: revealed with the old past schedule still
// stored. {reveal_mode:false} then {reveal_mode:true} without
// reveal_at must re-hide, not instantly re-open via the stale date.
await db('events').where('id', eventId).update({
reveal_mode: 0,
revealed_at: new Date().toISOString(),
reveal_at: new Date(Date.now() - 3600_000).toISOString(),
});
const res = await request(app)
.put(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ reveal_mode: true });
expect(res.status).toBe(200);
const row = await db('events').where('id', eventId).first();
expect(row.revealed_at).toBeNull();
expect(row.reveal_at).toBeNull();
const gallery = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(gallery.body.hidden_until_reveal).toBe(true);
expect(gallery.body.photos).toEqual([]);
});
it('POST /:id/reveal 400s while reveal mode is off', async () => {
await db('events').where('id', eventId).update({ reveal_mode: 0, revealed_at: null });
const res = await request(app)
.post(`/api/admin/events/${eventId}/reveal`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(400);
await db('events').where('id', eventId).update({ reveal_mode: 1 });
});
});
});
@@ -0,0 +1,44 @@
/**
* Reveal mode (#838): hide the gallery from guests until a manual or
* scheduled reveal — guests can still upload, the host/admin/slideshow see
* everything.
*
* - events.reveal_mode: the per-event toggle (only meaningful together with
* allow_user_uploads; off by default so nothing changes for existing events)
* - events.reveal_at: optional scheduled reveal time. Effective visibility is
* computed at REQUEST time (reveal_at <= now opens the gate even before the
* scheduler runs), the minutely scheduler only stamps revealed_at durably.
* - events.revealed_at: set by "Reveal now" or the scheduler; NULL while
* hidden. Re-enabling reveal_mode clears it (re-hide).
*/
// Each column guarded independently: a partially applied prior run (or a
// fork that added one of them) must not leave the others missing — the
// routes select all three.
exports.up = async function (knex) {
if (!(await knex.schema.hasColumn('events', 'reveal_mode'))) {
await knex.schema.alterTable('events', (table) => {
table.boolean('reveal_mode').defaultTo(false);
});
}
if (!(await knex.schema.hasColumn('events', 'reveal_at'))) {
await knex.schema.alterTable('events', (table) => {
table.timestamp('reveal_at').nullable();
});
}
if (!(await knex.schema.hasColumn('events', 'revealed_at'))) {
await knex.schema.alterTable('events', (table) => {
table.timestamp('revealed_at').nullable();
});
}
};
exports.down = async function (knex) {
for (const column of ['revealed_at', 'reveal_at', 'reveal_mode']) {
if (await knex.schema.hasColumn('events', column)) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn(column);
});
}
}
};
+3
View File
@@ -20,6 +20,7 @@ const path = require('path');
const { initializeDatabase, db } = require('./src/database/db');
const { startFileWatcher } = require('./src/services/fileWatcher');
const { startExpirationChecker } = require('./src/services/expirationChecker');
const { startRevealScheduler } = require('./src/services/revealScheduler');
const { startInvoiceScheduler } = require('./src/services/invoiceSchedulerService');
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
const { startBackupService } = require('./src/services/backupService');
@@ -903,6 +904,8 @@ async function startServer() {
// Start expiration checker
startExpirationChecker();
// Reveal-mode scheduler (#838): minutely stamp for scheduled reveals.
startRevealScheduler();
// CRM invoice scheduler: hourly tick to flush scheduled-send invoices
// + run the overdue reminder ladder. No-op when the `bills` feature
// flag is OFF (the service short-circuits on empty result sets).
+88
View File
@@ -1177,6 +1177,9 @@ module.exports = (router) => {
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
body('color_theme').optional({ nullable: true }),
body('allow_user_uploads').optional().isBoolean(),
// Reveal mode (#838): hide the gallery from guests until reveal.
body('reveal_mode').optional().isBoolean(),
body('reveal_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
// Migration 143 — per-event reminder overrides. All three are
// optional; nullable values are accepted so admins can clear an
// override (e.g. drop a custom offset back to the global default).
@@ -1489,6 +1492,39 @@ module.exports = (router) => {
}
}
// Reveal mode (#838). Turning the toggle ON from off clears
// revealed_at, so a gallery can be re-hidden after a reveal;
// reveal_at accepts null/'' to drop a schedule.
if (Object.prototype.hasOwnProperty.call(updates, 'reveal_mode')) {
const nextRevealMode = parseBooleanInput(updates.reveal_mode, false);
updates.reveal_mode = formatBoolean(nextRevealMode);
const wasOn = event.reveal_mode === true || event.reveal_mode === 1 || event.reveal_mode === '1';
if (nextRevealMode && !wasOn) {
updates.revealed_at = null;
// A stale PAST schedule from a previous cycle would instantly
// re-open the gate on re-arm. Only bites partial API updates —
// isGalleryHidden() with the stamp cleared tells us whether the
// stored schedule still hides anything.
const { isGalleryHidden } = require('../../utils/revealMode');
if (!Object.prototype.hasOwnProperty.call(updates, 'reveal_at')
&& event.reveal_at
&& !isGalleryHidden({ reveal_mode: true, revealed_at: null, reveal_at: event.reveal_at })) {
updates.reveal_at = null;
}
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'reveal_at')) {
// ISO string, not a Date object — the SQLite driver stringifies raw
// Dates uselessly; ISO round-trips on both engines.
updates.reveal_at = updates.reveal_at ? new Date(updates.reveal_at).toISOString() : null;
// Scheduling a FUTURE reveal on an already-revealed gallery re-arms
// hiding — that's the only way this state can be reached, since
// "Reveal now" and the scheduler both clear/consume the schedule.
if (updates.reveal_at && new Date(updates.reveal_at) > new Date() && event.revealed_at) {
updates.revealed_at = null;
}
}
// Handle client access fields (#172)
if (Object.prototype.hasOwnProperty.call(updates, 'client_access_enabled')) {
updates.client_access_enabled = formatBoolean(updates.client_access_enabled);
@@ -1543,6 +1579,58 @@ module.exports = (router) => {
});
// Delete event
// Reveal now (#838): stamp revealed_at so the gallery opens for guests
// immediately. Idempotent — revealing an already-revealed event no-ops.
router.post('/:id/reveal', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const isOn = event.reveal_mode === true || event.reveal_mode === 1 || event.reveal_mode === '1';
if (!isOn) {
return res.status(400).json({ error: 'Reveal mode is not enabled for this event' });
}
const now = new Date().toISOString();
// Also clear a pending schedule — "reveal now" makes it obsolete, and
// a stale future reveal_at would re-arm hiding on the next form save.
const stamped = await db('events')
.where('id', id)
.whereNull('revealed_at')
.update({ revealed_at: now, reveal_at: null });
if (stamped === 1) {
await logActivity('gallery_revealed', { scheduled: false }, id, {
type: 'admin', id: req.admin.id, name: req.admin.username,
});
try {
await require('../../services/workflows').emitWorkflowEvent('gallery.revealed', {
entityType: 'event',
entityId: parseInt(id, 10),
dedupSuffix: String(new Date(now).getTime()),
payload: {
eventId: parseInt(id, 10),
slug: event.slug,
eventName: event.event_name,
revealedAt: now,
scheduled: false,
},
});
} catch (e) {
logger.warn('Failed to emit gallery.revealed workflow event', { eventId: id, error: e.message });
}
}
const fresh = await db('events').where('id', id).first();
res.json({ message: 'Gallery revealed', revealed_at: fresh.revealed_at });
} catch (error) {
logger.error('Failed to reveal gallery:', error);
res.status(500).json({ error: 'Failed to reveal gallery' });
}
});
router.delete('/:id', adminAuth, requirePermission('events.delete'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
+35 -6
View File
@@ -28,6 +28,7 @@ const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
const { isGalleryHidden, guestBlockedByReveal, blockHiddenGallery } = require('../utils/revealMode');
const { NotFoundError } = require('../utils/errors');
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
const downloadZipService = require('../services/downloadZipService');
@@ -205,6 +206,9 @@ router.get('/:slug/info', async (req, res) => {
'share_token',
'allow_downloads',
'allow_user_uploads',
'reveal_mode',
'reveal_at',
'revealed_at',
'disable_right_click',
'watermark_downloads',
'watermark_text',
@@ -275,6 +279,10 @@ router.get('/:slug/info', async (req, res) => {
color_theme: event.color_theme,
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
allow_user_uploads: event.allow_user_uploads === true || event.allow_user_uploads === 1 || event.allow_user_uploads === '1',
// Reveal mode (#838): effective hidden state (computed, time-exact) so
// the landing page can hint at the reveal before login too.
hidden_until_reveal: isGalleryHidden(event),
reveal_at: isGalleryHidden(event) ? (event.reveal_at || null) : null,
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
watermark_text: event.watermark_text,
@@ -622,8 +630,15 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
photosQuery = photosQuery.orderBy('photos.uploaded_at', sortOrder);
}
// Reveal mode (#838): while the gallery is hidden, plain guests get
// the event shell with an empty photo/category set plus the
// hidden_until_reveal flag — the frontend renders the upload-only view
// from it. Slideshow, client access and the admin preview bypass
// (guestBlockedByReveal). Enforced here, not just in the UI.
const hiddenForGuest = guestBlockedByReveal(req);
// Execute the query
let photos = await photosQuery;
let photos = hiddenForGuest ? [] : await photosQuery;
// Apply filtering if requested (supports global stats + per-guest interactions)
if (filter) {
@@ -749,7 +764,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// Get actual categories used by photos in this event
// This includes both global categories and event-specific ones
const usedCategoryIds = await db('photos')
const usedCategoryIds = hiddenForGuest ? [] : await db('photos')
.where('event_id', req.event.id)
.whereNotNull('category_id')
.distinct('category_id')
@@ -853,6 +868,9 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
hero_photo_id: req.event.hero_photo_id,
allow_downloads: req.event.allow_downloads !== false,
allow_user_uploads: req.event.allow_user_uploads === true,
// Reveal mode (#838): armed flag lets an open VISIBLE gallery keep
// polling so a re-hide propagates without a manual reload.
reveal_armed: req.event.reveal_mode === true || req.event.reveal_mode === 1 || req.event.reveal_mode === '1',
disable_right_click: req.event.disable_right_click === true,
watermark_downloads: req.event.watermark_downloads === true,
watermark_text: req.event.watermark_text,
@@ -872,6 +890,10 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
use_original_filenames: useOriginalFilenames,
...protectionSettings
},
// Reveal mode (#838): the guest UI switches to the upload-only view
// on this flag; reveal_at lets it show the scheduled time.
hidden_until_reveal: hiddenForGuest,
reveal_at: hiddenForGuest ? (req.event.reveal_at || null) : undefined,
categories: categories,
photos: photos.map(photo => {
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
@@ -1006,8 +1028,9 @@ router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, r
}
});
// Download single photo
router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
const { photoId } = req.params;
@@ -1128,7 +1151,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
});
// Download all photos as ZIP
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
// Check if downloads are allowed for this event
if (req.event.allow_downloads === false) {
@@ -1313,7 +1336,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
});
// Download selected photos as ZIP
router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
// Check if downloads are allowed for this event
if (req.event.allow_downloads === false) {
@@ -1437,6 +1460,7 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
// View single photo (with watermark if enabled)
router.get('/:slug/photo/:photoId',
verifyGalleryAccess,
blockHiddenGallery,
async (req, res) => {
try {
const { photoId } = req.params;
@@ -1669,6 +1693,7 @@ router.get('/:slug/photo/:photoId',
// Serve thumbnail
router.get('/:slug/thumbnail/:photoId',
verifyGalleryAccess,
blockHiddenGallery,
async (req, res) => {
try {
const { photoId } = req.params;
@@ -1760,6 +1785,9 @@ router.get('/:slug/thumbnail/:photoId',
// Serve hero-optimized image (1920x1080 for full-width hero sections)
router.get('/:slug/hero/:photoId',
verifyGalleryAccess,
// Reveal-gated too: this route serves a 1920px derivative of ANY photo id,
// not just the chosen hero — an open bypass while hidden (review round 1).
blockHiddenGallery,
async (req, res) => {
try {
const { photoId } = req.params;
@@ -1858,6 +1886,7 @@ router.get('/:slug/hero/:photoId',
// guest would see on the full original.
router.get('/:slug/preview/:photoId',
verifyGalleryAccess,
blockHiddenGallery,
async (req, res) => {
try {
const { photoId } = req.params;
@@ -1967,7 +1996,7 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) =>
});
// Get photo stats
router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
router.get('/:slug/stats', verifyGalleryAccess, blockHiddenGallery, async (req, res) => {
try {
const totalPhotos = await db('photos')
.where('event_id', req.event.id)
+20 -1
View File
@@ -1,6 +1,7 @@
const express = require('express');
const router = express.Router();
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
const { guestBlockedByReveal, blockHiddenGallery } = require('../utils/revealMode');
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const { resolveGuest } = require('../middleware/guestAuth');
const feedbackService = require('../services/feedbackService');
@@ -53,6 +54,9 @@ router.get('/:slug/feedback-settings',
// Get feedback for a specific photo
router.get('/:slug/photos/:photoId/feedback',
verifyGalleryAccess,
// Reveal-gated (#838): sequential photo ids would let hidden-gallery
// guests enumerate comments/stats.
blockHiddenGallery,
resolveGuest,
validatePhotoId,
checkValidation,
@@ -156,6 +160,8 @@ router.get('/:slug/photos/:photoId/feedback',
router.post('/:slug/photos/:photoId/feedback',
verifyGalleryAccess,
denySlideshowToken,
// Reveal-gated (#838): no interacting with photos you cannot see.
blockHiddenGallery,
resolveGuest,
validatePhotoId,
validateFeedbackSubmission,
@@ -326,7 +332,13 @@ router.get('/:slug/feedback-summary',
async (req, res) => {
try {
const event = req.event;
// Reveal mode (#838): the summary lists top photos by filename —
// hidden along with the gallery for plain guests.
if (guestBlockedByReveal(req)) {
return res.json({ enabled: false, summary: null });
}
// Get feedback settings
const settings = await feedbackService.getEventFeedbackSettings(event.id);
@@ -379,6 +391,13 @@ router.get('/:slug/my-feedback',
try {
const event = req.event;
// Reveal mode (#838): rows join photos (filename + storage path) —
// return the empty back-compat shape rather than a 403 so the gallery
// shell loading in parallel doesn't surface error toasts.
if (guestBlockedByReveal(req)) {
return res.json([]);
}
const query = db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.event_id', event.id);
+23 -8
View File
@@ -2,6 +2,7 @@ const express = require('express');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyGalleryAccess } = require('../middleware/gallery');
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
const watermarkService = require('../services/watermarkService');
const secureImageService = require('../services/secureImageService');
const { getStorage } = require('../services/storage');
@@ -16,10 +17,13 @@ const router = express.Router();
/**
* Generate a signed URL token for image access
*/
function generateImageToken(photoId, expiresIn = 3600) {
function generateImageToken(photoId, expiresIn = 3600, revealBypass = false) {
const secret = process.env.JWT_SECRET;
const expires = Date.now() + (expiresIn * 1000);
const data = `${photoId}:${expires}`;
// Third segment (#838): whether the minting context bypasses reveal mode
// (slideshow/client/admin). Old two-segment tokens verify unchanged and
// read as no-bypass.
const data = `${photoId}:${expires}:${revealBypass ? 1 : 0}`;
const signature = crypto.createHmac('sha256', secret).update(data).digest('hex');
return `${Buffer.from(data).toString('base64')}.${signature}`;
}
@@ -32,7 +36,7 @@ function verifyImageToken(token) {
const secret = process.env.JWT_SECRET;
const [data, signature] = token.split('.');
const decoded = Buffer.from(data, 'base64').toString();
const [photoId, expires] = decoded.split(':');
const [photoId, expires, bypassFlag] = decoded.split(':');
// Verify signature (constant-time — avoids leaking the HMAC byte-by-byte)
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
@@ -45,7 +49,7 @@ function verifyImageToken(token) {
return null;
}
return { photoId: parseInt(photoId), expires: parseInt(expires) };
return { photoId: parseInt(photoId), expires: parseInt(expires), revealBypass: bypassFlag === '1' };
} catch (error) {
return null;
}
@@ -54,7 +58,7 @@ function verifyImageToken(token) {
/**
* Serve protected image with enhanced security
*/
router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) => {
router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, blockHiddenGallery, async (req, res) => {
try {
const { photoId } = req.params;
const { protectionLevel = 'standard', token } = req.query;
@@ -174,7 +178,7 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) =
/**
* Generate secure token for enhanced image access
*/
router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess, async (req, res) => {
router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess, blockHiddenGallery, async (req, res) => {
try {
const { photoId } = req.params;
const { protectionLevel = 'standard', expiresIn = 300 } = req.body;
@@ -219,6 +223,11 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
* Generate signed URL for image access (legacy support)
*/
router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (req, res) => {
// Reveal-gated for plain guests; bypass callers get a token that stays
// valid at SERVE time too (third token segment below).
if (require('../utils/revealMode').guestBlockedByReveal(req)) {
return res.status(403).json({ error: 'Gallery is hidden until reveal', code: 'GALLERY_HIDDEN' });
}
try {
const { photoId } = req.params;
@@ -235,7 +244,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
}
// Generate signed token
const token = generateImageToken(photoId);
const token = generateImageToken(photoId, 3600, bypassesReveal(req));
const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`;
res.json({
@@ -271,7 +280,13 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Reveal mode (#838): a signed URL minted before a re-hide must not keep
// serving hidden photos; tokens minted by bypass contexts carry the flag.
if (isGalleryHidden(event) && !tokenData.revealBypass) {
return res.status(403).json({ error: 'Gallery is hidden until reveal', code: 'GALLERY_HIDDEN' });
}
// Get photo
const photo = await db('photos')
.where({
+13 -2
View File
@@ -1,6 +1,7 @@
const express = require('express');
const { db } = require('../database/db');
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
const secureImageService = require('../services/secureImageService');
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger');
@@ -23,7 +24,7 @@ router.post('/:slug/generate-token', async (req, res, next) => {
// Add slug to request for verifyGalleryAccess
req.requestedSlug = req.params.slug;
next();
}, verifyGalleryAccess, denySlideshowToken, async (req, res) => {
}, verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
const { photoId, accessType = 'view' } = req.body;
@@ -51,7 +52,10 @@ router.post('/:slug/generate-token', async (req, res, next) => {
expiresIn: protectionLevel === 'maximum' ? 180 : 300, // 3-5 minutes
maxUses: accessType === 'download' ? 1 : 3,
clientFingerprint,
protectionLevel
protectionLevel,
// Reveal mode (#838): recorded in the token so a re-hide invalidates
// in-flight guest tokens at serve time without breaking the slideshow.
revealBypass: bypassesReveal(req)
};
const token = secureImageService.generateSecureToken(
@@ -137,6 +141,12 @@ router.get('/:slug/secure/:photoId/:token',
return res.status(404).json({ error: 'Gallery not found' });
}
// Reveal mode (#838): tokens minted by plain guests die the moment the
// gallery is (re-)hidden — bypass contexts keep working.
if (isGalleryHidden(event) && !tokenValidation.data?.revealBypass) {
return res.status(403).json({ error: 'Gallery is hidden until reveal', code: 'GALLERY_HIDDEN' });
}
// Verify photo exists and belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: event.id })
@@ -273,6 +283,7 @@ router.get('/:slug/secure-download/:photoId/:token',
next();
},
verifyGalleryAccess,
blockHiddenGallery,
denySlideshowToken,
async (req, res) => {
try {
+7 -2
View File
@@ -207,7 +207,11 @@ async function buildOgMetadata(slug, requestPath) {
// the logo on any of those misses so a half-configured event still
// gets a polished link preview rather than a broken image.
let image = logoUrl;
if (event.og_image_share_enabled && event.hero_photo_id) {
// Reveal mode (#838): while the gallery is hidden from guests, social
// crawlers must not get the hero photo either — fall back to the brand
// logo like the opt-out case.
const { isGalleryHidden } = require('../utils/revealMode');
if (event.og_image_share_enabled && event.hero_photo_id && !isGalleryHidden(event)) {
const heroPhoto = await db('photos')
.where({ id: event.hero_photo_id, event_id: event.id })
.select('id', 'thumbnail_path')
@@ -302,7 +306,8 @@ async function handleGalleryOgCover(req, res) {
return;
}
const event = await resolveSlug(slug);
if (!event || !event.og_image_share_enabled || !event.hero_photo_id) {
const { isGalleryHidden } = require('../utils/revealMode');
if (!event || !event.og_image_share_enabled || !event.hero_photo_id || isGalleryHidden(event)) {
res.status(404).type('text/plain').send('Cover not available');
return;
}
+76
View File
@@ -0,0 +1,76 @@
/**
* 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 };
+6 -1
View File
@@ -21,7 +21,11 @@ class SecureImageService {
expiresIn = 300, // 5 minutes default
maxUses = 1,
clientFingerprint = '',
protectionLevel = 'standard'
protectionLevel = 'standard',
// Reveal mode (#838): whether the minting context bypasses the
// hidden-gallery gate — re-checked at SERVE time so a re-hide
// invalidates in-flight guest tokens without breaking the slideshow.
revealBypass = false
} = options;
const tokenData = {
@@ -32,6 +36,7 @@ class SecureImageService {
maxUses,
usedCount: 0,
protectionLevel,
revealBypass,
createdAt: Date.now()
};
+5 -2
View File
@@ -263,7 +263,7 @@ async function resumeRun(runId, { decisionHandle = null } = {}) {
* workflow (idempotent via dedup_key) and starts it. Never throws safe to
* call after a caller's commit. Fails CLOSED if the flag system is unavailable.
*/
async function emitWorkflowEvent(triggerType, { entityType = null, entityId = null, payload = {}, targetWorkflowId = null } = {}) {
async function emitWorkflowEvent(triggerType, { entityType = null, entityId = null, payload = {}, targetWorkflowId = null, dedupSuffix = null } = {}) {
try {
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
let enabled = false;
@@ -285,7 +285,10 @@ async function emitWorkflowEvent(triggerType, { entityType = null, entityId = nu
const tcfg = parseJson(wf.trigger_config, {});
if (tcfg && tcfg.filter && !matchFilter(tcfg.filter, payload)) continue;
const dedupKey = `${wf.id}:${wf.version}:${triggerType}:${entityType || ''}:${entityId || ''}`;
// dedupSuffix (#838): repeatable lifecycle events (a gallery can be
// re-hidden and revealed again) append a cycle marker so each cycle
// gets its own run while accidental double-emits still dedupe.
const dedupKey = `${wf.id}:${wf.version}:${triggerType}:${entityType || ''}:${entityId || ''}${dedupSuffix ? `:${dedupSuffix}` : ''}`;
const existing = await db('workflow_runs').where({ dedup_key: dedupKey }).first();
if (existing) continue;
+75
View File
@@ -0,0 +1,75 @@
/**
* Reveal mode (#838): effective-visibility math, shared by the gallery
* routes, the admin routes and the reveal scheduler.
*
* The gate is computed from the event row at request time a scheduled
* reveal opens EXACTLY at reveal_at even if the minutely scheduler (which
* only stamps revealed_at durably and fires notifications) lags behind.
*/
/** Truthy check that survives SQLite 0/1 and Postgres booleans. */
function isTrue(value) {
return value === true || value === 1 || value === '1';
}
/**
* Parse a timestamp column that may arrive as a Date (Postgres), an ISO
* string, or a millisecond number/number-string (SQLite stores knex Dates
* as ms `new Date("178…")` on that string would be Invalid Date and
* silently keep the gallery hidden past its scheduled reveal).
*/
function toDate(value) {
if (value instanceof Date) return value;
if (typeof value === 'number') return new Date(value);
const asNumber = Number(value);
if (!Number.isNaN(asNumber) && String(value).trim() !== '') return new Date(asNumber);
return new Date(value);
}
/**
* Whether the gallery is currently hidden from plain guests.
* Host/admin/slideshow/client access bypasses this at the route layer.
*/
function isGalleryHidden(event, now = new Date()) {
if (!isTrue(event.reveal_mode)) return false;
if (event.revealed_at) return false;
if (event.reveal_at && toDate(event.reveal_at) <= now) return false;
return true;
}
/**
* Which access levels see the full gallery while it is hidden:
* the live slideshow (the "surprise beamer" case), client access and
* customer-portal-minted tokens (both are the host/customer reviewing
* their own event those tokens carry via:'customer' with NO accessLevel,
* so accessLevel alone would misclassify them as guests) and the admin
* preview.
*/
function bypassesReveal(req) {
if (req.accessLevel === 'slideshow' || req.accessLevel === 'client') return true;
if (req.viaCustomer) return true;
// Lazy require avoids a cycle: middleware/gallery requires nothing from
// here, but keeping the import local makes that permanent.
const { isAdminPreview } = require('../middleware/gallery');
return Boolean(isAdminPreview(req));
}
/** Route guard result: is THIS request blocked by reveal mode? */
function guestBlockedByReveal(req, now = new Date()) {
return isGalleryHidden(req.event, now) && !bypassesReveal(req);
}
/**
* Route guard: hard 403 for plain guests on photo/derivative/download
* endpoints while the gallery is hidden. Photo IDs are sequential, so
* gating only the listing would leave images probeable. Mount AFTER
* verifyGalleryAccess (needs req.event / req.accessLevel).
*/
function blockHiddenGallery(req, res, next) {
if (guestBlockedByReveal(req)) {
return res.status(403).json({ error: 'Gallery is hidden until reveal', code: 'GALLERY_HIDDEN' });
}
next();
}
module.exports = { isGalleryHidden, bypassesReveal, guestBlockedByReveal, blockHiddenGallery };
@@ -140,6 +140,24 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
}, [data?.event?.default_photo_sort, defaultSortApplied]);
// Reveal mode (#838): an already-open view must follow reveal-state
// changes in BOTH directions — hidden→visible at reveal_at (or manual
// "Reveal now"), and visible→hidden on a re-hide. Refetch right at
// reveal_at plus a 60s poll while the mode is armed; there is no push
// channel.
const hiddenUntilReveal = data?.hidden_until_reveal === true;
const revealArmed = (data?.event as { reveal_armed?: boolean } | undefined)?.reveal_armed === true;
const revealAtMs = data?.reveal_at ? new Date(data.reveal_at).getTime() : null;
useEffect(() => {
if (!hiddenUntilReveal && !revealArmed) return undefined;
const timers: Array<ReturnType<typeof setTimeout>> = [];
if (revealAtMs && revealAtMs > Date.now()) {
timers.push(setTimeout(() => { refetch(); }, Math.min(revealAtMs - Date.now() + 1000, 2 ** 31 - 1)));
}
const interval = setInterval(() => { refetch(); }, 60_000);
return () => { timers.forEach(clearTimeout); clearInterval(interval); };
}, [hiddenUntilReveal, revealArmed, revealAtMs, refetch]);
// Get individual protection settings from event
const disableRightClick = data?.event?.disable_right_click === true;
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
@@ -701,6 +719,58 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
&& settingsData?.gallery_show_filter_bar !== false
&& (data?.photos?.length ?? 0) > 0;
// Reveal mode (#838): the server returned the event shell with no photos —
// render the upload-only view for EVERY layout. Enforcement is server-side
// (the photo endpoints refuse plain guests); this is the friendly face.
if (hiddenUntilReveal) {
const uploadsOn = Boolean(data?.event?.allow_user_uploads || event?.allow_user_uploads);
return (
<GalleryLayout event={event} brandingSettings={brandingSettings}>
<div className="max-w-xl mx-auto text-center py-16 px-4">
<div className="mx-auto mb-5 w-16 h-16 rounded-full bg-surface flex items-center justify-center">
<EyeOff className="w-8 h-8 text-muted-theme" />
</div>
<h2 className="text-2xl font-semibold mb-3" style={{ color: 'var(--color-text, #171717)' }}>
{t('gallery.revealPendingTitle', 'The photos are still a surprise')}
</h2>
<p className="text-muted-theme mb-2">
{t('gallery.revealPendingMessage', 'The host will reveal the gallery later — check back soon!')}
</p>
{data.reveal_at && (
<p className="text-sm text-muted-theme mb-6">
{t('gallery.revealScheduledFor', 'Reveal scheduled for {{date}}', {
date: new Date(data.reveal_at).toLocaleString(),
})}
</p>
)}
{uploadsOn && (
<div className="mt-6">
<p className="text-sm text-muted-theme mb-3">
{t('gallery.revealUploadHint', 'You can already add your own photos to the collection:')}
</p>
<Button
variant="primary"
size="lg"
leftIcon={<Upload className="w-5 h-5" />}
onClick={() => setShowUploadModal(true)}
>
{t('upload.uploadPhotos', 'Upload Photos')}
</Button>
</div>
)}
</div>
{showUploadModal && uploadsOn && (
<UserPhotoUpload
eventId={data?.event?.id || event?.id}
categoryId={data?.event?.upload_category_id || event?.upload_category_id}
onUploadComplete={() => setShowUploadModal(false)}
onClose={() => setShowUploadModal(false)}
/>
)}
</GalleryLayout>
);
}
// Full-page layouts (gallery-premium, gallery-story) have their own integrated UI
// Skip all wrapper elements (header, footer, sidebar, filters) for these layouts
const isFullPageLayout = theme.galleryLayout === 'gallery-premium' || theme.galleryLayout === 'gallery-story';
+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",
@@ -206,6 +206,18 @@ export const EventDetailsPage: React.FC = () => {
});
// Archive mutation
// Reveal now (#838)
const revealMutation = useMutation({
mutationFn: () => eventsService.revealEvent(Number(id)),
onSuccess: () => {
toast.success(t('events.revealedToast', 'Gallery revealed — guests can see the photos now'));
refetchEvent();
},
onError: () => {
toast.error(t('events.revealError', 'Failed to reveal the gallery'));
},
});
const archiveMutation = useMutation({
mutationFn: () => eventsService.archiveEvent(parseInt(id!)),
onSuccess: () => {
@@ -289,6 +301,11 @@ export const EventDetailsPage: React.FC = () => {
css_template_id: event.css_template_id || null,
expires_at: expiresAtDate ? format(expiresAtDate, 'yyyy-MM-dd') : '',
allow_user_uploads: event.allow_user_uploads || false,
reveal_mode: event.reveal_mode || false,
// datetime-local wants local "YYYY-MM-DDTHH:mm"
reveal_at: event.reveal_at
? (() => { const d = new Date(event.reveal_at); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 16); })()
: '',
upload_category_id: event.upload_category_id || null,
hero_photo_id: event.hero_photo_id || null,
customer_name: event.customer_name || '',
@@ -428,6 +445,10 @@ export const EventDetailsPage: React.FC = () => {
const updateData: any = {
expires_at: editForm.expires_at || null,
allow_user_uploads: editForm.allow_user_uploads,
reveal_mode: editForm.allow_user_uploads && editForm.reveal_mode,
reveal_at: editForm.allow_user_uploads && editForm.reveal_mode && editForm.reveal_at
? new Date(editForm.reveal_at).toISOString()
: null,
require_password: editForm.require_password,
css_template_id: editForm.css_template_id,
// Download protection settings
@@ -566,6 +587,7 @@ export const EventDetailsPage: React.FC = () => {
photos={photos}
phoneFieldEnabled={phoneFieldEnabled}
daysUntilExpiration={daysUntilExpiration}
onRevealNow={() => revealMutation.mutate()}
refetchEvent={refetchEvent}
setActiveTab={setActiveTab}
setShowPasswordReset={setShowPasswordReset}
@@ -43,6 +43,8 @@ interface EventInformationCardProps {
photos: AdminPhoto[];
phoneFieldEnabled: boolean;
daysUntilExpiration: number | null;
// Reveal mode (#838): stamps revealed_at via POST /events/:id/reveal
onRevealNow?: () => void;
}
export const EventInformationCard: React.FC<EventInformationCardProps> = ({
@@ -58,7 +60,8 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
categories,
photos,
phoneFieldEnabled,
daysUntilExpiration
daysUntilExpiration,
onRevealNow
}) => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
@@ -430,6 +433,42 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
</div>
)}
{/* Reveal mode (#838) — only meaningful with guest uploads */}
{editForm.allow_user_uploads && (
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.reveal_mode}
onChange={(e) => setEditForm(prev => ({ ...prev, reveal_mode: e.target.checked }))}
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
{t('events.revealMode', 'Reveal mode (hide gallery until reveal)')}
</span>
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1 ml-6">
{t('events.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.')}
</p>
{editForm.reveal_mode && (
<div className="mt-2 ml-6">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.revealAt', 'Scheduled reveal (optional)')}
</label>
<input
type="datetime-local"
value={editForm.reveal_at}
onChange={(e) => setEditForm(prev => ({ ...prev, reveal_at: e.target.value }))}
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('events.revealAtHelp', 'Leave empty to reveal manually with the "Reveal now" button.')}
</p>
</div>
)}
</div>
)}
{/* Feedback Settings */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('feedback.settings.title', 'Guest Feedback Settings')}</h3>
@@ -834,6 +873,39 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
</dd>
</div>
{Boolean(event.reveal_mode) && (
<div>
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.revealModeStatus', 'Reveal mode')}</dt>
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
{event.revealed_at ? (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/40 rounded">
{t('events.revealed', 'Revealed')}
</span>
) : (
<div className="space-y-2">
<span className="inline-flex items-center px-2 py-1 text-xs font-medium text-amber-700 dark:text-amber-300 bg-amber-100 dark:bg-amber-900/40 rounded">
{t('events.hiddenUntilReveal', 'Hidden from guests')}
</span>
{event.reveal_at && (
<p className="text-xs text-neutral-600 dark:text-neutral-400">
{t('events.revealScheduled', 'Scheduled: {{date}}', { date: new Date(event.reveal_at).toLocaleString() })}
</p>
)}
{onRevealNow && (
<button
type="button"
onClick={onRevealNow}
className="block px-3 py-1.5 text-xs font-medium text-white bg-accent hover:bg-accent-dark rounded transition-colors"
>
{t('events.revealNow', 'Reveal now')}
</button>
)}
</div>
)}
</dd>
</div>
)}
{/* Download Protection Display */}
<div className="pt-3 mt-3 border-t border-neutral-200 dark:border-neutral-700">
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400 flex items-center gap-2">
@@ -32,6 +32,7 @@ interface OverviewTabProps {
photos: AdminPhoto[];
phoneFieldEnabled: boolean;
daysUntilExpiration: number | null;
onRevealNow?: () => void;
refetchEvent: () => void;
setActiveTab: (tab: EventDetailsTab) => void;
setShowPasswordReset: (show: boolean) => void;
@@ -63,6 +64,7 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
photos,
phoneFieldEnabled,
daysUntilExpiration,
onRevealNow,
refetchEvent,
setActiveTab,
setShowPasswordReset,
@@ -100,6 +102,7 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
photos={photos}
phoneFieldEnabled={phoneFieldEnabled}
daysUntilExpiration={daysUntilExpiration}
onRevealNow={onRevealNow}
/>
{/* Share Link */}
@@ -6,6 +6,9 @@ export type EditFormState = {
css_template_id: number | null;
expires_at: string;
allow_user_uploads: boolean;
// Reveal mode (#838): reveal_at is a datetime-local input string ('' = none)
reveal_mode: boolean;
reveal_at: string;
upload_category_id: number | null;
hero_photo_id: number | null;
customer_name: string;
@@ -54,6 +57,8 @@ export const INITIAL_EDIT_FORM: EditFormState = {
css_template_id: null,
expires_at: '',
allow_user_uploads: false,
reveal_mode: false,
reveal_at: '',
upload_category_id: null,
hero_photo_id: null,
customer_name: '',
@@ -32,7 +32,7 @@ const TRIGGERS = [
'quote.sent', 'quote.accepted', 'quote.declined',
'contract.sent', 'contract.signed',
'event.date_approaching',
'gallery.published', 'gallery.expiring', 'gallery.expired',
'gallery.published', 'gallery.expiring', 'gallery.expired', 'gallery.revealed',
'customer.created',
];
+9
View File
@@ -61,6 +61,9 @@ interface UpdateEventData {
expires_at?: string;
is_active?: boolean;
allow_user_uploads?: boolean;
// Reveal mode (#838)
reveal_mode?: boolean;
reveal_at?: string | null;
upload_category_id?: number | null;
hero_photo_id?: number | null;
source_mode?: 'managed' | 'reference';
@@ -129,6 +132,12 @@ export const eventsService = {
},
// Update event (admin)
// Reveal now (#838): stamps revealed_at so the gallery opens for guests.
async revealEvent(id: number): Promise<{ revealed_at: string }> {
const response = await api.post(`/admin/events/${id}/reveal`);
return response.data;
},
async updateEvent(id: number, data: UpdateEventData): Promise<Event> {
const response = await api.put<Event>(`/admin/events/${id}`, data);
return response.data;
+8
View File
@@ -28,6 +28,10 @@ export interface Event {
uploaded_at: string;
}>;
allow_user_uploads?: boolean;
// Reveal mode (#838)
reveal_mode?: boolean;
reveal_at?: string | null;
revealed_at?: string | null;
upload_category_id?: number | null;
hero_photo_id?: number | null;
total_views?: number;
@@ -208,6 +212,10 @@ export interface GalleryData {
};
categories?: PhotoCategory[];
photos: Photo[];
// Reveal mode (#838): the server returns the event shell with photos: []
// and this flag while the gallery is hidden from guests.
hidden_until_reveal?: boolean;
reveal_at?: string | null;
}
export interface GalleryStats {