diff --git a/__tests__/security/novu-payload-allowlist.test.ts b/__tests__/security/novu-payload-allowlist.test.ts
index 13bf6e8f7..16674d4a2 100644
--- a/__tests__/security/novu-payload-allowlist.test.ts
+++ b/__tests__/security/novu-payload-allowlist.test.ts
@@ -72,7 +72,9 @@ describe("ADR 20 — org-roster notifications carry no session content", () => {
// unused elsewhere in the file. So bind the two: take the identifier
// actually passed as the recipient argument, and require THAT identifier to
// be the one assigned from the attendee resolver.
- const call = /notifyRecordingAvailable\(\s*([A-Za-z_$][\w$]*)\s*,/.exec(src);
+ const call = /notifyRecordingAvailable\(\s*([A-Za-z_$][\w$]*)\s*,/.exec(
+ src,
+ );
expect(call).not.toBeNull();
const recipientVar = call![1];
@@ -134,3 +136,268 @@ describe("ADR 23 — dual-context payloads are attributable", () => {
});
});
});
+
+/**
+ * #536 — the inbox showed customers a raw ISO timestamp, an integer count of
+ * paise and a shouted enum, because the Novu templates interpolate payload
+ * fields verbatim. One pin over the whole trigger boundary rather than a unit
+ * test per formatter: what matters is the string that leaves the process, and
+ * that is only assembled once a recipient (and therefore a timezone) is known.
+ */
+describe("#536 — every payload leaves with customer-ready values", () => {
+ const ISO = "2026-09-06T02:23:35.600Z";
+
+ const trigger = jest.fn();
+ const findMany = jest.fn();
+
+ jest.mock("../../lib/novu/client", () => ({
+ isNovuConfigured: () => true,
+ getNovuClient: () => ({ trigger, triggerBroadcast: trigger }),
+ }));
+ jest.mock("../../lib/prisma", () => ({
+ __esModule: true,
+ default: { user: { findMany: (...args: unknown[]) => findMany(...args) } },
+ }));
+
+ /** The payload of the Nth `novu.trigger` call, whatever its recipients. */
+ const payloadOf = (call: number): Record =>
+ trigger.mock.calls[call][0].payload;
+
+ beforeEach(() => {
+ trigger.mockReset().mockResolvedValue(undefined);
+ // Two recipients in different zones: the same instant must reach each of
+ // them written in their own time, which one shared payload cannot do.
+ findMany.mockReset().mockResolvedValue([
+ { id: "u_kolkata", timezone: "Asia/Kolkata" },
+ { id: "u_newyork", timezone: "America/New_York" },
+ ]);
+ });
+
+ const appointmentBase = {
+ organizationId: null,
+ scope: "personal" as const,
+ consultantName: "Sarah Chen",
+ consulteeName: "Aarav Anderson",
+ planTitle: "Basic Consultation",
+ dashboardUrl: "https://example.test/dashboard",
+ };
+
+ it("renders one payload per recipient timezone, never an ISO string", async () => {
+ const { notifyAppointmentReminder } =
+ await import("../../lib/novu/service");
+
+ await notifyAppointmentReminder(["u_kolkata", "u_newyork"], {
+ ...appointmentBase,
+ appointmentType: "CONSULTATION",
+ dateTime: ISO,
+ });
+
+ expect(trigger).toHaveBeenCalledTimes(2);
+ const rendered = trigger.mock.calls.map((c) => [
+ c[0].to,
+ c[0].payload.dateTime,
+ ]);
+ expect(rendered).toEqual([
+ ["u_kolkata", "Sun, 6 Sep 2026 · 7:53 AM IST"],
+ ["u_newyork", "Sat, 5 Sep 2026 · 10:23 PM EDT"],
+ ]);
+ // The machine-readable original still travels, under its unit-suffixed name.
+ expect(payloadOf(0).dateTimeIso).toBe(ISO);
+ // And the shouted enum became a label, with the raw member kept beside it.
+ expect(payloadOf(0).appointmentType).toBe("consultation");
+ expect(payloadOf(0).appointmentTypeCode).toBe("CONSULTATION");
+ });
+
+ it("prints money as money and keeps the paise beside it", async () => {
+ const { notifyPaymentSuccess } = await import("../../lib/novu/service");
+
+ await notifyPaymentSuccess("u_kolkata", {
+ ...appointmentBase,
+ appointmentType: "SUBSCRIPTION",
+ amount: 5_567_948,
+ currency: "INR",
+ });
+
+ expect(payloadOf(0)).toMatchObject({
+ // The live template renders `{{currency}} {{amount}}` and cannot be
+ // edited today, so `amount` carries no symbol of its own.
+ amount: "55,679.48",
+ amountFormatted: "₹55,679.48",
+ amountPaise: 5_567_948,
+ currency: "INR",
+ appointmentType: "subscription session",
+ // #1484/#1489 — the plan's own title, never the plan id.
+ planTitle: "Basic Consultation",
+ });
+ });
+
+ it("prints a credit-covered zero as 0.00 rather than a blank", async () => {
+ // Zero is a live amount: a booking paid entirely with referral credit
+ // still raises payment-success. Negative amounts have no source — the
+ // `payment_amounts_nonnegative` CHECK refuses them at the row.
+ const { notifyPaymentSuccess } = await import("../../lib/novu/service");
+
+ await notifyPaymentSuccess("u_kolkata", {
+ ...appointmentBase,
+ appointmentType: "CONSULTATION",
+ amount: 0,
+ currency: "INR",
+ });
+
+ expect(payloadOf(0)).toMatchObject({
+ amount: "0.00",
+ amountFormatted: "₹0.00",
+ amountPaise: 0,
+ });
+ });
+
+ it("drops a time the formatter rejects instead of forwarding it raw", async () => {
+ // The templates gate on `{{#if payload.dateTime}}`; a non-empty garbage
+ // string would pass that test, so the raw value must not survive the spread.
+ const { notifyAppointmentReminder } =
+ await import("../../lib/novu/service");
+
+ await notifyAppointmentReminder(["u_kolkata"], {
+ ...appointmentBase,
+ appointmentType: "CONSULTATION",
+ dateTime: "not-a-date",
+ });
+
+ expect(payloadOf(0)).not.toHaveProperty("dateTime");
+ expect(payloadOf(0)).not.toHaveProperty("dateTimeIso");
+ });
+
+ it("says 'further notice' for an indefinite suspension, not a blank", async () => {
+ // The moderation caller sends "" when `banExpires` is null, and the
+ // sentence reads "until {{suspendedUntil}}".
+ const { notifyAccountSuspended } = await import("../../lib/novu/service");
+
+ await notifyAccountSuspended("u_kolkata", {
+ reason: "Repeated no-shows",
+ suspendedUntil: "",
+ });
+
+ expect(payloadOf(0)).toMatchObject({ suspendedUntil: "further notice" });
+ expect(payloadOf(0)).not.toHaveProperty("suspendedUntilIso");
+ });
+
+ it("names who cancelled instead of printing the role enum", async () => {
+ const { notifyAppointmentCancelled } =
+ await import("../../lib/novu/service");
+
+ await notifyAppointmentCancelled(["u_kolkata", "u_newyork"], {
+ ...appointmentBase,
+ appointmentType: "CONSULTATION",
+ cancelledBy: "consultant",
+ });
+
+ // One payload reaches both parties, so a relative phrase would be false for
+ // one of them; the name is true for both. The live template opens its
+ // sentence with this field, and ends it on "Reason: ".
+ expect(payloadOf(0)).toMatchObject({
+ cancelledBy: "Sarah Chen",
+ cancelledByRole: "consultant",
+ reason: "No reason given",
+ });
+ });
+
+ it.each([
+ [
+ "a system cancellation",
+ "system",
+ undefined,
+ "The platform",
+ "No reason given",
+ ],
+ [
+ "a raw enum reason",
+ "system",
+ "MODERATION",
+ "The platform",
+ "a moderation decision on this account",
+ ],
+ [
+ "free text a user typed",
+ "consultee",
+ "I am travelling that week",
+ "Aarav Anderson",
+ "I am travelling that week",
+ ],
+ ] as Array<
+ [
+ string,
+ "consultant" | "consultee" | "system",
+ string | undefined,
+ string,
+ string,
+ ]
+ >)(
+ "completes the cancellation sentence for %s",
+ async (_name, cancelledBy, reason, expectedBy, expectedReason) => {
+ const { notifyAppointmentCancelled } =
+ await import("../../lib/novu/service");
+ findMany.mockResolvedValue([
+ { id: "u_kolkata", timezone: "Asia/Kolkata" },
+ ]);
+
+ await notifyAppointmentCancelled(["u_kolkata"], {
+ ...appointmentBase,
+ appointmentType: "CONSULTATION",
+ cancelledBy,
+ reason,
+ });
+
+ expect(payloadOf(0)).toMatchObject({
+ cancelledBy: expectedBy,
+ reason: expectedReason,
+ });
+ },
+ );
+
+ const rescheduleCases: Array<
+ [string, import("@/lib/novu/workflows").RescheduleOutcomeFields, string]
+ > = [
+ [
+ "MOVED",
+ {
+ outcome: "MOVED",
+ oldDateTime: ISO,
+ newDateTime: "2026-09-07T02:23:35.600Z",
+ },
+ "Mon, 7 Sep 2026 · 7:53 AM IST",
+ ],
+ [
+ "RELEASED",
+ { outcome: "RELEASED", oldDateTime: ISO },
+ "a new time your consultant will confirm",
+ ],
+ [
+ "WITHDRAWN",
+ { outcome: "WITHDRAWN", oldDateTime: ISO },
+ "the time it was already booked for",
+ ],
+ ];
+
+ it.each(rescheduleCases)(
+ "a %s reschedule always completes the sentence",
+ async (outcome, outcomeFields, expected) => {
+ const { notifyAppointmentRescheduled } =
+ await import("../../lib/novu/service");
+ findMany.mockResolvedValue([
+ { id: "u_kolkata", timezone: "Asia/Kolkata" },
+ ]);
+
+ await notifyAppointmentRescheduled(["u_kolkata"], {
+ ...appointmentBase,
+ appointmentType: "CONSULTATION",
+ ...outcomeFields,
+ });
+
+ expect(payloadOf(0)).toMatchObject({
+ outcome,
+ oldDateTime: "Sun, 6 Sep 2026 · 7:53 AM IST",
+ newDateTime: expected,
+ });
+ },
+ );
+});
diff --git a/app/api/appointments/[appointmentId]/cancel/route.ts b/app/api/appointments/[appointmentId]/cancel/route.ts
index b6a2abb8a..6c79bf254 100644
--- a/app/api/appointments/[appointmentId]/cancel/route.ts
+++ b/app/api/appointments/[appointmentId]/cancel/route.ts
@@ -12,6 +12,7 @@ import { CancellationReason } from "@prisma/client";
import { notifyAppointmentCancelled } from "@/lib/novu";
import { notificationScope } from "@/lib/novu/workflows";
import { notificationHref } from "@/lib/novu/resolve-href";
+import { planTitleOrSessionLabel } from "@/lib/novu/humanize";
import { CancelAppointmentSchema } from "@/schemas/appointments";
import {
logConsultationCancelled,
@@ -739,7 +740,12 @@ export async function POST(
appointmentType: notificationMeta.appointmentType,
consultantName: notificationMeta.consultantName || "Consultant",
consulteeName: notificationMeta.consulteeName || "Consultee",
- planTitle: notificationMeta.planTitle || "N/A",
+ // #536 — "N/A" is a developer's placeholder; it used to be the name the
+ // customer read for the session they had just lost.
+ planTitle: planTitleOrSessionLabel(
+ notificationMeta.planTitle,
+ notificationMeta.appointmentType,
+ ),
dateTime: notificationMeta.dateTime,
// Both parties receive one payload, so the href has to suit either.
dashboardUrl: notificationHref(
diff --git a/app/api/appointments/[appointmentId]/reschedule/route.ts b/app/api/appointments/[appointmentId]/reschedule/route.ts
index af92ffdb9..90ed42a0c 100644
--- a/app/api/appointments/[appointmentId]/reschedule/route.ts
+++ b/app/api/appointments/[appointmentId]/reschedule/route.ts
@@ -31,6 +31,7 @@ import {
import { notifyAppointmentRescheduled } from "@/lib/novu/service";
import { notificationScope } from "@/lib/novu/workflows";
import { notificationHref } from "@/lib/novu/resolve-href";
+import { planTitleOrSessionLabel } from "@/lib/novu/humanize";
import { logActivity } from "@/lib/activity/log-activity";
import { tryAutoConfirmProposal } from "@/lib/booking/reschedule-auto-confirm";
import { hasActiveDisputeForAppointment } from "@/lib/payments/dispute-guard";
@@ -928,7 +929,8 @@ export async function POST(
appointmentType,
consultantName: plan?.consultantProfile?.user?.name ?? "Consultant",
consulteeName: requestedBy?.user?.name ?? "Participant",
- planTitle: plan?.title ?? "Unknown",
+ // #536 — "Unknown" read as the name of the booking being moved.
+ planTitle: planTitleOrSessionLabel(plan?.title, appointmentType),
// Group events fan out to every attendee, so one href must serve
// them all — org route when org-hosted, router bounce otherwise.
dashboardUrl: notificationHref(
diff --git a/docs/enterprise/90-audits/03-verification-guide.md b/docs/enterprise/90-audits/03-verification-guide.md
index 55c0df8ab..2c95e8050 100644
--- a/docs/enterprise/90-audits/03-verification-guide.md
+++ b/docs/enterprise/90-audits/03-verification-guide.md
@@ -28,21 +28,36 @@ The database was reset and reseeded (`small` mode). This guide gives you **every
### By org archetype
| Org | Archetype | Funding / Program | Persona | Email |
|---|---|---|---|---|
-| **Wipro Limited** (`wipro`) | **SPONSOR** (sponsor✓ host✗) | INVOICE · LICENSED_SEAT (200 seats, 12 covered/cycle) | OWNER | `samantha.anderson@yahoo.com` |
+| **Wipro Limited** (`wipro`) | **SPONSOR** (sponsor✓ host✗) | INVOICE · LICENSED_SEAT (200 seats, 12 covered/cycle) | OWNER | `zara.brown@yahoo.com` |
| | | | OWNER (tour) | `tour-owner@familiarise.dev` |
-| | | | LEARNER | `olivia.anderson@gmail.com` |
-| | | | LEARNER | `patrick.anderson@outlook.com` |
-| | | | LEARNER | `priya.anderson@yahoo.com` |
+| | | | LEARNER | `robert.brown@gmail.com` |
+| | | | LEARNER | `samantha.brown@outlook.com` |
+| | | | LEARNER | `sarah.brown@yahoo.com` |
| **IIT Madras** (`iit-madras`) | **HYBRID** (sponsor✓ host✓) | WALLET (₹14,75,000) · CREDIT_POOL | OWNER | `charlotte.anderson@gmail.com` |
| | | | EXPERT | `andrew.anderson@gmail.com` (also: angela, arjun, benjamin, catherine) |
-| | | | LEARNER | `rachel.anderson@hotmail.com` (also: raj, rebecca, robert) |
+| | | | LEARNER | `sophia.brown@hotmail.com` (also: thomas, victoria, william) |
| **LearnPro Academy** (`learnpro-academy`) | **HOST** (sponsor✗ host✓) | RateCard 10/10/80 | OWNER | `daniel.anderson@outlook.com` |
| | | | EXPERT | `aarav.anderson@gmail.com` (also: aditi, alex, amit, ananya) |
-| **Arjun Anderson's Coaching** (`arjun-anderson-coaching-mrpk`) | **solo HOST** | personal org | OWNER | `arjun.anderson@yahoo.com` |
-| **Platform admin** | — | — | ADMIN | `olivia.brown@protonmail.com` |
+| **Arjun Anderson's Coaching** (`arjun-anderson-coaching-2ncb`) | **solo HOST** | personal org | OWNER | `arjun.anderson@yahoo.com` |
+| **Platform admin** | — | — | ADMIN | `robert.davis@yahoo.com` |
> Note: `arjun.anderson@yahoo.com` is OWNER of the solo coaching org **and** an EXPERT at IIT Madras — a built-in **multi-org consultant** example.
+The org-archetype roster above was regenerated from the live database on 2026-09-06, against the `Membership`, `users`, and `organizations` tables in Supabase project `pzmbxqdgibfkhjwzeprf`, after a 2026-09-06 E2E run found that `rachel.anderson@hotmail.com` (the previously documented IIT Madras LEARNER) has no live membership at all; the actual IIT Madras LEARNER cohort is the Brown family (`sophia`, `thomas`, `victoria`, `william`).
+
+```sql
+-- Active enterprise memberships for the four seed orgs, joined to email/name/role.
+SELECT o.name AS org_name, m.role, u.email, u.name, m.status
+FROM "Membership" m
+JOIN users u ON u.id = m."userId"
+JOIN organizations o ON o.id = m."organizationId"
+WHERE o.slug IN ('wipro', 'learnpro-academy', 'iit-madras', 'arjun-anderson-coaching-2ncb')
+ORDER BY o.name, m.role, u.email;
+
+-- Live ADMIN-role users (the "Platform admin" persona).
+SELECT email, name, role FROM users WHERE role = 'ADMIN';
+```
+
### Seeded data summary
`4 orgs · 78 users · 22 enterprise memberships · 2 programs · 2 contracts · 1 invoice (Wipro, DRAFT) · 8 ledger transactions`
diff --git a/docs/notifications/02-workflows-and-api.md b/docs/notifications/02-workflows-and-api.md
index 45cba31f4..df9b541b9 100644
--- a/docs/notifications/02-workflows-and-api.md
+++ b/docs/notifications/02-workflows-and-api.md
@@ -66,6 +66,42 @@ graph TD
---
+## Payload conventions
+
+The Novu templates live in the Novu dashboard rather than in this repository, and every one of them interpolates payload fields verbatim: `{{payload.dateTime}}`, `{{payload.amount}}`, `{{payload.planTitle}}`. Whatever this codebase puts in a field is therefore the exact text a customer reads in their inbox. Issue #536 was filed because that fact had been forgotten in several places at once, and the inbox was showing raw ISO timestamps, integer counts of paise and shouted enum members.
+
+The rule the whole payload layer now follows is that **a template interpolates the unit-free field name and receives a value written for a person, while the machine-readable original travels beside it under the same stem with a unit suffix**. So `dateTime` carries `Sat, 6 Sep 2026 · 7:53 AM IST` and `dateTimeIso` carries `2026-09-06T02:23:35.600Z`; `amount` carries `₹55,679.48` and `amountPaise` carries `5567948`; `appointmentType` carries `consultation` and `appointmentTypeCode` carries `CONSULTATION`. A consumer that needs to compute or branch reads the suffixed field, and a template author never has to know which of two fields is safe to print.
+
+Call sites do not perform any of this conversion themselves. Each `notifyX` function accepts an _input_ type — `AppointmentPayloadInput`, `PaymentSuccessInput`, `RefundInput` and so on — whose fields hold the values exactly as they are stored: an ISO string, an integer number of paise, the raw Prisma enum member. The trigger boundary in `lib/novu/service.ts` (and `lib/novu/org-workflows.ts` for the organisation workflows) converts them using the helpers in `lib/novu/humanize.ts` and sends the customer-facing shape. This keeps every notification consistent, and it means a new call site cannot forget to format anything.
+
+### Dates render in the recipient's timezone
+
+A date is only meaningful once you know whose clock it is on, so `formatNotificationDateTime` renders in the recipient's `User.timezone`, falling back to `Asia/Kolkata` when that column is unset or holds a zone Intl cannot resolve. The rendered string always names the zone it used, so nobody has to guess.
+
+This has a consequence for the multi-recipient helpers. `triggerForMultiple` sends one payload to a list of subscribers, which means a single rendered date can be correct for at most one of them. Any workflow whose payload carries a date is therefore dispatched through `triggerForMultipleZoned` instead: it loads every recipient's zone in one query, groups the recipients by zone, and sends one payload per distinct zone. Both parties to a booking usually share a zone, so this is a single trigger in the common case and two in the cross-border one. The zone lookup is bounded by a short timeout and never throws — if it fails, every recipient is rendered in the platform default rather than the notification being lost.
+
+Two payloads have no recipient whose zone could be used, and both say so in their field documentation. A maintenance broadcast goes to every subscriber at once, and an organisation invite is emailed to someone who does not have an account yet; both render in the platform default zone.
+
+### Money is printed as money
+
+Every amount reaching a notification is stored in integer minor units, and `formatNotificationMoney` renders it through `formatCurrencyAmount`, the platform's single paise-taking formatter. That formatter already knows the ISO 4217 decimal rules, so a zero-decimal or three-decimal currency is handled without the notification layer having an opinion. A payload never carries a bare integer in a field a template prints.
+
+Money nevertheless arrives in two shapes, and the reason is a property of the live templates rather than of the money. Four in-app templates — `payment-success`, `payment-failed`, `refund-processed` and `refund-requested` — already print `{{payload.currency}} {{payload.amount}}`, and they cannot be edited on the Novu plan currently in use. A symbol-bearing `amount` would therefore render "INR ₹55,679.48" in exactly those four places. So `PaymentSuccessPayload`, `PaymentFailedPayload` and `RefundPayload` send `amount` as the localised figure with the symbol stripped (`55,679.48`), leave `currency` as the ISO code the template prints, and carry the symbol-bearing string alongside as `amountFormatted` for whichever template is written next. Every other money payload — `PayoutPayload`, `DisputePayload`, the referral payloads and the organisation ones — keeps the symbol in `amount`, because nothing prints a currency code beside it.
+
+The stripping is done by `formatCurrencyAmountBare`, which removes the currency part from the currency formatter's own output rather than configuring a second formatter, so the grouping, locale and subunit rules cannot drift between the two shapes.
+
+### Enums become labels, and a plan is named by its title
+
+`cancelledByLabel` does the same job for the cancellation payload. The live `appointment-cancelled` template reads "{{cancelledBy}} cancelled the {{appointmentType}} session for {{planTitle}}. Reason: {{reason}}", so this field opens the sentence and every branch of it is capitalised. One payload reaches both parties at once, which is why the field names the person rather than describing them — "Your consultant" would be false for the consultant reading their own copy. It therefore renders "Sarah Chen", or "The platform" for a system-driven cancellation, and the raw discriminator moves to `cancelledByRole` for any template that wants to branch on who acted.
+
+That template also ends on "Reason: ", which an absent value left dangling on a colon, and the moderation paths were passing the raw enum member `MODERATION` to a person it had just been used against. `cancellationReasonLabel` makes `reason` a required field on the wire payload: an exact `CancellationReason` member becomes a clause ("a moderation decision on this account"), free text a user typed passes through verbatim, and nothing at all becomes "No reason given". The enum table is exhaustive, so a reason added to the schema without copy fails the build rather than reaching an inbox as its own identifier.
+
+`appointmentTypeLabel` maps `AppointmentsType` to the noun phrase a sentence needs — `consultation`, `subscription session`, `webinar`, `class`, `trial session` — and accepts both the Prisma enum member and the lower-case literals some booking paths already used, so the two sources cannot drift apart. `planTitle` is always the plan's own title. Where a plan row genuinely cannot be read, `planTitleOrSessionLabel` substitutes the capitalised session label rather than a developer placeholder; the three call sites that used to send `"N/A"` and the one that sent `"Unknown"` were naming, to the customer, the very thing that had just been cancelled or moved.
+
+### A reschedule sentence always completes
+
+The `appointment-rescheduled` template renders a "from X to Y" sentence, and three of the five reschedule outcomes have no destination time — a plain release hands the slots back to the consultant's queue precisely so that no new time exists yet. `AppointmentRescheduledInput` keeps the discriminated union introduced by #1083, so a caller still cannot construct a `MOVED` or `PROPOSED` outcome without both timestamps. The wire payload, however, declares `newDateTime` as required and the trigger boundary fills it with a phrase when there is no instant to render: "a new time your consultant will confirm" for a release, and "the time it was already booked for" for a declined or withdrawn proposal. The blank-blank sentence is therefore unrepresentable from either direction. Issue #1085 remains open for the template-side branch on `outcome`, which would let the release case read as its own sentence rather than reusing the "from … to …" shape.
+
## Workflows by Category
### Appointment Lifecycle
@@ -95,11 +131,11 @@ sequenceDiagram
NC-->>U: In-App + Email
```
-**AppointmentPayload fields**: `appointmentId?`, `appointmentType`, `consultantName`, `consulteeName`, `planTitle`, `dateTime?`, `dashboardUrl`
+**AppointmentPayload fields**: `appointmentId?`, `appointmentType` (label), `appointmentTypeCode?` (raw enum), `consultantName`, `consulteeName`, `planTitle`, `dateTime?` (recipient-zone), `dateTimeIso?`, `dashboardUrl`. Callers pass `AppointmentPayloadInput`, which holds the raw enum and an ISO instant.
-**AppointmentCancelledPayload** extends AppointmentPayload with: `reason?`, `cancelledBy: "consultant" | "consultee" | "system"`
+**AppointmentCancelledPayload** extends AppointmentPayload with: `reason` (required — a clause, or "No reason given"), `cancelledBy` (a capitalised name, or "The platform"), `cancelledByRole?` (the raw discriminator). Callers pass `AppointmentCancelledInput`, whose `cancelledBy` is still `"consultant" | "consultee" | "system"`.
-**AppointmentRescheduledPayload** extends AppointmentPayload with: `oldDateTime?`, `newDateTime?`
+**AppointmentRescheduledPayload** extends AppointmentPayload with: `outcome`, `oldDateTime?`, `oldDateTimeIso?`, `newDateTime` (required — a phrase when the outcome has no destination time), `newDateTimeIso?`. Callers pass `AppointmentRescheduledInput`, whose `RescheduleOutcomeFields` union still forbids a destination time on the outcomes that have none.
---
@@ -112,11 +148,11 @@ sequenceDiagram
| `refund-processed` | `notifyRefundProcessed(userId, payload)` | Recipient (consultee) | `RefundPayload` |
| `refund-requested` | `notifyRefundRequested(adminUserIds[], payload)` | Admin team | `RefundPayload` |
-**PaymentSuccessPayload**: `amount`, `currency`, `consultantName`, `appointmentType`, `planTitle`, `receiptUrl?`, `dashboardUrl`
+**PaymentSuccessPayload**: `amount` (symbol-free figure), `amountFormatted` (with symbol), `amountPaise`, `currency`, `consultantName`, `appointmentType` (label), `appointmentTypeCode?`, `planTitle`, `receiptUrl?`, `dashboardUrl`. Callers pass `PaymentSuccessInput`, whose `amount` is an integer number of paise.
-**PaymentFailedPayload**: `amount`, `currency`, `consultantName`, `appointmentType`, `planTitle?`, `failureReason`, `retryUrl?`
+**PaymentFailedPayload**: `amount` (symbol-free figure), `amountFormatted` (with symbol), `amountPaise`, `currency`, `consultantName`, `appointmentType` (label), `appointmentTypeCode?`, `planTitle?`, `failureReason`, `retryUrl?`. Callers pass `PaymentFailedInput`.
-**RefundPayload**: `amount`, `currency`, `reason?`, `appointmentType?`, `consultantName?`, `dashboardUrl`
+**RefundPayload**: `amount` (symbol-free figure), `amountFormatted` (with symbol), `amountPaise`, `currency`, `reason?`, `appointmentType?` (label), `appointmentTypeCode?`, `consultantName?`, `dashboardUrl`. Callers pass `RefundInput`.
---
@@ -154,7 +190,7 @@ sequenceDiagram
| `trial-session-completed` | `notifyTrialSessionCompleted(userIds[], payload)` | Both parties | `TrialSessionPayload` |
| `trial-session-cancelled` | `notifyTrialSessionCancelled(userIds[], payload)` | Both parties | `TrialSessionPayload` |
-**TrialSessionPayload**: `consultantName`, `consulteeName`, `planTitle`, `dateTime?`, `status`, `dashboardUrl`
+**TrialSessionPayload**: `consultantName`, `consulteeName`, `planTitle`, `dateTime?` (recipient-zone), `dateTimeIso?`, `status` (label), `statusCode?`, `dashboardUrl`. Callers pass `TrialSessionInput`.
---
@@ -178,11 +214,11 @@ sequenceDiagram
| `verification-status-changed` | `notifyVerificationStatusChanged(consultantUserId, payload)` | Consultant | `VerificationPayload` |
| `payout-processed` | `notifyPayoutProcessed(consultantUserId, payload)` | Consultant | `PayoutPayload` |
-**BookingRequestPayload**: `consulteeName`, `planTitle`, `appointmentType`, `requestedDateTime?`, `dashboardUrl`
+**BookingRequestPayload**: `consulteeName`, `planTitle`, `appointmentType` (label), `appointmentTypeCode?`, `requestedDateTime?` (recipient-zone), `requestedDateTimeIso?`, `dashboardUrl`. Callers pass `BookingRequestInput`.
**VerificationPayload**: `status`, `reason?`, `dashboardUrl`
-**PayoutPayload**: `amount`, `currency`, `payoutId?`, `dashboardUrl`
+**PayoutPayload**: `amount` (formatted), `amountPaise`, `currency`, `payoutId?`, `dashboardUrl`. Callers pass `PayoutInput`.
---
@@ -201,16 +237,15 @@ sequenceDiagram
### Disputes, Recordings
-| Workflow ID | Trigger Function | Recipients | Payload Type |
-| ------------------------- | ---------------------------------------------- | --------------- | ------------------ |
-| `dispute-created` | `notifyDisputeCreated(userIds[], payload)` | Both parties | `DisputePayload` |
-| `dispute-resolved` | `notifyDisputeResolved(userIds[], payload)` | Both parties | `DisputePayload` |
-| `recording-available` | `notifyRecordingAvailable(userIds[], payload)` | Both parties | `RecordingPayload` |
-
+| Workflow ID | Trigger Function | Recipients | Payload Type |
+| --------------------- | ---------------------------------------------- | ------------ | ------------------ |
+| `dispute-created` | `notifyDisputeCreated(userIds[], payload)` | Both parties | `DisputePayload` |
+| `dispute-resolved` | `notifyDisputeResolved(userIds[], payload)` | Both parties | `DisputePayload` |
+| `recording-available` | `notifyRecordingAvailable(userIds[], payload)` | Both parties | `RecordingPayload` |
-**DisputePayload**: `disputeId?`, `amount`, `currency`, `reason?`, `status?`, `consultantName?`, `consulteeName?`, `dashboardUrl`
+**DisputePayload**: `disputeId?`, `amount` (formatted), `amountPaise`, `currency`, `reason?`, `status?`, `consultantName?`, `consulteeName?`, `dashboardUrl`. Callers pass `DisputeInput`.
-**RecordingPayload**: `appointmentType`, `consultantName`, `consulteeName?`, `recordingUrl`, `dashboardUrl`
+**RecordingPayload**: `appointmentType` (label), `appointmentTypeCode?`, `consultantName`, `consulteeName?`, `recordingUrl`, `dashboardUrl`.
---
diff --git a/docs/notifications/03-novu-template-specs.md b/docs/notifications/03-novu-template-specs.md
index 7fd5fff54..eea7222d4 100644
--- a/docs/notifications/03-novu-template-specs.md
+++ b/docs/notifications/03-novu-template-specs.md
@@ -7,6 +7,8 @@
**Created**: 2026-03-24
**Source of Truth**: `lib/novu/workflows.ts` (payload types)
+> **Payload values are customer-ready before they reach a template (#536).** Every field named below without a unit suffix already holds the string a person should read: `dateTime` is a sentence in the recipient's own timezone, `amount` is formatted money including its currency symbol, and `appointmentType` is a label rather than an enum member. The machine-readable original travels alongside under a unit-suffixed name (`dateTimeIso`, `amountPaise`, `appointmentTypeCode`). See "Payload conventions" in `02-workflows-and-api.md` for the full rule. A template must never format a date itself. The one exception to "print the field as-is" is money: the four templates that already render `{{payload.currency}} {{payload.amount}}` receive `amount` with the symbol stripped, so they keep printing the ISO code and read correctly; every other money template gets the symbol inside `amount` and must not print a currency code beside it.
+
---
## Table of Contents
@@ -91,11 +93,11 @@ In the Novu editor, replicate this using their visual builder or paste the HTML
```
{{payload.appointmentId}} - Appointment ID
-{{payload.appointmentType}} - "consultation" | "subscription" | "webinar" | "class"
+{{payload.appointmentType}} - "consultation" | "subscription session" | "webinar" | "class" | "trial session"
{{payload.consultantName}} - Consultant display name
{{payload.consulteeName}} - Consultee display name
{{payload.planTitle}} - Plan/service title
-{{payload.dateTime}} - Formatted date/time string
+{{payload.dateTime}} - Recipient-zone date/time, e.g. "Sat, 6 Sep 2026 · 7:53 AM IST"
{{payload.dashboardUrl}} - Link to dashboard
```
@@ -188,20 +190,21 @@ Booking Confirmed — {{payload.planTitle}}
```
{{payload.appointmentId}} - Appointment ID
-{{payload.appointmentType}} - "consultation" | "subscription" | "webinar" | "class"
+{{payload.appointmentType}} - "consultation" | "subscription session" | "webinar" | "class" | "trial session"
{{payload.consultantName}} - Consultant display name
{{payload.consulteeName}} - Consultee display name
{{payload.planTitle}} - Plan/service title
-{{payload.dateTime}} - Original date/time
+{{payload.dateTime}} - Original date/time, rendered in the recipient's timezone
{{payload.dashboardUrl}} - Link to dashboard
-{{payload.reason}} - Cancellation reason (optional)
-{{payload.cancelledBy}} - "consultant" | "consultee" | "system"
+{{payload.reason}} - Cancellation reason. Always present — "No reason given" when there is none
+{{payload.cancelledBy}} - Who cancelled, as a name or "The platform". Capitalised: it opens the sentence
+{{payload.cancelledByRole}} - "consultant" | "consultee" | "system" (for branching)
```
**In-App notification**:
```
-Your {{payload.appointmentType}} "{{payload.planTitle}}" has been cancelled{{#if payload.reason}}: {{payload.reason}}{{/if}}.
+{{payload.cancelledBy}} cancelled the {{payload.appointmentType}} session for {{payload.planTitle}}. Reason: {{payload.reason}}
```
**Email subject**:
@@ -222,8 +225,8 @@ Appointment Cancelled — {{payload.planTitle}}
- Your {{payload.appointmentType}} "{{payload.planTitle}}" has
- been cancelled by the {{payload.cancelledBy}}.
+ Your {{payload.appointmentType}} "{{payload.planTitle}}" was
+ cancelled by {{payload.cancelledBy}}.
{{#if payload.reason}}
@@ -284,11 +287,11 @@ Appointment Cancelled — {{payload.planTitle}}
**Payload variables** (`AppointmentPayload`):
```
-{{payload.appointmentType}} - "consultation" | "subscription" | "webinar" | "class"
+{{payload.appointmentType}} - "consultation" | "subscription session" | "webinar" | "class" | "trial session"
{{payload.consultantName}} - Consultant display name
{{payload.consulteeName}} - Consultee display name
{{payload.planTitle}} - Plan/service title
-{{payload.dateTime}} - Upcoming date/time
+{{payload.dateTime}} - Upcoming date/time, rendered in the recipient's timezone
{{payload.dashboardUrl}} - Link to dashboard
```
@@ -365,10 +368,12 @@ Reminder — {{payload.planTitle}} is coming up
**Payload variables** (`PaymentSuccessPayload`):
```
-{{payload.amount}} - Payment amount (number)
-{{payload.currency}} - Currency code (e.g., "INR", "USD")
+{{payload.amount}} - Localised figure without a symbol, e.g. "55,679.48"
+{{payload.amountFormatted}} - The same figure with the symbol, e.g. "₹55,679.48"
+{{payload.amountPaise}} - The same amount in integer minor units
+{{payload.currency}} - Currency code (e.g., "INR", "USD") — printed before {{payload.amount}}
{{payload.consultantName}} - Consultant display name
-{{payload.appointmentType}} - Service type
+{{payload.appointmentType}} - Service label, e.g. "consultation"
{{payload.planTitle}} - Plan title
{{payload.receiptUrl}} - Receipt URL (optional)
{{payload.dashboardUrl}} - Link to dashboard
@@ -464,10 +469,12 @@ Payment Confirmed — {{payload.planTitle}}
**Payload variables** (`PaymentFailedPayload`):
```
-{{payload.amount}} - Payment amount
-{{payload.currency}} - Currency code
+{{payload.amount}} - Localised figure without a symbol, e.g. "55,679.48"
+{{payload.amountFormatted}} - The same figure with the symbol, e.g. "₹55,679.48"
+{{payload.amountPaise}} - The same amount in integer minor units
+{{payload.currency}} - Currency code — printed before {{payload.amount}}
{{payload.consultantName}} - Consultant display name
-{{payload.appointmentType}} - Service type
+{{payload.appointmentType}} - Service label, e.g. "consultation"
{{payload.planTitle}} - Plan title (optional)
{{payload.failureReason}} - Reason for failure
{{payload.retryUrl}} - Retry URL (optional)
@@ -554,8 +561,8 @@ Payment Failed — Action Required
```
{{payload.consulteeName}} - Consultee display name
{{payload.planTitle}} - Plan title
-{{payload.appointmentType}} - "consultation" | "subscription"
-{{payload.requestedDateTime}} - Requested date/time (optional)
+{{payload.appointmentType}} - "consultation" | "subscription session"
+{{payload.requestedDateTime}} - Requested date/time, rendered in the recipient's timezone (optional)
{{payload.dashboardUrl}} - Link to dashboard
```
@@ -752,7 +759,7 @@ Subscription Cancelled — {{payload.planTitle}}
{{payload.consultantName}} - Consultant display name
{{payload.consulteeName}} - Consultee display name
{{payload.planTitle}} - Plan title
-{{payload.dateTime}} - Requested date/time (optional)
+{{payload.dateTime}} - Requested date/time, rendered in the recipient's timezone (optional)
{{payload.status}} - Current status
{{payload.dashboardUrl}} - Link to dashboard
```
@@ -823,7 +830,7 @@ New Trial Request — {{payload.planTitle}}
{{payload.consultantName}} - Consultant display name
{{payload.consulteeName}} - Consultee display name
{{payload.planTitle}} - Plan title
-{{payload.dateTime}} - Scheduled date/time
+{{payload.dateTime}} - Scheduled date/time, rendered in the recipient's timezone
{{payload.dashboardUrl}} - Link to dashboard
```
diff --git a/lib/moderation/cancel-user-engagements.ts b/lib/moderation/cancel-user-engagements.ts
index b7ce2a022..a8d32c00d 100644
--- a/lib/moderation/cancel-user-engagements.ts
+++ b/lib/moderation/cancel-user-engagements.ts
@@ -11,11 +11,10 @@
*/
import * as Sentry from "@sentry/nextjs";
import prisma from "@/lib/prisma";
-import {
- notifyAppointmentCancelled,
-} from "@/lib/novu";
+import { notifyAppointmentCancelled } from "@/lib/novu";
import { notificationScope } from "@/lib/novu/workflows";
import { notificationHref } from "@/lib/novu/resolve-href";
+import { planTitleOrSessionLabel } from "@/lib/novu/humanize";
import { refundBookingPayment } from "@/lib/payments/operations/booking-refund";
import { refundWholeEventPayments } from "@/lib/payments/operations/event-refunds";
import {
@@ -445,7 +444,11 @@ function notifyExclusiveCancellation(
engagement.appointments[0]?.appointmentType ?? kind.toUpperCase(),
consultantName: engagement.consultantUser?.name || "Consultant",
consulteeName: engagement.consulteeUser?.name || "Consultee",
- planTitle: engagement.planTitle || "N/A",
+ // #536 — never show the customer a placeholder as the session's name.
+ planTitle: planTitleOrSessionLabel(
+ engagement.planTitle,
+ engagement.appointments[0]?.appointmentType ?? kind,
+ ),
dashboardUrl: notificationHref(engagementOrgId, "appointments"),
reason: "MODERATION",
cancelledBy: "system",
@@ -552,7 +555,9 @@ async function cancelGroupEvent(
appointmentType: isWebinar ? "WEBINAR" : "CLASS",
consultantName: "Consultant",
consulteeName: "Attendee",
- planTitle: "N/A",
+ // #536 — the event's own title is not loaded on this path, so the
+ // session label stands in rather than a placeholder.
+ planTitle: planTitleOrSessionLabel(null, isWebinar ? "WEBINAR" : "CLASS"),
dashboardUrl: notificationHref(eventOrgId, "appointments"),
reason: "MODERATION",
cancelledBy: "system",
diff --git a/lib/novu/humanize.ts b/lib/novu/humanize.ts
new file mode 100644
index 000000000..53c2d5aa6
--- /dev/null
+++ b/lib/novu/humanize.ts
@@ -0,0 +1,314 @@
+/**
+ * Customer-ready values for Novu payloads (#536).
+ *
+ * The Novu templates live in the dashboard and interpolate payload fields
+ * verbatim — `{{payload.dateTime}}`, `{{payload.amount}}`, `{{payload.planTitle}}`.
+ * Whatever this repository puts in those fields is what a customer reads, so a
+ * raw ISO timestamp, an integer count of paise or a shouted enum lands in the
+ * inbox exactly as stored. These helpers are the single place that turns the
+ * stored value into the sentence fragment the template needs.
+ *
+ * The naming rule the whole payload layer follows: a template interpolates the
+ * unit-free field name and gets a human value; the raw value keeps the same
+ * stem with a unit suffix (`dateTimeIso`, `amountPaise`, `appointmentTypeCode`)
+ * for any consumer that has to compute or branch on it.
+ */
+
+import type { CancellationReason } from "@prisma/client";
+import prisma from "@/lib/prisma";
+import {
+ formatCurrencyAmount,
+ formatCurrencyAmountBare,
+} from "@/utils/formatting";
+
+/** The platform's home zone, used whenever a recipient has none recorded. */
+export const DEFAULT_NOTIFICATION_TIMEZONE = "Asia/Kolkata";
+
+/**
+ * CLDR carries no English abbreviation for these zones, so Intl's `short`
+ * time-zone name renders "GMT+5:30". #536 — the platform's primary market
+ * reads that as noise; they know the zone as IST. Everywhere else Intl's own
+ * abbreviation (EDT, AEST, …) is both correct and unambiguous, so only the
+ * zones this platform actually defaults to are overridden here.
+ */
+const ZONE_ABBREVIATION: Record = {
+ "Asia/Kolkata": "IST",
+ "Asia/Calcutta": "IST",
+};
+
+/** A junk zone throws inside Intl, which would turn a notification into a 500. */
+function isRenderableTimezone(timezone: string): boolean {
+ try {
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * The zone a notification should be rendered in: the recipient's own when it
+ * is recorded and valid, the platform default otherwise.
+ */
+export function resolveNotificationTimezone(
+ timezone: string | null | undefined,
+): string {
+ const candidate = timezone?.trim();
+ if (!candidate || !isRenderableTimezone(candidate)) {
+ return DEFAULT_NOTIFICATION_TIMEZONE;
+ }
+ return candidate;
+}
+
+/**
+ * Render an instant as `Sat, 6 Sep 2026 · 7:53 AM IST`.
+ *
+ * The parts are assembled by hand rather than taken from a single `format()`
+ * call because no locale produces this order: `en-US` gives the month before
+ * the day, and `en-GB` gives a lower-case meridiem and "Sept". Assembling from
+ * `formatToParts` keeps one house format across every locale-independent
+ * notification.
+ */
+export function formatNotificationDateTime(
+ value: string | Date | null | undefined,
+ timezone?: string | null,
+): string | undefined {
+ if (value === null || value === undefined || value === "") return undefined;
+ const date = value instanceof Date ? value : new Date(value);
+ if (Number.isNaN(date.getTime())) return undefined;
+
+ const zone = resolveNotificationTimezone(timezone);
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone: zone,
+ weekday: "short",
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ hour12: true,
+ timeZoneName: "short",
+ }).formatToParts(date);
+
+ const part = (type: Intl.DateTimeFormatPartTypes): string =>
+ parts.find((p) => p.type === type)?.value ?? "";
+
+ const zoneLabel = ZONE_ABBREVIATION[zone] ?? part("timeZoneName");
+ const day = `${part("weekday")}, ${part("day")} ${part("month")} ${part("year")}`;
+ const time = `${part("hour")}:${part("minute")} ${part("dayPeriod")}`;
+ return `${day} · ${time} ${zoneLabel}`.trim();
+}
+
+/**
+ * Render an amount held in the smallest currency unit as money — `₹55,679.48`
+ * rather than `5567948`. Delegates to the platform's one paise-taking
+ * formatter so the notification layer cannot drift from every other surface.
+ */
+export function formatNotificationMoney(
+ amountInSmallestUnit: number | bigint,
+ currency: string,
+): string {
+ return formatCurrencyAmount(Number(amountInSmallestUnit), currency);
+}
+
+/**
+ * The same amount with the symbol or code stripped: `55,679.48`.
+ *
+ * Four live in-app templates — `payment-success`, `payment-failed`,
+ * `refund-processed` and `refund-requested` — render `{{currency}} {{amount}}`,
+ * and the Novu plan in use cannot edit them today. A symbol-bearing `amount`
+ * would read "INR ₹55,679.48" there, so those four send the bare figure and let
+ * the template supply the ISO code it already prints (#536). Their payloads
+ * also carry `amountFormatted` with the symbol, for whichever template is
+ * written next.
+ */
+export function formatNotificationAmountBare(
+ amountInSmallestUnit: number | bigint,
+ currency: string,
+): string {
+ return formatCurrencyAmountBare(Number(amountInSmallestUnit), currency);
+}
+
+/**
+ * Who cancelled, written as a noun a sentence can use.
+ *
+ * The live `appointment-cancelled` template OPENS its sentence with this value
+ * — "{{cancelledBy}} cancelled the {{appointmentType}} session for
+ * {{planTitle}}" — so every branch is capitalised; a person's name already is.
+ *
+ * One payload reaches BOTH parties, which is why the field names the person
+ * rather than describing them: "Your consultant" would be false for the
+ * consultant reading their own copy. The role-relative strings survive only as
+ * fallbacks for a record whose name is missing (#536).
+ */
+export function cancelledByLabel(
+ cancelledBy: "consultant" | "consultee" | "system",
+ names: { consultantName?: string | null; consulteeName?: string | null },
+): string {
+ if (cancelledBy === "consultant") {
+ return names.consultantName?.trim() || "Your consultant";
+ }
+ if (cancelledBy === "consultee") {
+ return names.consulteeName?.trim() || "The participant";
+ }
+ return "The platform";
+}
+
+/**
+ * Why it was cancelled, as a clause that can follow "Reason: ".
+ *
+ * The live template ends on `Reason: {{reason}}`, so an absent value left the
+ * sentence hanging on a colon and a raw `CancellationReason` member shouted
+ * `MODERATION` at the person it had just been used against (#536). Callers pass
+ * three different things — a member of the enum, free text a user typed, or
+ * nothing at all — and all three have to come out as a readable clause.
+ *
+ * The table is exhaustive over the enum on purpose: a reason added to the
+ * schema without copy here should fail the build rather than reach an inbox as
+ * its own identifier.
+ */
+const CANCELLATION_REASON_LABEL: Record = {
+ SCHEDULE_CONFLICT: "a scheduling conflict",
+ FOUND_ALTERNATIVE: "an alternative was arranged",
+ FINANCIAL_REASONS: "financial reasons",
+ PERSONAL_EMERGENCY: "a personal emergency",
+ NO_LONGER_NEEDED: "the session was no longer needed",
+ CONSULTANT_UNAVAILABLE: "the consultant was unavailable",
+ CONSULTANT_EMERGENCY: "an emergency on the consultant's side",
+ PAYMENT_FAILED: "the payment did not go through",
+ EXPIRED: "the booking expired before it was confirmed",
+ CONSULTANT_ISSUE: "an issue on the consultant's side",
+ TECHNICAL_ISSUE: "a technical issue",
+ MODERATION: "a moderation decision on this account",
+ OTHER: "a reason the other party did not specify",
+};
+
+export function cancellationReasonLabel(
+ reason: string | null | undefined,
+): string {
+ const raw = reason?.trim();
+ if (!raw) return "No reason given";
+ const key = raw.toUpperCase().replace(/[\s-]+/g, "_");
+ // Free text a user typed is returned verbatim; only an exact enum member is
+ // rewritten, so a sentence that happens to contain a member's words survives.
+ return CANCELLATION_REASON_LABEL[key as CancellationReason] ?? raw;
+}
+
+/**
+ * Sentence-case labels for `AppointmentsType`. The templates read
+ * "Your {{payload.appointmentType}} … has been booked", so the value has to be
+ * a noun phrase that fits mid-sentence — `SUBSCRIPTION` does not.
+ *
+ * The input is normalised first because callers reach this from two directions:
+ * the Prisma enum (`SUBSCRIPTION`) at the payment and trial sites, and an
+ * already-lower-case literal (`"subscription"`) at the reminder and reschedule
+ * sites. Both must produce the same label.
+ */
+const APPOINTMENT_TYPE_LABEL: Record = {
+ CONSULTATION: "consultation",
+ SUBSCRIPTION: "subscription session",
+ WEBINAR: "webinar",
+ CLASS: "class",
+ TRIAL: "trial session",
+};
+
+export function appointmentTypeLabel(
+ appointmentType: string | null | undefined,
+): string {
+ const raw = appointmentType?.trim();
+ if (!raw) return "session";
+ const key = raw.toUpperCase().replace(/[\s-]+/g, "_");
+ return APPOINTMENT_TYPE_LABEL[key] ?? key.toLowerCase().replace(/_/g, " ");
+}
+
+/**
+ * The name to print when the plan row behind a notification is gone.
+ *
+ * Three cancellation paths sent `planTitle: "N/A"` and one reschedule path sent
+ * `"Unknown"` (#536). Those are placeholders a developer reads in a log, and
+ * they arrived in the inbox as the name of the thing the customer had just lost
+ * — "your session \"N/A\" has been cancelled". The session label is not the
+ * plan's name, but it is at least true and reads as English.
+ */
+export function planTitleOrSessionLabel(
+ planTitle: string | null | undefined,
+ appointmentType: string | null | undefined,
+): string {
+ const title = planTitle?.trim();
+ if (title) return title;
+ const label = appointmentTypeLabel(appointmentType);
+ return label.charAt(0).toUpperCase() + label.slice(1);
+}
+
+/**
+ * How long the recipient-timezone read may take before the send proceeds on the
+ * default zone.
+ *
+ * A notification must never be the thing that hangs a request. `PG_POOL_MAX=1`
+ * on Netlify means this read waits behind whatever else holds the connection,
+ * and a caller that triggers from inside a transaction would otherwise wait for
+ * a connection its own transaction is holding. Abandoning the wait degrades one
+ * notification to the platform zone; blocking would degrade the request.
+ */
+const TIMEZONE_LOOKUP_TIMEOUT_MS = 2_000;
+
+/**
+ * Load the recipients' zones in one query, keyed by user id.
+ *
+ * `triggerForMultiple` sends one payload to several subscribers, so a single
+ * rendered date would be right for at most one of them. Callers group by the
+ * value this returns and send one payload per distinct zone.
+ *
+ * Never throws and never blocks for long: on any failure every recipient falls
+ * back to the platform default, which is the behaviour this layer had before
+ * #536 anyway.
+ */
+export async function resolveRecipientTimezones(
+ userIds: string[],
+): Promise