diff --git a/.gitignore b/.gitignore index a3058391e..041260c9f 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,11 @@ node-payment-main/ # Sentry Config File .env.sentry-build-plugin + +# Agent + scratch artefacts that live in the working tree but are not the app. +# `.claude/worktrees/` in particular holds OTHER agents' checkouts — committing +# it drags their in-flight branches into this one. These were swept in once by a +# `git add -A`; ignoring them means the next one cannot repeat it. +.claude/worktrees/ +screens/ +prompts/ diff --git a/__tests__/booking-algorithm/allocationAlgorithms.test.ts b/__tests__/booking-algorithm/allocationAlgorithms.test.ts index 127e92c8c..8c6550592 100644 --- a/__tests__/booking-algorithm/allocationAlgorithms.test.ts +++ b/__tests__/booking-algorithm/allocationAlgorithms.test.ts @@ -18,8 +18,8 @@ import "./setup"; import { AllocationAlgorithms, type AllocationOptions, -} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationAlgorithms"; -import { AllocationService } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationService"; +} from "@/lib/scheduling/allocationAlgorithms"; +import { AllocationService } from "@/lib/scheduling/allocationService"; import { makeTimeSlot, makeConsecutiveTimeSlots, diff --git a/__tests__/booking-algorithm/calendarUtils.test.ts b/__tests__/booking-algorithm/calendarUtils.test.ts index 60ae07bdf..0f4f60f9f 100644 --- a/__tests__/booking-algorithm/calendarUtils.test.ts +++ b/__tests__/booking-algorithm/calendarUtils.test.ts @@ -38,7 +38,7 @@ import { getAppointmentUser, type TimeSlot, type Appointment, -} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/calendarUtils"; +} from "@/lib/scheduling/calendarUtils"; import { ScheduleType, DayOfWeek, AppointmentsType } from "@prisma/client"; import { makeTimeSlot, diff --git a/__tests__/booking-algorithm/idempotency-key.test.ts b/__tests__/booking-algorithm/idempotency-key.test.ts index 0a59c4833..3a9cb8e36 100644 --- a/__tests__/booking-algorithm/idempotency-key.test.ts +++ b/__tests__/booking-algorithm/idempotency-key.test.ts @@ -9,10 +9,10 @@ import "./setup"; import { computeAttemptFingerprint, resolveAttemptKey, -} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useSlotAllocation"; +} from "@/hooks/scheduling/useSlotAllocation"; // eslint-disable-next-line jest/no-mocks-import -- shared fixture builders, not module mocks (suite-wide pattern) import { makeConsecutiveTimeSlots } from "./__mocks__/booking.mockData"; -import type { TimeSlot } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/calendarUtils"; +import type { TimeSlot } from "@/lib/scheduling/calendarUtils"; const slots = makeConsecutiveTimeSlots( "2026-08-03T09:00:00.000Z", diff --git a/__tests__/booking-algorithm/mode-parity.test.ts b/__tests__/booking-algorithm/mode-parity.test.ts index 3f16d18b2..2fafb3f69 100644 --- a/__tests__/booking-algorithm/mode-parity.test.ts +++ b/__tests__/booking-algorithm/mode-parity.test.ts @@ -12,18 +12,18 @@ import "./setup"; import { AllocationAlgorithms, type AllocationOptions, -} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationAlgorithms"; -import { AllocationService } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationService"; +} from "@/lib/scheduling/allocationAlgorithms"; +import { AllocationService } from "@/lib/scheduling/allocationService"; import { validateEventSlots, getEventConstraints, getSlotLimits, groupSlotsByDay, -} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/slotSelectionValidation"; +} from "@/lib/scheduling/slotSelectionValidation"; import { validateSlotDistribution, type TimeSlot, -} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/calendarUtils"; +} from "@/lib/scheduling/calendarUtils"; // eslint-disable-next-line jest/no-mocks-import -- shared fixture builders, not module mocks (suite-wide pattern) import { makeConsecutiveTimeSlots } from "./__mocks__/booking.mockData"; diff --git a/__tests__/booking-algorithm/slot-boundary-bucketing.test.ts b/__tests__/booking-algorithm/slot-boundary-bucketing.test.ts index 7a2f783ad..fdaef0b7c 100644 --- a/__tests__/booking-algorithm/slot-boundary-bucketing.test.ts +++ b/__tests__/booking-algorithm/slot-boundary-bucketing.test.ts @@ -21,10 +21,10 @@ import { dayKey, weekKey, type SlotLimits, -} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/slotSelectionValidation"; +} from "@/lib/scheduling/slotSelectionValidation"; // eslint-disable-next-line jest/no-mocks-import -- shared fixture builders, not module mocks (suite-wide pattern) import { makeConsecutiveTimeSlots } from "./__mocks__/booking.mockData"; -import type { TimeSlot } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/calendarUtils"; +import type { TimeSlot } from "@/lib/scheduling/calendarUtils"; const limits = (slotsPerSession: number, maxSlots: number): SlotLimits => ({ minSlots: maxSlots * slotsPerSession, diff --git a/__tests__/booking-algorithm/toast-queue.test.ts b/__tests__/booking-algorithm/toast-queue.test.ts index 605b31276..a4766eb1c 100644 --- a/__tests__/booking-algorithm/toast-queue.test.ts +++ b/__tests__/booking-algorithm/toast-queue.test.ts @@ -7,8 +7,8 @@ import "./setup"; -import { enqueueToast } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useSlotAllocation"; -import type { AllocationToast } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationMessages"; +import { enqueueToast } from "@/hooks/scheduling/useSlotAllocation"; +import type { AllocationToast } from "@/lib/scheduling/allocationMessages"; const msg = (title: string, description = "d"): AllocationToast => ({ title, diff --git a/__tests__/dashboard/nav-targets-resolve.test.ts b/__tests__/dashboard/nav-targets-resolve.test.ts index 38227e755..a1cbb0fd3 100644 --- a/__tests__/dashboard/nav-targets-resolve.test.ts +++ b/__tests__/dashboard/nav-targets-resolve.test.ts @@ -55,6 +55,10 @@ describe("org nav targets resolve", () => { "my-program", "compensation", "appointments", + // Participant surface, org-scoped by route — see the layout comment. + "messages", + // Delivery surface: slot allocation for org-funded bookings. + "requests", "members", "collaborations", "contracts", diff --git a/__tests__/documents/revision-threading.test.ts b/__tests__/documents/revision-threading.test.ts index e121d6ebe..6d845511a 100644 --- a/__tests__/documents/revision-threading.test.ts +++ b/__tests__/documents/revision-threading.test.ts @@ -20,7 +20,10 @@ const ROUTE = join( ); const UPLOAD_UI = join( process.cwd(), - "app/dashboard/consultee/[consulteeId]/(features)/appointments/DocumentUpload.tsx", + // Moved out of the consultee route folder: the org appointment detail page + // renders the same component, and a shared component cannot live inside one + // tree's route directory. + "components/appointments/DocumentUpload.tsx", ); describe("consultee revision upload", () => { diff --git a/__tests__/enterprise/list-appointments-scope.test.ts b/__tests__/enterprise/list-appointments-scope.test.ts index ca271f2ed..1464ca72c 100644 --- a/__tests__/enterprise/list-appointments-scope.test.ts +++ b/__tests__/enterprise/list-appointments-scope.test.ts @@ -41,13 +41,29 @@ describe("buildWhere — personal scope (#org-appts)", () => { expect(hasConsulteeUser).toBe(true); }); - it("org scope filters by orgId with no user OR", () => { + it("org scope covers events the org HOSTS or FUNDED, and never filters by user", () => { const w = buildWhere({ scope: { kind: "org", orgId: "org1" }, userId: "u1", }) as Record; - expect(w.organizationId).toBe("org1"); - expect(w.OR).toBeUndefined(); + + // Two columns, two questions. A group event shares ONE Appointment across + // every registrant and checkout tags it with the HOST's org, so filtering + // on `organizationId` alone hid every webinar a sponsor had paid into. + // Per-registrant funding lives on Payment.organizationId. + const or = w.OR as Record[]; + expect(or).toHaveLength(2); + expect(or).toContainEqual({ organizationId: "org1" }); + expect(or).toContainEqual({ + payment: { some: { organizationId: "org1" } }, + }); + + // The property the previous version of this test was really protecting: + // the org arm carries NO user filter, which is why it requires + // `operations.read` and why a non-operator is downgraded to `orgMember`. + // Widening to hosted-or-funded must not have smuggled one in. + expect(JSON.stringify(w)).not.toContain('"u1"'); + expect(JSON.stringify(w)).not.toContain("userId"); }); it("orgMember scope pins organizationId AND filters to the user's participation (#org-appts)", () => { diff --git a/__tests__/security/dm-channel-org-precedence.test.ts b/__tests__/security/dm-channel-org-precedence.test.ts new file mode 100644 index 000000000..99c849baf --- /dev/null +++ b/__tests__/security/dm-channel-org-precedence.test.ts @@ -0,0 +1,113 @@ +/** + * @jest-environment node + */ + +/** + * Four call sites compute a DM channel id, and every one of them has to agree + * with the creators in `actions/stream/chat/channel.action.ts` — they are + * recomputing an id those creators already used, so any divergence points at a + * channel that does not exist. + * + * The creators resolve context as: + * + * plan.organizationId ?? appointment.organizationId ?? null + * + * Two genuinely distinct cases sit behind that `??`: a plan can be org-HOSTED + * while the booking is self-funded, and a personal plan can be booked through an + * org-funded membership. Review found three consumers reading only the + * appointment, which treated every org-hosted-plan booking as personal. The + * reconcile set then looked for `dm--` while the real channel was `dmo-…` + * — never re-joined at best, and at worst treated as stale so the user was + * removed from their own conversation. + * + * These assertions are source-level because the point is which expression each + * site uses, not what a mocked Prisma row would return. + */ + +import { readFileSync } from "fs"; +import { join } from "path"; + +import { getDmChannelId, STREAM_CHANNEL_ID_MAX } from "@/lib/stream-utils"; + +const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8"); + +describe("every consumer matches the creators' precedence", () => { + it("the creators put plan org first", () => { + const src = read("actions/stream/chat/channel.action.ts"); + expect(src).toContain( + "consultation.consultationPlan.organizationId ??\n consultation.appointment?.organizationId ??", + ); + }); + + it.each([ + ["reconcile", "actions/stream/chat/event-channel.action.ts", "consultationPlan?.organizationId"], + ["search", "app/api/stream/channels/search-appointments/route.ts", "consultationPlan.organizationId ??"], + ["backfill", "scripts/stream/backfill-channel-org.ts", "consultationPlan?.organizationId ??"], + ])("%s reads the plan org before the appointment's", (_label, rel, needle) => { + expect(read(rel)).toContain(needle); + }); + + it.each([ + ["reconcile", "actions/stream/chat/event-channel.action.ts"], + ["search", "app/api/stream/channels/search-appointments/route.ts"], + ["backfill", "scripts/stream/backfill-channel-org.ts"], + ])("%s loads the plan org it now depends on", (_label, rel) => { + // A precedence that reads a field the query never selected is silently + // `undefined`, which falls through to the appointment and reintroduces the + // bug without failing anything. + expect(read(rel)).toContain("organizationId: true"); + }); +}); + +describe("channel ids stay inside Stream's cap without throwing", () => { + const CUID_A = "cmqb1757m005stxyoe218odf1"; + const CUID_B = "cmqb190qa00hotxyor367yjz1"; + const UUID_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + const UUID_B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + + it("leaves ordinary personal ids byte-identical, so no channel moves", () => { + expect(getDmChannelId(CUID_A, CUID_B)).toBe(`dm-${CUID_A}-${CUID_B}`); + }); + + it("is order-independent", () => { + expect(getDmChannelId(CUID_B, CUID_A)).toBe(getDmChannelId(CUID_A, CUID_B)); + }); + + it("degrades legacy uuid pairs instead of throwing", () => { + // `dm-<36>-<36>` is 76 chars. Throwing here rejected the whole + // reconciliation — `getDmPairsForUser` and the `expectedChannelIds` map are + // not wrapped per-item — so one legacy account broke channel sync for + // everyone paired with it. + expect(`dm-${UUID_A}-${UUID_B}`.length).toBeGreaterThan( + STREAM_CHANNEL_ID_MAX, + ); + + const id = getDmChannelId(UUID_A, UUID_B); + expect(id.startsWith("dmh-")).toBe(true); + expect(id.length).toBeLessThanOrEqual(STREAM_CHANNEL_ID_MAX); + // Deterministic, or the fallback would strand the conversation. + expect(getDmChannelId(UUID_B, UUID_A)).toBe(id); + }); + + it("keeps the three namespaces distinct", () => { + const personal = getDmChannelId(CUID_A, CUID_B); + const org = getDmChannelId(CUID_A, CUID_B, "org-1"); + const legacy = getDmChannelId(UUID_A, UUID_B); + + expect(new Set([personal, org, legacy]).size).toBe(3); + expect(org.startsWith("dmo-")).toBe(true); + }); + + it("separates two orgs for the same pair, with room to spare", () => { + const a = getDmChannelId(CUID_A, CUID_B, "org-1"); + const b = getDmChannelId(CUID_A, CUID_B, "org-2"); + expect(a).not.toBe(b); + + // The org segment is the ONLY differentiator between two orgs' otherwise + // identical pair digest, so a collision would merge two organizations' DM + // threads. 8 hex chars was 32 bits; this is 64. + const orgSegment = a.split("-")[1]; + expect(orgSegment).toHaveLength(16); + expect(a.length).toBeLessThanOrEqual(STREAM_CHANNEL_ID_MAX); + }); +}); diff --git a/__tests__/security/org-appointment-detail-ownership.test.ts b/__tests__/security/org-appointment-detail-ownership.test.ts new file mode 100644 index 000000000..268b22355 --- /dev/null +++ b/__tests__/security/org-appointment-detail-ownership.test.ts @@ -0,0 +1,70 @@ +/** + * The org appointment detail page takes BOTH ids from the URL, and neither + * constrains the other: `/dashboard/organization//appointments/` + * would happily pair a member's own org with somebody else's appointment. + * + * Membership alone is not enough to close that. `requireOrgAccess` answers "is + * the caller in this org", which says nothing about whether the appointment + * belongs to the org or whether the caller is on it. Both have to be asked + * separately, and this file pins that they are — it is the same shape as the + * SSR ownership hole closed in #1029, where a server page trusted a route param + * because a client layout appeared to have checked it. + * + * Participation rather than `operations.read`: the page renders documents and + * offers reschedule and cancel, which are participant actions. An operator's + * view of org sessions stays the metadata-only list (ADR 20), so an OWNER who + * is not on the session gets a 404 here, not a read. + */ + +import { readFileSync } from "fs"; +import { join } from "path"; + +const PAGE = + "app/dashboard/organization/[orgId]/appointments/[appointmentId]/page.tsx"; + +const src = readFileSync(join(process.cwd(), PAGE), "utf8"); + +describe("org appointment detail binds both ids", () => { + it("requires org membership first", () => { + expect(src).toContain("await requireOrgAccess(orgId)"); + }); + + it("checks the appointment belongs to THIS org, not merely to some org", () => { + // Without this, any member of any org could read any org-funded + // appointment by pairing their own orgId with a foreign appointmentId. + expect(src).toContain("appointment.organizationId !== orgId"); + }); + + it("checks the caller is a party to the appointment", () => { + // Requester, trial consultee, or attached to a slot — the same test the + // consultee detail page applies. + expect(src).toContain("requestedBy?.id === profile.id"); + expect(src).toContain("trialSession?.consulteeProfile?.id === profile.id"); + expect(src).toContain("slotsOfAppointment.some"); + }); + + it("fails closed on every branch", () => { + // notFound() rather than a redirect: a redirect would confirm the + // appointment exists to someone who should not know that. + const checks = [ + "if (access.error)", + "if (!detail || !profile) notFound()", + "if (appointment.organizationId !== orgId) notFound()", + "if (!owns) notFound()", + ]; + for (const c of checks) expect(src).toContain(c); + }); + + it("orders the org check before the participation check", () => { + // Cheap scalar comparison before the ownership walk; also means a + // cross-org id never reaches the participation logic at all. + const orgCheck = src.indexOf("appointment.organizationId !== orgId"); + const ownsCheck = src.indexOf("const owns ="); + expect(orgCheck).toBeGreaterThan(-1); + expect(ownsCheck).toBeGreaterThan(orgCheck); + }); + + it("is a server component, so the checks run before anything streams", () => { + expect(src.slice(0, 200)).not.toContain('"use client"'); + }); +}); diff --git a/__tests__/security/org-sponsor-event-visibility.test.ts b/__tests__/security/org-sponsor-event-visibility.test.ts new file mode 100644 index 000000000..278fdfa6e --- /dev/null +++ b/__tests__/security/org-sponsor-event-visibility.test.ts @@ -0,0 +1,94 @@ +/** + * @jest-environment node + */ + +/** + * A webinar or class is ONE Appointment shared by every registrant, and + * checkout tags it with the HOST's org (`plan.organizationId`) rather than the + * first registrant's — deliberately, so whoever books first does not decide + * which organization the event belongs to. Per-registrant funding lives on + * `Payment.organizationId` instead. + * + * The consequence nobody had traced: the org appointments view filtered on + * `Appointment.organizationId` alone, so a sponsor that paid to put five + * employees into someone else's public webinar saw NOTHING. The money appeared + * on its invoice and the seats came off its program, but the session itself was + * invisible. 1:1 kinds were never affected — there the appointment's org is + * already the funding org. + * + * Widening that view to hosted-OR-funded is only safe if it stays narrow in the + * other direction. A shared appointment may carry registrants from several + * sponsors and the public, so a sponsor must see the seats it paid for and not + * the ones it did not. That is the boundary this file pins. + */ + +import { readFileSync } from "fs"; +import { join } from "path"; + +import { buildWhere } from "@/lib/api/scope/list-appointments"; + +const SRC = readFileSync( + join(process.cwd(), "lib/api/scope/list-appointments.ts"), + "utf8", +); + +describe("a sponsor sees events it funded, not only ones it hosts", () => { + it("matches on host org OR funding payment", () => { + const w = buildWhere({ + scope: { kind: "org", orgId: "acme" }, + userId: "irrelevant", + }) as Record; + + const or = w.OR as Record[]; + expect(or).toContainEqual({ organizationId: "acme" }); + expect(or).toContainEqual({ payment: { some: { organizationId: "acme" } } }); + }); + + it("does not pin organizationId at the top level any more", () => { + // A top-level `organizationId` would AND with the OR and re-exclude every + // funded-but-not-hosted event — silently restoring the bug. + const w = buildWhere({ + scope: { kind: "org", orgId: "acme" }, + userId: "irrelevant", + }) as Record; + expect(w.organizationId).toBeUndefined(); + }); +}); + +describe("but only the sponsor's OWN seats are returned", () => { + it("the payer include is filtered to the viewing org", () => { + // An unfiltered `payment: true` would hand a sponsor every registrant's + // identity on a shared webinar — including other sponsors' employees and + // members of the public. The `where` is the whole guard. + expect(SRC).toContain("where: { organizationId: params.scope.orgId }"); + + const start = SRC.indexOf("payment: {\n where:"); + expect(start).toBeGreaterThan(-1); + }); + + it("the payer include is attached ONLY on the org scope", () => { + // `orgMember` is already narrowed to the viewer's own rows and `personal` + // has no org at all; attaching it there would be meaningless at best. + expect(SRC).toContain('...(params.scope.kind === "org"'); + }); + + it("selects the payer's identity and nothing else from Payment", () => { + const start = SRC.indexOf("payment: {\n where:"); + const block = SRC.slice(start, start + 400); + + expect(block).toContain("user: { select: { id: true, name: true, email: true } }"); + // No money on this surface: amounts belong on Billing and Reimbursements, + // both of which gate on finance permissions rather than operations.read. + for (const field of ["amount", "amountPaise", "paymentIntent", "status"]) { + expect(block).not.toContain(`${field}: true`); + } + }); + + it("still applies no user filter — the org arm is role-gated, not self-scoped", () => { + const w = buildWhere({ + scope: { kind: "org", orgId: "acme" }, + userId: "u1", + }); + expect(JSON.stringify(w)).not.toContain("u1"); + }); +}); diff --git a/actions/stream/chat/channel.action.ts b/actions/stream/chat/channel.action.ts index 1a392c1ca..ddc40db76 100644 --- a/actions/stream/chat/channel.action.ts +++ b/actions/stream/chat/channel.action.ts @@ -161,18 +161,26 @@ export async function createChannel(input: { export async function createDirectMessageChannel( currentUserId: string, targetUserId: string, + /** + * Context this conversation belongs to. Omitted (or null) means personal — + * the channel then lives in the B2C dashboards and carries no org tag. Pass + * an org id to open the thread inside that organization instead; the two are + * separate channels by design (see getDmChannelId). + */ + organizationId?: string | null, ) { // Validate inputs memberIdSchema.parse(currentUserId); memberIdSchema.parse(targetUserId); - const channelId = getDmChannelId(currentUserId, targetUserId); + const channelId = getDmChannelId(currentUserId, targetUserId, organizationId); return createChannel({ channelType: "messaging", channelId, members: [currentUserId, targetUserId], createdById: currentUserId, + organizationId, }); } @@ -415,12 +423,13 @@ export async function createConsultationChannel( null : organizationId; - // DM channel is per consultant-consultee pair (not per event). - // Per-event IDs are not stored on the channel since multiple - // consultations/subscriptions between the same pair share one DM. + // One DM per pair PER CONTEXT. Still not per event — multiple + // consultations/subscriptions between the same pair in the same context share + // one thread — but a personal booking and an org-funded one no longer collide + // into a single channel that can only live in one dashboard (ADR 19). return createChannel({ channelType: "messaging", - channelId: getDmChannelId(consultantId, consulteeId), + channelId: getDmChannelId(consultantId, consulteeId, resolvedOrgId), members: [consultantId, consulteeId], createdById: consultantId, additionalData: { @@ -501,12 +510,13 @@ export async function createSubscriptionChannel( null : organizationId; - // DM channel is per consultant-consultee pair (not per event). - // Per-event IDs are not stored on the channel since multiple - // consultations/subscriptions between the same pair share one DM. + // One DM per pair PER CONTEXT. Still not per event — multiple + // consultations/subscriptions between the same pair in the same context share + // one thread — but a personal booking and an org-funded one no longer collide + // into a single channel that can only live in one dashboard (ADR 19). return createChannel({ channelType: "messaging", - channelId: getDmChannelId(consultantId, consulteeId), + channelId: getDmChannelId(consultantId, consulteeId, resolvedOrgId), members: [consultantId, consulteeId], createdById: consultantId, additionalData: { diff --git a/actions/stream/chat/event-channel.action.ts b/actions/stream/chat/event-channel.action.ts index 405ec9277..e961e8e14 100644 --- a/actions/stream/chat/event-channel.action.ts +++ b/actions/stream/chat/event-channel.action.ts @@ -556,8 +556,8 @@ export async function syncUserEventChannels( // Build the set of channel IDs this user is expected to be in const expectedChannelIds = new Set([ ...eventIds.map(({ type, id }) => getChannelId(type, id)), - ...dmPairs.map(({ consultantUserId, consulteeUserId }) => - getDmChannelId(consultantUserId, consulteeUserId), + ...dmPairs.map(({ consultantUserId, consulteeUserId, organizationId }) => + getDmChannelId(consultantUserId, consulteeUserId, organizationId), ), ]); @@ -583,7 +583,9 @@ export async function syncUserEventChannels( } } - // --- DM pair add-pass: join/create one channel per consultant-consultee pair --- + // --- DM add-pass: one channel per pair PER FUNDING CONTEXT --- + // A pair working both B2C and through an org now has two threads, and this + // pass joins the user to each. `dmPairs` is already keyed that way. for (let i = 0; i < dmPairs.length; i += BATCH_SIZE) { const batch = dmPairs.slice(i, i + BATCH_SIZE); const results = await Promise.allSettled( @@ -592,6 +594,7 @@ export async function syncUserEventChannels( pair.consultantUserId, pair.consulteeUserId, userId, + pair.organizationId, ), ), ); @@ -696,17 +699,56 @@ export async function syncUserEventChannels( /** * Get unique consultant-consultee DM pairs for a user, across consultations and subscriptions. */ +/** A DM the user should be a member of, in one specific funding context. */ +interface DmPair { + consultantUserId: string; + consulteeUserId: string; + /** null = personal (B2C). Part of the channel key — see getDmChannelId. */ + organizationId: string | null; +} + +/** + * The org context a DM channel was created under. + * + * Precedence MUST match `createConsultationChannel` / `createSubscriptionChannel` + * exactly — `plan.organizationId ?? appointment.organizationId ?? null` — because + * this function recomputes the channel id those creators already used. They are + * two distinct cases: a plan can be org-HOSTED while the booking is self-funded, + * and a personal plan can be booked through an org-funded membership. + * + * Reading only the appointment treated every org-hosted-plan booking as + * personal, so the reconcile set looked for `dm--` while the real channel + * was `dmo-…`. At best it was never re-joined; at worst the real one was treated + * as stale and the user removed from it. + * + * Subscriptions carry many appointments but are funded once, so the first is + * representative. + */ +function bookingOrgId(booking: { + consultationPlan?: { organizationId: string | null } | null; + subscriptionPlan?: { organizationId: string | null } | null; + appointment?: { organizationId: string | null } | null; + appointments?: { organizationId: string | null }[]; +}): string | null { + return ( + booking.consultationPlan?.organizationId ?? + booking.subscriptionPlan?.organizationId ?? + booking.appointment?.organizationId ?? + booking.appointments?.[0]?.organizationId ?? + null + ); +} + async function getDmPairsForUser( userId: string, user: { consultantProfileId: string | null; consulteeProfileId: string | null; }, -): Promise<{ consultantUserId: string; consulteeUserId: string }[]> { - const pairMap = new Map< - string, - { consultantUserId: string; consulteeUserId: string } - >(); +): Promise { + // Keyed by channel id, so a pair working in two contexts yields two entries + // rather than one overwriting the other. + const pairMap = new Map(); if (user.consultantProfileId) { const [consultations, subscriptions] = await Promise.all([ @@ -717,6 +759,12 @@ async function getDmPairsForUser( }, include: { requestedBy: { include: { user: { select: { id: true } } } }, + // The DM channel key includes the funding context, so the reconcile + // set has to know it too — otherwise it looks for a personal channel + // that an org booking never created. Plan org FIRST, matching + // createConsultationChannel's precedence exactly. + consultationPlan: { select: { organizationId: true } }, + appointment: { select: { organizationId: true } }, }, }), prisma.subscription.findMany({ @@ -726,14 +774,21 @@ async function getDmPairsForUser( }, include: { requestedBy: { include: { user: { select: { id: true } } } }, + subscriptionPlan: { select: { organizationId: true } }, + appointments: { select: { organizationId: true }, take: 1 }, }, }), ]); for (const c of [...consultations, ...subscriptions]) { const consulteeUserId = c.requestedBy?.user?.id; if (!consulteeUserId) continue; - const channelId = getDmChannelId(userId, consulteeUserId); - pairMap.set(channelId, { consultantUserId: userId, consulteeUserId }); + const organizationId = bookingOrgId(c); + const channelId = getDmChannelId(userId, consulteeUserId, organizationId); + pairMap.set(channelId, { + consultantUserId: userId, + consulteeUserId, + organizationId, + }); } } @@ -752,6 +807,7 @@ async function getDmPairsForUser( }, }, }, + appointment: { select: { organizationId: true } }, }, }), prisma.subscription.findMany({ @@ -767,20 +823,32 @@ async function getDmPairsForUser( }, }, }, + appointments: { select: { organizationId: true }, take: 1 }, }, }), ]); for (const c of consultations) { const consultantUserId = c.consultationPlan?.consultantProfile?.user?.id; if (!consultantUserId) continue; - const channelId = getDmChannelId(consultantUserId, userId); - pairMap.set(channelId, { consultantUserId, consulteeUserId: userId }); + const organizationId = bookingOrgId(c); + const channelId = getDmChannelId(consultantUserId, userId, organizationId); + pairMap.set(channelId, { + consultantUserId, + consulteeUserId: userId, + organizationId, + }); } - for (const s of subscriptions) { - const consultantUserId = s.subscriptionPlan?.consultantProfile?.user?.id; + for (const sub of subscriptions) { + const consultantUserId = + sub.subscriptionPlan?.consultantProfile?.user?.id; if (!consultantUserId) continue; - const channelId = getDmChannelId(consultantUserId, userId); - pairMap.set(channelId, { consultantUserId, consulteeUserId: userId }); + const organizationId = bookingOrgId(sub); + const channelId = getDmChannelId(consultantUserId, userId, organizationId); + pairMap.set(channelId, { + consultantUserId, + consulteeUserId: userId, + organizationId, + }); } } @@ -794,8 +862,14 @@ async function addUserToDmChannel( consultantUserId: string, consulteeUserId: string, currentUserId: string, + /** Funding context — the channel key differs per org (see getDmChannelId). */ + organizationId: string | null, ): Promise<{ success: boolean; channelId: string; created?: boolean }> { - const channelId = getDmChannelId(consultantUserId, consulteeUserId); + const channelId = getDmChannelId( + consultantUserId, + consulteeUserId, + organizationId, + ); const channelType = "messaging"; if (getMembershipCached(channelId, currentUserId) === true) { diff --git a/app/api/stream/channels/search-appointments/route.ts b/app/api/stream/channels/search-appointments/route.ts index 39433665e..0ad850793 100644 --- a/app/api/stream/channels/search-appointments/route.ts +++ b/app/api/stream/channels/search-appointments/route.ts @@ -3,14 +3,10 @@ import * as Sentry from "@sentry/nextjs"; import prisma from "lib/prisma"; import { getSession } from "@/lib/auth-server"; import { getDmChannelId } from "@/lib/stream-utils"; -export type AppointmentSearchResult = { - id: string; - type: "consultation" | "subscription" | "webinar" | "class"; - name: string; - consultantName: string; - consultantImage?: string; - channelId: string; -}; +import { + AppointmentSearchResultSchema, + type AppointmentSearchResult, +} from "@/schemas/stream-search"; export async function GET(request: NextRequest) { try { @@ -115,6 +111,8 @@ export async function GET(request: NextRequest) { }, }, }, + // Needed to resolve which DM thread this hit belongs to. + appointment: { select: { organizationId: true } }, }, take: 10, }); @@ -130,9 +128,18 @@ export async function GET(request: NextRequest) { consultantImage: consultation.consultationPlan.consultantProfile.user.image || undefined, + // Funding context is part of the DM key, so a hit must resolve to the + // SAME channel the creator made. Precedence matches + // createConsultationChannel exactly — plan org first, then the + // appointment's. Reading only the appointment sent org-hosted-plan + // bookings to a personal channel that was never created, so clicking + // the result opened an empty conversation. channelId: getDmChannelId( consultation.consultationPlan.consultantProfile.user.id, consultation.requestedBy.user.id, + consultation.consultationPlan.organizationId ?? + consultation.appointment?.organizationId ?? + null, ), }); } @@ -220,6 +227,9 @@ export async function GET(request: NextRequest) { }, }, }, + // Needed to resolve which DM thread this hit belongs to. A subscription + // is funded once, so every appointment under it shares the org. + appointments: { select: { organizationId: true }, take: 1 }, }, take: 10, }); @@ -235,9 +245,13 @@ export async function GET(request: NextRequest) { consultantImage: subscription.subscriptionPlan.consultantProfile.user.image || undefined, + // Same precedence as createSubscriptionChannel. channelId: getDmChannelId( subscription.subscriptionPlan.consultantProfile.user.id, subscription.requestedBy.user.id, + subscription.subscriptionPlan.organizationId ?? + subscription.appointments?.[0]?.organizationId ?? + null, ), }); } @@ -412,7 +426,13 @@ export async function GET(request: NextRequest) { results.sort((a, b) => a.name.localeCompare(b.name)); // Limit total results - return NextResponse.json(results.slice(0, 20)); + // Parse on the way out. The consumer derives its type from this same + // schema, so validating here is what makes the two agree by construction + // rather than by assertion — a field renamed in this handler fails at the + // boundary instead of arriving as `undefined` in the search dropdown. + return NextResponse.json( + AppointmentSearchResultSchema.array().parse(results.slice(0, 20)), + ); } catch (error) { Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "stream" } }); console.error("Error searching appointments:", error); diff --git a/app/api/stream/search-consultees/route.ts b/app/api/stream/search-consultees/route.ts index 273ede57e..b25b09cdc 100644 --- a/app/api/stream/search-consultees/route.ts +++ b/app/api/stream/search-consultees/route.ts @@ -3,13 +3,11 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "lib/prisma"; import { getSession } from "@/lib/auth-server"; -export type ConsulteeSearchResult = { - id: string; - name: string | null; - email: string | null; - image: string | null; - relationshipType: "consultation" | "subscription" | "webinar" | "class"; -}; +// See schemas/stream-search.ts for why the shape does not live here. +import { + ConsulteeSearchResultSchema, + type ConsulteeSearchResult, +} from "@/schemas/stream-search"; /** * Search consultees of the current consultant @@ -254,9 +252,13 @@ export async function GET(req: NextRequest) { // Sort by name results.sort((a, b) => (a.name || "").localeCompare(b.name || "")); + // Validated against the same schema the dialog derives its type from, so + // a drift in this handler fails here rather than showing up as a blank row. return NextResponse.json({ success: true, - consultees: results.slice(0, 50), // Limit to 50 results + consultees: ConsulteeSearchResultSchema.array().parse( + results.slice(0, 50), + ), total: results.length, }); } catch (error) { diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx index 461a095bf..caf05681e 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx @@ -25,7 +25,7 @@ import type { } from "@/lib/appointments/map-consultant"; import type { TAppointment } from "@/types/appointment"; import type { UnscheduledClass, UnscheduledWebinar } from "../../types"; -import { useLazyJoinMeeting } from "../shared/hooks/useLazyJoinMeeting"; +import { useLazyJoinMeeting } from "@/hooks/scheduling/useLazyJoinMeeting"; import { buildUnscheduledClassAppointment, buildUnscheduledWebinarAppointment, @@ -37,8 +37,8 @@ import { } from "./utils/participantHelpers"; import { EventTimingsCalendar } from "./components/EventTimingsCalendar"; import { useConsultantEventActions } from "./components/useConsultantEventActions"; -import { CancelConfirmationDialog } from "@/app/dashboard/consultee/[consulteeId]/(features)/appointments/CancelConfirmationDialog"; -import { RescheduleSessionsModal } from "@/app/dashboard/consultee/[consulteeId]/(features)/appointments/components/RescheduleSessionsModal"; +import { CancelConfirmationDialog } from "@/components/appointments/consultee/CancelConfirmationDialog"; +import { RescheduleSessionsModal } from "@/components/appointments/consultee/RescheduleSessionsModal"; import { ConsultantResponseUpload } from "../documents/ConsultantResponseUpload"; import { AlertDialog, diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/components/EventTimingsCalendar.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/components/EventTimingsCalendar.tsx index 47c759026..882130f67 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/components/EventTimingsCalendar.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/components/EventTimingsCalendar.tsx @@ -9,7 +9,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useParams } from "next/navigation"; -import { SafeUnifiedCalendar } from "../../shared/components/SafeUnifiedCalendar"; +import { SafeUnifiedCalendar } from "@/components/scheduling/SafeUnifiedCalendar"; import type { UnscheduledAppointment } from "../utils/unscheduledAppointments"; import { getClassPlanDefaults, type ClassPlanType } from "@/utils/classPlans"; diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/participants/[eventType]/[eventId]/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/participants/[eventType]/[eventId]/page.tsx index 9aa4ca6b6..1ee760d78 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/participants/[eventType]/[eventId]/page.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/participants/[eventType]/[eventId]/page.tsx @@ -40,7 +40,7 @@ import { import { DashboardHeader } from "@/components/dashboard/PageScaffold"; import type { WaitlistParticipant } from "@/types/participants"; -import type { ClassEvent, WebinarEvent } from "../../../types/event"; +import type { ClassEvent, WebinarEvent } from "@/types/planner-events"; /** URL segment → API path segment and the noun used in the count line. */ const EVENT_KINDS = { diff --git a/app/dashboard/consultant/[consultantId]/(features)/documents/ConsultantResponseUpload.tsx b/app/dashboard/consultant/[consultantId]/(features)/documents/ConsultantResponseUpload.tsx index d15cf69b0..db2a02744 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/documents/ConsultantResponseUpload.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/documents/ConsultantResponseUpload.tsx @@ -15,7 +15,7 @@ import { } from "@/components/ui/dialog"; import { useToast } from "@/hooks/use-toast"; import { Upload, X, FileText, Loader2 } from "lucide-react"; -import { formatFileSize } from "@/app/dashboard/shared/utils/document-utils"; +import { formatFileSize } from "@/lib/documents/document-utils"; import { ConsultantDocumentService } from "../../(features)/planner/services/materials-service"; import { IDocument } from "../../types"; diff --git a/app/dashboard/consultant/[consultantId]/(features)/documents/DocumentsTab.tsx b/app/dashboard/consultant/[consultantId]/(features)/documents/DocumentsTab.tsx index 918b17793..f8d788618 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/documents/DocumentsTab.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/documents/DocumentsTab.tsx @@ -51,7 +51,7 @@ import { ConsultantResponseUpload } from "./ConsultantResponseUpload"; import { formatFileSize, getDocumentTypeIcon, -} from "@/app/dashboard/shared/utils/document-utils"; +} from "@/lib/documents/document-utils"; // Appointment types are fixed on the server (Consultation | Subscription). // Hardcoding here so the type filter dropdown isn't dependent on the current diff --git a/app/dashboard/consultant/[consultantId]/(features)/home/HomeTab.tsx b/app/dashboard/consultant/[consultantId]/(features)/home/HomeTab.tsx index 0b789e626..35ff8b7ce 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/home/HomeTab.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/home/HomeTab.tsx @@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button"; // lib/meeting (which imports the SDK) here — that would pull the heavy SDK into // the dashboard-HOME bundle / critical path. The video client + meeting helper // are acquired lazily inside the Join handler (only when a user clicks Join). -import { useLazyJoinMeeting } from "../shared/hooks/useLazyJoinMeeting"; +import { useLazyJoinMeeting } from "@/hooks/scheduling/useLazyJoinMeeting"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { motion } from "framer-motion"; @@ -60,7 +60,7 @@ import { getAppointmentLifecycleStatus } from "@/lib/appointments/map-consultant import { TAppointment } from "@/types/appointment"; import { getJoinableSlot } from "../../utils/joinState"; import { getInitials } from "@/utils/formatting"; -import { RequestSlotAllocationTabMini } from "../requests/RequestSlotAllocationTabMini"; +import { RequestSlotAllocationTabMini } from "@/components/dashboard/shared/requests/RequestSlotAllocationTabMini"; import { PerformanceSnapshot } from "./PerformanceSnapshot"; import { FinancialSummary } from "./FinancialSummary"; import type { diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventCard.tsx b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventCard.tsx index a2bb152be..81c846d98 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventCard.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventCard.tsx @@ -25,7 +25,7 @@ import { ClassEvent, ConsultationPlanEvent, SubscriptionPlanEvent, -} from "../types/event"; +} from "@/types/planner-events"; type EventType = "consultation" | "subscription" | "webinar" | "class"; diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventCarousel.tsx b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventCarousel.tsx index 4481d2edb..2c8bd71e5 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventCarousel.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventCarousel.tsx @@ -20,7 +20,7 @@ import { ConsultationPlanEvent, SubscriptionPlanEvent, Event, -} from "../types/event"; +} from "@/types/planner-events"; import { EventCard } from "./EventCard"; import { FormConfirmationDialog } from "./form-fields/FormConfirmationDialog"; diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventManagementDashboard.tsx b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventManagementDashboard.tsx index b12be90c0..1144cc3c5 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventManagementDashboard.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventManagementDashboard.tsx @@ -20,7 +20,7 @@ import { ConsultationPlanEvent, SubscriptionPlanEvent, Event, -} from "../types/event"; +} from "@/types/planner-events"; import { PlannerService } from "../services/planner"; import type { ConsultationPlan, SubscriptionPlan } from "@/schemas/plans"; import { useToast } from "@/hooks/use-toast"; diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlanner.tsx b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlanner.tsx index 1c82c13b4..961faada6 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlanner.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlanner.tsx @@ -7,7 +7,7 @@ import { ConsultationPlanEvent, SubscriptionPlanEvent, Event, -} from "../types/event"; +} from "@/types/planner-events"; import { EventPlannerForWebinar } from "./EventPlannerForWebinar"; import { EventPlannerForClass } from "./EventPlannerForClass"; import { EventPlannerForConsultation } from "./EventPlannerForConsultation"; diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForClass.tsx b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForClass.tsx index 792ac21f7..8bc3aaa6f 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForClass.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForClass.tsx @@ -57,7 +57,7 @@ import { SubmitButton } from "./form-fields/SubmitButton"; import { FormConfirmationDialog } from "./form-fields/FormConfirmationDialog"; import { TopicsMultiSelect } from "./TopicsMultiSelect"; import { PlannerService } from "../services/planner"; -import { ClassEvent, ClassPlannerProps } from "../types/event"; +import { ClassEvent, ClassPlannerProps } from "@/types/planner-events"; import { PlanMaterialsUpload } from "./PlanMaterialsUpload"; import { CollaboratorsTab } from "@/components/collaborators/CollaboratorsTab"; import { PlanImageUploader } from "@/components/plans/PlanImageUploader"; diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForConsultation.tsx b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForConsultation.tsx index 244c11c4d..a9b5fc1d8 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForConsultation.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForConsultation.tsx @@ -45,7 +45,7 @@ import { TopicsMultiSelect } from "./TopicsMultiSelect"; import { ConsultationPlanEvent, ConsultationPlannerProps, -} from "../types/event"; +} from "@/types/planner-events"; import { PlannerService } from "../services/planner"; import { PlanMaterialsUpload } from "./PlanMaterialsUpload"; diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForSubscription.tsx b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForSubscription.tsx index 6e8bb3723..5ded2249c 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForSubscription.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForSubscription.tsx @@ -59,7 +59,7 @@ import { TopicsMultiSelect } from "./TopicsMultiSelect"; import { SubscriptionPlanEvent, SubscriptionPlannerProps, -} from "../types/event"; +} from "@/types/planner-events"; import { PlannerService } from "../services/planner"; import { PlanMaterialsUpload } from "./PlanMaterialsUpload"; diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForWebinar.tsx b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForWebinar.tsx index a77551cda..3fedef51a 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForWebinar.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForWebinar.tsx @@ -47,7 +47,7 @@ import { SubmitButton } from "./form-fields/SubmitButton"; import { FormConfirmationDialog } from "./form-fields/FormConfirmationDialog"; import { TopicsMultiSelect } from "./TopicsMultiSelect"; import { PlannerService } from "../services/planner"; -import { WebinarEvent, WebinarPlannerProps } from "../types/event"; +import { WebinarEvent, WebinarPlannerProps } from "@/types/planner-events"; import { PlanMaterialsUpload } from "./PlanMaterialsUpload"; import { CollaboratorsTab } from "@/components/collaborators/CollaboratorsTab"; import { PlanImageUploader } from "@/components/plans/PlanImageUploader"; diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/components/PlanMaterialsUpload.tsx b/app/dashboard/consultant/[consultantId]/(features)/planner/components/PlanMaterialsUpload.tsx index d636b3e6b..9e33bd41a 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/components/PlanMaterialsUpload.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/components/PlanMaterialsUpload.tsx @@ -24,7 +24,7 @@ import { Trash2, Loader2, } from "lucide-react"; -import { formatFileSize } from "@/app/dashboard/shared/utils/document-utils"; +import { formatFileSize } from "@/lib/documents/document-utils"; import { MaterialsService, type PlanType } from "../services/materials-service"; import { IPlanMaterial } from "../../../types"; diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/services/events/class-service.ts b/app/dashboard/consultant/[consultantId]/(features)/planner/services/events/class-service.ts index 6d53001e2..55383eb16 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/services/events/class-service.ts +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/services/events/class-service.ts @@ -3,7 +3,7 @@ */ import { toast } from "@/hooks/use-toast"; -import { ClassEvent } from "../../types/event"; +import { ClassEvent } from "@/types/planner-events"; import { CreateClassPayload, UpdateClassPayload, diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/services/events/webinar-service.ts b/app/dashboard/consultant/[consultantId]/(features)/planner/services/events/webinar-service.ts index b5b95949b..d861d0ffa 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/services/events/webinar-service.ts +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/services/events/webinar-service.ts @@ -3,7 +3,7 @@ */ import { toast } from "@/hooks/use-toast"; -import { WebinarEvent } from "../../types/event"; +import { WebinarEvent } from "@/types/planner-events"; import { CreateWebinarPayload, UpdateWebinarPayload, diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/services/planner.ts b/app/dashboard/consultant/[consultantId]/(features)/planner/services/planner.ts index c6bba7a9b..c5bd653f2 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/services/planner.ts +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/services/planner.ts @@ -23,7 +23,7 @@ import { ClassContentInput, WebinarFormInput, ClassFormInput, -} from "../types/event"; +} from "@/types/planner-events"; import { WebinarService } from "./events/webinar-service"; import { ClassService } from "./events/class-service"; import { ConsultationService } from "./plans/consultation-service"; diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/services/plans/consultation-service.ts b/app/dashboard/consultant/[consultantId]/(features)/planner/services/plans/consultation-service.ts index 727513626..e4886f3c8 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/services/plans/consultation-service.ts +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/services/plans/consultation-service.ts @@ -3,7 +3,7 @@ */ import type { ConsultationPlan } from "@/schemas/plans"; -import { ConsultationPlanEvent } from "../../types/event"; +import { ConsultationPlanEvent } from "@/types/planner-events"; export class ConsultationService { /** diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/services/plans/subscription-service.ts b/app/dashboard/consultant/[consultantId]/(features)/planner/services/plans/subscription-service.ts index 32fbb74bd..727ce2d77 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/planner/services/plans/subscription-service.ts +++ b/app/dashboard/consultant/[consultantId]/(features)/planner/services/plans/subscription-service.ts @@ -3,7 +3,7 @@ */ import type { SubscriptionPlan } from "@/schemas/plans"; -import { SubscriptionPlanEvent } from "../../types/event"; +import { SubscriptionPlanEvent } from "@/types/planner-events"; export class SubscriptionService { /** diff --git a/app/dashboard/consultant/[consultantId]/(features)/recordings/components/RecordingCard.tsx b/app/dashboard/consultant/[consultantId]/(features)/recordings/components/RecordingCard.tsx index 38e834d2d..6b8791a75 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/recordings/components/RecordingCard.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/recordings/components/RecordingCard.tsx @@ -1,5 +1,7 @@ "use client"; +import type { RecordingData } from "@/types/recording"; + import { useState } from "react"; import Image from "next/image"; import { formatDistanceToNow, format } from "date-fns"; @@ -26,27 +28,6 @@ import { import { useToast } from "@/hooks/use-toast"; import { cn } from "@/utils/tailwind"; -export interface RecordingData { - id: string; - title: string; - durationInMinutes: number; - recordedAt: string; - status: string; - storageType: string; - playbackUrl: string | null; - thumbnailUrl: string | null; - resolution: string | null; - fileSize: number | null; - streamUrlExpiresAt: string | null; - transferredAt: string | null; - planType: "webinar" | "class" | null; - planId: string | null; - planTitle: string | null; - participantNames: string[]; - participantCount: number; - appointmentDate: string | null; - createdAt: string; -} function formatFileSize(bytes: number): string { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; diff --git a/app/dashboard/consultant/[consultantId]/(features)/requests/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/requests/page.tsx index 820f9cb20..db81f58f7 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/requests/page.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/requests/page.tsx @@ -2,7 +2,7 @@ import { DashboardErrorBoundary } from "@/components/DashboardErrorBoundary"; import { DashboardHeader } from "@/components/dashboard/PageScaffold"; -import { RequestSlotAllocationTab } from "./RequestSlotAllocationTab"; +import { RequestSlotAllocationTab } from "@/components/dashboard/shared/requests/RequestSlotAllocationTab"; /** * Requests tab page. RequestSlotAllocationTab owns its data: it resolves the diff --git a/app/dashboard/consultant/[consultantId]/(features)/trials/TrialsTab.tsx b/app/dashboard/consultant/[consultantId]/(features)/trials/TrialsTab.tsx index 79b7b0414..d960edf17 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/trials/TrialsTab.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/trials/TrialsTab.tsx @@ -49,7 +49,7 @@ import { cn } from "@/utils/tailwind"; // #248: no static Stream SDK / lib/meeting import — the shared hook // lazy-loads both at click time. Type-only imports are erased. import type { MeetingSlot } from "@/lib/meeting"; -import { useLazyJoinMeeting } from "../shared/hooks/useLazyJoinMeeting"; +import { useLazyJoinMeeting } from "@/hooks/scheduling/useLazyJoinMeeting"; import { TrialScheduleCalendar, SelectedSlot, diff --git a/app/dashboard/consultee/[consulteeId]/(features)/appointments/AppointmentsPageClient.tsx b/app/dashboard/consultee/[consulteeId]/(features)/appointments/AppointmentsPageClient.tsx index a82b4c155..63ff6c15d 100644 --- a/app/dashboard/consultee/[consulteeId]/(features)/appointments/AppointmentsPageClient.tsx +++ b/app/dashboard/consultee/[consulteeId]/(features)/appointments/AppointmentsPageClient.tsx @@ -11,7 +11,7 @@ import { AppointmentsShell } from "@/components/appointments/AppointmentsShell"; import { AppointmentsPageSkeleton } from "@/components/appointments/skeletons"; import { mapConsulteeEvents } from "@/lib/appointments/map-consultee"; import { createConsulteeQueries } from "@/lib/dashboard-queries"; -import { useConsulteeAppointmentsAdapter } from "./ConsulteeAppointmentsAdapter"; +import { useConsulteeAppointmentsAdapter } from "@/components/appointments/consultee/ConsulteeAppointmentsAdapter"; export default function AppointmentsPageClient({ consulteeId, diff --git a/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/DetailPageClient.tsx b/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/DetailPageClient.tsx index 94cbf37fc..ed563e6ee 100644 --- a/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/DetailPageClient.tsx +++ b/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/DetailPageClient.tsx @@ -3,8 +3,8 @@ import { AppointmentDetailClient } from "@/components/appointments/detail/AppointmentDetailClient"; import { CONSULTEE_JOIN_WINDOW_MS } from "@/lib/appointments/slots"; import { isConfirmedStatus } from "@/lib/appointments/status"; -import { useConsulteeAppointmentsAdapter } from "../ConsulteeAppointmentsAdapter"; -import { DocumentUpload } from "../DocumentUpload"; +import { useConsulteeAppointmentsAdapter } from "@/components/appointments/consultee/ConsulteeAppointmentsAdapter"; +import { DocumentUpload } from "@/components/appointments/DocumentUpload"; const DOCUMENT_KINDS = new Set(["CONSULTATION", "TRIAL", "SUBSCRIPTION"]); diff --git a/app/dashboard/organization/[orgId]/appointments/AppointmentsPageClient.tsx b/app/dashboard/organization/[orgId]/appointments/AppointmentsPageClient.tsx index 1a77e58e0..d45e0efa4 100644 --- a/app/dashboard/organization/[orgId]/appointments/AppointmentsPageClient.tsx +++ b/app/dashboard/organization/[orgId]/appointments/AppointmentsPageClient.tsx @@ -76,6 +76,16 @@ interface AppointmentRow { user: { id: string; name: string | null; email: string }; }; } | null; + /** + * Seats in this session that THIS org funded, pre-filtered server-side to the + * viewing org. Present on the "Everyone" scope only; a group event shares one + * appointment across every registrant, so this is how a sponsor learns which + * of its own people were in it without learning who else was. + */ + payment?: { + id: string; + user: { id: string; name: string | null; email: string } | null; + }[]; } interface AppointmentsResponse { @@ -130,7 +140,20 @@ function getMember(row: AppointmentRow): string { row.consultation?.requestedBy?.user ?? row.subscription?.requestedBy?.user ?? row.trialSession?.consulteeProfile?.user; - return u ? u.name || u.email : "—"; + if (u) return u.name || u.email; + + // Group events share one appointment across every registrant, so there is no + // single "member" — but `payment` is pre-filtered server-side to THIS org's + // funded seats, so these are our people and only ours. Without this the row + // read "—" and a sponsor could see that it had paid for a webinar without + // being told whom it had paid for. + const funded = row.payment ?? []; + const names = funded + .map((p) => p.user?.name || p.user?.email) + .filter((n): n is string => Boolean(n)); + if (names.length === 0) return "—"; + if (names.length === 1) return names[0]; + return `${names[0]} +${names.length - 1}`; } /** Earliest slot that hasn't ended yet, else the latest. Mirrors the member view. */ diff --git a/app/dashboard/organization/[orgId]/appointments/MyAppointmentsClient.tsx b/app/dashboard/organization/[orgId]/appointments/MyAppointmentsClient.tsx index c268e0dd5..ccbe27e13 100644 --- a/app/dashboard/organization/[orgId]/appointments/MyAppointmentsClient.tsx +++ b/app/dashboard/organization/[orgId]/appointments/MyAppointmentsClient.tsx @@ -28,7 +28,7 @@ import { getJoinableSlot, } from "@/lib/appointments/slots"; import type { MeetingAppointment } from "@/lib/meeting"; -import { useLazyJoinMeeting } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useLazyJoinMeeting"; +import { useLazyJoinMeeting } from "@/hooks/scheduling/useLazyJoinMeeting"; // Slot shape as delivered by getOrgMemberAppointments (see the include in // lib/api/scope/list-appointments.ts). Dates survive the RSC boundary as @@ -271,7 +271,16 @@ export function MyAppointmentsClient({

-
+
+ {/* The card used to offer Join and nothing else — it named your + counterpart and gave you no way to reach them, reschedule, + cancel or hand over a document for a session the org paid + for. Details carries all of that. */} + {joinable ? (