diff --git a/__tests__/dashboard/nav-targets-resolve.test.ts b/__tests__/dashboard/nav-targets-resolve.test.ts index a1cbb0fd3..0fc7f14c6 100644 --- a/__tests__/dashboard/nav-targets-resolve.test.ts +++ b/__tests__/dashboard/nav-targets-resolve.test.ts @@ -63,6 +63,7 @@ describe("org nav targets resolve", () => { "collaborations", "contracts", "purchase-orders", + "catalog", "programs", "billing", "payouts", diff --git a/__tests__/dashboards/filtered-queries-keep-previous-data.test.ts b/__tests__/dashboards/filtered-queries-keep-previous-data.test.ts index 06b184e93..95de2701f 100644 --- a/__tests__/dashboards/filtered-queries-keep-previous-data.test.ts +++ b/__tests__/dashboards/filtered-queries-keep-previous-data.test.ts @@ -33,7 +33,9 @@ const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8"); * keepPreviousData exists for. */ const FILTER_DRIVEN_QUERIES = [ - "app/dashboard/consultant/[consultantId]/(features)/earnings/page.tsx", + // The filtered query moved into the Summary panel when Analytics folded onto + // this route as a tab (ADR 19); `page.tsx` is now a server wrapper. + "app/dashboard/consultant/[consultantId]/(features)/earnings/EarningsSummaryPanel.tsx", "app/dashboard/organization/[orgId]/payouts/PayoutsPageClient.tsx", "app/dashboard/admin/payouts/_sections/EarningsSection.tsx", "app/dashboard/organization/[orgId]/purchase-orders/page.tsx", @@ -72,7 +74,7 @@ describe("filter-driven dashboard queries keep the previous page on screen", () // keepPreviousData `data` is populated on a tab switch, so `!data` is false // and the page keeps its header, stat cards and tabs. const src = read( - "app/dashboard/consultant/[consultantId]/(features)/earnings/page.tsx", + "app/dashboard/consultant/[consultantId]/(features)/earnings/EarningsSummaryPanel.tsx", ); expect(src).toContain("isPlaceholderData"); expect(src).toMatch(/isLoading && !data/); diff --git a/__tests__/enterprise/catalog-archive.test.ts b/__tests__/enterprise/catalog-archive.test.ts new file mode 100644 index 000000000..b90d717db --- /dev/null +++ b/__tests__/enterprise/catalog-archive.test.ts @@ -0,0 +1,108 @@ +/** + * Retiring a catalog plan must never delete it. + * + * The plan foreign keys cascade the whole way down — + * `WebinarPlan` → `Webinar` → `Appointment` → `Payment`, every hop declared + * `onDelete: Cascade`. So a hard delete of one catalog row would physically + * destroy the sessions booked from it AND their payment records. The plan row + * is also the terms of every sale made against it: past appointments, invoices + * and earnings all resolve their title, price and duration by reading it. + * + * Archiving is therefore the only safe primitive here, and these tests pin the + * three things that keep it safe: + * + * 1. the catalog endpoint has no delete call at all, so there is no path to + * the cascade even by accident; + * 2. public discovery filters archived plans out; + * 3. the archive filter is NOT attached to the two plan models that lack the + * column, which would make their queries throw. + * + * Source-level assertions, matching the sibling suites: what matters is which + * where-clause each surface reaches for. + */ + +import { readFileSync } from "fs"; +import { join } from "path"; + +const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8"); + +const CATALOG_ROUTE = "app/api/organizations/[orgId]/catalog/route.ts"; +const PLAN_FILTERS = "app/api/plans/shared/plan-filters.ts"; +const VISIBILITY = "lib/api/plans/visibility.ts"; +const EXPLORE = "lib/data/explore-programs.ts"; +const SCHEMA = "prisma/schema.prisma"; + +describe("catalog plans are archived, never deleted", () => { + it("the catalog endpoint contains no delete call", () => { + const src = read(CATALOG_ROUTE); + // Not `.not.toContain("delete")` — that would match `DELETE` the HTTP verb. + // These are the Prisma client calls that would actually fire the cascade. + expect(src).not.toMatch(/\.deleteMany\(/); + expect(src).not.toMatch(/\btx\.\w+Plan\.delete\(/); + expect(src).not.toMatch(/\bprisma\.\w+Plan\.delete\(/); + // And it does archive. + expect(src).toMatch(/archivedAt/); + }); + + it("both archivable models declare the column", () => { + const schema = read(SCHEMA); + for (const model of ["WebinarPlan", "ClassPlan"]) { + const block = schema.slice( + schema.indexOf(`model ${model} {`), + schema.indexOf("\n}", schema.indexOf(`model ${model} {`)), + ); + expect(block).toMatch(/archivedAt\s+DateTime\?/); + } + }); + + it("the cascade this protects against is still declared", () => { + // If someone relaxes these to SetNull/Restrict the archive-only rule could + // be revisited — so pin the premise, not just the conclusion. + const schema = read(SCHEMA); + const webinar = schema.slice( + schema.indexOf("model Webinar {"), + schema.indexOf("\n}", schema.indexOf("model Webinar {")), + ); + expect(webinar).toMatch(/webinarPlan\s+WebinarPlan @relation\(.*onDelete: Cascade/); + }); +}); + +describe("archived plans leave public discovery", () => { + it("the shared plan filter pins archivedAt: null", () => { + const src = read(PLAN_FILTERS); + expect(src).toMatch(/archivedAt:\s*null/); + }); + + it("the event-plan discovery helper carries both gates", () => { + const src = read(VISIBILITY); + const start = src.indexOf("export function eventPlanDiscoverableWhere()"); + expect(start).toBeGreaterThan(-1); + // Slice to the function's own closing brace (column 0), not the first "}" + // encountered — that one belongs to the nested visibility object. + const body = src.slice(start, src.indexOf("\n}", start)); + expect(body).toMatch(/MARKETPLACE_VISIBILITY/); + expect(body).toMatch(/archivedAt:\s*null/); + }); + + it("every explore query for an event plan uses that helper", () => { + const src = read(EXPLORE); + // The plain visibility helper would let an archived plan through here; only + // ConsultationPlan / SubscriptionPlan surfaces may still use it, and this + // file queries neither. + expect(src).not.toContain("marketplaceVisibilityWhere("); + expect(src).toContain("eventPlanDiscoverableWhere("); + }); + + it("the models without the column keep the plain visibility filter", () => { + // ConsultationPlan and SubscriptionPlan have no archivedAt; attaching the + // filter to their queries would make Prisma reject them at runtime. + for (const rel of [ + "app/api/plans/consultations/route.ts", + "app/api/plans/subscriptions/route.ts", + ]) { + const src = read(rel); + expect(src).toContain("marketplaceVisibilityWhere("); + expect(src).not.toContain("eventPlanDiscoverableWhere("); + } + }); +}); diff --git a/__tests__/enterprise/catalog-discovery-guard.test.ts b/__tests__/enterprise/catalog-discovery-guard.test.ts new file mode 100644 index 000000000..1bf5f5c39 --- /dev/null +++ b/__tests__/enterprise/catalog-discovery-guard.test.ts @@ -0,0 +1,136 @@ +/** + * @jest-environment node + */ + +/** + * The marketplace must not surface a tenant-private or a withdrawn plan. + * + * Two guards, one test file, because they fail the same way and are enforced in + * the same `where` clauses: + * + * #726 — an `ORG_ONLY` plan is visible to its own org only, and + * `/explore/**` plus the public plan APIs must filter it out. + * #catalog-archive — an archived plan is withdrawn from sale and must leave + * discovery too. + * + * `#726` shipped with no test at all, which mattered less while nothing could + * actually produce an `ORG_ONLY` row. The org catalog is the first surface that + * can, so the guard is now load-bearing and gets pinned here. + * + * These are BEHAVIOURAL rather than source-level: they call the real functions + * and capture the `where` object actually handed to Prisma. A source grep would + * pass if the filter were built and then dropped before the query. + * + * Deliberately NOT an end-to-end check against a live database. Proving "an + * ORG_ONLY plan stays off the marketplace" that way means creating one on the + * shared database first — and if the guard is broken, which is the case the + * test exists to catch, the plan is briefly live on the public marketplace. + * The failure mode of the experiment is the incident. + */ + +import { + buildPlanWhereClause, + type PlanFilterParams, +} from "@/app/api/plans/shared/plan-filters"; +import { + marketplaceVisibilityWhere, + eventPlanDiscoverableWhere, +} from "@/lib/api/plans/visibility"; + +const PUBLIC_SET = ["PUBLIC", "ORG_AND_PUBLIC"]; + +describe("#726 — ORG_ONLY never reaches a public plan query", () => { + /** Every filter off — what an unfiltered marketplace request produces. */ + const NO_FILTERS: PlanFilterParams = { + consultantId: null, + topicIds: null, + language: null, + domainId: null, + sort: null, + minPrice: undefined, + maxPrice: undefined, + search: null, + level: null, + page: 1, + limit: 20, + skip: 0, + }; + + it("buildPlanWhereClause constrains visibility on an empty filter set", () => { + // This is the real builder behind GET /api/plans/webinars and /classes. + const where = buildPlanWhereClause(NO_FILTERS); + expect(where.visibility).toEqual({ in: PUBLIC_SET }); + expect(where.visibility?.in).not.toContain("ORG_ONLY"); + }); + + it("caller-supplied filters cannot dislodge the visibility gate", () => { + // The filters come from query params, so the guard has to survive whatever + // a caller passes. If the builder ever spread user input over its own + // defaults, this is where it would show. + const where = buildPlanWhereClause({ + ...NO_FILTERS, + consultantId: "consultant-1", + language: "English", + level: "Beginner", + minPrice: 0, + maxPrice: 100000, + search: "anything", + topicIds: "t1,t2", + domainId: "d1", + }); + + expect(where.visibility).toEqual({ in: PUBLIC_SET }); + expect(where.consultantProfileId).toBe("consultant-1"); + }); + + it("the shared visibility constant excludes ORG_ONLY", () => { + expect(marketplaceVisibilityWhere().visibility.in).toEqual(PUBLIC_SET); + expect(eventPlanDiscoverableWhere().visibility.in).toEqual(PUBLIC_SET); + }); +}); + +describe("#catalog-archive — withdrawn plans leave discovery", () => { + it("buildPlanWhereClause excludes archived rows", () => { + expect( + buildPlanWhereClause({ + consultantId: null, + topicIds: null, + language: null, + domainId: null, + sort: null, + minPrice: undefined, + maxPrice: undefined, + search: null, + level: null, + page: 1, + limit: 20, + skip: 0, + }).archivedAt, + ).toBeNull(); + }); + + it("the event-plan helper carries BOTH gates", () => { + const where = eventPlanDiscoverableWhere(); + expect(where.visibility.in).toEqual(PUBLIC_SET); + expect(where.archivedAt).toBeNull(); + }); + + it("the plain helper does NOT carry the archive gate", () => { + // ConsultationPlan and SubscriptionPlan have no archivedAt column. If this + // helper grew the filter, Prisma would reject every query that spreads it — + // which is why the two helpers exist separately rather than one being + // extended. + expect(marketplaceVisibilityWhere()).not.toHaveProperty("archivedAt"); + }); +}); + +describe("the two guards are independent", () => { + it("an archived PUBLIC plan is still excluded", () => { + // Regression shape: someone could reasonably assume the visibility gate + // subsumes the archive one. It does not — a plan can be perfectly public + // and still withdrawn. + const where = eventPlanDiscoverableWhere(); + expect(where.visibility.in).toContain("PUBLIC"); + expect(where.archivedAt).toBeNull(); + }); +}); diff --git a/__tests__/enterprise/catalog-earnings-attribution.test.ts b/__tests__/enterprise/catalog-earnings-attribution.test.ts new file mode 100644 index 000000000..40bcb4e23 --- /dev/null +++ b/__tests__/enterprise/catalog-earnings-attribution.test.ts @@ -0,0 +1,231 @@ +/** + * @jest-environment node + */ + +/** + * The org that SOLD the plan is the org that gets paid. + * + * Host-side earnings resolve the settling org from the expert's `Membership`, + * and the rule was "oldest ACTIVE EXPERT membership at a canHost org wins". + * That was fine while nothing could set `Plan.organizationId` — every plan was + * personal, so the expert's own host org was the only sensible answer. + * + * The org catalog changes that. Once Org B can publish a plan delivered by an + * expert who ALSO belongs to Org A, the oldest-membership rule pays Org A for + * something Org B sold. ADR 18 already records oldest-membership-wins as + * "known-crude"; this is where it becomes wrong rather than merely crude. + * + * These tests pin the resulting rule: + * - org-owned plan -> the OWNING org settles, whatever the join order + * - personal plan -> unchanged, oldest membership still wins + * - owning org where the expert is not an ACTIVE expert -> no self-dealing, + * fall back rather than pay an org someone does not work for + */ + +const EXPERT = "consultant-expert"; +const ORG_SELLER = "org-seller-b"; +const ORG_OLDEST = "org-oldest-a"; +const PAYMENT_ID = "pay-catalog-1"; +const PLAN_ID = "webinar-plan-1"; + +interface CapturedCreate { + paymentId: string; + organizationId: string; + [k: string]: unknown; +} + +const capturedOrgEarnings: CapturedCreate[] = []; + +jest.mock("../../lib/collaborators/service", () => ({ + calculateRevenueSplit: jest.fn().mockResolvedValue([]), +})); +jest.mock("../../lib/api/organizations/rate-card", () => ({ + resolveEffectiveRateCard: jest.fn(), +})); +jest.mock("../../lib/feature-flags", () => ({ + ...jest.requireActual("../../lib/feature-flags"), + ENABLE_HOST_ORGS: true, +})); + +jest.mock("../../lib/payments/ledger/post", () => ({ + ...jest.requireActual("../../lib/payments/ledger/post"), + postLedgerTxn: jest + .fn() + .mockResolvedValue({ transactionId: "ltxn-stub", created: true }), +})); + +jest.mock("../../lib/prisma", () => { + const mockTx = { + ledgerAccount: { + findFirst: jest.fn().mockResolvedValue(null), + upsert: jest.fn().mockImplementation(async () => ({ id: "ledger-1" })), + }, + ledgerAccountBalance: { upsert: jest.fn().mockResolvedValue({}) }, + paymentLeg: { findMany: jest.fn().mockResolvedValue([]) }, + consultantEarnings: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest + .fn() + .mockImplementation(async ({ data }: { data: object }) => ({ + id: "earn-1", + ...data, + })), + }, + consultantProfile: { update: jest.fn().mockResolvedValue({}) }, + organization: { + findUnique: jest.fn().mockResolvedValue({ status: "ACTIVE" }), + }, + organizationInvoice: { count: jest.fn().mockResolvedValue(1) }, + organizationEarnings: { + create: jest + .fn() + .mockImplementation(async ({ data }: { data: CapturedCreate }) => { + capturedOrgEarnings.push(data); + return { id: "org-earn-1", ...data }; + }), + }, + membership: { findFirst: jest.fn() }, + webinarPlan: { findUnique: jest.fn() }, + classPlan: { + findUnique: jest.fn().mockResolvedValue({ organizationId: null }), + }, + }; + return { + __esModule: true, + default: { + $transaction: jest + .fn() + .mockImplementation(async (fn: (tx: unknown) => Promise) => + fn(mockTx), + ), + __mockTx: mockTx, + }, + }; +}); + +import prisma from "@/lib/prisma"; +import { resolveEffectiveRateCard } from "@/lib/api/organizations/rate-card"; +import { createEarningsFromPayment } from "@/lib/payments/payouts/earnings-service"; + +const tx = ( + prisma as unknown as { + __mockTx: { + membership: { findFirst: jest.Mock }; + webinarPlan: { findUnique: jest.Mock }; + }; + } +).__mockTx; + +const mockedRateCard = resolveEffectiveRateCard as jest.MockedFunction< + typeof resolveEffectiveRateCard +>; + +function payment() { + return { + id: PAYMENT_ID, + amount: 100_000, + originalAmount: 100_000, + createdAt: new Date("2026-07-30T00:00:00Z"), + appointment: { + consultantProfile: { id: EXPERT }, + webinar: { webinarPlanId: PLAN_ID }, + class: null, + }, + } as unknown as Parameters[0]["payment"]; +} + +beforeEach(() => { + capturedOrgEarnings.length = 0; + mockedRateCard.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (async (_t: unknown, params: { orgId: string | null }) => ({ + rateCardId: `rc-${params.orgId}`, + platformBps: 1000, + orgBps: 500, + consultantBps: 8500, + ownerOrgId: params.orgId, + ownerContractId: null, + })) as any, + ); +}); + +describe("org-owned plans settle to the selling org", () => { + it("pays the OWNING org, not the expert's oldest membership", async () => { + tx.webinarPlan.findUnique.mockResolvedValue({ + organizationId: ORG_SELLER, + }); + // The scoped lookup finds the expert at the seller org. + tx.membership.findFirst.mockImplementation( + async (args: { where: { organizationId?: string } }) => + args.where.organizationId === ORG_SELLER + ? { + id: "mem-seller", + rateCardOverrideId: null, + payoutRecipient: "SELF", + organization: { id: ORG_SELLER }, + } + : { + id: "mem-oldest", + rateCardOverrideId: null, + payoutRecipient: "SELF", + organization: { id: ORG_OLDEST }, + }, + ); + + await createEarningsFromPayment({ + payment: payment(), + appointmentType: "WEBINAR", + } as unknown as Parameters[0]); + + expect(capturedOrgEarnings).toHaveLength(1); + expect(capturedOrgEarnings[0].organizationId).toBe(ORG_SELLER); + // The regression this guards: Org A being paid for Org B's sale. + expect(capturedOrgEarnings[0].organizationId).not.toBe(ORG_OLDEST); + }); + + it("falls back to the oldest membership for a personal plan", async () => { + tx.webinarPlan.findUnique.mockResolvedValue({ organizationId: null }); + tx.membership.findFirst.mockResolvedValue({ + id: "mem-oldest", + rateCardOverrideId: null, + payoutRecipient: "SELF", + organization: { id: ORG_OLDEST }, + }); + + await createEarningsFromPayment({ + payment: payment(), + appointmentType: "WEBINAR", + } as unknown as Parameters[0]); + + expect(capturedOrgEarnings).toHaveLength(1); + expect(capturedOrgEarnings[0].organizationId).toBe(ORG_OLDEST); + }); + + it("does not pay an owning org the expert does not work for", async () => { + // Plan claims ORG_SELLER, but the expert holds no ACTIVE EXPERT membership + // there — the scoped query returns null and we fall back rather than + // letting a plan row direct money to an org on its own say-so. + tx.webinarPlan.findUnique.mockResolvedValue({ + organizationId: ORG_SELLER, + }); + tx.membership.findFirst.mockImplementation( + async (args: { where: { organizationId?: string } }) => + args.where.organizationId === ORG_SELLER + ? null + : { + id: "mem-oldest", + rateCardOverrideId: null, + payoutRecipient: "SELF", + organization: { id: ORG_OLDEST }, + }, + ); + + await createEarningsFromPayment({ + payment: payment(), + appointmentType: "WEBINAR", + } as unknown as Parameters[0]); + + expect(capturedOrgEarnings).toHaveLength(1); + expect(capturedOrgEarnings[0].organizationId).toBe(ORG_OLDEST); + }); +}); diff --git a/__tests__/enterprise/collaborator-org-earnings.test.ts b/__tests__/enterprise/collaborator-org-earnings.test.ts index b3748e1d6..32129d9e9 100644 --- a/__tests__/enterprise/collaborator-org-earnings.test.ts +++ b/__tests__/enterprise/collaborator-org-earnings.test.ts @@ -141,6 +141,16 @@ jest.mock("../../lib/prisma", () => { membership: { findFirst: jest.fn(), }, + // #catalog-archive — resolveOrgSplit reads plan ownership in-transaction so + // an org-published plan settles to the org that SOLD it. These tests use a + // personal plan, so both resolve to no owner and the oldest-membership + // fallback applies, which is what the assertions below already expect. + webinarPlan: { + findUnique: jest.fn().mockResolvedValue({ organizationId: null }), + }, + classPlan: { + findUnique: jest.fn().mockResolvedValue({ organizationId: null }), + }, }; return { __esModule: true, diff --git a/__tests__/payments/currency-and-tax-gates.test.ts b/__tests__/payments/currency-and-tax-gates.test.ts index 197d751c4..28be4e73f 100644 --- a/__tests__/payments/currency-and-tax-gates.test.ts +++ b/__tests__/payments/currency-and-tax-gates.test.ts @@ -100,7 +100,7 @@ describe("the planner cannot create an unsettleable plan", () => { const src = require("fs").readFileSync( require("path").join( process.cwd(), - "app/dashboard/consultant/[consultantId]/(features)/planner/components/form-fields/PriceField.tsx", + "components/planner/components/form-fields/PriceField.tsx", ), "utf8", ); diff --git a/__tests__/payments/multi-party-booking-journal.test.ts b/__tests__/payments/multi-party-booking-journal.test.ts index d7dc41f50..0d2a67c6b 100644 --- a/__tests__/payments/multi-party-booking-journal.test.ts +++ b/__tests__/payments/multi-party-booking-journal.test.ts @@ -118,6 +118,16 @@ jest.mock("../../lib/prisma", () => { ), }, membership: { findFirst: jest.fn() }, + // #catalog-archive — resolveOrgSplit now reads plan ownership in-transaction + // so an org-published plan settles to the org that SOLD it. These fixtures + // use personal plans, so no owner is found and the previous + // oldest-membership behaviour (which these assertions encode) still applies. + webinarPlan: { + findUnique: jest.fn().mockResolvedValue({ organizationId: null }), + }, + classPlan: { + findUnique: jest.fn().mockResolvedValue({ organizationId: null }), + }, }; return { __esModule: true, 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/__tests__/security/personal-dashboard-ssr-ownership.test.ts b/__tests__/security/personal-dashboard-ssr-ownership.test.ts index 0687594e0..6df166856 100644 --- a/__tests__/security/personal-dashboard-ssr-ownership.test.ts +++ b/__tests__/security/personal-dashboard-ssr-ownership.test.ts @@ -36,7 +36,11 @@ const GUARDED_PAGES: Array<[string, "consultee" | "consultant", string]> = [ [`${CE}/appointments/[appointmentId]/page.tsx`, "consultee", "consulteeId"], [`${CA}/home/page.tsx`, "consultant", "consultantId"], [`${CA}/appointments/page.tsx`, "consultant", "consultantId"], - [`${CA}/analytics/page.tsx`, "consultant", "consultantId"], + // Analytics stopped being its own route when it folded onto Earnings as a + // tab (ADR 19). The guard did not move with it — `earnings/page.tsx` became + // a server component specifically so it could keep the SSR prefetch that + // page owned, which means it has to hold the ownership check too. + [`${CA}/earnings/page.tsx`, "consultant", "consultantId"], [`${CA}/appointments/[appointmentId]/page.tsx`, "consultant", "consultantId"], ]; 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/organizations/[orgId]/catalog/route.ts b/app/api/organizations/[orgId]/catalog/route.ts new file mode 100644 index 000000000..708b18e04 --- /dev/null +++ b/app/api/organizations/[orgId]/catalog/route.ts @@ -0,0 +1,283 @@ +/** + * GET /api/organizations/[orgId]/catalog + * POST /api/organizations/[orgId]/catalog + * DELETE /api/organizations/[orgId]/catalog + * + * The org's OWN bookable offerings — the plans it authors and owns, as opposed + * to `/programs`, which is the sponsor-side entitlement that funds bookings of + * somebody else's plans. + * + * #778 collapsed the standalone `OrganizationPlan` table into the per-type + * plans, so "an org's catalog" is exactly its `WebinarPlan` / `ClassPlan` rows + * with `organizationId` set. Consultation and Subscription are deliberately + * absent: both require a `consultantProfileId` in the schema and so can never + * be solely org-owned. See docs/enterprise/30-programs-and-lifecycle/05-public-pages-and-discovery.md. + */ + +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; + +const PlanKindSchema = z.enum(["WEBINAR", "CLASS"]); + +const VisibilitySchema = z.enum(["PUBLIC", "ORG_ONLY", "ORG_AND_PUBLIC"]); + +const RemoveBodySchema = z.object({ + kind: PlanKindSchema, + planIds: z.array(z.string().min(1)).min(1).max(100), +}); + +const BaseCreateSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().min(1), + // Paise. ADR 02 — money is integer paise end to end; the column is BigInt. + pricePaise: z.number().int().min(0), + // The EXPERT who delivers. Nullable in the schema, but an org plan nobody + // can deliver is not bookable, so the endpoint requires it. + consultantProfileId: z.string().min(1), + visibility: VisibilitySchema.default("ORG_AND_PUBLIC"), + maxParticipants: z.number().int().positive().optional(), + language: z.string().default("English"), + level: z.string().default("Beginner"), + certificateProvided: z.boolean().default(false), + recordingEnabled: z.boolean().default(false), +}); + +// The two types diverge on their duration grid, which is why this is a +// discriminated union rather than one schema with optional extras: a webinar is +// a single sitting of N hours, a class is a recurring course. +const CreateBodySchema = z.discriminatedUnion("kind", [ + BaseCreateSchema.extend({ + kind: z.literal("WEBINAR"), + durationInHours: z.number().positive().default(1), + }), + BaseCreateSchema.extend({ + kind: z.literal("CLASS"), + durationInMonths: z.number().int().positive().default(1), + meetingsPerWeek: z.number().int().positive().default(1), + sessionDurationInHours: z.number().positive().default(1), + }), +]); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, { + permission: "catalog.manage", + canHost: true, + }); + if (access.error) return access.error; + + // Archived plans are hidden unless explicitly asked for, so the catalog reads + // as "what we currently sell" while the history stays reachable. + const url = new URL(req.url); + const includeArchived = url.searchParams.get("includeArchived") === "true"; + const archiveFilter = includeArchived ? {} : { archivedAt: null }; + + const [webinars, classes] = await Promise.all([ + prisma.webinarPlan.findMany({ + where: { organizationId: orgId, ...archiveFilter }, + orderBy: { createdAt: "desc" }, + include: { consultantProfile: { select: { id: true, userId: true } } }, + }), + prisma.classPlan.findMany({ + where: { organizationId: orgId, ...archiveFilter }, + orderBy: { createdAt: "desc" }, + include: { consultantProfile: { select: { id: true, userId: true } } }, + }), + ]); + + // BigInt is not JSON-serializable — paise cross the wire as strings, matching + // the money convention the rest of the org surfaces use. + return NextResponse.json({ + webinars: webinars.map((w) => ({ ...w, price: w.price.toString() })), + classes: classes.map((c) => ({ ...c, price: c.price.toString() })), + }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, { + permission: "catalog.manage", + canHost: true, + // Publishing an offering is a commercial act; an unverified org must not + // put bookable inventory on the marketplace. + requireActive: true, + }); + if (access.error) return access.error; + + const parsed = CreateBodySchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + // The named deliverer must be an ACTIVE EXPERT of THIS org. Without this an + // operator could point an org plan at any consultantProfileId on the + // platform and commit a stranger to delivering it. + const expert = await prisma.membership.findFirst({ + where: { + organizationId: orgId, + status: "ACTIVE", + role: "EXPERT", + consultantProfileId: body.consultantProfileId, + }, + select: { id: true }, + }); + if (!expert) { + return NextResponse.json( + { + error: "NOT_AN_ORG_EXPERT", + message: + "The selected consultant is not an active EXPERT in this organization.", + }, + { status: 422 }, + ); + } + + const created = await prisma.$transaction(async (tx) => { + const common = { + title: body.title, + description: body.description, + price: BigInt(body.pricePaise), + priceCurrency: "INR" as const, + consultantProfileId: body.consultantProfileId, + organizationId: orgId, + visibility: body.visibility, + language: body.language, + level: body.level, + certificateProvided: body.certificateProvided, + recordingEnabled: body.recordingEnabled, + }; + + const plan = + body.kind === "WEBINAR" + ? await tx.webinarPlan.create({ + data: { + ...common, + durationInHours: body.durationInHours, + maxParticipants: body.maxParticipants ?? 100, + }, + }) + : await tx.classPlan.create({ + data: { + ...common, + durationInMonths: body.durationInMonths, + meetingsPerWeek: body.meetingsPerWeek, + sessionDurationInHours: body.sessionDurationInHours, + // Kept consistent with the schema's own derivation note + // (meetingsPerWeek × durationInMonths × 4) so the stored + // totals never disagree with the grid that produced them. + totalSessions: body.meetingsPerWeek * body.durationInMonths * 4, + totalHours: + body.meetingsPerWeek * + body.durationInMonths * + 4 * + body.sessionDurationInHours, + maxParticipants: body.maxParticipants ?? 30, + }, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "CATALOG", + action: AUDIT_ACTIONS.CATALOG.CATALOG_PLAN_CREATED, + description: `${body.kind === "WEBINAR" ? "Webinar" : "Class"} plan "${plan.title}" added to the catalog`, + details: { + planId: plan.id, + kind: body.kind, + visibility: body.visibility, + consultantProfileId: body.consultantProfileId, + }, + }, + }); + + return plan; + }); + + return NextResponse.json( + { plan: { ...created, price: created.price.toString() } }, + { status: 201 }, + ); +} + +/** + * Withdraw plans from the catalog, or put them back. + * + * ARCHIVES rather than deletes, always. The plan FK chain is `onDelete: + * Cascade` the whole way down — `WebinarPlan` → `Webinar` → `Appointment` → + * `Payment` — so removing a booked plan would physically destroy settled money + * records. A plan row is also the TERMS of every sale made against it, which + * past appointments, invoices and earnings still resolve by reading it. + * + * There is deliberately no hard-delete path even for a never-booked plan. One + * code path cannot trip the cascade; two invite a future edit that misses a + * reference (materials, collaborators) and turns a catalog button into a + * history shredder. An unused plan simply sits archived, which costs nothing. + * + * `?restore=true` reverses it. + */ +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, { + permission: "catalog.manage", + canHost: true, + }); + if (access.error) return access.error; + + const parsed = RemoveBodySchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const { kind, planIds } = parsed.data; + const restore = new URL(req.url).searchParams.get("restore") === "true"; + const archivedAt = restore ? null : new Date(); + + const count = await prisma.$transaction(async (tx) => { + // Scoped by organizationId as well as id, so a stolen id from another + // tenant matches nothing rather than touching their row. + const scope = { id: { in: planIds }, organizationId: orgId }; + + const { count: affected } = + kind === "WEBINAR" + ? await tx.webinarPlan.updateMany({ where: scope, data: { archivedAt } }) + : await tx.classPlan.updateMany({ where: scope, data: { archivedAt } }); + + if (affected > 0) { + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "CATALOG", + action: restore + ? AUDIT_ACTIONS.CATALOG.CATALOG_PLAN_RESTORED + : AUDIT_ACTIONS.CATALOG.CATALOG_PLAN_DEACTIVATED, + description: `${affected} ${kind === "WEBINAR" ? "webinar" : "class"} plan(s) ${restore ? "restored to" : "withdrawn from"} the catalog`, + details: { kind, planIds }, + }, + }); + } + + return affected; + }); + + return NextResponse.json(restore ? { restored: count } : { archived: count }); +} diff --git a/app/api/organizations/[orgId]/route.ts b/app/api/organizations/[orgId]/route.ts index 0357c9a08..a1d9feb89 100644 --- a/app/api/organizations/[orgId]/route.ts +++ b/app/api/organizations/[orgId]/route.ts @@ -134,7 +134,14 @@ export async function GET( return NextResponse.json({ organization: org, - membership: { role: access.member.role, status: access.member.status }, + membership: { + role: access.member.role, + status: access.member.status, + // Drives the Requests nav gate: delivery surfaces belong to whoever + // holds a consultant profile, which is not the same set as MemberRole + // EXPERT (an OWNER can also deliver). + consultantProfileId: access.member.consultantProfileId, + }, }); } diff --git a/app/api/plans/shared/plan-filters.ts b/app/api/plans/shared/plan-filters.ts index 516a4a647..e7197060d 100644 --- a/app/api/plans/shared/plan-filters.ts +++ b/app/api/plans/shared/plan-filters.ts @@ -65,6 +65,8 @@ export interface PlanWhereClause { topics?: { some: { id: { in: string[] } } }; consultantProfile?: { domainId: string }; visibility?: { in: OrgPlanVisibility[] }; + /** #catalog-archive — `null` keeps withdrawn plans out of public lists. */ + archivedAt?: null; } /** @@ -79,8 +81,14 @@ export function buildPlanWhereClause( // is applied unconditionally here because every caller of this helper // is a public surface; org-internal catalog endpoints have their own // where-builders. + // #catalog-archive — an archived plan is withdrawn from sale. It is kept + // rather than deleted because the row carries the terms of every booking made + // against it (and the FK chain cascades to Payment), so discovery has to + // filter it out explicitly. Same reasoning as the visibility gate above: every + // caller here is a public surface. const where: PlanWhereClause = { visibility: { in: MARKETPLACE_VISIBILITY }, + archivedAt: null, }; if (filters.consultantId) { 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/consultant/[consultantId]/(features)/analytics/AnalyticsPageClient.tsx b/app/dashboard/consultant/[consultantId]/(features)/analytics/AnalyticsPageClient.tsx index 76ded382c..6f8ecad4d 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/analytics/AnalyticsPageClient.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/analytics/AnalyticsPageClient.tsx @@ -27,7 +27,6 @@ import { DashboardErrorBoundary } from "@/components/DashboardErrorBoundary"; import { DashboardContent, DashboardGrid, - DashboardHeader, } from "@/components/dashboard/PageScaffold"; import { StatCard, StatCardSkeleton } from "@/components/dashboard/StatCard"; import { DataCard, EmptyState } from "@/components/dashboard/DataCard"; @@ -144,11 +143,9 @@ export default function AnalyticsPageClient({ const summary = earningsData?.summary; return ( + // No DashboardHeader — this is the Analytics tab of the Earnings page now, + // which renders one header above the tab strip. - {/* KPI grid */} {earningsLoading || appointmentsLoading ? ( diff --git a/app/dashboard/consultant/[consultantId]/(features)/analytics/loading.tsx b/app/dashboard/consultant/[consultantId]/(features)/analytics/loading.tsx deleted file mode 100644 index 6b8518954..000000000 --- a/app/dashboard/consultant/[consultantId]/(features)/analytics/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PageSkeleton } from "@/components/dashboard/DashboardSkeletons"; - -export default function Loading() { - return ; -} diff --git a/app/dashboard/consultant/[consultantId]/(features)/analytics/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/analytics/page.tsx deleted file mode 100644 index 2556de909..000000000 --- a/app/dashboard/consultant/[consultantId]/(features)/analytics/page.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { - HydrationBoundary, - QueryClient, - dehydrate, -} from "@tanstack/react-query"; -import AnalyticsPageClient from "./AnalyticsPageClient"; -import { getConsultantAppointments } from "@/lib/data/consultant-appointments"; -import { buildConsultantEarningsPayload } from "@/lib/data/consultant-earnings-analytics"; -import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access"; - -type PageProps = { - params: Promise<{ consultantId: string }>; -}; - -export default async function AnalyticsPage({ params }: Readonly) { - const { consultantId } = await params; - // Ownership is enforced HERE, not by the layout: the layout is a client - // component, so its check runs after this server render has already read - // and streamed the data. See lib/auth/personal-dashboard-access.ts. - await requirePersonalProfileAccess("consultant", consultantId); - const queryClient = new QueryClient(); - - // SSR prefetch both analytics reads (org home-page pattern). Keys MUST - // match AnalyticsPageClient's useQuery keys exactly or hydration won't - // apply. Payload parity is guaranteed structurally: the earnings payload - // comes from the same buildConsultantEarningsPayload assembler the API - // route serves, and the appointments read reuses the #890 prefetch - // (personal scope — deterministic, session-independent). allSettled so a - // failed read degrades to a client-side fetch rather than crashing. - await Promise.allSettled([ - queryClient.prefetchQuery({ - queryKey: ["consultant-earnings-analytics", consultantId], - queryFn: () => - // #org-appts (#1024) — personal dashboard = B2C-only VIEW. - // Explicit to match the client's default (unset orgScope). - buildConsultantEarningsPayload(consultantId, { - limit: 1, - includeMonthly: true, - organizationId: null, - }), - }), - queryClient.prefetchQuery({ - queryKey: ["consultant-appointments", consultantId, "personal"], - queryFn: () => - getConsultantAppointments({ - consultantProfileId: consultantId, - orgScopeFilter: { organizationId: null }, - }), - }), - ]); - - return ( - - - - ); -} diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/AppointmentsPageClient.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/AppointmentsPageClient.tsx index 65a7a8533..fb14c7c09 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/AppointmentsPageClient.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/AppointmentsPageClient.tsx @@ -13,6 +13,7 @@ import { AppointmentsPageSkeleton } from "@/components/appointments/skeletons"; import { mapConsultantAppointments } from "@/lib/appointments/map-consultant"; import { createConsultantQueries } from "@/lib/dashboard-queries"; import { useConsultantAppointmentsAdapter } from "./ConsultantAppointmentsAdapter"; +import { TrialsTab } from "../trials/TrialsTab"; /** Old HomeTab deep-links carry groupRecurringAppointments keys — map the * non-recurring "single-" form onto the VM row id. */ @@ -196,6 +197,16 @@ export default function AppointmentsPageClient({ adapter={adapter} highlightedId={highlightedId} notices={notices} + // ADR 19 folded trials onto Appointments on the org side because a + // trial IS an appointment. This is the personal half of that move — + // the standalone /trials nav entry is gone. + extraTabs={[ + { + value: "trials", + label: "Trials", + content: , + }, + ]} /> )} diff --git a/app/dashboard/consultant/[consultantId]/(features)/documents/ConsultantResponseUpload.tsx b/app/dashboard/consultant/[consultantId]/(features)/documents/ConsultantResponseUpload.tsx index db2a02744..639af1d60 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/documents/ConsultantResponseUpload.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/documents/ConsultantResponseUpload.tsx @@ -16,7 +16,7 @@ import { import { useToast } from "@/hooks/use-toast"; import { Upload, X, FileText, Loader2 } from "lucide-react"; import { formatFileSize } from "@/lib/documents/document-utils"; -import { ConsultantDocumentService } from "../../(features)/planner/services/materials-service"; +import { ConsultantDocumentService } from "@/components/planner/services/materials-service"; import { IDocument } from "../../types"; interface ConsultantResponseUploadProps { diff --git a/app/dashboard/consultant/[consultantId]/(features)/earnings/EarningsSummaryPanel.tsx b/app/dashboard/consultant/[consultantId]/(features)/earnings/EarningsSummaryPanel.tsx new file mode 100644 index 000000000..2dbf53722 --- /dev/null +++ b/app/dashboard/consultant/[consultantId]/(features)/earnings/EarningsSummaryPanel.tsx @@ -0,0 +1,514 @@ +"use client"; + +import { useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import type { EarningStatus } from "@prisma/client"; +import { + DashboardContent, + DashboardGrid, +} from "@/components/dashboard/PageScaffold"; +import { StatCard } from "@/components/dashboard/StatCard"; +import { EmptyState } from "@/components/dashboard/DataCard"; +import { StatusBadge } from "@/components/dashboard/StatusBadge"; +import { earningStatusBadge } from "@/lib/labels/session-labels"; +import { + ResponsiveTable, + type ResponsiveColumn, +} from "@/components/ui/responsive-table"; +import { Button } from "@/components/ui/button"; +import { + Wallet, + Clock, + CheckCircle, + AlertTriangle, + IndianRupee, + ArrowUpRight, + Loader2, + ShieldQuestion, +} from "lucide-react"; +import { formatCurrencyAmount } from "@/utils/formatting"; +import { IndiaOnlyPayoutNotice } from "@/components/payouts/IndiaOnlyPayoutNotice"; + +interface EarningsSummary { + consultantProfileId: string; + totalEarnings: number; + pendingEarnings: number; + readyEarnings: number; + batchedEarnings: number; + paidEarnings: number; + heldEarnings: number; + pendingTrustEarnings: number; +} + +interface EarningRecord { + id: string; + consultantSharePaise: number; + platformFeePaise: number; + status: EarningStatus; + holdUntil: string | null; + createdAt: string; + role: "OWNER" | "COLLABORATOR"; + // #772 B5 — basis points (10000 = 100%); divide by 100 for display. + shareBps: number; + payment: { + id: string; + amount: number; + originalAmount: number; + currency: string; + createdAt: string; + appointment: { + id: string; + appointmentType: string; + } | null; + }; + payout: { + id: string; + status: string; + processedAt: string | null; + } | null; +} + +interface EarningsResponse { + summary: EarningsSummary; + /** + * #776 §B — server-only ENABLE_LIVE_PAYOUTS, relayed because this page is a + * client component with no RSC wrapper. With disbursement gated, a BATCHED + * earning is reserved at the platform rather than in transit, and the UI must + * not imply otherwise (same posture as the org payouts page). + */ + livePayoutsEnabled?: boolean; + eligibility: { + isEligible: boolean; + reason?: string; + readyAmount: number; + minimumAmount: number; + }; + earnings: EarningRecord[]; + pagination: { + total: number; + limit: number; + offset: number; + hasMore: boolean; + }; +} + +/** + * Format currency using the shared utility. + * Summary amounts don't carry a currency (backend aggregates across all), + * so we default to INR. Individual earning rows use payment.currency. + */ +const formatSummaryAmount = (amount: number) => + formatCurrencyAmount(amount, "INR"); + +const formatEarningAmount = (amount: number, currency: string) => + formatCurrencyAmount(amount, currency); + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString("en-IN", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +/** + * The Summary panel of the Earnings page. + * + * Was the whole `earnings/page.tsx` until ADR 19's "a nav entry is a + * destination" rule was applied to the consultant sidebar: Analytics read the + * very same `/api/consultant/earnings` endpoint with `?includeMonthly=1`, so + * two nav entries pointed at one object. It is now the sibling tab, and the + * route wrapper owns the tab strip. + */ +export function EarningsSummaryPanel({ + consultantId, +}: Readonly<{ consultantId: string }>) { + const [statusFilter, setStatusFilter] = useState( + "ALL", + ); + const [page, setPage] = useState(0); + const limit = 15; + + const { data, isLoading, isPlaceholderData, error, refetch } = + useQuery({ + queryKey: ["consultant-earnings", consultantId, statusFilter, page], + queryFn: async () => { + const params = new URLSearchParams(); + if (statusFilter !== "ALL") params.set("status", statusFilter); + params.set("limit", String(limit)); + params.set("offset", String(page * limit)); + const res = await fetch(`/api/consultant/earnings?${params}`); + if (!res.ok) throw new Error("Failed to fetch earnings"); + return res.json(); + }, + staleTime: 30_000, + // `statusFilter` and `page` are in the key, so every tab and every page + // is a separate query. Without this, switching to a tab you have not + // opened in the last 30s left `data` undefined and `isLoading` true, and + // the branch below replaced the entire page — header, stat cards, tabs — + // with a centred spinner. Keeping the previous result means the outgoing + // rows stay put, dimmed, while the new ones load. + placeholderData: keepPreviousData, + }); + + if (isLoading && !data) { + return ( +
+ +
+ ); + } + + if (error && !data) { + return ( + refetch()}> + Retry + + } + /> + ); + } + + const summary = data?.summary; + const earnings = data?.earnings ?? []; + const eligibility = data?.eligibility; + // Default to FALSE, not true: if the flag is missing for any reason the + // honest reading is "disbursement may not be live", never "your money is on + // its way". + const livePayoutsEnabled = data?.livePayoutsEnabled ?? false; + const pagination = data?.pagination; + + const filterTabs: { label: string; value: EarningStatus | "ALL" }[] = [ + { label: "All", value: "ALL" }, + { label: "Pending", value: "PENDING" }, + { label: "Ready", value: "READY" }, + { label: "Processing", value: "BATCHED" }, // #837 — batched, cash not yet disbursed + { label: "Paid", value: "PAID" }, + { label: "On Hold", value: "HELD" }, + { label: "Org Trust", value: "PENDING_TRUST" }, + { label: "Refunded", value: "REFUNDED" }, + ]; + + const columns: ResponsiveColumn[] = [ + { + key: "type", + header: "Type", + primary: true, + cell: (earning) => ( + + {( + earning.payment?.appointment?.appointmentType ?? "N/A" + ).toLowerCase()} + + ), + }, + { + key: "date", + header: "Date", + cell: (earning) => ( + {formatDate(earning.createdAt)} + ), + }, + { + key: "role", + header: "Role", + cell: (earning) => ( + + {earning.role === "COLLABORATOR" + ? `Collab ${earning.shareBps / 100}%` + : earning.shareBps < 10000 + ? `Owner ${earning.shareBps / 100}%` + : "Owner"} + + ), + }, + { + key: "planPrice", + header: "Plan Price", + headClassName: "text-right", + className: "text-right text-zinc-600", + cell: (earning) => + formatEarningAmount( + earning.payment?.originalAmount ?? 0, + earning.payment?.currency ?? "INR", + ), + }, + { + key: "yourEarnings", + header: "Your Earnings", + headClassName: "text-right", + className: "text-right font-medium text-zinc-900", + cell: (earning) => + formatEarningAmount( + earning.consultantSharePaise, + earning.payment?.currency ?? "INR", + ), + }, + { + key: "platformFee", + header: "Platform Fee", + headClassName: "text-right", + className: "text-right text-zinc-400", + hideOnCard: true, + cell: (earning) => + formatEarningAmount( + earning.platformFeePaise, + earning.payment?.currency ?? "INR", + ), + }, + { + key: "status", + header: "Status", + cell: (earning) => ( + + ), + }, + { + key: "payout", + header: "Payout", + cell: (earning) => + earning.payout ? ( + + {earning.payout.status === "COMPLETED" ? ( + + ) : ( + + )} + {earning.payout.processedAt + ? formatDate(earning.payout.processedAt) + : earning.payout.status} + + ) : ( + - + ), + }, + ]; + + // No DashboardHeader here — the route wrapper renders one header above the + // tab strip, so both panels sit under a single "Earnings" title. + return ( + <> + + {/* Summary Cards */} + + + + + + {summary && summary.batchedEarnings > 0 && ( + + )} + {summary && summary.pendingTrustEarnings > 0 && ( + + )} + + + {/* Held earnings alert */} + {summary && summary.heldEarnings > 0 && ( +
+ +
+

+ {formatSummaryAmount(summary.heldEarnings)} is on hold +

+

+ Earnings may be held due to disputes or policy review. Contact + support if you have questions. +

+
+
+ )} + + {/* Filter Tabs */} +
+ {filterTabs.map((tab) => ( + + ))} +
+ + {/* Earnings Table — dimmed while the tab being switched to is still + in flight, so the rows on screen are visibly stale rather than + silently wrong. */} +
+ earning.id} + className="[&>ul]:p-3" + empty={ + + } + /> + + {/* Pagination */} + {pagination && pagination.total > limit && ( +
+

+ Showing {page * limit + 1}– + {Math.min((page + 1) * limit, pagination.total)} of{" "} + {pagination.total} +

+
+ + +
+
+ )} +
+ + {/* Payout eligibility info */} + {eligibility && ( +
+
+ +
+

+ Payout Information +

+ + {eligibility.isEligible ? ( +

+ {formatSummaryAmount(eligibility.readyAmount)} ready — + payouts are processed weekly +

+ ) : ( +
+
+

+ {formatSummaryAmount(eligibility.readyAmount)} of{" "} + {formatSummaryAmount(eligibility.minimumAmount)} minimum + reached +

+

+ {Math.min( + 100, + Math.round( + (eligibility.readyAmount / + eligibility.minimumAmount) * + 100, + ), + )} + % +

+
+
+
+
+
+ )} +
+
+
+ )} + + + ); +} diff --git a/app/dashboard/consultant/[consultantId]/(features)/earnings/EarningsTabs.tsx b/app/dashboard/consultant/[consultantId]/(features)/earnings/EarningsTabs.tsx new file mode 100644 index 000000000..98232b40c --- /dev/null +++ b/app/dashboard/consultant/[consultantId]/(features)/earnings/EarningsTabs.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { UrlTabs } from "@/components/dashboard/UrlTabs"; +import AnalyticsPageClient from "../analytics/AnalyticsPageClient"; +import { EarningsSummaryPanel } from "./EarningsSummaryPanel"; + +/** + * Earnings, with Analytics as its second panel. + * + * ADR 19: a navigation entry must be a distinct destination. Analytics was a + * second sidebar entry over the same object — it called the same + * `/api/consultant/earnings` endpoint, only adding `?includeMonthly=1` — which + * is the pattern the rule exists to stop. Both panels keep their own filter and + * pagination state, deliberately: they answer different questions and resetting + * one when the other moves would be surprising. + */ +export function EarningsTabs({ + consultantId, +}: Readonly<{ consultantId: string }>) { + return ( + , + }, + { + value: "analytics", + label: "Analytics", + content: , + }, + ]} + /> + ); +} diff --git a/app/dashboard/consultant/[consultantId]/(features)/earnings/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/earnings/page.tsx index ef3a6dc32..86094c1fd 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/earnings/page.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/earnings/page.tsx @@ -1,511 +1,70 @@ -"use client"; - -import { use, useState } from "react"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import type { EarningStatus } from "@prisma/client"; -import { - DashboardHeader, - DashboardContent, - DashboardGrid, -} from "@/components/dashboard/PageScaffold"; -import { StatCard } from "@/components/dashboard/StatCard"; -import { EmptyState } from "@/components/dashboard/DataCard"; -import { StatusBadge } from "@/components/dashboard/StatusBadge"; -import { earningStatusBadge } from "@/lib/labels/session-labels"; import { - ResponsiveTable, - type ResponsiveColumn, -} from "@/components/ui/responsive-table"; -import { Button } from "@/components/ui/button"; -import { - Wallet, - Clock, - CheckCircle, - AlertTriangle, - IndianRupee, - ArrowUpRight, - Loader2, - ShieldQuestion, -} from "lucide-react"; -import { formatCurrencyAmount } from "@/utils/formatting"; -import { IndiaOnlyPayoutNotice } from "@/components/payouts/IndiaOnlyPayoutNotice"; + HydrationBoundary, + QueryClient, + dehydrate, +} from "@tanstack/react-query"; -interface EarningsSummary { - consultantProfileId: string; - totalEarnings: number; - pendingEarnings: number; - readyEarnings: number; - batchedEarnings: number; - paidEarnings: number; - heldEarnings: number; - pendingTrustEarnings: number; -} +import { DashboardHeader } from "@/components/dashboard/PageScaffold"; +import { getConsultantAppointments } from "@/lib/data/consultant-appointments"; +import { buildConsultantEarningsPayload } from "@/lib/data/consultant-earnings-analytics"; +import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access"; -interface EarningRecord { - id: string; - consultantSharePaise: number; - platformFeePaise: number; - status: EarningStatus; - holdUntil: string | null; - createdAt: string; - role: "OWNER" | "COLLABORATOR"; - // #772 B5 — basis points (10000 = 100%); divide by 100 for display. - shareBps: number; - payment: { - id: string; - amount: number; - originalAmount: number; - currency: string; - createdAt: string; - appointment: { - id: string; - appointmentType: string; - } | null; - }; - payout: { - id: string; - status: string; - processedAt: string | null; - } | null; -} +import { EarningsTabs } from "./EarningsTabs"; -interface EarningsResponse { - summary: EarningsSummary; - /** - * #776 §B — server-only ENABLE_LIVE_PAYOUTS, relayed because this page is a - * client component with no RSC wrapper. With disbursement gated, a BATCHED - * earning is reserved at the platform rather than in transit, and the UI must - * not imply otherwise (same posture as the org payouts page). - */ - livePayoutsEnabled?: boolean; - eligibility: { - isEligible: boolean; - reason?: string; - readyAmount: number; - minimumAmount: number; - }; - earnings: EarningRecord[]; - pagination: { - total: number; - limit: number; - offset: number; - hasMore: boolean; - }; -} +type PageProps = { + params: Promise<{ consultantId: string }>; +}; /** - * Format currency using the shared utility. - * Summary amounts don't carry a currency (backend aggregates across all), - * so we default to INR. Individual earning rows use payment.currency. + * /dashboard/consultant/[consultantId]/earnings — Summary + Analytics. + * + * This route was a client component until Analytics folded into it (ADR 19). + * It is a server component now so the Analytics panel keeps the SSR prefetch it + * had as its own page; the Summary panel still fetches on the client, as it + * always did. */ -const formatSummaryAmount = (amount: number) => - formatCurrencyAmount(amount, "INR"); - -const formatEarningAmount = (amount: number, currency: string) => - formatCurrencyAmount(amount, currency); - -function formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString("en-IN", { - day: "numeric", - month: "short", - year: "numeric", - }); -} - -export default function EarningsPage({ - params, -}: { - params: Promise<{ consultantId: string }>; -}) { - const { consultantId } = use(params); - const [statusFilter, setStatusFilter] = useState( - "ALL", - ); - const [page, setPage] = useState(0); - const limit = 15; - - const { data, isLoading, isPlaceholderData, error, refetch } = - useQuery({ - queryKey: ["consultant-earnings", consultantId, statusFilter, page], - queryFn: async () => { - const params = new URLSearchParams(); - if (statusFilter !== "ALL") params.set("status", statusFilter); - params.set("limit", String(limit)); - params.set("offset", String(page * limit)); - const res = await fetch(`/api/consultant/earnings?${params}`); - if (!res.ok) throw new Error("Failed to fetch earnings"); - return res.json(); - }, - staleTime: 30_000, - // `statusFilter` and `page` are in the key, so every tab and every page - // is a separate query. Without this, switching to a tab you have not - // opened in the last 30s left `data` undefined and `isLoading` true, and - // the branch below replaced the entire page — header, stat cards, tabs — - // with a centred spinner. Keeping the previous result means the outgoing - // rows stay put, dimmed, while the new ones load. - placeholderData: keepPreviousData, - }); - - if (isLoading && !data) { - return ( -
- -
- ); - } - - if (error && !data) { - return ( - refetch()}> - Retry - - } - /> - ); - } - - const summary = data?.summary; - const earnings = data?.earnings ?? []; - const eligibility = data?.eligibility; - // Default to FALSE, not true: if the flag is missing for any reason the - // honest reading is "disbursement may not be live", never "your money is on - // its way". - const livePayoutsEnabled = data?.livePayoutsEnabled ?? false; - const pagination = data?.pagination; - - const filterTabs: { label: string; value: EarningStatus | "ALL" }[] = [ - { label: "All", value: "ALL" }, - { label: "Pending", value: "PENDING" }, - { label: "Ready", value: "READY" }, - { label: "Processing", value: "BATCHED" }, // #837 — batched, cash not yet disbursed - { label: "Paid", value: "PAID" }, - { label: "On Hold", value: "HELD" }, - { label: "Org Trust", value: "PENDING_TRUST" }, - { label: "Refunded", value: "REFUNDED" }, - ]; - - const columns: ResponsiveColumn[] = [ - { - key: "type", - header: "Type", - primary: true, - cell: (earning) => ( - - {( - earning.payment?.appointment?.appointmentType ?? "N/A" - ).toLowerCase()} - - ), - }, - { - key: "date", - header: "Date", - cell: (earning) => ( - {formatDate(earning.createdAt)} - ), - }, - { - key: "role", - header: "Role", - cell: (earning) => ( - - {earning.role === "COLLABORATOR" - ? `Collab ${earning.shareBps / 100}%` - : earning.shareBps < 10000 - ? `Owner ${earning.shareBps / 100}%` - : "Owner"} - - ), - }, - { - key: "planPrice", - header: "Plan Price", - headClassName: "text-right", - className: "text-right text-zinc-600", - cell: (earning) => - formatEarningAmount( - earning.payment?.originalAmount ?? 0, - earning.payment?.currency ?? "INR", - ), - }, - { - key: "yourEarnings", - header: "Your Earnings", - headClassName: "text-right", - className: "text-right font-medium text-zinc-900", - cell: (earning) => - formatEarningAmount( - earning.consultantSharePaise, - earning.payment?.currency ?? "INR", - ), - }, - { - key: "platformFee", - header: "Platform Fee", - headClassName: "text-right", - className: "text-right text-zinc-400", - hideOnCard: true, - cell: (earning) => - formatEarningAmount( - earning.platformFeePaise, - earning.payment?.currency ?? "INR", - ), - }, - { - key: "status", - header: "Status", - cell: (earning) => ( - - ), - }, - { - key: "payout", - header: "Payout", - cell: (earning) => - earning.payout ? ( - - {earning.payout.status === "COMPLETED" ? ( - - ) : ( - - )} - {earning.payout.processedAt - ? formatDate(earning.payout.processedAt) - : earning.payout.status} - - ) : ( - - - ), - }, - ]; +export default async function EarningsPage({ params }: Readonly) { + const { consultantId } = await params; + // Ownership is enforced HERE, not by the layout: the layout is a client + // component, so its check runs after this server render has already read + // and streamed the data. See lib/auth/personal-dashboard-access.ts. + await requirePersonalProfileAccess("consultant", consultantId); + const queryClient = new QueryClient(); + + // Keys MUST match AnalyticsPageClient's useQuery keys exactly or hydration + // won't apply. allSettled so a failed read degrades to a client-side fetch + // rather than crashing the whole page. + await Promise.allSettled([ + queryClient.prefetchQuery({ + queryKey: ["consultant-earnings-analytics", consultantId], + queryFn: () => + // #org-appts (#1024) — personal dashboard = B2C-only VIEW. + // Explicit to match the client's default (unset orgScope). + buildConsultantEarningsPayload(consultantId, { + limit: 1, + includeMonthly: true, + organizationId: null, + }), + }), + queryClient.prefetchQuery({ + queryKey: ["consultant-appointments", consultantId, "personal"], + queryFn: () => + getConsultantAppointments({ + consultantProfileId: consultantId, + orgScopeFilter: { organizationId: null }, + }), + }), + ]); return ( <> - - {/* Summary Cards */} - - - - - - {summary && summary.batchedEarnings > 0 && ( - - )} - {summary && summary.pendingTrustEarnings > 0 && ( - - )} - - - {/* Held earnings alert */} - {summary && summary.heldEarnings > 0 && ( -
- -
-

