diff --git a/backend/__tests__/integration/revealMode.test.js b/backend/__tests__/integration/revealMode.test.js new file mode 100644 index 00000000..802517fe --- /dev/null +++ b/backend/__tests__/integration/revealMode.test.js @@ -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 }); + }); + }); +}); diff --git a/backend/migrations/core/165_add_reveal_mode.js b/backend/migrations/core/165_add_reveal_mode.js new file mode 100644 index 00000000..9e489cb6 --- /dev/null +++ b/backend/migrations/core/165_add_reveal_mode.js @@ -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); + }); + } + } +}; diff --git a/backend/server.js b/backend/server.js index 346a71f2..aa027789 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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). diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index 85f4f295..60fe45a8 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -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; diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 59d3771b..61cd4d29 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -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) diff --git a/backend/src/routes/galleryFeedback.js b/backend/src/routes/galleryFeedback.js index f973b13a..c249a159 100644 --- a/backend/src/routes/galleryFeedback.js +++ b/backend/src/routes/galleryFeedback.js @@ -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); diff --git a/backend/src/routes/protectedImages.js b/backend/src/routes/protectedImages.js index 0bf9e725..2069920a 100644 --- a/backend/src/routes/protectedImages.js +++ b/backend/src/routes/protectedImages.js @@ -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({ diff --git a/backend/src/routes/secureImages.js b/backend/src/routes/secureImages.js index fcd7c065..2dda8c0a 100644 --- a/backend/src/routes/secureImages.js +++ b/backend/src/routes/secureImages.js @@ -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 { diff --git a/backend/src/services/galleryOgService.js b/backend/src/services/galleryOgService.js index 5cac9cf6..141a528d 100644 --- a/backend/src/services/galleryOgService.js +++ b/backend/src/services/galleryOgService.js @@ -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; } diff --git a/backend/src/services/revealScheduler.js b/backend/src/services/revealScheduler.js new file mode 100644 index 00000000..a1458c2e --- /dev/null +++ b/backend/src/services/revealScheduler.js @@ -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 }; diff --git a/backend/src/services/secureImageService.js b/backend/src/services/secureImageService.js index fc2e9551..4e7a7ae9 100644 --- a/backend/src/services/secureImageService.js +++ b/backend/src/services/secureImageService.js @@ -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() }; diff --git a/backend/src/services/workflows/engine.js b/backend/src/services/workflows/engine.js index d1766a1a..8bfccb87 100644 --- a/backend/src/services/workflows/engine.js +++ b/backend/src/services/workflows/engine.js @@ -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; diff --git a/backend/src/utils/revealMode.js b/backend/src/utils/revealMode.js new file mode 100644 index 00000000..d10e074d --- /dev/null +++ b/backend/src/utils/revealMode.js @@ -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 }; diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index aa4a8d51..e50d62b2 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -140,6 +140,24 @@ export const GalleryView: React.FC = ({ 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> = []; + 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 = ({ 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 ( + +
+
+ +
+

+ {t('gallery.revealPendingTitle', 'The photos are still a surprise')} +

+

+ {t('gallery.revealPendingMessage', 'The host will reveal the gallery later — check back soon!')} +

+ {data.reveal_at && ( +

+ {t('gallery.revealScheduledFor', 'Reveal scheduled for {{date}}', { + date: new Date(data.reveal_at).toLocaleString(), + })} +

+ )} + {uploadsOn && ( +
+

+ {t('gallery.revealUploadHint', 'You can already add your own photos to the collection:')} +

+ +
+ )} +
+ {showUploadModal && uploadsOn && ( + setShowUploadModal(false)} + onClose={() => setShowUploadModal(false)} + /> + )} +
+ ); + } + // 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'; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index b62ee3d6..36657ce1 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 6f9d1b1c..a753c5f3 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index c29f0479..e4a48a8b 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -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", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 28720ba1..1b30f209 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 94f871d1..77da18b9 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -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", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 588647a8..85aeed57 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -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", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index d9c68c6f..92459cd3 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -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": "Создать событие", diff --git a/frontend/src/i18n/locales/sl.json b/frontend/src/i18n/locales/sl.json index 066dab76..e5f8cde8 100644 --- a/frontend/src/i18n/locales/sl.json +++ b/frontend/src/i18n/locales/sl.json @@ -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", diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index c661bbfe..80091dc8 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -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} diff --git a/frontend/src/pages/admin/event-details/EventInformationCard.tsx b/frontend/src/pages/admin/event-details/EventInformationCard.tsx index 24290e85..8f7079c5 100644 --- a/frontend/src/pages/admin/event-details/EventInformationCard.tsx +++ b/frontend/src/pages/admin/event-details/EventInformationCard.tsx @@ -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 = ({ @@ -58,7 +60,8 @@ export const EventInformationCard: React.FC = ({ categories, photos, phoneFieldEnabled, - daysUntilExpiration + daysUntilExpiration, + onRevealNow }) => { const { t } = useTranslation(); const { format } = useLocalizedDate(); @@ -430,6 +433,42 @@ export const EventInformationCard: React.FC = ({ )} + {/* Reveal mode (#838) — only meaningful with guest uploads */} + {editForm.allow_user_uploads && ( +
+ +

+ {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.')} +

+ {editForm.reveal_mode && ( +
+ + 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" + /> +

+ {t('events.revealAtHelp', 'Leave empty to reveal manually with the "Reveal now" button.')} +

+
+ )} +
+ )} + {/* Feedback Settings */}

{t('feedback.settings.title', 'Guest Feedback Settings')}

@@ -834,6 +873,39 @@ export const EventInformationCard: React.FC = ({
+ {Boolean(event.reveal_mode) && ( +
+
{t('events.revealModeStatus', 'Reveal mode')}
+
+ {event.revealed_at ? ( + + {t('events.revealed', 'Revealed')} + + ) : ( +
+ + {t('events.hiddenUntilReveal', 'Hidden from guests')} + + {event.reveal_at && ( +

+ {t('events.revealScheduled', 'Scheduled: {{date}}', { date: new Date(event.reveal_at).toLocaleString() })} +

+ )} + {onRevealNow && ( + + )} +
+ )} +
+
+ )} + {/* Download Protection Display */}
diff --git a/frontend/src/pages/admin/event-details/OverviewTab.tsx b/frontend/src/pages/admin/event-details/OverviewTab.tsx index cd3c420c..9cede3b4 100644 --- a/frontend/src/pages/admin/event-details/OverviewTab.tsx +++ b/frontend/src/pages/admin/event-details/OverviewTab.tsx @@ -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 = ({ photos, phoneFieldEnabled, daysUntilExpiration, + onRevealNow, refetchEvent, setActiveTab, setShowPasswordReset, @@ -100,6 +102,7 @@ export const OverviewTab: React.FC = ({ photos={photos} phoneFieldEnabled={phoneFieldEnabled} daysUntilExpiration={daysUntilExpiration} + onRevealNow={onRevealNow} /> {/* Share Link */} diff --git a/frontend/src/pages/admin/event-details/types.ts b/frontend/src/pages/admin/event-details/types.ts index 3f1806f7..71dec4f7 100644 --- a/frontend/src/pages/admin/event-details/types.ts +++ b/frontend/src/pages/admin/event-details/types.ts @@ -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: '', diff --git a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx index 26ade706..e3ccceb4 100644 --- a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx +++ b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx @@ -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', ]; diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts index 0acc02bd..682b27d6 100644 --- a/frontend/src/services/events.service.ts +++ b/frontend/src/services/events.service.ts @@ -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 { const response = await api.put(`/admin/events/${id}`, data); return response.data; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0b45f394..f0715abf 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 {