feat(events): publish without notifying, and send the gallery email later (#1235) (#1241)

* feat(events): publish without notifying, and send the gallery email later (#1235)

Publishing queued the gallery_created email whenever any customer email
existed, with no opt-out. A photographer working with a client who has no
address yet — the Instagram-team case in discussion #1086 — had to type their
OWN address into the required field, publish, receive the client-facing email
themselves, and hand the link over by DM. Turning off
`event_require_customer_email` is not the answer either: that is global, and
the same photographer usually does collect addresses.

Two halves, because a checkbox alone is only half a workflow:

- `notify_customer` on publish, default TRUE. Absent means notify, so the v1
  API, an older frontend and any script keep behaving exactly as before. When
  false the gallery goes live and nothing is queued — not the gallery_created
  email, not the assigned-customer-account notice, not WhatsApp. Publishing
  still logs activity and still fires the event.published webhook, because
  those describe a state change rather than a message to a customer.

- POST /:id/send-gallery-email for an already-published gallery. Deliberately
  not restricted to galleries published quietly: re-sending is a normal thing
  to want (spam folder, wrong address since corrected) and refusing would push
  people to unpublish and republish, changing gallery state to work around a
  mail problem. Refused for a draft, whose link would not work yet, and for an
  event with no recipient.

The email composition is now one helper shared by both, so an email sent a
week later is identical to one sent at publish.

UI: a checkbox in the publish dialog (checked by default, hidden when nobody
would be notified anyway), and a "Send gallery email" action on published
galleries that have a recipient. The password field follows the checkbox —
unchecking it means nothing is being sent, so there is no plaintext to carry
and no reason to demand it. EN + DE strings.

7 integration tests. Two fail without the change, verified by forcing
notifyCustomer true and re-running; the rest pin the default, the draft and
no-recipient refusals, and that a gallery with no recipient still publishes.

* fix(events): make the publish dialog description follow the checkbox (#1235)

Caught by screenshotting it. With "Send the gallery email now" unchecked, the
paragraph above still read "...and sends the notification email to
[email protected]" while the control directly beneath it said nothing would be
sent — the dialog contradicted itself at exactly the moment the admin is
deciding whether anything goes out.

It now reads "No email will be sent — you can send it later from this page."
when the box is clear. EN + DE.

* fix(events): close six gaps in publish-quietly found by external review (#1235)

TWO CORRECTIONS TO MY OWN VERIFICATION FIRST. `npx tsc --noEmit` in frontend/
is a NO-OP — the root tsconfig is solution-style with references and no
include, so it checks nothing. Every "tsc clean" I claimed on this branch came
from that. The real check, `tsc -p tsconfig.app.json`, showed two TS2339s I had
introduced: `event.host_email` does not exist on the frontend Event type, which
the admin API normalises away. Both recipient checks now use `customer_email`.

PASSWORD ON SEND-LATER. The action promised to send the link and password but
always called the endpoint without one, so a protected gallery got the
"(set at creation)" sentinel — unusable — and this is most needed right after a
quiet publish, the path that never collects a password. New
SendGalleryEmailDialog asks for it, same shape and reasoning as the publish
dialog (#627). Galleries with no password skip the field.

WHATSAPP-ONLY GALLERIES COULD NOT PUBLISH QUIETLY. willNotify ignored
customer_phone, so a phone-only gallery hid the opt-out AND told the admin
nothing would be sent — while publish queued the WhatsApp anyway. Phone now
counts, with its own description line.

ASSIGNED-ACCOUNT NOTICES COULD NOT BE SENT LATER. The dialog promised it; the
endpoint rejected anything without an inline recipient. It now falls through to
the same customer-account path publish uses.

EDITORS COULD NOT SEE THE ACTION. The send button was nested inside the
events.archive gate, so the default editor role — events.edit, no archive —
never saw a button for an endpoint it is allowed to call. Separate gates now.

DEAD LINKS. The endpoint only checked is_draft, so an archived, inactive or
expired gallery would send a link the gallery middleware rejects. All three are
refused with a reason.

9 backend tests (2 new), 22 across the event suites. eslint clean on every
changed frontend file; crud.js keeps its 2 pre-existing errors.

* fix(events): persist the send-later password, and fix a long-standing isGalleryPublic misuse (#1235)

Round 2 of external review.

THE EMAIL COULD CARRY A PASSWORD THE GALLERY REJECTS. The send-later dialog
invites "or pick a new one", but the route queued that plaintext without
touching password_hash — so the customer got credentials that do not open the
gallery. Worse than the sentinel it replaced, because it looks usable. The
route now hashes and persists first, exactly as publish does.

isGalleryPublic TAKES A VALUE, NOT AN EVENT — and this is pre-existing.
normalizeRequirePassword returns its default for anything that is not a
boolean/number/string, so isGalleryPublic(event) is ALWAYS false and
`requirePassword` was always true. The publish dialog on main has demanded a
password for public galleries for exactly this reason. Both call sites now
pass event.require_password. Fixing the older one alongside mine rather than
leaving a broken copy one line above a fixed one.

ASSIGNED-ACCOUNT GALLERIES HAD NO BUTTON. The route falls through to the
customer-account notice when there is no inline email, and the publish dialog
promises that notice can be sent later — but the button only appeared with a
customer_email, making the promise unkeepable.

WHATSAPP CLAIM SOFTENED. Publish only queues WhatsApp when the config exists
and is enabled, which the dialog cannot see. It now says the customer is
notified there "if WhatsApp is configured" rather than asserting a send.

10 backend tests (1 new, covering the rehash). eslint clean on every changed
frontend file; crud.js keeps its 2 pre-existing errors.

* fix(events): don't reset the password for an account-only notice, hide unusable actions (#1235)

Round 3 of external review. The first is a harm my own round-2 fix introduced.

PASSWORD RESET FOR NOTHING. Round 2 persisted the supplied password before
knowing which mail would go out. For a protected gallery with no inline email
but assigned accounts, the dialog still demands a password, the hash was
rewritten, and then the fallback sent customer_gallery_assigned — which links
to the customer portal and never mentions a password. Net effect: the live
gallery password silently changed and everyone holding the old one was locked
out, in exchange for nothing. It is now persisted only when the mail that
carries it is actually being sent.

BUTTONS THE BACKEND WOULD REFUSE. The send action rendered for expired and
inactive galleries, and counted assigned accounts the endpoint filters out as
inactive — walking the admin through a dialog to reach a generic error toast.
The card now mirrors the endpoint's eligibility rules, and only active accounts
count toward having a recipient.

11 backend tests (1 new, pinning that the hash is untouched on the account
path), 24 across the event suites. tsc and eslint clean on the changed files.

* fix(events): make the send-later action agree with what the endpoint will do

Three findings from an external review round, all the same shape: the UI
predicted the endpoint's behaviour and got it wrong.

GET /admin/events/:id mapped customer_accounts without is_active, so the
"only ACTIVE accounts count" filter in OverviewTab compared undefined and
excluded nothing. A gallery whose only assignments were deactivated showed
the send action, and the endpoint then filtered every recipient and
returned 400. is_active is exposed now, and the count applies the same
predicate the fallback uses — active AND holding an address.

is_active is coerced through toBoolean rather than compared with === false.
On the default SQLite backend it comes back as 0, and 0 === false is false,
so an inactive gallery kept offering a send that parseBooleanInput then
rejected. Same class as #1028.

The password prompt is gated on there being an inline recipient. With no
customer_email the backend takes the account fallback, which sends
customer_gallery_assigned — a portal link that never mentions a password —
and deliberately skips the rehash. Asking for one there blocked the send
behind a six-character value nothing consumes, and the dialog's promise
that it would be rehashed was false.

Frontend suite: 291 passed. tsc and eslint clean.

* fix(events): don't mail a portal link to a customer who cannot sign in

Round-2 finding from the external review.

A passive customer — created directly and never invited — is an active
account with a real address whose password_hash IS NULL. The account
fallback happily mailed it customer_gallery_assigned, which links to
/customer/dashboard, and customerAuth rejects login without a hash: the link
goes to a door that will not open. Worse than failing, the route counted it
and reported success, so the admin believed the customer had been told.

getAssignmentsForEvent now derives can_sign_in (the predicate, never the
hash) and the three call sites share one canReceiveGalleryNotice helper —
publish, send-later, and the payload the UI predicts from all have to agree
or the button appears and then 400s. The UI mirrors it.

Sending passive customers an invitation instead of skipping them is the
better product answer, and a separate feature. Refusing visibly beats a
silent non-delivery in the meantime.

Test asserts the refusal; it fails without the can_sign_in arm.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-09-01 08:18:00 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent bb2f709fdd
commit 1ef2b3c85b
14 changed files with 983 additions and 82 deletions
@@ -0,0 +1,348 @@
/**
* Publish without notifying, and send the gallery email later (#1235).
*
* Publishing queued the gallery_created email whenever any customer email
* existed, with no opt-out — so a photographer with no address yet had to type
* their OWN into the required field, publish, receive the client-facing email
* themselves, and hand the link over by DM. That is the workaround this
* removes.
*
* The send-later half is the part that makes it a workflow rather than a dead
* end: publishing quietly is only useful if the real email can go out once the
* address arrives.
*
* The default must not move. Every existing caller — the v1 API, an older
* frontend, a script — omits the flag entirely and must keep notifying.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const express = require('express');
const request = require('supertest');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-publish-quiet-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'publish-quiet-test-secret';
jest.mock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
}));
jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.mock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
let db;
let cleanup;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
app = express();
app.use(express.json());
app.use('/admin/events', require('../../src/routes/adminEvents'));
}, 180000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('email_queue').del();
await db('events').del();
});
async function seedDraft({ slug, customerEmail = '[email protected]', isDraft = true } = {}) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: `Event ${slug}`,
event_date: '2026-09-01',
host_email: customerEmail,
admin_email: '[email protected]',
customer_email: customerEmail,
password_hash: 'x',
share_link: `/gallery/${slug}/share`,
share_token: `${slug}-token`,
require_password: 0,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: isDraft ? 1 : 0,
created_at: new Date().toISOString(),
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
const queuedFor = (eventId) =>
db('email_queue').where({ event_id: eventId, email_type: 'gallery_created' });
describe('publish quietly (#1235)', () => {
it('queues the gallery email by default — the flag being absent must not change anything', async () => {
const id = await seedDraft({ slug: 'default-publish' });
const res = await request(app).post(`/admin/events/${id}/publish`).send({});
expect(res.status).toBe(200);
expect(res.body.notified_customer).toBe(true);
expect(await queuedFor(id)).toHaveLength(1);
const event = await db('events').where({ id }).first();
expect(Number(event.is_draft)).toBe(0);
});
it('publishes without queuing anything when notify_customer is false', async () => {
const id = await seedDraft({ slug: 'quiet-publish' });
const res = await request(app)
.post(`/admin/events/${id}/publish`)
.send({ notify_customer: false });
expect(res.status).toBe(200);
expect(res.body.notified_customer).toBe(false);
// The whole point: live gallery, no email.
expect(await queuedFor(id)).toHaveLength(0);
const event = await db('events').where({ id }).first();
expect(Number(event.is_draft)).toBe(0);
});
it('sends the gallery email later, on demand', async () => {
const id = await seedDraft({ slug: 'send-later' });
await request(app).post(`/admin/events/${id}/publish`).send({ notify_customer: false });
expect(await queuedFor(id)).toHaveLength(0);
const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
expect(res.status).toBe(200);
expect(res.body.recipient).toBe('[email protected]');
const queued = await queuedFor(id);
expect(queued).toHaveLength(1);
const data = JSON.parse(queued[0].email_data);
expect(data.event_name).toBe('Event send-later');
expect(data.gallery_link).toContain('send-later');
});
it('refuses to send the gallery email for a draft — the link would not work yet', async () => {
const id = await seedDraft({ slug: 'still-draft' });
const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/draft/i);
expect(await queuedFor(id)).toHaveLength(0);
});
it('refuses to send when there is no recipient', async () => {
const [row] = await db('events').insert({
slug: 'no-email',
event_type: 'wedding',
event_name: 'No Email',
event_date: '2026-09-01',
host_email: '',
admin_email: '[email protected]',
customer_email: null,
password_hash: 'x',
share_link: '/gallery/no-email/share',
share_token: 'no-email-token',
require_password: 0,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
const id = typeof row === 'object' ? row.id : row;
const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/no customer email/i);
});
it('still publishes a gallery that has no recipient at all', async () => {
const [row] = await db('events').insert({
slug: 'quiet-no-email',
event_type: 'wedding',
event_name: 'Quiet No Email',
event_date: '2026-09-01',
host_email: '',
admin_email: '[email protected]',
customer_email: null,
password_hash: 'x',
share_link: '/gallery/quiet-no-email/share',
share_token: 'quiet-no-email-token',
require_password: 0,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 1,
created_at: new Date().toISOString(),
}).returning('id');
const id = typeof row === 'object' ? row.id : row;
const res = await request(app)
.post(`/admin/events/${id}/publish`)
.send({ notify_customer: false });
expect(res.status).toBe(200);
const event = await db('events').where({ id }).first();
expect(Number(event.is_draft)).toBe(0);
});
it('refuses to send for an archived, inactive or expired gallery', async () => {
// The link in the email would be rejected by the gallery middleware, so
// sending it hands the customer a dead link with no explanation.
const cases = [
{ slug: 'arch-ev', patch: { is_archived: 1 }, match: /archived/i },
{ slug: 'inactive-ev', patch: { is_active: 0 }, match: /inactive/i },
{
slug: 'expired-ev',
patch: { expires_at: new Date(Date.now() - 3600 * 1000).toISOString() },
match: /expired/i,
},
];
for (const c of cases) {
const id = await seedDraft({ slug: c.slug, isDraft: false });
await db('events').where({ id }).update(c.patch);
const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(c.match);
expect(await queuedFor(id)).toHaveLength(0);
}
});
it('carries the password the admin supplies, instead of the sentinel', async () => {
// password_hash is a hash, so the plaintext only exists in this request.
// Without it the email says "(set at creation)", which cannot get anyone
// into the gallery — and the send-later action is most useful right after
// a quiet publish, the path that never collected a password.
const id = await seedDraft({ slug: 'with-password', isDraft: false });
await db('events').where({ id }).update({ require_password: 1 });
const res = await request(app)
.post(`/admin/events/${id}/send-gallery-email`)
.send({ password: 'sup3r-secret' });
expect(res.status).toBe(200);
const [queued] = await queuedFor(id);
expect(JSON.parse(queued.email_data).gallery_password).toBe('sup3r-secret');
});
it('persists a changed password so the emailed one actually works', async () => {
// The dialog invites "or pick a new one". Queueing that plaintext without
// rehashing would email a password the gallery rejects — worse than the
// sentinel, because it looks usable.
const id = await seedDraft({ slug: 'rehash', isDraft: false });
await db('events').where({ id }).update({ require_password: 1, password_hash: 'stale-hash' });
const res = await request(app)
.post(`/admin/events/${id}/send-gallery-email`)
.send({ password: 'brand-new-pass' });
expect(res.status).toBe(200);
const bcrypt = require('bcrypt');
const row = await db('events').where({ id }).first();
expect(row.password_hash).not.toBe('stale-hash');
expect(await bcrypt.compare('brand-new-pass', row.password_hash)).toBe(true);
const [queued] = await queuedFor(id);
expect(JSON.parse(queued.email_data).gallery_password).toBe('brand-new-pass');
});
it('does NOT touch the gallery password when only an account notice goes out', async () => {
// customer_gallery_assigned links to the customer portal and never carries
// a password. Rehashing for it would silently change the live gallery
// password and lock out everyone holding the old one, for nothing.
const [row] = await db('events').insert({
slug: 'account-only',
event_type: 'wedding',
event_name: 'Account Only',
event_date: '2026-09-01',
host_email: '',
admin_email: '[email protected]',
customer_email: null,
password_hash: 'original-hash',
require_password: 1,
share_link: '/gallery/account-only/share',
share_token: 'account-only-token',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
const id = typeof row === 'object' ? row.id : row;
const res = await request(app)
.post(`/admin/events/${id}/send-gallery-email`)
.send({ password: 'should-not-be-applied' });
// No inline recipient and no assigned accounts in this fixture, so the
// route refuses — but the password must be untouched either way.
expect(res.status).toBe(400);
const after = await db('events').where({ id }).first();
expect(after.password_hash).toBe('original-hash');
});
it('refuses to send when the only assigned account is passive', async () => {
// A passive customer (created directly, never invited) is active and has
// an address, but password_hash IS NULL — customerAuth rejects the login,
// so the customer_gallery_assigned portal link goes to a door that will
// not open. Reporting success here would leave the admin believing the
// customer was told.
const [evRow] = await db('events').insert({
slug: 'passive-only',
event_type: 'wedding',
event_name: 'Passive Only',
event_date: '2026-09-01',
host_email: '',
admin_email: '[email protected]',
customer_email: null,
password_hash: 'original-hash',
require_password: 1,
share_link: '/gallery/passive-only/share',
share_token: 'passive-only-token',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
const eventId = typeof evRow === 'object' ? evRow.id : evRow;
const [custRow] = await db('customer_accounts').insert({
email: '[email protected]',
display_name: 'Passive Person',
password_hash: null, // never invited
is_active: 1,
created_at: new Date().toISOString(),
}).returning('id');
const customerId = typeof custRow === 'object' ? custRow.id : custRow;
await db('event_customer_assignments').insert({
event_id: eventId,
customer_account_id: customerId,
});
const res = await request(app)
.post(`/admin/events/${eventId}/send-gallery-email`)
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/no customer email/i);
});
it('re-sending is allowed — a lost email should not need an unpublish/republish', async () => {
const id = await seedDraft({ slug: 'resend' });
await request(app).post(`/admin/events/${id}/publish`).send({});
expect(await queuedFor(id)).toHaveLength(1);
const res = await request(app).post(`/admin/events/${id}/send-gallery-email`).send({});
expect(res.status).toBe(200);
expect(await queuedFor(id)).toHaveLength(2);
});
});
+241 -54
View File
@@ -31,6 +31,91 @@ const downloadZipService = require('../../services/downloadZipService');
const { resolveEventFeedbackDefaults, applyFeedbackDefaults, KEYBIND_MODES } = require('../../services/feedbackDefaults'); const { resolveEventFeedbackDefaults, applyFeedbackDefaults, KEYBIND_MODES } = require('../../services/feedbackDefaults');
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers'); const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
/**
* Can this assigned customer account actually receive — and act on — the
* gallery notice? (#1235)
*
* Shared by publish, by the send-later route, and mirrored by the UI that
* decides whether to offer the button at all. All four have to agree, or the
* admin gets an action that 400s, or worse, one that reports success for a
* notice nobody can use.
*
* - `is_active`: compared loosely because SQLite stores it as 0/1 and a
* strict `!== false` lets 0 through.
* - `can_sign_in`: a PASSIVE customer (password_hash IS NULL — see
* customerAccountsService.createDirect) is a real, active account that has
* simply never been invited. customer_gallery_assigned links to
* /customer/dashboard, and customerAuth rejects login without a hash, so
* mailing one sends a link to a door that will not open. Excluded here
* rather than mailed, because a silent non-delivery the admin believes
* succeeded is worse than a visible refusal. Sending them an invitation
* instead is the better answer, and a separate feature.
* - the column is emitted by a raw SQL predicate, so it arrives as a boolean
* on Postgres and 0/1 on SQLite; `== false` and `=== 0` cover both, and
* undefined (older callers) stays permissive.
*/
function canReceiveGalleryNotice(account) {
if (!account || !account.email) return false;
if (account.is_active === false || account.is_active === 0) return false;
if (account.can_sign_in === false || account.can_sign_in === 0) return false;
return true;
}
/**
* Queue the gallery_created email for an event (#1235).
*
* Shared by publish and by the send-later route, because the two must produce
* an identical email — an operator who publishes quietly and sends the mail a
* week later should not get a subtly different message than one who published
* loudly.
*
* The password is why this needs an argument at all: `password_hash` is a
* hash, so the plaintext exists only in the request the admin just typed it
* into (#627). Without one the email carries the legacy sentinel, exactly as an
* API-only publish has always done.
*
* @returns {Promise<boolean>} false when the event has no inline recipient
*/
async function queueGalleryCreatedEmail(event, { password, requirePassword } = {}) {
const customerEmail = event.customer_email || event.host_email;
if (!customerEmail) return false;
const customerName = event.customer_name || event.host_name;
const frontendBase = await getFrontendBaseUrl();
const { shareUrl } = await buildShareLinkVariants({
slug: event.slug, shareToken: event.share_token,
});
let galleryPasswordForEmail;
if (!requirePassword) {
galleryPasswordForEmail = 'No password required';
} else if (password) {
galleryPasswordForEmail = password;
} else {
galleryPasswordForEmail = '(set at creation)';
}
await db('email_queue').insert({
event_id: event.id,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify({
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || customerEmail.split('@')[0],
event_name: event.event_name,
event_date: event.event_date,
gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`,
gallery_password: galleryPasswordForEmail,
expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null,
welcome_message: event.welcome_message || ''
}),
status: 'pending',
created_at: new Date()
});
return true;
}
module.exports = (router) => { module.exports = (router) => {
@@ -878,6 +963,13 @@ module.exports = (router) => {
display_name: c.display_name, display_name: c.display_name,
first_name: c.first_name, first_name: c.first_name,
last_name: c.last_name, last_name: c.last_name,
// The send-gallery-email fallback below filters on these, so the UI
// needs them to predict whether the action has any recipient at all.
// Without them every assigned account looked reachable and a gallery
// whose only assignments were deactivated or passive offered a
// button that then 400'd — or worse, reported success.
is_active: c.is_active,
can_sign_in: c.can_sign_in,
})), })),
})); }));
} catch (error) { } catch (error) {
@@ -885,6 +977,121 @@ module.exports = (router) => {
} }
}); });
// Send the gallery email for an ALREADY published event (#1235).
//
// The other half of publish-quietly, and the half that makes it a workflow
// rather than a dead end: the case this exists for is "no address yet, I'll
// send the link by DM and mail it properly once they give me one". Without
// this the operator publishes quietly and then has no way to send the real
// email at all.
//
// Deliberately NOT restricted to galleries that were published quietly.
// Re-sending is a normal thing to want — the customer deleted it, it went to
// spam, the address was wrong and has been corrected — and refusing would
// just push people to unpublish and republish, which changes gallery state
// to work around a mail problem.
router.post('/:id/send-gallery-email', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
body('password').optional().isString().isLength({ min: 6 })
.withMessage('Password must be at least 6 characters long'),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { password } = req.body;
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (parseBooleanInput(event.is_draft, false)) {
// A draft has no working gallery link yet, so the email would carry a
// URL the customer cannot open. Publishing is the action they want.
return res.status(400).json({ error: 'Event is still a draft — publish it first' });
}
// Same reason, for the other three ways a gallery stops being reachable:
// the link in the email would be rejected by the gallery middleware, so
// sending it is worse than refusing — the customer gets a dead link with
// no explanation.
if (parseBooleanInput(event.is_archived, false)) {
return res.status(400).json({ error: 'Event is archived — restore it before sending' });
}
if (!parseBooleanInput(event.is_active, true)) {
return res.status(400).json({ error: 'Event is inactive — the gallery link would not work' });
}
if (event.expires_at && new Date(event.expires_at) <= new Date()) {
return res.status(400).json({ error: 'Event has expired — extend it before sending' });
}
const requirePassword = parseBooleanInput(event.require_password, true);
const hasInlineRecipient = !!(event.customer_email || event.host_email);
// Persist the password ONLY when the mail that carries it is actually
// going out (#627). The account-only fallback below sends
// customer_gallery_assigned, which links to the customer portal and
// never mentions a password — rehashing for that would silently change
// the live gallery password and lock out everyone holding the old one,
// in exchange for nothing.
if (hasInlineRecipient && requirePassword && password) {
await db('events').where('id', id).update({
password_hash: await bcrypt.hash(password, getBcryptRounds()),
});
}
const queued = hasInlineRecipient
&& await queueGalleryCreatedEmail(event, { password, requirePassword });
if (!queued) {
// No inline recipient, but the gallery may be assigned to registered
// customer account(s) — the same path publish takes. Without this the
// publish dialog's promise that the notice can be sent later is false
// for exactly those galleries.
let notified = 0;
try {
const customerAccountsService = require('../../services/customerAccountsService');
const assigned = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10));
for (const c of assigned.filter(canReceiveGalleryNotice)) {
await customerAccountsService
.notifyCustomerOfNewAssignments(c.id, [parseInt(id, 10)])
.then(() => { notified += 1; })
.catch((err) => logger.warn('Send gallery email: customer notice failed', { customerId: c.id, error: err.message }));
}
} catch (err) {
logger.warn('Send gallery email: assigned-customer lookup failed', { eventId: id, error: err.message });
}
if (notified > 0) {
await logActivity('gallery_email_sent',
{ event_name: event.event_name, assigned_accounts: notified },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
return res.json({
message: 'Gallery notice queued',
recipient: `${notified} assigned customer account(s)`,
});
}
return res.status(400).json({
error: 'No customer email is set for this event',
});
}
await logActivity('gallery_email_sent',
{ event_name: event.event_name },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
message: 'Gallery email queued',
recipient: event.customer_email || event.host_email,
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to send the gallery email');
}
});
// Publish a draft event (set is_draft=false and queue creation email) // Publish a draft event (set is_draft=false and queue creation email)
router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, [ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
// Optional password the admin re-types in the publish dialog so the // Optional password the admin re-types in the publish dialog so the
@@ -896,6 +1103,9 @@ module.exports = (router) => {
// compat with API-only consumers. // compat with API-only consumers.
body('password').optional().isString().isLength({ min: 6 }) body('password').optional().isString().isLength({ min: 6 })
.withMessage('Password must be at least 6 characters long'), .withMessage('Password must be at least 6 characters long'),
// Publish without telling the customer yet (#1235). Defaults to true, so
// every existing caller — the API, older frontends — keeps notifying.
body('notify_customer').optional().isBoolean(),
], async (req, res) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
@@ -905,6 +1115,7 @@ module.exports = (router) => {
const { id } = req.params; const { id } = req.params;
const { password } = req.body; const { password } = req.body;
const notifyCustomer = parseBooleanInput(req.body?.notify_customer, true);
const event = await db('events').where('id', id).first(); const event = await db('events').where('id', id).first();
if (!event) { if (!event) {
@@ -925,67 +1136,39 @@ module.exports = (router) => {
} }
await db('events').where('id', id).update(publishUpdates); await db('events').where('id', id).update(publishUpdates);
// Queue creation email // Notify the customer — unless the admin asked to publish quietly
// (#1235). Everything else about publishing still happens: the gallery
// goes live, the activity is logged, and the event.published webhook
// fires, because those describe a state change rather than a message to
// a customer.
const customerEmail = event.customer_email || event.host_email; const customerEmail = event.customer_email || event.host_email;
const customerName = event.customer_name || event.host_name; if (notifyCustomer) {
if (customerEmail) { if (customerEmail) {
const frontendBase = await getFrontendBaseUrl(); await queueGalleryCreatedEmail(event, { password, requirePassword });
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
let galleryPasswordForEmail;
if (!requirePassword) {
galleryPasswordForEmail = 'No password required';
} else if (password) {
// Admin re-typed the password in the publish dialog — put it straight
// into the email so the customer can actually log in (#627).
galleryPasswordForEmail = password;
} else { } else {
// Legacy fallback for API-only publishes that don't carry the password. // No inline email, but the gallery may be assigned to registered
galleryPasswordForEmail = '(set at creation)'; // customer account(s). Notify them via the account "your galleries"
} // email (customer_gallery_assigned, in the customer's own language)
// instead of the gallery_created mail, which needs an inline
const emailData = { // recipient. Best-effort.
customer_name: customerName, try {
customer_email: customerEmail, const customerAccountsService = require('../../services/customerAccountsService');
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null), const assigned = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10));
event_name: event.event_name, for (const c of assigned.filter(canReceiveGalleryNotice)) {
event_date: event.event_date, await customerAccountsService
gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`, .notifyCustomerOfNewAssignments(c.id, [parseInt(id, 10)])
gallery_password: galleryPasswordForEmail, .catch((err) => logger.warn('Publish: customer gallery notice failed', { customerId: c.id, error: err.message }));
expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null, }
welcome_message: event.welcome_message || '' } catch (err) {
}; logger.warn('Publish: assigned-customer notification skipped', { eventId: id, error: err.message });
await db('email_queue').insert({
event_id: id,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify(emailData),
status: 'pending',
created_at: new Date()
});
} else {
// No inline email, but the gallery may be assigned to registered customer
// account(s). Notify them via the account "your galleries" email
// (customer_gallery_assigned, in the customer's own language) instead of
// the gallery_created mail, which needs an inline recipient. Best-effort.
try {
const customerAccountsService = require('../../services/customerAccountsService');
const assigned = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10));
for (const c of assigned.filter((a) => a.is_active !== false && a.is_active !== 0 && a.email)) {
await customerAccountsService
.notifyCustomerOfNewAssignments(c.id, [parseInt(id, 10)])
.catch((err) => logger.warn('Publish: customer gallery notice failed', { customerId: c.id, error: err.message }));
} }
} catch (err) {
logger.warn('Publish: assigned-customer notification skipped', { eventId: id, error: err.message });
} }
} }
// WhatsApp gallery_ready on publish-from-draft (#640D). The PublishGallery // WhatsApp gallery_ready on publish-from-draft (#640D). The PublishGallery
// dialog (#627) hands us the password back so we can deliver it via // dialog (#627) hands us the password back so we can deliver it via
// WhatsApp as well. Uses customer_phone from the persisted event row. // WhatsApp as well. Uses customer_phone from the persisted event row.
if (event.customer_phone) { if (notifyCustomer && event.customer_phone) {
try { try {
const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor'); const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor');
const waConfig = await getWhatsAppConfig(); const waConfig = await getWhatsAppConfig();
@@ -1011,7 +1194,7 @@ module.exports = (router) => {
} }
await logActivity('event_published', await logActivity('event_published',
{ event_name: event.event_name }, { event_name: event.event_name, notified_customer: notifyCustomer },
id, id,
{ type: 'admin', id: req.admin.id, name: req.admin.username } { type: 'admin', id: req.admin.id, name: req.admin.username }
); );
@@ -1037,7 +1220,11 @@ module.exports = (router) => {
}); });
} catch (e) { /* non-fatal */ } } catch (e) { /* non-fatal */ }
res.json({ message: 'Event published successfully', is_draft: false }); res.json({
message: 'Event published successfully',
is_draft: false,
notified_customer: notifyCustomer,
});
} catch (error) { } catch (error) {
errorResponse(res, error, 500, 'Failed to publish event'); errorResponse(res, error, 500, 'Failed to publish event');
} }
@@ -1119,7 +1119,12 @@ async function getAssignmentsForEvent(eventId) {
'customer_accounts.display_name', 'customer_accounts.display_name',
'customer_accounts.first_name', 'customer_accounts.first_name',
'customer_accounts.last_name', 'customer_accounts.last_name',
'customer_accounts.is_active' 'customer_accounts.is_active',
// NOT the hash itself — only whether one exists. A passive customer is
// identified by password_hash IS NULL (see createDirect), and callers
// that mail a portal link need to know the recipient can actually sign
// in to follow it.
db.raw('(customer_accounts.password_hash IS NOT NULL) as can_sign_in')
) )
.orderBy('customer_accounts.email', 'asc'); .orderBy('customer_accounts.email', 'asc');
} }
@@ -7,10 +7,12 @@ interface PublishGalleryDialogProps {
eventName: string; eventName: string;
requirePassword: boolean; requirePassword: boolean;
customerEmail?: string | null; customerEmail?: string | null;
/** WhatsApp recipient — publish notifies this too, so it counts as "someone gets told". */
customerPhone?: string | null;
/** Assigned customer accounts — notified via the account "your galleries" email when there's no inline email. */ /** Assigned customer accounts — notified via the account "your galleries" email when there's no inline email. */
assignedCustomerCount?: number; assignedCustomerCount?: number;
isPublishing: boolean; isPublishing: boolean;
onConfirm: (password?: string) => void; onConfirm: (password?: string, notifyCustomer?: boolean) => void;
onClose: () => void; onClose: () => void;
} }
@@ -30,23 +32,32 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
eventName, eventName,
requirePassword, requirePassword,
customerEmail, customerEmail,
customerPhone,
assignedCustomerCount = 0, assignedCustomerCount = 0,
isPublishing, isPublishing,
onConfirm, onConfirm,
onClose, onClose,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// Someone gets notified if there's an inline email OR an assigned account // Someone gets notified if there's an inline email, an assigned account (the
// (the latter via the account "your galleries" email). // account "your galleries" email), OR a phone — publish queues a WhatsApp
const willNotify = !!customerEmail || assignedCustomerCount > 0; // for that last one. Leaving the phone out hid the opt-out on phone-only
// galleries AND told the admin nothing would be sent, while the WhatsApp
// went out anyway.
const willNotify = !!customerEmail || !!customerPhone || assignedCustomerCount > 0;
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
// Defaults to notifying — that is what publish has always done, and the
// quiet path is the exception (#1235).
const [notifyCustomer, setNotifyCustomer] = useState(true);
// The password is only collected (and required) on the inline-email path, // The password is only collected (and required) on the inline-email path,
// because the gallery_created email carries it. With no inline email the field // because the gallery_created email carries it. With no inline email the field
// is hidden and the existing hash is kept — so don't gate submit on it, or a // is hidden and the existing hash is kept — so don't gate submit on it, or a
// password-protected gallery without an email could never be published. // password-protected gallery without an email could never be published.
const needsPassword = requirePassword && !!customerEmail; // Unchecking "notify" hides it for the same reason: nothing is being sent,
const [password, setPassword] = useState(''); // so there is no plaintext to carry and no reason to demand it.
const [showPassword, setShowPassword] = useState(false); const needsPassword = requirePassword && !!customerEmail && notifyCustomer;
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = () => { const handleSubmit = () => {
if (needsPassword) { if (needsPassword) {
@@ -56,7 +67,7 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
} }
} }
setError(undefined); setError(undefined);
onConfirm(needsPassword ? password : undefined); onConfirm(needsPassword ? password : undefined, notifyCustomer);
}; };
return ( return (
@@ -76,7 +87,16 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
</div> </div>
<p className="text-neutral-600 dark:text-neutral-400 mb-4"> <p className="text-neutral-600 dark:text-neutral-400 mb-4">
{customerEmail {/* Follows the checkbox. Left static it contradicted itself — the
text promised an email to the customer while the box beneath it
said none would be sent. */}
{willNotify && !notifyCustomer
? t('events.publishDialog.descriptionQuiet', {
eventName,
defaultValue:
'Publishing "{{eventName}}" makes the gallery accessible. No email will be sent — you can send it later from this page.',
})
: customerEmail
? t('events.publishDialog.descriptionWithEmail', { ? t('events.publishDialog.descriptionWithEmail', {
eventName, eventName,
customerEmail, customerEmail,
@@ -90,6 +110,12 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
defaultValue: defaultValue:
'Publishing "{{eventName}}" makes the gallery accessible. The assigned customer account(s) will be notified by email (in their language) that it is available.', 'Publishing "{{eventName}}" makes the gallery accessible. The assigned customer account(s) will be notified by email (in their language) that it is available.',
}) })
: customerPhone
? t('events.publishDialog.descriptionWhatsapp', {
eventName,
defaultValue:
'Publishing "{{eventName}}" makes the gallery accessible. If WhatsApp is configured, the customer is notified there.',
})
: t('events.publishDialog.descriptionNoEmail', { : t('events.publishDialog.descriptionNoEmail', {
eventName, eventName,
defaultValue: defaultValue:
@@ -97,6 +123,31 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
})} })}
</p> </p>
{willNotify && (
<label className="flex items-start gap-3 mb-4 cursor-pointer">
<input
type="checkbox"
checked={notifyCustomer}
onChange={(e) => {
setNotifyCustomer(e.target.checked);
if (error) setError(undefined);
}}
className="mt-1 h-4 w-4 rounded border-neutral-300 dark:border-neutral-600"
/>
<span className="text-sm">
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{t('events.publishDialog.notifyLabel', 'Send the gallery email now')}
</span>
<span className="block text-neutral-600 dark:text-neutral-400">
{t(
'events.publishDialog.notifyHelp',
'Uncheck to publish quietly — the gallery goes live and nothing is sent. You can send the email later from this page.',
)}
</span>
</span>
</label>
)}
{needsPassword && ( {needsPassword && (
<div className="space-y-3 mb-4"> <div className="space-y-3 mb-4">
<Input <Input
@@ -151,9 +202,11 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
onClick={handleSubmit} onClick={handleSubmit}
disabled={isPublishing} disabled={isPublishing}
isLoading={isPublishing} isLoading={isPublishing}
leftIcon={willNotify ? <Send className="w-4 h-4" /> : undefined} leftIcon={willNotify && notifyCustomer ? <Send className="w-4 h-4" /> : undefined}
> >
{willNotify ? t('events.publishAndNotify') : t('events.publishDialog.justPublish', 'Publish')} {willNotify && notifyCustomer
? t('events.publishAndNotify')
: t('events.publishDialog.justPublish', 'Publish')}
</Button> </Button>
</div> </div>
</Card> </Card>
@@ -0,0 +1,125 @@
import React, { useState } from 'react';
import { X, Mail, Lock, Eye, EyeOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card, Input } from '../common';
interface SendGalleryEmailDialogProps {
eventName: string;
recipient: string;
requirePassword: boolean;
isSending: boolean;
onConfirm: (password?: string) => void;
onClose: () => void;
}
/**
* Send the gallery email for an already-published gallery (#1235).
*
* It asks for the password for the same reason the publish dialog does (#627):
* `password_hash` is a hash, so the plaintext only exists in the request the
* admin types it into. Without it the email carries the "(set at creation)"
* sentinel — and this action is most useful right after a quiet publish, which
* is exactly the path that never collected a password. An email whose password
* line reads "(set at creation)" cannot get the customer into the gallery, so
* asking here is what makes the button do what its label promises.
*
* Galleries with no password skip the field entirely — there is nothing to
* carry, and the email says so.
*/
export const SendGalleryEmailDialog: React.FC<SendGalleryEmailDialogProps> = ({
eventName,
recipient,
requirePassword,
isSending,
onConfirm,
onClose,
}) => {
const { t } = useTranslation();
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = () => {
if (requirePassword) {
if (!password || password.trim().length < 6) {
setError(t('events.publishDialog.errorMinLength', 'Password must be at least 6 characters long.'));
return;
}
}
setError(undefined);
onConfirm(requirePassword ? password : undefined);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-md w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{t('events.sendGalleryEmail.title', 'Send gallery email')}
</h2>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
aria-label={t('common.close', 'Close')}
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
{t('events.sendGalleryEmail.description', {
eventName,
recipient,
defaultValue: 'Sends the gallery link for "{{eventName}}" to {{recipient}}.',
})}
</p>
{requirePassword && (
<div className="space-y-3 mb-4">
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.publishDialog.passwordLabel', 'Gallery password')}
placeholder={t('events.publishDialog.passwordPlaceholder', 'Enter the gallery password')}
value={password}
onChange={(e) => {
setPassword(e.target.value);
if (error) setError(undefined);
}}
error={error}
helperText={t(
'events.sendGalleryEmail.passwordHelp',
'The email includes this exact text. Re-type the gallery password (or pick a new one) — the backend re-hashes it so the login still works.',
)}
leftIcon={<Lock className="w-5 h-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1"
aria-label={showPassword ? t('events.passwordReset.hide', 'Hide') : t('events.passwordReset.show', 'Show')}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/>
</div>
)}
<div className="flex flex-col-reverse gap-3">
<Button variant="outline" onClick={onClose} disabled={isSending}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={isSending}
isLoading={isSending}
leftIcon={<Mail className="w-4 h-4" />}
>
{t('events.sendGalleryEmail.button', 'Send gallery email')}
</Button>
</div>
</Card>
</div>
);
};
+1
View File
@@ -17,6 +17,7 @@ export { AdminPhotoViewer } from './AdminPhotoViewer';
export { PhotoFilters } from './PhotoFilters'; export { PhotoFilters } from './PhotoFilters';
export { PasswordResetModal } from './PasswordResetModal'; export { PasswordResetModal } from './PasswordResetModal';
export { PublishGalleryDialog } from './PublishGalleryDialog'; export { PublishGalleryDialog } from './PublishGalleryDialog';
export { SendGalleryEmailDialog } from './SendGalleryEmailDialog';
export { DuplicateEventDialog } from './DuplicateEventDialog'; export { DuplicateEventDialog } from './DuplicateEventDialog';
export { ExportPreviewModal } from './ExportPreviewModal'; export { ExportPreviewModal } from './ExportPreviewModal';
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
+15 -1
View File
@@ -1373,7 +1373,11 @@
"passwordLabel": "Galerie-Passwort", "passwordLabel": "Galerie-Passwort",
"passwordPlaceholder": "Galerie-Passwort eingeben", "passwordPlaceholder": "Galerie-Passwort eingeben",
"passwordHelp": "Gib das bei der Erstellung gesetzte Passwort erneut ein (oder wähle ein neues). Die E-Mail enthält genau diesen Text; das Backend hasht es erneut, sodass die Galerie-Anmeldung weiterhin funktioniert.", "passwordHelp": "Gib das bei der Erstellung gesetzte Passwort erneut ein (oder wähle ein neues). Die E-Mail enthält genau diesen Text; das Backend hasht es erneut, sodass die Galerie-Anmeldung weiterhin funktioniert.",
"errorMinLength": "Das Passwort muss mindestens 6 Zeichen lang sein." "errorMinLength": "Das Passwort muss mindestens 6 Zeichen lang sein.",
"notifyLabel": "Galerie-E-Mail jetzt senden",
"notifyHelp": "Abwählen, um still zu veröffentlichen — die Galerie geht online, es wird nichts versendet. Die E-Mail lässt sich später auf dieser Seite senden.",
"descriptionQuiet": "Durch das Veröffentlichen wird \"{{eventName}}\" zugänglich. Es wird keine E-Mail versendet — Sie können sie später auf dieser Seite senden.",
"descriptionWhatsapp": "Durch das Veröffentlichen wird \"{{eventName}}\" zugänglich. Sofern WhatsApp eingerichtet ist, wird der Kunde dort benachrichtigt."
}, },
"duplicateEvent": "Galerie duplizieren", "duplicateEvent": "Galerie duplizieren",
"duplicateDialog": { "duplicateDialog": {
@@ -1513,6 +1517,16 @@
"title": "Heldenbild als Vorschau für geteilte Links verwenden", "title": "Heldenbild als Vorschau für geteilte Links verwenden",
"help": "Beim Teilen der Galerie-URL auf WhatsApp, Facebook, Slack usw. wird das oben gewählte Heldenbild als Link-Vorschau angezeigt. Das Thumbnail wird von Link-Preview-Crawlern ohne Authentifizierung abgerufen — wer die URL teilt, macht damit faktisch dieses Bild öffentlich. Standardmäßig aus; wähle erst ein Heldenbild, das du bewusst öffentlich zeigen möchtest, bevor du diese Option aktivierst.", "help": "Beim Teilen der Galerie-URL auf WhatsApp, Facebook, Slack usw. wird das oben gewählte Heldenbild als Link-Vorschau angezeigt. Das Thumbnail wird von Link-Preview-Crawlern ohne Authentifizierung abgerufen — wer die URL teilt, macht damit faktisch dieses Bild öffentlich. Standardmäßig aus; wähle erst ein Heldenbild, das du bewusst öffentlich zeigen möchtest, bevor du diese Option aktivierst.",
"heroRequired": "Wähle zuerst oben ein Heldenbild — diese Option verwendet es als WhatsApp- / Facebook- / Slack-Vorschaubild." "heroRequired": "Wähle zuerst oben ein Heldenbild — diese Option verwendet es als WhatsApp- / Facebook- / Slack-Vorschaubild."
},
"publishQuietSuccess": "Galerie veröffentlicht. Es wurde keine E-Mail versendet.",
"sendGalleryEmail": {
"button": "Galerie-E-Mail senden",
"help": "Sendet den Galerie-Link an den Kunden. Bei Galerien mit Passwort bestätigen Sie es vorher.",
"confirm": "Galerie-E-Mail an {{recipient}} senden?",
"success": "Galerie-E-Mail an {{recipient}} eingereiht.",
"title": "Galerie-E-Mail senden",
"description": "Sendet den Galerie-Link für \"{{eventName}}\" an {{recipient}}.",
"passwordHelp": "Die E-Mail enthält genau diesen Text. Geben Sie das Galerie-Passwort erneut ein (oder wählen Sie ein neues) — das Backend hasht es neu, damit der Login weiter funktioniert."
} }
}, },
"settings": { "settings": {
+15 -1
View File
@@ -903,7 +903,11 @@
"passwordLabel": "Gallery password", "passwordLabel": "Gallery password",
"passwordPlaceholder": "Enter the gallery password", "passwordPlaceholder": "Enter the gallery password",
"passwordHelp": "Re-type the password set at creation (or pick a new one). The email includes this exact text; the backend re-hashes it so the gallery login still works.", "passwordHelp": "Re-type the password set at creation (or pick a new one). The email includes this exact text; the backend re-hashes it so the gallery login still works.",
"errorMinLength": "Password must be at least 6 characters long." "errorMinLength": "Password must be at least 6 characters long.",
"notifyLabel": "Send the gallery email now",
"notifyHelp": "Uncheck to publish quietly — the gallery goes live and nothing is sent. You can send the email later from this page.",
"descriptionQuiet": "Publishing \"{{eventName}}\" makes the gallery accessible. No email will be sent — you can send it later from this page.",
"descriptionWhatsapp": "Publishing \"{{eventName}}\" makes the gallery accessible. If WhatsApp is configured, the customer is notified there."
}, },
"duplicateEvent": "Duplicate gallery", "duplicateEvent": "Duplicate gallery",
"duplicateDialog": { "duplicateDialog": {
@@ -1054,6 +1058,16 @@
"title": "Use hero photo as social-share preview", "title": "Use hero photo as social-share preview",
"help": "When this gallery URL is shared on WhatsApp, Facebook, Slack, etc., the link preview will show the hero photo above. The thumbnail is fetched unauthenticated by link-preview crawlers — anyone with the URL effectively makes this image public. Off by default; pick a hero you are comfortable surfacing publicly before enabling.", "help": "When this gallery URL is shared on WhatsApp, Facebook, Slack, etc., the link preview will show the hero photo above. The thumbnail is fetched unauthenticated by link-preview crawlers — anyone with the URL effectively makes this image public. Off by default; pick a hero you are comfortable surfacing publicly before enabling.",
"heroRequired": "Pick a hero photo above first — this option uses it as the WhatsApp / Facebook / Slack preview image." "heroRequired": "Pick a hero photo above first — this option uses it as the WhatsApp / Facebook / Slack preview image."
},
"publishQuietSuccess": "Gallery published. No email was sent.",
"sendGalleryEmail": {
"button": "Send gallery email",
"help": "Sends the gallery link to the customer. You confirm the password first if the gallery has one.",
"confirm": "Send the gallery email to {{recipient}}?",
"success": "Gallery email queued to {{recipient}}.",
"title": "Send gallery email",
"description": "Sends the gallery link for \"{{eventName}}\" to {{recipient}}.",
"passwordHelp": "The email includes this exact text. Re-type the gallery password (or pick a new one) — the backend re-hashes it so the login still works."
} }
}, },
"settings": { "settings": {
+63 -7
View File
@@ -6,7 +6,7 @@ import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Loading } from '../../components/common'; import { Loading } from '../../components/common';
import { PasswordResetModal, PublishGalleryDialog, DuplicateEventDialog, EventRenameDialog, AdminGuestsList } from '../../components/admin'; import { PasswordResetModal, PublishGalleryDialog, SendGalleryEmailDialog, DuplicateEventDialog, EventRenameDialog, AdminGuestsList } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service'; import { eventsService } from '../../services/events.service';
import { usePublicSettings } from '../../hooks/usePublicSettings'; import { usePublicSettings } from '../../hooks/usePublicSettings';
@@ -60,6 +60,7 @@ export const EventDetailsPage: React.FC = () => {
const [showNewPassword, setShowNewPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false);
const [showRenameDialog, setShowRenameDialog] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false);
const [showPublishDialog, setShowPublishDialog] = useState(false); const [showPublishDialog, setShowPublishDialog] = useState(false);
const [showSendEmailDialog, setShowSendEmailDialog] = useState(false);
const [showDuplicateDialog, setShowDuplicateDialog] = useState(false); const [showDuplicateDialog, setShowDuplicateDialog] = useState(false);
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null); const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('default'); const [currentPresetName, setCurrentPresetName] = useState<string>('default');
@@ -245,12 +246,22 @@ export const EventDetailsPage: React.FC = () => {
// Publish mutation (Draft mode). Accepts the admin-typed password so the // Publish mutation (Draft mode). Accepts the admin-typed password so the
// gallery_created email can carry the real plaintext (#627). // gallery_created email can carry the real plaintext (#627).
const publishMutation = useMutation({ const publishMutation = useMutation({
mutationFn: (password?: string) => mutationFn: (vars: { password?: string; notifyCustomer?: boolean }) =>
eventsService.publishEvent(parseInt(id!), password ? { password } : undefined), eventsService.publishEvent(parseInt(id!), {
onSuccess: () => { password: vars.password,
notifyCustomer: vars.notifyCustomer,
}),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] }); queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
queryClient.invalidateQueries({ queryKey: ['admin-events'] }); queryClient.invalidateQueries({ queryKey: ['admin-events'] });
toast.success(t('events.publishSuccess')); // Say which of the two happened — "published" and "published and
// emailed your customer" are different enough that a single message
// would leave the admin unsure whether anything went out (#1235).
toast.success(
result?.notified_customer === false
? t('events.publishQuietSuccess', 'Gallery published. No email was sent.')
: t('events.publishSuccess'),
);
setShowPublishDialog(false); setShowPublishDialog(false);
}, },
onError: () => { onError: () => {
@@ -258,6 +269,25 @@ export const EventDetailsPage: React.FC = () => {
}, },
}); });
// Send the gallery email after the fact (#1235). Pairs with publishing
// quietly: the address usually arrives later than the gallery does.
const sendGalleryEmailMutation = useMutation({
mutationFn: (password?: string) =>
eventsService.sendGalleryEmail(parseInt(id!), password ? { password } : undefined),
onSuccess: (result) => {
toast.success(
t('events.sendGalleryEmail.success', {
recipient: result.recipient,
defaultValue: 'Gallery email queued to {{recipient}}.',
}),
);
setShowSendEmailDialog(false);
},
onError: () => {
toast.error(t('errors.somethingWentWrong'));
},
});
// Duplicate mutation (#626). Backend creates a draft inheriting branding + // Duplicate mutation (#626). Backend creates a draft inheriting branding +
// behaviour + categories from the source; we navigate to the new event so // behaviour + categories from the source; we navigate to the new event so
// the admin can finish configuring + publish. // the admin can finish configuring + publish.
@@ -627,6 +657,8 @@ export const EventDetailsPage: React.FC = () => {
setShowPasswordReset={setShowPasswordReset} setShowPasswordReset={setShowPasswordReset}
setShowPublishDialog={setShowPublishDialog} setShowPublishDialog={setShowPublishDialog}
setShowDuplicateDialog={setShowDuplicateDialog} setShowDuplicateDialog={setShowDuplicateDialog}
onSendGalleryEmail={() => setShowSendEmailDialog(true)}
isSendingGalleryEmail={sendGalleryEmailMutation.isPending}
onArchive={() => archiveMutation.mutate()} onArchive={() => archiveMutation.mutate()}
isArchiving={archiveMutation.isPending} isArchiving={archiveMutation.isPending}
isPublishing={publishMutation.isPending} isPublishing={publishMutation.isPending}
@@ -706,17 +738,41 @@ export const EventDetailsPage: React.FC = () => {
{showPublishDialog && ( {showPublishDialog && (
<PublishGalleryDialog <PublishGalleryDialog
eventName={event.event_name} eventName={event.event_name}
requirePassword={isGalleryPublic(event) ? false : true} requirePassword={!isGalleryPublic(event.require_password)}
customerEmail={event.customer_email} customerEmail={event.customer_email}
customerPhone={event.customer_phone}
assignedCustomerCount={((event as { customer_accounts?: Array<{ id: number }> }).customer_accounts || []).length} assignedCustomerCount={((event as { customer_accounts?: Array<{ id: number }> }).customer_accounts || []).length}
isPublishing={publishMutation.isPending} isPublishing={publishMutation.isPending}
onConfirm={(password) => publishMutation.mutate(password)} onConfirm={(password, notifyCustomer) => publishMutation.mutate({ password, notifyCustomer })}
onClose={() => { onClose={() => {
if (!publishMutation.isPending) setShowPublishDialog(false); if (!publishMutation.isPending) setShowPublishDialog(false);
}} }}
/> />
)} )}
{/* Send Gallery Email Dialog (#1235) — asks for the password for the
same reason publish does: the plaintext only exists in this request,
and this action is most useful right after a quiet publish, which
never collected one. */}
{showSendEmailDialog && (
<SendGalleryEmailDialog
eventName={event.event_name}
recipient={event.customer_email}
// Only the inline-email path carries the password. With no
// customer_email the backend takes the account fallback, which sends
// customer_gallery_assigned — a portal link that never mentions a
// password — and deliberately skips the rehash (crud.js). Asking for
// one there blocks the send behind a value nothing consumes, and the
// dialog's promise that it will be rehashed would be false.
requirePassword={!!event.customer_email && !isGalleryPublic(event.require_password)}
isSending={sendGalleryEmailMutation.isPending}
onConfirm={(password) => sendGalleryEmailMutation.mutate(password)}
onClose={() => {
if (!sendGalleryEmailMutation.isPending) setShowSendEmailDialog(false);
}}
/>
)}
{/* Duplicate Event Dialog (#626) — admin types a new event name/date {/* Duplicate Event Dialog (#626) — admin types a new event name/date
(+ optional customer); backend clones the source gallery's config (+ optional customer); backend clones the source gallery's config
and we navigate to the new draft. */} and we navigate to the new draft. */}
@@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Archive, Send, Copy } from 'lucide-react'; import { Archive, Send, Copy, Mail } from 'lucide-react';
import type { Event } from '../../../types'; import type { Event } from '../../../types';
import { Button, Card } from '../../../components/common'; import { Button, Card } from '../../../components/common';
import { PermissionGate } from '../../../components/admin/PermissionGate'; import { PermissionGate } from '../../../components/admin/PermissionGate';
import { toBoolean } from '../../../utils/parsers';
interface EventActionsCardProps { interface EventActionsCardProps {
event: Event; event: Event;
@@ -13,6 +14,11 @@ interface EventActionsCardProps {
isPublishing: boolean; isPublishing: boolean;
setShowDuplicateDialog: (show: boolean) => void; setShowDuplicateDialog: (show: boolean) => void;
isDuplicating: boolean; isDuplicating: boolean;
/** Send the gallery email for an already-published gallery (#1235). */
onSendGalleryEmail: () => void;
isSendingGalleryEmail: boolean;
/** Assigned customer accounts — a recipient even with no inline email. */
assignedCustomerCount?: number;
} }
export const EventActionsCard: React.FC<EventActionsCardProps> = ({ export const EventActionsCard: React.FC<EventActionsCardProps> = ({
@@ -22,10 +28,22 @@ export const EventActionsCard: React.FC<EventActionsCardProps> = ({
setShowPublishDialog, setShowPublishDialog,
isPublishing, isPublishing,
setShowDuplicateDialog, setShowDuplicateDialog,
isDuplicating isDuplicating,
onSendGalleryEmail,
isSendingGalleryEmail,
assignedCustomerCount = 0
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// Mirror the endpoint's own eligibility rules. Showing a button the backend
// is guaranteed to reject just walks the admin through a dialog to reach a
// generic error toast — the archived case is already handled by the caller,
// which does not render this card at all for archived events.
const hasRecipient = !!event.customer_email || assignedCustomerCount > 0;
const isExpired = !!event.expires_at && new Date(event.expires_at) <= new Date();
const isInactive = !toBoolean(event.is_active, true);
const canSendGalleryEmail = hasRecipient && !isExpired && !isInactive;
return ( return (
<Card padding="md"> <Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.actions')}</h2> <h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.actions')}</h2>
@@ -47,7 +65,38 @@ export const EventActionsCard: React.FC<EventActionsCardProps> = ({
</p> </p>
</PermissionGate> </PermissionGate>
) : ( ) : (
<PermissionGate permission="events.archive"> <>
{/* Send the gallery email after publishing (#1235). The pair to
publishing quietly — the address often arrives later than the
gallery — and it doubles as a re-send when the first one was
lost. Hidden without a recipient, since there is nowhere to
send it.
Its own gate, NOT nested inside the archive one below: the
default editor role has events.edit but not events.archive, so
nesting hid this action from exactly the people allowed to use
the endpoint behind it. */}
{/* Assigned accounts count as a recipient: the route falls through
to the customer-account notice when there is no inline email,
and the publish dialog promises that notice can be sent later —
so hiding the button here made that promise unkeepable. */}
{canSendGalleryEmail && (
<PermissionGate permission="events.edit">
<Button
variant="outline"
leftIcon={<Mail className="w-4 h-4" />}
onClick={onSendGalleryEmail}
isLoading={isSendingGalleryEmail}
className="w-full justify-center"
>
{t('events.sendGalleryEmail.button', 'Send gallery email')}
</Button>
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center mb-3">
{t('events.sendGalleryEmail.help', 'Sends the gallery link to the customer. You confirm the password first if the gallery has one.')}
</p>
</PermissionGate>
)}
<PermissionGate permission="events.archive">
<Button <Button
variant="outline" variant="outline"
leftIcon={<Archive className="w-4 h-4" />} leftIcon={<Archive className="w-4 h-4" />}
@@ -64,7 +113,8 @@ export const EventActionsCard: React.FC<EventActionsCardProps> = ({
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center"> <p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">
{t('events.archivingInfo')} {t('events.archivingInfo')}
</p> </p>
</PermissionGate> </PermissionGate>
</>
)} )}
{/* Duplicate (#626) — visible in both draft and live mode. {/* Duplicate (#626) — visible in both draft and live mode.
Creates a new draft inheriting this gallery's config. */} Creates a new draft inheriting this gallery's config. */}
@@ -20,6 +20,7 @@ import { EventActionsCard } from './EventActionsCard';
import { PhotoStatisticsCard } from './PhotoStatisticsCard'; import { PhotoStatisticsCard } from './PhotoStatisticsCard';
import { EventThemeSection } from './EventThemeSection'; import { EventThemeSection } from './EventThemeSection';
import { ArchiveStatusCard } from './ArchiveStatusCard'; import { ArchiveStatusCard } from './ArchiveStatusCard';
import { toBoolean } from '../../../utils/parsers';
interface OverviewTabProps { interface OverviewTabProps {
event: Event; event: Event;
@@ -40,6 +41,8 @@ interface OverviewTabProps {
setActiveTab: (tab: EventDetailsTab) => void; setActiveTab: (tab: EventDetailsTab) => void;
setShowPasswordReset: (show: boolean) => void; setShowPasswordReset: (show: boolean) => void;
setShowPublishDialog: (show: boolean) => void; setShowPublishDialog: (show: boolean) => void;
onSendGalleryEmail: () => void;
isSendingGalleryEmail: boolean;
setShowDuplicateDialog: (show: boolean) => void; setShowDuplicateDialog: (show: boolean) => void;
onArchive: () => void; onArchive: () => void;
isArchiving: boolean; isArchiving: boolean;
@@ -72,6 +75,8 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
setActiveTab, setActiveTab,
setShowPasswordReset, setShowPasswordReset,
setShowPublishDialog, setShowPublishDialog,
onSendGalleryEmail,
isSendingGalleryEmail,
setShowDuplicateDialog, setShowDuplicateDialog,
onArchive, onArchive,
isArchiving, isArchiving,
@@ -177,6 +182,25 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
isPublishing={isPublishing} isPublishing={isPublishing}
setShowDuplicateDialog={setShowDuplicateDialog} setShowDuplicateDialog={setShowDuplicateDialog}
isDuplicating={isDuplicating} isDuplicating={isDuplicating}
onSendGalleryEmail={onSendGalleryEmail}
isSendingGalleryEmail={isSendingGalleryEmail}
assignedCustomerCount={
((event as {
customer_accounts?: Array<{
id: number; email?: string; is_active?: unknown; can_sign_in?: unknown
}>
}).customer_accounts || [])
// Only accounts the endpoint would actually mail count, or
// the button appears and then 400s. Mirrors
// canReceiveGalleryNotice in crud.js: active, holding an
// address, and able to sign in — a PASSIVE customer
// (never invited, so no password) would get a portal link
// to a door that will not open. toBoolean rather than
// `!== false` because SQLite returns 0/1.
.filter((c) => toBoolean(c.is_active, true)
&& toBoolean(c.can_sign_in, true)
&& !!c.email).length
}
/> />
</PermissionGate> </PermissionGate>
)} )}
+26 -2
View File
@@ -1,6 +1,7 @@
import { api } from '../config/api'; import { api } from '../config/api';
import type { Event } from '../types'; import type { Event } from '../types';
import { normalizeRequirePassword } from '../utils/accessControl'; import { normalizeRequirePassword } from '../utils/accessControl';
import { toBoolean } from '../utils/parsers';
const normalizeEvent = (event: Event): Event => { const normalizeEvent = (event: Event): Event => {
const legacyHostName = (event as any)?.host_name; const legacyHostName = (event as any)?.host_name;
@@ -14,6 +15,10 @@ const normalizeEvent = (event: Event): Event => {
customer_name: customerName, customer_name: customerName,
customer_email: customerEmail, customer_email: customerEmail,
require_password: normalizeRequirePassword((event as any)?.require_password, true), require_password: normalizeRequirePassword((event as any)?.require_password, true),
// SQLite hands these back as 0/1, so a strict `=== false` consumer reads
// an inactive gallery as active (the #1028 class). Coerced once here with
// the same default the backend's parseBooleanInput uses.
is_active: toBoolean((event as any)?.is_active, true),
}; };
}; };
@@ -272,11 +277,30 @@ export const eventsService = {
// email carry the actual plaintext instead of the "set at creation" sentinel // email carry the actual plaintext instead of the "set at creation" sentinel
// (#627) — the backend also re-hashes it so the stored hash matches. // (#627) — the backend also re-hashes it so the stored hash matches.
async publishEvent( async publishEvent(
eventId: number,
options?: { password?: string; notifyCustomer?: boolean },
): Promise<{ message: string; is_draft: boolean; notified_customer?: boolean }> {
// Only send what was actually chosen. Omitting notify_customer entirely
// when it is true keeps the request identical to the pre-#1235 shape.
const body: Record<string, unknown> = {};
if (options?.password) body.password = options.password;
if (options?.notifyCustomer === false) body.notify_customer = false;
const response = await api.post(
`/admin/events/${eventId}/publish`,
Object.keys(body).length ? body : undefined,
);
return response.data;
},
// Send the gallery email for an already-published gallery (#1235). The other
// half of publishing quietly: the address often arrives after the gallery
// does. Also covers an ordinary re-send when the first one was lost.
async sendGalleryEmail(
eventId: number, eventId: number,
options?: { password?: string }, options?: { password?: string },
): Promise<{ message: string; is_draft: boolean }> { ): Promise<{ message: string; recipient: string }> {
const body = options?.password ? { password: options.password } : undefined; const body = options?.password ? { password: options.password } : undefined;
const response = await api.post(`/admin/events/${eventId}/publish`, body); const response = await api.post(`/admin/events/${eventId}/send-gallery-email`, body);
return response.data; return response.data;
}, },
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB