Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
269 changes: 268 additions & 1 deletion __tests__/security/novu-payload-allowlist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -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<string, unknown> =>
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,
});
},
);
});
8 changes: 7 additions & 1 deletion app/api/appointments/[appointmentId]/cancel/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion app/api/appointments/[appointmentId]/reschedule/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
29 changes: 22 additions & 7 deletions docs/enterprise/90-audits/03-verification-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
Loading
Loading