diff --git a/backend/migrations/core/080_add_customer_phone.js b/backend/migrations/core/080_add_customer_phone.js new file mode 100644 index 00000000..db2c765e --- /dev/null +++ b/backend/migrations/core/080_add_customer_phone.js @@ -0,0 +1,33 @@ +const { addColumnIfNotExists } = require('../helpers'); + +/** + * #322 — optional phone-number field on events. Off by default; surfaced + * only when the global `event_phone_field_enabled` app setting is true, + * so existing deployments see no UI change unless the admin opts in. + */ +exports.up = async function up(knex) { + await addColumnIfNotExists(knex, 'events', 'customer_phone', (table) => { + table.string('customer_phone', 32).nullable(); + }); + + // Seed the global enable flag (default false). + const exists = await knex('app_settings') + .where('setting_key', 'event_phone_field_enabled') + .first(); + if (!exists) { + await knex('app_settings').insert({ + setting_key: 'event_phone_field_enabled', + setting_value: JSON.stringify(false), + setting_type: 'boolean' + }); + } +}; + +exports.down = async function down(knex) { + if (await knex.schema.hasColumn('events', 'customer_phone')) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('customer_phone'); + }); + } + await knex('app_settings').where('setting_key', 'event_phone_field_enabled').delete(); +}; diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index c12dadd4..a729aacb 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -185,6 +185,25 @@ const getBrandingDefaults = async () => { // Use parseStringInput from shared parsers for customer data extraction const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name); const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email); +const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone); + +// Whether the global "phone field" toggle (#322) is enabled. Cached for +// the request via a module-level read; drift is acceptable since this +// only governs whether to persist the field, not security boundaries. +const isPhoneFieldEnabled = async () => { + try { + const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first(); + if (!row) return false; + let value = row.setting_value; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch { /* keep raw */ } + } + return value === true; + } catch (error) { + logger.debug('Failed to read event_phone_field_enabled', { error: error.message }); + return false; + } +}; const mapEventForApi = (event) => { if (!event || typeof event !== 'object') { @@ -196,6 +215,7 @@ const mapEventForApi = (event) => { host_email, customer_name, customer_email, + customer_phone, password_hash: _ph, client_password_hash: _cph, ...rest @@ -204,7 +224,8 @@ const mapEventForApi = (event) => { return { ...rest, customer_name: customer_name ?? host_name ?? null, - customer_email: customer_email ?? host_email ?? null + customer_email: customer_email ?? host_email ?? null, + customer_phone: customer_phone ?? null }; }; @@ -239,6 +260,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [ body('event_date').optional({ values: 'falsy' }).isDate(), body('customer_name').optional().trim(), body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(), + body('customer_phone').optional({ nullable: true, checkFalsy: true }) + .isString().trim() + .isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'), body('admin_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(), body('require_password').optional().isBoolean(), body('password').optional().isString().custom((value, { req }) => { @@ -354,6 +378,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [ const customerName = getCustomerNameFromPayload(req.body); const customerEmail = getCustomerEmailFromPayload(req.body); + // Phone field is opt-in via the global setting (#322). If disabled, + // ignore whatever the client posted — defence in depth against form + // bypass. + const phoneEnabled = await isPhoneFieldEnabled(); + const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null; const customerColumnsAvailable = await hasCustomerContactColumns(); @@ -509,6 +538,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ event_name, event_date: event_date || null, ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}), + ...(customerPhone ? { customer_phone: customerPhone } : {}), host_name: customerName || null, host_email: customerEmail || null, admin_email: admin_email || null, @@ -863,6 +893,9 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne body('allow_user_uploads').optional().isBoolean(), body('customer_name').optional({ nullable: true, checkFalsy: true }).trim(), body('customer_email').optional().isEmail().normalizeEmail(), + body('customer_phone').optional({ nullable: true, checkFalsy: true }) + .isString().trim() + .isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'), body('upload_category_id').optional().custom((value) => { // Accept null, undefined, or integer values if (value === null || value === undefined) return true; @@ -961,6 +994,19 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne } } + // Phone is gated on the global toggle (#322). Strip from the update + // unconditionally if disabled — even null/clear is rejected so an + // admin can't accidentally write to a field they've turned off. + if (Object.prototype.hasOwnProperty.call(updates, 'customer_phone')) { + const phoneEnabled = await isPhoneFieldEnabled(); + if (!phoneEnabled) { + delete updates.customer_phone; + } else { + const nextPhone = getCustomerPhoneFromPayload(updates); + updates.customer_phone = nextPhone || null; + } + } + const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password'); let requirePasswordUpdate; if (hasRequirePasswordUpdate) { diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index ff7441e1..1d689b3d 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -16,7 +16,8 @@ router.get('/', async (req, res) => { .orWhereIn('setting_key', [ 'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai', 'event_default_require_password', - 'gallery_show_filter_bar' + 'gallery_show_filter_bar', + 'event_phone_field_enabled' ]); }) .select('setting_key', 'setting_value'); @@ -84,6 +85,8 @@ router.get('/', async (req, res) => { event_require_expiration: settingsObject.event_require_expiration !== false, // Default value for "Require password" toggle in event creation form event_default_require_password: settingsObject.event_default_require_password !== false, + // Phone-number field on events is opt-in (#322). + event_phone_field_enabled: settingsObject.event_phone_field_enabled === true, // Whether to show the search/sort filter bar in public galleries (default: true) gallery_show_filter_bar: settingsObject.gallery_show_filter_bar !== false, // Upload settings (safe to expose - needed for client-side validation) diff --git a/frontend/src/features/settings/hooks/useSettingsState.ts b/frontend/src/features/settings/hooks/useSettingsState.ts index 1c3b7cfb..911d36cc 100644 --- a/frontend/src/features/settings/hooks/useSettingsState.ts +++ b/frontend/src/features/settings/hooks/useSettingsState.ts @@ -52,6 +52,7 @@ export interface EventSettings { event_require_expiration: boolean; event_default_require_password: boolean; gallery_show_filter_bar: boolean; + event_phone_field_enabled: boolean; } export interface SeoSettings { @@ -127,7 +128,8 @@ export function useSettingsState() { event_require_event_date: true, event_require_expiration: true, event_default_require_password: true, - gallery_show_filter_bar: true + gallery_show_filter_bar: true, + event_phone_field_enabled: false }); // SEO settings state @@ -212,7 +214,8 @@ export function useSettingsState() { event_require_event_date: toBoolean(settings.event_require_event_date, true), event_require_expiration: toBoolean(settings.event_require_expiration, true), event_default_require_password: toBoolean(settings.event_default_require_password, true), - gallery_show_filter_bar: toBoolean(settings.gallery_show_filter_bar, true) + gallery_show_filter_bar: toBoolean(settings.gallery_show_filter_bar, true), + event_phone_field_enabled: toBoolean(settings.event_phone_field_enabled, false) }); setSeoSettings({ diff --git a/frontend/src/features/settings/tabs/EventsTab.tsx b/frontend/src/features/settings/tabs/EventsTab.tsx index c89c3202..1365d3c7 100644 --- a/frontend/src/features/settings/tabs/EventsTab.tsx +++ b/frontend/src/features/settings/tabs/EventsTab.tsx @@ -187,6 +187,25 @@ export const EventsTab: React.FC = ({ + +
+ +
diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index 11d59417..3aa64bbd 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -35,6 +35,7 @@ interface FormData { event_date: string; customer_name: string; customer_email: string; + customer_phone: string; admin_email: string; require_password: boolean; password: string; @@ -95,6 +96,7 @@ export const CreateEventPage: React.FC = () => { event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format customer_name: '', customer_email: '', + customer_phone: '', admin_email: '', require_password: true, password: '', @@ -177,6 +179,7 @@ export const CreateEventPage: React.FC = () => { // Get field requirements (default to true if not set) const requireCustomerName = publicSettings?.event_require_customer_name !== false; const requireCustomerEmail = publicSettings?.event_require_customer_email !== false; + const phoneFieldEnabled = publicSettings?.event_phone_field_enabled === true; const requireAdminEmail = publicSettings?.event_require_admin_email !== false; const requireEventDate = publicSettings?.event_require_event_date !== false; const requireExpiration = publicSettings?.event_require_expiration !== false; @@ -351,6 +354,7 @@ export const CreateEventPage: React.FC = () => { event_date: formData.event_date || undefined, customer_name: formData.customer_name, customer_email: formData.customer_email, + ...(phoneFieldEnabled && formData.customer_phone ? { customer_phone: formData.customer_phone.trim() } : {}), admin_email: formData.admin_email, require_password: formData.require_password, password: formData.require_password ? formData.password : undefined, @@ -654,6 +658,16 @@ export const CreateEventPage: React.FC = () => { />
+ {phoneFieldEnabled && ( + + )} + { hero_photo_id: number | null; customer_name: string; customer_email: string; + customer_phone: string; source_mode: 'managed' | 'reference'; external_path: string; require_password: boolean; @@ -184,6 +185,7 @@ export const EventDetailsPage: React.FC = () => { hero_photo_id: null, customer_name: '', customer_email: '', + customer_phone: '', source_mode: 'managed', external_path: '', require_password: true, @@ -338,6 +340,7 @@ export const EventDetailsPage: React.FC = () => { queryFn: () => publicSettingsService.getPublicSettings(), }); const requireExpiration = publicSettings?.event_require_expiration !== false; + const phoneFieldEnabled = publicSettings?.event_phone_field_enabled === true; // Fetch categories for the event const { data: categories = [] } = useQuery({ @@ -430,6 +433,7 @@ export const EventDetailsPage: React.FC = () => { hero_photo_id: event.hero_photo_id || null, customer_name: event.customer_name || '', customer_email: event.customer_email || '', + customer_phone: (event as any).customer_phone || '', source_mode: event.source_mode === 'reference' ? 'reference' : 'managed', external_path: event.external_path || '', require_password: normalizeRequirePassword(event.require_password), @@ -617,6 +621,11 @@ export const EventDetailsPage: React.FC = () => { if (editForm.customer_email !== undefined && editForm.customer_email !== null && editForm.customer_email.trim()) { updateData.customer_email = editForm.customer_email; } + if (editForm.customer_phone !== undefined) { + // Send empty string as null so an admin can clear the field. Backend + // strips this entirely if the global phone-field toggle is off. + updateData.customer_phone = editForm.customer_phone.trim() || null; + } if (editForm.new_password) { updateData.password = editForm.new_password; @@ -970,6 +979,20 @@ export const EventDetailsPage: React.FC = () => { /> + {phoneFieldEnabled && ( +
+ + setEditForm(prev => ({ ...prev, customer_phone: e.target.value }))} + placeholder={t('events.customerPhonePlaceholder', '+1 555 555 1234')} + /> +
+ )} +