diff --git a/__tests__/security/novu-payload-allowlist.test.ts b/__tests__/security/novu-payload-allowlist.test.ts new file mode 100644 index 000000000..13bf6e8f7 --- /dev/null +++ b/__tests__/security/novu-payload-allowlist.test.ts @@ -0,0 +1,136 @@ +/** + * ADR 20 for the notification layer. + * + * The list-helper allowlists (`org-scope-payload-allowlist.test.ts`) pin what + * an organization can READ through the scoped queries. Nothing pinned what it + * can be SENT. That gap matters because `RecordingPayload.recordingUrl` puts a + * live media URL in a notification body, and the only thing keeping it away + * from an operator is that `notifyRecordingAvailable` is called with + * `getEventAttendeeIds(...)` — a participant list — rather than a roster. + * + * That is exactly the shape ADR 20 was written about: "the accident happened to + * be mostly right... but it held only because no one had yet added a field to a + * select statement". A future change widening that recipient list to + * `rosterForOrg(orgId, VISIBILITY_ROLES)` would leak the URL with no test + * failing, so these assertions pin the two halves of the rule: + * + * 1. Content-bearing payloads are only ever sent to participant-derived + * recipient lists. + * 2. The org-roster dispatchers carry no content field at all. + * + * Source-level assertions for the same reason the sibling suite gives: what + * matters is which recipient list each trigger reaches for. + */ + +import { readFileSync } from "fs"; +import { join } from "path"; + +const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8"); + +const ORG_WORKFLOWS = "lib/novu/org-workflows.ts"; +const WORKFLOWS = "lib/novu/workflows.ts"; +const RECORDING_HANDLERS = "lib/stream/recording-handlers.ts"; + +/** + * Fields ADR 20 names as session CONTENT. A payload carrying one of these may + * only reach the two people who were in the session. + */ +const CONTENT_FIELDS = [ + "recordingUrl", + "fileUrl", + "storagePath", + "requestNotes", + "feedbackFromConsultee", + "feedbackFromConsultant", + "cancellationNotes", + "transcript", +]; + +describe("ADR 20 — org-roster notifications carry no session content", () => { + it("no org-roster payload type declares a content field", () => { + const src = read(ORG_WORKFLOWS); + // Everything in org-workflows.ts dispatches to rosterForOrg(), so no + // payload defined or forwarded there may name a content field. + for (const field of CONTENT_FIELDS) { + expect(src).not.toContain(field); + } + }); + + it("the roster resolver is the only recipient source in org-workflows", () => { + const src = read(ORG_WORKFLOWS); + // Guards against someone importing getEventAttendeeIds here and blurring + // the two audiences into one file. + expect(src).not.toContain("getEventAttendeeIds"); + expect(src).toContain("rosterForOrg"); + }); + + it("recording notifications go to participants, never a roster", () => { + const src = read(RECORDING_HANDLERS); + + // Asserting that `getEventAttendeeIds` merely APPEARS is too weak — it would + // still pass if the notifier were handed a roster while the resolver sat + // 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); + expect(call).not.toBeNull(); + const recipientVar = call![1]; + + const assignedFromResolver = new RegExp( + `(?:const|let|var)\\s+${recipientVar}\\s*=\\s*await\\s+getEventAttendeeIds\\(`, + ); + expect(src).toMatch(assignedFromResolver); + + // And the roster resolvers must not be reachable from this file at all, so + // the recipient list cannot be rebuilt from one further down. + expect(src).not.toContain("rosterForOrg"); + expect(src).not.toContain("VISIBILITY_ROLES"); + expect(src).not.toContain("OPERATOR_ROLES"); + }); + + it("RecordingPayload is still the only content-bearing shared payload", () => { + const src = read(WORKFLOWS); + // If a second payload grows a content field, this fails and whoever added + // it has to come and think about who receives it. + const carriers = CONTENT_FIELDS.filter((f) => src.includes(f)); + expect(carriers).toEqual(["recordingUrl"]); + }); +}); + +describe("ADR 23 — dual-context payloads are attributable", () => { + const SCOPED_PAYLOADS = [ + "AppointmentPayload", + "PaymentSuccessPayload", + "BookingRequestPayload", + "RecordingPayload", + ]; + + it.each(SCOPED_PAYLOADS)("%s composes NotificationScope", (name) => { + const src = read(WORKFLOWS); + expect(src).toContain(`export type ${name} = NotificationScope & {`); + }); + + it("notificationScope keeps scope and organizationId consistent", async () => { + const { notificationScope } = await import("@/lib/novu/workflows"); + + expect(notificationScope(null)).toEqual({ + organizationId: null, + scope: "personal", + }); + expect(notificationScope(undefined)).toEqual({ + organizationId: null, + scope: "personal", + }); + expect(notificationScope("org_1", "Acme")).toEqual({ + organizationId: "org_1", + scope: "org", + orgName: "Acme", + }); + // A personal notification must not carry an org name — it would render an + // attribution the scope contradicts. + expect(notificationScope(null, "Acme")).toEqual({ + organizationId: null, + scope: "personal", + }); + }); +}); diff --git a/actions/maintenance/freeze-appointments.ts b/actions/maintenance/freeze-appointments.ts index 3b832db8b..96c836d08 100644 --- a/actions/maintenance/freeze-appointments.ts +++ b/actions/maintenance/freeze-appointments.ts @@ -12,6 +12,8 @@ import crypto from "crypto"; import * as Sentry from "@sentry/nextjs"; import { notifyAppointmentCancelled } from "@/lib/novu/service"; +import { notificationScope } from "@/lib/novu/workflows"; +import { notificationHref } from "@/lib/novu/resolve-href"; import { createRefund } from "@/lib/payments"; import prisma from "@/lib/prisma"; @@ -23,6 +25,8 @@ type NotificationPayload = { function buildCancellationNotification(params: { appointmentId: string; appointmentType: string; + /** ADR 23 — routes the notification to the dashboard owning the session. */ + organizationId: string | null; consultantUser: { id: string; name: string | null } | null | undefined; participantIds: string[]; planTitle: string; @@ -46,7 +50,8 @@ function buildCancellationNotification(params: { consulteeName: params.consulteeName || "Participants", planTitle: params.planTitle, dateTime: params.dateTime, - dashboardUrl: "/dashboard", + ...notificationScope(params.organizationId), + dashboardUrl: notificationHref(params.organizationId, "appointments"), reason: "Scheduled platform maintenance", cancelledBy: "system", }, @@ -227,6 +232,7 @@ export async function freezeAppointments( const notif = buildCancellationNotification({ appointmentId: appointment.id, + organizationId: appointment.organizationId, appointmentType: "CONSULTATION", consultantUser: consultation.consultationPlan?.consultantProfile?.user, @@ -258,6 +264,7 @@ export async function freezeAppointments( const notif = buildCancellationNotification({ appointmentId: appointment.id, + organizationId: appointment.organizationId, appointmentType: "SUBSCRIPTION", consultantUser: subscription.subscriptionPlan?.consultantProfile?.user, @@ -286,6 +293,7 @@ export async function freezeAppointments( ); const notif = buildCancellationNotification({ appointmentId: appointment.id, + organizationId: appointment.organizationId, appointmentType: "WEBINAR", consultantUser: webinar.webinarPlan?.consultantProfile?.user, participantIds: webinarParticipantIds, @@ -310,6 +318,7 @@ export async function freezeAppointments( ); const notif = buildCancellationNotification({ appointmentId: appointment.id, + organizationId: appointment.organizationId, appointmentType: "CLASS", consultantUser: classEvent.classPlan?.consultantProfile?.user, participantIds: classParticipantIds, @@ -329,6 +338,7 @@ export async function freezeAppointments( const notif = buildCancellationNotification({ appointmentId: appointment.id, + organizationId: appointment.organizationId, appointmentType: "TRIAL", consultantUser: trial.consultantProfile?.user, participantIds: trial.consulteeProfile?.user?.id diff --git a/app/api/appointments/[appointmentId]/cancel/route.ts b/app/api/appointments/[appointmentId]/cancel/route.ts index 4e3d6845d..ece81022c 100644 --- a/app/api/appointments/[appointmentId]/cancel/route.ts +++ b/app/api/appointments/[appointmentId]/cancel/route.ts @@ -2,7 +2,11 @@ import * as Sentry from "@sentry/nextjs"; import prisma from "@/lib/prisma"; import { NextRequest, NextResponse } from "next/server"; import { CancellationReason } from "@prisma/client"; -import { notifyAppointmentCancelled } from "@/lib/novu"; +import { + notifyAppointmentCancelled, +} from "@/lib/novu"; +import { notificationScope } from "@/lib/novu/workflows"; +import { notificationHref } from "@/lib/novu/resolve-href"; import { CancelAppointmentSchema } from "@/schemas/appointments"; import { logConsultationCancelled, @@ -416,12 +420,18 @@ export async function POST( ].filter((id): id is string => !!id); if (userIds.length > 0) { void notifyAppointmentCancelled(userIds, { + ...notificationScope(appointment.organizationId), + appointmentId, appointmentType: notificationMeta.appointmentType, consultantName: notificationMeta.consultantName || "Consultant", consulteeName: notificationMeta.consulteeName || "Consultee", planTitle: notificationMeta.planTitle || "N/A", dateTime: notificationMeta.dateTime, - dashboardUrl: "/dashboard", + // Both parties receive one payload, so the href has to suit either. + dashboardUrl: notificationHref( + appointment.organizationId, + "appointments", + ), reason: validatedData.reason || undefined, cancelledBy: notificationMeta.cancelledBy === notificationMeta.consultantUserId diff --git a/app/api/appointments/[appointmentId]/reschedule/route.ts b/app/api/appointments/[appointmentId]/reschedule/route.ts index a19b93150..159390cb6 100644 --- a/app/api/appointments/[appointmentId]/reschedule/route.ts +++ b/app/api/appointments/[appointmentId]/reschedule/route.ts @@ -10,8 +10,9 @@ import { AppointmentNotFoundError, } from "@/utils/errors/RescheduleErrors"; import { notifyAppointmentRescheduled } from "@/lib/novu/service"; +import { notificationScope } from "@/lib/novu/workflows"; +import { notificationHref } from "@/lib/novu/resolve-href"; import { logActivity } from "@/lib/activity/log-activity"; -import { getAppUrl } from "@/lib/url"; import { hasActiveDisputeForAppointment } from "@/lib/payments/dispute-guard"; import { CLASS_EVENT_ALLOWED_FROM, @@ -585,13 +586,18 @@ export async function POST( : "class"; if (uniqueUserIds.length > 0) { - const baseUrl = getAppUrl(); void notifyAppointmentRescheduled(uniqueUserIds, { + ...notificationScope(appointment.organizationId), appointmentType, consultantName: plan?.consultantProfile?.user?.name ?? "Consultant", consulteeName: requestedBy?.user?.name ?? "Participant", planTitle: plan?.title ?? "Unknown", - dashboardUrl: `${baseUrl}/dashboard`, + // Group events fan out to every attendee, so one href must serve + // them all — org route when org-hosted, router bounce otherwise. + dashboardUrl: notificationHref( + appointment.organizationId, + "appointments", + ), }).catch((err) => console.error("[reschedule] Failed to send notification:", err), ); diff --git a/app/api/novu/preferences/route.ts b/app/api/novu/preferences/route.ts index 2d4e91aba..c64d83deb 100644 --- a/app/api/novu/preferences/route.ts +++ b/app/api/novu/preferences/route.ts @@ -37,6 +37,9 @@ export async function GET() { trialNotifications: true, subscriptionAlerts: true, marketingEmails: false, + orgBillingAlerts: true, + orgMembershipAlerts: true, + orgProgramAlerts: true, quietHoursEnabled: false, quietHoursStart: null, quietHoursEnd: null, @@ -101,6 +104,10 @@ export async function PUT(req: NextRequest) { trialNotifications: updated.trialNotifications, subscriptionAlerts: updated.subscriptionAlerts, marketingEmails: updated.marketingEmails, + // ADR 23 — org categories + orgBillingAlerts: updated.orgBillingAlerts, + orgMembershipAlerts: updated.orgMembershipAlerts, + orgProgramAlerts: updated.orgProgramAlerts, }); return NextResponse.json(updated); diff --git a/app/api/novu/subscriber/route.ts b/app/api/novu/subscriber/route.ts index 2859456da..df050525f 100644 --- a/app/api/novu/subscriber/route.ts +++ b/app/api/novu/subscriber/route.ts @@ -25,6 +25,8 @@ export async function POST() { phone: true, image: true, timezone: true, + // ADR 23 — routing preference for operators who own a workspace. + orgWorkspaceProfile: { select: { notificationRoutingMode: true } }, }, }); @@ -41,6 +43,8 @@ export async function POST() { phone: user.phone || undefined, avatar: user.image || undefined, locale: "en", + routingMode: + user.orgWorkspaceProfile?.notificationRoutingMode ?? undefined, }); return NextResponse.json({ success: true }); diff --git a/app/api/slots/request-for-approval/route.ts b/app/api/slots/request-for-approval/route.ts index eea643ff9..efa770935 100644 --- a/app/api/slots/request-for-approval/route.ts +++ b/app/api/slots/request-for-approval/route.ts @@ -4,7 +4,11 @@ import { AppointmentStatus } from "@prisma/client"; import { lockSlotBooking, unlockSlotBooking } from "@/utils/appointmentlock"; import { SlotLockError } from "@/utils/errors/SlotLockError"; import { SlotValidationService } from "@/utils/slotAllocation/SlotValidationService"; -import { notifyNewBookingRequest } from "@/lib/novu"; +import { + notifyNewBookingRequest, +} from "@/lib/novu"; +import { notificationScope } from "@/lib/novu/workflows"; +import { scopedHref } from "@/lib/novu/resolve-href"; import { RequestForApprovalSchema } from "@/schemas/slots"; import { requestApprovalLimiter, applyRateLimit } from "@/lib/rate-limit"; import { ensureConsulteeProfile } from "@/lib/profiles/ensure-consultee-profile"; @@ -230,15 +234,29 @@ export async function POST(req: NextRequest) { }), ); - // Fire-and-forget: notify consultant of new booking request + // Fire-and-forget: notify consultant of new booking request. + // + // ADR 23 — the link used to hardcode the personal Requests page even for + // an org-hosted plan, where the request is not listed: the personal scope + // pins organizationId: null. Single recipient with a known side, so this + // resolves to a precise route rather than the /dashboard bounce. + const requestOrgId = consultation.appointment?.organizationId ?? null; void notifyNewBookingRequest( consultation.consultationPlan.consultantProfile.user.id, { + ...notificationScope(requestOrgId), consulteeName: consultation.requestedBy.user.name || "A consultee", planTitle: consultation.consultationPlan.title, appointmentType: "CONSULTATION", requestedDateTime: startTime.toISOString(), - dashboardUrl: `/dashboard/consultant/${consultation.consultationPlan.consultantProfile.id}/requests`, + dashboardUrl: scopedHref({ + organizationId: requestOrgId, + surface: "requests", + personal: { + kind: "consultant", + profileId: consultation.consultationPlan.consultantProfile.id, + }, + }), }, ); diff --git a/app/api/webhooks/utils.ts b/app/api/webhooks/utils.ts index 418b85a54..f10027099 100644 --- a/app/api/webhooks/utils.ts +++ b/app/api/webhooks/utils.ts @@ -16,6 +16,7 @@ import { notifyDisputeCreated, notifyDisputeResolved, } from "@/lib/novu"; +import { notificationScope } from "@/lib/novu/workflows"; import { notifyOrgInvoicePaid, notifyOrgWalletTopupConfirmed, @@ -958,6 +959,11 @@ export async function handleRefundCreated( // --- Novu notification (fire-and-forget) --- void notifyRefundProcessed(payment.userId, { + // Payment.organizationId is the org tag (#PaymentOrgTag), so a refund + // inherits the org-ness of the payment it reverses. dashboardUrl stays a + // router bounce deliberately: this goes to the PAYER, and an org billing + // page is not readable by a LEARNER whose booking was org-sponsored. + ...notificationScope(payment.organizationId), amount, currency, dashboardUrl: `${getAppUrl()}/dashboard`, diff --git a/app/dashboard/org-workspace/[orgWorkspaceId]/OrgWorkspaceShell.tsx b/app/dashboard/org-workspace/[orgWorkspaceId]/OrgWorkspaceShell.tsx index 8858ebe19..e574356ee 100644 --- a/app/dashboard/org-workspace/[orgWorkspaceId]/OrgWorkspaceShell.tsx +++ b/app/dashboard/org-workspace/[orgWorkspaceId]/OrgWorkspaceShell.tsx @@ -38,6 +38,7 @@ import { DashboardErrorBoundary } from "@/components/DashboardErrorBoundary"; import { isActiveRoute } from "@/components/dashboard/route-active"; import { LinkPendingIcon } from "@/components/ui/NavLink"; import { OrganizationSwitcher } from "@/components/dashboard/OrganizationSwitcher"; +import { useNovuSubscriberSync } from "@/hooks/useNovuSubscriberSync"; import { NotificationInbox } from "@/components/notifications/NotificationInbox"; import { signOutEverywhere } from "@/lib/auth/sign-out"; @@ -85,6 +86,11 @@ export function OrgWorkspaceShell({ userImage: string | null; children: React.ReactNode; }) { + // ADR 23 — see the org layout. This tree also carries a bell but never + // synced the subscriber behind it, and it is where the routing-mode + // preference is set, so the sync has to run here for that to take effect. + useNovuSubscriberSync(); + // usePathname() returns the URL-encoded path, while orgWorkspaceId (from // route params) is decoded — decode so basePath.slice + isActiveRoute compare // like-for-like even for ids that need encoding. `?? ""` guards a null path; diff --git a/app/dashboard/org-workspace/[orgWorkspaceId]/settings/components/NotificationRoutingSection.tsx b/app/dashboard/org-workspace/[orgWorkspaceId]/settings/components/NotificationRoutingSection.tsx index d561b17d0..48b68e9a4 100644 --- a/app/dashboard/org-workspace/[orgWorkspaceId]/settings/components/NotificationRoutingSection.tsx +++ b/app/dashboard/org-workspace/[orgWorkspaceId]/settings/components/NotificationRoutingSection.tsx @@ -4,8 +4,14 @@ * Section: notification routing preferences. * * Operator chooses where org-lifecycle events appear (bell, email, or - * neither). Novu dispatchers in lib/novu/org-workflows.ts read this off - * the operator's profile when routing multi-org notifications. + * neither). + * + * The chosen mode is pushed onto the user's Novu subscriber record as + * `data.routingMode` / `routingBell` / `routingEmail` by + * `POST /api/novu/subscriber`, and the Novu workflow conditions gate the + * channel steps on those flags — the same mechanism the category preferences + * use. This docstring previously claimed `lib/novu/org-workflows.ts` read the + * column directly; it never did, and the setting was inert (ADR 23). */ import { useState } from "react"; diff --git a/app/dashboard/organization/[orgId]/layout.tsx b/app/dashboard/organization/[orgId]/layout.tsx index c6402e7c2..2f7d20d54 100644 --- a/app/dashboard/organization/[orgId]/layout.tsx +++ b/app/dashboard/organization/[orgId]/layout.tsx @@ -57,10 +57,14 @@ const MOBILE_TABS: { surface: "operations.read", }, { + // No surface gate, matching the desktop entry (ADR 23). The Settings page + // floors at active membership and each tab carries its own gate, so an + // ordinary member reaches it for the Notifications tab and nothing else. + // Gating only the desktop sidebar would have left mobile LEARNER/EXPERT + // users with no route to their own notification preferences. label: "Settings", path: "settings", Icon: Settings, - surface: "settings.manage", }, ]; @@ -73,6 +77,7 @@ import { DashboardContextBar } from "@/components/dashboard/DashboardContextBar" import { LinkPendingIcon } from "@/components/ui/NavLink"; import { DashboardErrorBoundary } from "@/components/DashboardErrorBoundary"; import { useSession } from "@/lib/auth-client"; +import { useNovuSubscriberSync } from "@/hooks/useNovuSubscriberSync"; import { signOutEverywhere } from "@/lib/auth/sign-out"; import { hasOrgPermission, type OrgSurface } from "@/lib/auth/org-permissions"; import { @@ -168,6 +173,12 @@ export default function OrgLayout({ const router = useRouter(); const { data: session, isPending: isSessionLoading } = useSession(); + // ADR 23 — the personal dashboards did this and the org tree did not, so a + // user onboarded straight into an org by invite was never POSTed to + // /api/novu/subscriber. Their Novu record stayed bare and any template + // interpolating subscriber.firstName / email degraded. + useNovuSubscriberSync(); + const { data: org, error, @@ -449,7 +460,12 @@ export default function OrgLayout({ name: "Settings", icon: Settings, path: "settings", - show: can("settings.manage") || can("integrations.read"), + // Ungated as of ADR 23. The PAGE has always floored at active + // membership — each tab carries its own gate and UrlTabs renders + // nothing when none apply — but the nav entry demanded an operator + // grant, so a LEARNER or EXPERT could reach Settings only by typing the + // URL. That gap became user-visible once the member-level Notifications + // tab landed there. Non-operators now see Settings with that one tab. }, ]; diff --git a/app/dashboard/organization/[orgId]/settings/SettingsTabs.tsx b/app/dashboard/organization/[orgId]/settings/SettingsTabs.tsx index 64c8b8211..fd00169d0 100644 --- a/app/dashboard/organization/[orgId]/settings/SettingsTabs.tsx +++ b/app/dashboard/organization/[orgId]/settings/SettingsTabs.tsx @@ -29,6 +29,7 @@ import { SsoPanel } from "./SsoPanel"; import { WebhooksPanel } from "./WebhooksPanel"; import { ScimPanel } from "./ScimPanel"; import { DataExportsPanel } from "./DataExportsPanel"; +import { NotificationPreferencesPanel } from "@/components/notifications/NotificationPreferencesPanel"; export function SettingsTabs({ orgId }: { orgId: string }) { const { role, isLoading } = useOrgRole(orgId); @@ -83,6 +84,18 @@ export function SettingsTabs({ orgId }: { orgId: string }) { content: , show: canIntegrations, }, + { + // ADR 23 — the org dashboard carried a notification bell but no way to + // configure it, and no org category existed at all, so the whole ORG_* + // family was unmutable. Deliberately ungated: this configures the + // VIEWER's own delivery, not org config, so it needs no matrix key and + // every active member reaches it — the same floor as Appointments and + // Messages. The preferences themselves are per-user, not per-org, which + // is why the panel is the same one the personal dashboards mount. + value: "notifications", + label: "Notifications", + content: , + }, ]; return ( diff --git a/components/notifications/NotificationInbox.tsx b/components/notifications/NotificationInbox.tsx index c516073fe..66a03a330 100644 --- a/components/notifications/NotificationInbox.tsx +++ b/components/notifications/NotificationInbox.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import { Inbox } from "@novu/nextjs"; import { Bell } from "lucide-react"; import { useRouter } from "next/navigation"; @@ -7,10 +8,62 @@ import { useSession } from "@/lib/auth-client"; const NOVU_APP_ID = process.env.NEXT_PUBLIC_NOVU_APP_ID; +type OrgMembershipLite = { + organizationId: string; + organizationName: string; +}; + export function NotificationInbox() { const router = useRouter(); const { data: session } = useSession(); + const memberships = useMemo(() => { + const raw = (session?.user as Record | undefined) + ?.organizationMemberships; + if (!Array.isArray(raw)) return [] as OrgMembershipLite[]; + // Validate rather than coerce. `String(someObject)` yields + // "[object Object]", which would become a tab filter matching nothing and a + // label rendering that literal — a malformed entry should drop out, not + // produce a broken tab. (Also the SonarCloud finding on this block.) + return raw.flatMap((m): OrgMembershipLite[] => { + if (typeof m !== "object" || m === null) return []; + const { organizationId, organizationName } = m as Record; + if (typeof organizationId !== "string" || organizationId === "") return []; + return [ + { + organizationId, + organizationName: + typeof organizationName === "string" && organizationName !== "" + ? organizationName + : "Organization", + }, + ]; + }); + }, [session?.user]); + + /** + * ADR 23 — one subscriber per user means every context shares a feed. Tabs + * filter it back apart on the `scope` / `organizationId` the payloads now + * carry. + * + * Only rendered for someone who actually belongs to an organization: a purely + * B2C consultant has one context, and an "All / Personal" pair that always + * shows the same list is noise. `scope` exists precisely so this filter can be + * written — Novu matches payload fields by equality, and "organizationId is + * null" is not expressible that way. + */ + const tabs = useMemo(() => { + if (memberships.length === 0) return undefined; + return [ + { label: "All", filter: {} }, + { label: "Personal", filter: { data: { scope: "personal" } } }, + ...memberships.map((m) => ({ + label: m.organizationName, + filter: { data: { organizationId: m.organizationId } }, + })), + ]; + }, [memberships]); + if (!session?.user?.id || !NOVU_APP_ID) { return null; } @@ -19,6 +72,7 @@ export function NotificationInbox() { { diff --git a/components/notifications/NotificationPreferencesPanel.tsx b/components/notifications/NotificationPreferencesPanel.tsx index 30db39aa6..7cf643742 100644 --- a/components/notifications/NotificationPreferencesPanel.tsx +++ b/components/notifications/NotificationPreferencesPanel.tsx @@ -22,6 +22,9 @@ interface NotificationPreferences { trialNotifications: boolean; subscriptionAlerts: boolean; marketingEmails: boolean; + orgBillingAlerts: boolean; + orgMembershipAlerts: boolean; + orgProgramAlerts: boolean; quietHoursEnabled: boolean; quietHoursStart: string | null; quietHoursEnd: string | null; @@ -97,11 +100,44 @@ const CATEGORY_FIELDS: ToggleField[] = [ }, ]; +/** + * ADR 23 — the seven categories above are all B2C-shaped, so every ORG_* + * workflow was unmutable. Rendered only for someone who actually belongs to an + * organization; a purely B2C user has nothing behind these switches. + */ +const ORG_CATEGORY_FIELDS: ToggleField[] = [ + { + key: "orgBillingAlerts", + label: "Billing & Payouts", + description: "Invoices, dunning, wallet balance, payouts, and overages", + }, + { + key: "orgMembershipAlerts", + label: "Membership", + description: "Invitations, roster changes, and role updates", + }, + { + key: "orgProgramAlerts", + label: "Programs", + description: "Cap warnings, exhausted programs, and renewals", + }, +]; + export function NotificationPreferencesPanel() { const { data: session } = useSession(); const queryClient = useQueryClient(); const { toast } = useToast(); + // Same predicate the Inbox tabs use, so the two surfaces agree on whether + // this user has an org context at all. + const hasOrgMembership = Array.isArray( + (session?.user as Record | undefined) + ?.organizationMemberships, + ) + ? ((session?.user as Record) + .organizationMemberships as unknown[]).length > 0 + : false; + const { data: preferences, isLoading, @@ -188,6 +224,9 @@ export function NotificationPreferencesPanel() { trialNotifications: true, subscriptionAlerts: true, marketingEmails: false, + orgBillingAlerts: true, + orgMembershipAlerts: true, + orgProgramAlerts: true, quietHoursEnabled: false, quietHoursStart: null, quietHoursEnd: null, @@ -275,6 +314,34 @@ export function NotificationPreferencesPanel() { )} + {/* Organization categories — hidden for users with no membership */} + {prefs.allNotifications && hasOrgMembership && ( + + + Organization + + + {ORG_CATEGORY_FIELDS.map((field, index) => ( +
+ {index > 0 && } +
+
+ +

