();
if (user.consultantProfileId) {
const [consultations, subscriptions] = await Promise.all([
@@ -717,6 +743,10 @@ async function getDmPairsForUser(
},
include: {
requestedBy: { include: { user: { select: { id: true } } } },
+ // The DM channel key now includes the funding context, so the
+ // reconcile set has to know it too — otherwise it would look for a
+ // personal channel that an org booking never created.
+ appointment: { select: { organizationId: true } },
},
}),
prisma.subscription.findMany({
@@ -726,14 +756,20 @@ async function getDmPairsForUser(
},
include: {
requestedBy: { include: { user: { select: { id: 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 +788,7 @@ async function getDmPairsForUser(
},
},
},
+ appointment: { select: { organizationId: true } },
},
}),
prisma.subscription.findMany({
@@ -767,20 +804,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 +843,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/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx
index 461a095bf..74bcf8700 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,
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)/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/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)/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/organization/[orgId]/appointments/MyAppointmentsClient.tsx b/app/dashboard/organization/[orgId]/appointments/MyAppointmentsClient.tsx
index c268e0dd5..13921ab1d 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
diff --git a/app/dashboard/organization/[orgId]/layout.tsx b/app/dashboard/organization/[orgId]/layout.tsx
index 642c33819..e2fc49b31 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
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/utils.ts b/app/explore/programs/utils.ts
deleted file mode 100644
index bebea5e6e..000000000
--- a/app/explore/programs/utils.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import {
- ClassPlan as PrismaClassPlan,
- WebinarPlan as PrismaWebinarPlan,
-} from "@prisma/client";
-
-export const ITEMS_PER_PAGE = 12;
-
-export type ProgramType = "all" | "class" | "webinar";
-
-// Type for registration data from API
-interface SlotUser {
- id: string;
-}
-
-interface SlotWithUser {
- user?: SlotUser[];
-}
-
-interface WebinarWithAppointment {
- appointment?: {
- slotsOfAppointment?: SlotWithUser[];
- } | null;
-}
-
-interface ClassSlot extends Record {
- user?: SlotUser[];
-}
-
-interface ClassAppointment {
- slotsOfAppointment: ClassSlot[];
-}
-
-export interface ClassInstance {
- id: string;
- schedulingPeriodStartsAt?: string | Date | null;
- appointments?: ClassAppointment[];
-}
-
-type ProgramConsultantProfile = {
- rating?: number;
- headline?: string | null;
- user?: {
- name?: string | null;
- image?: string | null;
- workExperiences?: Array<{
- company: string;
- companyDomain: string | null;
- isCurrent: boolean;
- }>;
- };
-};
-
-type ProgramCollaborator = {
- consultantProfile?: ProgramConsultantProfile | null;
-};
-
-// #780 — price reaches here as number (extended-client read → JSON), never bigint
-export type ClassPlanProgram = Omit & {
- price: number;
- classes: ClassInstance[];
- type: "class";
- imageUrl: string;
- isRegistered?: boolean;
- consultantProfile?: ProgramConsultantProfile | null;
- collaborators?: ProgramCollaborator[];
-};
-
-export type WebinarPlanProgram = Omit & {
- price: number;
- webinars?: WebinarWithAppointment[];
- type: "webinar";
- imageUrl: string;
- isRegistered?: boolean;
- consultantProfile?: ProgramConsultantProfile | null;
- collaborators?: ProgramCollaborator[];
-};
-
-export type Program = ClassPlanProgram | WebinarPlanProgram;
-
-export interface ApiMeta {
- page: number;
- limit: number;
- total: number;
- totalPages: number;
-}
-
-export interface TopicWithCount {
- id: string;
- name: string;
- programCount: number;
-}
-
-export interface ProgramFilters {
- topicIds?: string[];
- language?: string;
- domainId?: string;
- sort?: string;
- minPrice?: number;
- maxPrice?: number;
- search?: string;
-}
-
-// Generate program image URL based on ID with dimensions, using DB image if available
-export function generateProgramImageUrl(
- id: string,
- width: number = 600,
- height: number = 400,
- dbImageUrl?: string | null,
-): string {
- if (dbImageUrl) return dbImageUrl;
- return `https://picsum.photos/seed/${id}/${width}/${height}`;
-}
-
-export function isClassProgram(program: Program): program is ClassPlanProgram {
- return program.type === "class";
-}
-
-// Client-side filtering for search term and level only.
-// Sort is handled server-side via API params — no need to re-sort here.
-export function filterAndSortPrograms(
- programs: Program[],
- searchTerm: string,
- selectedLevel: string,
-): Program[] {
- return programs.filter((program) => {
- const matchesSearch =
- !searchTerm ||
- program.title.toLowerCase().includes(searchTerm.toLowerCase());
- const matchesLevel =
- selectedLevel === "all" || program.level === selectedLevel;
- return matchesSearch && matchesLevel;
- });
-}
-
-export function getUniqueLevels(programs: Program[]): string[] {
- const levels = programs
- .map((program) => program.level)
- .filter((level): level is string => level !== null);
- return Array.from(new Set(levels));
-}
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..94f5d8afd 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}`,
),
]);
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/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..3d6658e0d 100644
--- a/hooks/useOrgScope.ts
+++ b/hooks/useOrgScope.ts
@@ -66,10 +66,15 @@ 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.
+ * - "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 always default to "all" regardless of this option.
* B2C users (no orgs) always default to "personal".
*/
- defaultForOrgMember?: "first-org" | "all";
+ defaultForOrgMember?: "first-org" | "all" | "personal";
}
export function useOrgScope(
@@ -100,6 +105,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/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/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..ae9a19c27 100644
--- a/lib/stream-utils.ts
+++ b/lib/stream-utils.ts
@@ -1,8 +1,70 @@
+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);
+}
+
+function assertFits(channelId: string): string {
+ if (channelId.length > STREAM_CHANNEL_ID_MAX) {
+ throw new Error(
+ `Stream channel id exceeds ${STREAM_CHANNEL_ID_MAX} chars (${channelId.length}): ${channelId}`,
+ );
+ }
+ return channelId;
+}
+
+/**
+ * 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 assertFits(`dm-${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.
+ return assertFits(
+ `dmo-${digest(organizationId, 8)}-${digest(`${a}-${b}`, 16)}`,
+ );
}
diff --git a/scripts/stream/backfill-channel-org.ts b/scripts/stream/backfill-channel-org.ts
index 1e1ae59c8..c2da5507e 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;
@@ -121,7 +123,13 @@ async function resolveChannelTarget(appointment: {
if (!consultantId || !consulteeId) return null;
return {
channelType: "messaging",
- channelId: getDmChannelId(consultantId, consulteeId),
+ // The DM key carries the funding context, and this backfill is walking
+ // appointments — so the org is the appointment's own, not a guess.
+ channelId: getDmChannelId(
+ consultantId,
+ consulteeId,
+ appointment.organizationId,
+ ),
};
}
case "SUBSCRIPTION": {
@@ -143,7 +151,13 @@ async function resolveChannelTarget(appointment: {
if (!consultantId || !consulteeId) return null;
return {
channelType: "messaging",
- channelId: getDmChannelId(consultantId, consulteeId),
+ // The DM key carries the funding context, and this backfill is walking
+ // appointments — so the org is the appointment's own, not a guess.
+ channelId: getDmChannelId(
+ consultantId,
+ consulteeId,
+ 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..1dd154b38
--- /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. The card re-exports it so route-local imports are
+ * unchanged and there is still exactly one definition.
+ */
+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;
+}
From 37eea26ed86ca69c5a10f9e0e8c15c742b175773 Mon Sep 17 00:00:00 2001
From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com>
Date: Tue, 28 Jul 2026 00:06:00 +0530
Subject: [PATCH 2/6] =?UTF-8?q?refactor(layering):=20app/=20is=20the=20rou?=
=?UTF-8?q?ting=20layer=20=E2=80=94=20enforce=20it?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reusing the slot-allocation UI in the org tree meant `components/` importing
from `app/dashboard/consultant/[consultantId]/(features)/shared/` — backwards,
and through a dynamic route segment. Fixing that one import surfaced that the
pattern was already established, so this closes the class and adds the rule
that stops it coming back.
Moved out of app/
-------------------
`lib/data/explore-programs.ts` imported live FUNCTIONS from
`app/explore/programs/utils` — the only remaining runtime dependency pointing
the wrong way. Nothing in that module is routing: pagination constants, program
shapes, an image-URL builder. It is now `lib/explore/programs.ts`.
Repointed the 19 real importers by RESOLVED PATH rather than by matching
`../utils`, after a first pass caught `app/explore/experts/`, which has its own
utils module and was briefly rewritten to import the wrong one.
Response shapes derive from Zod
---------------------------------
`AppointmentSearchResult` and `ConsulteeSearchResult` were `export type`
declarations inside route handlers, imported by `components/chat/*`. They move
to `schemas/stream-search.ts` as Zod schemas, and both handlers now PARSE their
results against them on the way out.
That is the part worth having: the components derive their types from the same
definitions the handlers validate against, 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 a
dropdown — the same drift that put ₹NaN on two money tables in #1029.
No re-export shims. The first pass left `export type { X }` behind in the old
locations so existing importers kept working; those are deleted and the
importers point at the real definition. It turned out nothing imported
`RecordingData` from the recording card at all, so that shim was pure weight.
Enforced, not remembered
--------------------------
`no-restricted-imports` bans `@/app/*` from lib, components, hooks, types and
schemas, with a message that says what to do instead. Verified it fires on a
deliberate violation and that the tree is clean under it, so it lands green.
This regressed silently more than once before it was enforced, which is fair
evidence that review alone was not catching it.
Also fixes a `"use client"` directive that an earlier edit in this branch pushed
off line 1 — it has to be the first statement or the client boundary is silently
lost.
Part of #1021
Co-Authored-By: Claude Opus 5
---
.../channels/search-appointments/route.ts | 30 ++--
app/api/stream/search-consultees/route.ts | 18 ++-
.../recordings/components/RecordingCard.tsx | 23 +--
.../programs/ProgramsInteractiveContent.tsx | 2 +-
.../programs/components/AdvancedFilters.tsx | 2 +-
.../programs/components/CategoryGrid.tsx | 2 +-
.../programs/components/FeaturedCarousel.tsx | 2 +-
.../programs/components/ProgramCard.tsx | 2 +-
.../programs/components/ProgramResults.tsx | 2 +-
.../programs/components/ProgramRow.tsx | 2 +-
.../programs/components/ProgramTabs.tsx | 2 +-
.../programs/components/StaticTopRows.tsx | 2 +-
app/explore/programs/hooks/_helpers.ts | 2 +-
.../programs/hooks/useCuratedPrograms.ts | 2 +-
.../programs/hooks/useProgramFilterChips.ts | 2 +-
app/explore/programs/hooks/usePrograms.ts | 2 +-
.../programs/hooks/useProgramsFilters.ts | 2 +-
.../programs/hooks/useTopicsWithCount.ts | 2 +-
.../[classPlanId]/components/ClassDetails.tsx | 2 +-
.../components/ClientClassRegistration.tsx | 2 +-
.../plans/classes/[classPlanId]/page.tsx | 2 +-
.../components/WebinarDetails.tsx | 2 +-
components/chat/AddMembersDialog.tsx | 2 +-
components/chat/ChannelSearch.tsx | 2 +-
eslint.config.mjs | 40 +++++
lib/data/explore-programs.ts | 4 +-
lib/explore/programs.ts | 149 ++++++++++++++++++
schemas/stream-search.ts | 49 ++++++
28 files changed, 294 insertions(+), 61 deletions(-)
create mode 100644 lib/explore/programs.ts
create mode 100644 schemas/stream-search.ts
diff --git a/app/api/stream/channels/search-appointments/route.ts b/app/api/stream/channels/search-appointments/route.ts
index 39433665e..c9a9087ab 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,13 @@ export async function GET(request: NextRequest) {
consultantImage:
consultation.consultationPlan.consultantProfile.user.image ||
undefined,
+ // Funding context is part of the DM key now, so a search hit on an
+ // org-funded session must resolve to that org's thread rather than the
+ // pair's personal one.
channelId: getDmChannelId(
consultation.consultationPlan.consultantProfile.user.id,
consultation.requestedBy.user.id,
+ consultation.appointment?.organizationId ?? null,
),
});
}
@@ -220,6 +222,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,
});
@@ -238,6 +243,7 @@ export async function GET(request: NextRequest) {
channelId: getDmChannelId(
subscription.subscriptionPlan.consultantProfile.user.id,
subscription.requestedBy.user.id,
+ subscription.appointments?.[0]?.organizationId ?? null,
),
});
}
@@ -412,7 +418,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)/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/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/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/eslint.config.mjs b/eslint.config.mjs
index 4aacd5c2e..c4ae02050 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -132,4 +132,44 @@ 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: [
+ {
+ group: ["@/app/*", "@/app/**"],
+ 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/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/lib/explore/programs.ts b/lib/explore/programs.ts
new file mode 100644
index 000000000..947eb049a
--- /dev/null
+++ b/lib/explore/programs.ts
@@ -0,0 +1,149 @@
+/**
+ * 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,
+} from "@prisma/client";
+
+export const ITEMS_PER_PAGE = 12;
+
+export type ProgramType = "all" | "class" | "webinar";
+
+// Type for registration data from API
+interface SlotUser {
+ id: string;
+}
+
+interface SlotWithUser {
+ user?: SlotUser[];
+}
+
+interface WebinarWithAppointment {
+ appointment?: {
+ slotsOfAppointment?: SlotWithUser[];
+ } | null;
+}
+
+interface ClassSlot extends Record {
+ user?: SlotUser[];
+}
+
+interface ClassAppointment {
+ slotsOfAppointment: ClassSlot[];
+}
+
+export interface ClassInstance {
+ id: string;
+ schedulingPeriodStartsAt?: string | Date | null;
+ appointments?: ClassAppointment[];
+}
+
+type ProgramConsultantProfile = {
+ rating?: number;
+ headline?: string | null;
+ user?: {
+ name?: string | null;
+ image?: string | null;
+ workExperiences?: Array<{
+ company: string;
+ companyDomain: string | null;
+ isCurrent: boolean;
+ }>;
+ };
+};
+
+type ProgramCollaborator = {
+ consultantProfile?: ProgramConsultantProfile | null;
+};
+
+// #780 — price reaches here as number (extended-client read → JSON), never bigint
+export type ClassPlanProgram = Omit & {
+ price: number;
+ classes: ClassInstance[];
+ type: "class";
+ imageUrl: string;
+ isRegistered?: boolean;
+ consultantProfile?: ProgramConsultantProfile | null;
+ collaborators?: ProgramCollaborator[];
+};
+
+export type WebinarPlanProgram = Omit & {
+ price: number;
+ webinars?: WebinarWithAppointment[];
+ type: "webinar";
+ imageUrl: string;
+ isRegistered?: boolean;
+ consultantProfile?: ProgramConsultantProfile | null;
+ collaborators?: ProgramCollaborator[];
+};
+
+export type Program = ClassPlanProgram | WebinarPlanProgram;
+
+export interface ApiMeta {
+ page: number;
+ limit: number;
+ total: number;
+ totalPages: number;
+}
+
+export interface TopicWithCount {
+ id: string;
+ name: string;
+ programCount: number;
+}
+
+export interface ProgramFilters {
+ topicIds?: string[];
+ language?: string;
+ domainId?: string;
+ sort?: string;
+ minPrice?: number;
+ maxPrice?: number;
+ search?: string;
+}
+
+// Generate program image URL based on ID with dimensions, using DB image if available
+export function generateProgramImageUrl(
+ id: string,
+ width: number = 600,
+ height: number = 400,
+ dbImageUrl?: string | null,
+): string {
+ if (dbImageUrl) return dbImageUrl;
+ return `https://picsum.photos/seed/${id}/${width}/${height}`;
+}
+
+export function isClassProgram(program: Program): program is ClassPlanProgram {
+ return program.type === "class";
+}
+
+// Client-side filtering for search term and level only.
+// Sort is handled server-side via API params — no need to re-sort here.
+export function filterAndSortPrograms(
+ programs: Program[],
+ searchTerm: string,
+ selectedLevel: string,
+): Program[] {
+ return programs.filter((program) => {
+ const matchesSearch =
+ !searchTerm ||
+ program.title.toLowerCase().includes(searchTerm.toLowerCase());
+ const matchesLevel =
+ selectedLevel === "all" || program.level === selectedLevel;
+ return matchesSearch && matchesLevel;
+ });
+}
+
+export function getUniqueLevels(programs: Program[]): string[] {
+ const levels = programs
+ .map((program) => program.level)
+ .filter((level): level is string => level !== null);
+ return Array.from(new Set(levels));
+}
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;
From a698712fbe5793db11620f625f176860623c60f2 Mon Sep 17 00:00:00 2001
From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com>
Date: Tue, 28 Jul 2026 00:06:35 +0530
Subject: [PATCH 3/6] chore(git): ignore agent worktrees and generated screen
artefacts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A `git add -A` on this branch swept 22 files that were never part of it:
`screens/` (generated HTML inventory), `prompts/`, and — worst — four
`.claude/worktrees/` entries, which are other agents' live checkouts. Those were
removed from the two commits rather than left in and reverted, so the PR diff
shows only the work.
Ignoring them so the mistake is not available to make again.
Co-Authored-By: Claude Opus 5
---
.gitignore | 8 ++++++++
1 file changed, 8 insertions(+)
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/
From 90695f0731c776067fa969a43b90d368d9705773 Mon Sep 17 00:00:00 2001
From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com>
Date: Tue, 28 Jul 2026 00:26:56 +0530
Subject: [PATCH 4/6] feat(org): let members act on their own org sessions, not
just join them
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The org appointment card offered exactly one action — Join, and only inside the
join window. It named your counterpart and gave you no way to reach them,
reschedule, cancel, or hand over a document, for a session the organization
paid for. Everything except joining meant switching to the personal dashboard.
ADR 19 puts org-funded work in the org dashboard; the actions belong there too.
Adds `/dashboard/organization/[orgId]/appointments/[appointmentId]`, reachable
from a Details button on each card.
Reused, not rebuilt
---------------------
The page mounts the same `AppointmentDetailClient` and consultee adapter the
B2C tree uses, so reschedule, cancel, report and document upload behave
identically in both places rather than being a second implementation that
drifts. Only `detailHref` is overridden, so navigating inside the detail view
keeps the member in the org context instead of bouncing them to
`/dashboard/consultee/...`.
`role="consultee"` because this is the ATTENDING side. An EXPERT delivering org
sessions works from Requests; the two roles want different actions on the same
row, and conflating them behind one page is how the appointments surfaces
drifted in the first place.
Both ids are bound
--------------------
`orgId` and `appointmentId` both come from the URL and neither constrains the
other, so membership alone would let any member of any org read any org-funded
appointment by pairing their own org id with a foreign appointment id. The page
asks three separate questions: is the caller in this org, does the appointment
belong to THIS org, and is the caller a party to it. `notFound()` on each —
a redirect would confirm the appointment exists to someone who should not know
that.
Participation rather than `operations.read`, because the page renders documents
and offers reschedule and cancel. An operator's view stays the metadata-only
list; an OWNER who is not on the session gets a 404 here, per ADR 20.
More code out of route folders
--------------------------------
The consultee adapter and its four leaf dialogs move to
`components/appointments/consultee/`, and `document-utils` to `lib/documents/`.
Both were already shared in practice — the consultant adapter was importing the
reschedule and cancel dialogs across dashboard trees via a `@/app/dashboard/
consultee/...` path, and three consultant surfaces import document-utils.
The lint rule added earlier in this branch is what caught the last one: moving
`DocumentUpload` into `components/` made its import of
`app/dashboard/shared/utils/document-utils` an error rather than something to
notice later. That is the rule doing its job on its author.
No cross-tree `app/ → app/` imports remain between the consultant and consultee
dashboards either.
6 tests pin the ownership checks.
Part of #1021
Co-Authored-By: Claude Opus 5
---
.../documents/revision-threading.test.ts | 5 +-
.../org-appointment-detail-ownership.test.ts | 70 ++++++++++++++++++
.../ConsultantAppointmentsAdapter.tsx | 4 +-
.../documents/ConsultantResponseUpload.tsx | 2 +-
.../(features)/documents/DocumentsTab.tsx | 2 +-
.../components/PlanMaterialsUpload.tsx | 2 +-
.../appointments/AppointmentsPageClient.tsx | 2 +-
.../[appointmentId]/DetailPageClient.tsx | 4 +-
.../appointments/MyAppointmentsClient.tsx | 11 ++-
.../[appointmentId]/DetailPageClient.tsx | 72 +++++++++++++++++++
.../appointments/[appointmentId]/page.tsx | 71 ++++++++++++++++++
.../appointments/DocumentUpload.tsx | 2 +-
.../consultee}/CancelConfirmationDialog.tsx | 0
.../ConsulteeAppointmentsAdapter.tsx | 10 +--
.../consultee}/ReportIssueDialog.tsx | 0
.../consultee}/RescheduleSessionsModal.tsx | 0
.../consultee}/useEventActions.ts | 0
.../utils => lib/documents}/document-utils.ts | 0
18 files changed, 241 insertions(+), 16 deletions(-)
create mode 100644 __tests__/security/org-appointment-detail-ownership.test.ts
create mode 100644 app/dashboard/organization/[orgId]/appointments/[appointmentId]/DetailPageClient.tsx
create mode 100644 app/dashboard/organization/[orgId]/appointments/[appointmentId]/page.tsx
rename {app/dashboard/consultee/[consulteeId]/(features) => components}/appointments/DocumentUpload.tsx (99%)
rename {app/dashboard/consultee/[consulteeId]/(features)/appointments => components/appointments/consultee}/CancelConfirmationDialog.tsx (100%)
rename {app/dashboard/consultee/[consulteeId]/(features)/appointments => components/appointments/consultee}/ConsulteeAppointmentsAdapter.tsx (96%)
rename {app/dashboard/consultee/[consulteeId]/(features)/appointments => components/appointments/consultee}/ReportIssueDialog.tsx (100%)
rename {app/dashboard/consultee/[consulteeId]/(features)/appointments/components => components/appointments/consultee}/RescheduleSessionsModal.tsx (100%)
rename {app/dashboard/consultee/[consulteeId]/(features)/appointments/components => components/appointments/consultee}/useEventActions.ts (100%)
rename {app/dashboard/shared/utils => lib/documents}/document-utils.ts (100%)
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__/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/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx
index 74bcf8700..caf05681e 100644
--- a/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx
+++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx
@@ -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)/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)/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/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/MyAppointmentsClient.tsx b/app/dashboard/organization/[orgId]/appointments/MyAppointmentsClient.tsx
index 13921ab1d..ccbe27e13 100644
--- a/app/dashboard/organization/[orgId]/appointments/MyAppointmentsClient.tsx
+++ b/app/dashboard/organization/[orgId]/appointments/MyAppointmentsClient.tsx
@@ -271,7 +271,16 @@ export function MyAppointmentsClient({