diff --git a/backend/__tests__/integration/publishQuietly.test.js b/backend/__tests__/integration/publishQuietly.test.js new file mode 100644 index 00000000..8f2851e6 --- /dev/null +++ b/backend/__tests__/integration/publishQuietly.test.js @@ -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 = 'client@example.com', 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: 'admin@example.com', + 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('client@example.com'); + + 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: 'admin@example.com', + 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: 'admin@example.com', + 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: 'admin@example.com', + 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: 'admin@example.com', + 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: 'passive@example.com', + 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); + }); +}); diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index 662e2071..720370c6 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -31,6 +31,91 @@ const downloadZipService = require('../../services/downloadZipService'); 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'); +/** + * 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} 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) => { @@ -878,6 +963,13 @@ module.exports = (router) => { display_name: c.display_name, first_name: c.first_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) { @@ -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) router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, [ // Optional password the admin re-types in the publish dialog so the @@ -896,6 +1103,9 @@ module.exports = (router) => { // compat with API-only consumers. body('password').optional().isString().isLength({ min: 6 }) .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) => { try { const errors = validationResult(req); @@ -905,6 +1115,7 @@ module.exports = (router) => { const { id } = req.params; const { password } = req.body; + const notifyCustomer = parseBooleanInput(req.body?.notify_customer, true); const event = await db('events').where('id', id).first(); if (!event) { @@ -925,67 +1136,39 @@ module.exports = (router) => { } 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 customerName = event.customer_name || event.host_name; - if (customerEmail) { - 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) { - // 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; + if (notifyCustomer) { + if (customerEmail) { + await queueGalleryCreatedEmail(event, { password, requirePassword }); } else { - // Legacy fallback for API-only publishes that don't carry the password. - galleryPasswordForEmail = '(set at creation)'; - } - - const emailData = { - customer_name: customerName, - customer_email: customerEmail, - host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null), - 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 || '' - }; - - 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 })); + // 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(canReceiveGalleryNotice)) { + 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 }); } - } catch (err) { - logger.warn('Publish: assigned-customer notification skipped', { eventId: id, error: err.message }); } } // WhatsApp gallery_ready on publish-from-draft (#640D). The PublishGallery // dialog (#627) hands us the password back so we can deliver it via // WhatsApp as well. Uses customer_phone from the persisted event row. - if (event.customer_phone) { + if (notifyCustomer && event.customer_phone) { try { const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor'); const waConfig = await getWhatsAppConfig(); @@ -1011,7 +1194,7 @@ module.exports = (router) => { } await logActivity('event_published', - { event_name: event.event_name }, + { event_name: event.event_name, notified_customer: notifyCustomer }, id, { type: 'admin', id: req.admin.id, name: req.admin.username } ); @@ -1037,7 +1220,11 @@ module.exports = (router) => { }); } 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) { errorResponse(res, error, 500, 'Failed to publish event'); } diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js index 6e3a2235..e6a9567a 100644 --- a/backend/src/services/customerAccountsService.js +++ b/backend/src/services/customerAccountsService.js @@ -1119,7 +1119,12 @@ async function getAssignmentsForEvent(eventId) { 'customer_accounts.display_name', 'customer_accounts.first_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'); } diff --git a/frontend/src/components/admin/PublishGalleryDialog.tsx b/frontend/src/components/admin/PublishGalleryDialog.tsx index a265cbf2..ad3b6ef6 100644 --- a/frontend/src/components/admin/PublishGalleryDialog.tsx +++ b/frontend/src/components/admin/PublishGalleryDialog.tsx @@ -7,10 +7,12 @@ interface PublishGalleryDialogProps { eventName: string; requirePassword: boolean; 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. */ assignedCustomerCount?: number; isPublishing: boolean; - onConfirm: (password?: string) => void; + onConfirm: (password?: string, notifyCustomer?: boolean) => void; onClose: () => void; } @@ -30,23 +32,32 @@ export const PublishGalleryDialog: React.FC = ({ eventName, requirePassword, customerEmail, + customerPhone, assignedCustomerCount = 0, isPublishing, onConfirm, onClose, }) => { const { t } = useTranslation(); - // Someone gets notified if there's an inline email OR an assigned account - // (the latter via the account "your galleries" email). - const willNotify = !!customerEmail || assignedCustomerCount > 0; + // Someone gets notified if there's an inline email, an assigned account (the + // account "your galleries" email), OR a phone — publish queues a WhatsApp + // 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(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, // 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 // password-protected gallery without an email could never be published. - const needsPassword = requirePassword && !!customerEmail; - const [password, setPassword] = useState(''); - const [showPassword, setShowPassword] = useState(false); - const [error, setError] = useState(undefined); + // Unchecking "notify" hides it for the same reason: nothing is being sent, + // so there is no plaintext to carry and no reason to demand it. + const needsPassword = requirePassword && !!customerEmail && notifyCustomer; const handleSubmit = () => { if (needsPassword) { @@ -56,7 +67,7 @@ export const PublishGalleryDialog: React.FC = ({ } } setError(undefined); - onConfirm(needsPassword ? password : undefined); + onConfirm(needsPassword ? password : undefined, notifyCustomer); }; return ( @@ -76,7 +87,16 @@ export const PublishGalleryDialog: React.FC = ({