{field.description}

+
+ + handleToggle(field.key, checked) + } + /> +
+
+ ))} +
+
+ )} + {/* Quiet Hours */} {prefs.allNotifications && ( diff --git a/docs/enterprise/50-operations/09-novu-console-conditions.md b/docs/enterprise/50-operations/09-novu-console-conditions.md new file mode 100644 index 000000000..840fdad65 --- /dev/null +++ b/docs/enterprise/50-operations/09-novu-console-conditions.md @@ -0,0 +1,94 @@ +--- +title: Novu console conditions for notification scoping +band: 50-operations +audience: operator +status: live +last-reviewed: 2026-07-30 +--- + +# Novu console conditions runbook + +[ADR 23](../70-design-decisions/23-notification-scope.md) made notifications carry their organization scope and made the organization preference categories writable. The application half of that is complete: every field below is written to the Novu subscriber record by `POST /api/novu/subscriber` and `PUT /api/novu/preferences`. + +The other half lives in the Novu console and cannot be done from the repository. Until the conditions in this document exist, **the preference switches save, display and read back correctly but do not gate delivery** — a member who turns off billing alerts still receives them. Nothing regresses in the meantime, because the default for every flag is permissive; the switches are simply inert. + +This document exists so that work is mechanical rather than reverse-engineered from the code. Work through it once and the feature is complete. + +## Where the flags come from + +Every flag is a key on the subscriber's `data` object. The two writers are `lib/novu/subscriber.ts` — `syncSubscriber` for the routing flags and `updateSubscriberPreferences` for the category flags. In the Novu step editor these are referenced as `subscriber.data.`. + +| Key | Type | Default | Written from | +|---|---|---|---| +| `routingBell` | boolean | `true` | `OrgWorkspaceProfile.notificationRoutingMode` | +| `routingEmail` | boolean | `true` | `OrgWorkspaceProfile.notificationRoutingMode` | +| `routingMode` | string | `BELL_AND_EMAIL` | the same column, kept for readability in the console | +| `categoryOrgBilling` | boolean | `true` | `NotificationPreference.orgBillingAlerts` | +| `categoryOrgMembership` | boolean | `true` | `NotificationPreference.orgMembershipAlerts` | +| `categoryOrgProgram` | boolean | `true` | `NotificationPreference.orgProgramAlerts` | + +The seven pre-existing `category*` flags are unchanged and already wired; do not touch them. + +## Step one: the routing flags + +These gate the channel rather than the event, so they apply to **every** workflow that has the corresponding step, not only the organization ones. + +On each workflow's **In-App** step, add the condition `subscriber.data.routingBell` **is true**. On each workflow's **Email** step, add `subscriber.data.routingEmail` **is true**. + +Both default to `true`, so a subscriber who has never touched the setting is unaffected. Only an operator who has explicitly chosen `BELL_ONLY`, `EMAIL_ONLY` or `NEITHER` in their workspace settings sees a difference — which is the behaviour the settings panel has been promising and not delivering. + +## Step two: the organization category flags + +Each workflow below takes exactly one category condition, applied to **all** of its steps. The mapping follows the audience rather than the noun: an operator who wants invoices but not roster churn, and an expert who wants the reverse, are the two cases the split exists to serve. + +### `categoryOrgBilling` — money in and money out + +| Workflow slug | +|---| +| `org-invoice-issued` | +| `org-invoice-paid` | +| `org-invoice-overdue` | +| `org-wallet-topup-confirmed` | +| `org-wallet-low` | +| `org-payout-completed` | +| `org-payout-failed` | +| `org-payout-reversed` | +| `org-member-overage-timed-out` | +| `org-program-overage-due` | + +The two overage workflows sit here rather than under programs because both are addressed to the member who now owes money, not to the operator watching a cap. + +### `categoryOrgMembership` — who is in the organization + +| Workflow slug | +|---| +| `org-invite-sent` | +| `org-invite-accepted` | +| `org-expert-removed` | +| `org-sso-provider-deleted` | +| `org-sso-cert-expiring` | + +The two SSO workflows are membership rather than a category of their own: they concern how people get into the organization, and an operator who mutes roster noise is unlikely to want certificate warnings routed elsewhere. Revisit this if an organization asks for security alerts to be separately non-mutable. + +### `categoryOrgProgram` — entitlement and capacity + +| Workflow slug | +|---| +| `org-program-exhausted` | +| `org-program-cap-near` | +| `org-license-renewal-upcoming` | +| `org-data-export-ready` | + +`org-data-export-ready` is the loosest fit. It is operational rather than commercial, and it sits here because it is addressed to the same operator audience as the capacity warnings. + +## Step three: verify + +Two checks are enough to prove the wiring end to end. + +For a category, open the organization dashboard, go to **Settings → Notifications**, turn **Billing & Payouts** off, and trigger an invoice event for that organization. Nothing should arrive. Turn it back on and repeat; the notification should arrive. If it arrives in both cases the condition is missing or is reading the wrong key. + +For routing, set **Notification routing** to `EMAIL_ONLY` in the cross-organization workspace settings, then trigger any organization event. An email should arrive and the bell should stay silent. Note that the routing flags come from `syncSubscriber`, which runs when a dashboard mounts — so sign out and back in, or reload a dashboard, after changing the setting. + +## What is deliberately not conditioned + +The `ORG_*` payloads do not carry a `NotificationScope`. They are unambiguous by construction — every one is an organization-lifecycle event and names its organization in the payload — so a scope discriminator would be redundant. The consequence, recorded in ADR 23, is that these notifications appear under the inbox's **All** tab but not under a specific organization's tab, which filters on `organizationId`. If an operator asks for organization-lifecycle events to file under their organization's tab, the fix is to add the scope to those payloads in code, not to add a console condition. diff --git a/docs/enterprise/70-design-decisions/00-README.md b/docs/enterprise/70-design-decisions/00-README.md index 8f086a74b..9f08a81e1 100644 --- a/docs/enterprise/70-design-decisions/00-README.md +++ b/docs/enterprise/70-design-decisions/00-README.md @@ -47,3 +47,4 @@ All twenty-two ADRs below are written and live (#793 wrote the first twelve; #87 | 20 | [Org visibility into member sessions](20-org-visibility-into-member-sessions.md) | An organization sees that a session happened — member, counterpart, plan title, time, status, cost — and never what happened in it; notes, feedback, recordings, document contents and chat stay with the two participants, enforced by select allowlists that branch on whether the scope constrains the caller to be one of them. | | 21 | [Single writer for payment confirmation](21-single-writer-for-payment-confirmation.md) | `Payment.paymentStatus` is written by the confirmation pipeline and by nothing else; the webhook, the client's signature return, the on-demand sync and the reconcile cron all call `routeCapturedPayment` rather than recording the conclusion themselves, because a second writer turns `handlePaymentSuccess`'s already-SUCCEEDED guard from "this work is done" into "this work will never be done". | | 22 | [Queue posture, revisited with measurements](22-queue-posture-revisited-with-measurements.md) | Measured cadence shows sub-hourly GitHub Actions schedules deliver roughly one tick per 100 minutes while nothing overlaps, so the defect is missed ticks rather than contention; the QStash escalation in ADR 14 is now authorised, Temporal stays out on cost and fit rather than on the architectural objection its 2026 Lambda workers retired, and Inngest is recorded with concrete adoption triggers. | +| 23 | [Notification scope](23-notification-scope.md) | A notification inherits the org-ness of the record that triggered it: dual-context payloads carry a required `NotificationScope`, deep links resolve to the owning dashboard rather than bouncing everyone to their personal tree, the Inbox filters the shared feed back apart by scope, and three org categories make the previously unmutable `ORG_*` family configurable. | diff --git a/docs/enterprise/70-design-decisions/23-notification-scope.md b/docs/enterprise/70-design-decisions/23-notification-scope.md new file mode 100644 index 000000000..988198c8c --- /dev/null +++ b/docs/enterprise/70-design-decisions/23-notification-scope.md @@ -0,0 +1,49 @@ +--- +title: A notification inherits the org-ness of the record that triggered it +band: 70-design-decisions +audience: sde3 +status: live +last-reviewed: 2026-07-30 +--- + +# ADR 23 — Notifications are scoped, routed and mutable per context + +## Context + +[ADR 19](19-personal-vs-org-dashboard-split.md) split the dashboards by the org-ness of the underlying session, plan or payment, and every read path learned the rule: the scoped list helpers, the chat channel query, the appointment feeds and the money views all filter on `organizationId`. The notification layer learned none of it. A July 2026 audit of the Novu stack found the split invisible from end to end. + +There is one Novu subscriber per user, keyed on `User.id`, and never one per profile or per organization. No topics are used and no tags are set. Of the roughly forty payload types, not one carried an `organizationId` — the single occurrence of that identifier anywhere under `lib/novu/` was a Prisma `where` clause inside a roster resolver. The organization-lifecycle payloads carried an `orgName` string, but that is display copy rather than anything a client can filter on, and the payloads that fire in *both* contexts — appointments, bookings, payments, recordings — carried no discriminator at all. The `Inbox` component rendered with no `tabs` and no `filter`. + +The result was that a consultant who also delivers for an organization received one merged feed, rendered identically on every dashboard they could open, in which an organization-hosted booking was byte-for-byte indistinguishable from a business-to-consumer one. Three further problems followed from the same root. Deep links pointed at the wrong tree: the booking-request notification hardcoded the personal Requests page even when the plan was organization-hosted, where the personal scope pins `organizationId: null` and the request is therefore filtered out of the list the user was just sent to. Deterministic transaction ids, derived by hashing the workflow and the canonical payload, collided across contexts whenever two structurally identical events occurred — with no organization field and an optional `appointmentId`, Novu deduplicated the second one away silently. And the seven notification categories a user could configure were all business-to-consumer in shape, so the entire `ORG_*` workflow family was unmutable: an organization owner could not turn off invoice dunning. + +A separate, smaller finding sat alongside these. `OrgWorkspaceProfile.notificationRoutingMode` was written by a settings panel, read back into that panel, and consumed by nothing. Its own component docstring asserted that the dispatchers in `lib/novu/org-workflows.ts` read it; they never did. An operator who selected "email only" continued to receive bell notifications and was told the preference had saved. + +## Decision + +**A notification inherits the org-ness of the record that triggered it, and is delivered and deep-linked into the dashboard that owns that record.** + +Every payload describing work that can happen in either context composes a `NotificationScope`, carrying `organizationId`, a derived `scope` of `personal` or `org`, and an optional `orgName` for display. The fields are required rather than optional, so a trigger site that forgets to attribute its notification fails the build instead of quietly emitting another unattributable one; adding the type flushed out thirteen call sites, which is a fair measure of how far the drift had spread. `scope` is derivable from `organizationId` and is stored anyway, because Novu's `Inbox` filters tabs by payload equality and "this field is null" cannot be expressed that way. Both are produced by one helper so they cannot disagree. + +Attribution is not delivery. The scope tag changes how a notification is filed and where it points; it does not widen who receives it, and the recipient lists are untouched by this decision. + +**Deep links resolve to the owning tree, with one constraint that shapes the answer.** Several workflows trigger once for many recipients with a single payload, so one href has to be correct for all of them. For organization-hosted work the organization route satisfies that: the learner who attended and the expert who delivered both reach the same page. For business-to-consumer work the link stays a bare `/dashboard`, deliberately rather than by omission — the consultant and the consultee have different personal dashboards, and the capability router already resolves the right one per viewer. The old bare `/dashboard` was not wrong in itself; it was wrong because it was also used for organization work. Where a trigger has exactly one recipient whose side is known, a precise route is used instead. + +**Preferences gain three organization categories** — billing, membership and programs — rather than one blanket switch, because the audiences genuinely differ: an operator wants invoices but not every roster change, while an expert wants delivery notices and no invoices at all. They are per user rather than per organization, matching the one-subscriber-per-user model, and they surface on a Notifications tab in the organization Settings page as well as on the personal dashboards. + +That tab exposed a second instance of the gate-and-page disagreement ADR 19 records. The Settings *page* has always floored at active membership, since its tabs answer to different grants and gating the page on any one of them would lock out a role holding another. The Settings *sidebar entry* demanded an operator grant, so a learner or expert could reach Settings only by typing the URL. The nav entry is now ungated to match the page, and `UrlTabs` shows each role only the tabs it holds. + +The console side of this — the workflow conditions that actually read the flags — is written up as a step-by-step runbook at [50-operations/09-novu-console-conditions](../50-operations/09-novu-console-conditions.md), so it is mechanical rather than reverse-engineered from the code. + +**`notificationRoutingMode` is honoured rather than deleted.** It is pushed onto the subscriber record as `data.routingMode` alongside boolean channel flags, and the Novu workflow conditions gate their channel steps on those — the same mechanism the category preferences already used. Deleting the field was the alternative, and was rejected because the control has been shown to operators and removing it would take away a choice they believe they have made. + +**The organization trees sync their subscriber.** The personal dashboards called `useNovuSubscriberSync` and the organization ones did not, so a user onboarded straight into an organization by invitation was never posted to `/api/novu/subscriber`. Their subscriber record stayed bare and any template interpolating a first name or an email degraded. Both organization trees now sync, which is also what makes the routing preference take effect, since that is where it is set. + +## Consequences + +The transaction-id collisions resolve as a side effect rather than needing their own fix: once the payload carries an organization field, two structurally identical events in different contexts hash differently and both arrive. + +The `ORG_*` workflows do not carry a `NotificationScope`. They are already unambiguous — every one of them is organization-lifecycle by construction and names its organization in the payload — and adding a redundant discriminator to a family that cannot be anything else would be noise. The Inbox tab for an organization filters on `organizationId`, which those payloads would need if they were ever to appear under it; that is a real limitation and the right time to address it is when an organization-lifecycle notification needs to be filed under its organization's tab rather than under All. + +ADR 20's boundary is unchanged and now has a test. The notification payload surface sat entirely outside the allowlist suite that pins the list helpers, even though `RecordingPayload.recordingUrl` puts a live media URL in a notification body. Nothing leaked, because the recipient list came from a participant resolver rather than a roster — but that is the "accident of implementation" ADR 20 exists to stop, and a future change widening that list would have leaked the URL with no test failing. `__tests__/security/novu-payload-allowlist.test.ts` now pins both halves: content-bearing payloads reach only participant-derived recipients, and the organization-roster dispatchers carry no content field. + +What this decision does not do is give an organization its own inbox. There is still one subscriber per human and one feed, now filterable. A per-organization subscriber, or Novu topics keyed by organization, would let an operator hand off notification duty without handing over an account; that is a larger change and nothing has yet asked for it. diff --git a/lib/moderation/cancel-user-engagements.ts b/lib/moderation/cancel-user-engagements.ts index fc60cee9f..bb8b8f538 100644 --- a/lib/moderation/cancel-user-engagements.ts +++ b/lib/moderation/cancel-user-engagements.ts @@ -11,7 +11,11 @@ */ 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 { refundPayment } from "@/lib/payments/operations/refund"; import { refundWholeEventPayments } from "@/lib/payments/operations/event-refunds"; import { @@ -280,6 +284,7 @@ interface NormalizedEngagement { appointments: Array<{ id: string; appointmentType: string; + organizationId: string | null; // amount is number at runtime — the extended client converts BigInt on read payment: Array<{ id: string; amount: number; paymentStatus: string }>; }>; @@ -309,6 +314,9 @@ async function cancelExclusiveEngagement( select: { id: true, appointmentType: true, + // ADR 23 — attribute the cancellation notification to the dashboard that + // owns the session rather than defaulting everyone to their personal one. + organizationId: true, payment: { select: { id: true, amount: true, paymentStatus: true } }, }, } as const; @@ -404,13 +412,16 @@ async function cancelExclusiveEngagement( engagement.consulteeUser?.id, ].filter((id): id is string => !!id); if (userIds.length > 0) { + const engagementOrgId = + engagement.appointments[0]?.organizationId ?? null; void notifyAppointmentCancelled(userIds, { + ...notificationScope(engagementOrgId), appointmentType: engagement.appointments[0]?.appointmentType ?? kind.toUpperCase(), consultantName: engagement.consultantUser?.name || "Consultant", consulteeName: engagement.consulteeUser?.name || "Consultee", planTitle: engagement.planTitle || "N/A", - dashboardUrl: "/dashboard", + dashboardUrl: notificationHref(engagementOrgId, "appointments"), reason: "MODERATION", cancelledBy: "system", }); @@ -478,16 +489,23 @@ async function cancelGroupEvent( paymentStatus: "SUCCEEDED", amount: { gt: 0 }, }, - select: { userId: true }, + select: { + userId: true, + // Every attendee of one event shares its org-ness, so the first row + // decides the scope for the whole batch. + appointment: { select: { organizationId: true } }, + }, }); const attendeeIds = Array.from(new Set(attendees.map((p) => p.userId))); if (attendeeIds.length > 0) { + const eventOrgId = attendees[0]?.appointment?.organizationId ?? null; void notifyAppointmentCancelled(attendeeIds, { + ...notificationScope(eventOrgId), appointmentType: isWebinar ? "WEBINAR" : "CLASS", consultantName: "Consultant", consulteeName: "Attendee", planTitle: "N/A", - dashboardUrl: "/dashboard", + dashboardUrl: notificationHref(eventOrgId, "appointments"), reason: "MODERATION", cancelledBy: "system", }); diff --git a/lib/novu/index.ts b/lib/novu/index.ts index 9717aeed5..a49ade8fd 100644 --- a/lib/novu/index.ts +++ b/lib/novu/index.ts @@ -1,5 +1,19 @@ export { getNovuClient, isNovuConfigured, validateNovuConfig } from "./client"; -export { NOVU_WORKFLOWS } from "./workflows"; +export { NOVU_WORKFLOWS, notificationScope } from "./workflows"; +export type { NotificationScope } from "./workflows"; +export { notificationHref, personalHref, scopedHref } from "./resolve-href"; + +/** + * Import `notificationScope` and the href helpers from `./workflows` and + * `./resolve-href` DIRECTLY at trigger sites, not through this barrel. + * + * They are re-exported here for convenience, but tests routinely stub this + * module — `jest.mock("../../lib/novu", () => ({ notifyX: jest.fn() }))` — to + * keep notifications off the wire. A barrel mock replaces the whole module, so + * a pure helper pulled through it resolves to `undefined` and throws at the + * call site, turning a 200 into a 500 in any suite that mocks the barrel. + * These helpers are deterministic and want to run for real in tests anyway. + */ export { syncSubscriber, deleteSubscriber, diff --git a/lib/novu/resolve-href.ts b/lib/novu/resolve-href.ts new file mode 100644 index 000000000..0f312d394 --- /dev/null +++ b/lib/novu/resolve-href.ts @@ -0,0 +1,76 @@ +import { getAppUrl } from "@/lib/url"; + +/** + * Where a notification should land. + * + * ADR 19 puts org-hosted work in the organization dashboard and B2C work in the + * personal ones. Notification deep-links did not follow: booking requests + * hardcoded `/dashboard/consultant//requests` even for org-hosted plans, and + * everything else sent a bare `/dashboard`, which the router bounces to the + * recipient's personal tree regardless of who owns the session. A member + * clicking an org-session notification was dropped into their personal + * dashboard, where the item is filtered out by design. + * + * There is a constraint that shapes all of this: several workflows trigger once + * for MANY recipients with a single payload, so one href has to be right for + * every one of them. + * + * - Org-hosted work → the org route. Correct for every participant, because + * the LEARNER who attended and the EXPERT who delivered both reach the same + * `/dashboard/organization//…` page. + * - B2C work → a bare `/dashboard`. Deliberately NOT a guessed personal + * route: the consultant and the consultee have different dashboards, and + * the capability router already resolves the right one per viewer. The old + * bare `/dashboard` was only wrong because it was used for org work too. + * + * Use {@link personalHref} instead when a trigger has exactly one recipient and + * their side is known — a precise link beats a router bounce. + */ + +type Surface = "appointments" | "requests" | "recordings" | "earnings"; + +/** + * Multi-recipient safe. `organizationId` null means B2C. + */ +export function notificationHref( + organizationId: string | null | undefined, + surface: Surface, +): string { + const base = getAppUrl(); + if (!organizationId) { + // The router picks the viewer's own dashboard. One payload, N recipients, + // possibly on different sides — nothing more specific is correct here. + return `${base}/dashboard`; + } + return `${base}/dashboard/organization/${organizationId}/${surface}`; +} + +/** + * Single-recipient variant: when the trigger targets exactly one person and we + * know which personal dashboard is theirs, link straight to it. + */ +export function personalHref( + kind: "consultant" | "consultee", + profileId: string, + surface: Surface, +): string { + return `${getAppUrl()}/dashboard/${kind}/${profileId}/${surface}`; +} + +/** + * Convenience for the common case: one recipient whose side is known, but the + * work may be org-hosted. Falls back to the org route when it is. + */ +export function scopedHref(args: { + organizationId: string | null | undefined; + surface: Surface; + personal?: { kind: "consultant" | "consultee"; profileId: string }; +}): string { + if (args.organizationId) { + return notificationHref(args.organizationId, args.surface); + } + if (args.personal) { + return personalHref(args.personal.kind, args.personal.profileId, args.surface); + } + return notificationHref(null, args.surface); +} diff --git a/lib/novu/subscriber.ts b/lib/novu/subscriber.ts index 57e946535..6b20bfa38 100644 --- a/lib/novu/subscriber.ts +++ b/lib/novu/subscriber.ts @@ -13,6 +13,15 @@ interface SubscriberData { phone?: string; avatar?: string; locale?: string; + /** + * ADR 23 — `OrgWorkspaceProfile.notificationRoutingMode` for operators who + * own a workspace. Written onto subscriber data so the Novu workflow + * conditions can honour it, which is the same mechanism the category flags + * below use. Before this it was written by the UI, displayed back, and read + * by nothing: an operator who chose EMAIL_ONLY still got bell notifications + * and was told the setting had saved. + */ + routingMode?: "BELL_AND_EMAIL" | "BELL_ONLY" | "EMAIL_ONLY" | "NEITHER"; } /** @@ -37,6 +46,17 @@ export async function syncSubscriber(data: SubscriberData): Promise { phone: data.phone || undefined, avatar: data.avatar || undefined, locale: data.locale || "en", + data: { + routingMode: data.routingMode ?? "BELL_AND_EMAIL", + routingBell: + data.routingMode === "BELL_AND_EMAIL" || + data.routingMode === "BELL_ONLY" || + data.routingMode === undefined, + routingEmail: + data.routingMode === "BELL_AND_EMAIL" || + data.routingMode === "EMAIL_ONLY" || + data.routingMode === undefined, + }, }); console.log(`[Novu] Subscriber synced: ${data.userId}`); } catch (error) { @@ -69,6 +89,10 @@ export async function updateSubscriberPreferences( trialNotifications?: boolean; subscriptionAlerts?: boolean; marketingEmails?: boolean; + // Org category preferences (ADR 23) + orgBillingAlerts?: boolean; + orgMembershipAlerts?: boolean; + orgProgramAlerts?: boolean; }, ): Promise { if (!isNovuConfigured()) return; @@ -90,6 +114,10 @@ export async function updateSubscriberPreferences( categoryTrials: preferences.trialNotifications ?? true, categorySubscriptions: preferences.subscriptionAlerts ?? true, categoryMarketing: preferences.marketingEmails ?? false, + // ADR 23 — the ORG_* workflow family was unmutable before these. + categoryOrgBilling: preferences.orgBillingAlerts ?? true, + categoryOrgMembership: preferences.orgMembershipAlerts ?? true, + categoryOrgProgram: preferences.orgProgramAlerts ?? true, }, }, userId, diff --git a/lib/novu/workflows.ts b/lib/novu/workflows.ts index cb85ba1e4..9e3bb9a68 100644 --- a/lib/novu/workflows.ts +++ b/lib/novu/workflows.ts @@ -124,11 +124,52 @@ export const NOVU_WORKFLOWS = { ORG_EXPERT_REMOVED: "org-expert-removed", } as const; +// ============================================================================ +// Notification scope +// ============================================================================ + +/** + * Which dashboard owns the work a notification is about. + * + * ADR 19 splits the dashboards by the org-ness of the underlying session, plan + * or payment, but the notification layer never learned the split: one Novu + * subscriber per user, no org field on any payload, and an Inbox with no + * filter. A consultant who also delivers for an organization got one merged + * feed in which an org-session booking was byte-identical to a B2C one. + * + * Every payload for work that can happen in both contexts carries this. It is + * REQUIRED rather than optional on purpose — an omission should fail the build + * at the call site, not silently produce another unattributable notification. + * + * `scope` is derivable from `organizationId` and is stored anyway: Novu's Inbox + * filters tabs on payload equality, and "this field is null" is not expressible + * that way. Use {@link notificationScope} so the two can never disagree. + */ +export type NotificationScope = { + /** Null for B2C work. Copied from the triggering record's own column. */ + organizationId: string | null; + scope: "personal" | "org"; + /** Display name of the owning org. Absent for personal work. */ + orgName?: string; +}; + +export function notificationScope( + organizationId: string | null | undefined, + orgName?: string | null, +): NotificationScope { + const orgId = organizationId ?? null; + return { + organizationId: orgId, + scope: orgId ? "org" : "personal", + ...(orgId && orgName ? { orgName } : {}), + }; +} + // ============================================================================ // Payload Type Definitions // ============================================================================ -export type AppointmentPayload = { +export type AppointmentPayload = NotificationScope & { appointmentId?: string; appointmentType: string; consultantName: string; @@ -148,7 +189,7 @@ export type AppointmentRescheduledPayload = AppointmentPayload & { newDateTime?: string; }; -export type PaymentSuccessPayload = { +export type PaymentSuccessPayload = NotificationScope & { amount: number; currency: string; consultantName: string; @@ -168,7 +209,7 @@ export type PaymentFailedPayload = { retryUrl?: string; }; -export type RefundPayload = { +export type RefundPayload = NotificationScope & { amount: number; currency: string; reason?: string; @@ -219,7 +260,7 @@ export type SubscriptionPayload = { dashboardUrl: string; }; -export type BookingRequestPayload = { +export type BookingRequestPayload = NotificationScope & { consulteeName: string; planTitle: string; appointmentType: string; @@ -275,7 +316,7 @@ export type DisputePayload = { dashboardUrl: string; }; -export type RecordingPayload = { +export type RecordingPayload = NotificationScope & { appointmentType: string; consultantName: string; consulteeName?: string; diff --git a/lib/payments/webhooks/handlers.ts b/lib/payments/webhooks/handlers.ts index d5f0647b7..e923a5827 100644 --- a/lib/payments/webhooks/handlers.ts +++ b/lib/payments/webhooks/handlers.ts @@ -34,6 +34,8 @@ import { notifyPaymentFailed, notifyAppointmentBooked, } from "@/lib/novu"; +import { notificationScope } from "@/lib/novu/workflows"; +import { notificationHref } from "@/lib/novu/resolve-href"; import { processQualifyingAction, processConsultantBookingReferral, @@ -774,6 +776,10 @@ ACTION REQUIRED: Customer was charged but appointment was NOT created! const appointmentForNotif = await prisma.appointment.findUnique({ where: { id: appointmentId }, select: { + // ADR 23 — the notification inherits the org-ness of the record that + // triggered it, so both payloads below can be attributed and routed. + organizationId: true, + organization: { select: { name: true } }, consultation: { select: { consultationPlan: { @@ -820,10 +826,19 @@ ACTION REQUIRED: Customer was charged but appointment was NOT created! ? metadata.appointmentType : metadata.appointmentType || "Appointment"; - const dashboardUrl = `${getAppUrl()}/dashboard`; + const orgId = appointmentForNotif?.organizationId ?? null; + const scope = notificationScope( + orgId, + appointmentForNotif?.organization?.name, + ); + // Org-hosted → the org route, which is right for every recipient of the + // batched trigger below. B2C → the bare /dashboard router bounce, because + // consultant and consultee land in different personal trees. + const dashboardUrl = notificationHref(orgId, "appointments"); // Notify consultee of successful payment void notifyPaymentSuccess(userId, { + ...scope, amount, currency, consultantName: consultantNameForNotif, @@ -839,6 +854,7 @@ ACTION REQUIRED: Customer was charged but appointment was NOT created! } void notifyAppointmentBooked(notifUserIds, { + ...scope, appointmentId, appointmentType: metadata.appointmentType, consultantName: consultantNameForNotif, diff --git a/lib/stream/recording-handlers.ts b/lib/stream/recording-handlers.ts index 59bbc2112..168221d68 100644 --- a/lib/stream/recording-handlers.ts +++ b/lib/stream/recording-handlers.ts @@ -11,6 +11,8 @@ import { notifyRecordingAvailable, notifyRecordingFailed, } from "@/lib/novu/service"; +import { notificationScope } from "@/lib/novu/workflows"; +import { notificationHref } from "@/lib/novu/resolve-href"; import { getAppUrl } from "@/lib/url"; import { generateRecordingTitle, @@ -369,10 +371,18 @@ export async function handleRecordingReady( // notification via `after()` so it survives the webhook response. after(() => notifyRecordingAvailable(userIds, { + // ADR 20 still holds: `userIds` here is the participant list from + // getEventAttendeeIds, never an org roster, so the recordingUrl below + // does not reach an operator. The scope tag is attribution only — it + // does not widen who receives this. + ...notificationScope(appointment?.organizationId), appointmentType, consultantName, recordingUrl: url, - dashboardUrl: `${getAppUrl()}/dashboard`, + dashboardUrl: notificationHref( + appointment?.organizationId, + "recordings", + ), }).catch((err) => streamLogger.error("Failed to send recording notification", err, { streamCallId, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4556f1121..b98507453 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -497,6 +497,15 @@ model NotificationPreference { subscriptionAlerts Boolean @default(true) marketingEmails Boolean @default(false) + // Org category preferences (ADR 23). The seven categories above are all + // B2C-shaped, so the entire ORG_* workflow family was unmutable — an org + // OWNER could not turn off invoice dunning. Split three ways rather than one + // "org" switch because the audiences differ: an operator wants billing but + // not every roster change, an EXPERT wants delivery but no invoices at all. + orgBillingAlerts Boolean @default(true) // invoices, wallet, payouts, overages + orgMembershipAlerts Boolean @default(true) // invites, roster + role changes + orgProgramAlerts Boolean @default(true) // caps, exhaustion, renewals + // Quiet hours quietHoursEnabled Boolean @default(false) quietHoursStart String? // "22:00" format in user timezone diff --git a/schemas/user.ts b/schemas/user.ts index 3e8a8c016..a56df6071 100644 --- a/schemas/user.ts +++ b/schemas/user.ts @@ -339,6 +339,12 @@ export const NotificationPreferenceSchema = z.object({ subscriptionAlerts: z.boolean().default(true), marketingEmails: z.boolean().default(false), + // Org category preferences (ADR 23) — the categories above are all + // B2C-shaped, which left the ORG_* workflow family unmutable. + orgBillingAlerts: z.boolean().default(true), + orgMembershipAlerts: z.boolean().default(true), + orgProgramAlerts: z.boolean().default(true), + // Quiet hours quietHoursEnabled: z.boolean().default(false), quietHoursStart: z.string().nullable().default(null), diff --git a/scripts/appointments/auto-complete-appointments.ts b/scripts/appointments/auto-complete-appointments.ts index 2342aa1c5..fd1e31cb4 100644 --- a/scripts/appointments/auto-complete-appointments.ts +++ b/scripts/appointments/auto-complete-appointments.ts @@ -26,7 +26,8 @@ import { TrialSessionStatus, } from "@prisma/client"; import { notifyAppointmentCompleted } from "../../lib/novu/service"; -import { getAppUrl } from "../../lib/url"; +import { notificationScope } from "../../lib/novu/workflows"; +import { notificationHref } from "../../lib/novu/resolve-href"; import { withCronLock } from "@/lib/cron/with-cron-lock"; import { REQUEST_ALLOWED_FROM } from "@/lib/booking/transitions"; @@ -293,13 +294,17 @@ async function completeConsultations(): Promise<{ ); if (userIds.length > 0) { void notifyAppointmentCompleted(userIds, { + ...notificationScope(consultation.appointment?.organizationId), appointmentType: "consultation", consultantName: consultation.consultationPlan?.consultantProfile?.user?.name ?? "Consultant", consulteeName: consultation.requestedBy?.user?.name ?? "Consultee", planTitle: consultation.consultationPlan.title, - dashboardUrl: `${getAppUrl()}/dashboard`, + dashboardUrl: notificationHref( + consultation.appointment?.organizationId, + "appointments", + ), }).catch((error) => console.error( `[auto-complete] Failed to send consultation completion notification:`, @@ -423,13 +428,17 @@ async function completeSubscriptions(): Promise<{ ); if (userIds.length > 0) { void notifyAppointmentCompleted(userIds, { + ...notificationScope(subscription.appointments[0]?.organizationId), appointmentType: "subscription", consultantName: subscription.subscriptionPlan?.consultantProfile?.user?.name ?? "Consultant", consulteeName: subscription.requestedBy?.user?.name ?? "Consultee", planTitle: subscription.subscriptionPlan.title, - dashboardUrl: `${getAppUrl()}/dashboard`, + dashboardUrl: notificationHref( + subscription.appointments[0]?.organizationId, + "appointments", + ), }).catch((error) => console.error( `[auto-complete] Failed to send subscription completion notification:`, diff --git a/scripts/appointments/detect-consultant-no-shows.ts b/scripts/appointments/detect-consultant-no-shows.ts index fd1fcda67..a03ad3e40 100644 --- a/scripts/appointments/detect-consultant-no-shows.ts +++ b/scripts/appointments/detect-consultant-no-shows.ts @@ -29,7 +29,8 @@ import { notifyAppointmentCancelled, notifyRefundProcessed, } from "../../lib/novu/service"; -import { getAppUrl } from "../../lib/url"; +import { notificationScope } from "../../lib/novu/workflows"; +import { notificationHref } from "../../lib/novu/resolve-href"; import { refundPayment } from "@/lib/payments/operations/refund"; import { withCronLock } from "@/lib/cron/with-cron-lock"; import { CANCELLABLE_FROM } from "@/lib/booking/transitions"; @@ -247,11 +248,13 @@ function notifyNoShowParties( "Consultant"; const consulteeName = consultation.requestedBy?.user?.name ?? "Consultee"; const planTitle = consultation.consultationPlan?.title ?? "Consultation"; - const dashboardUrl = `${getAppUrl()}/dashboard`; + const noShowOrgId = consultation.appointment?.organizationId ?? null; + const dashboardUrl = notificationHref(noShowOrgId, "appointments"); void notifyAppointmentCancelled( [party.consultantUserId, party.consulteeUserId], { + ...notificationScope(noShowOrgId), appointmentId: party.appointmentId, appointmentType: "consultation", consultantName, @@ -265,6 +268,7 @@ function notifyNoShowParties( if (refundedPaise > 0 && paidPayment) { void notifyRefundProcessed(party.consulteeUserId, { + ...notificationScope(noShowOrgId), amount: refundedPaise, currency: paidPayment.currency, reason: "consultant no-show", diff --git a/scripts/appointments/send-appointment-reminders.ts b/scripts/appointments/send-appointment-reminders.ts index 236913052..defd3a723 100644 --- a/scripts/appointments/send-appointment-reminders.ts +++ b/scripts/appointments/send-appointment-reminders.ts @@ -16,7 +16,8 @@ import prisma from "../../lib/prisma"; import redis from "../../lib/redis"; import { notifyAppointmentReminder } from "../../lib/novu/service"; -import { getAppUrl } from "../../lib/url"; +import { notificationScope } from "../../lib/novu/workflows"; +import { notificationHref } from "../../lib/novu/resolve-href"; import { withCronLock } from "@/lib/cron/with-cron-lock"; // Reminder windows (in milliseconds) @@ -208,17 +209,16 @@ async function sendRemindersForWindow(window: { // Redis unavailable — send anyway rather than skip silently } - const baseUrl = getAppUrl(); - await notifyAppointmentReminder( uniqueUserIds, { + ...notificationScope(apt.organizationId), appointmentType, consultantName, consulteeName, planTitle, dateTime: slot.startsAt.toISOString(), - dashboardUrl: `${baseUrl}/dashboard`, + dashboardUrl: notificationHref(apt.organizationId, "appointments"), }, // 24h and 1h payloads are identical — key the Novu transactionId by // window so the second reminder isn't deduped away. diff --git a/scripts/refunds/reconcile-pending-refunds.ts b/scripts/refunds/reconcile-pending-refunds.ts index 6be21e036..bc34a8d96 100644 --- a/scripts/refunds/reconcile-pending-refunds.ts +++ b/scripts/refunds/reconcile-pending-refunds.ts @@ -16,6 +16,7 @@ import { mapGatewayRefundStatus } from "@/lib/payments/refund-status"; import { PaymentGateway, Prisma, RefundStatus } from "@prisma/client"; import { listRefunds } from "../../lib/payments"; import { notifyRefundFailed } from "../../lib/novu/service"; +import { notificationScope } from "../../lib/novu/workflows"; import { getAppUrl } from "../../lib/url"; import { withCronLock, LONG_JOB_TTL_MS } from "@/lib/cron/with-cron-lock"; @@ -209,7 +210,7 @@ async function notifyFailedRefundsUnlocked(): Promise status: RefundStatus.FAILED, failedNotifiedAt: null, }, - include: { payment: { select: { userId: true } } }, + include: { payment: { select: { userId: true, organizationId: true } } }, orderBy: { createdAt: "asc" }, }); @@ -243,6 +244,7 @@ async function notifyFailedRefundsUnlocked(): Promise // Fire-and-forget — committed state, no DB writes in the notify path. void notifyRefundFailed(refund.payment.userId, { + ...notificationScope(refund.payment.organizationId), amount: refund.amountPaise, currency: refund.currency, reason: failureReason,