* feat(gallery): reveal mode — hide gallery from guests until reveal (#838) Guests can upload during the event but see no photos until the host reveals the gallery, manually ("Reveal now") or at a scheduled time. - migration 165: events.reveal_mode / reveal_at / revealed_at. Effective visibility is computed at REQUEST time (reveal_at <= now opens the gate exactly on schedule); the minutely scheduler only stamps revealed_at durably and emits a gallery.revealed workflow trigger - server-side enforcement in gallery.js: /photos returns the event shell with photos: [] + hidden_until_reveal for plain guests; image/download/stats endpoints 403 with GALLERY_HIDDEN (photo IDs are sequential — listing-only gating would be probeable); feedback-summary gated too. Slideshow tokens (surprise beamer), client access and the admin preview bypass; the guest upload route stays open - admin: reveal toggle + optional scheduled datetime next to the guest upload settings, status line and "Reveal now" button on the overview; re-enabling the toggle clears revealed_at so a gallery can re-hide - guest UI: upload-only view (hero, friendly message, scheduled time, upload button) for every layout; i18n for all 8 locales - timestamps written as ISO strings — the SQLite driver stringifies raw Date objects into garbage; ISO round-trips on both engines - 14 integration tests over minted gallery/slideshow/client/admin tokens * fix(gallery): reveal/re-arm semantics + upload button i18n key (#838) - "Reveal now" also clears a pending reveal_at: the schedule is consumed, so the full-form admin save can't accidentally re-hide a revealed gallery with a stale future date - setting a FUTURE reveal_at on a revealed gallery re-arms hiding — the one intentional way to re-hide without double-toggling the mode - guest upload button uses the existing upload.uploadPhotos key (gallery.uploadPhotos never existed; the button showed EN everywhere) * fix(gallery): close reveal bypasses from review round 1 (#838) - the hero-derivative route and the secure-images token-mint + secure-download routes are now reveal-gated: hero serves a 1920px derivative of ANY sequential photo id and secure tokens fetch originals — both were open bypasses while hidden. blockHiddenGallery moved to utils/revealMode.js and shared - customer-portal tokens (via:'customer', no accessLevel) now bypass reveal mode — they are the host/customer, not a guest, and were getting the upload-only view - an open hidden guest view refetches exactly at reveal_at plus a 60s fallback poll, so the gallery appears without a manual reload - gallery.revealed added to the workflow editor's trigger picker so the advertised notification hook is reachable in the UI - migration 165 guards each column independently (partial-state safe) * fix(gallery): reveal round 2 — remaining bypass surfaces + lifecycle edges (#838) - legacy /api/images router reveal-gated (view, secure-token + signed-url minting), and the signed-URL SERVE path re-checks hidden state via a backward-compatible bypass flag in the token payload - secure-image tokens record revealBypass at mint and are re-validated at serve time — a re-hide kills in-flight guest tokens within the request, while slideshow/client tokens keep working - OG metadata and the unauthenticated /og cover fall back to the brand logo / 404 while hidden — no hero-photo spoiler for social crawlers - photo-feedback GET/POST reveal-gated (sequential ids were enumerable); /my-feedback returns the empty back-compat shape (rows leak filename + storage path) - the reveal scheduler skips drafts — no premature stamp/notification for unpublished galleries - emitWorkflowEvent gains an additive dedupSuffix; both reveal emitters pass the reveal timestamp so a re-hidden gallery's second reveal fires workflows again instead of deduping into silence * fix(gallery): reveal round 3 — schedule consumption + two-way client sync (#838) - the scheduler now consumes reveal_at when stamping (matching "Reveal now"), and re-arming via a partial API update clears a stale PAST schedule — previously {reveal_mode:true} without reveal_at could instantly re-open the gate through the leftover date - /photos exposes reveal_armed so an open VISIBLE gallery keeps a 60s poll while the mode is on — a re-hide now propagates to open clients in both directions, not just hidden→visible Codex round-3 claim about timestamp-without-timezone drift on non-UTC Postgres was verified FALSE: knex's table.timestamp() creates timestamptz on PG (confirmed via information_schema on a live install), which stores absolute instants regardless of server TZ. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
3d6c9848dc
commit
2f05fcc39d
@@ -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: '[email protected]',
|
||||||
|
admin_email: '[email protected]',
|
||||||
|
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: '[email protected]',
|
||||||
|
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: '[email protected]',
|
||||||
|
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 { initializeDatabase, db } = require('./src/database/db');
|
||||||
const { startFileWatcher } = require('./src/services/fileWatcher');
|
const { startFileWatcher } = require('./src/services/fileWatcher');
|
||||||
const { startExpirationChecker } = require('./src/services/expirationChecker');
|
const { startExpirationChecker } = require('./src/services/expirationChecker');
|
||||||
|
const { startRevealScheduler } = require('./src/services/revealScheduler');
|
||||||
const { startInvoiceScheduler } = require('./src/services/invoiceSchedulerService');
|
const { startInvoiceScheduler } = require('./src/services/invoiceSchedulerService');
|
||||||
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
|
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
|
||||||
const { startBackupService } = require('./src/services/backupService');
|
const { startBackupService } = require('./src/services/backupService');
|
||||||
@@ -903,6 +904,8 @@ async function startServer() {
|
|||||||
|
|
||||||
// Start expiration checker
|
// Start expiration checker
|
||||||
startExpirationChecker();
|
startExpirationChecker();
|
||||||
|
// Reveal-mode scheduler (#838): minutely stamp for scheduled reveals.
|
||||||
|
startRevealScheduler();
|
||||||
// CRM invoice scheduler: hourly tick to flush scheduled-send invoices
|
// CRM invoice scheduler: hourly tick to flush scheduled-send invoices
|
||||||
// + run the overdue reminder ladder. No-op when the `bills` feature
|
// + run the overdue reminder ladder. No-op when the `bills` feature
|
||||||
// flag is OFF (the service short-circuits on empty result sets).
|
// 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('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
|
||||||
body('color_theme').optional({ nullable: true }),
|
body('color_theme').optional({ nullable: true }),
|
||||||
body('allow_user_uploads').optional().isBoolean(),
|
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
|
// Migration 143 — per-event reminder overrides. All three are
|
||||||
// optional; nullable values are accepted so admins can clear an
|
// optional; nullable values are accepted so admins can clear an
|
||||||
// override (e.g. drop a custom offset back to the global default).
|
// 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)
|
// Handle client access fields (#172)
|
||||||
if (Object.prototype.hasOwnProperty.call(updates, 'client_access_enabled')) {
|
if (Object.prototype.hasOwnProperty.call(updates, 'client_access_enabled')) {
|
||||||
updates.client_access_enabled = formatBoolean(updates.client_access_enabled);
|
updates.client_access_enabled = formatBoolean(updates.client_access_enabled);
|
||||||
@@ -1543,6 +1579,58 @@ module.exports = (router) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete event
|
// 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) => {
|
router.delete('/:id', adminAuth, requirePermission('events.delete'), requireEventOwnership, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const { resolvePhotoFilePath } = require('../services/photoResolver');
|
|||||||
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
|
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
|
||||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||||
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
|
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
|
||||||
|
const { isGalleryHidden, guestBlockedByReveal, blockHiddenGallery } = require('../utils/revealMode');
|
||||||
const { NotFoundError } = require('../utils/errors');
|
const { NotFoundError } = require('../utils/errors');
|
||||||
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
|
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
|
||||||
const downloadZipService = require('../services/downloadZipService');
|
const downloadZipService = require('../services/downloadZipService');
|
||||||
@@ -205,6 +206,9 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
'share_token',
|
'share_token',
|
||||||
'allow_downloads',
|
'allow_downloads',
|
||||||
'allow_user_uploads',
|
'allow_user_uploads',
|
||||||
|
'reveal_mode',
|
||||||
|
'reveal_at',
|
||||||
|
'revealed_at',
|
||||||
'disable_right_click',
|
'disable_right_click',
|
||||||
'watermark_downloads',
|
'watermark_downloads',
|
||||||
'watermark_text',
|
'watermark_text',
|
||||||
@@ -275,6 +279,10 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
color_theme: event.color_theme,
|
color_theme: event.color_theme,
|
||||||
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
|
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',
|
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',
|
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_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
|
||||||
watermark_text: event.watermark_text,
|
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);
|
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
|
// Execute the query
|
||||||
let photos = await photosQuery;
|
let photos = hiddenForGuest ? [] : await photosQuery;
|
||||||
|
|
||||||
// Apply filtering if requested (supports global stats + per-guest interactions)
|
// Apply filtering if requested (supports global stats + per-guest interactions)
|
||||||
if (filter) {
|
if (filter) {
|
||||||
@@ -749,7 +764,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
|||||||
|
|
||||||
// Get actual categories used by photos in this event
|
// Get actual categories used by photos in this event
|
||||||
// This includes both global categories and event-specific ones
|
// 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)
|
.where('event_id', req.event.id)
|
||||||
.whereNotNull('category_id')
|
.whereNotNull('category_id')
|
||||||
.distinct('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,
|
hero_photo_id: req.event.hero_photo_id,
|
||||||
allow_downloads: req.event.allow_downloads !== false,
|
allow_downloads: req.event.allow_downloads !== false,
|
||||||
allow_user_uploads: req.event.allow_user_uploads === true,
|
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,
|
disable_right_click: req.event.disable_right_click === true,
|
||||||
watermark_downloads: req.event.watermark_downloads === true,
|
watermark_downloads: req.event.watermark_downloads === true,
|
||||||
watermark_text: req.event.watermark_text,
|
watermark_text: req.event.watermark_text,
|
||||||
@@ -872,6 +890,10 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
|||||||
use_original_filenames: useOriginalFilenames,
|
use_original_filenames: useOriginalFilenames,
|
||||||
...protectionSettings
|
...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,
|
categories: categories,
|
||||||
photos: photos.map(photo => {
|
photos: photos.map(photo => {
|
||||||
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
|
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
|
// 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 {
|
try {
|
||||||
const { photoId } = req.params;
|
const { photoId } = req.params;
|
||||||
|
|
||||||
@@ -1128,7 +1151,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Download all photos as ZIP
|
// 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 {
|
try {
|
||||||
// Check if downloads are allowed for this event
|
// Check if downloads are allowed for this event
|
||||||
if (req.event.allow_downloads === false) {
|
if (req.event.allow_downloads === false) {
|
||||||
@@ -1313,7 +1336,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Download selected photos as ZIP
|
// 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 {
|
try {
|
||||||
// Check if downloads are allowed for this event
|
// Check if downloads are allowed for this event
|
||||||
if (req.event.allow_downloads === false) {
|
if (req.event.allow_downloads === false) {
|
||||||
@@ -1437,6 +1460,7 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
|||||||
// View single photo (with watermark if enabled)
|
// View single photo (with watermark if enabled)
|
||||||
router.get('/:slug/photo/:photoId',
|
router.get('/:slug/photo/:photoId',
|
||||||
verifyGalleryAccess,
|
verifyGalleryAccess,
|
||||||
|
blockHiddenGallery,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { photoId } = req.params;
|
const { photoId } = req.params;
|
||||||
@@ -1669,6 +1693,7 @@ router.get('/:slug/photo/:photoId',
|
|||||||
// Serve thumbnail
|
// Serve thumbnail
|
||||||
router.get('/:slug/thumbnail/:photoId',
|
router.get('/:slug/thumbnail/:photoId',
|
||||||
verifyGalleryAccess,
|
verifyGalleryAccess,
|
||||||
|
blockHiddenGallery,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { photoId } = req.params;
|
const { photoId } = req.params;
|
||||||
@@ -1760,6 +1785,9 @@ router.get('/:slug/thumbnail/:photoId',
|
|||||||
// Serve hero-optimized image (1920x1080 for full-width hero sections)
|
// Serve hero-optimized image (1920x1080 for full-width hero sections)
|
||||||
router.get('/:slug/hero/:photoId',
|
router.get('/:slug/hero/:photoId',
|
||||||
verifyGalleryAccess,
|
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) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { photoId } = req.params;
|
const { photoId } = req.params;
|
||||||
@@ -1858,6 +1886,7 @@ router.get('/:slug/hero/:photoId',
|
|||||||
// guest would see on the full original.
|
// guest would see on the full original.
|
||||||
router.get('/:slug/preview/:photoId',
|
router.get('/:slug/preview/:photoId',
|
||||||
verifyGalleryAccess,
|
verifyGalleryAccess,
|
||||||
|
blockHiddenGallery,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { photoId } = req.params;
|
const { photoId } = req.params;
|
||||||
@@ -1967,7 +1996,7 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get photo stats
|
// Get photo stats
|
||||||
router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
|
router.get('/:slug/stats', verifyGalleryAccess, blockHiddenGallery, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const totalPhotos = await db('photos')
|
const totalPhotos = await db('photos')
|
||||||
.where('event_id', req.event.id)
|
.where('event_id', req.event.id)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
|
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
|
||||||
|
const { guestBlockedByReveal, blockHiddenGallery } = require('../utils/revealMode');
|
||||||
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
||||||
const { resolveGuest } = require('../middleware/guestAuth');
|
const { resolveGuest } = require('../middleware/guestAuth');
|
||||||
const feedbackService = require('../services/feedbackService');
|
const feedbackService = require('../services/feedbackService');
|
||||||
@@ -53,6 +54,9 @@ router.get('/:slug/feedback-settings',
|
|||||||
// Get feedback for a specific photo
|
// Get feedback for a specific photo
|
||||||
router.get('/:slug/photos/:photoId/feedback',
|
router.get('/:slug/photos/:photoId/feedback',
|
||||||
verifyGalleryAccess,
|
verifyGalleryAccess,
|
||||||
|
// Reveal-gated (#838): sequential photo ids would let hidden-gallery
|
||||||
|
// guests enumerate comments/stats.
|
||||||
|
blockHiddenGallery,
|
||||||
resolveGuest,
|
resolveGuest,
|
||||||
validatePhotoId,
|
validatePhotoId,
|
||||||
checkValidation,
|
checkValidation,
|
||||||
@@ -156,6 +160,8 @@ router.get('/:slug/photos/:photoId/feedback',
|
|||||||
router.post('/:slug/photos/:photoId/feedback',
|
router.post('/:slug/photos/:photoId/feedback',
|
||||||
verifyGalleryAccess,
|
verifyGalleryAccess,
|
||||||
denySlideshowToken,
|
denySlideshowToken,
|
||||||
|
// Reveal-gated (#838): no interacting with photos you cannot see.
|
||||||
|
blockHiddenGallery,
|
||||||
resolveGuest,
|
resolveGuest,
|
||||||
validatePhotoId,
|
validatePhotoId,
|
||||||
validateFeedbackSubmission,
|
validateFeedbackSubmission,
|
||||||
@@ -327,6 +333,12 @@ router.get('/:slug/feedback-summary',
|
|||||||
try {
|
try {
|
||||||
const event = req.event;
|
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
|
// Get feedback settings
|
||||||
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||||
|
|
||||||
@@ -379,6 +391,13 @@ router.get('/:slug/my-feedback',
|
|||||||
try {
|
try {
|
||||||
const event = req.event;
|
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')
|
const query = db('photo_feedback')
|
||||||
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||||
.where('photo_feedback.event_id', event.id);
|
.where('photo_feedback.event_id', event.id);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||||
|
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
|
||||||
const watermarkService = require('../services/watermarkService');
|
const watermarkService = require('../services/watermarkService');
|
||||||
const secureImageService = require('../services/secureImageService');
|
const secureImageService = require('../services/secureImageService');
|
||||||
const { getStorage } = require('../services/storage');
|
const { getStorage } = require('../services/storage');
|
||||||
@@ -16,10 +17,13 @@ const router = express.Router();
|
|||||||
/**
|
/**
|
||||||
* Generate a signed URL token for image access
|
* 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 secret = process.env.JWT_SECRET;
|
||||||
const expires = Date.now() + (expiresIn * 1000);
|
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');
|
const signature = crypto.createHmac('sha256', secret).update(data).digest('hex');
|
||||||
return `${Buffer.from(data).toString('base64')}.${signature}`;
|
return `${Buffer.from(data).toString('base64')}.${signature}`;
|
||||||
}
|
}
|
||||||
@@ -32,7 +36,7 @@ function verifyImageToken(token) {
|
|||||||
const secret = process.env.JWT_SECRET;
|
const secret = process.env.JWT_SECRET;
|
||||||
const [data, signature] = token.split('.');
|
const [data, signature] = token.split('.');
|
||||||
const decoded = Buffer.from(data, 'base64').toString();
|
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)
|
// Verify signature (constant-time — avoids leaking the HMAC byte-by-byte)
|
||||||
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
|
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
|
||||||
@@ -45,7 +49,7 @@ function verifyImageToken(token) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { photoId: parseInt(photoId), expires: parseInt(expires) };
|
return { photoId: parseInt(photoId), expires: parseInt(expires), revealBypass: bypassFlag === '1' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -54,7 +58,7 @@ function verifyImageToken(token) {
|
|||||||
/**
|
/**
|
||||||
* Serve protected image with enhanced security
|
* 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 {
|
try {
|
||||||
const { photoId } = req.params;
|
const { photoId } = req.params;
|
||||||
const { protectionLevel = 'standard', token } = req.query;
|
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
|
* 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 {
|
try {
|
||||||
const { photoId } = req.params;
|
const { photoId } = req.params;
|
||||||
const { protectionLevel = 'standard', expiresIn = 300 } = req.body;
|
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)
|
* Generate signed URL for image access (legacy support)
|
||||||
*/
|
*/
|
||||||
router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (req, res) => {
|
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 {
|
try {
|
||||||
const { photoId } = req.params;
|
const { photoId } = req.params;
|
||||||
|
|
||||||
@@ -235,7 +244,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate signed token
|
// 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}`;
|
const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -272,6 +281,12 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Event not found' });
|
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
|
// Get photo
|
||||||
const photo = await db('photos')
|
const photo = await db('photos')
|
||||||
.where({
|
.where({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
|
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
|
||||||
|
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
|
||||||
const secureImageService = require('../services/secureImageService');
|
const secureImageService = require('../services/secureImageService');
|
||||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
@@ -23,7 +24,7 @@ router.post('/:slug/generate-token', async (req, res, next) => {
|
|||||||
// Add slug to request for verifyGalleryAccess
|
// Add slug to request for verifyGalleryAccess
|
||||||
req.requestedSlug = req.params.slug;
|
req.requestedSlug = req.params.slug;
|
||||||
next();
|
next();
|
||||||
}, verifyGalleryAccess, denySlideshowToken, async (req, res) => {
|
}, verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { photoId, accessType = 'view' } = req.body;
|
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
|
expiresIn: protectionLevel === 'maximum' ? 180 : 300, // 3-5 minutes
|
||||||
maxUses: accessType === 'download' ? 1 : 3,
|
maxUses: accessType === 'download' ? 1 : 3,
|
||||||
clientFingerprint,
|
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(
|
const token = secureImageService.generateSecureToken(
|
||||||
@@ -137,6 +141,12 @@ router.get('/:slug/secure/:photoId/:token',
|
|||||||
return res.status(404).json({ error: 'Gallery not found' });
|
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
|
// Verify photo exists and belongs to event
|
||||||
const photo = await db('photos')
|
const photo = await db('photos')
|
||||||
.where({ id: photoId, event_id: event.id })
|
.where({ id: photoId, event_id: event.id })
|
||||||
@@ -273,6 +283,7 @@ router.get('/:slug/secure-download/:photoId/:token',
|
|||||||
next();
|
next();
|
||||||
},
|
},
|
||||||
verifyGalleryAccess,
|
verifyGalleryAccess,
|
||||||
|
blockHiddenGallery,
|
||||||
denySlideshowToken,
|
denySlideshowToken,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -207,7 +207,11 @@ async function buildOgMetadata(slug, requestPath) {
|
|||||||
// the logo on any of those misses so a half-configured event still
|
// the logo on any of those misses so a half-configured event still
|
||||||
// gets a polished link preview rather than a broken image.
|
// gets a polished link preview rather than a broken image.
|
||||||
let image = logoUrl;
|
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')
|
const heroPhoto = await db('photos')
|
||||||
.where({ id: event.hero_photo_id, event_id: event.id })
|
.where({ id: event.hero_photo_id, event_id: event.id })
|
||||||
.select('id', 'thumbnail_path')
|
.select('id', 'thumbnail_path')
|
||||||
@@ -302,7 +306,8 @@ async function handleGalleryOgCover(req, res) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const event = await resolveSlug(slug);
|
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');
|
res.status(404).type('text/plain').send('Cover not available');
|
||||||
return;
|
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
|
expiresIn = 300, // 5 minutes default
|
||||||
maxUses = 1,
|
maxUses = 1,
|
||||||
clientFingerprint = '',
|
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;
|
} = options;
|
||||||
|
|
||||||
const tokenData = {
|
const tokenData = {
|
||||||
@@ -32,6 +36,7 @@ class SecureImageService {
|
|||||||
maxUses,
|
maxUses,
|
||||||
usedCount: 0,
|
usedCount: 0,
|
||||||
protectionLevel,
|
protectionLevel,
|
||||||
|
revealBypass,
|
||||||
createdAt: Date.now()
|
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
|
* 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.
|
* 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 {
|
try {
|
||||||
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
|
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
|
||||||
let enabled = false;
|
let enabled = false;
|
||||||
@@ -285,7 +285,10 @@ async function emitWorkflowEvent(triggerType, { entityType = null, entityId = nu
|
|||||||
const tcfg = parseJson(wf.trigger_config, {});
|
const tcfg = parseJson(wf.trigger_config, {});
|
||||||
if (tcfg && tcfg.filter && !matchFilter(tcfg.filter, payload)) continue;
|
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();
|
const existing = await db('workflow_runs').where({ dedup_key: dedupKey }).first();
|
||||||
if (existing) continue;
|
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]);
|
}, [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
|
// Get individual protection settings from event
|
||||||
const disableRightClick = data?.event?.disable_right_click === true;
|
const disableRightClick = data?.event?.disable_right_click === true;
|
||||||
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === 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
|
&& settingsData?.gallery_show_filter_bar !== false
|
||||||
&& (data?.photos?.length ?? 0) > 0;
|
&& (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
|
// Full-page layouts (gallery-premium, gallery-story) have their own integrated UI
|
||||||
// Skip all wrapper elements (header, footer, sidebar, filters) for these layouts
|
// Skip all wrapper elements (header, footer, sidebar, filters) for these layouts
|
||||||
const isFullPageLayout = theme.galleryLayout === 'gallery-premium' || theme.galleryLayout === 'gallery-story';
|
const isFullPageLayout = theme.galleryLayout === 'gallery-premium' || theme.galleryLayout === 'gallery-story';
|
||||||
|
|||||||
@@ -813,6 +813,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"gallery": {
|
"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",
|
"expires": "Läuft ab",
|
||||||
"expired": "Abgelaufen",
|
"expired": "Abgelaufen",
|
||||||
"contactOrganizer": "Bitte kontaktieren Sie den Veranstalter, wenn Sie Zugriff auf diese Fotos benötigen.",
|
"contactOrganizer": "Bitte kontaktieren Sie den Veranstalter, wenn Sie Zugriff auf diese Fotos benötigen.",
|
||||||
@@ -964,6 +968,17 @@
|
|||||||
"failedToToggleDownloads": "Aktualisierung der Download-Berechtigung fehlgeschlagen"
|
"failedToToggleDownloads": "Aktualisierung der Download-Berechtigung fehlgeschlagen"
|
||||||
},
|
},
|
||||||
"events": {
|
"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",
|
"totalPhotos": "Gesamtfotos",
|
||||||
"totalViews": "Gesamtaufrufe",
|
"totalViews": "Gesamtaufrufe",
|
||||||
"totalDownloads": "Gesamte Downloads",
|
"totalDownloads": "Gesamte Downloads",
|
||||||
|
|||||||
@@ -360,6 +360,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"gallery": {
|
"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",
|
"expires": "Expires",
|
||||||
"expired": "Expired",
|
"expired": "Expired",
|
||||||
"contactOrganizer": "Please contact the event organizer if you need access to these photos.",
|
"contactOrganizer": "Please contact the event organizer if you need access to these photos.",
|
||||||
@@ -511,6 +515,17 @@
|
|||||||
"failedToToggleDownloads": "Failed to update download permission"
|
"failedToToggleDownloads": "Failed to update download permission"
|
||||||
},
|
},
|
||||||
"events": {
|
"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",
|
"title": "Events",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"createEvent": "Create Event",
|
"createEvent": "Create Event",
|
||||||
|
|||||||
@@ -223,6 +223,10 @@
|
|||||||
"passwordHint": "La contraseña la proporcionó el organizador del evento. Contacta con ellos si no la tienes."
|
"passwordHint": "La contraseña la proporcionó el organizador del evento. Contacta con ellos si no la tienes."
|
||||||
},
|
},
|
||||||
"gallery": {
|
"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",
|
"title": "Galería de fotos",
|
||||||
"welcomeMessage": "Mensaje de bienvenida",
|
"welcomeMessage": "Mensaje de bienvenida",
|
||||||
"expiresOn": "Expira el",
|
"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."
|
"categoryHeroHint": "Si no se establece una foto de portada para una categoría, se usará la foto hero por defecto."
|
||||||
},
|
},
|
||||||
"events": {
|
"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",
|
"title": "Eventos",
|
||||||
"create": "Crear",
|
"create": "Crear",
|
||||||
"createEvent": "Crear evento",
|
"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."
|
"passwordHint": "Le mot de passe a été fourni par l'organisateur de l'événement. Contactez-le si vous ne l'avez pas."
|
||||||
},
|
},
|
||||||
"gallery": {
|
"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",
|
"expires": "Expire",
|
||||||
"expired": "Expiré",
|
"expired": "Expiré",
|
||||||
"contactOrganizer": "Veuillez contacter l'organisateur de l'événement si vous avez besoin d'accéder à ces photos.",
|
"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."
|
"categoryHeroHint": "Si aucune photo de couverture n'est définie pour une catégorie, la photo par défaut sera utilisée."
|
||||||
},
|
},
|
||||||
"events": {
|
"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",
|
"title": "Événements",
|
||||||
"create": "Créer",
|
"create": "Créer",
|
||||||
"createEvent": "Créer un événement",
|
"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."
|
"passwordHint": "Het wachtwoord is verstrekt door de organisator van het evenement. Neem contact op als u het niet heeft."
|
||||||
},
|
},
|
||||||
"gallery": {
|
"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",
|
"expires": "Verloopt",
|
||||||
"expired": "Verlopen",
|
"expired": "Verlopen",
|
||||||
"contactOrganizer": "Neem contact op met de organisator als u toegang tot deze foto's nodig heeft.",
|
"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."
|
"categoryHeroHint": "Als er geen omslagfoto is ingesteld voor een categorie, wordt de standaard hero-foto gebruikt."
|
||||||
},
|
},
|
||||||
"events": {
|
"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",
|
"title": "Evenementen",
|
||||||
"create": "Aanmaken",
|
"create": "Aanmaken",
|
||||||
"createEvent": "Evenement 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."
|
"passwordHint": "A senha foi fornecida pelo organizador do evento. Entre em contato se não a tiver."
|
||||||
},
|
},
|
||||||
"gallery": {
|
"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",
|
"expires": "Expira",
|
||||||
"expired": "Expirada",
|
"expired": "Expirada",
|
||||||
"contactOrganizer": "Entre em contato com o organizador do evento se precisar de acesso a estas fotos",
|
"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."
|
"categoryHeroHint": "Se nenhuma foto de capa for definida, a foto de destaque padrão será usada."
|
||||||
},
|
},
|
||||||
"events": {
|
"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",
|
"title": "Eventos",
|
||||||
"create": "Criar",
|
"create": "Criar",
|
||||||
"createEvent": "Criar Evento",
|
"createEvent": "Criar Evento",
|
||||||
|
|||||||
@@ -227,6 +227,10 @@
|
|||||||
"passwordHint": "Пароль предоставлен организатором события. Свяжитесь с ним, если у вас его нет."
|
"passwordHint": "Пароль предоставлен организатором события. Свяжитесь с ним, если у вас его нет."
|
||||||
},
|
},
|
||||||
"gallery": {
|
"gallery": {
|
||||||
|
"revealPendingTitle": "Фотографии пока остаются сюрпризом",
|
||||||
|
"revealPendingMessage": "Организатор откроет галерею позже — загляните ещё раз!",
|
||||||
|
"revealScheduledFor": "Открытие запланировано на {{date}}",
|
||||||
|
"revealUploadHint": "Вы уже можете добавить свои фотографии в коллекцию:",
|
||||||
"expires": "Истекает",
|
"expires": "Истекает",
|
||||||
"expired": "Истекла",
|
"expired": "Истекла",
|
||||||
"contactOrganizer": "Свяжитесь с организатором события, если вам нужен доступ к этим фотографиям",
|
"contactOrganizer": "Свяжитесь с организатором события, если вам нужен доступ к этим фотографиям",
|
||||||
@@ -374,6 +378,17 @@
|
|||||||
"categoryHeroHint": "Если обложка для категории не задана, будет использоваться стандартное главное фото."
|
"categoryHeroHint": "Если обложка для категории не задана, будет использоваться стандартное главное фото."
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
|
"revealMode": "Режим сюрприза (скрыть галерею до открытия)",
|
||||||
|
"revealModeHelp": "Гости могут загружать фото, но не видят их, пока вы не откроете галерею — вручную или в запланированное время. Слайд-шоу и клиентский доступ продолжают работать.",
|
||||||
|
"revealAt": "Запланированное открытие (необязательно)",
|
||||||
|
"revealAtHelp": "Оставьте пустым, чтобы открыть вручную кнопкой «Открыть сейчас».",
|
||||||
|
"revealModeStatus": "Режим сюрприза",
|
||||||
|
"revealed": "Открыта",
|
||||||
|
"hiddenUntilReveal": "Скрыта от гостей",
|
||||||
|
"revealScheduled": "Запланировано: {{date}}",
|
||||||
|
"revealNow": "Открыть сейчас",
|
||||||
|
"revealedToast": "Галерея открыта — гости теперь видят фотографии",
|
||||||
|
"revealError": "Не удалось открыть галерею",
|
||||||
"title": "События",
|
"title": "События",
|
||||||
"create": "Создать",
|
"create": "Создать",
|
||||||
"createEvent": "Создать событие",
|
"createEvent": "Создать событие",
|
||||||
|
|||||||
@@ -221,6 +221,10 @@
|
|||||||
"passwordHint": "Geslo vam je posredoval organizator dogodka. Če ga nimate, se obrnite nanj."
|
"passwordHint": "Geslo vam je posredoval organizator dogodka. Če ga nimate, se obrnite nanj."
|
||||||
},
|
},
|
||||||
"gallery": {
|
"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",
|
"expires": "Poteče",
|
||||||
"expired": "Poteklo",
|
"expired": "Poteklo",
|
||||||
"contactOrganizer": "Če potrebujete dostop do teh fotografij, se obrnite na organizatorja dogodka.",
|
"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."
|
"categoryHeroHint": "Če za kategorijo ni nastavljena naslovna fotografija, bo uporabljena privzeta hero fotografija."
|
||||||
},
|
},
|
||||||
"events": {
|
"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",
|
"title": "Dogodki",
|
||||||
"create": "Ustvari",
|
"create": "Ustvari",
|
||||||
"createEvent": "Ustvari dogodek",
|
"createEvent": "Ustvari dogodek",
|
||||||
|
|||||||
@@ -206,6 +206,18 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Archive mutation
|
// 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({
|
const archiveMutation = useMutation({
|
||||||
mutationFn: () => eventsService.archiveEvent(parseInt(id!)),
|
mutationFn: () => eventsService.archiveEvent(parseInt(id!)),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -289,6 +301,11 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
css_template_id: event.css_template_id || null,
|
css_template_id: event.css_template_id || null,
|
||||||
expires_at: expiresAtDate ? format(expiresAtDate, 'yyyy-MM-dd') : '',
|
expires_at: expiresAtDate ? format(expiresAtDate, 'yyyy-MM-dd') : '',
|
||||||
allow_user_uploads: event.allow_user_uploads || false,
|
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,
|
upload_category_id: event.upload_category_id || null,
|
||||||
hero_photo_id: event.hero_photo_id || null,
|
hero_photo_id: event.hero_photo_id || null,
|
||||||
customer_name: event.customer_name || '',
|
customer_name: event.customer_name || '',
|
||||||
@@ -428,6 +445,10 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
const updateData: any = {
|
const updateData: any = {
|
||||||
expires_at: editForm.expires_at || null,
|
expires_at: editForm.expires_at || null,
|
||||||
allow_user_uploads: editForm.allow_user_uploads,
|
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,
|
require_password: editForm.require_password,
|
||||||
css_template_id: editForm.css_template_id,
|
css_template_id: editForm.css_template_id,
|
||||||
// Download protection settings
|
// Download protection settings
|
||||||
@@ -566,6 +587,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
photos={photos}
|
photos={photos}
|
||||||
phoneFieldEnabled={phoneFieldEnabled}
|
phoneFieldEnabled={phoneFieldEnabled}
|
||||||
daysUntilExpiration={daysUntilExpiration}
|
daysUntilExpiration={daysUntilExpiration}
|
||||||
|
onRevealNow={() => revealMutation.mutate()}
|
||||||
refetchEvent={refetchEvent}
|
refetchEvent={refetchEvent}
|
||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
setShowPasswordReset={setShowPasswordReset}
|
setShowPasswordReset={setShowPasswordReset}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ interface EventInformationCardProps {
|
|||||||
photos: AdminPhoto[];
|
photos: AdminPhoto[];
|
||||||
phoneFieldEnabled: boolean;
|
phoneFieldEnabled: boolean;
|
||||||
daysUntilExpiration: number | null;
|
daysUntilExpiration: number | null;
|
||||||
|
// Reveal mode (#838): stamps revealed_at via POST /events/:id/reveal
|
||||||
|
onRevealNow?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EventInformationCard: React.FC<EventInformationCardProps> = ({
|
export const EventInformationCard: React.FC<EventInformationCardProps> = ({
|
||||||
@@ -58,7 +60,8 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
|
|||||||
categories,
|
categories,
|
||||||
photos,
|
photos,
|
||||||
phoneFieldEnabled,
|
phoneFieldEnabled,
|
||||||
daysUntilExpiration
|
daysUntilExpiration,
|
||||||
|
onRevealNow
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { format } = useLocalizedDate();
|
const { format } = useLocalizedDate();
|
||||||
@@ -430,6 +433,42 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
|
|||||||
</div>
|
</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 */}
|
{/* Feedback Settings */}
|
||||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
<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>
|
<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>
|
</dd>
|
||||||
</div>
|
</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 */}
|
{/* Download Protection Display */}
|
||||||
<div className="pt-3 mt-3 border-t border-neutral-200 dark:border-neutral-700">
|
<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">
|
<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[];
|
photos: AdminPhoto[];
|
||||||
phoneFieldEnabled: boolean;
|
phoneFieldEnabled: boolean;
|
||||||
daysUntilExpiration: number | null;
|
daysUntilExpiration: number | null;
|
||||||
|
onRevealNow?: () => void;
|
||||||
refetchEvent: () => void;
|
refetchEvent: () => void;
|
||||||
setActiveTab: (tab: EventDetailsTab) => void;
|
setActiveTab: (tab: EventDetailsTab) => void;
|
||||||
setShowPasswordReset: (show: boolean) => void;
|
setShowPasswordReset: (show: boolean) => void;
|
||||||
@@ -63,6 +64,7 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
|
|||||||
photos,
|
photos,
|
||||||
phoneFieldEnabled,
|
phoneFieldEnabled,
|
||||||
daysUntilExpiration,
|
daysUntilExpiration,
|
||||||
|
onRevealNow,
|
||||||
refetchEvent,
|
refetchEvent,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
setShowPasswordReset,
|
setShowPasswordReset,
|
||||||
@@ -100,6 +102,7 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
|
|||||||
photos={photos}
|
photos={photos}
|
||||||
phoneFieldEnabled={phoneFieldEnabled}
|
phoneFieldEnabled={phoneFieldEnabled}
|
||||||
daysUntilExpiration={daysUntilExpiration}
|
daysUntilExpiration={daysUntilExpiration}
|
||||||
|
onRevealNow={onRevealNow}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Share Link */}
|
{/* Share Link */}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export type EditFormState = {
|
|||||||
css_template_id: number | null;
|
css_template_id: number | null;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
allow_user_uploads: boolean;
|
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;
|
upload_category_id: number | null;
|
||||||
hero_photo_id: number | null;
|
hero_photo_id: number | null;
|
||||||
customer_name: string;
|
customer_name: string;
|
||||||
@@ -54,6 +57,8 @@ export const INITIAL_EDIT_FORM: EditFormState = {
|
|||||||
css_template_id: null,
|
css_template_id: null,
|
||||||
expires_at: '',
|
expires_at: '',
|
||||||
allow_user_uploads: false,
|
allow_user_uploads: false,
|
||||||
|
reveal_mode: false,
|
||||||
|
reveal_at: '',
|
||||||
upload_category_id: null,
|
upload_category_id: null,
|
||||||
hero_photo_id: null,
|
hero_photo_id: null,
|
||||||
customer_name: '',
|
customer_name: '',
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const TRIGGERS = [
|
|||||||
'quote.sent', 'quote.accepted', 'quote.declined',
|
'quote.sent', 'quote.accepted', 'quote.declined',
|
||||||
'contract.sent', 'contract.signed',
|
'contract.sent', 'contract.signed',
|
||||||
'event.date_approaching',
|
'event.date_approaching',
|
||||||
'gallery.published', 'gallery.expiring', 'gallery.expired',
|
'gallery.published', 'gallery.expiring', 'gallery.expired', 'gallery.revealed',
|
||||||
'customer.created',
|
'customer.created',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ interface UpdateEventData {
|
|||||||
expires_at?: string;
|
expires_at?: string;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
allow_user_uploads?: boolean;
|
allow_user_uploads?: boolean;
|
||||||
|
// Reveal mode (#838)
|
||||||
|
reveal_mode?: boolean;
|
||||||
|
reveal_at?: string | null;
|
||||||
upload_category_id?: number | null;
|
upload_category_id?: number | null;
|
||||||
hero_photo_id?: number | null;
|
hero_photo_id?: number | null;
|
||||||
source_mode?: 'managed' | 'reference';
|
source_mode?: 'managed' | 'reference';
|
||||||
@@ -129,6 +132,12 @@ export const eventsService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Update event (admin)
|
// 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> {
|
async updateEvent(id: number, data: UpdateEventData): Promise<Event> {
|
||||||
const response = await api.put<Event>(`/admin/events/${id}`, data);
|
const response = await api.put<Event>(`/admin/events/${id}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export interface Event {
|
|||||||
uploaded_at: string;
|
uploaded_at: string;
|
||||||
}>;
|
}>;
|
||||||
allow_user_uploads?: boolean;
|
allow_user_uploads?: boolean;
|
||||||
|
// Reveal mode (#838)
|
||||||
|
reveal_mode?: boolean;
|
||||||
|
reveal_at?: string | null;
|
||||||
|
revealed_at?: string | null;
|
||||||
upload_category_id?: number | null;
|
upload_category_id?: number | null;
|
||||||
hero_photo_id?: number | null;
|
hero_photo_id?: number | null;
|
||||||
total_views?: number;
|
total_views?: number;
|
||||||
@@ -208,6 +212,10 @@ export interface GalleryData {
|
|||||||
};
|
};
|
||||||
categories?: PhotoCategory[];
|
categories?: PhotoCategory[];
|
||||||
photos: Photo[];
|
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 {
|
export interface GalleryStats {
|
||||||
|
|||||||
Reference in New Issue
Block a user