* 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:
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Создать событие",
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user