- {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', { eventName, customerEmail, @@ -90,6 +110,12 @@ export const PublishGalleryDialog: React.FC = ({ defaultValue: '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', { eventName, defaultValue: @@ -97,6 +123,31 @@ export const PublishGalleryDialog: React.FC = ({ })}

+ {willNotify && ( + + )} + {needsPassword && (
= ({ onClick={handleSubmit} disabled={isPublishing} isLoading={isPublishing} - leftIcon={willNotify ? : undefined} + leftIcon={willNotify && notifyCustomer ? : undefined} > - {willNotify ? t('events.publishAndNotify') : t('events.publishDialog.justPublish', 'Publish')} + {willNotify && notifyCustomer + ? t('events.publishAndNotify') + : t('events.publishDialog.justPublish', 'Publish')}
diff --git a/frontend/src/components/admin/SendGalleryEmailDialog.tsx b/frontend/src/components/admin/SendGalleryEmailDialog.tsx new file mode 100644 index 00000000..d768ad04 --- /dev/null +++ b/frontend/src/components/admin/SendGalleryEmailDialog.tsx @@ -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 = ({ + eventName, + recipient, + requirePassword, + isSending, + onConfirm, + onClose, +}) => { + const { t } = useTranslation(); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(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 ( +
+ +
+

+ {t('events.sendGalleryEmail.title', 'Send gallery email')} +

+ +
+ +

+ {t('events.sendGalleryEmail.description', { + eventName, + recipient, + defaultValue: 'Sends the gallery link for "{{eventName}}" to {{recipient}}.', + })} +

+ + {requirePassword && ( +
+ { + 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={} + rightIcon={ + + } + /> +
+ )} + +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts index 9bba7381..986e6402 100644 --- a/frontend/src/components/admin/index.ts +++ b/frontend/src/components/admin/index.ts @@ -17,6 +17,7 @@ export { AdminPhotoViewer } from './AdminPhotoViewer'; export { PhotoFilters } from './PhotoFilters'; export { PasswordResetModal } from './PasswordResetModal'; export { PublishGalleryDialog } from './PublishGalleryDialog'; +export { SendGalleryEmailDialog } from './SendGalleryEmailDialog'; export { DuplicateEventDialog } from './DuplicateEventDialog'; export { ExportPreviewModal } from './ExportPreviewModal'; export { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index bf4bf9c5..6ae92488 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1373,7 +1373,11 @@ "passwordLabel": "Galerie-Passwort", "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.", - "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", "duplicateDialog": { @@ -1513,6 +1517,16 @@ "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.", "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": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 935a8483..9a3688be 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -903,7 +903,11 @@ "passwordLabel": "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.", - "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", "duplicateDialog": { @@ -1054,6 +1058,16 @@ "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.", "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": { diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index bff1bea1..5c5b3823 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -6,7 +6,7 @@ import { toast } from 'react-toastify'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; 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 { eventsService } from '../../services/events.service'; import { usePublicSettings } from '../../hooks/usePublicSettings'; @@ -60,6 +60,7 @@ export const EventDetailsPage: React.FC = () => { const [showNewPassword, setShowNewPassword] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); const [showPublishDialog, setShowPublishDialog] = useState(false); + const [showSendEmailDialog, setShowSendEmailDialog] = useState(false); const [showDuplicateDialog, setShowDuplicateDialog] = useState(false); const [currentTheme, setCurrentTheme] = useState(null); const [currentPresetName, setCurrentPresetName] = useState('default'); @@ -245,12 +246,22 @@ export const EventDetailsPage: React.FC = () => { // Publish mutation (Draft mode). Accepts the admin-typed password so the // gallery_created email can carry the real plaintext (#627). const publishMutation = useMutation({ - mutationFn: (password?: string) => - eventsService.publishEvent(parseInt(id!), password ? { password } : undefined), - onSuccess: () => { + mutationFn: (vars: { password?: string; notifyCustomer?: boolean }) => + eventsService.publishEvent(parseInt(id!), { + password: vars.password, + notifyCustomer: vars.notifyCustomer, + }), + onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['admin-event', id] }); 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); }, 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 + // behaviour + categories from the source; we navigate to the new event so // the admin can finish configuring + publish. @@ -627,6 +657,8 @@ export const EventDetailsPage: React.FC = () => { setShowPasswordReset={setShowPasswordReset} setShowPublishDialog={setShowPublishDialog} setShowDuplicateDialog={setShowDuplicateDialog} + onSendGalleryEmail={() => setShowSendEmailDialog(true)} + isSendingGalleryEmail={sendGalleryEmailMutation.isPending} onArchive={() => archiveMutation.mutate()} isArchiving={archiveMutation.isPending} isPublishing={publishMutation.isPending} @@ -706,17 +738,41 @@ export const EventDetailsPage: React.FC = () => { {showPublishDialog && ( }).customer_accounts || []).length} isPublishing={publishMutation.isPending} - onConfirm={(password) => publishMutation.mutate(password)} + onConfirm={(password, notifyCustomer) => publishMutation.mutate({ password, notifyCustomer })} onClose={() => { 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 && ( + sendGalleryEmailMutation.mutate(password)} + onClose={() => { + if (!sendGalleryEmailMutation.isPending) setShowSendEmailDialog(false); + }} + /> + )} + {/* Duplicate Event Dialog (#626) — admin types a new event name/date (+ optional customer); backend clones the source gallery's config and we navigate to the new draft. */} diff --git a/frontend/src/pages/admin/event-details/EventActionsCard.tsx b/frontend/src/pages/admin/event-details/EventActionsCard.tsx index e1ad187a..d30d4ba0 100644 --- a/frontend/src/pages/admin/event-details/EventActionsCard.tsx +++ b/frontend/src/pages/admin/event-details/EventActionsCard.tsx @@ -1,9 +1,10 @@ import React from 'react'; 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 { Button, Card } from '../../../components/common'; import { PermissionGate } from '../../../components/admin/PermissionGate'; +import { toBoolean } from '../../../utils/parsers'; interface EventActionsCardProps { event: Event; @@ -13,6 +14,11 @@ interface EventActionsCardProps { isPublishing: boolean; setShowDuplicateDialog: (show: boolean) => void; 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 = ({ @@ -22,10 +28,22 @@ export const EventActionsCard: React.FC = ({ setShowPublishDialog, isPublishing, setShowDuplicateDialog, - isDuplicating + isDuplicating, + onSendGalleryEmail, + isSendingGalleryEmail, + assignedCustomerCount = 0 }) => { 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 (

{t('events.actions')}

@@ -47,7 +65,38 @@ export const EventActionsCard: React.FC = ({

) : ( - + <> + {/* 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 && ( + + +

+ {t('events.sendGalleryEmail.help', 'Sends the gallery link to the customer. You confirm the password first if the gallery has one.')} +

+
+ )} +