19-Apr-2026 — optional attendee emails, per-event booking scope, on-demand admin email dispatch and p... #236
build 616 — commit dd5ec77
Make booking attendee emails optional for all but the primary attendee, let admins choose whether bookings apply to all events automatically or only to specific events opted in one at a time, give admins a one-click control to send emails of any type for an event without waiting for the overnight cron, fix the booking email "event page" link to point at the local site, and extract the email-preview pane into a single reusable component used by the committee, group-event and bookings admin flows.
What changes for end users
Booking form (public)
- Only the first attendee now needs an email address. Seats 2..n only need a name; the email field is hidden for those rows. Phone is still offered for every attendee.
- Submitting a group booking without an email on the primary attendee now gives a clear error instead of a generic failure.
- After cancelling a multi-seat booking, the confirmation now reads "all your places have been released" so it is unambiguous that the entire booking was cancelled, not just one seat.
Booking emails (all types)
- The "View full event details on the event page" link now points at
the local group site (for example
https://kent.ngx-ramblers.org.uk/walks/
) rather than https://www.ramblers.org.uk/go-walking. Area is resolved from systemConfig.group.pages (walks / social-events), slug from the groupEvent URL or a kebab-cased title + start-date fallback.
Admin Configuration tab
- When "Enable booking on events" is on, a new radio pair controls the scope of bookings for the whole group:
- All events of the enabled types accept bookings automatically - the historical behaviour.
- Per event
- only events with Bookings enabled ticked on the event will accept bookings.
- Existing groups keep the All-events behaviour by default on first load; no admin action required to maintain parity.
Walk / Group Event editor
- New "Bookings enabled for this event" checkbox on the walk edit and group-event edit forms, bound to the event's bookings-enabled flag. In per-event scope this is the single source of truth for whether the event accepts bookings.
- The Max Capacity field label no longer says "leave blank to disable bookings" - capacity and opt-in are now two separate controls.
Admin bookings screen
- Summary tab gains Group (name + code) and Status (Bookable / Not bookable pill) columns.
- Per-Event Detail tab:
- Event picker renders each option as a two-line entry (event label on top, muted group name + group code beneath) with a Bookable / Not bookable badge on the right. The custom ng-select search function matches across label, title, group name, group code, date, time and status word.
- "Include past events" toggle filters the picker to upcoming events by default (orphaned entries are always included for reassignment).
- Inline "Bookings enabled for this event" checkbox and Max Capacity input let admins flip per-event state without leaving the page; saved via Save all changes.
- Per-event email overrides selector syncs to the URL via
email-type=
. The selected event id is also persisted via event-id= so the full state is deep-linkable. - New Preview button next to the override selector renders the resolved email (Brevo template applied) inline via the shared EmailPreviewComponent.
- "Send reminder emails now" is replaced by a dynamic "Send {type} emails now" button that dispatches the currently selected email type (confirmation, cancellation, waitlisted, restored, reminder) to the matching set of bookings (active, waitlisted or cancelled) for the event. Reminder behaviour preserved.
- Orphaned-booking reassignment picker shows the same group + bookable information and now only lists bookable targets.
What changes under the hood
Attendee model
- BookingAttendee type loosened: email and phone are now explicit string | null, and everything except displayName is optional.
- Mongo schema drops required: true on attendee email.
Per-event booking opt-in
- ExtendedFields gains bookingsEnabled?: boolean, persisted via a new boolean field on the Mongoose extended-group-event schema.
- BookingSummaryRow carries groupName, groupCode, eventStartDateTime, upcoming and bookable fields so the admin UI can badge, filter and search each row without recomputing.
Booking scope model
- New BookingScope enum (ALL_EVENTS, PER_EVENT) and BookingConfig.scope field. Defaults to ALL_EVENTS in service defaults, environment-setup template, and the server-side legacy merge, so unset configs behave exactly as before.
- New helpers in booking-config.model.ts:
- bookingScope(config)
- resolves scope with a safe default.
- effectiveMaxCapacityForEvent(config, event)
- returns the event's explicit max capacity, falling back to the config default.
- eventOptedInToBookings(config, event)
- returns true under ALL_EVENTS scope, otherwise returns event.fields.bookingsEnabled.
- bookingEnabledForEvent(config, event)
- combines the event-type check, the scoped opt-in check, and the effective-capacity check into one predicate used by both the public booking form and the server create endpoint.
Shared email preview component
- New EmailPreviewComponent at projects/ngx-ramblers/src/app/modules/common/email-preview. Owns the iframe, blob URL lifecycle, ResizeObserver/MutationObserver driven content observation, loading and error states, and the render() / clear() / showError() public API. Consumers pass a TemplateRenderRequest to render() and the component calls MailService.renderTemplate under the hood.
- committee-send-notification and group-event-send-notification-modal both refactored to drop their inline iframe + previewUrl + observer machinery (each shed ~100 lines of duplicated preview state) and now embed <app-email-preview #emailPreview/>, calling emailPreview.render(request) in their existing refreshPreview() methods.
Booking email request builder
- New buildBookingEmailRequest(emailType, bookingRecord, event, suppliedEventLink) in send-booking-email.ts returns the full rendered template request (templateId, params, body, sender, subject, etc). sendBookingNotification() now reduces to a thin wrapper that builds the request and calls sendTransactionalEmailRequest.
- publicEventLink() takes systemCfg and produces a local group URL as described above.
Send-by-type server plumbing
- New sendEmailsByTypeForEvent(eventId, emailType) in booking-reminder-job.ts dispatches confirmation, cancellation, waitlisted, restored or reminder emails to the matching booking statuses for an event. Reminder type delegates to the existing sendReminderEmailsForEvent.
- New booking controller handlers:
- sendEmailsByType on POST /booking/send-emails/:emailType/:eventId (authenticated) - returns the standard BookingReminderDispatch shape.
- previewEmail on GET /booking/preview-email/:emailType/:eventId (authenticated) - loads a sample booking (real or synthesised), builds the template render request via buildBookingEmailRequest and returns {templateId, htmlContent, params, subject} for the client to feed into EmailPreviewComponent.
Server-side attendee normalisation
- New normaliseBookingAttendees helper in controllers/booking.ts trims each attendee's display name / email / phone, nulls blanks, and drops rows without a display name.
- Duplicate-email detection and attendee-email list helpers filter out null/undefined emails before deduping.
- cancel() attendee match uses a shared bookingAttendeeEmail helper so a null email can never match.
- create() returns a 400 when the first attendee has no email.
Reminder dispatch plumbing
- New BookingReminderDispatch model: { eventId, eventTitle, sentCount, alreadySentCount, skippedCount }.
- server/lib/cron/booking-reminder-job.ts refactored: the per-event reminder block becomes an exported sendReminderEmailsForEvent that the cron and the admin button share.
- Brevo attendee-list markdown renderer handles attendees with no email (renders - Name rather than - Name (undefined)).
URL-persisted admin state
- StoredValue gains EMAIL_TYPE = "email-type" and EVENT_ID = "event-id" entries (kebab-case values). Per-Event Detail tab pushes and reads both via queryParamsHandling: "merge", so the email-type section toggle, selected event, and active tab together deep-link to the exact same view.
Salutation de-duplication (#235)
- BookingConfig gains templatesIncludeSalutation (default true) so the Brevo layout stops rendering ADDRESS_LINE when the markdown template already opens with "Hi {{ATTENDEE_NAME}},". Templates themselves are left untouched. Admin Configuration tab exposes the flag as a checkbox, and both the frontend normalise step and the backend loadBookingConfig default the flag to true when the DB value is missing, so existing installs get the fix without a schema backfill.