From 9c44a0ebfa527fa133512eb7f2f03335a2377aaa Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 8 Mar 2026 15:36:38 +0100 Subject: [PATCH] fix: respect optional email settings in event creation (#217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When admin/customer emails were configured as optional in Settings > Event Creation, the backend still rejected empty values because: 1. express-validator .optional() only skips undefined, not empty strings — changed to .optional({ values: 'falsy' }) so "" is treated as absent 2. DB columns host_email and admin_email had NOT NULL constraints — added migration to make them nullable 3. Email queue insert crashed on null recipient_email — skip queuing when no customer email is provided --- .../core/073_make_event_emails_nullable.js | 13 ++ backend/src/routes/adminEvents.js | 56 +++---- .../e2e/optional-email-event-creation.spec.ts | 151 ++++++++++++++++++ 3 files changed, 193 insertions(+), 27 deletions(-) create mode 100644 backend/migrations/core/073_make_event_emails_nullable.js create mode 100644 tests/e2e/optional-email-event-creation.spec.ts diff --git a/backend/migrations/core/073_make_event_emails_nullable.js b/backend/migrations/core/073_make_event_emails_nullable.js new file mode 100644 index 00000000..248b6889 --- /dev/null +++ b/backend/migrations/core/073_make_event_emails_nullable.js @@ -0,0 +1,13 @@ +exports.up = async function(knex) { + await knex.schema.alterTable('events', (table) => { + table.string('host_email', 255).nullable().alter(); + table.string('admin_email', 255).nullable().alter(); + }); +}; + +exports.down = async function(knex) { + await knex.schema.alterTable('events', (table) => { + table.string('host_email', 255).notNullable().defaultTo('').alter(); + table.string('admin_email', 255).notNullable().defaultTo('').alter(); + }); +}; diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 383f5861..0f460ed2 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -162,10 +162,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [ return true; }), body('event_name').notEmpty().trim(), - body('event_date').optional().isDate(), + body('event_date').optional({ values: 'falsy' }).isDate(), body('customer_name').optional().trim(), - body('customer_email').optional().isEmail().normalizeEmail(), - body('admin_email').optional().isEmail().normalizeEmail(), + body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(), + body('admin_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(), body('require_password').optional().isBoolean(), body('password').optional().isString().custom((value, { req }) => { const input = req.body.require_password; @@ -392,9 +392,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [ event_name, event_date: event_date || null, ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}), - host_name: customerName, - host_email: customerEmail, - admin_email, + host_name: customerName || null, + host_email: customerEmail || null, + admin_email: admin_email || null, password_hash, welcome_message, color_theme, @@ -446,28 +446,30 @@ router.post('/', adminAuth, requirePermission('events.create'), [ { type: 'admin', id: req.admin.id, name: req.admin.username } ); - // Queue creation email + // Queue creation email (only if there is a recipient) // Language detection is handled by email processor - - await db('email_queue').insert({ - event_id: eventId, - recipient_email: customerEmail, - email_type: 'gallery_created', - email_data: JSON.stringify({ - customer_name: customerName, - customer_email: customerEmail, - host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null), - event_name, - event_date: event_date, // Pass raw date - will be formatted by email processor - gallery_link: shareUrl, - gallery_password: requirePassword ? password : 'No password required', - expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor - welcome_message: welcome_message || '' - }), - status: 'pending', - created_at: new Date() - // scheduled_at will use default value - }); + + if (customerEmail) { + await db('email_queue').insert({ + event_id: eventId, + recipient_email: customerEmail, + email_type: 'gallery_created', + email_data: JSON.stringify({ + customer_name: customerName, + customer_email: customerEmail, + host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null), + event_name, + event_date: event_date, // Pass raw date - will be formatted by email processor + gallery_link: shareUrl, + gallery_password: requirePassword ? password : 'No password required', + expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor + welcome_message: welcome_message || '' + }), + status: 'pending', + created_at: new Date() + // scheduled_at will use default value + }); + } res.json({ id: eventId, diff --git a/tests/e2e/optional-email-event-creation.spec.ts b/tests/e2e/optional-email-event-creation.spec.ts new file mode 100644 index 00000000..641a1bf2 --- /dev/null +++ b/tests/e2e/optional-email-event-creation.spec.ts @@ -0,0 +1,151 @@ +import { test, expect, Page } from '@playwright/test'; + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; + +async function getAdminToken(page: Page): Promise { + const res = await page.request.post('/api/auth/admin/login', { + data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD }, + }); + expect(res.ok()).toBeTruthy(); + const body = await res.json(); + expect(body.token).toBeTruthy(); + return body.token; +} + +async function updateEventSettings( + page: Page, + token: string, + settings: Record +) { + const res = await page.request.put('/api/admin/settings/general', { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: settings, + }); + expect(res.ok()).toBeTruthy(); +} + +test.describe('Optional email fields in event creation (#217)', () => { + test('event creation succeeds with empty emails when set to optional', async ({ page }) => { + const token = await getAdminToken(page); + + // Disable email requirements + await updateEventSettings(page, token, { + event_require_customer_email: false, + event_require_admin_email: false, + }); + + try { + // Create event with empty email fields + const eventRes = await page.request.post('/api/admin/events', { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + event_type: 'wedding', + event_name: `E2E Optional Emails ${Date.now()}`, + event_date: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), + customer_name: 'Test Host', + customer_email: '', + admin_email: '', + password: 'TestPass123!', + expiration_days: 30, + }, + }); + + const body = await eventRes.json(); + expect(eventRes.ok(), `Expected 200 but got ${eventRes.status()}: ${JSON.stringify(body)}`).toBeTruthy(); + expect(body.id).toBeTruthy(); + + // Cleanup: delete the created event + await page.request.delete(`/api/admin/events/${body.id}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + } finally { + // Revert settings to required + await updateEventSettings(page, token, { + event_require_customer_email: true, + event_require_admin_email: true, + }); + } + }); + + test('event creation still fails with empty emails when set to required', async ({ page }) => { + const token = await getAdminToken(page); + + // Ensure email requirements are enabled + await updateEventSettings(page, token, { + event_require_customer_email: true, + event_require_admin_email: true, + }); + + const eventRes = await page.request.post('/api/admin/events', { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + event_type: 'wedding', + event_name: `E2E Required Emails ${Date.now()}`, + event_date: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), + customer_name: 'Test Host', + customer_email: '', + admin_email: '', + password: 'TestPass123!', + expiration_days: 30, + }, + }); + + expect(eventRes.status()).toBe(400); + const body = await eventRes.json(); + const paths = body.errors.map((e: { path: string }) => e.path); + expect(paths).toContain('customer_email'); + expect(paths).toContain('admin_email'); + }); + + test('event creation succeeds with missing email fields when optional', async ({ page }) => { + const token = await getAdminToken(page); + + // Disable email requirements + await updateEventSettings(page, token, { + event_require_customer_email: false, + event_require_admin_email: false, + }); + + try { + // Create event without email fields at all (undefined, not empty string) + const eventRes = await page.request.post('/api/admin/events', { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + event_type: 'wedding', + event_name: `E2E Missing Emails ${Date.now()}`, + event_date: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), + customer_name: 'Test Host', + password: 'TestPass123!', + expiration_days: 30, + }, + }); + + const body = await eventRes.json(); + expect(eventRes.ok(), `Expected 200 but got ${eventRes.status()}: ${JSON.stringify(body)}`).toBeTruthy(); + expect(body.id).toBeTruthy(); + + // Cleanup + await page.request.delete(`/api/admin/events/${body.id}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + } finally { + await updateEventSettings(page, token, { + event_require_customer_email: true, + event_require_admin_email: true, + }); + } + }); +});