Test Plan: Communications & Broadcasts
<textarea> — it's a rich-text editor (#488, Jun 29 release) with an HTML-source toggle. See the green banner below for the full list. Everything else in the 2026-06-10/11/16 corrections (segment filters, scheduling, unsubscribe) still holds — see below.
- Send-Test is wired (#433, Jun 25 release): a real "Send Test" action on a draft/scheduled broadcast sends ONE email to the requesting admin's own address (
POST /api/broadcasts/:id/send-test) — subject prefixed[TEST], does not mutate the broadcast's status/recipient ledger, so it stays editable. See TC-COMM-006. - Merge-tag personalization + XSS-escaping guard (#252, hardened further #449, Jun 25 release): broadcasts now support
{{firstName}},{{lastName}},{{email}},{{membershipType}},{{memberSince}}— resolved per-recipient on send, and against the admin's own member record (or sample data) on Send-Test/Preview. Unsupported{{tokens}}are left inert in preview but stripped before the real send. Substituted VALUES are HTML-escaped on the HTML path — a member first name containing<script>cannot inject markup into the rendered email. resolveBroadcastRecipients+ segment-filter validation + token strip (#449, Jun 25 release): the real send path now runs through a compliance-grade resolver that excludes opted-out (broadcasts:false/unsubscribedAll:true) and bounced-email members before sending. Segment filters are validated with a strict zod schema — an unknown filter key or a Prisma-operator-shaped value (e.g.{"in":[...]}) is now rejected with 400, not silently ignored.- Rich-text compose + sandboxed preview + HTML-source toggle (#488, Jun 29 release): the composer body field is a Tiptap rich-text editor (bold/italic/link/bullet+numbered lists) with an HTML-source toggle for raw markup. The Preview modal renders the server-built branded email inside a sandboxed iframe (
sandbox="allow-same-origin", noallow-scripts) so the tenant's logo/CSS/layout displays faithfully. Switching HTML-source → Rich-text warns before a lossy conversion if the HTML has tables/images/headings (Tiptap can't model them). See TC-COMM-006. - Tenant brand colors in the email wrapper (Jun 29 release): the header/footer bars of every broadcast (and the Send-Test email) use the tenant's
OrgProfile.primaryColor/accentColorinstead of hardcoded blue/purple. - Invitation email on "Mark as Invited" (#362, prospects — Membership module): transitioning a prospect to
INVITEDstatus now fires the same invitation-email path asPOST /membership/invitations(prospectHandlers.ts—sendProspectInvitationEmail, guarded to fire only on the transition INTOINVITED). This lives in the Membership module's prospect pipeline, not this module's broadcast/segment code — cross-linked here because testers checking "did an email go out" often land in this module's MailHog habits first. Assert the link host in the email matches the tenant origin (same host caveat as broadcast unsubscribe links, §6). - Email-preference toggles persist (#364): the §6 FE↔BE field-name mismatch below is unchanged for
membershipAlerts/newsletterEmails/marketingEmails(still no backend column) — but for the two that DO map (paymentReminders,eventReminders), toggle → save → refresh now reliably sticks. Re-verify all 5 per §6 before filing a persistence bug on a specific toggle.
status / roles / membershipTypeId (no "unpaid invoices" filter) — but an invalid/unknown filter is now rejected with 400, not silently dropped (see the green banner above, #449). Scheduled sends are still non-functional (stored but never fired).
2026-06-11 update (#226 / #231 closed, live on stage): the member email-preferences screen is now reachable as the Notifications tab at
/account/notifications (My Account → Notifications), and the backend 403 for members is fixed (user?.memberId). ⚠️ Note a remaining FE↔BE field-name mismatch: see §6.
2026-06-16 update: there is now a segment management UI at
/admin/segments (#224 — supersedes the old "no segment-builder UI; pick from a dropdown only" note: you can now create/edit segments in the UI). The public unsubscribe flow is now functional (#257 — HMAC-token route registered before auth so the link resolves for logged-out recipients; pairs with the Jun 15 #254 tenant-param fix). See the Jun 16 release note.
1. Introduction
The Communications module is admin-to-member email broadcasts, addressed to a reusable segment of members, plus a public unsubscribe flow and a per-member email-preferences screen (the Notifications tab under My Account, reachable as of #226). There is no SMS and no campaign/drip automation. (A separate admin in-app bell/notification feature for sign-ups & event registrations shipped this week — #186/#217/#230/#233 — but that lives under Admin/Events, not this module.)
2. Where it lives (UI)
- Admin Broadcasts:
/admin/broadcasts→frontend/pwa-app/src/pages/AdminBroadcastsPage.tsx. Reached via the sidebar Communications group → Broadcasts (visible to ADMIN, EVENT_MANAGER, COMMUNICATIONS_MANAGER, SUPER_ADMIN; gated by thecommunicationsfeature flag, default on). - Broadcast composer: an inline form on the same page (subject, body, an "Audience" segment
<select>, tags). Body field is now a rich-text editor (#488). The body field rendersRichTextEditor(Tiptap — bold/italic/link/bullet+numbered lists) withallowHtmlModeon, so there's a Rich text ⇄ HTML toggle for raw markup. The stored value is still HTML either way — the backend sanitizes it at write time (sanitizeBroadcastBody, allow-list strips<script>/event handlers/<iframe>) regardless of which mode authored it. There is also a standalone edit route/admin/broadcasts/:id/edit(AdminBroadcastEditPage.tsx) but nothing links to it — direct-URL only. - Segments: NOW HAS A UI (#224, on stage 2026-06-16). A dedicated segment management page at
/admin/segments(AdminSegmentsPage.tsx) lets ADMIN / COMMS_MANAGER create / edit / delete segments; they're still selectable from the composer's Audience dropdown too. (Supersedes the old "no page, no route" note.) Filters remainstatus/roles/membershipTypeId— still no billing/"unpaid invoices" filter. - Unsubscribe: public page
/unsubscribe(not tenant-prefixed) →UnsubscribePage.tsx. Only reached from a link inside a sent email. - Email preferences: REACHABLE (as of #226, on stage). Now the Notifications tab at
/account/notifications(My Account → Notifications) →frontend/pwa-app/src/pages/account/NotificationsTab.tsxrenderingEmailPreferencesSection.tsx. The old orphanProfilePage.tsxpath is gone. BackendGET/PUT /api/email-preferencesworks for members (the #231403bug is fixed). ⚠️ But 3 of the 5 UI toggles use field names the backend doesn't persist — see §6. See TC-COMM-004.
3. Endpoints (current code)
Mounted on the communicationsRouter at /api (auth-service/src/server.ts); handlers in auth-service/src/handlers/broadcastHandlers.ts & segmentHandlers.ts.
| Method | Endpoint | Permission | Notes |
|---|---|---|---|
| GET | /api/broadcasts | COMMUNICATIONS:VIEW:broadcasts | Query: status, limit (50), offset. Returns {items,total,limit,offset}. |
| POST | /api/broadcasts | COMMUNICATIONS:CREATE:broadcasts | Body {subject, body, plainText?, segmentId?, tags?, scheduledAt?}. Created as DRAFT. |
| GET | /api/broadcasts/:id | COMMUNICATIONS:VIEW:broadcasts | Includes first 100 recipients. |
| PUT | /api/broadcasts/:id | COMMUNICATIONS:EDIT:broadcasts | Editable only while DRAFT or SCHEDULED. |
| DELETE | /api/broadcasts/:id | COMMUNICATIONS:DELETE:broadcasts | Deletes only while DRAFT. |
| GET | /api/broadcasts/:id/preview | COMMUNICATIONS:VIEW:broadcasts | Returns {html, plainText, subject} (unsubscribe link = #preview placeholder). |
| POST | /api/broadcasts/:id/send | COMMUNICATIONS:EDIT:broadcasts | Sends now (see §4). This is the only way to send — there is no scheduled trigger. |
| GET / POST | /api/segments | VIEW / CREATE:broadcasts | GET auto-seeds 3 system segments on first call. |
| GET / PUT / DELETE | /api/segments/:id | VIEW / EDIT / DELETE:broadcasts | System segments (isSystem:true) cannot be edited or deleted. |
| POST | /api/segments/preview | COMMUNICATIONS:VIEW:broadcasts | Preview member count for a filter set. |
| GET / PUT | /api/email-preferences | requireMemberId() | Per-member toggles; UI = Notifications tab (#226). Backend persists paymentReminders, eventReminders, membershipUpdates, broadcasts, unsubscribedAll. |
| POST | /api/unsubscribe, /api/resubscribe | public | Signed-token based (#254/#257): body {token, category} — memberId/tenantId are carried inside the HMAC token, no longer query/body params. Route registered before auth middleware so logged-out links resolve. |
All four RBAC permissions use the resource broadcasts — even segment routes. Roles that hold them: ADMIN (COMMUNICATIONS:*:*), EVENT_MANAGER, COMMUNICATIONS_MANAGER.
4. Broadcast lifecycle & send mechanism
- Status enum (
BroadcastStatus):DRAFT → SENDING → SENT(orFAILED).SCHEDULEDandCANCELLEDexist in the enum but:SCHEDULEDis never auto-fired, and no code path ever setsCANCELLED(there is no cancel endpoint). - Send is fire-and-forget, in-process.
POST .../sendflips status toSENDING, creates recipient rows, then loops members sending one email each via SMTP — no queue, no worker. On completion the row becomesSENTorFAILED. Large segments tie up the request process. - ⚠️ Scheduling does not work. Setting
scheduledAtstores the date but no cron/worker ever processesSCHEDULEDbroadcasts — they sit forever until an admin manually hits Send. The composer UI doesn't even render a schedule-date input. Do not write "broadcast auto-sends at scheduled time" test cases — they will always fail by design. - No test-send. There is no "send me a test" endpoint or button. To preview, use
GET .../preview(rendered in a modal). - No merge tags. The body is raw HTML stored verbatim and wrapped in a branded shell (org name/logo + per-recipient unsubscribe link). There is no
{{firstName}}/{{memberName}}substitution for broadcasts. (The{memberName}/{orgName}tags inmergeTags.tsare for homepage welcome text only, not broadcasts.)
5. Segments (the real model)
A segment is a saved filter over the Member table. The API accepts exactly three filter fields (segmentHandlers.ts SegmentFilters):
| Filter | Type | Behaviour |
|---|---|---|
status | string[] | Member.status IN [...]. Defaults to ["ACTIVE"] if omitted. |
roles | string[] | Member.roles hasSome [...]. Optional. |
membershipTypeId | string | Exact match. Optional. |
There is NO billing/payment filter. No "unpaid invoices", no dues state, no membershipExpiresAt, no last-login. Any TC asking to segment by invoice/payment status is testing a feature that doesn't exist.
3 auto-seeded system segments (created on first GET /api/segments, not by the DB seed): All Active Members (status:["ACTIVE"]), Administrators (status:["ACTIVE"], roles:["ADMIN"]), Pending Members (status:["PENDING_PAYMENT","PENDING_APPROVAL"]). These are read-only. The seed script creates no broadcasts and no segments.
6. Unsubscribe & preferences
- Unsubscribe (updated #254 / #257, on stage): links now carry a signed HMAC token —
${baseUrl}/unsubscribe?token=…&category=…— and no longer leakmemberId/tenantIdin the query (#254). The public route is registered before auth middleware so a logged-out recipient's link resolves (#257 — previously the token couldn't be decoded and unsubscribe failed). What to verify: click unsubscribe from a broadcast email while logged out → it resolves and unsubscribes. ⚠️ Host caveat:generateUnsubscribeUrlfalls back to the staticFRONTEND_URLwhen the caller doesn't pass a tenant origin, so the unsubscribe host may still differ from the tenant subdomain — flag if the host looks wrong for the tenant. - Unsubscribe-link categories:
paymentReminders,eventReminders,membershipUpdates,broadcasts,all. Stored onMemberEmailPreference(all defaulttrue;unsubscribedAlldefaultfalse). - ⚠️ Notifications-tab field-name mismatch (testable): the backend model +
GET/PUT /api/email-preferencespersistpaymentReminders,eventReminders,membershipUpdates,broadcasts(+unsubscribedAll). But the Notifications-tab UI (EmailPreferencesSection.tsx) renders 5 toggles named Payment Reminders, Event Reminders, Membership Alerts (membershipAlerts), Newsletter (newsletterEmails), Marketing Emails (marketingEmails). OnlypaymentReminders+eventRemindersmatch the backend; the other three FE field names have no backend column, so they likely won't round-trip (load as off / don't persist on save). What to verify: toggle each of the 5, save, reload — flag any that don't stick (expected: Membership Alerts / Newsletter / Marketing Emails). This is a real defect candidate (not yet filed). - Token: HMAC-SHA256 over
memberId:tenantId:category, deterministic, no expiry (same inputs → same token forever). Resubscribe =POST /api/resubscribewith the same shape.
7. Environment (staging)
- App:
stg-rcme.membervu.com· API:stg-api.membervu.com· Outbound email inbox (MailHog):stg-webmail.membervu.com. - To see a broadcast actually land, check the MailHog inbox after hitting Send.
8. Do NOT test (features that don't exist)
- Custom filter-rule builder beyond
status/roles/membershipTypeId(a segment management UI now exists at/admin/segmentsper #224, but those three are still the only filter dimensions — no billing/"unpaid invoices" rules). - "Unpaid invoices" or any billing-based segment.
- Merge-tag personalization in broadcast bodies.
- Scheduled / recurring / drip auto-send.
- Test-send / "send to myself".
- Open-rate / click-tracking analytics (not implemented).
- (Removed — the email-preferences screen is now reachable; the Notifications tab is testable, see §6.)
9. Test Deliverables
- TC-COMM-001 — Broadcast Management (create / edit / preview / send / delete)
- TC-COMM-002 — Segments (select Audience; system segments)
- TC-COMM-003 — Broadcast Delivery (SMTP → MailHog)
- TC-COMM-004 — Email Preferences (Notifications tab, reachable as of #226)
- TC-COMM-005 — Unsubscribe Flow (public
/unsubscribe) - TC-COMM-006 — 🆕 Rich-Text Compose & Send-Test (rich-text editor, HTML-source toggle, sandboxed preview, Send-Test, merge-tag XSS escape) · detailed
10. Risk Areas
- In-process send blocks the request thread for large segments (no queue).
- Branded-shell HTML rendering across mail clients.
- Unsubscribe token never expires (security/privacy consideration).
- Email-preferences Notifications tab has a FE↔BE field-name mismatch — 3 of 5 toggles may not persist (§6).