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> { + const zones = new Map(); + const unique = Array.from(new Set(userIds)); + for (const id of unique) zones.set(id, DEFAULT_NOTIFICATION_TIMEZONE); + if (unique.length === 0) return zones; + + let timer: ReturnType | undefined; + try { + const users = await Promise.race([ + prisma.user.findMany({ + where: { id: { in: unique } }, + select: { id: true, timezone: true }, + }), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), TIMEZONE_LOOKUP_TIMEOUT_MS); + }), + ]); + if (!users) return zones; + for (const user of users) { + zones.set(user.id, resolveNotificationTimezone(user.timezone)); + } + } catch { + // Zones stay at the platform default — a notification is never worth an + // exception, and the caller has already been told nothing about the read. + } finally { + // The losing timer would otherwise keep the event loop busy for up to two + // seconds after a fast read. + if (timer) clearTimeout(timer); + } + + return zones; +} + +/** Recipients bucketed by the zone their payload must be rendered in. */ +export function groupRecipientsByTimezone( + userIds: string[], + zones: Map, +): Map { + const buckets = new Map(); + for (const id of userIds) { + const zone = zones.get(id) ?? DEFAULT_NOTIFICATION_TIMEZONE; + const bucket = buckets.get(zone); + if (bucket) bucket.push(id); + else buckets.set(zone, [id]); + } + return buckets; +} diff --git a/lib/novu/org-workflows.ts b/lib/novu/org-workflows.ts index dc504fef9..9c7c8dffb 100644 --- a/lib/novu/org-workflows.ts +++ b/lib/novu/org-workflows.ts @@ -20,24 +20,45 @@ import type { MemberRole } from "@prisma/client"; import prisma from "@/lib/prisma"; import { NOVU_WORKFLOWS, - type OrgInviteSentPayload, + type OrgDataExportReadyInput, + type OrgDataExportReadyPayload, type OrgInviteAcceptedPayload, + type OrgInviteSentInput, + type OrgInviteSentPayload, + type OrgInvoiceIssuedInput, type OrgInvoiceIssuedPayload, - type OrgInvoicePaidPayload, + type OrgInvoiceOverdueInput, type OrgInvoiceOverduePayload, - type OrgMemberOverageTimedOutPayload, + type OrgInvoicePaidInput, + type OrgInvoicePaidPayload, + type OrgLicenseRenewalUpcomingInput, type OrgLicenseRenewalUpcomingPayload, - type OrgDataExportReadyPayload, - type OrgWalletTopupConfirmedPayload, - type OrgWalletLowPayload, + type OrgMemberOverageTimedOutInput, + type OrgMemberOverageTimedOutPayload, + type OrgPayoutCompletedInput, type OrgPayoutCompletedPayload, - type OrgProgramExhaustedPayload, + type OrgPayoutFailedInput, + type OrgPayoutFailedPayload, type OrgProgramCapNearPayload, + type OrgProgramExhaustedPayload, + type OrgProgramOverageDueInput, type OrgProgramOverageDuePayload, - type OrgSsoProviderDeletedPayload, + type OrgSsoCertExpiringInput, type OrgSsoCertExpiringPayload, + type OrgSsoProviderDeletedPayload, + type OrgWalletLowInput, + type OrgWalletLowPayload, + type OrgWalletTopupConfirmedInput, + type OrgWalletTopupConfirmedPayload, } from "./workflows"; import { getNovuClient, isNovuConfigured } from "./client"; +import { + DEFAULT_NOTIFICATION_TIMEZONE, + formatNotificationDateTime, + formatNotificationMoney, + groupRecipientsByTimezone, + resolveRecipientTimezones, +} from "./humanize"; // ============================================================================ // Internal trigger helpers (non-throwing, schema-typed) @@ -55,7 +76,10 @@ async function triggerOne( const novu = getNovuClient(); await novu.trigger({ workflowId, to: subscriberId, payload }); } catch (err) { - Sentry.captureException(err instanceof Error ? err : new Error(String(err)), { tags: { subsystem: "novu" } }); + Sentry.captureException( + err instanceof Error ? err : new Error(String(err)), + { tags: { subsystem: "novu" } }, + ); console.error(`[Novu/org] Failed to trigger ${workflowId}:`, err); } } @@ -71,11 +95,39 @@ async function triggerMany( const novu = getNovuClient(); await novu.trigger({ workflowId, to: subscriberIds, payload }); } catch (err) { - Sentry.captureException(err instanceof Error ? err : new Error(String(err)), { tags: { subsystem: "novu" } }); + Sentry.captureException( + err instanceof Error ? err : new Error(String(err)), + { tags: { subsystem: "novu" } }, + ); console.error(`[Novu/org] Failed to trigger ${workflowId} batch:`, err); } } +/** + * #536 — a roster spans people, and people span timezones, so a payload with a + * rendered date can only be built once the recipient's zone is known. This + * sends one payload per distinct zone in the roster; see the sibling helper in + * `lib/novu/service.ts` for the reasoning in full. + */ +async function triggerManyZoned( + workflowId: string, + subscriberIds: string[], + build: (timezone: string) => T, +): Promise { + if (subscriberIds.length === 0) return; + if (!isNovuConfigured()) return; + const zones = await resolveRecipientTimezones(subscriberIds); + for (const [timezone, recipients] of groupRecipientsByTimezone( + subscriberIds, + zones, + )) { + await triggerMany(workflowId, recipients, build(timezone)); + } +} + +/** Settlement is INR-only, so an org payload without a currency is INR. */ +const ORG_DEFAULT_CURRENCY = "INR"; + // ============================================================================ // Roster resolvers — map an orgId + role-set to active-member user ids // ============================================================================ @@ -121,9 +173,20 @@ const OWNER_ONLY: MemberRole[] = ["OWNER"]; */ export async function notifyOrgInviteSent( inviteeEmail: string, - payload: OrgInviteSentPayload, + payload: OrgInviteSentInput, ): Promise { - return triggerOne(NOVU_WORKFLOWS.ORG_INVITE_SENT, inviteeEmail, payload); + // The invitee has no account yet, so there is no recorded zone to render in; + // the platform default is used and the rendered string names it (#536). + const wire: OrgInviteSentPayload = { + ...payload, + expiresAt: + formatNotificationDateTime( + payload.expiresAt, + DEFAULT_NOTIFICATION_TIMEZONE, + ) ?? payload.expiresAt, + expiresAtIso: payload.expiresAt, + }; + return triggerOne(NOVU_WORKFLOWS.ORG_INVITE_SENT, inviteeEmail, wire); } /** @@ -145,10 +208,21 @@ export async function notifyOrgInviteAccepted( */ export async function notifyOrgInvoiceIssued( orgId: string, - payload: OrgInvoiceIssuedPayload, + payload: OrgInvoiceIssuedInput, ): Promise { const owners = await rosterForOrg(orgId, OWNER_ONLY); - return triggerMany(NOVU_WORKFLOWS.ORG_INVOICE_ISSUED, owners, payload); + return triggerManyZoned( + NOVU_WORKFLOWS.ORG_INVOICE_ISSUED, + owners, + (timezone): OrgInvoiceIssuedPayload => ({ + ...payload, + total: formatNotificationMoney(payload.totalPaise, payload.currency), + dueDate: + formatNotificationDateTime(payload.dueDate, timezone) ?? + payload.dueDate, + dueDateIso: payload.dueDate, + }), + ); } /** @@ -157,10 +231,20 @@ export async function notifyOrgInvoiceIssued( */ export async function notifyOrgInvoicePaid( orgId: string, - payload: OrgInvoicePaidPayload, + payload: OrgInvoicePaidInput, ): Promise { const owners = await rosterForOrg(orgId, OWNER_ONLY); - return triggerMany(NOVU_WORKFLOWS.ORG_INVOICE_PAID, owners, payload); + return triggerManyZoned( + NOVU_WORKFLOWS.ORG_INVOICE_PAID, + owners, + (timezone): OrgInvoicePaidPayload => ({ + ...payload, + total: formatNotificationMoney(payload.totalPaise, payload.currency), + paidAt: + formatNotificationDateTime(payload.paidAt, timezone) ?? payload.paidAt, + paidAtIso: payload.paidAt, + }), + ); } /** @@ -171,10 +255,14 @@ export async function notifyOrgInvoicePaid( */ export async function notifyOrgInvoiceOverdue( orgId: string, - payload: OrgInvoiceOverduePayload, + payload: OrgInvoiceOverdueInput, ): Promise { const recipients = await rosterForOrg(orgId, VISIBILITY_ROLES); - return triggerMany(NOVU_WORKFLOWS.ORG_INVOICE_OVERDUE, recipients, payload); + const wire: OrgInvoiceOverduePayload = { + ...payload, + total: formatNotificationMoney(payload.totalPaise, payload.currency), + }; + return triggerMany(NOVU_WORKFLOWS.ORG_INVOICE_OVERDUE, recipients, wire); } /** @@ -185,12 +273,16 @@ export async function notifyOrgInvoiceOverdue( */ export async function notifyMemberOverageTimedOut( memberUserId: string, - payload: OrgMemberOverageTimedOutPayload, + payload: OrgMemberOverageTimedOutInput, ): Promise { + const wire: OrgMemberOverageTimedOutPayload = { + ...payload, + amount: formatNotificationMoney(payload.amountPaise, payload.currency), + }; return triggerMany( NOVU_WORKFLOWS.ORG_MEMBER_OVERAGE_TIMED_OUT, [memberUserId], - payload, + wire, ); } @@ -202,13 +294,25 @@ export async function notifyMemberOverageTimedOut( */ export async function notifyOrgLicenseRenewalUpcoming( orgId: string, - payload: OrgLicenseRenewalUpcomingPayload, + payload: OrgLicenseRenewalUpcomingInput, ): Promise { const owners = await rosterForOrg(orgId, OWNER_ONLY); - return triggerMany( + return triggerManyZoned( NOVU_WORKFLOWS.ORG_LICENSE_RENEWAL_UPCOMING, owners, - payload, + (timezone): OrgLicenseRenewalUpcomingPayload => ({ + ...payload, + cycle: payload.cycle.toLowerCase(), + cycleCode: payload.cycle, + renewalDate: + formatNotificationDateTime(payload.renewalDate, timezone) ?? + payload.renewalDate, + renewalDateIso: payload.renewalDate, + expectedTotal: formatNotificationMoney( + payload.expectedTotalPaise, + payload.currency, + ), + }), ); } @@ -221,10 +325,20 @@ export async function notifyOrgLicenseRenewalUpcoming( */ export async function notifyOrgDataExportReady( orgId: string, - payload: OrgDataExportReadyPayload, + payload: OrgDataExportReadyInput, ): Promise { const owners = await rosterForOrg(orgId, OWNER_ONLY); - return triggerMany(NOVU_WORKFLOWS.ORG_DATA_EXPORT_READY, owners, payload); + return triggerManyZoned( + NOVU_WORKFLOWS.ORG_DATA_EXPORT_READY, + owners, + (timezone): OrgDataExportReadyPayload => ({ + ...payload, + expiresAt: + formatNotificationDateTime(payload.expiresAt, timezone) ?? + payload.expiresAt, + expiresAtIso: payload.expiresAt, + }), + ); } /** @@ -235,14 +349,18 @@ export async function notifyOrgDataExportReady( */ export async function notifyOrgWalletTopupConfirmed( orgId: string, - payload: OrgWalletTopupConfirmedPayload, + payload: OrgWalletTopupConfirmedInput, ): Promise { const owners = await rosterForOrg(orgId, OWNER_ONLY); - return triggerMany( - NOVU_WORKFLOWS.ORG_WALLET_TOPUP_CONFIRMED, - owners, - payload, - ); + const wire: OrgWalletTopupConfirmedPayload = { + ...payload, + amount: formatNotificationMoney(payload.amountPaise, payload.currency), + newBalance: formatNotificationMoney( + payload.newBalancePaise, + payload.currency, + ), + }; + return triggerMany(NOVU_WORKFLOWS.ORG_WALLET_TOPUP_CONFIRMED, owners, wire); } /** @@ -253,10 +371,15 @@ export async function notifyOrgWalletTopupConfirmed( */ export async function notifyOrgWalletLow( orgId: string, - payload: OrgWalletLowPayload, + payload: OrgWalletLowInput, ): Promise { const recipients = await rosterForOrg(orgId, VISIBILITY_ROLES); - return triggerMany(NOVU_WORKFLOWS.ORG_WALLET_LOW, recipients, payload); + const wire: OrgWalletLowPayload = { + ...payload, + balance: formatNotificationMoney(payload.balancePaise, payload.currency), + minimum: formatNotificationMoney(payload.minimumPaise, payload.currency), + }; + return triggerMany(NOVU_WORKFLOWS.ORG_WALLET_LOW, recipients, wire); } /** @@ -266,10 +389,14 @@ export async function notifyOrgWalletLow( */ export async function notifyOrgPayoutCompleted( orgId: string, - payload: OrgPayoutCompletedPayload, + payload: OrgPayoutCompletedInput, ): Promise { const recipients = await rosterForOrg(orgId, VISIBILITY_ROLES); - return triggerMany(NOVU_WORKFLOWS.ORG_PAYOUT_COMPLETED, recipients, payload); + const wire: OrgPayoutCompletedPayload = { + ...payload, + amount: formatNotificationMoney(payload.amountPaise, payload.currency), + }; + return triggerMany(NOVU_WORKFLOWS.ORG_PAYOUT_COMPLETED, recipients, wire); } /** @@ -281,14 +408,18 @@ export async function notifyOrgPayoutCompleted( */ export async function notifyOrgPayoutFailed( orgId: string, - payload: import("./workflows").OrgPayoutFailedPayload, + payload: OrgPayoutFailedInput, ): Promise { const recipients = await rosterForOrg(orgId, VISIBILITY_ROLES); const workflowId = payload.kind === "REVERSED" ? NOVU_WORKFLOWS.ORG_PAYOUT_REVERSED : NOVU_WORKFLOWS.ORG_PAYOUT_FAILED; - return triggerMany(workflowId, recipients, payload); + const wire: OrgPayoutFailedPayload = { + ...payload, + amount: formatNotificationMoney(payload.amountPaise, payload.currency), + }; + return triggerMany(workflowId, recipients, wire); } /** @@ -304,11 +435,7 @@ export async function notifyOrgProgramExhausted( ): Promise { const operators = await rosterForOrg(orgId, OPERATOR_ROLES); const recipients = Array.from(new Set([assigneeUserId, ...operators])); - return triggerMany( - NOVU_WORKFLOWS.ORG_PROGRAM_EXHAUSTED, - recipients, - payload, - ); + return triggerMany(NOVU_WORKFLOWS.ORG_PROGRAM_EXHAUSTED, recipients, payload); } /** @@ -325,11 +452,7 @@ export async function notifyOrgProgramCapNear( ): Promise { const operators = await rosterForOrg(orgId, OPERATOR_ROLES); const recipients = Array.from(new Set([assigneeUserId, ...operators])); - return triggerMany( - NOVU_WORKFLOWS.ORG_PROGRAM_CAP_NEAR, - recipients, - payload, - ); + return triggerMany(NOVU_WORKFLOWS.ORG_PROGRAM_CAP_NEAR, recipients, payload); } /** @@ -339,12 +462,16 @@ export async function notifyOrgProgramCapNear( */ export async function notifyOrgProgramOverageDue( memberUserId: string, - payload: OrgProgramOverageDuePayload, + payload: OrgProgramOverageDueInput, ): Promise { + const wire: OrgProgramOverageDuePayload = { + ...payload, + amount: formatNotificationMoney(payload.amountPaise, ORG_DEFAULT_CURRENCY), + }; return triggerMany( NOVU_WORKFLOWS.ORG_PROGRAM_OVERAGE_DUE, [memberUserId], - payload, + wire, ); } @@ -358,11 +485,7 @@ export async function notifyOrgSsoProviderDeleted( payload: OrgSsoProviderDeletedPayload, ): Promise { const owners = await rosterForOrg(orgId, OWNER_ONLY); - return triggerMany( - NOVU_WORKFLOWS.ORG_SSO_PROVIDER_DELETED, - owners, - payload, - ); + return triggerMany(NOVU_WORKFLOWS.ORG_SSO_PROVIDER_DELETED, owners, payload); } /** @@ -372,8 +495,18 @@ export async function notifyOrgSsoProviderDeleted( */ export async function notifyOrgSsoCertExpiring( orgId: string, - payload: OrgSsoCertExpiringPayload, + payload: OrgSsoCertExpiringInput, ): Promise { const owners = await rosterForOrg(orgId, OWNER_ONLY); - return triggerMany(NOVU_WORKFLOWS.ORG_SSO_CERT_EXPIRING, owners, payload); + return triggerManyZoned( + NOVU_WORKFLOWS.ORG_SSO_CERT_EXPIRING, + owners, + (timezone): OrgSsoCertExpiringPayload => ({ + ...payload, + notAfter: + formatNotificationDateTime(payload.notAfter, timezone) ?? + payload.notAfter, + notAfterIso: payload.notAfter, + }), + ); } diff --git a/lib/novu/service.ts b/lib/novu/service.ts index 96d2de9c3..d449bef27 100644 --- a/lib/novu/service.ts +++ b/lib/novu/service.ts @@ -9,41 +9,70 @@ import * as Sentry from "@sentry/nextjs"; import { getNovuClient, isNovuConfigured } from "./client"; import { NOVU_WORKFLOWS, - type AppointmentPayload, - type AppointmentPartiallyScheduledPayload, + type AccountBannedPayload, + type AccountSuspendedInput, + type AccountSuspendedPayload, + type AnnouncementPayload, + type AppointmentCancelledInput, type AppointmentCancelledPayload, + type AppointmentPartiallyScheduledInput, + type AppointmentPartiallyScheduledPayload, + type AppointmentPayload, + type AppointmentPayloadInput, + type AppointmentRescheduledInput, type AppointmentRescheduledPayload, - type PaymentSuccessPayload, - type PaymentFailedPayload, - type RefundPayload, - type SupportTicketPayload, - type FeedbackPayload, - type ReviewPayload, - type TrialSessionPayload, - type SubscriptionPayload, + type BookingRequestInput, type BookingRequestPayload, - type VerificationPayload, + type CollaboratorAcceptedPayload, + type CollaboratorInvitedPayload, + type CollaboratorRemovedPayload, + type ConsultantApplicationPayload, + type DisputeInput, + type DisputePayload, + type DocumentReviewedPayload, + type DocumentUploadedPayload, + type FeedbackPayload, + type MaintenanceInput, + type MaintenancePayload, type ModerationWarningPayload, - type AccountSuspendedPayload, - type AccountBannedPayload, + type OrgExpertRemovedPayload, + type PaymentFailedInput, + type PaymentFailedPayload, + type PaymentSuccessInput, + type PaymentSuccessPayload, + type PayoutInput, type PayoutPayload, - type AnnouncementPayload, - type DisputePayload, - type RecordingPayload, - type RecordingFailedPayload, + type RecordingExpiringInput, type RecordingExpiringPayload, - type DocumentUploadedPayload, - type DocumentReviewedPayload, - type ConsultantApplicationPayload, - type ReferralBonusPayload, + type RecordingFailedPayload, + type RecordingPayload, + type RefereeWelcomeBonusInput, type RefereeWelcomeBonusPayload, + type ReferralBonusInput, + type ReferralBonusPayload, + type ReferralCreditsAppliedInput, type ReferralCreditsAppliedPayload, - type CollaboratorInvitedPayload, - type CollaboratorAcceptedPayload, - type CollaboratorRemovedPayload, - type MaintenancePayload, - type OrgExpertRemovedPayload, + type RefundInput, + type RefundPayload, + type ReviewPayload, + type RescheduleOutcomeFields, + type SubscriptionPayload, + type SupportTicketPayload, + type TrialSessionInput, + type TrialSessionPayload, + type VerificationPayload, } from "./workflows"; +import { + appointmentTypeLabel, + cancellationReasonLabel, + cancelledByLabel, + DEFAULT_NOTIFICATION_TIMEZONE, + formatNotificationAmountBare, + formatNotificationDateTime, + formatNotificationMoney, + groupRecipientsByTimezone, + resolveRecipientTimezones, +} from "./humanize"; // ============================================================================ // Core trigger function @@ -220,18 +249,200 @@ async function triggerBroadcastWorkflow( } } +// ============================================================================ +// Customer-ready payloads (#536) +// ============================================================================ + +/** + * Trigger once per distinct recipient timezone. + * + * `triggerForMultiple` sends ONE payload to a list of subscribers, so a + * rendered date inside it can only be correct for whichever recipient happens + * to share the zone it was rendered in. Every other recipient reads a time that + * is not theirs. Splitting on the zone is cheaper than it looks: both parties + * to a booking are usually in the same zone, so this is one trigger in the + * common case and two in the cross-border one. + * + * The zones are loaded in a single query; see `resolveRecipientTimezones` for + * why that read is bounded and never throws. + */ +async function triggerForMultipleZoned( + workflowId: string, + userIds: string[], + build: (timezone: string) => NovuPayload, + dedupeKey?: string, +): Promise { + if (!isNovuConfigured()) { + reportNotConfigured(workflowId); + return userIds.map(() => ({ + success: false, + error: "Novu not configured" as const, + })); + } + if (userIds.length === 0) return []; + + const zones = await resolveRecipientTimezones(userIds); + const results: TriggerResult[] = []; + for (const [timezone, recipients] of groupRecipientsByTimezone( + userIds, + zones, + )) { + results.push( + ...(await triggerForMultiple( + workflowId, + recipients, + build(timezone), + dedupeKey, + )), + ); + } + return results; +} + +/** Single-recipient sibling of {@link triggerForMultipleZoned}. */ +async function triggerWorkflowZoned( + workflowId: string, + subscriberId: string, + build: (timezone: string) => NovuPayload, + dedupeKey?: string, +): Promise { + if (!isNovuConfigured()) { + reportNotConfigured(workflowId); + return { success: false, error: "Novu not configured" }; + } + const zones = await resolveRecipientTimezones([subscriberId]); + const timezone = zones.get(subscriberId) ?? DEFAULT_NOTIFICATION_TIMEZONE; + return triggerWorkflow(workflowId, subscriberId, build(timezone), dedupeKey); +} + +/** Raw enum in, sentence label plus the original out. */ +function appointmentWire( + input: AppointmentPayloadInput, + timezone: string, +): AppointmentPayload { + // The raw instant is lifted out BEFORE the spread: the templates gate on + // `{{#if payload.dateTime}}`, which any non-empty string satisfies, so a + // value the formatter rejects must not ride through under the display key. + // Omitted rather than blanked for the same reason. + const { dateTime: rawDateTime, ...rest } = input; + const dateTime = formatNotificationDateTime(rawDateTime, timezone); + return { + ...rest, + appointmentType: appointmentTypeLabel(input.appointmentType), + appointmentTypeCode: input.appointmentType, + ...(dateTime ? { dateTime, dateTimeIso: rawDateTime } : {}), + }; +} + +function partiallyScheduledWire( + input: AppointmentPartiallyScheduledInput, + timezone: string, +): AppointmentPartiallyScheduledPayload { + return { + ...appointmentWire(input, timezone), + placedSessions: input.placedSessions, + requiredSessions: input.requiredSessions, + unplacedSessions: input.unplacedSessions, + }; +} + +function cancelledWire( + input: AppointmentCancelledInput, + timezone: string, +): AppointmentCancelledPayload { + return { + ...appointmentWire(input, timezone), + reason: cancellationReasonLabel(input.reason), + cancelledBy: cancelledByLabel(input.cancelledBy, input), + cancelledByRole: input.cancelledBy, + }; +} + +/** + * #1085 — what fills `newDateTime` when the outcome has no destination time. + * + * The `appointment-rescheduled` template renders "from X to Y" unconditionally, + * and three of the five outcomes have no Y, which is how the inbox came to show + * "rescheduled the CONSULTATION for Basic Consultation from  to". A + * phrase completes the sentence in every case. The MOVED and PROPOSED entries + * are reachable only if a stored instant fails to parse, which would otherwise + * reintroduce the blank. + */ +const RESCHEDULE_AWAITING_TIME: Record< + RescheduleOutcomeFields["outcome"], + string +> = { + MOVED: "a new time your consultant will confirm", + PROPOSED: "a new time your consultant will confirm", + RELEASED: "a new time your consultant will confirm", + DECLINED: "the time it was already booked for", + WITHDRAWN: "the time it was already booked for", +}; + +function rescheduledWire( + input: AppointmentRescheduledInput, + timezone: string, +): AppointmentRescheduledPayload { + // Both raw instants leave the input before it reaches `appointmentWire`, so + // neither can survive that spread unformatted. + const { oldDateTime: rawOld, newDateTime: rawNew, ...base } = input; + const oldDateTime = formatNotificationDateTime(rawOld, timezone); + const hasDestination = + input.outcome === "MOVED" || input.outcome === "PROPOSED"; + const newDateTimeIso = hasDestination ? rawNew : undefined; + const newDateTime = formatNotificationDateTime(newDateTimeIso, timezone); + + return { + ...appointmentWire(base, timezone), + outcome: input.outcome, + ...(oldDateTime ? { oldDateTime, oldDateTimeIso: rawOld } : {}), + newDateTime: newDateTime ?? RESCHEDULE_AWAITING_TIME[input.outcome], + ...(newDateTime ? { newDateTimeIso } : {}), + }; +} + +function trialWire( + input: TrialSessionInput, + timezone: string, +): TrialSessionPayload { + const { dateTime: rawDateTime, ...rest } = input; + const dateTime = formatNotificationDateTime(rawDateTime, timezone); + return { + ...rest, + status: input.status.toLowerCase().replace(/_/g, " "), + statusCode: input.status, + ...(dateTime ? { dateTime, dateTimeIso: rawDateTime } : {}), + }; +} + +function bookingRequestWire( + input: BookingRequestInput, + timezone: string, +): BookingRequestPayload { + const { requestedDateTime: rawRequested, ...rest } = input; + const requestedDateTime = formatNotificationDateTime(rawRequested, timezone); + return { + ...rest, + appointmentType: appointmentTypeLabel(input.appointmentType), + appointmentTypeCode: input.appointmentType, + ...(requestedDateTime + ? { requestedDateTime, requestedDateTimeIso: rawRequested } + : {}), + }; +} + // ============================================================================ // Appointment Notifications // ============================================================================ export async function notifyAppointmentBooked( userIds: string[], - payload: AppointmentPayload, + payload: AppointmentPayloadInput, ) { - return triggerForMultiple( + return triggerForMultipleZoned( NOVU_WORKFLOWS.APPOINTMENT_BOOKED, userIds, - payload, + (timezone) => appointmentWire(payload, timezone), ); } @@ -242,45 +453,45 @@ export async function notifyAppointmentBooked( */ export async function notifyAppointmentPartiallyScheduled( userIds: string[], - payload: AppointmentPartiallyScheduledPayload, + payload: AppointmentPartiallyScheduledInput, ) { - return triggerForMultiple( + return triggerForMultipleZoned( NOVU_WORKFLOWS.APPOINTMENT_PARTIALLY_SCHEDULED, userIds, - payload, + (timezone) => partiallyScheduledWire(payload, timezone), ); } export async function notifyAppointmentCancelled( userIds: string[], - payload: AppointmentCancelledPayload, + payload: AppointmentCancelledInput, ) { - return triggerForMultiple( + return triggerForMultipleZoned( NOVU_WORKFLOWS.APPOINTMENT_CANCELLED, userIds, - payload, + (timezone) => cancelledWire(payload, timezone), ); } export async function notifyAppointmentRescheduled( userIds: string[], - payload: AppointmentRescheduledPayload, + payload: AppointmentRescheduledInput, ) { - return triggerForMultiple( + return triggerForMultipleZoned( NOVU_WORKFLOWS.APPOINTMENT_RESCHEDULED, userIds, - payload, + (timezone) => rescheduledWire(payload, timezone), ); } export async function notifyAppointmentCompleted( userIds: string[], - payload: AppointmentPayload, + payload: AppointmentPayloadInput, ) { - return triggerForMultiple( + return triggerForMultipleZoned( NOVU_WORKFLOWS.APPOINTMENT_COMPLETED, userIds, - payload, + (timezone) => appointmentWire(payload, timezone), ); } @@ -288,13 +499,13 @@ export async function notifyAppointmentCompleted( // swallowed as a duplicate of the 24h one — their payloads are identical. export async function notifyAppointmentReminder( userIds: string[], - payload: AppointmentPayload, + payload: AppointmentPayloadInput, dedupeKey?: string, ) { - return triggerForMultiple( + return triggerForMultipleZoned( NOVU_WORKFLOWS.APPOINTMENT_REMINDER, userIds, - payload, + (timezone) => appointmentWire(payload, timezone), dedupeKey, ); } @@ -305,42 +516,85 @@ export async function notifyAppointmentReminder( export async function notifyPaymentSuccess( userId: string, - payload: PaymentSuccessPayload, + payload: PaymentSuccessInput, ) { - return triggerWorkflow(NOVU_WORKFLOWS.PAYMENT_SUCCESS, userId, payload); + const wire: PaymentSuccessPayload = { + ...payload, + amount: formatNotificationAmountBare(payload.amount, payload.currency), + amountFormatted: formatNotificationMoney(payload.amount, payload.currency), + amountPaise: payload.amount, + appointmentType: appointmentTypeLabel(payload.appointmentType), + appointmentTypeCode: payload.appointmentType, + }; + return triggerWorkflow(NOVU_WORKFLOWS.PAYMENT_SUCCESS, userId, wire); } export async function notifyPaymentFailed( userId: string, - payload: PaymentFailedPayload, + payload: PaymentFailedInput, ) { - return triggerWorkflow(NOVU_WORKFLOWS.PAYMENT_FAILED, userId, payload); + const wire: PaymentFailedPayload = { + ...payload, + amount: formatNotificationAmountBare(payload.amount, payload.currency), + amountFormatted: formatNotificationMoney(payload.amount, payload.currency), + amountPaise: payload.amount, + appointmentType: appointmentTypeLabel(payload.appointmentType), + appointmentTypeCode: payload.appointmentType, + }; + return triggerWorkflow(NOVU_WORKFLOWS.PAYMENT_FAILED, userId, wire); +} + +/** + * Paise become money before the payer reads them. `amount` is symbol-free + * because `refund-processed` and `refund-requested` print `{{currency}}` + * themselves; `refund-failed` shares this payload type and so shares its shape, + * which is the point — one type cannot mean two things depending on which + * workflow happens to carry it. + */ +function refundWire(payload: RefundInput): RefundPayload { + return { + ...payload, + amount: formatNotificationAmountBare(payload.amount, payload.currency), + amountFormatted: formatNotificationMoney(payload.amount, payload.currency), + amountPaise: payload.amount, + ...(payload.appointmentType + ? { + appointmentType: appointmentTypeLabel(payload.appointmentType), + appointmentTypeCode: payload.appointmentType, + } + : {}), + }; } export async function notifyRefundProcessed( userId: string, - payload: RefundPayload, + payload: RefundInput, ) { - return triggerWorkflow(NOVU_WORKFLOWS.REFUND_PROCESSED, userId, payload); + return triggerWorkflow( + NOVU_WORKFLOWS.REFUND_PROCESSED, + userId, + refundWire(payload), + ); } // #779 §A — the gateway rejected a refund (Refund.status = FAILED). Notifies // the payer; `reason` on the payload carries the gateway failure reason. -export async function notifyRefundFailed( - userId: string, - payload: RefundPayload, -) { - return triggerWorkflow(NOVU_WORKFLOWS.REFUND_FAILED, userId, payload); +export async function notifyRefundFailed(userId: string, payload: RefundInput) { + return triggerWorkflow( + NOVU_WORKFLOWS.REFUND_FAILED, + userId, + refundWire(payload), + ); } export async function notifyRefundRequested( adminUserIds: string[], - payload: RefundPayload, + payload: RefundInput, ) { return triggerForMultiple( NOVU_WORKFLOWS.REFUND_REQUESTED, adminUserIds, - payload, + refundWire(payload), ); } @@ -427,45 +681,45 @@ export async function notifyNewReview( export async function notifyTrialSessionRequested( consultantUserId: string, - payload: TrialSessionPayload, + payload: TrialSessionInput, ) { - return triggerWorkflow( + return triggerWorkflowZoned( NOVU_WORKFLOWS.TRIAL_SESSION_REQUESTED, consultantUserId, - payload, + (timezone) => trialWire(payload, timezone), ); } export async function notifyTrialSessionScheduled( consulteeUserId: string, - payload: TrialSessionPayload, + payload: TrialSessionInput, ) { - return triggerWorkflow( + return triggerWorkflowZoned( NOVU_WORKFLOWS.TRIAL_SESSION_SCHEDULED, consulteeUserId, - payload, + (timezone) => trialWire(payload, timezone), ); } export async function notifyTrialSessionCompleted( userIds: string[], - payload: TrialSessionPayload, + payload: TrialSessionInput, ) { - return triggerForMultiple( + return triggerForMultipleZoned( NOVU_WORKFLOWS.TRIAL_SESSION_COMPLETED, userIds, - payload, + (timezone) => trialWire(payload, timezone), ); } export async function notifyTrialSessionCancelled( userIds: string[], - payload: TrialSessionPayload, + payload: TrialSessionInput, ) { - return triggerForMultiple( + return triggerForMultipleZoned( NOVU_WORKFLOWS.TRIAL_SESSION_CANCELLED, userIds, - payload, + (timezone) => trialWire(payload, timezone), ); } @@ -504,12 +758,12 @@ export async function notifySubscriptionRenewed( export async function notifyNewBookingRequest( consultantUserId: string, - payload: BookingRequestPayload, + payload: BookingRequestInput, ) { - return triggerWorkflow( + return triggerWorkflowZoned( NOVU_WORKFLOWS.NEW_BOOKING_REQUEST, consultantUserId, - payload, + (timezone) => bookingRequestWire(payload, timezone), ); } @@ -539,12 +793,23 @@ export async function notifyModerationWarning( export async function notifyAccountSuspended( targetUserId: string, - payload: AccountSuspendedPayload, + payload: AccountSuspendedInput, ) { - return triggerWorkflow( + return triggerWorkflowZoned( NOVU_WORKFLOWS.ACCOUNT_SUSPENDED, targetUserId, - payload, + (timezone): AccountSuspendedPayload => { + // An indefinite suspension has no `banExpires`, and the moderation + // caller sends "" for it — the sentence reads "until {{suspendedUntil}}", + // so the blank needs words, and the ISO twin is only sent for a real date. + const { suspendedUntil: raw, ...rest } = payload; + const suspendedUntil = formatNotificationDateTime(raw, timezone); + return { + ...rest, + suspendedUntil: suspendedUntil ?? "further notice", + ...(suspendedUntil ? { suspendedUntilIso: raw } : {}), + }; + }, ); } @@ -557,12 +822,17 @@ export async function notifyAccountBanned( export async function notifyPayoutProcessed( consultantUserId: string, - payload: PayoutPayload, + payload: PayoutInput, ) { + const wire: PayoutPayload = { + ...payload, + amount: formatNotificationMoney(payload.amount, payload.currency), + amountPaise: payload.amount, + }; return triggerWorkflow( NOVU_WORKFLOWS.PAYOUT_PROCESSED, consultantUserId, - payload, + wire, ); } @@ -605,18 +875,34 @@ export async function notifyNewConsultantApplication( // Dispute Notifications // ============================================================================ +function disputeWire(payload: DisputeInput): DisputePayload { + return { + ...payload, + amount: formatNotificationMoney(payload.amount, payload.currency), + amountPaise: payload.amount, + }; +} + export async function notifyDisputeCreated( userIds: string[], - payload: DisputePayload, + payload: DisputeInput, ) { - return triggerForMultiple(NOVU_WORKFLOWS.DISPUTE_CREATED, userIds, payload); + return triggerForMultiple( + NOVU_WORKFLOWS.DISPUTE_CREATED, + userIds, + disputeWire(payload), + ); } export async function notifyDisputeResolved( userIds: string[], - payload: DisputePayload, + payload: DisputeInput, ) { - return triggerForMultiple(NOVU_WORKFLOWS.DISPUTE_RESOLVED, userIds, payload); + return triggerForMultiple( + NOVU_WORKFLOWS.DISPUTE_RESOLVED, + userIds, + disputeWire(payload), + ); } // ============================================================================ @@ -625,13 +911,14 @@ export async function notifyDisputeResolved( export async function notifyRecordingAvailable( userIds: string[], - payload: RecordingPayload, + payload: Omit, ) { - return triggerForMultiple( - NOVU_WORKFLOWS.RECORDING_AVAILABLE, - userIds, - payload, - ); + const wire: RecordingPayload = { + ...payload, + appointmentType: appointmentTypeLabel(payload.appointmentType), + appointmentTypeCode: payload.appointmentType, + }; + return triggerForMultiple(NOVU_WORKFLOWS.RECORDING_AVAILABLE, userIds, wire); } export async function notifyRecordingFailed( @@ -648,12 +935,20 @@ export async function notifyRecordingFailed( // STR-3 — warn a consultant their STREAM_ONLY recording(s) expire soon. export async function notifyRecordingExpiring( consultantUserId: string, - payload: RecordingExpiringPayload, + payload: RecordingExpiringInput, ) { - return triggerWorkflow( + return triggerWorkflowZoned( NOVU_WORKFLOWS.RECORDING_EXPIRING, consultantUserId, - payload, + (timezone): RecordingExpiringPayload => { + const { expiresAt: raw, ...rest } = payload; + const expiresAt = formatNotificationDateTime(raw, timezone); + return { + ...rest, + expiresAt: expiresAt ?? "the date shown in your dashboard", + ...(expiresAt ? { expiresAtIso: raw } : {}), + }; + }, ); } @@ -691,35 +986,53 @@ export async function notifyDocumentReviewed( export async function notifyReferralBonusEarned( referrerUserId: string, - payload: ReferralBonusPayload, + payload: ReferralBonusInput, ) { + const wire: ReferralBonusPayload = { + ...payload, + bonusAmount: formatNotificationMoney(payload.bonusAmount, payload.currency), + bonusAmountPaise: payload.bonusAmount, + }; return triggerWorkflow( NOVU_WORKFLOWS.REFERRAL_BONUS_EARNED, referrerUserId, - payload, + wire, ); } export async function notifyRefereeWelcomeBonus( refereeUserId: string, - payload: RefereeWelcomeBonusPayload, + payload: RefereeWelcomeBonusInput, ) { + const wire: RefereeWelcomeBonusPayload = { + ...payload, + bonusAmount: formatNotificationMoney(payload.bonusAmount, payload.currency), + bonusAmountPaise: payload.bonusAmount, + }; return triggerWorkflow( NOVU_WORKFLOWS.REFEREE_WELCOME_BONUS, refereeUserId, - payload, + wire, ); } export async function notifyReferralCreditsApplied( userId: string, - payload: ReferralCreditsAppliedPayload, + payload: ReferralCreditsAppliedInput, ) { - return triggerWorkflow( - NOVU_WORKFLOWS.REFERRAL_CREDITS_APPLIED, - userId, - payload, - ); + const wire: ReferralCreditsAppliedPayload = { + ...payload, + creditsUsed: formatNotificationMoney(payload.creditsUsed, payload.currency), + creditsUsedPaise: payload.creditsUsed, + remainingCredits: formatNotificationMoney( + payload.remainingCredits, + payload.currency, + ), + remainingCreditsPaise: payload.remainingCredits, + appointmentType: appointmentTypeLabel(payload.appointmentType), + appointmentTypeCode: payload.appointmentType, + }; + return triggerWorkflow(NOVU_WORKFLOWS.REFERRAL_CREDITS_APPLIED, userId, wire); } // ============================================================================ @@ -761,17 +1074,40 @@ export async function notifyCollaboratorRemoved( // Maintenance notifications (broadcast to all users) -export async function notifyMaintenanceScheduled(payload: MaintenancePayload) { +/** + * A broadcast has no recipient list to load zones from, so the ETA renders in + * the platform default zone — which the rendered string names, so nobody has to + * guess which zone they are reading (#536). + */ +function maintenanceWire(payload: MaintenanceInput): MaintenancePayload { + const { estimatedEnd: raw, ...rest } = payload; + const estimatedEnd = formatNotificationDateTime( + raw, + DEFAULT_NOTIFICATION_TIMEZONE, + ); + return { + ...rest, + ...(estimatedEnd ? { estimatedEnd, estimatedEndIso: raw } : {}), + }; +} + +export async function notifyMaintenanceScheduled(payload: MaintenanceInput) { return triggerBroadcastWorkflow( NOVU_WORKFLOWS.MAINTENANCE_SCHEDULED, - payload, + maintenanceWire(payload), ); } -export async function notifyMaintenanceStarted(payload: MaintenancePayload) { - return triggerBroadcastWorkflow(NOVU_WORKFLOWS.MAINTENANCE_STARTED, payload); +export async function notifyMaintenanceStarted(payload: MaintenanceInput) { + return triggerBroadcastWorkflow( + NOVU_WORKFLOWS.MAINTENANCE_STARTED, + maintenanceWire(payload), + ); } -export async function notifyMaintenanceEnded(payload: MaintenancePayload) { - return triggerBroadcastWorkflow(NOVU_WORKFLOWS.MAINTENANCE_ENDED, payload); +export async function notifyMaintenanceEnded(payload: MaintenanceInput) { + return triggerBroadcastWorkflow( + NOVU_WORKFLOWS.MAINTENANCE_ENDED, + maintenanceWire(payload), + ); } diff --git a/lib/novu/workflows.ts b/lib/novu/workflows.ts index bd840ce44..a26d4e6fe 100644 --- a/lib/novu/workflows.ts +++ b/lib/novu/workflows.ts @@ -179,16 +179,46 @@ export function notificationScope( // Payload Type Definitions // ============================================================================ +/** + * What the `appointment-*` templates render. + * + * #536 — every field here is the value a customer reads. `appointmentType` is a + * label ("consultation"), not the enum; `dateTime` is a sentence such as + * "Sat, 6 Sep 2026 · 7:53 AM IST" rendered in the RECIPIENT's zone, not an ISO + * timestamp. The machine-readable originals travel alongside under a + * unit-suffixed name so a consumer that has to branch or compute still can. + * + * Callers do not build this type. They pass {@link AppointmentPayloadInput} — + * raw values straight off the record — and `lib/novu/service.ts` renders it + * once per distinct recipient timezone. + */ export type AppointmentPayload = NotificationScope & { appointmentId?: string; + /** Sentence-ready label, e.g. "consultation". */ appointmentType: string; + /** The raw `AppointmentsType` member, for consumers that branch on it. */ + appointmentTypeCode?: string; consultantName: string; consulteeName: string; planTitle: string; + /** Friendly, in the recipient's timezone. */ dateTime?: string; + /** ISO 8601 copy of `dateTime`. */ + dateTimeIso?: string; dashboardUrl: string; }; +/** + * The caller-facing half of {@link AppointmentPayload}: `appointmentType` is + * the raw enum member and `dateTime` is an ISO 8601 instant. Both are converted + * at the trigger boundary, so no call site has to know the house date format or + * the label table. + */ +export type AppointmentPayloadInput = Omit< + AppointmentPayload, + "appointmentTypeCode" | "dateTimeIso" +>; + /** * #1206 — only SOME of the plan's sessions have times yet. The consultant was * shown the shortfall and chose to place what fits, so the consultee has to be @@ -202,7 +232,31 @@ export type AppointmentPartiallyScheduledPayload = AppointmentPayload & { unplacedSessions: number; }; +export type AppointmentPartiallyScheduledInput = AppointmentPayloadInput & { + placedSessions: number; + requiredSessions: number; + unplacedSessions: number; +}; + export type AppointmentCancelledPayload = AppointmentPayload & { + /** + * Always present. The live template ends on "Reason: {{reason}}", so an + * absent value left the sentence hanging on a colon; "No reason given" + * stands in when the caller has nothing to say. + */ + reason: string; + /** + * A noun the template can print, e.g. "Sarah Chen" or "the platform". The + * live template renders this value straight into its sentence, and one + * payload reaches both parties, so it names the person rather than taking a + * side ("your consultant" is false for the consultant reading it). + */ + cancelledBy: string; + /** The raw discriminator, for templates that branch on who acted. */ + cancelledByRole?: "consultant" | "consultee" | "system"; +}; + +export type AppointmentCancelledInput = AppointmentPayloadInput & { reason?: string; cancelledBy: "consultant" | "consultee" | "system"; }; @@ -247,38 +301,115 @@ export type RescheduleOutcomeFields = // `dateTime` from AppointmentPayload is deliberately unused here: a reschedule // is about the pair of times, not a single one. -export type AppointmentRescheduledPayload = AppointmentPayload & +export type AppointmentRescheduledInput = AppointmentPayloadInput & RescheduleOutcomeFields; +/** + * #1085 — what the `appointment-rescheduled` template actually receives. + * + * `newDateTime` is REQUIRED here even though three of the five outcomes have no + * destination time, because the template renders "from X to Y" unconditionally + * and an absent field rendered as "from  to". The outcomes without a + * destination get a phrase instead of a timestamp ("a new time your consultant + * will confirm"), so the sentence always completes. The discriminated + * {@link RescheduleOutcomeFields} input keeps its compile-time guarantee that a + * caller cannot invent a time that does not exist — only the trigger boundary + * may substitute the phrase. + */ +export type AppointmentRescheduledPayload = AppointmentPayload & { + outcome: RescheduleOutcomeFields["outcome"]; + /** Friendly, in the recipient's timezone. Absent if the source time is unknown. */ + oldDateTime?: string; + oldDateTimeIso?: string; + /** Friendly time, or the awaiting-a-time phrase. Never blank. */ + newDateTime: string; + /** Present only when `newDateTime` is a real instant. */ + newDateTimeIso?: string; +}; + +/* + * #536 — money comes in two shapes here, and the difference is not arbitrary. + * + * `PaymentSuccessPayload`, `PaymentFailedPayload` and `RefundPayload` feed the + * four live in-app templates that already print `{{currency}} {{amount}}` + * themselves. Those templates cannot be edited on the current Novu plan, so + * their `amount` is the bare figure and the ISO code the template prints is the + * only currency marker; `amountFormatted` carries the symbol-bearing string for + * whichever template is written next. + * + * Every other money payload — `PayoutPayload`, `DisputePayload`, the referral + * payloads and the organisation ones — puts the symbol in `amount`, because no + * template prints a currency code beside it. + */ export type PaymentSuccessPayload = NotificationScope & { - amount: number; + /** + * The figure WITHOUT a symbol, e.g. "55,679.48". The live template renders + * `{{currency}} {{amount}}`, so a symbol here would read "INR ₹55,679.48". + */ + amount: string; + /** The same figure WITH the symbol, e.g. "₹55,679.48". */ + amountFormatted: string; + /** The same amount in integer minor units, for consumers doing arithmetic. */ + amountPaise: number; currency: string; consultantName: string; + /** Sentence-ready label, e.g. "subscription session". */ appointmentType: string; + appointmentTypeCode?: string; planTitle: string; receiptUrl?: string; dashboardUrl: string; }; -export type PaymentFailedPayload = { +/** Callers pass integer minor units and the raw enum; see {@link PaymentSuccessPayload}. */ +export type PaymentSuccessInput = Omit< + PaymentSuccessPayload, + "amount" | "amountFormatted" | "amountPaise" | "appointmentTypeCode" +> & { amount: number; +}; + +export type PaymentFailedPayload = { + /** Symbol-free; the live template supplies `{{currency}}` itself. */ + amount: string; + amountFormatted: string; + amountPaise: number; currency: string; consultantName: string; appointmentType: string; + appointmentTypeCode?: string; planTitle?: string; failureReason: string; retryUrl?: string; }; -export type RefundPayload = NotificationScope & { +export type PaymentFailedInput = Omit< + PaymentFailedPayload, + "amount" | "amountFormatted" | "amountPaise" | "appointmentTypeCode" +> & { amount: number; +}; + +export type RefundPayload = NotificationScope & { + /** Symbol-free; the live templates supply `{{currency}}` themselves. */ + amount: string; + amountFormatted: string; + amountPaise: number; currency: string; reason?: string; appointmentType?: string; + appointmentTypeCode?: string; consultantName?: string; dashboardUrl: string; }; +export type RefundInput = Omit< + RefundPayload, + "amount" | "amountFormatted" | "amountPaise" | "appointmentTypeCode" +> & { + amount: number; +}; + export type SupportTicketPayload = NotificationScope & { ticketId: string; ticketTitle: string; @@ -307,12 +438,25 @@ export type ReviewPayload = { export type TrialSessionPayload = { consultantName: string; consulteeName: string; + /** The parent subscription plan's title — never its id (#536). */ planTitle: string; + /** Friendly, in the recipient's timezone. */ dateTime?: string; + /** ISO 8601 copy of `dateTime`. */ + dateTimeIso?: string; + /** Sentence-ready status label, e.g. "awaiting payment". */ status: string; + /** The raw `TrialSessionStatus` member. */ + statusCode?: string; dashboardUrl: string; }; +/** Callers pass an ISO instant and the raw status; see {@link TrialSessionPayload}. */ +export type TrialSessionInput = Omit< + TrialSessionPayload, + "dateTimeIso" | "statusCode" +>; + export type SubscriptionPayload = { subscriptionId?: string; planTitle: string; @@ -324,11 +468,21 @@ export type SubscriptionPayload = { export type BookingRequestPayload = NotificationScope & { consulteeName: string; planTitle: string; + /** Sentence-ready label, e.g. "consultation". */ appointmentType: string; + appointmentTypeCode?: string; + /** Friendly, in the recipient's timezone. */ requestedDateTime?: string; + /** ISO 8601 copy of `requestedDateTime`. */ + requestedDateTimeIso?: string; dashboardUrl: string; }; +export type BookingRequestInput = Omit< + BookingRequestPayload, + "appointmentTypeCode" | "requestedDateTimeIso" +>; + export type VerificationPayload = { status: string; reason?: string; @@ -342,23 +496,39 @@ export type ModerationWarningPayload = { export type AccountSuspendedPayload = { reason?: string; - /** ISO timestamp the suspension lapses (lazy expiry at sign-in). */ + /** + * Friendly, in the recipient's timezone — the date they get their account + * back, or "further notice" when the suspension has no end date. + */ suspendedUntil: string; + /** ISO timestamp the suspension lapses (lazy expiry at sign-in); absent when indefinite. */ + suspendedUntilIso?: string; appointmentsCancelled?: number; }; +export type AccountSuspendedInput = Omit< + AccountSuspendedPayload, + "suspendedUntilIso" +>; + export type AccountBannedPayload = { reason?: string; appointmentsCancelled?: number; }; export type PayoutPayload = { - amount: number; + /** Money as the consultant reads it, e.g. "₹12,400.00". */ + amount: string; + amountPaise: number; currency: string; payoutId?: string; dashboardUrl: string; }; +export type PayoutInput = Omit & { + amount: number; +}; + export type AnnouncementPayload = { title: string; content: string; @@ -368,7 +538,8 @@ export type AnnouncementPayload = { export type DisputePayload = { disputeId?: string; - amount: number; + amount: string; + amountPaise: number; currency: string; reason?: string; status?: string; @@ -377,8 +548,14 @@ export type DisputePayload = { dashboardUrl: string; }; +export type DisputeInput = Omit & { + amount: number; +}; + export type RecordingPayload = NotificationScope & { + /** Sentence-ready label, e.g. "class". */ appointmentType: string; + appointmentTypeCode?: string; consultantName: string; consulteeName?: string; recordingUrl: string; @@ -396,10 +573,18 @@ export type RecordingFailedPayload = { // batch so the copy can lead with the nearest deadline. export type RecordingExpiringPayload = { recordingCount: number; + /** Friendly, in the recipient's timezone. */ expiresAt: string; + /** ISO 8601 copy of `expiresAt`. */ + expiresAtIso?: string; dashboardUrl: string; }; +export type RecordingExpiringInput = Omit< + RecordingExpiringPayload, + "expiresAtIso" +>; + /** * Fired when a document lands on an appointment (consultee submission, * consultee revision, or consultant response). Recipient is the other @@ -449,27 +634,54 @@ export type ConsultantApplicationPayload = { export type ReferralBonusPayload = { referrerName: string; refereeName: string; - bonusAmount: number; + /** Money as the referrer reads it, e.g. "₹500.00". */ + bonusAmount: string; + bonusAmountPaise: number; currency: string; dashboardUrl: string; }; +export type ReferralBonusInput = Omit< + ReferralBonusPayload, + "bonusAmount" | "bonusAmountPaise" +> & { bonusAmount: number }; + export type RefereeWelcomeBonusPayload = { refereeName: string; referrerName: string; - bonusAmount: number; + bonusAmount: string; + bonusAmountPaise: number; currency: string; dashboardUrl: string; }; +export type RefereeWelcomeBonusInput = Omit< + RefereeWelcomeBonusPayload, + "bonusAmount" | "bonusAmountPaise" +> & { bonusAmount: number }; + export type ReferralCreditsAppliedPayload = { - creditsUsed: number; + /** Money as the buyer reads it, e.g. "₹250.00". */ + creditsUsed: string; + creditsUsedPaise: number; currency: string; - remainingCredits: number; + remainingCredits: string; + remainingCreditsPaise: number; + /** Sentence-ready label, e.g. "consultation". */ appointmentType: string; + appointmentTypeCode?: string; dashboardUrl: string; }; +export type ReferralCreditsAppliedInput = Omit< + ReferralCreditsAppliedPayload, + | "creditsUsed" + | "creditsUsedPaise" + | "remainingCredits" + | "remainingCreditsPaise" + | "appointmentTypeCode" +> & { creditsUsed: number; remainingCredits: number }; + export type CollaboratorInvitedPayload = { planTitle: string; planType: string; @@ -496,21 +708,50 @@ export type CollaboratorRemovedPayload = { export type MaintenancePayload = { phase: string; reason?: string; + /** + * Friendly. A maintenance notice is broadcast to every subscriber at once, so + * there is no single recipient whose zone could be used — it renders in the + * platform default zone and names it (#536). + */ estimatedEnd?: string; + /** ISO 8601 copy of `estimatedEnd`. */ + estimatedEndIso?: string; }; +export type MaintenanceInput = Omit; + // ============================================================================ // Enterprise (arch-4) Payload Types // ============================================================================ +/* + * #536 — the org payloads follow the same naming rule as the B2C ones: a + * template interpolates the unit-free name and gets a human value, while the + * unit-suffixed sibling keeps the machine value. + * + * Money is the one place the two families differ in migration cost. These + * fields were named `*Paise` from the start, so the value they carry is honest + * and cannot simply be replaced with a string; the human amount arrives as a + * NEW unit-free field (`totalPaise` keeps the integer, `total` gains + * "₹12,400.00"). The org templates therefore need a one-line dashboard edit to + * read the new name — tracked in the pull request that introduced this rule. + * Dates need no such edit: they were never unit-suffixed, so the existing field + * now carries the sentence and the ISO copy moves to `*Iso`. + */ + export type OrgInviteSentPayload = { inviterName: string; orgName: string; role: string; inviteUrl: string; + /** Friendly. Delivered by email to someone with no account, so no recipient + * zone exists — rendered in the platform default zone, which it names. */ expiresAt: string; + expiresAtIso?: string; }; +export type OrgInviteSentInput = Omit; + export type OrgInviteAcceptedPayload = { accepteeName: string; accepteeEmail: string; @@ -522,23 +763,40 @@ export type OrgInviteAcceptedPayload = { export type OrgInvoiceIssuedPayload = { invoiceNumber: string; orgName: string; + /** Money as the payer reads it, e.g. "₹12,400.00". */ + total: string; totalPaise: number; currency: string; + /** Friendly, in the recipient's timezone. */ dueDate: string; + dueDateIso?: string; dashboardUrl: string; /** #438 — deep link to the invoice PDF route (302s to a signed URL). */ pdfUrl?: string; }; +export type OrgInvoiceIssuedInput = Omit< + OrgInvoiceIssuedPayload, + "total" | "dueDateIso" +>; + export type OrgInvoicePaidPayload = { invoiceNumber: string; orgName: string; + total: string; totalPaise: number; currency: string; + /** Friendly, in the recipient's timezone. */ paidAt: string; + paidAtIso?: string; dashboardUrl: string; }; +export type OrgInvoicePaidInput = Omit< + OrgInvoicePaidPayload, + "total" | "paidAtIso" +>; + // #779 §A — dunning notice. `reminderStage` is 0 for the first OVERDUE // notice and 1..3 for the escalating 7-day reminders so the template can // ramp the urgency copy. `daysLate` is days since dueDate; `payUrl` deep- @@ -546,6 +804,7 @@ export type OrgInvoicePaidPayload = { export type OrgInvoiceOverduePayload = { invoiceNumber: string; orgName: string; + total: string; totalPaise: number; currency: string; daysLate: number; @@ -553,63 +812,105 @@ export type OrgInvoiceOverduePayload = { payUrl: string; }; +export type OrgInvoiceOverdueInput = Omit; + // #779 §A — a member-owed overage side-charge timed out (PENDING→FAILED) // after 14 days unpaid. `payUrl` still points at the settle surface (the // member can retry via FAILED→PENDING resume-checkout). export type OrgMemberOverageTimedOutPayload = { orgName: string; programName: string; + amount: string; amountPaise: number; currency: string; payUrl: string; }; +export type OrgMemberOverageTimedOutInput = Omit< + OrgMemberOverageTimedOutPayload, + "amount" +>; + export type OrgLicenseRenewalUpcomingPayload = { orgName: string; - cycle: "MONTHLY" | "QUARTERLY" | "ANNUAL"; + /** Sentence-ready label, e.g. "monthly". */ + cycle: string; + cycleCode?: "MONTHLY" | "QUARTERLY" | "ANNUAL"; + /** Friendly, in the recipient's timezone. */ renewalDate: string; + renewalDateIso?: string; daysUntilRenewal: number; + expectedTotal: string; expectedTotalPaise: number; currency: string; dashboardUrl: string; }; +export type OrgLicenseRenewalUpcomingInput = Omit< + OrgLicenseRenewalUpcomingPayload, + "cycle" | "cycleCode" | "renewalDateIso" | "expectedTotal" +> & { cycle: "MONTHLY" | "QUARTERLY" | "ANNUAL" }; + export type OrgDataExportReadyPayload = { orgName: string; exportId: string; fileSizeBytes: number; + /** Friendly, in the recipient's timezone. */ expiresAt: string; + expiresAtIso?: string; downloadUrl: string; dashboardUrl: string; }; +export type OrgDataExportReadyInput = Omit< + OrgDataExportReadyPayload, + "expiresAtIso" +>; + export type OrgWalletTopupConfirmedPayload = { orgName: string; + amount: string; amountPaise: number; currency: string; + newBalance: string; newBalancePaise: number; dashboardUrl: string; }; +export type OrgWalletTopupConfirmedInput = Omit< + OrgWalletTopupConfirmedPayload, + "amount" | "newBalance" +>; + // #777 §C — wallet low-balance alert. `balancePaise` is the live balance that // tripped the floor; `minimumPaise` is the configured threshold. NOTIFY-ONLY — // no money moves until mandates land. `topUpUrl` deep-links to the wallet tab. export type OrgWalletLowPayload = { orgName: string; + balance: string; balancePaise: number; + minimum: string; minimumPaise: number; currency: string; topUpUrl: string; }; +export type OrgWalletLowInput = Omit< + OrgWalletLowPayload, + "balance" | "minimum" +>; + export type OrgPayoutCompletedPayload = { orgName: string; payoutId: string; + amount: string; amountPaise: number; currency: string; dashboardUrl: string; }; +export type OrgPayoutCompletedInput = Omit; + export type OrgProgramExhaustedPayload = { orgName: string; programName: string; @@ -635,10 +936,18 @@ export type OrgProgramCapNearPayload = { export type OrgProgramOverageDuePayload = { orgName: string; programName: string; + /** Money as the member reads it. Settlement is INR-only, so no currency + * field exists to disagree with. */ + amount: string; amountPaise: number; payUrl: string; }; +export type OrgProgramOverageDueInput = Omit< + OrgProgramOverageDuePayload, + "amount" +>; + export type OrgSsoProviderDeletedPayload = { orgName: string; providerId: string; @@ -651,16 +960,24 @@ export type OrgSsoCertExpiringPayload = { providerId: string; daysRemaining: number; severity: "WARN" | "CRITICAL" | "EXPIRED"; + /** Friendly, in the recipient's timezone. */ notAfter: string; + notAfterIso?: string; dashboardUrl: string; }; +export type OrgSsoCertExpiringInput = Omit< + OrgSsoCertExpiringPayload, + "notAfterIso" +>; + // A1+A8: discriminated payload for the failed/reversed payout webhook // fan-out. `kind` distinguishes a gateway rejection (FAILED) from a bank // reversal (REVERSED) so the Novu template can render the right copy. export type OrgPayoutFailedPayload = { orgName: string; payoutId: string; + amount: string; amountPaise: number; currency: string; reason: string; @@ -668,6 +985,8 @@ export type OrgPayoutFailedPayload = { dashboardUrl: string; }; +export type OrgPayoutFailedInput = Omit; + // A7: payload for the EXPERT-removed-from-org notification. `removedByName` // is the operator who triggered the soft-delete (or "system" for cron- // driven removals such as contract expiry). `reason` is optional free-text. diff --git a/lib/payments/webhooks/handlers.ts b/lib/payments/webhooks/handlers.ts index 4a10a660a..f3193c780 100644 --- a/lib/payments/webhooks/handlers.ts +++ b/lib/payments/webhooks/handlers.ts @@ -57,6 +57,7 @@ import { } from "@/lib/novu"; import { notificationScope } from "@/lib/novu/workflows"; import { notificationHref } from "@/lib/novu/resolve-href"; +import { planTitleOrSessionLabel } from "@/lib/novu/humanize"; import { processQualifyingAction, processConsultantBookingReferral, @@ -65,29 +66,6 @@ import { ensureChannelsForAppointment } from "@/lib/payments/webhooks/ensure-cha import { streamLogger } from "@/lib/stream-logger"; import { getAppUrl } from "@/lib/url"; -/** - * Sentence-case label for a raw AppointmentsType, used by buyer-facing copy - * that has no plan title to name (#1484). Deliberately not exhaustive over the - * enum via a Record: an appointment type added later should degrade to - * "Appointment" rather than fail the build in a notification path. - */ -function humaniseAppointmentType(appointmentType: string): string { - switch (appointmentType) { - case AppointmentsType.CONSULTATION: - return "Consultation"; - case AppointmentsType.SUBSCRIPTION: - return "Subscription"; - case AppointmentsType.WEBINAR: - return "Webinar"; - case AppointmentsType.CLASS: - return "Class"; - case AppointmentsType.TRIAL: - return "Trial session"; - default: - return "Appointment"; - } -} - // ============================================================================ // Type Definitions // ============================================================================ @@ -1012,11 +990,14 @@ ACTION REQUIRED: Customer was charged but appointment was NOT created! const resolvedPlanTitle = metadata.appointmentType === AppointmentsType.TRIAL ? "Trial session" - : (appointmentForNotif?.consultation?.consultationPlan?.title ?? - appointmentForNotif?.subscription?.subscriptionPlan?.title ?? - appointmentForNotif?.webinar?.webinarPlan?.title ?? - appointmentForNotif?.class?.classPlan?.title ?? - humaniseAppointmentType(metadata.appointmentType)); + : planTitleOrSessionLabel( + appointmentForNotif?.consultation?.consultationPlan?.title ?? + appointmentForNotif?.subscription?.subscriptionPlan?.title ?? + appointmentForNotif?.webinar?.webinarPlan?.title ?? + appointmentForNotif?.class?.classPlan?.title ?? + null, + metadata.appointmentType, + ); const orgId = appointmentForNotif?.organizationId ?? null; const scope = notificationScope( diff --git a/scripts/appointments/send-appointment-reminders.ts b/scripts/appointments/send-appointment-reminders.ts index defd3a723..aeb574d1d 100644 --- a/scripts/appointments/send-appointment-reminders.ts +++ b/scripts/appointments/send-appointment-reminders.ts @@ -18,6 +18,7 @@ import redis from "../../lib/redis"; import { notifyAppointmentReminder } from "../../lib/novu/service"; import { notificationScope } from "../../lib/novu/workflows"; import { notificationHref } from "../../lib/novu/resolve-href"; +import { planTitleOrSessionLabel } from "../../lib/novu/humanize"; import { withCronLock } from "@/lib/cron/with-cron-lock"; // Reminder windows (in milliseconds) @@ -141,7 +142,9 @@ async function sendRemindersForWindow(window: { try { // Determine event type and plan info let appointmentType = "consultation"; - let planTitle = "Unknown"; + // #536 — empty, not "Unknown": an appointment matching none of the four + // shapes below would have named the customer's session "Unknown". + let planTitle = ""; let consultantName = "Consultant"; let consulteeName = "Consultee"; const userIds: string[] = []; @@ -216,7 +219,8 @@ async function sendRemindersForWindow(window: { appointmentType, consultantName, consulteeName, - planTitle, + planTitle: planTitleOrSessionLabel(planTitle, appointmentType), + // Rendered per recipient timezone at the trigger boundary (#536). dateTime: slot.startsAt.toISOString(), dashboardUrl: notificationHref(apt.organizationId, "appointments"), }, diff --git a/utils/formatting.ts b/utils/formatting.ts index 50aabdf6f..1f8c8fd3f 100644 --- a/utils/formatting.ts +++ b/utils/formatting.ts @@ -94,21 +94,60 @@ export function formatCurrencyAmount( amountInSmallestUnit: number, currency: string, ): string { + const { format, divisor } = currencyFormatter(currency); + return format.format(amountInSmallestUnit / divisor); +} + +/** The one place the subunit and locale rules for a currency are resolved. */ +function currencyFormatter(currency: string): { + format: Intl.NumberFormat; + divisor: number; +} { const upper = currency.toUpperCase(); const locale = CURRENCY_LOCALE_MAP[upper] || "en-IN"; - const divisor = getCurrencyDivisor(upper); const fractionDigits = ZERO_DECIMAL_CURRENCIES.has(upper) ? 0 : THREE_DECIMAL_CURRENCIES.has(upper) ? 3 : 2; - return new Intl.NumberFormat(locale, { - style: "currency", - currency: upper, - minimumFractionDigits: fractionDigits, - maximumFractionDigits: fractionDigits, - }).format(amountInSmallestUnit / divisor); + return { + format: new Intl.NumberFormat(locale, { + style: "currency", + currency: upper, + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }), + divisor: getCurrencyDivisor(upper), + }; +} + +/** + * The same figure as `formatCurrencyAmount` with the symbol or code removed: + * `55,679.48` rather than `₹55,679.48`. + * + * For surfaces that already print the currency themselves and would otherwise + * say it twice — four Novu templates render `{{currency}} {{amount}}` and + * cannot be edited today (#536). Built by dropping the currency part from the + * currency formatter's own output rather than by configuring a second + * formatter, so the grouping, locale and subunit rules cannot drift from the + * symbol-bearing version. + * + * @example + * formatCurrencyAmountBare(5567948, "INR") // "55,679.48" + * formatCurrencyAmountBare(1500, "KWD") // "1.500" + */ +export function formatCurrencyAmountBare( + amountInSmallestUnit: number, + currency: string, +): string { + const { format, divisor } = currencyFormatter(currency); + return format + .formatToParts(amountInSmallestUnit / divisor) + .filter((part) => part.type !== "currency") + .map((part) => part.value) + .join("") + .trim(); } // #1396 — a deprecated major-unit formatter lived here, taking rupees rather