- {formatSummaryAmount(summary.heldEarnings)} is on hold -

-

- Earnings may be held due to disputes or policy review. Contact - support if you have questions. -

-
-
- )} - - {/* Filter Tabs */} -
- {filterTabs.map((tab) => ( - - ))} -
- - {/* Earnings Table — dimmed while the tab being switched to is still - in flight, so the rows on screen are visibly stale rather than - silently wrong. */} -
- earning.id} - className="[&>ul]:p-3" - empty={ - - } - /> - - {/* Pagination */} - {pagination && pagination.total > limit && ( -
-

- Showing {page * limit + 1}– - {Math.min((page + 1) * limit, pagination.total)} of{" "} - {pagination.total} -

-
- - -
-
- )} -
- - {/* Payout eligibility info */} - {eligibility && ( -
-
- -
-

- Payout Information -

- - {eligibility.isEligible ? ( -

- {formatSummaryAmount(eligibility.readyAmount)} ready — - payouts are processed weekly -

- ) : ( -
-
-

- {formatSummaryAmount(eligibility.readyAmount)} of{" "} - {formatSummaryAmount(eligibility.minimumAmount)} minimum - reached -

-

- {Math.min( - 100, - Math.round( - (eligibility.readyAmount / - eligibility.minimumAmount) * - 100, - ), - )} - % -

-
-
-
-
-
- )} -
-
-
- )} - + + + ); } diff --git a/app/dashboard/consultant/[consultantId]/(features)/home/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/home/page.tsx index 591b8c7d2..7f64c8d40 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/home/page.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/home/page.tsx @@ -4,7 +4,9 @@ import { dehydrate, } from "@tanstack/react-query"; import HomePageClient from "./HomePageClient"; +import { NeedsYouCard } from "@/components/dashboard/NeedsYouCard"; import { getConsultantDashboard } from "@/lib/data/consultant-dashboard"; +import { getNeedsYouSummary, type NeedsYouSummary } from "@/lib/data/needs-you"; import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access"; type PageProps = { @@ -16,9 +18,21 @@ export default async function HomePage({ params }: Readonly) { // Ownership is enforced HERE, not by the layout: the layout is a client // component, so its check runs after this server render has already read // and streamed the data. See lib/auth/personal-dashboard-access.ts. - await requirePersonalProfileAccess("consultant", consultantId); + const access = await requirePersonalProfileAccess("consultant", consultantId); const queryClient = new QueryClient(); + // Cross-context roll-up (ADR 19's sanctioned "derived read"). Skipped when an + // ADMIN/STAFF is inspecting someone else's dashboard: the summary keys off + // the VIEWER's memberships, which are not the profile owner's, so it would + // answer a question nobody asked. Failure is non-fatal — the card is + // supplementary and the page must not 500 because a count timed out. + let needsYou: NeedsYouSummary | null = null; + if (!access.isInspecting) { + needsYou = await getNeedsYouSummary(access.userId, consultantId).catch( + () => null, + ); + } + // #890 — SSR prefetch the dashboard so the client useQuery hydrates // without a fetch waterfall. Key MUST match // createConsultantQueries(...).dashboard: ["consultant-dashboard", id]. @@ -35,6 +49,11 @@ export default async function HomePage({ params }: Readonly) { return ( + {needsYou && ( +
+ +
+ )}
); diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/planner/page.tsx index eed9770be..89ea48950 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/page.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/page.tsx @@ -12,7 +12,7 @@ import { } from "@/components/dashboard/PageScaffold"; import { EmptyState } from "@/components/dashboard/DataCard"; import { createConsultantQueries } from "@/lib/dashboard-queries"; -import { EventManagementDashboard } from "./components/EventManagementDashboard"; +import { EventManagementDashboard } from "@/components/planner/components/EventManagementDashboard"; export default function PlannerPage() { const params = useParams(); diff --git a/app/dashboard/consultant/[consultantId]/(features)/trials/loading.tsx b/app/dashboard/consultant/[consultantId]/(features)/trials/loading.tsx deleted file mode 100644 index 13ed6fd14..000000000 --- a/app/dashboard/consultant/[consultantId]/(features)/trials/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TableSkeleton } from "@/components/dashboard/DashboardSkeletons"; - -export default function Loading() { - return ; -} diff --git a/app/dashboard/consultant/[consultantId]/(features)/trials/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/trials/page.tsx deleted file mode 100644 index eacf6dc36..000000000 --- a/app/dashboard/consultant/[consultantId]/(features)/trials/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TrialsTab } from "./TrialsTab"; - -export default function TrialsPage() { - return ; -} diff --git a/app/dashboard/consultant/[consultantId]/layout.tsx b/app/dashboard/consultant/[consultantId]/layout.tsx index 7819b5e6e..27e5ef29a 100644 --- a/app/dashboard/consultant/[consultantId]/layout.tsx +++ b/app/dashboard/consultant/[consultantId]/layout.tsx @@ -11,11 +11,9 @@ import { CalendarRange, Inbox, Users, - Sparkles, Video, FileText, Wallet, - BarChart3, Gift, Settings, MessageSquareText, @@ -56,9 +54,14 @@ import { import type { VerificationStatus } from "@/components/verification/VerificationStatusBadge"; import { useVerificationStatus } from "./hooks/useVerificationStatus"; -// Grouped sidebar nav — same IA as before the shell swap (Services / -// Content / Finance), now rendered by the shared CollapsibleSidebar. -// Analytics is live (built as part of the redesign, was a hidden TODO). +// Grouped sidebar nav (Services / Resources / Finance / Support), rendered by +// the shared CollapsibleSidebar. +// +// Trials and Analytics used to be entries here and are now tabs on Appointments +// and Earnings respectively — see the group comments below. This array is still +// static and unfiltered, unlike the org sidebar's permission-driven one: every +// surface on a personal dashboard belongs to the one person who owns it, so +// there is nothing to filter on. const NAV_GROUPS: CollapsibleSidebarGroup[] = [ { items: [ @@ -68,12 +71,15 @@ const NAV_GROUPS: CollapsibleSidebarGroup[] = [ ], }, { + // Trials is absent by ADR 19's rule that a nav entry must be a distinct + // destination: a trial IS an appointment, which is why the org sidebar + // already folded it onto Appointments. It lives at + // `appointments?tab=trials` now. label: "Services", items: [ { name: "Event Planner", icon: CalendarRange, path: "planner" }, { name: "Requests", icon: Inbox, path: "requests" }, { name: "Collaborations", icon: Users, path: "collaborations" }, - { name: "Trials", icon: Sparkles, path: "trials" }, ], }, { @@ -87,10 +93,13 @@ const NAV_GROUPS: CollapsibleSidebarGroup[] = [ ], }, { + // Analytics is absent for the same reason as Trials: it read the very same + // /api/consultant/earnings endpoint as Earnings, only adding + // `?includeMonthly=1`. Two entries over one object is exactly what ADR 19 + // forbids, so it is the Analytics tab of Earnings now. label: "Finance", items: [ { name: "Earnings", icon: Wallet, path: "earnings" }, - { name: "Analytics", icon: BarChart3, path: "analytics" }, { name: "Referrals", icon: Gift, path: "referrals" }, ], }, @@ -132,11 +141,9 @@ const PAGE_LABELS: Record = { planner: "Event Planner", requests: "Requests", collaborations: "Collaborations", - trials: "Trials", recordings: "Recordings", documents: "Documents", earnings: "Earnings", - analytics: "Analytics", referrals: "Referrals", settings: "Settings", support: "Support requests", diff --git a/app/dashboard/consultant/[consultantId]/types.ts b/app/dashboard/consultant/[consultantId]/types.ts index 417cf4ef1..e6e2fcc17 100644 --- a/app/dashboard/consultant/[consultantId]/types.ts +++ b/app/dashboard/consultant/[consultantId]/types.ts @@ -44,25 +44,6 @@ export interface IDocument { tag: string; } -export interface IPlanMaterial { - id: string; - fileName: string; - originalName: string; - fileSize: number; - mimeType: string; - fileUrl: string; - storagePath: string; - description: string | null; - order: number; - // Plan references (one will be set) - consultationPlanId?: string | null; - subscriptionPlanId?: string | null; - webinarPlanId?: string | null; - classPlanId?: string | null; - uploadedAt: Date; - updatedAt?: Date; -} - export interface IActivity { id: string; type: string; 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]/catalog/CatalogClient.tsx b/app/dashboard/organization/[orgId]/catalog/CatalogClient.tsx new file mode 100644 index 000000000..090196e59 --- /dev/null +++ b/app/dashboard/organization/[orgId]/catalog/CatalogClient.tsx @@ -0,0 +1,364 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Library, Plus } from "lucide-react"; + +import { + DashboardHeader, + DashboardContent, +} from "@/components/dashboard/PageScaffold"; +import { EmptyState } from "@/components/dashboard/DataCard"; +import { UrlTabs } from "@/components/dashboard/UrlTabs"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useToast } from "@/hooks/use-toast"; +import { EventPlannerForWebinar } from "@/components/planner/components/EventPlannerForWebinar"; +import { EventPlannerForClass } from "@/components/planner/components/EventPlannerForClass"; +import type { WebinarEvent, ClassEvent } from "@/types/planner-events"; +import { CatalogPanel } from "./CatalogPanel"; +import type { CatalogRow, CatalogResponse, Kind } from "./types"; + +interface Expert { + consultantProfileId: string; + name: string; +} + +export function CatalogClient({ + orgId, + experts, +}: Readonly<{ orgId: string; experts: Expert[] }>) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [composing, setComposing] = useState(null); + const [expertId, setExpertId] = useState( + experts.length === 1 ? experts[0].consultantProfileId : "", + ); + + const queryKey = useMemo(() => ["org-catalog", orgId], [orgId]); + + const { data, isLoading, error } = useQuery({ + queryKey, + queryFn: async () => { + // One fetch drives both views; the client partitions on archivedAt so + // restoring does not need a second round trip. + const res = await fetch( + `/api/organizations/${orgId}/catalog?includeArchived=true`, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Failed to load the catalog"); + } + return res.json(); + }, + }); + + const createPlan = useMutation({ + mutationFn: async (payload: Record) => { + const res = await fetch(`/api/organizations/${orgId}/catalog`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message ?? body.error ?? "Could not save the plan"); + } + return res.json(); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey }); + setComposing(null); + toast({ title: "Added to the catalog" }); + }, + onError: (e: Error) => + toast({ + title: "Could not save", + description: e.message, + variant: "destructive", + }), + }); + + const setArchived = useMutation({ + mutationFn: async ({ + kind, + planId, + restore, + }: { + kind: Kind; + planId: string; + restore: boolean; + }) => { + const res = await fetch( + `/api/organizations/${orgId}/catalog${restore ? "?restore=true" : ""}`, + { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind, planIds: [planId] }), + }, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message ?? body.error ?? "Could not update"); + } + return res.json(); + }, + onSuccess: (_data, vars) => { + void queryClient.invalidateQueries({ queryKey }); + toast({ + title: vars.restore ? "Back on sale" : "Withdrawn from the catalog", + description: vars.restore + ? undefined + : "Existing bookings and their records are unaffected.", + }); + }, + onError: (e: Error) => + toast({ + title: "Could not update", + description: e.message, + variant: "destructive", + }), + }); + + // Stable per-kind callbacks so CatalogPanel's memoized columns are not + // invalidated on every parent render — which would undo the point of the + // split. `setArchived.mutate` is referentially stable across renders. + const archiveWebinar = useCallback( + (planId: string, restore: boolean) => + setArchived.mutate({ kind: "WEBINAR", planId, restore }), + [setArchived], + ); + const archiveClass = useCallback( + (planId: string, restore: boolean) => + setArchived.mutate({ kind: "CLASS", planId, restore }), + [setArchived], + ); + + // The shared planner forms hand back a full plan object; the catalog endpoint + // wants the flat subset it owns. Mapping here rather than widening the API + // keeps the endpoint's contract independent of the form's internals. + const handleWebinarSave = useCallback( + (event: Partial) => { + const plan = event.webinarPlan; + if (!plan) return; + createPlan.mutate({ + kind: "WEBINAR", + title: plan.title, + description: plan.description ?? "", + pricePaise: plan.price, + consultantProfileId: expertId, + visibility: plan.visibility, + maxParticipants: plan.maxParticipants, + durationInHours: plan.durationInHours, + language: plan.language ?? "English", + level: plan.level ?? "Beginner", + certificateProvided: plan.certificateProvided, + recordingEnabled: plan.recordingEnabled, + }); + }, + [createPlan, expertId], + ); + + const handleClassSave = useCallback( + (event: Partial) => { + const plan = event.classPlan; + if (!plan) return; + createPlan.mutate({ + kind: "CLASS", + title: plan.title, + description: plan.description ?? "", + pricePaise: plan.price, + consultantProfileId: expertId, + visibility: plan.visibility, + maxParticipants: plan.maxParticipants, + durationInMonths: plan.durationInMonths, + meetingsPerWeek: plan.meetingsPerWeek, + sessionDurationInHours: plan.sessionDurationInHours, + language: plan.language ?? "English", + level: plan.level ?? "Beginner", + certificateProvided: plan.certificateProvided, + recordingEnabled: plan.recordingEnabled, + }); + }, + [createPlan, expertId], + ); + + // The fetch asks for everything; the split happens here so restoring a plan + // does not need a second round trip. + const live = (rows: CatalogRow[] | undefined) => + (rows ?? []).filter((r) => r.archivedAt === null); + const archivedWebinars = (data?.webinars ?? []).filter( + (r) => r.archivedAt !== null, + ); + const archivedClasses = (data?.classes ?? []).filter( + (r) => r.archivedAt !== null, + ); + + // No experts, nothing to publish — an org plan needs somebody to deliver it, + // so say that plainly rather than opening a form that will 422. + const blocked = experts.length === 0; + + return ( + <> + + + {blocked ? ( + + ) : ( + <> +
+
+ + +
+ + +
+ + + ), + }, + { + value: "classes", + label: "Classes", + content: ( + + ), + }, + // Withdrawn plans keep their own view rather than a filter + // toggle: it answers a different question ("what did we stop + // selling") and keeps the two live tabs uncluttered. Hidden + // entirely until something has been withdrawn. + ...(archivedWebinars.length > 0 + ? [ + { + value: "archived-webinars", + label: `Archived webinars (${archivedWebinars.length})`, + content: ( + + ), + }, + ] + : []), + ...(archivedClasses.length > 0 + ? [ + { + value: "archived-classes", + label: `Archived classes (${archivedClasses.length})`, + content: ( + + ), + }, + ] + : []), + ]} + /> + + )} +
+ + {/* The same forms the consultant planner uses. `organizationId` is what + makes the resulting plan org-owned; `consultantId` names the deliverer, + which on this surface is the picked expert rather than the viewer. */} + {composing === "WEBINAR" && expertId && ( + setComposing(null)} + onSave={handleWebinarSave} + consultantId={expertId} + organizationId={orgId} + isSaving={createPlan.isPending} + /> + )} + {composing === "CLASS" && expertId && ( + setComposing(null)} + onSave={handleClassSave} + consultantId={expertId} + organizationId={orgId} + isSaving={createPlan.isPending} + /> + )} + + ); +} diff --git a/app/dashboard/organization/[orgId]/catalog/CatalogPanel.tsx b/app/dashboard/organization/[orgId]/catalog/CatalogPanel.tsx new file mode 100644 index 000000000..9ce3e300d --- /dev/null +++ b/app/dashboard/organization/[orgId]/catalog/CatalogPanel.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useMemo } from "react"; +import { Library, Archive, Undo2, Loader2 } from "lucide-react"; + +import { EmptyState } from "@/components/dashboard/DataCard"; +import { Button } from "@/components/ui/button"; +import { + ResponsiveTable, + type ResponsiveColumn, +} from "@/components/ui/responsive-table"; +import { formatCurrencyAmount } from "@/utils/formatting"; + +import type { CatalogRow, Kind } from "./types"; + +const VISIBILITY_LABEL: Record = { + PUBLIC: "Public", + ORG_ONLY: "Members only", + ORG_AND_PUBLIC: "Public + members", +}; + +/** + * One kind's table. + * + * Split out of `CatalogClient` for the reason `MembersTabs` and `SettingsTabs` + * already pass component references rather than inline markup: `UrlTabs` writes + * the active tab through `router.replace`, and Next 15's App Router re-renders + * the tree via `useSearchParams` reactivity even with `{ scroll: false }` — + * documented at `app/explore/hooks/useUrlSyncedFilters.ts:34`. When that + * re-render lands, a tab body that is an element reference costs almost nothing + * to reconcile, whereas the previous inline `renderList(...)` calls rebuilt + * BOTH kinds' full row sets, columns array and cell closures on every tab + * switch. Radix unmounts the inactive panel, so only the visible kind's rows + * are built now. + * + * The router-level cause is filed separately — this removes the amplifier that + * made the catalog the worst-hit consumer of it. + */ +export function CatalogPanel({ + kind, + rows, + isLoading, + error, + onToggleArchive, + isMutating, +}: Readonly<{ + kind: Kind; + rows: CatalogRow[]; + isLoading: boolean; + error: unknown; + onToggleArchive: (planId: string, restore: boolean) => void; + isMutating: boolean; +}>) { + // Memoized so the array and its cell closures survive re-renders that do not + // change the handler — previously reallocated on every call, twice per render. + const columns = useMemo[]>( + () => [ + { + key: "title", + header: "Offering", + primary: true, + cell: (r) => {r.title}, + }, + { + key: "price", + header: "Price", + cell: (r) => formatCurrencyAmount(Number(r.price), "INR"), + }, + { + key: "visibility", + header: "Visibility", + cell: (r) => VISIBILITY_LABEL[r.visibility], + }, + { + key: "seats", + header: "Seats", + cell: (r) => r.maxParticipants, + }, + { + key: "actions", + header: "", + hideOnCard: true, + cell: (r) => { + const archived = r.archivedAt !== null; + return ( + + ); + }, + }, + ], + [onToggleArchive, isMutating], + ); + + if (isLoading) { + return ( +
+ + Loading the catalog… +
+ ); + } + + if (error) { + return ( + + ); + } + + if (rows.length === 0) { + return ( + + ); + } + + return ( + r.id} /> + ); +} diff --git a/app/dashboard/organization/[orgId]/catalog/page.tsx b/app/dashboard/organization/[orgId]/catalog/page.tsx new file mode 100644 index 000000000..d53a52ce4 --- /dev/null +++ b/app/dashboard/organization/[orgId]/catalog/page.tsx @@ -0,0 +1,59 @@ +import { redirect } from "next/navigation"; + +import prisma from "@/lib/prisma"; +import { requireOrgAccess } from "@/lib/auth-helpers"; + +import { CatalogClient } from "./CatalogClient"; + +/** + * /dashboard/organization/[orgId]/catalog — the offerings this org OWNS. + * + * Separate from the sibling `programs` page on purpose, and for the same reason + * ADR 19 keeps `my-program` separate from `programs`: they are different + * objects, not two scopes of one. Catalog is host-side — what the org sells. + * Programs is sponsor-side — the entitlement that funds bookings of anybody's + * plans. No toggle sensibly spans them. + * + * Only Webinar and Class appear here. `ConsultationPlan` and `SubscriptionPlan` + * require a `consultantProfileId` in the schema, so an org can never solely own + * one — those stay on the consultant's own planner with the org as a tag. + */ +export default async function OrgCatalogPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = await params; + + const access = await requireOrgAccess(orgId, { + permission: "catalog.manage", + canHost: true, + }); + if (access.error) { + redirect(`/dashboard/organization/${orgId}/home`); + } + + // The pickable deliverers. An org plan with no consultant behind it is not + // bookable, so the form requires one and the API re-checks the membership — + // this list is convenience, not authorization. + const expertMemberships = await prisma.membership.findMany({ + where: { + organizationId: orgId, + status: "ACTIVE", + role: "EXPERT", + consultantProfileId: { not: null }, + }, + select: { + consultantProfileId: true, + user: { select: { name: true, email: true } }, + }, + orderBy: { createdAt: "asc" }, + }); + + const experts = expertMemberships.map((m) => ({ + consultantProfileId: m.consultantProfileId as string, + name: m.user.name ?? m.user.email, + })); + + return ; +} diff --git a/app/dashboard/organization/[orgId]/catalog/types.ts b/app/dashboard/organization/[orgId]/catalog/types.ts new file mode 100644 index 000000000..29a51f627 --- /dev/null +++ b/app/dashboard/organization/[orgId]/catalog/types.ts @@ -0,0 +1,19 @@ +/** Shared shapes for the org catalog surface. */ + +export type Kind = "WEBINAR" | "CLASS"; + +/** Row as the API returns it — `price` is a paise string (BigInt on the wire). */ +export interface CatalogRow { + id: string; + title: string; + price: string; + visibility: "PUBLIC" | "ORG_ONLY" | "ORG_AND_PUBLIC"; + maxParticipants: number; + consultantProfileId: string | null; + archivedAt: string | null; +} + +export interface CatalogResponse { + webinars: CatalogRow[]; + classes: CatalogRow[]; +} diff --git a/app/dashboard/organization/[orgId]/layout.tsx b/app/dashboard/organization/[orgId]/layout.tsx index b40579e83..2f7d20d54 100644 --- a/app/dashboard/organization/[orgId]/layout.tsx +++ b/app/dashboard/organization/[orgId]/layout.tsx @@ -27,6 +27,7 @@ import { Receipt, ShieldCheck, ShieldAlert, + Library, type LucideIcon, } from "lucide-react"; @@ -56,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", }, ]; @@ -72,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 { @@ -167,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, @@ -189,7 +201,8 @@ export default function OrgLayout({ const sidebarGroups: CollapsibleSidebarGroup[] = useMemo(() => { if (!org) return []; const { canSponsor, canHost, fundingSource, requiresPO } = org.organization; - const role = org.membership.role; + const membership = org.membership; + const role = membership.role; const can = (surface: OrgSurface) => hasOrgPermission(role, surface); // Resources group defaults collapsed for OWNER + MAINTAINER — their @@ -260,12 +273,16 @@ export default function OrgLayout({ name: "Requests", icon: ClipboardCheck, path: "requests", - // Same gate as Compensation, which is the other EXPERT delivery - // surface: `myArrangement.read` is EXPERT-only and `canHost` means the - // org actually has experts. The page re-checks the membership's own - // consultantProfileId and redirects if absent, so a mismatch degrades - // to a redirect rather than a broken tab. - show: can("myArrangement.read") && canHost, + // The comment above described this gate for months but the code did + // not implement it: `myArrangement.read` is exact-role EXPERT, so an + // OWNER or MANAGER who also delivers got no nav entry even though the + // page admits anyone holding a consultantProfileId. That is ADR 19's + // "gate and page disagree" failure inverted — a reachable page with no + // way to reach it. The profile is the real predicate; the role check + // stays as the cheap path for the common EXPERT case. + show: + (can("myArrangement.read") || membership.consultantProfileId !== null) && + canHost, }, ]; @@ -326,6 +343,17 @@ export default function OrgLayout({ path: "purchase-orders", show: canSponsor && requiresPO && can("purchaseOrders.read"), }, + { + // What the org SELLS, above what it SPONSORS — the two are different + // objects, not two scopes of one, so this is a separate entry rather + // than a toggle on Programs (ADR 19's my-program/programs precedent). + // Catalog is the host-side offering the org owns; Programs is the + // sponsor-side entitlement that funds bookings of anyone's plans. + name: "Catalog", + icon: Library, + path: "catalog", + show: canHost && can("catalog.manage"), + }, { name: "Programs", icon: Briefcase, @@ -432,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. }, ]; @@ -578,6 +611,7 @@ export default function OrgLayout({ messages: "Messages", requests: "Requests", members: "Members", + catalog: "Catalog", programs: "Programs", contracts: "Contracts", "purchase-orders": "Purchase Orders", 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/appointments/AppointmentsShell.tsx b/components/appointments/AppointmentsShell.tsx index 763fd846c..eece8a796 100644 --- a/components/appointments/AppointmentsShell.tsx +++ b/components/appointments/AppointmentsShell.tsx @@ -84,6 +84,24 @@ function initialTab(param: string | null): TabValue { } } +/** + * A tab that is NOT a bucket of this shell's appointment VMs, appended after + * the five standard ones and rendering its own content. + * + * Exists so the consultant dashboard can host Trials here — ADR 19 folded + * trials onto Appointments on the org side because "a trial IS an appointment", + * and this is the personal half of that move. It is opt-in rather than a sixth + * entry in `TABS` because the consultee shares this shell and has no trials + * concept; passing nothing leaves that side byte-identical. + */ +export interface AppointmentsExtraTab { + value: string; + label: string; + content: ReactNode; + /** Rendered beside the label like the bucket counts. */ + count?: number; +} + interface AppointmentsShellProps { vms: AppointmentVM[]; adapter: AppointmentActionAdapter; @@ -92,6 +110,7 @@ interface AppointmentsShellProps { notices?: ReactNode; /** Row id (VM id) to flash + scroll to (consultant ?highlight= deep-link). */ highlightedId?: string | null; + extraTabs?: AppointmentsExtraTab[]; } export function AppointmentsShell({ @@ -100,13 +119,18 @@ export function AppointmentsShell({ orgFilterSlot, notices, highlightedId = null, + extraTabs = [], }: AppointmentsShellProps) { const searchParams = useSearchParams(); const { data: session } = useSession(); - const [tab, setTab] = useState(() => - initialTab(searchParams?.get("tab") ?? null), - ); + const [tab, setTab] = useState(() => { + const param = searchParams?.get("tab") ?? null; + // An extra tab's own value wins over the bucket fallback, so + // `?tab=trials` deep-links land on it instead of silently on Upcoming. + if (param && extraTabs.some((t) => t.value === param)) return param; + return initialTab(param); + }); // Legacy consultee ?tab=calendar deep-links land on the calendar view. const [view, setView] = useState<"list" | "calendar">(() => searchParams?.get("tab") === "calendar" ? "calendar" : "list", @@ -179,6 +203,12 @@ export function AppointmentsShell({ const countOf = (value: TabValue) => value === "all" ? filtered.length : byBucket[value].length; + // An extra tab owns its whole panel: the filter bar and the calendar toggle + // both describe appointment VMs and do not apply to content this shell + // doesn't own. The hero stays — "what's next" is page-level context, and + // dropping it would shift the layout on every tab change. + const onExtraTab = extraTabs.some((t) => t.value === tab); + // Hero reads the UNFILTERED list — it answers "what's next", not "what's // next among the current filters". A row only qualifies while its anchor // session hasn't ended (live sessions stay; an elapsed pending booking in @@ -233,7 +263,7 @@ export function AppointmentsShell({
- setTab(v as TabValue)}> +
{TABS.map(({ value, label }) => ( @@ -244,10 +274,25 @@ export function AppointmentsShell({ ))} + {extraTabs.map(({ value, label, count }) => ( + + {label} + {count !== undefined && ( + + {count} + + )} + + ))} {/* List / calendar view toggle */} -
+
{( [ { value: "list", label: "List", icon: List }, @@ -273,21 +318,29 @@ export function AppointmentsShell({
-
- -
+ {!onExtraTab && ( +
+ +
+ )} + + {notices && !onExtraTab &&
{notices}
} - {notices &&
{notices}
} + {extraTabs.map(({ value, content }) => ( + + {content} + + ))} - {view === "calendar" ? ( + {onExtraTab ? null : view === "calendar" ? (
diff --git a/components/dashboard/NeedsYouCard.tsx b/components/dashboard/NeedsYouCard.tsx new file mode 100644 index 000000000..0d9ba29bc --- /dev/null +++ b/components/dashboard/NeedsYouCard.tsx @@ -0,0 +1,66 @@ +import Link from "next/link"; +import { ArrowRight, Inbox } from "lucide-react"; + +import type { NeedsYouSummary } from "@/lib/data/needs-you"; + +/** + * Cross-context "needs you" roll-up. + * + * ADR 19 accepts, as a stated cost of splitting dashboards by org-ness, that a + * consultant working through an organization has to visit more than one + * dashboard. It also fixes the only sanctioned remedy: a cross-context summary + * must be "a derived read rather than by re-merging the views". So this card + * shows counts and links out — it never renders the work in place, and clicking + * always lands in the dashboard that owns it. Each number stays authoritative + * in exactly one place. + * + * Renders nothing when there is nothing waiting, so a purely B2C consultant + * never sees an org concept they have no use for. + */ +export function NeedsYouCard({ summary }: Readonly<{ summary: NeedsYouSummary }>) { + if (summary.total === 0) return null; + + // With only a personal context there is nothing cross-context to reconcile, + // and the Requests nav entry already says this. The card earns its space only + // once the work is genuinely spread across dashboards. + if (summary.contexts.length === 1 && summary.contexts[0].organizationId === null) { + return null; + } + + return ( +
+
+ +

+ Needs you +

+ + across {summary.contexts.length} dashboards + +
+ +
    + {summary.contexts.map((context) => ( +
  • + + + {context.label} + + + {context.pendingRequests}{" "} + {context.pendingRequests === 1 ? "request" : "requests"} + + + +
  • + ))} +
+
+ ); +} 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/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventCard.tsx b/components/planner/components/EventCard.tsx similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/components/EventCard.tsx rename to components/planner/components/EventCard.tsx diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventCarousel.tsx b/components/planner/components/EventCarousel.tsx similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/components/EventCarousel.tsx rename to components/planner/components/EventCarousel.tsx diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventManagementDashboard.tsx b/components/planner/components/EventManagementDashboard.tsx similarity index 99% rename from app/dashboard/consultant/[consultantId]/(features)/planner/components/EventManagementDashboard.tsx rename to components/planner/components/EventManagementDashboard.tsx index 1144cc3c5..1fc866dea 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventManagementDashboard.tsx +++ b/components/planner/components/EventManagementDashboard.tsx @@ -32,7 +32,7 @@ import { useSubscriptionPlans, useSubscriptionPlanMutations, usePlannerRefresh, -} from "../../../hooks/usePlanner"; +} from "../hooks/usePlanner"; import { LayoutTemplate, Radio, @@ -327,9 +327,12 @@ export function EventManagementDashboard({ fetchTrialCounts(); }, [fetchTrialCounts, subscriptionPlans]); - // Redirect to Trials tab when clicking trial button + // Trials live on Appointments now (ADR 19 — a trial IS an appointment), so + // this deep-links to the tab rather than the retired standalone route. const handleTrialsClick = () => { - router.push(`/dashboard/consultant/${consultantId}/trials`); + router.push( + `/dashboard/consultant/${consultantId}/appointments?tab=trials`, + ); }; // Handle webinar saved event diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlanner.tsx b/components/planner/components/EventPlanner.tsx similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlanner.tsx rename to components/planner/components/EventPlanner.tsx diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForClass.tsx b/components/planner/components/EventPlannerForClass.tsx similarity index 98% rename from app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForClass.tsx rename to components/planner/components/EventPlannerForClass.tsx index 2cbf65c73..1d58ff39d 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForClass.tsx +++ b/components/planner/components/EventPlannerForClass.tsx @@ -70,6 +70,7 @@ export function EventPlannerForClass({ initialData, isSaving: externalIsSaving, consultantId, + organizationId = null, }: Readonly) { const [internalIsSaving, setInternalIsSaving] = useState(false); const [showConfirmation, setShowConfirmation] = useState(false); @@ -305,10 +306,15 @@ export function EventPlannerForClass({ topics: formData.topics, consultantProfileId: consultantId, consultantProfile: null, - organizationId: null, - // #726 — personal plans default to PUBLIC; org-owned plans - // surface a visibility toggle in their dedicated catalog UI. - visibility: initialData?.classPlan?.visibility ?? "PUBLIC", + organizationId, + // A plan is created live; archiving is a later, explicit act. + archivedAt: null, + // #726 — personal plans default to PUBLIC. Org-owned plans default to + // ORG_AND_PUBLIC per the enum's own doc comment, and the catalog form + // renders the toggle that narrows them to ORG_ONLY. + visibility: + initialData?.classPlan?.visibility ?? + (organizationId ? "ORG_AND_PUBLIC" : "PUBLIC"), certificateProvided: formData.certificateProvided ?? false, recordingEnabled: formData.recordingEnabled ?? false, recordingStoragePolicy: diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForConsultation.tsx b/components/planner/components/EventPlannerForConsultation.tsx similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForConsultation.tsx rename to components/planner/components/EventPlannerForConsultation.tsx diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForSubscription.tsx b/components/planner/components/EventPlannerForSubscription.tsx similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForSubscription.tsx rename to components/planner/components/EventPlannerForSubscription.tsx diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForWebinar.tsx b/components/planner/components/EventPlannerForWebinar.tsx similarity index 98% rename from app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForWebinar.tsx rename to components/planner/components/EventPlannerForWebinar.tsx index 3580eaeab..c3af34bc7 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForWebinar.tsx +++ b/components/planner/components/EventPlannerForWebinar.tsx @@ -85,6 +85,7 @@ export function EventPlannerForWebinar({ initialData, isSaving: externalIsSaving, consultantId, + organizationId = null, }: Readonly) { const [internalIsSaving, setInternalIsSaving] = useState(false); const [showConfirmation, setShowConfirmation] = useState(false); @@ -303,10 +304,15 @@ export function EventPlannerForWebinar({ topics: formData.topics, consultantProfileId: consultantId, consultantProfile: null, - organizationId: null, - // #726 — personal plans default to PUBLIC; org-owned plans - // surface a visibility toggle in their dedicated catalog UI. - visibility: initialData?.webinarPlan?.visibility ?? "PUBLIC", + organizationId, + // A plan is created live; archiving is a later, explicit act. + archivedAt: null, + // #726 — personal plans default to PUBLIC. Org-owned plans default to + // ORG_AND_PUBLIC per the enum's own doc comment, and the catalog form + // renders the toggle that narrows them to ORG_ONLY. + visibility: + initialData?.webinarPlan?.visibility ?? + (organizationId ? "ORG_AND_PUBLIC" : "PUBLIC"), imageUrl: initialData?.webinarPlan?.imageUrl ?? null, createdAt: initialData?.webinarPlan?.createdAt ?? now, updatedAt: now, diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/PlanMaterialsUpload.tsx b/components/planner/components/PlanMaterialsUpload.tsx similarity index 99% rename from app/dashboard/consultant/[consultantId]/(features)/planner/components/PlanMaterialsUpload.tsx rename to components/planner/components/PlanMaterialsUpload.tsx index 9e33bd41a..0ffaee856 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/components/PlanMaterialsUpload.tsx +++ b/components/planner/components/PlanMaterialsUpload.tsx @@ -26,7 +26,7 @@ import { } from "lucide-react"; import { formatFileSize } from "@/lib/documents/document-utils"; import { MaterialsService, type PlanType } from "../services/materials-service"; -import { IPlanMaterial } from "../../../types"; +import { IPlanMaterial } from "@/types/planner-events"; interface PlanMaterialsUploadProps { planType: PlanType; diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/TopicsMultiSelect.tsx b/components/planner/components/TopicsMultiSelect.tsx similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/components/TopicsMultiSelect.tsx rename to components/planner/components/TopicsMultiSelect.tsx diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/form-fields/FormConfirmationDialog.tsx b/components/planner/components/form-fields/FormConfirmationDialog.tsx similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/components/form-fields/FormConfirmationDialog.tsx rename to components/planner/components/form-fields/FormConfirmationDialog.tsx diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/form-fields/FormSection.tsx b/components/planner/components/form-fields/FormSection.tsx similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/components/form-fields/FormSection.tsx rename to components/planner/components/form-fields/FormSection.tsx diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/form-fields/LanguageLevelFields.tsx b/components/planner/components/form-fields/LanguageLevelFields.tsx similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/components/form-fields/LanguageLevelFields.tsx rename to components/planner/components/form-fields/LanguageLevelFields.tsx diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/form-fields/LearningOutcomesField.tsx b/components/planner/components/form-fields/LearningOutcomesField.tsx similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/components/form-fields/LearningOutcomesField.tsx rename to components/planner/components/form-fields/LearningOutcomesField.tsx diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/form-fields/PriceField.tsx b/components/planner/components/form-fields/PriceField.tsx similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/components/form-fields/PriceField.tsx rename to components/planner/components/form-fields/PriceField.tsx diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/form-fields/SubmitButton.tsx b/components/planner/components/form-fields/SubmitButton.tsx similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/components/form-fields/SubmitButton.tsx rename to components/planner/components/form-fields/SubmitButton.tsx diff --git a/app/dashboard/consultant/[consultantId]/hooks/usePlanner.ts b/components/planner/hooks/usePlanner.ts similarity index 100% rename from app/dashboard/consultant/[consultantId]/hooks/usePlanner.ts rename to components/planner/hooks/usePlanner.ts diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/services/events/class-service.ts b/components/planner/services/events/class-service.ts similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/services/events/class-service.ts rename to components/planner/services/events/class-service.ts diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/services/events/webinar-service.ts b/components/planner/services/events/webinar-service.ts similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/services/events/webinar-service.ts rename to components/planner/services/events/webinar-service.ts diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/services/materials-service.ts b/components/planner/services/materials-service.ts similarity index 98% rename from app/dashboard/consultant/[consultantId]/(features)/planner/services/materials-service.ts rename to components/planner/services/materials-service.ts index f1c2b5ad7..5904fa606 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/services/materials-service.ts +++ b/components/planner/services/materials-service.ts @@ -1,4 +1,4 @@ -import { IPlanMaterial } from "../../../types"; +import { IPlanMaterial } from "@/types/planner-events"; export type PlanType = "consultation" | "subscription" | "webinar" | "class"; diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/services/planner.ts b/components/planner/services/planner.ts similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/services/planner.ts rename to components/planner/services/planner.ts diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/services/plans/consultation-service.ts b/components/planner/services/plans/consultation-service.ts similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/services/plans/consultation-service.ts rename to components/planner/services/plans/consultation-service.ts diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/services/plans/subscription-service.ts b/components/planner/services/plans/subscription-service.ts similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/services/plans/subscription-service.ts rename to components/planner/services/plans/subscription-service.ts diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/services/topic-service.ts b/components/planner/services/topic-service.ts similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/services/topic-service.ts rename to components/planner/services/topic-service.ts diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/services/types.ts b/components/planner/services/types.ts similarity index 100% rename from app/dashboard/consultant/[consultantId]/(features)/planner/services/types.ts rename to components/planner/services/types.ts diff --git a/docs/enterprise/00-foundations/04-roles-and-permissions.md b/docs/enterprise/00-foundations/04-roles-and-permissions.md index 360c559bb..27c0d62bb 100644 --- a/docs/enterprise/00-foundations/04-roles-and-permissions.md +++ b/docs/enterprise/00-foundations/04-roles-and-permissions.md @@ -541,9 +541,14 @@ Both `/my-program` and `/compensation` are read-only in v1. A LEARNER cannot self-assign to a Program; an EXPERT cannot flip their own `payoutRecipient`. Mutations remain on operator pages. Membership also carries an `exclusiveEngagement` boolean (ADR 18) recording an -org-declared exclusivity arrangement for internal consultants; it is an -unenforced schema stub today and must not surface in any UI until an -enforcement feature ships. The "Personal +org-declared exclusivity arrangement for internal consultants. This was +an unenforced schema stub when it landed, but it is no longer: as of +2026-07-11 checkout rejects a booking of the consultant's independent +plans when an `ACTIVE` membership carries the flag (#982), and the +schema comment says the same. What remains unbuilt is the other half of +exclusivity — filtering those plans out of marketplace listings — so the +flag still must not surface in any UI that implies discovery is +suppressed. The "Personal Dashboard" footer chip on the sidebar (`resolvePersonalDashboardHref`) stays so consumers can hop back to their personal surface without hunting for the URL. diff --git a/docs/enterprise/30-programs-and-lifecycle/04-dashboard-pages.md b/docs/enterprise/30-programs-and-lifecycle/04-dashboard-pages.md index e1163e488..1a5c68d44 100644 --- a/docs/enterprise/30-programs-and-lifecycle/04-dashboard-pages.md +++ b/docs/enterprise/30-programs-and-lifecycle/04-dashboard-pages.md @@ -61,6 +61,10 @@ The list below is the actual `page.tsx` set under canHost only. /dashboard/organization/[orgId]/contracts → contracts + linked programs; term-edit drawer (locked vs editable) +/dashboard/organization/[orgId]/catalog → org-OWNED webinar + class + plans; expert picker + + visibility; archive/restore + (never deletes). canHost only. /dashboard/organization/[orgId]/programs → programs + assignments; config-lock-aware edit dialog /dashboard/organization/[orgId]/purchase-orders → PO list + 3-way match view @@ -218,7 +222,7 @@ granted through the **permission matrix** in `lib/auth/org-permissions.ts`, which the sidebar, the page guards, and the API routes all consume — so the "Roles" column below names a matrix surface and lists exactly which roles it admits. Second, the boxes are *also* capability-gated — a `canHost=false` -org hides the Experts tab + `/payouts` even from an OWNER, and a +org hides the Experts tab, `/payouts` and `/catalog` even from an OWNER, and a `canSponsor=false` org hides `/programs`, `/contracts`, `/billing`, and `/purchase-orders`. @@ -240,6 +244,7 @@ readable projection of it. | `/members?tab=experts` | — | ✅ | ✅ | `experts.read` (OWNER, MAINTAINER, MANAGER) | tab (if `canHost`) | Tab on Members. Hidden when `canHost = false`. | | `/members?tab=learners` | ✅ | — | ✅ | `learners.read` (OWNER, MAINTAINER, MANAGER) | tab (if `canSponsor`) | Tab on Members. Hidden when `canSponsor = false`. | | `/members?tab=invitations` | ✅ | ✅ | ✅ | `invitations.manage` (OWNER, MAINTAINER) | tab | Tab on Members. Send-invite button disabled pre-verification; uses `humanizeOrgError` for `ORG_NOT_VERIFIED`. | +| `/catalog` | — | ✅ | ✅ | `catalog.manage` (OWNER, MAINTAINER, MANAGER) | yes (if `canHost`) | The offerings the org OWNS, distinct from the sponsorship entitlements on `/programs`. Webinar and Class only — `ConsultationPlan` and `SubscriptionPlan` require a `consultantProfileId`, so an org can never solely own one. The named deliverer is re-checked server-side against an ACTIVE EXPERT membership. | | `/programs` | ✅ | — | ✅ | `programs.manage` (OWNER, MAINTAINER) | yes (if `canSponsor`) | The learner-facing catalog GETs stay open to any active member by design. | | `/billing` | ✅ | — | ✅ | `billing.read` (OWNER, MAINTAINER, BILLING_ADMIN, MANAGER); mutations `billing.manage` (OWNER, BILLING_ADMIN) | yes (if `canSponsor`) | BillingAccount summary + wallet (`WalletTab`) + invoices — one unified surface. The former extra `fundingSource=WALLET` sidebar branch was removed as unreachable (a BillingAccount only exists when `canSponsor=true`). | | `/payouts` | — | ✅ | ✅ | `payouts.read`; mutations `payouts.manage` (OWNER, BILLING_ADMIN) | yes (if `canHost`) | Host-side only. | 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/19-personal-vs-org-dashboard-split.md b/docs/enterprise/70-design-decisions/19-personal-vs-org-dashboard-split.md index a3d18694d..a927667d7 100644 --- a/docs/enterprise/70-design-decisions/19-personal-vs-org-dashboard-split.md +++ b/docs/enterprise/70-design-decisions/19-personal-vs-org-dashboard-split.md @@ -3,7 +3,7 @@ title: Personal-vs-org dashboard split by org-ness, and one navigation entry per band: 70-design-decisions audience: sde3 status: live -last-reviewed: 2026-07-26 +last-reviewed: 2026-07-30 --- # ADR 19 — Dashboards split by org-ness, and a nav entry is a destination @@ -26,6 +26,14 @@ Transaction **views** split the same way, but **instruments do not**. A `PayoutA This rule has a deliberate limit. Surfaces that share a prefix but describe **different objects** stay separate: `my-program` is a LEARNER's own assignment and coverage detail while `programs` is the sponsor's catalog CRUD, and no toggle sensibly spans them. Appointments was the only genuine same-object scope split. +The same rule was applied to the personal consultant sidebar in a later pass, which the original revision of this decision had left untouched. Two of its sixteen entries failed the test. `Trials` became a tab on Appointments, for the reason the organization sidebar had already folded it there — a trial is an appointment, and the consultant dashboard was simply the inconsistency. `Analytics` became a tab on Earnings, because it read the very same `/api/consultant/earnings` endpoint and differed only by an `?includeMonthly=1` parameter, which is two navigation entries over one object. Both moved as `?tab=` panels on the page that owns the data, and the shell that renders Appointments grew an opt-in `extraTabs` prop rather than a sixth hardcoded bucket, because the consultee dashboard shares that shell and has no trials concept. The consultant sidebar is unfiltered where the organization one is permission-driven, and stays that way on purpose: every surface on a personal dashboard belongs to the one person who owns it, so there is nothing to filter on. + +**An organization can own the offerings it sells.** `#778` collapsed the standalone `OrganizationPlan` table into the four per-type plans, which left "the catalog an organization exposes" defined as exactly its `WebinarPlan` and `ClassPlan` rows carrying an `organizationId`. That definition was never given a surface: no route wrote the column, the consultant planner hardcoded it to null, and the `CATALOG_PLAN_CREATED` and `CATALOG_PLAN_DEACTIVATED` audit actions sat in the codebase with no emitter. An organization with `canHost` could pay experts but could not own anything they delivered. A `Catalog` entry now sits above `Programs` in the Commerce group, gated on `canHost` and a new `catalog.manage` key resolving to the operator tier, and the two forms the consultant planner already used moved to `components/planner/` so both surfaces author from one implementation rather than two that drift. + +Only Webinar and Class appear there, and the asymmetry is the schema's rather than the interface's: `ConsultationPlan` and `SubscriptionPlan` declare a required `consultantProfileId`, so an organization can never solely own one, while both event types declare it nullable. Authoring is an operator act rather than an EXPERT one, because an organization-owned plan commits the organization's revenue and its payout obligation; the EXPERT is named on the plan as its deliverer, and the endpoint re-checks that they hold an `ACTIVE` EXPERT membership of that organization rather than trusting the id the form submitted. + +Withdrawing an offering archives it and never deletes it. The plan foreign keys are declared `onDelete: Cascade` at every hop from `WebinarPlan` through `Webinar` and `Appointment` to `Payment`, so removing one catalog row would physically destroy the sessions booked from it and the payment records attached to them. The row is also the terms of every sale made against it, which past appointments, invoices and earnings resolve by reading it. An `archivedAt` timestamp therefore withdraws a plan from booking and from discovery while leaving all of that intact, and it is reversible. There is deliberately no hard-delete path even for a plan nothing has referenced: one code path cannot trip the cascade, whereas two invite a later edit that misses a reference and turns a catalog button into a history shredder, and an unused plan costs nothing sitting archived. Discovery needed a second filter rather than an extension of the existing one, because `marketplaceVisibilityWhere` is also spread into `ConsultationPlan` and `SubscriptionPlan` queries and those models have no such column. + **The admin and staff dashboards remain two URL trees with one implementation behind them.** They are not merged, because the two audiences want different landing pages and different framing, and because a staff-facing surface should be able to diverge without negotiating with the admin surface. What is merged is everything underneath: both sidebars build from a single grouped definition in `lib/dashboard/backoffice-nav.ts`, and the pages that exist in both render the same component from `components/dashboard/shared/`. **Back-office access is a matrix, not a URL tree.** `lib/auth/backoffice-permissions.ts` maps each `UserRole` to a set of named surfaces, mirroring the `lib/auth/org-permissions.ts` pattern that the organization dashboard already proved. The sidebar, the page guards and the route handlers all read that one map, which is what prevents a surface drifting into the state where a tab is visible, the page redirects, and the API returns 403 — a class of bug the 2026-07 role audit found nine instances of on the org side. @@ -40,8 +48,14 @@ The navigation is smaller and each entry means something. The organization sideb A consultant who works through an organization now visits two dashboards to see all of their money: personal earnings for B2C work, and the organization's compensation page for org-routed work. This is the intended consequence of splitting views by org-ness — each number is authoritative in exactly one place — but it is a real ergonomic cost, and a future cross-context earnings summary would have to be built as a derived read rather than by re-merging the views. +The first such derived read is the "Needs you" card on the personal consultant home, which counts the booking requests waiting for that consultant in each context they deliver into and links out to the dashboard that owns each one. It returns counts and hrefs and nothing else: no rows are rendered in place, so no number moves out of the surface that owns it. Its scope predicates are copied from the two endpoints behind the Requests page rather than rewritten, including their asymmetry — a `Consultation` holds one optional appointment and treats a missing one as personal, a `Subscription` holds many and is filtered with `none`/`some` — because a summary that disagreed with the page it links to would send people to a list not containing the item they were promised. The card hides itself when the only context is personal, and an ADMIN or STAFF inspecting somebody else's dashboard does not get it at all, since it keys off the viewer's memberships rather than the profile owner's. + +Fixing the split also exposed a smaller inconsistency worth recording. The sidebar badge on the personal Messages entry read Stream's `total_unread_count`, which is global across every channel a user belongs to, while the inbox behind it filters to channels with no `organization_id`. An organization message therefore lit up a personal badge that led nowhere. The badge now runs the same predicate as the list it points at, which is the general rule this class of bug wants: a count and the collection it summarizes must share one filter. + Deleting the personal org-context filter retired the `#674` carve-out, where the personal consultant scope force-included delivered organization sessions. Anything that deep-linked into a personal dashboard with an `orgScope` parameter had to be repointed; one such link, on the organization `my-program` page, had been silently broken since the scope was pinned to `organizationId: null`. Because the same permission matrix now backs the navigation, the guards and the routes, adding a back-office surface means adding a key to one map. Forgetting to do so fails closed — the item does not render — rather than producing a visible tab that refuses to load. +The failure has an inverted form that the matrix does not catch, and the organization Requests entry had it. Its gate was `myArrangement.read`, an exact-role EXPERT key, while the page behind it admits anyone whose membership carries a `consultantProfileId`. An OWNER who also delivers could therefore reach the page by URL but had no navigation entry leading to it — a reachable surface with no way to reach it, which is as much a drift as a visible tab that 403s. The gate now tests the profile, which is the real predicate, and keeps the role check only as the cheap path for the common case. The lesson generalizes: a nav gate has to encode the same condition the page enforces, not a role that usually implies it. + No redirect layer was written for the routes that moved. The product is pre-MVP, no URL has escaped into a bookmark, a sent email or a delivered notification, and the three in-app callers of moved routes were repointed instead. A post-launch move of the same kind would need redirects and should not read this decision as precedent. 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/hooks/useChatUnreadCount.ts b/hooks/useChatUnreadCount.ts index 1cc6e8b23..49080a8cc 100644 --- a/hooks/useChatUnreadCount.ts +++ b/hooks/useChatUnreadCount.ts @@ -6,10 +6,22 @@ import { StreamChat } from "stream-chat"; const apiKey = process.env.NEXT_PUBLIC_STREAM_API_KEY; /** - * Subscribes to total unread message count from Stream Chat. - * Works outside StreamProvider — uses the StreamChat singleton directly. - * The singleton is created by StreamProvider on layout mount, so the count - * becomes available once the chat client connects. + * Unread message count for the personal (B2C) inbox. + * + * Deliberately NOT Stream's `total_unread_count`, which this hook used to + * return. That number is global across every channel the user belongs to, + * including org-tagged ones — so a consultant who is also an org EXPERT saw + * their personal sidebar badge light up for an org conversation, clicked + * through, and found nothing: `ChatSidebar` filters the personal inbox to + * `organization_id: { $exists: false }`. The badge has to use the same + * predicate as the list it points at, or it is lying about where the message is. + * + * Org unread counts belong to the org dashboard's own Messages entry (ADR 19 — + * split by the org-ness of the underlying work). + * + * Works outside StreamProvider — uses the StreamChat singleton directly. The + * singleton is created by StreamProvider on layout mount, so the count becomes + * available once the chat client connects. */ export function useChatUnreadCount(): number { const [unreadCount, setUnreadCount] = useState(0); @@ -18,21 +30,54 @@ export function useChatUnreadCount(): number { if (!apiKey) return; const client = StreamChat.getInstance(apiKey); - if (!client.userID) return; // Not connected yet + const userId = client.userID; + if (!userId) return; // Not connected yet - // Get initial count — total_unread_count exists on OwnUserResponse but not on the union type - const user = client.user as Record | undefined; - const total = user?.total_unread_count; - if (typeof total === "number") setUnreadCount(total); + let cancelled = false; - // Subscribe to count changes via Stream events + // Same filter as ChatSidebar's `personal` arm, so the badge and the inbox + // can never disagree about which channels count. + const recount = async () => { + try { + // `organization_id` is a custom channel field, which ChannelFilters + // doesn't declare — spread it in the same way ChatSidebar does rather + // than asserting the whole filter. + const orgFilter: Record = { + organization_id: { $exists: false }, + }; + const channels = await client.queryChannels( + { members: { $in: [userId] }, ...orgFilter }, + { last_message_at: -1 }, + { limit: 30, state: true }, + ); + if (cancelled) return; + setUnreadCount( + channels.reduce((sum, channel) => sum + channel.countUnread(), 0), + ); + } catch { + // A failed recount leaves the previous number in place — a stale badge + // beats one that drops to zero on a transient error. + } + }; + + void recount(); + + // Stream emits these alongside a global total_unread_count. We ignore the + // value and use the event purely as a signal to recount within our scope. const handler = client.on((event) => { - if (typeof event.total_unread_count === "number") { - setUnreadCount(event.total_unread_count); + if ( + event.type === "message.new" || + event.type === "notification.mark_read" || + event.type === "notification.message_new" + ) { + void recount(); } }); - return () => handler.unsubscribe(); + return () => { + cancelled = true; + handler.unsubscribe(); + }; }, []); return unreadCount; diff --git a/lib/api/plans/visibility.ts b/lib/api/plans/visibility.ts index 2d6525e38..f1633c7cd 100644 --- a/lib/api/plans/visibility.ts +++ b/lib/api/plans/visibility.ts @@ -44,3 +44,23 @@ export const MARKETPLACE_VISIBILITY: OrgPlanVisibility[] = [ export function marketplaceVisibilityWhere() { return { visibility: { in: MARKETPLACE_VISIBILITY } } as const; } + +/** + * Discovery filter for the two ARCHIVABLE plan types (WebinarPlan, ClassPlan). + * + * `marketplaceVisibilityWhere()` cannot carry this: it is also spread into + * ConsultationPlan and SubscriptionPlan queries, and those models have no + * `archivedAt` column — Prisma would reject the filter. So the two gates live + * side by side here rather than one being inlined at eight call sites, for the + * same auditability reason the header gives. + * + * Archived means withdrawn from sale. The row is kept because it carries the + * terms of every booking made against it, and because the plan FK chain + * cascades to Appointment and Payment — see the catalog DELETE handler. + */ +export function eventPlanDiscoverableWhere() { + return { + visibility: { in: MARKETPLACE_VISIBILITY }, + archivedAt: null, + } as const; +} diff --git a/lib/auth/org-permissions.ts b/lib/auth/org-permissions.ts index dad20f185..4146a3f6c 100644 --- a/lib/auth/org-permissions.ts +++ b/lib/auth/org-permissions.ts @@ -36,6 +36,10 @@ export type OrgSurface = // Commerce (sponsor-side; combine with canSponsor at the consumer) | "contracts.read" | "contracts.manage" + // Host-side: authoring the org's OWN bookable offerings. Distinct from + // programs.manage, which is the sponsor's entitlement CRUD — combine with + // canHost at the consumer. + | "catalog.manage" | "programs.manage" | "purchaseOrders.read" | "purchaseOrders.manage" @@ -86,6 +90,12 @@ export const ORG_PERMISSIONS: Record> = { // decisions (spec: MAINTAINER floor); POs are day-to-day. "contracts.read": GOVERNANCE, "contracts.manage": roles("OWNER"), + // OPERATORS rather than GOVERNANCE: publishing an offering is day-to-day + // delivery work, not an org-structural decision like a contract or a + // sponsorship program, so MANAGER holds it. EXPERT deliberately does NOT — + // an org-owned plan commits the ORG's revenue and payout obligation, so it + // needs an operator in the loop. An EXPERT is named as the deliverer instead. + "catalog.manage": OPERATORS, "programs.manage": GOVERNANCE, "purchaseOrders.read": FINANCE_READERS, "purchaseOrders.manage": FINANCE_MUTATORS, diff --git a/lib/data/explore-programs.ts b/lib/data/explore-programs.ts index 8cc991c9c..1758c3c85 100644 --- a/lib/data/explore-programs.ts +++ b/lib/data/explore-programs.ts @@ -2,7 +2,7 @@ import { unstable_cache } from "next/cache"; import prisma from "@/lib/prisma"; import { toPlain } from "@/lib/data/serialize"; import type { Prisma } from "@prisma/client"; -import { marketplaceVisibilityWhere } from "@/lib/api/plans/visibility"; +import { eventPlanDiscoverableWhere } from "@/lib/api/plans/visibility"; import { generateProgramImageUrl } from "@/lib/explore/programs"; import type { Program, @@ -76,7 +76,7 @@ const getTrendingClassPlanIds = unstable_cache( thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); const ranked = await prisma.classPlan.findMany({ - where: { ...marketplaceVisibilityWhere(), ...liveConsultantWhere }, // #726 — no ORG_ONLY in curated feed + where: { ...eventPlanDiscoverableWhere(), ...liveConsultantWhere }, // #726 — no ORG_ONLY in curated feed select: { id: true, classes: { @@ -120,7 +120,7 @@ const getTrendingWebinarPlanIds = unstable_cache( thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); const ranked = await prisma.webinarPlan.findMany({ - where: { ...marketplaceVisibilityWhere(), ...liveConsultantWhere }, // #726 — no ORG_ONLY in curated feed + where: { ...eventPlanDiscoverableWhere(), ...liveConsultantWhere }, // #726 — no ORG_ONLY in curated feed select: { id: true, webinars: { @@ -184,7 +184,7 @@ export const getCuratedPrograms = unstable_cache( classPlans = await prisma.classPlan.findMany({ where: { id: { in: sortedIds }, - ...marketplaceVisibilityWhere(), + ...eventPlanDiscoverableWhere(), ...liveConsultantWhere, }, // #726 include: { @@ -202,7 +202,7 @@ export const getCuratedPrograms = unstable_cache( ); } else { classPlans = await prisma.classPlan.findMany({ - where: { ...marketplaceVisibilityWhere(), ...liveConsultantWhere }, // #726 + where: { ...eventPlanDiscoverableWhere(), ...liveConsultantWhere }, // #726 include: { consultantProfile: planConsultantInclude, topics: true, @@ -238,7 +238,7 @@ export const getCuratedPrograms = unstable_cache( webinarPlans = await prisma.webinarPlan.findMany({ where: { id: { in: sortedIds }, - ...marketplaceVisibilityWhere(), + ...eventPlanDiscoverableWhere(), ...liveConsultantWhere, }, // #726 include: { @@ -253,7 +253,7 @@ export const getCuratedPrograms = unstable_cache( ); } else { webinarPlans = await prisma.webinarPlan.findMany({ - where: { ...marketplaceVisibilityWhere(), ...liveConsultantWhere }, // #726 + where: { ...eventPlanDiscoverableWhere(), ...liveConsultantWhere }, // #726 include: { consultantProfile: planConsultantInclude, topics: true, @@ -306,7 +306,7 @@ export const getTopicsWithCount = unstable_cache( ? { classPlans: { where: { - ...marketplaceVisibilityWhere(), + ...eventPlanDiscoverableWhere(), ...liveConsultantWhere, }, }, @@ -316,7 +316,7 @@ export const getTopicsWithCount = unstable_cache( ? { webinarPlans: { where: { - ...marketplaceVisibilityWhere(), + ...eventPlanDiscoverableWhere(), ...liveConsultantWhere, }, }, diff --git a/lib/data/needs-you.ts b/lib/data/needs-you.ts new file mode 100644 index 000000000..b7de6b7f7 --- /dev/null +++ b/lib/data/needs-you.ts @@ -0,0 +1,121 @@ +import prisma from "@/lib/prisma"; + +/** + * Cross-context "needs you" roll-up for a consultant. + * + * ADR 19 splits the dashboards by the org-ness of the underlying work, and + * accepts as a known cost that "a consultant who works through an organization + * now visits two dashboards". It also states the only sanctioned remedy: a + * cross-context summary "would have to be built as a derived read rather than + * by re-merging the views". This is that derived read. + * + * It deliberately returns COUNTS AND LINKS ONLY. It does not return rows, and + * nothing renders from it in place — every item routes the user into the + * dashboard that owns the work, which is what keeps each number authoritative + * in exactly one place. + */ + +export interface NeedsYouContext { + /** Null for the personal B2C context. */ + organizationId: string | null; + label: string; + /** Booking requests waiting for this consultant to allocate slots. */ + pendingRequests: number; + /** Where to send someone who clicks through. */ + href: string; +} + +export interface NeedsYouSummary { + contexts: NeedsYouContext[]; + total: number; +} + +/** + * @param userId the signed-in user + * @param consultantProfileId their delivering profile + */ +export async function getNeedsYouSummary( + userId: string, + consultantProfileId: string, +): Promise { + // Orgs this person DELIVERS into. A LEARNER membership is not a delivery + // context and must not appear here — nothing is ever awaiting their + // allocation there. + const deliveringMemberships = await prisma.membership.findMany({ + where: { + userId, + status: "ACTIVE", + consultantProfileId, + organization: { canHost: true }, + }, + select: { + organizationId: true, + organization: { select: { name: true } }, + }, + }); + + const scopes: { organizationId: string | null; label: string; href: string }[] = + [ + { + organizationId: null, + label: "Personal", + href: `/dashboard/consultant/${consultantProfileId}/requests`, + }, + ...deliveringMemberships.map((m) => ({ + organizationId: m.organizationId, + label: m.organization.name, + href: `/dashboard/organization/${m.organizationId}/requests`, + })), + ]; + + const contexts = await Promise.all( + scopes.map(async (scope) => { + // Scope predicates are copied verbatim from the two endpoints that back + // the Requests page (app/api/bookings/{consultations,subscriptions}), + // including their asymmetry: Consultation has one optional Appointment + // and counts a missing one as personal ("not org-funded → personal"), + // whereas Subscription has many and uses none/some. If the panel and the + // page disagreed on what personal means, the count would send people to + // a list that doesn't contain the item. + const isPersonal = scope.organizationId === null; + + const [consultations, subscriptions] = await Promise.all([ + prisma.consultation.count({ + where: { + status: "PENDING", + consultationPlan: { consultantProfileId }, + ...(isPersonal + ? { + OR: [ + { appointment: null }, + { appointment: { organizationId: null } }, + ], + } + : { appointment: { organizationId: scope.organizationId } }), + }, + }), + prisma.subscription.count({ + where: { + status: "PENDING", + subscriptionPlan: { consultantProfileId }, + appointments: isPersonal + ? { none: { organizationId: { not: null } } } + : { some: { organizationId: scope.organizationId } }, + }, + }), + ]); + + return { + ...scope, + pendingRequests: consultations + subscriptions, + }; + }), + ); + + const withWork = contexts.filter((c) => c.pendingRequests > 0); + + return { + contexts: withWork, + total: withWork.reduce((sum, c) => sum + c.pendingRequests, 0), + }; +} diff --git a/lib/enterprise/audit-actions.ts b/lib/enterprise/audit-actions.ts index bf8dcc075..1990edcfa 100644 --- a/lib/enterprise/audit-actions.ts +++ b/lib/enterprise/audit-actions.ts @@ -155,10 +155,14 @@ export const AUDIT_ACTIONS = { // Emitted from POST /api/organizations/[orgId]/catalog when an OWNER // adds an OrganizationPlan to the sponsored catalog. CATALOG_PLAN_CREATED: "CATALOG_PLAN_CREATED", - // Emitted from DELETE /api/organizations/[orgId]/catalog bulk - // deactivate (isActive=false). Kept as a single audit row per call, - // with the affected planIds surfaced via `details`. + // Emitted from DELETE /api/organizations/[orgId]/catalog, which ARCHIVES + // (sets `archivedAt`) rather than deleting — the plan FK chain cascades to + // Appointment and Payment, so a real delete would destroy settled money + // records. Kept as a single audit row per call, with the affected planIds + // surfaced via `details`. CATALOG_PLAN_DEACTIVATED: "CATALOG_PLAN_DEACTIVATED", + // The inverse: an archived plan put back on sale. + CATALOG_PLAN_RESTORED: "CATALOG_PLAN_RESTORED", }, SYSTEM: { VERIFIED: "VERIFIED", 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/payouts/earnings-service.ts b/lib/payments/payouts/earnings-service.ts index a9cf58beb..318a47dce 100644 --- a/lib/payments/payouts/earnings-service.ts +++ b/lib/payments/payouts/earnings-service.ts @@ -137,8 +137,16 @@ import { prorate, sumPaise } from "@/lib/payments/utils/money"; * membership at a canHost org. Returns null for independent consultants * or when the HOST-orgs feature flag is off. * - * For multi-org consultants, uses the first active canHost membership. - * (Future: allow consultant to select which org gets credit per-booking.) + * For multi-org consultants the OWNING org wins when the plan has one — an + * org that publishes a plan through its catalog is the seller, so it must be + * the org that gets paid for it. Without that, an expert who is EXPERT at two + * host orgs sends Org B's catalog revenue to Org A purely because they joined + * Org A first. That was unreachable until the org catalog could set + * `Plan.organizationId`; it is reachable now. + * + * With no owning org (a personal B2C plan) the previous rule stands: the + * oldest active canHost membership. ADR 18 records that as known-crude, and it + * remains the fallback rather than the primary rule. */ async function resolveOrgSplit( tx: Tx, @@ -150,25 +158,61 @@ async function resolveOrgSplit( * would silently rewrite what the consultant was owed for bookings * made before the bump. */ at: Date = new Date(), + /** The booked plan, when it is one of the two org-ownable kinds. Read + * inside this transaction rather than by the caller, so plan ownership and + * the earnings rows it decides are read and written under one snapshot. */ + plan: { id: string; kind: "webinar" | "class" } | null = null, ): Promise { if (!ENABLE_HOST_ORGS) return null; + // Only Webinar and Class can be org-owned — Consultation and Subscription + // require a consultantProfileId, so an org can never solely own one. + const ownerOrgId = plan + ? (( + await (plan.kind === "webinar" + ? tx.webinarPlan.findUnique({ + where: { id: plan.id }, + select: { organizationId: true }, + }) + : tx.classPlan.findUnique({ + where: { id: plan.id }, + select: { organizationId: true }, + })) + )?.organizationId ?? null) + : null; + // Arch-4: Membership where role=EXPERT and parent org canHost=true. - // Oldest membership wins (multi-org consultants route deterministically - // to the same org). Rate card resolved via the time-scoped resolver at - // the booking instant. - const membership = await tx.membership.findFirst({ - where: { - consultantProfileId, - role: "EXPERT", - status: "ACTIVE", - organization: { canHost: true, status: "ACTIVE" }, - }, - orderBy: { createdAt: "asc" }, - include: { - organization: { select: { id: true } }, - }, - }); + // Rate card resolved via the time-scoped resolver at the booking instant. + // + // The owning org is tried FIRST. The membership still has to exist and be + // ACTIVE at a canHost org — an org cannot direct earnings to itself for + // someone who is not its expert, and the catalog endpoint enforces the same + // thing at publish time. + const membership = + (ownerOrgId + ? await tx.membership.findFirst({ + where: { + consultantProfileId, + role: "EXPERT", + status: "ACTIVE", + organizationId: ownerOrgId, + organization: { canHost: true, status: "ACTIVE" }, + }, + include: { organization: { select: { id: true } } }, + }) + : null) ?? + // Fallback: oldest membership wins, so multi-org consultants selling their + // OWN plans route deterministically to the same org. + (await tx.membership.findFirst({ + where: { + consultantProfileId, + role: "EXPERT", + status: "ACTIVE", + organization: { canHost: true, status: "ACTIVE" }, + }, + orderBy: { createdAt: "asc" }, + include: { organization: { select: { id: true } } }, + })); if (!membership) return null; @@ -282,6 +326,7 @@ export async function createEarningsFromPayment({ planId = payment.appointment.class.classPlanId; } + // FIX #9: Wrap earnings creation + balance updates in a transaction for atomicity. // Also handles P2002 unique constraint violations gracefully for idempotency. // #896 — Serializable isolation + P2034 retry so the waiver eligibility count() @@ -312,6 +357,7 @@ export async function createEarningsFromPayment({ consultantProfileId, grossAmount, payment.createdAt, + planId && planType ? { id: planId, kind: planType } : null, ); // #687 E-01/E-02 — the PENDING_TRUST park keys on the SPONSORING org @@ -397,6 +443,11 @@ export async function createEarningsFromPayment({ ); for (const split of splits) { if (split.role === "OWNER" || split.share <= 0) continue; + // No ownerOrgId on purpose. ADR 18: collaborations are org-blind + // and "each collaborator's earnings resolve to their own org + // independently". A collaborator on someone else's org-owned plan + // is not that org's expert, so their share settles to THEIR host + // org, not the seller's. const collabOrgSplit = await resolveOrgSplit( tx, split.consultantProfileId, 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 0d958be1c..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 @@ -3164,6 +3173,16 @@ model WebinarPlan { // #726 — marketplace leak guard. See ConsultationPlan.visibility. visibility OrgPlanVisibility @default(PUBLIC) + // #catalog-archive — retiring a plan MUST NOT delete it. The FK chain is + // onDelete: Cascade the whole way down (WebinarPlan -> Webinar -> Appointment + // -> Payment), so removing a booked plan would physically destroy settled + // money records. A plan row is also the TERMS of every sale made against it, + // which past appointments, invoices and earnings still resolve. Archiving + // keeps the history intact and only withdraws the plan from booking and + // discovery. Null means live; set means withdrawn, and it is reversible. + archivedAt DateTime? + + webinars Webinar[] materials PlanMaterial[] collaborators Collaborator[] @@ -3248,6 +3267,16 @@ model ClassPlan { // #726 — marketplace leak guard. See ConsultationPlan.visibility. visibility OrgPlanVisibility @default(PUBLIC) + // #catalog-archive — retiring a plan MUST NOT delete it. The FK chain is + // onDelete: Cascade the whole way down (WebinarPlan -> Webinar -> Appointment + // -> Payment), so removing a booked plan would physically destroy settled + // money records. A plan row is also the TERMS of every sale made against it, + // which past appointments, invoices and earnings still resolve. Archiving + // keeps the history intact and only withdraws the plan from booking and + // discovery. Null means live; set means withdrawn, and it is reversible. + archivedAt DateTime? + + classes Class[] materials PlanMaterial[] collaborators Collaborator[] 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, diff --git a/types/org-details.ts b/types/org-details.ts index febddd947..9adf841ca 100644 --- a/types/org-details.ts +++ b/types/org-details.ts @@ -51,7 +51,17 @@ export interface OrgDetailsResponse { * synthesized ADMIN stub, or a 403. A null membership would mean * the caller bypassed the access check — not representable here. */ - membership: { role: MemberRole; status: MemberStatus }; + membership: { + role: MemberRole; + status: MemberStatus; + /** + * Set when this member also delivers sessions. Distinct from + * `role === "EXPERT"` — an OWNER or MANAGER can hold a consultant profile + * too, and the delivery surfaces (Requests) key off the profile, not the + * role. + */ + consultantProfileId: string | null; + }; } /** diff --git a/types/planner-events.ts b/types/planner-events.ts index 2548edeed..67330b62d 100644 --- a/types/planner-events.ts +++ b/types/planner-events.ts @@ -16,6 +16,32 @@ import { import { PlanEmailSupport } from "@prisma/client"; import { ConsultationPlan, SubscriptionPlan } from "@/schemas/plans"; +/** + * Uploaded handout attached to a plan. Followed the planner components out of + * the consultant route folder for the reason in the header above — the only + * consumers are `components/planner/**`, and a shared component importing a + * type through `app/dashboard/consultant/[consultantId]/` would invert the + * dependency the same way. + */ +export interface IPlanMaterial { + id: string; + fileName: string; + originalName: string; + fileSize: number; + mimeType: string; + fileUrl: string; + storagePath: string; + description: string | null; + order: number; + // Plan references (one will be set) + consultationPlanId?: string | null; + subscriptionPlanId?: string | null; + webinarPlanId?: string | null; + classPlanId?: string | null; + uploadedAt: Date; + updatedAt?: Date; +} + // UI-focused event types with topics as string[] (transformed at service boundary) // Services convert Topic[] from Prisma to string[] before passing to components @@ -161,8 +187,21 @@ export type FormData = { interface BasePlannerProps { isOpen: boolean; onClose: () => void; + /** + * The consultant profile that DELIVERS the session. On the personal planner + * this is the signed-in consultant; on the org catalog it is the EXPERT the + * operator picked, which is why it stays a plain id rather than being read + * from the session inside the form. + */ consultantId: string; isSaving?: boolean; + /** + * Org that OWNS the plan. Absent or null means a personal B2C plan, which is + * the default and what the consultant planner passes. Only Webinar and Class + * accept a non-null value — `ConsultationPlan`/`SubscriptionPlan` require a + * `consultantProfileId` in the schema and so can never be solely org-owned. + */ + organizationId?: string | null; } export interface WebinarPlannerProps extends BasePlannerProps {