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. */}
+
+
+ Details
+
+
{joinable ? (
) {
+ const base = useConsulteeAppointmentsAdapter();
+
+ const adapter = useMemo(
+ () => ({
+ ...base,
+ detailHref: () =>
+ `/dashboard/organization/${orgId}/appointments/${appointmentId}`,
+ }),
+ [base, orgId, appointmentId],
+ );
+
+ return (
+
+ DOCUMENT_KINDS.has(vm.kind) && isConfirmedStatus(vm.status) ? (
+
+ ) : (
+
+ Documents can be shared once the booking is confirmed.
+
+ )
+ }
+ />
+ );
+}
diff --git a/app/dashboard/organization/[orgId]/appointments/[appointmentId]/page.tsx b/app/dashboard/organization/[orgId]/appointments/[appointmentId]/page.tsx
new file mode 100644
index 000000000..27a61486f
--- /dev/null
+++ b/app/dashboard/organization/[orgId]/appointments/[appointmentId]/page.tsx
@@ -0,0 +1,71 @@
+import { notFound } from "next/navigation";
+
+import prisma from "@/lib/prisma";
+import { requireOrgAccess } from "@/lib/auth-helpers";
+import { readAppointmentDetail } from "@/lib/data/appointment-detail";
+
+import DetailPageClient from "./DetailPageClient";
+
+/**
+ * One org-funded session, for the member who is on it.
+ *
+ * Two checks, and they are different questions:
+ *
+ * 1. `requireOrgAccess` — is the caller a member of this org at all.
+ * 2. Below — is this appointment ACTUALLY this org's, and is the caller a
+ * party to it.
+ *
+ * The second matters because the org id and the appointment id both come from
+ * the URL and neither constrains the other. Without it, any member of any org
+ * could read any appointment by pairing their own org id with someone else's
+ * appointment id — 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 is required rather than `operations.read`: this page renders
+ * documents and offers reschedule and cancel, which are participant actions.
+ * An operator's view of org sessions stays the metadata-only list, per ADR 20.
+ */
+export default async function OrgAppointmentDetailPage({
+ params,
+}: {
+ params: Promise<{ orgId: string; appointmentId: string }>;
+}) {
+ const { orgId, appointmentId } = await params;
+
+ const access = await requireOrgAccess(orgId);
+ if (access.error) {
+ notFound();
+ }
+
+ const userId = access.session.user.id;
+
+ const [detail, profile] = await Promise.all([
+ readAppointmentDetail(appointmentId),
+ prisma.consulteeProfile.findUnique({
+ where: { userId },
+ select: { id: true },
+ }),
+ ]);
+ if (!detail || !profile) notFound();
+
+ const { appointment } = detail;
+
+ // Belongs to THIS org — not merely to some org.
+ if (appointment.organizationId !== orgId) notFound();
+
+ // And the caller is on it. Mirrors the consultee detail page's participation
+ // test: requester, trial consultee, or a user attached to one of the slots.
+ const owns =
+ appointment.consultation?.requestedBy?.id === profile.id ||
+ appointment.subscription?.requestedBy?.id === profile.id ||
+ appointment.trialSession?.consulteeProfile?.id === profile.id ||
+ appointment.slotsOfAppointment.some((slot) =>
+ slot.user.some((u) => u.id === userId),
+ );
+ if (!owns) notFound();
+
+ return (
+
+ );
+}
diff --git a/app/dashboard/organization/[orgId]/layout.tsx b/app/dashboard/organization/[orgId]/layout.tsx
index 642c33819..32d65d28c 100644
--- a/app/dashboard/organization/[orgId]/layout.tsx
+++ b/app/dashboard/organization/[orgId]/layout.tsx
@@ -21,6 +21,8 @@ import {
Clock,
FileText,
CalendarCheck,
+ MessageSquare,
+ ClipboardCheck,
Video,
Receipt,
ShieldCheck,
@@ -237,6 +239,35 @@ export default function OrgLayout({
icon: CalendarCheck,
path: "appointments",
},
+ {
+ // Participant surface, same floor as Appointments. Chat is scoped to
+ // this org purely by living on this route — `useOrgScope` pins under
+ // /dashboard/organization/[orgId]/ — so a member of several orgs gets
+ // one clean inbox per org with no picker.
+ //
+ // Not an operator surface: Stream only returns channels the viewer is a
+ // member of, and there is no org-wide chat query behind it. ADR 20
+ // keeps session content with the participants.
+ name: "Messages",
+ icon: MessageSquare,
+ path: "messages",
+ },
+ {
+ // Delivery surface: allocating slots is something only the person
+ // delivering the session can do, so it shows for members who hold a
+ // consultant profile. The page itself redirects anyone else — gating on
+ // the profile rather than on MemberRole.EXPERT means an OWNER who also
+ // delivers still gets it.
+ 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,
+ },
];
// People — governance + roster surfaces (BILLING_ADMIN is
@@ -544,6 +575,8 @@ export default function OrgLayout({
compensation: "Compensation",
collaborations: "Collaborations",
appointments: "Appointments",
+ messages: "Messages",
+ requests: "Requests",
members: "Members",
programs: "Programs",
contracts: "Contracts",
diff --git a/app/dashboard/organization/[orgId]/messages/MessagesClient.tsx b/app/dashboard/organization/[orgId]/messages/MessagesClient.tsx
new file mode 100644
index 000000000..6d377fb2d
--- /dev/null
+++ b/app/dashboard/organization/[orgId]/messages/MessagesClient.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { Loader2 } from "lucide-react";
+
+import { ChatLayout } from "@/components/chat/ChatLayout";
+import { ChatUnavailable } from "@/components/chat/ChatUnavailable";
+import { useStreamConnection } from "@/providers/StreamProvider";
+
+/**
+ * Org-scoped chat.
+ *
+ * Nothing here filters by organization, and that is the point: `useOrgScope`
+ * inside `ChatSidebar` is ROUTE-PINNED under `/dashboard/organization/[orgId]/`,
+ * so simply living at this path scopes the channel query to
+ * `organization_id: { $eq: orgId }`. A member of several orgs gets one of these
+ * per org, each showing only that org's threads, with no picker to set and
+ * nothing to get wrong.
+ *
+ * What the viewer sees is still only their own conversations — Stream returns
+ * channels they are a member of. This is a participant surface that happens to
+ * live in the org tree, not an operator one. Operators cannot read it: there is
+ * no org-wide chat query behind this page, and ADR 20 keeps session content with
+ * the participants.
+ */
+export function MessagesClient() {
+ const { chatConnected, error, retryConnection } = useStreamConnection();
+
+ if (error) {
+ return ;
+ }
+
+ if (!chatConnected) {
+ return (
+
+
+ Connecting to chat…
+
+ );
+ }
+
+ return ;
+}
diff --git a/app/dashboard/organization/[orgId]/messages/page.tsx b/app/dashboard/organization/[orgId]/messages/page.tsx
new file mode 100644
index 000000000..e75b5223b
--- /dev/null
+++ b/app/dashboard/organization/[orgId]/messages/page.tsx
@@ -0,0 +1,57 @@
+import { notFound } from "next/navigation";
+
+import { requireOrgAccess } from "@/lib/auth-helpers";
+import { DashboardHeader } from "@/components/dashboard/PageScaffold";
+import StreamProvider from "@/providers/StreamProvider";
+
+import { MessagesClient } from "./MessagesClient";
+
+/**
+ * Messages, scoped to this organization.
+ *
+ * ADR 19 splits dashboards by the org-ness of the underlying work, and chat was
+ * the surface that never got split: it existed only in the personal trees, so a
+ * member's org conversations either vanished or leaked into their B2C inbox
+ * depending on which default the scope hook happened to resolve. This is the
+ * org half.
+ *
+ * Access floors at active membership, exactly like Appointments — a LEARNER has
+ * to be able to reach their own conversations. There is deliberately NO
+ * `operations.read` variant of this page: an operator has no business reading
+ * member conversations, and ADR 20 says so. Stream only ever returns channels
+ * the viewer is a member of, so the floor is also the ceiling here.
+ *
+ * `enableChat` is true only on this route rather than on the org layout, so the
+ * rest of the org tree keeps the video-only client it already had and no other
+ * org page opens a chat websocket.
+ */
+export default async function OrgMessagesPage({
+ params,
+}: {
+ params: Promise<{ orgId: string }>;
+}) {
+ const { orgId } = await params;
+
+ const access = await requireOrgAccess(orgId);
+ if (access.error) {
+ notFound();
+ }
+
+ const userId = access.session.user.id;
+
+ return (
+ <>
+
+ {/* Full-bleed: cancel the scaffold padding so the chat fills the column
+ under the context bar, matching the personal Messages tabs. */}
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/dashboard/organization/[orgId]/requests/RequestsClient.tsx b/app/dashboard/organization/[orgId]/requests/RequestsClient.tsx
new file mode 100644
index 000000000..88b613c00
--- /dev/null
+++ b/app/dashboard/organization/[orgId]/requests/RequestsClient.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { useState } from "react";
+
+import { RequestSlotAllocationTab } from "@/components/dashboard/shared/requests/RequestSlotAllocationTab";
+import { DashboardErrorBoundary } from "@/components/DashboardErrorBoundary";
+
+/**
+ * Slot allocation for this organization's bookings.
+ *
+ * The same component the consultant tree mounts, pointed at one org. It reads
+ * `?orgScope=`, which is the whole fix: the bookings endpoints exclude
+ * org-funded rows when that param is absent, and the only allocation surface in
+ * the product used to sit in the consultant tree without it. An org-sponsored
+ * subscription was therefore paid for and then never scheduled, because the
+ * request to allocate its slots appeared nowhere anyone could act on it.
+ *
+ * `consultantProfileId` is passed explicitly because this route has no
+ * `[consultantId]` param for the component's usual `useParams` fallback.
+ */
+export function RequestsClient({
+ orgId,
+ consultantProfileId,
+}: Readonly<{ orgId: string; consultantProfileId: string }>) {
+ // The component asks its parent to refresh; it owns its own data, so this is
+ // the same no-op the consultant page passes.
+ const [, setRefreshToken] = useState(0);
+
+ return (
+
+ setRefreshToken((n) => n + 1)}
+ consultantProfileId={consultantProfileId}
+ orgScope={orgId}
+ />
+
+ );
+}
diff --git a/app/dashboard/organization/[orgId]/requests/page.tsx b/app/dashboard/organization/[orgId]/requests/page.tsx
new file mode 100644
index 000000000..3bc004f9d
--- /dev/null
+++ b/app/dashboard/organization/[orgId]/requests/page.tsx
@@ -0,0 +1,58 @@
+import { notFound, redirect } from "next/navigation";
+
+import { requireOrgAccess } from "@/lib/auth-helpers";
+import { DashboardHeader, DashboardContent } from "@/components/dashboard/PageScaffold";
+
+import { RequestsClient } from "./RequestsClient";
+
+/**
+ * Requests — slot allocation for sessions this organization funded or hosts.
+ *
+ * This page exists because its absence had a cost. Allocation lived only in the
+ * consultant tree, which fetches the bookings endpoints without an `orgScope`,
+ * and those endpoints drop org-funded rows when the param is missing. So an
+ * org-sponsored subscription could be paid for and never scheduled: the request
+ * existed and no surface in the product would show it.
+ *
+ * Gated on the member holding a consultant profile rather than on a permission
+ * key. Allocation is a delivery act — only the person who delivers the session
+ * can choose its slots — so this is an EXPERT-shaped surface even though
+ * `MemberRole.EXPERT` is not itself the gate: an OWNER who also delivers has a
+ * consultant profile and belongs here, while an OWNER who does not deliver has
+ * nothing to allocate and is sent back to the org home rather than shown an
+ * empty page they cannot act on.
+ */
+export default async function OrgRequestsPage({
+ params,
+}: {
+ params: Promise<{ orgId: string }>;
+}) {
+ const { orgId } = await params;
+
+ const access = await requireOrgAccess(orgId);
+ if (access.error) {
+ notFound();
+ }
+
+ // `Membership.consultantProfileId` is set when the member joined as an
+ // EXPERT; the global profile is what the bookings endpoints key on.
+ const consultantProfileId = access.member.consultantProfileId;
+ if (!consultantProfileId) {
+ redirect(`/dashboard/organization/${orgId}/home`);
+ }
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/app/explore/programs/ProgramsInteractiveContent.tsx b/app/explore/programs/ProgramsInteractiveContent.tsx
index 060c3198a..d436a3f43 100644
--- a/app/explore/programs/ProgramsInteractiveContent.tsx
+++ b/app/explore/programs/ProgramsInteractiveContent.tsx
@@ -10,7 +10,7 @@ import {
getUniqueLevels,
type Program,
type TopicWithCount,
-} from "./utils";
+} from "@/lib/explore/programs";
import {
useCuratedPrograms,
useInfiniteScroll,
diff --git a/app/explore/programs/components/AdvancedFilters.tsx b/app/explore/programs/components/AdvancedFilters.tsx
index 9e1e24c84..356d65509 100644
--- a/app/explore/programs/components/AdvancedFilters.tsx
+++ b/app/explore/programs/components/AdvancedFilters.tsx
@@ -11,7 +11,7 @@ import {
} from "@/components/ui/select";
import { Search, LayoutGrid, List, SlidersHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
-import { TopicWithCount, ProgramFilters } from "../utils";
+import { TopicWithCount, ProgramFilters } from "@/lib/explore/programs";
import { memo, useEffect, useRef, useState } from "react";
interface AdvancedFiltersProps {
diff --git a/app/explore/programs/components/CategoryGrid.tsx b/app/explore/programs/components/CategoryGrid.tsx
index 95204ee7f..251cdba3e 100644
--- a/app/explore/programs/components/CategoryGrid.tsx
+++ b/app/explore/programs/components/CategoryGrid.tsx
@@ -2,7 +2,7 @@
import { memo, useState } from "react";
import { Hash, ChevronDown, ChevronUp } from "lucide-react";
-import { TopicWithCount } from "../utils";
+import { TopicWithCount } from "@/lib/explore/programs";
interface CategoryGridProps {
topics: TopicWithCount[];
diff --git a/app/explore/programs/components/FeaturedCarousel.tsx b/app/explore/programs/components/FeaturedCarousel.tsx
index 62cc316d4..d75e72775 100644
--- a/app/explore/programs/components/FeaturedCarousel.tsx
+++ b/app/explore/programs/components/FeaturedCarousel.tsx
@@ -7,7 +7,7 @@ import Image from "next/image";
import { useRouter } from "next/navigation";
import { CompanyLogo } from "@/components/ui/company-logo";
import { useCurrency } from "@/hooks/useCurrency";
-import { isClassProgram, Program } from "../utils";
+import { isClassProgram, Program } from "@/lib/explore/programs";
interface FeaturedCarouselProps {
programs: Program[];
diff --git a/app/explore/programs/components/ProgramCard.tsx b/app/explore/programs/components/ProgramCard.tsx
index addef32f6..6962057ec 100644
--- a/app/explore/programs/components/ProgramCard.tsx
+++ b/app/explore/programs/components/ProgramCard.tsx
@@ -8,7 +8,7 @@ import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCurrency } from "@/hooks/useCurrency";
import { CompanyLogo } from "@/components/ui/company-logo";
-import { isClassProgram, Program } from "../utils";
+import { isClassProgram, Program } from "@/lib/explore/programs";
type ProgramCardVariant = "grid" | "list" | "carousel";
export type ProgramBadge = "featured" | "trending" | "new";
diff --git a/app/explore/programs/components/ProgramResults.tsx b/app/explore/programs/components/ProgramResults.tsx
index 3ff22f4a4..876770737 100644
--- a/app/explore/programs/components/ProgramResults.tsx
+++ b/app/explore/programs/components/ProgramResults.tsx
@@ -3,7 +3,7 @@
import { memo, type RefObject } from "react";
import { motion } from "framer-motion";
import { Search } from "lucide-react";
-import type { Program } from "../utils";
+import type { Program } from "@/lib/explore/programs";
import ProgramCard from "./ProgramCard";
interface ProgramResultsProps {
diff --git a/app/explore/programs/components/ProgramRow.tsx b/app/explore/programs/components/ProgramRow.tsx
index 87e3c7dc7..a07648c73 100644
--- a/app/explore/programs/components/ProgramRow.tsx
+++ b/app/explore/programs/components/ProgramRow.tsx
@@ -2,7 +2,7 @@
import { memo, useRef } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
-import { Program } from "../utils";
+import { Program } from "@/lib/explore/programs";
import ProgramCard, { ProgramBadge } from "./ProgramCard";
interface ProgramRowProps {
diff --git a/app/explore/programs/components/ProgramTabs.tsx b/app/explore/programs/components/ProgramTabs.tsx
index 208cca7e0..62d3c296b 100644
--- a/app/explore/programs/components/ProgramTabs.tsx
+++ b/app/explore/programs/components/ProgramTabs.tsx
@@ -2,7 +2,7 @@
import { memo } from "react";
import { GraduationCap, Layers, Video } from "lucide-react";
-import { ProgramType } from "../utils";
+import { ProgramType } from "@/lib/explore/programs";
interface ProgramTabsProps {
activeTab: ProgramType;
diff --git a/app/explore/programs/components/StaticTopRows.tsx b/app/explore/programs/components/StaticTopRows.tsx
index aacce8207..205a865b3 100644
--- a/app/explore/programs/components/StaticTopRows.tsx
+++ b/app/explore/programs/components/StaticTopRows.tsx
@@ -2,7 +2,7 @@
import { memo } from "react";
import { Sparkles, Flame, Clock, Hash } from "lucide-react";
-import type { Program, TopicWithCount } from "../utils";
+import type { Program, TopicWithCount } from "@/lib/explore/programs";
import SectionHeader from "./SectionHeader";
import FeaturedCarousel from "./FeaturedCarousel";
import ProgramRow from "./ProgramRow";
diff --git a/app/explore/programs/hooks/_helpers.ts b/app/explore/programs/hooks/_helpers.ts
index b0981e174..b768ce974 100644
--- a/app/explore/programs/hooks/_helpers.ts
+++ b/app/explore/programs/hooks/_helpers.ts
@@ -7,7 +7,7 @@ import type {
ClassInstance,
ProgramFilters,
TopicWithCount,
-} from "../utils";
+} from "@/lib/explore/programs";
interface WebinarWithAppointment {
appointment?: {
diff --git a/app/explore/programs/hooks/useCuratedPrograms.ts b/app/explore/programs/hooks/useCuratedPrograms.ts
index 150580336..66848c5e8 100644
--- a/app/explore/programs/hooks/useCuratedPrograms.ts
+++ b/app/explore/programs/hooks/useCuratedPrograms.ts
@@ -7,7 +7,7 @@ import {
type ProgramType,
type ClassPlanProgram,
type WebinarPlanProgram,
-} from "../utils";
+} from "@/lib/explore/programs";
import {
fetchPlans,
type ClassPlanApiItem,
diff --git a/app/explore/programs/hooks/useProgramFilterChips.ts b/app/explore/programs/hooks/useProgramFilterChips.ts
index 77fc94b41..08c1dfd38 100644
--- a/app/explore/programs/hooks/useProgramFilterChips.ts
+++ b/app/explore/programs/hooks/useProgramFilterChips.ts
@@ -5,7 +5,7 @@ import type { ActiveFilter } from "../components/FilterChips";
import type {
ProgramFilters,
TopicWithCount,
-} from "../utils";
+} from "@/lib/explore/programs";
/**
* Structured chip key. Replaces the old `topic-${id}` string encoding so
diff --git a/app/explore/programs/hooks/usePrograms.ts b/app/explore/programs/hooks/usePrograms.ts
index c35bb19d9..52233c0b0 100644
--- a/app/explore/programs/hooks/usePrograms.ts
+++ b/app/explore/programs/hooks/usePrograms.ts
@@ -14,7 +14,7 @@ import {
type ProgramFilters,
type ClassPlanProgram,
type WebinarPlanProgram,
-} from "../utils";
+} from "@/lib/explore/programs";
import {
buildFilterParams,
fetchPlans,
diff --git a/app/explore/programs/hooks/useProgramsFilters.ts b/app/explore/programs/hooks/useProgramsFilters.ts
index c44c10505..eefe5a3b1 100644
--- a/app/explore/programs/hooks/useProgramsFilters.ts
+++ b/app/explore/programs/hooks/useProgramsFilters.ts
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useDebouncedCallback } from "use-debounce";
-import type { ProgramFilters, ProgramType } from "../utils";
+import type { ProgramFilters, ProgramType } from "@/lib/explore/programs";
const SEARCH_DEBOUNCE_MS = 300;
diff --git a/app/explore/programs/hooks/useTopicsWithCount.ts b/app/explore/programs/hooks/useTopicsWithCount.ts
index 4c134bb4b..1125d5a40 100644
--- a/app/explore/programs/hooks/useTopicsWithCount.ts
+++ b/app/explore/programs/hooks/useTopicsWithCount.ts
@@ -1,7 +1,7 @@
"use client";
import { useQuery } from "@tanstack/react-query";
-import type { ProgramType, TopicWithCount } from "../utils";
+import type { ProgramType, TopicWithCount } from "@/lib/explore/programs";
import { fetchTopics } from "./_helpers";
/**
diff --git a/app/explore/programs/plans/classes/[classPlanId]/components/ClassDetails.tsx b/app/explore/programs/plans/classes/[classPlanId]/components/ClassDetails.tsx
index e648b5d43..7148f11af 100644
--- a/app/explore/programs/plans/classes/[classPlanId]/components/ClassDetails.tsx
+++ b/app/explore/programs/plans/classes/[classPlanId]/components/ClassDetails.tsx
@@ -26,7 +26,7 @@ import {
import { ClientClassRegistration } from "./ClientClassRegistration";
import { useCurrency } from "@/hooks/useCurrency";
import type { Topic } from "@prisma/client";
-import { generateProgramImageUrl } from "@/app/explore/programs/utils";
+import { generateProgramImageUrl } from "@/lib/explore/programs";
import { FeatureItem } from "@/app/explore/programs/plans/components/FeatureItem";
import type { TClassPlanDetailsData } from "../types";
diff --git a/app/explore/programs/plans/classes/[classPlanId]/components/ClientClassRegistration.tsx b/app/explore/programs/plans/classes/[classPlanId]/components/ClientClassRegistration.tsx
index 4b1bb80d7..87e500f34 100644
--- a/app/explore/programs/plans/classes/[classPlanId]/components/ClientClassRegistration.tsx
+++ b/app/explore/programs/plans/classes/[classPlanId]/components/ClientClassRegistration.tsx
@@ -12,7 +12,7 @@ import {
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { CheckCircle } from "lucide-react";
-import { ClassPlanProgram } from "@/app/explore/programs/utils";
+import { ClassPlanProgram } from "@/lib/explore/programs";
import {
isUserEnrolled,
countUniqueParticipants,
diff --git a/app/explore/programs/plans/classes/[classPlanId]/page.tsx b/app/explore/programs/plans/classes/[classPlanId]/page.tsx
index fae254e85..9bc96c8ad 100644
--- a/app/explore/programs/plans/classes/[classPlanId]/page.tsx
+++ b/app/explore/programs/plans/classes/[classPlanId]/page.tsx
@@ -1,7 +1,7 @@
import { notFound } from "next/navigation";
import { getClassPlanDetail } from "@/lib/data/plan-details";
import { ClassDetails } from "./components/ClassDetails";
-import { generateProgramImageUrl } from "@/app/explore/programs/utils";
+import { generateProgramImageUrl } from "@/lib/explore/programs";
// Stream behind the static layout's instant skeleton; don't prerender at build (#932).
export const dynamic = "force-dynamic";
diff --git a/app/explore/programs/plans/webinars/[webinarPlanId]/components/WebinarDetails.tsx b/app/explore/programs/plans/webinars/[webinarPlanId]/components/WebinarDetails.tsx
index e35bd73d4..44020f189 100644
--- a/app/explore/programs/plans/webinars/[webinarPlanId]/components/WebinarDetails.tsx
+++ b/app/explore/programs/plans/webinars/[webinarPlanId]/components/WebinarDetails.tsx
@@ -18,7 +18,7 @@ import {
} from "lucide-react";
import { formatInTimeZone } from "date-fns-tz";
import { ClientWebinarRegistration } from "./ClientWebinarRegistration";
-import { generateProgramImageUrl } from "../../../../utils";
+import { generateProgramImageUrl } from "@/lib/explore/programs";
import { useCurrency } from "@/hooks/useCurrency";
import type { Topic } from "@prisma/client";
import { FeatureItem } from "@/app/explore/programs/plans/components/FeatureItem";
diff --git a/app/dashboard/consultee/[consulteeId]/(features)/appointments/DocumentUpload.tsx b/components/appointments/DocumentUpload.tsx
similarity index 99%
rename from app/dashboard/consultee/[consulteeId]/(features)/appointments/DocumentUpload.tsx
rename to components/appointments/DocumentUpload.tsx
index 02e630ff7..c3a96f357 100644
--- a/app/dashboard/consultee/[consulteeId]/(features)/appointments/DocumentUpload.tsx
+++ b/components/appointments/DocumentUpload.tsx
@@ -41,7 +41,7 @@ import {
formatFileSize,
getStatusColor,
getStatusIcon,
-} from "@/app/dashboard/shared/utils/document-utils";
+} from "@/lib/documents/document-utils";
interface DocumentUploadProps {
appointmentId: string;
diff --git a/app/dashboard/consultee/[consulteeId]/(features)/appointments/CancelConfirmationDialog.tsx b/components/appointments/consultee/CancelConfirmationDialog.tsx
similarity index 100%
rename from app/dashboard/consultee/[consulteeId]/(features)/appointments/CancelConfirmationDialog.tsx
rename to components/appointments/consultee/CancelConfirmationDialog.tsx
diff --git a/app/dashboard/consultee/[consulteeId]/(features)/appointments/ConsulteeAppointmentsAdapter.tsx b/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx
similarity index 96%
rename from app/dashboard/consultee/[consulteeId]/(features)/appointments/ConsulteeAppointmentsAdapter.tsx
rename to components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx
index 0212f4353..b46e7abbc 100644
--- a/app/dashboard/consultee/[consulteeId]/(features)/appointments/ConsulteeAppointmentsAdapter.tsx
+++ b/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx
@@ -37,11 +37,11 @@ import type {
AppointmentVM,
SlotLike,
} from "@/lib/appointments/view-model";
-import { useEventActions } from "./components/useEventActions";
-import { RescheduleSessionsModal } from "./components/RescheduleSessionsModal";
-import { CancelConfirmationDialog } from "./CancelConfirmationDialog";
-import { ReportIssueDialog } from "./ReportIssueDialog";
-import { DocumentUpload } from "./DocumentUpload";
+import { useEventActions } from "@/components/appointments/consultee/useEventActions";
+import { RescheduleSessionsModal } from "@/components/appointments/consultee/RescheduleSessionsModal";
+import { CancelConfirmationDialog } from "@/components/appointments/consultee/CancelConfirmationDialog";
+import { ReportIssueDialog } from "@/components/appointments/consultee/ReportIssueDialog";
+import { DocumentUpload } from "@/components/appointments/DocumentUpload";
type DialogKind =
| "cancel"
diff --git a/app/dashboard/consultee/[consulteeId]/(features)/appointments/ReportIssueDialog.tsx b/components/appointments/consultee/ReportIssueDialog.tsx
similarity index 100%
rename from app/dashboard/consultee/[consulteeId]/(features)/appointments/ReportIssueDialog.tsx
rename to components/appointments/consultee/ReportIssueDialog.tsx
diff --git a/app/dashboard/consultee/[consulteeId]/(features)/appointments/components/RescheduleSessionsModal.tsx b/components/appointments/consultee/RescheduleSessionsModal.tsx
similarity index 100%
rename from app/dashboard/consultee/[consulteeId]/(features)/appointments/components/RescheduleSessionsModal.tsx
rename to components/appointments/consultee/RescheduleSessionsModal.tsx
diff --git a/app/dashboard/consultee/[consulteeId]/(features)/appointments/components/useEventActions.ts b/components/appointments/consultee/useEventActions.ts
similarity index 100%
rename from app/dashboard/consultee/[consulteeId]/(features)/appointments/components/useEventActions.ts
rename to components/appointments/consultee/useEventActions.ts
diff --git a/components/chat/AddMembersDialog.tsx b/components/chat/AddMembersDialog.tsx
index 77fb14348..73e4ee57f 100644
--- a/components/chat/AddMembersDialog.tsx
+++ b/components/chat/AddMembersDialog.tsx
@@ -15,7 +15,7 @@ import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { useToast } from "@/components/ui/use-toast";
import { Loader2Icon, SearchIcon, UserPlusIcon, XIcon } from "lucide-react";
-import type { ConsulteeSearchResult } from "@/app/api/stream/search-consultees/route";
+import type { ConsulteeSearchResult } from "@/schemas/stream-search";
interface AddMembersDialogProps {
open: boolean;
diff --git a/components/chat/ChannelSearch.tsx b/components/chat/ChannelSearch.tsx
index 001a6abdf..86094078c 100644
--- a/components/chat/ChannelSearch.tsx
+++ b/components/chat/ChannelSearch.tsx
@@ -5,7 +5,7 @@ import Image from "next/image";
import { useChatContext } from "stream-chat-react";
import { SearchIcon, UserIcon, VideoIcon, BookOpenIcon } from "lucide-react";
import { Input } from "@/components/ui/input";
-import type { AppointmentSearchResult } from "@/app/api/stream/channels/search-appointments/route";
+import type { AppointmentSearchResult } from "@/schemas/stream-search";
// Type badge configuration for events (webinars/classes)
const EVENT_TYPE_CONFIG = {
diff --git a/components/chat/ChatSidebar.tsx b/components/chat/ChatSidebar.tsx
index 81d966778..5a54f151e 100644
--- a/components/chat/ChatSidebar.tsx
+++ b/components/chat/ChatSidebar.tsx
@@ -149,7 +149,13 @@ ChannelItem.displayName = "ChannelItem";
export const ChatSidebar = () => {
const { client, setActiveChannel } = useChatContext();
const userRole = client?.user?.role as string | undefined;
- const { scope } = useOrgScope();
+ // Route-pinned under /dashboard/organization/[orgId]/ — that mount scopes
+ // itself to the org and this option is ignored there. Everywhere else this
+ // component renders is a PERSONAL dashboard, and ADR 19 pins personal to
+ // `organizationId: null`, so B2C is the right default rather than the hook's
+ // `first-org` (which silently hid a member's B2C threads behind whichever org
+ // happened to be first, and hid a second org's entirely).
+ const { scope } = useOrgScope({ defaultForOrgMember: "personal" });
const [teamChannels, setTeamChannels] = useState([]);
const [directMessages, setDirectMessages] = useState([]);
const [activeChannelId, setActiveChannelId] = useState(null);
diff --git a/app/dashboard/consultant/[consultantId]/(features)/requests/RequestSlotAllocationTab.tsx b/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx
similarity index 96%
rename from app/dashboard/consultant/[consultantId]/(features)/requests/RequestSlotAllocationTab.tsx
rename to components/dashboard/shared/requests/RequestSlotAllocationTab.tsx
index 20b1b9edb..93a51b463 100644
--- a/app/dashboard/consultant/[consultantId]/(features)/requests/RequestSlotAllocationTab.tsx
+++ b/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx
@@ -26,23 +26,23 @@ import { useParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { RequestedSlotsDialog } from "./components/RequestedSlotsDialog";
import { PaymentRequiredBadge } from "./components/PaymentRequiredBadge";
-import { SafeUnifiedCalendar } from "../shared/components/SafeUnifiedCalendar";
+import { SafeUnifiedCalendar } from "@/components/scheduling/SafeUnifiedCalendar";
import {
ConsultationApiResponse,
RequestedBy,
SubscriptionApiResponse,
} from "./types";
-import { countSundayWeeksInclusive } from "../shared/utils/calendarUtils";
+import { countSundayWeeksInclusive } from "@/lib/scheduling/calendarUtils";
import {
allocatedElsewhere,
allocationFailed,
planConfigIncomplete,
-} from "../shared/utils/allocationMessages";
+} from "@/lib/scheduling/allocationMessages";
import {
computeAttemptFingerprint,
resolveAttemptKey,
type AllocationAttemptKey,
-} from "../shared/hooks/useSlotAllocation";
+} from "@/hooks/scheduling/useSlotAllocation";
// Slot with tentative status for reschedule visibility
interface RequestedSlot {
@@ -85,6 +85,23 @@ type RequestType = "all" | "consultation" | "subscription";
interface RequestSlotAllocationTabProps {
type: RequestType;
onUpdate: () => void;
+ /**
+ * Whose requests to allocate. Falls back to the `[consultantId]` route param
+ * so the consultant tree keeps working untouched; the org tree has no such
+ * param and passes it explicitly.
+ */
+ consultantProfileId?: string;
+ /**
+ * Funding context, forwarded as `?orgScope=`.
+ *
+ * `/api/bookings/{consultations,subscriptions}` EXCLUDE org-funded rows when
+ * this is absent, so omitting it is how org-sponsored requests became
+ * invisible: the only allocation surface in the product sat in the consultant
+ * tree and silently dropped them, and an org-sponsored subscription was paid
+ * for and never scheduled. Personal keeps the B2C-only behaviour; an org id
+ * narrows to that organization.
+ */
+ orgScope?: "personal" | (string & {});
}
// Helper function to fetch and process data
@@ -136,9 +153,12 @@ async function fetchDataFromApi(
export function RequestSlotAllocationTab({
type,
onUpdate,
+ consultantProfileId,
+ orgScope = "personal",
}: RequestSlotAllocationTabProps) {
const params = useParams();
- const consultantId = params.consultantId as string;
+ const consultantId =
+ consultantProfileId ?? (params.consultantId as string);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [requests, setRequests] = useState([]);
@@ -158,10 +178,10 @@ export function RequestSlotAllocationTab({
// Fetch data in parallel (only PENDING requests).
const [consultationsResult, subscriptionsResult] = await Promise.all([
fetchDataFromApi(
- `/api/bookings/consultations?consultantProfileId=${consultantId}&status=PENDING`,
+ `/api/bookings/consultations?consultantProfileId=${consultantId}&status=PENDING&orgScope=${orgScope}`,
),
fetchDataFromApi(
- `/api/bookings/subscriptions?consultantProfileId=${consultantId}&status=PENDING`,
+ `/api/bookings/subscriptions?consultantProfileId=${consultantId}&status=PENDING&orgScope=${orgScope}`,
),
]);
@@ -334,7 +354,9 @@ export function RequestSlotAllocationTab({
setLoading(false);
}
}
- }, [consultantId, type, error]);
+ // orgScope belongs here: fetchData builds both URLs from it, so without it
+ // a scope change without a remount keeps refetching the previous org's rows.
+ }, [consultantId, type, error, orgScope]);
useEffect(() => {
fetchData();
diff --git a/app/dashboard/consultant/[consultantId]/(features)/requests/RequestSlotAllocationTabMini.tsx b/components/dashboard/shared/requests/RequestSlotAllocationTabMini.tsx
similarity index 100%
rename from app/dashboard/consultant/[consultantId]/(features)/requests/RequestSlotAllocationTabMini.tsx
rename to components/dashboard/shared/requests/RequestSlotAllocationTabMini.tsx
diff --git a/app/dashboard/consultant/[consultantId]/(features)/requests/components/PaymentRequiredBadge.tsx b/components/dashboard/shared/requests/components/PaymentRequiredBadge.tsx
similarity index 100%
rename from app/dashboard/consultant/[consultantId]/(features)/requests/components/PaymentRequiredBadge.tsx
rename to components/dashboard/shared/requests/components/PaymentRequiredBadge.tsx
diff --git a/app/dashboard/consultant/[consultantId]/(features)/requests/components/RequestedSlotsDialog.tsx b/components/dashboard/shared/requests/components/RequestedSlotsDialog.tsx
similarity index 99%
rename from app/dashboard/consultant/[consultantId]/(features)/requests/components/RequestedSlotsDialog.tsx
rename to components/dashboard/shared/requests/components/RequestedSlotsDialog.tsx
index 48336bf9a..66b2e4da0 100644
--- a/app/dashboard/consultant/[consultantId]/(features)/requests/components/RequestedSlotsDialog.tsx
+++ b/components/dashboard/shared/requests/components/RequestedSlotsDialog.tsx
@@ -11,8 +11,8 @@ import {
import { AppointmentsType } from "@prisma/client";
import { AlertTriangle, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
-import { AllocationService } from "../../shared/utils/allocationService";
-import { TimeSlot } from "../../shared/utils/calendarUtils";
+import { AllocationService } from "@/lib/scheduling/allocationService";
+import { TimeSlot } from "@/lib/scheduling/calendarUtils";
import type { SlotConflictResult } from "@/utils/slotAllocation/types";
// Slot with tentative status
diff --git a/app/dashboard/consultant/[consultantId]/(features)/requests/types.ts b/components/dashboard/shared/requests/types.ts
similarity index 100%
rename from app/dashboard/consultant/[consultantId]/(features)/requests/types.ts
rename to components/dashboard/shared/requests/types.ts
diff --git a/app/dashboard/consultant/[consultantId]/(features)/shared/components/CalendarErrorBoundary.tsx b/components/scheduling/CalendarErrorBoundary.tsx
similarity index 100%
rename from app/dashboard/consultant/[consultantId]/(features)/shared/components/CalendarErrorBoundary.tsx
rename to components/scheduling/CalendarErrorBoundary.tsx
diff --git a/app/dashboard/consultant/[consultantId]/(features)/shared/components/SafeUnifiedCalendar.tsx b/components/scheduling/SafeUnifiedCalendar.tsx
similarity index 100%
rename from app/dashboard/consultant/[consultantId]/(features)/shared/components/SafeUnifiedCalendar.tsx
rename to components/scheduling/SafeUnifiedCalendar.tsx
diff --git a/app/dashboard/consultant/[consultantId]/(features)/shared/components/UnifiedCalendar.tsx b/components/scheduling/UnifiedCalendar.tsx
similarity index 99%
rename from app/dashboard/consultant/[consultantId]/(features)/shared/components/UnifiedCalendar.tsx
rename to components/scheduling/UnifiedCalendar.tsx
index 0cb6fe075..838261e26 100644
--- a/app/dashboard/consultant/[consultantId]/(features)/shared/components/UnifiedCalendar.tsx
+++ b/components/scheduling/UnifiedCalendar.tsx
@@ -40,10 +40,10 @@ import {
calculateCallProgress,
countSundayWeeksInclusive,
validateDayBasedConsecutiveSlots,
-} from "../utils/calendarUtils";
-import { useCalendarData } from "../hooks/useCalendarData";
-import { useEventSlotAllocation } from "../hooks/useSlotAllocation";
-import type { AllocationResponse } from "../utils/allocationService";
+} from "@/lib/scheduling/calendarUtils";
+import { useCalendarData } from "@/hooks/scheduling/useCalendarData";
+import { useEventSlotAllocation } from "@/hooks/scheduling/useSlotAllocation";
+import type { AllocationResponse } from "@/lib/scheduling/allocationService";
import { SlotCalculationService } from "@/utils/slotAllocation/SlotCalculationService";
import {
outsideSchedulingWindow,
@@ -54,7 +54,7 @@ import {
sessionBeingRescheduled,
slotUnavailable,
notEnoughConsecutive,
-} from "../utils/allocationMessages";
+} from "@/lib/scheduling/allocationMessages";
import { useToast } from "@/hooks/use-toast";
/**
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 4aacd5c2e..bcc54d953 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -132,4 +132,53 @@ export default [
"no-control-regex": "off",
},
},
+
+ // Layering: `app/` is the routing layer. It may depend on lib, components,
+ // hooks, types and schemas — never the reverse.
+ //
+ // This regressed silently more than once before it was enforced. A shared
+ // component ended up importing a calendar and a slot-allocation hook from
+ // `app/dashboard/consultant/[consultantId]/(features)/shared/`, through a
+ // dynamic route segment; `lib/dashboard-queries.ts` pulled types out of two
+ // route folders; and `lib/data/explore-programs.ts` imported live functions
+ // from `app/explore`. Each was reasonable in isolation and each made the
+ // importing layer impossible to reuse or extract without dragging routing
+ // along with it.
+ //
+ // If a route folder holds something genuinely shared, the answer is to move
+ // it out — that is where `components/scheduling`, `hooks/scheduling`,
+ // `lib/scheduling` and `lib/explore` came from. For an API response shape,
+ // put it in `schemas/` and let both sides derive from one Zod definition.
+ {
+ files: [
+ "lib/**/*.{ts,tsx}",
+ "components/**/*.{ts,tsx}",
+ "hooks/**/*.{ts,tsx}",
+ "types/**/*.{ts,tsx}",
+ "schemas/**/*.{ts,tsx}",
+ ],
+ rules: {
+ "no-restricted-imports": [
+ "error",
+ {
+ patterns: [
+ {
+ // Both spellings. The alias form is what anyone would normally
+ // write, but `../../app/...` resolves to exactly the same module
+ // and would have walked straight past an alias-only rule.
+ group: [
+ "@/app/*",
+ "@/app/**",
+ "**/app/dashboard/**",
+ "**/app/api/**",
+ "**/app/explore/**",
+ ],
+ message:
+ "Do not import from app/ here — app/ is the routing layer and must depend on these layers, not the reverse. Move the shared code into lib/, components/, hooks/ or types/, or put the response shape in schemas/ and derive both sides from it.",
+ },
+ ],
+ },
+ ],
+ },
+ },
];
diff --git a/app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useCalendarData.ts b/hooks/scheduling/useCalendarData.ts
similarity index 99%
rename from app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useCalendarData.ts
rename to hooks/scheduling/useCalendarData.ts
index 37cc91d7d..7db035477 100644
--- a/app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useCalendarData.ts
+++ b/hooks/scheduling/useCalendarData.ts
@@ -8,7 +8,7 @@ import {
getDaysInMonth,
} from "date-fns";
import { useToast } from "@/hooks/use-toast";
-import { AllocationService } from "../utils/allocationService";
+import { AllocationService } from "@/lib/scheduling/allocationService";
import { INTERVALS } from "@/utils/timeSlotsMeta";
/**
diff --git a/app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useLazyJoinMeeting.ts b/hooks/scheduling/useLazyJoinMeeting.ts
similarity index 100%
rename from app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useLazyJoinMeeting.ts
rename to hooks/scheduling/useLazyJoinMeeting.ts
diff --git a/app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useSlotAllocation.ts b/hooks/scheduling/useSlotAllocation.ts
similarity index 99%
rename from app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useSlotAllocation.ts
rename to hooks/scheduling/useSlotAllocation.ts
index 300241193..f6d9c7407 100644
--- a/app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useSlotAllocation.ts
+++ b/hooks/scheduling/useSlotAllocation.ts
@@ -1,13 +1,13 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import * as Sentry from "@sentry/nextjs";
import { useToast } from "@/components/ui/use-toast";
-import { TimeSlot, calculateRequiredSlots } from "../utils/calendarUtils";
+import { TimeSlot, calculateRequiredSlots } from "@/lib/scheduling/calendarUtils";
import {
AllocationAlgorithms,
AllocationOptions,
AllocationResult,
-} from "../utils/allocationAlgorithms";
-import { AllocationService } from "../utils/allocationService";
+} from "@/lib/scheduling/allocationAlgorithms";
+import { AllocationService } from "@/lib/scheduling/allocationService";
import { isRecurringEventType } from "@/utils/slotAllocation/types";
import {
ValidationResult,
@@ -23,7 +23,7 @@ import {
findConsecutiveGroupContaining,
dayKey,
weekKey,
-} from "../utils/slotSelectionValidation";
+} from "@/lib/scheduling/slotSelectionValidation";
import {
AllocationToast,
weeklyLimitReached,
@@ -40,7 +40,7 @@ import {
autoScheduled,
allocationFailed,
allocatedElsewhere,
-} from "../utils/allocationMessages";
+} from "@/lib/scheduling/allocationMessages";
/**
* EVENT SLOT ALLOCATION HOOK
diff --git a/hooks/useOrgScope.ts b/hooks/useOrgScope.ts
index e58031c5b..9c6a0d7d1 100644
--- a/hooks/useOrgScope.ts
+++ b/hooks/useOrgScope.ts
@@ -66,10 +66,18 @@ export interface UseOrgScopeOptions {
* lib/api/scope/parse.ts) — e.g. the consultee appointments
* page, where seeing the full picture by default is the most
* useful landing state.
- * ADMIN / STAFF always default to "all" regardless of this option.
+ * - "personal" — B2C only, even for org members. For surfaces that
+ * ADR 19 splits strictly by org-ness and that have a separate
+ * org-tree counterpart, so merging the two here would duplicate a
+ * destination rather than complete one. Chat is the case: the org
+ * half lives at /dashboard/organization/[orgId]/messages.
+ * ADMIN / STAFF default to "all" regardless of this option — EXCEPT when a
+ * caller passes "personal" explicitly, which wins for everyone. A privileged
+ * user landing on the union of a surface that has a separate org-tree half
+ * would see the org rows twice, once here and once there.
* B2C users (no orgs) always default to "personal".
*/
- defaultForOrgMember?: "first-org" | "all";
+ defaultForOrgMember?: "first-org" | "all" | "personal";
}
export function useOrgScope(
@@ -100,6 +108,11 @@ export function useOrgScope(
// URL is the source of truth. Honor whatever it says.
if (raw) return parseRaw(raw);
+ // An explicit "personal" wins even for privileged users: the caller is
+ // saying this surface is the B2C half of a split, and an admin landing on
+ // the union there would see the org rows twice — once here and once in the
+ // org tree.
+ if (defaultForOrgMember === "personal") return { kind: "personal" };
if (role === "ADMIN" || role === "STAFF") return { kind: "all" };
if (firstOrgId) {
return defaultForOrgMember === "all"
diff --git a/lib/api/scope/list-appointments.ts b/lib/api/scope/list-appointments.ts
index 56ab04750..ff4de8a15 100644
--- a/lib/api/scope/list-appointments.ts
+++ b/lib/api/scope/list-appointments.ts
@@ -115,7 +115,32 @@ export function buildWhere(
}
if (params.scope.kind === "org") {
- return { ...base, organizationId: params.scope.orgId };
+ // Hosted OR funded, because those are two different columns answering two
+ // different questions.
+ //
+ // For a webinar or class every registrant shares ONE Appointment, and
+ // checkout deliberately tags it with the HOST's org (`plan.organizationId`)
+ // rather than the first registrant's — otherwise whoever booked first would
+ // decide which org the event belonged to. Per-registrant funding lives on
+ // `Payment.organizationId` instead.
+ //
+ // Filtering on `organizationId` alone therefore showed an org only the
+ // events it HOSTS. 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 already IS
+ // the funding org.
+ //
+ // No new exposure: the select carries no attendee list, and `getMember`
+ // renders "—" for group events. A sponsor sees that the session exists, on
+ // what plan, when — not who else was in the room. ADR 20 holds.
+ return {
+ ...base,
+ OR: [
+ { organizationId: params.scope.orgId },
+ { payment: { some: { organizationId: params.scope.orgId } } },
+ ],
+ };
}
// Explicit rather than a fall-through: `base` alone is the admin/staff arm,
@@ -137,6 +162,24 @@ export async function listAppointmentsScoped(
prisma.appointment.findMany({
where,
include: {
+ // Who from the VIEWING org was funded into this session.
+ //
+ // Scoped to `scope.orgId` and nothing else, which is the whole point: a
+ // webinar's registrants may span several sponsors and the public, and a
+ // sponsor is entitled to see the five people it paid for — not the
+ // twenty it did not. Empty for 1:1 kinds, where `getMember` already
+ // names the counterpart, and empty for a hosted-but-not-funded event.
+ ...(params.scope.kind === "org"
+ ? {
+ payment: {
+ where: { organizationId: params.scope.orgId },
+ select: {
+ id: true,
+ user: { select: { id: true, name: true, email: true } },
+ },
+ },
+ }
+ : {}),
// #org-appts — slot fields drive the in-context Join (getOrCreate needs
// slot id + startsAt); consultantProfile.user.id lets the caller stamp
// per-appointment identity into the Stream call (host/guest derivation).
diff --git a/lib/dashboard-queries.ts b/lib/dashboard-queries.ts
index 6cb548290..17ca572e4 100644
--- a/lib/dashboard-queries.ts
+++ b/lib/dashboard-queries.ts
@@ -21,8 +21,8 @@ import type {
import type {
PlannerWebinarEvent,
PlannerClassEvent,
-} from "@/app/dashboard/consultant/[consultantId]/(features)/planner/types/event";
-import type { RecordingData } from "@/app/dashboard/consultant/[consultantId]/(features)/recordings/components/RecordingCard";
+} from "@/types/planner-events";
+import type { RecordingData } from "@/types/recording";
// =============================================================================
// Types
diff --git a/lib/data/explore-programs.ts b/lib/data/explore-programs.ts
index 0201ceaa2..8cc991c9c 100644
--- a/lib/data/explore-programs.ts
+++ b/lib/data/explore-programs.ts
@@ -3,14 +3,14 @@ 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 { generateProgramImageUrl } from "@/app/explore/programs/utils";
+import { generateProgramImageUrl } from "@/lib/explore/programs";
import type {
Program,
ClassPlanProgram,
WebinarPlanProgram,
ProgramType,
TopicWithCount,
-} from "@/app/explore/programs/utils";
+} from "@/lib/explore/programs";
/**
* Server-side data access for the explore programs page.
diff --git a/app/dashboard/shared/utils/document-utils.ts b/lib/documents/document-utils.ts
similarity index 100%
rename from app/dashboard/shared/utils/document-utils.ts
rename to lib/documents/document-utils.ts
diff --git a/app/explore/programs/utils.ts b/lib/explore/programs.ts
similarity index 89%
rename from app/explore/programs/utils.ts
rename to lib/explore/programs.ts
index bebea5e6e..947eb049a 100644
--- a/app/explore/programs/utils.ts
+++ b/lib/explore/programs.ts
@@ -1,3 +1,12 @@
+/**
+ * Explore-programs domain helpers and shapes.
+ *
+ * Moved out of `app/explore/programs/utils.ts`: `lib/data/explore-programs.ts`
+ * imported FUNCTIONS from it, which made the data layer depend on a route
+ * folder at runtime — the wrong direction, and the only non-type instance of it
+ * left in the codebase. Nothing here is routing; it is pagination constants,
+ * program shapes and an image-URL builder.
+ */
import {
ClassPlan as PrismaClassPlan,
WebinarPlan as PrismaWebinarPlan,
diff --git a/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationAlgorithms.ts b/lib/scheduling/allocationAlgorithms.ts
similarity index 100%
rename from app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationAlgorithms.ts
rename to lib/scheduling/allocationAlgorithms.ts
diff --git a/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationMessages.ts b/lib/scheduling/allocationMessages.ts
similarity index 100%
rename from app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationMessages.ts
rename to lib/scheduling/allocationMessages.ts
diff --git a/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationService.ts b/lib/scheduling/allocationService.ts
similarity index 100%
rename from app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationService.ts
rename to lib/scheduling/allocationService.ts
diff --git a/app/dashboard/consultant/[consultantId]/(features)/shared/utils/calendarUtils.ts b/lib/scheduling/calendarUtils.ts
similarity index 100%
rename from app/dashboard/consultant/[consultantId]/(features)/shared/utils/calendarUtils.ts
rename to lib/scheduling/calendarUtils.ts
diff --git a/app/dashboard/consultant/[consultantId]/(features)/shared/utils/slotSelectionValidation.ts b/lib/scheduling/slotSelectionValidation.ts
similarity index 100%
rename from app/dashboard/consultant/[consultantId]/(features)/shared/utils/slotSelectionValidation.ts
rename to lib/scheduling/slotSelectionValidation.ts
diff --git a/lib/stream-utils.ts b/lib/stream-utils.ts
index 5b47123ea..fc5c3a215 100644
--- a/lib/stream-utils.ts
+++ b/lib/stream-utils.ts
@@ -1,8 +1,89 @@
+import { createHash } from "node:crypto";
+
/**
- * Returns a deterministic Stream channel ID for a consultant-consultee DM pair.
- * IDs are sorted so the same value is produced regardless of call order.
+ * Stream caps channel ids at 64 characters. Nothing used to check that, and we
+ * are closer to the ceiling than anyone realised: a seeded cuid pair already
+ * produces 54 characters and the longest live channel measured 61. Two users on
+ * 36-character uuid ids — 17 accounts carry them — would produce 76 and be
+ * rejected by Stream at runtime, silently, because the create action's
+ * `channelIdSchema` only asserts `min(1)`.
*/
-export function getDmChannelId(userId1: string, userId2: string): string {
+export const STREAM_CHANNEL_ID_MAX = 64;
+
+/** Short, stable digest. Hex, so the result is always `[a-f0-9]` — Stream-safe. */
+function digest(value: string, chars: number): string {
+ return createHash("sha256").update(value).digest("hex").slice(0, chars);
+}
+
+/**
+ * Personal ids are `dm--`, which fits comfortably for cuid users (54–61
+ * chars) but NOT for the 17 accounts still on 36-char uuids: two of those
+ * produce 76 chars.
+ *
+ * Throwing there was wrong. `getDmPairsForUser` and the `expectedChannelIds`
+ * map in `syncUserEventChannels` call this synchronously and un-isolated —
+ * unlike the add-pass, which is wrapped in `Promise.allSettled` — so a single
+ * legacy-id pair would reject the whole reconciliation and break channel sync
+ * for everyone paired with that account. Before the guard existed the same call
+ * simply produced a long id and failed later at the Stream API, affecting one
+ * channel rather than the run.
+ *
+ * So it degrades instead: a deterministic hashed form under a distinct prefix.
+ * Same input, same id, every time — and no id already in use changes, because
+ * every existing channel is under the cap.
+ */
+function fitOrHash(channelId: string, hashInput: string): string {
+ if (channelId.length <= STREAM_CHANNEL_ID_MAX) return channelId;
+ // `dmh-` keeps this namespace distinct from both `dm-` and `dmo-`.
+ return `dmh-${digest(hashInput, 40)}`;
+}
+
+/**
+ * Deterministic Stream channel id for a consultant–consultee DM.
+ *
+ * A DM used to be keyed on the pair alone, which made the channel the
+ * RELATIONSHIP rather than the booking: the same two people had one thread no
+ * matter how many sessions they booked or who funded them, and it carried the
+ * org tag of whichever booking happened to create it. ADR 19 splits dashboards
+ * by org-ness, so that single thread could not land in the right place — a pair
+ * who work both B2C and through an org had one conversation belonging in two
+ * dashboards. The org is now part of the key, so a pair gets one thread per
+ * context.
+ *
+ * The two forms are deliberately different shapes rather than one scheme with an
+ * appended segment:
+ *
+ * personal `dm--` 54–61 chars, byte-identical to before
+ * org `dmo--` 29 chars
+ *
+ * Personal ids are unchanged, so every existing conversation keeps its channel —
+ * and there was no headroom to extend them in any case (61 of 64). The org form
+ * hashes both halves precisely because appending anything to the pair would have
+ * overflowed. Verified before shipping that zero org-tagged channels existed, so
+ * nothing needed migrating.
+ *
+ * Opaque ids are the cost. Debugging goes through the channel's members and its
+ * `organization_id` custom field, both set at creation.
+ */
+export function getDmChannelId(
+ userId1: string,
+ userId2: string,
+ organizationId?: string | null,
+): string {
const [a, b] = [userId1, userId2].sort((x, y) => x.localeCompare(y));
- return `dm-${a}-${b}`;
+
+ if (!organizationId) {
+ return fitOrHash(`dm-${a}-${b}`, `${a}-${b}`);
+ }
+
+ // Hash the pair, not only the org: the pair is the long part, and the point is
+ // to stay well inside the ceiling rather than creep back up to it.
+ //
+ // 16 and 24 hex chars — 64 and 96 bits. The org segment is the ONLY thing
+ // separating two organizations' otherwise-identical pair digest, so a
+ // collision there would merge two orgs' DM threads for the same pair. At 8
+ // chars (32 bits) the birthday bound makes that non-trivial in the tens of
+ // thousands of orgs; at 64 bits it is not a concern. The format still uses
+ // only 45 of the 64 available characters.
+ return `dmo-${digest(organizationId, 16)}-${digest(`${a}-${b}`, 24)}`;
}
diff --git a/schemas/stream-search.ts b/schemas/stream-search.ts
new file mode 100644
index 000000000..27a3cc599
--- /dev/null
+++ b/schemas/stream-search.ts
@@ -0,0 +1,49 @@
+import { z } from "zod";
+
+/**
+ * Response shapes for the two Stream search endpoints.
+ *
+ * These used to be `export type` declarations inside the route handlers, which
+ * meant `components/chat/*` imported them from `@/app/api/...` — pointing the
+ * routing layer's way instead of away from it. Type-only, so nothing broke at
+ * runtime, but it makes those components impossible to reason about or extract
+ * without dragging `app/` along.
+ *
+ * Zod rather than a plain interface in `types/`: both handlers parse their
+ * results against these schemas on the way out, and both consumers derive their
+ * types from the same definitions. So the two agree by construction rather than
+ * by one asserting a shape the other hopes is true — a field renamed in a
+ * handler fails at the boundary instead of arriving as `undefined` in the UI,
+ * which is the exact class of drift that put ₹NaN on two money tables in
+ * #1029.
+ */
+
+/** The four bookable kinds, shared by both searches. */
+export const StreamSearchKindSchema = z.enum([
+ "consultation",
+ "subscription",
+ "webinar",
+ "class",
+]);
+
+export const AppointmentSearchResultSchema = z.object({
+ id: z.string(),
+ type: StreamSearchKindSchema,
+ name: z.string(),
+ consultantName: z.string(),
+ consultantImage: z.string().optional(),
+ channelId: z.string(),
+});
+
+export const ConsulteeSearchResultSchema = z.object({
+ id: z.string(),
+ name: z.string().nullable(),
+ email: z.string().nullable(),
+ image: z.string().nullable(),
+ relationshipType: StreamSearchKindSchema,
+});
+
+export type AppointmentSearchResult = z.infer<
+ typeof AppointmentSearchResultSchema
+>;
+export type ConsulteeSearchResult = z.infer;
diff --git a/scripts/stream/backfill-channel-org.ts b/scripts/stream/backfill-channel-org.ts
index 1e1ae59c8..a221310a0 100644
--- a/scripts/stream/backfill-channel-org.ts
+++ b/scripts/stream/backfill-channel-org.ts
@@ -84,6 +84,8 @@ type ChannelTarget = {
async function resolveChannelTarget(appointment: {
id: string;
appointmentType: string;
+ /** Non-null for every row this backfill walks; part of the DM channel key. */
+ organizationId: string | null;
webinarId: string | null;
classId: string | null;
consultationId: string | null;
@@ -109,6 +111,7 @@ async function resolveChannelTarget(appointment: {
select: {
consultationPlan: {
select: {
+ organizationId: true,
consultantProfile: { select: { user: { select: { id: true } } } },
},
},
@@ -121,7 +124,14 @@ async function resolveChannelTarget(appointment: {
if (!consultantId || !consulteeId) return null;
return {
channelType: "messaging",
- channelId: getDmChannelId(consultantId, consulteeId),
+ // Precedence must match the creators: an org-HOSTED plan wins over the
+ // appointment's own tag, or this targets a channel that was never made.
+ channelId: getDmChannelId(
+ consultantId,
+ consulteeId,
+ consultation?.consultationPlan?.organizationId ??
+ appointment.organizationId,
+ ),
};
}
case "SUBSCRIPTION": {
@@ -131,6 +141,7 @@ async function resolveChannelTarget(appointment: {
select: {
subscriptionPlan: {
select: {
+ organizationId: true,
consultantProfile: { select: { user: { select: { id: true } } } },
},
},
@@ -143,7 +154,13 @@ async function resolveChannelTarget(appointment: {
if (!consultantId || !consulteeId) return null;
return {
channelType: "messaging",
- channelId: getDmChannelId(consultantId, consulteeId),
+ // Same precedence as createSubscriptionChannel.
+ channelId: getDmChannelId(
+ consultantId,
+ consulteeId,
+ subscription?.subscriptionPlan?.organizationId ??
+ appointment.organizationId,
+ ),
};
}
default:
diff --git a/tsconfig.json b/tsconfig.json
index 9d33a1826..8292d47b2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,14 @@
{
"compilerOptions": {
- "target": "es5",
+ // ES2017 is the Next.js default and matches what the app actually ships:
+ // Next transpiles for its own browserslist targets regardless of this, so
+ // "es5" only ever constrained the type checker. It is also deprecated and
+ // stops working in TypeScript 7.0.
+ //
+ // The practical effect is that iterator spread (`[...map.values()]`,
+ // `[...str.matchAll()]`) now typechecks instead of demanding
+ // `--downlevelIteration` or an Array.from rewrite.
+ "target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"types": ["jest", "node"],
"allowJs": true,
@@ -20,6 +28,11 @@
"name": "next"
}
],
+ // `baseUrl` is deprecated in TS 7.0 and should eventually go, but it is
+ // load-bearing today: ~40 files import via bare specifiers ("lib/prisma"
+ // rather than "@/lib/prisma") and resolve only through it. Removing it is a
+ // mechanical import rewrite across those files — worth doing, but not on a
+ // branch about org chat surfaces.
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
diff --git a/app/dashboard/consultant/[consultantId]/(features)/planner/types/event.ts b/types/planner-events.ts
similarity index 94%
rename from app/dashboard/consultant/[consultantId]/(features)/planner/types/event.ts
rename to types/planner-events.ts
index 836b9a881..2548edeed 100644
--- a/app/dashboard/consultant/[consultantId]/(features)/planner/types/event.ts
+++ b/types/planner-events.ts
@@ -1,3 +1,12 @@
+/**
+ * Planner event types.
+ *
+ * Moved out of `app/dashboard/consultant/[consultantId]/(features)/planner/`
+ * because `lib/dashboard-queries.ts` consumes them, and a non-route layer
+ * importing through a dynamic route segment inverts the dependency direction:
+ * `app/` is the routing layer and should depend on `lib`, `components` and
+ * `hooks`, never the reverse.
+ */
import {
TWebinar,
TClass,
diff --git a/types/recording.ts b/types/recording.ts
new file mode 100644
index 000000000..c0b408991
--- /dev/null
+++ b/types/recording.ts
@@ -0,0 +1,31 @@
+/**
+ * Shape of a recording row as the API returns it.
+ *
+ * Lives here rather than beside the card that renders it because `lib/` needs
+ * it too, and `lib/` importing a type out of a route folder — through a
+ * `[consultantId]` dynamic segment — inverts the dependency direction: `app/`
+ * is the routing layer and should depend on `lib`, `components` and `hooks`,
+ * never the reverse. Nothing re-exports it — the card imports it from here like
+ * every other consumer, so there is one definition and one import path.
+ */
+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;
+}