From 2051fcd5701e606c13cfd5d2555cc24dda832b61 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:05:27 +0530 Subject: [PATCH 1/6] feat(org): give org members their own Messages and Requests surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 19 splits dashboards by the org-ness of the underlying work, but the participant surfaces were never split — they existed only in the personal trees. So an org member's conversations and their slot-allocation requests either vanished or leaked into their B2C dashboard depending on which default the scope hook happened to resolve. This builds the org half. DM channels are now keyed per context --------------------------------------- `getDmChannelId` keyed on the pair alone, which made a channel the RELATIONSHIP rather than the booking: the same two people had one thread however many sessions they booked and whoever funded them, carrying the org tag of whichever booking created it. That thread could not land in the right dashboard, because it belonged in two. personal `dm--` unchanged, byte for byte org `dmo--` 29 chars Personal ids are untouched so no existing conversation moves — and there was no room to extend them anyway. Stream caps ids at 64; a seeded cuid pair already produces 54 and the longest live channel measured 61, so appending a 25-char org id was never possible. The org form hashes both halves for that reason. Verified against the live Stream app that zero org-tagged channels existed, so nothing needed migrating. Also adds the length guard that was missing entirely: `channelIdSchema` asserted only `min(1)`, and two users on 36-char uuid ids — 17 accounts have them — would have produced a 76-char id that Stream rejects silently. Messages ---------- `/dashboard/organization/[orgId]/messages`. Nothing in it filters by org: `useOrgScope` is route-pinned under the org tree, so living at this path scopes the query to that org. A member of several orgs gets one clean inbox per org with no picker to set and nothing to get wrong — which was the merged inbox's real failure, since a conversation carries no visible org label. The personal inbox now pins to B2C explicitly (`defaultForOrgMember: "personal"`, a new option) instead of inheriting `first-org`, which hid a member's B2C threads behind whichever org happened to be first and hid a second org's entirely. Operators cannot read any of this. There is no org-wide chat query behind the page and Stream only returns channels the viewer belongs to; ADR 20 stands. Requests ---------- `/dashboard/organization/[orgId]/requests`, and this one had a cost. Slot allocation existed ONLY in the consultant tree — there is no allocation surface anywhere under `app/dashboard/organization/` — and it fetches the bookings endpoints without `orgScope`, which makes them drop org-funded rows. An org-sponsored subscription was therefore paid for and never scheduled: the request existed and no surface in the product would show it. Gated on the same pair as Compensation (`myArrangement.read` + `canHost`), with the page re-checking the membership's own consultantProfileId, so a mismatch degrades to a redirect rather than a visible tab that refuses to load. Dependency direction ---------------------- Reusing the allocation UI meant `components/` importing from `app/dashboard/consultant/[consultantId]/(features)/shared/` — through a dynamic route segment, and backwards: `app/` is the routing layer and should depend on `lib`, `components` and `hooks`, never the reverse. That folder was already shared in intent and merely parked in the wrong place, so it moves to where it belongs — `components/scheduling/`, `hooks/scheduling/`, `lib/scheduling/` — along with the two type modules `lib/dashboard-queries.ts` was reaching into route folders for. Every inversion this branch would have introduced is gone, and two that predated it as well. Four pre-existing ones remain and are untouched: two chat components importing API response types, and `lib/data/explore-programs.ts` importing a helper from `app/explore`. The last is a real inversion worth its own change. Also bumps `target` from es5 to es2017, the Next.js default. Next transpiles to its own browserslist regardless, so es5 only ever constrained the type checker — it is deprecated in TS 7.0 and was what forced iterator spread to be rewritten as `Array.from`. `baseUrl` stays: ~40 files import via bare specifiers and resolve only through it, so removing it is a separate mechanical sweep. Part of #1021 Co-Authored-By: Claude Opus 5 --- .../allocationAlgorithms.test.ts | 4 +- .../booking-algorithm/calendarUtils.test.ts | 2 +- .../booking-algorithm/idempotency-key.test.ts | 4 +- .../booking-algorithm/mode-parity.test.ts | 8 +- .../slot-boundary-bucketing.test.ts | 4 +- .../booking-algorithm/toast-queue.test.ts | 4 +- .../dashboard/nav-targets-resolve.test.ts | 4 + actions/stream/chat/channel.action.ts | 28 ++-- actions/stream/chat/event-channel.action.ts | 89 ++++++++--- .../ConsultantAppointmentsAdapter.tsx | 2 +- .../components/EventTimingsCalendar.tsx | 2 +- .../[eventType]/[eventId]/page.tsx | 2 +- .../(features)/home/HomeTab.tsx | 4 +- .../planner/components/EventCard.tsx | 2 +- .../planner/components/EventCarousel.tsx | 2 +- .../components/EventManagementDashboard.tsx | 2 +- .../planner/components/EventPlanner.tsx | 2 +- .../components/EventPlannerForClass.tsx | 2 +- .../EventPlannerForConsultation.tsx | 2 +- .../EventPlannerForSubscription.tsx | 2 +- .../components/EventPlannerForWebinar.tsx | 2 +- .../planner/services/events/class-service.ts | 2 +- .../services/events/webinar-service.ts | 2 +- .../(features)/planner/services/planner.ts | 2 +- .../services/plans/consultation-service.ts | 2 +- .../services/plans/subscription-service.ts | 2 +- .../(features)/requests/page.tsx | 2 +- .../(features)/trials/TrialsTab.tsx | 2 +- .../appointments/MyAppointmentsClient.tsx | 2 +- app/dashboard/organization/[orgId]/layout.tsx | 31 ++++ .../[orgId]/messages/MessagesClient.tsx | 42 ++++++ .../organization/[orgId]/messages/page.tsx | 57 +++++++ .../[orgId]/requests/RequestsClient.tsx | 39 +++++ .../organization/[orgId]/requests/page.tsx | 58 ++++++++ app/explore/programs/utils.ts | 140 ------------------ components/chat/ChatSidebar.tsx | 8 +- .../requests/RequestSlotAllocationTab.tsx | 34 ++++- .../requests/RequestSlotAllocationTabMini.tsx | 0 .../components/PaymentRequiredBadge.tsx | 0 .../components/RequestedSlotsDialog.tsx | 4 +- .../dashboard/shared}/requests/types.ts | 0 .../scheduling}/CalendarErrorBoundary.tsx | 0 .../scheduling}/SafeUnifiedCalendar.tsx | 0 .../scheduling}/UnifiedCalendar.tsx | 10 +- .../scheduling}/useCalendarData.ts | 2 +- .../scheduling}/useLazyJoinMeeting.ts | 0 .../scheduling}/useSlotAllocation.ts | 10 +- hooks/useOrgScope.ts | 12 +- lib/dashboard-queries.ts | 4 +- .../scheduling}/allocationAlgorithms.ts | 0 .../scheduling}/allocationMessages.ts | 0 .../scheduling}/allocationService.ts | 0 .../utils => lib/scheduling}/calendarUtils.ts | 0 .../scheduling}/slotSelectionValidation.ts | 0 lib/stream-utils.ts | 70 ++++++++- scripts/stream/backfill-channel-org.ts | 18 ++- tsconfig.json | 15 +- .../types/event.ts => types/planner-events.ts | 9 ++ types/recording.ts | 31 ++++ 59 files changed, 552 insertions(+), 231 deletions(-) create mode 100644 app/dashboard/organization/[orgId]/messages/MessagesClient.tsx create mode 100644 app/dashboard/organization/[orgId]/messages/page.tsx create mode 100644 app/dashboard/organization/[orgId]/requests/RequestsClient.tsx create mode 100644 app/dashboard/organization/[orgId]/requests/page.tsx delete mode 100644 app/explore/programs/utils.ts rename {app/dashboard/consultant/[consultantId]/(features) => components/dashboard/shared}/requests/RequestSlotAllocationTab.tsx (96%) rename {app/dashboard/consultant/[consultantId]/(features) => components/dashboard/shared}/requests/RequestSlotAllocationTabMini.tsx (100%) rename {app/dashboard/consultant/[consultantId]/(features) => components/dashboard/shared}/requests/components/PaymentRequiredBadge.tsx (100%) rename {app/dashboard/consultant/[consultantId]/(features) => components/dashboard/shared}/requests/components/RequestedSlotsDialog.tsx (99%) rename {app/dashboard/consultant/[consultantId]/(features) => components/dashboard/shared}/requests/types.ts (100%) rename {app/dashboard/consultant/[consultantId]/(features)/shared/components => components/scheduling}/CalendarErrorBoundary.tsx (100%) rename {app/dashboard/consultant/[consultantId]/(features)/shared/components => components/scheduling}/SafeUnifiedCalendar.tsx (100%) rename {app/dashboard/consultant/[consultantId]/(features)/shared/components => components/scheduling}/UnifiedCalendar.tsx (99%) rename {app/dashboard/consultant/[consultantId]/(features)/shared/hooks => hooks/scheduling}/useCalendarData.ts (99%) rename {app/dashboard/consultant/[consultantId]/(features)/shared/hooks => hooks/scheduling}/useLazyJoinMeeting.ts (100%) rename {app/dashboard/consultant/[consultantId]/(features)/shared/hooks => hooks/scheduling}/useSlotAllocation.ts (99%) rename {app/dashboard/consultant/[consultantId]/(features)/shared/utils => lib/scheduling}/allocationAlgorithms.ts (100%) rename {app/dashboard/consultant/[consultantId]/(features)/shared/utils => lib/scheduling}/allocationMessages.ts (100%) rename {app/dashboard/consultant/[consultantId]/(features)/shared/utils => lib/scheduling}/allocationService.ts (100%) rename {app/dashboard/consultant/[consultantId]/(features)/shared/utils => lib/scheduling}/calendarUtils.ts (100%) rename {app/dashboard/consultant/[consultantId]/(features)/shared/utils => lib/scheduling}/slotSelectionValidation.ts (100%) rename app/dashboard/consultant/[consultantId]/(features)/planner/types/event.ts => types/planner-events.ts (94%) create mode 100644 types/recording.ts diff --git a/__tests__/booking-algorithm/allocationAlgorithms.test.ts b/__tests__/booking-algorithm/allocationAlgorithms.test.ts index 127e92c8c..8c6550592 100644 --- a/__tests__/booking-algorithm/allocationAlgorithms.test.ts +++ b/__tests__/booking-algorithm/allocationAlgorithms.test.ts @@ -18,8 +18,8 @@ import "./setup"; import { AllocationAlgorithms, type AllocationOptions, -} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationAlgorithms"; -import { AllocationService } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationService"; +} from "@/lib/scheduling/allocationAlgorithms"; +import { AllocationService } from "@/lib/scheduling/allocationService"; import { makeTimeSlot, makeConsecutiveTimeSlots, diff --git a/__tests__/booking-algorithm/calendarUtils.test.ts b/__tests__/booking-algorithm/calendarUtils.test.ts index 60ae07bdf..0f4f60f9f 100644 --- a/__tests__/booking-algorithm/calendarUtils.test.ts +++ b/__tests__/booking-algorithm/calendarUtils.test.ts @@ -38,7 +38,7 @@ import { getAppointmentUser, type TimeSlot, type Appointment, -} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/calendarUtils"; +} from "@/lib/scheduling/calendarUtils"; import { ScheduleType, DayOfWeek, AppointmentsType } from "@prisma/client"; import { makeTimeSlot, diff --git a/__tests__/booking-algorithm/idempotency-key.test.ts b/__tests__/booking-algorithm/idempotency-key.test.ts index 0a59c4833..3a9cb8e36 100644 --- a/__tests__/booking-algorithm/idempotency-key.test.ts +++ b/__tests__/booking-algorithm/idempotency-key.test.ts @@ -9,10 +9,10 @@ import "./setup"; import { computeAttemptFingerprint, resolveAttemptKey, -} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useSlotAllocation"; +} from "@/hooks/scheduling/useSlotAllocation"; // eslint-disable-next-line jest/no-mocks-import -- shared fixture builders, not module mocks (suite-wide pattern) import { makeConsecutiveTimeSlots } from "./__mocks__/booking.mockData"; -import type { TimeSlot } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/calendarUtils"; +import type { TimeSlot } from "@/lib/scheduling/calendarUtils"; const slots = makeConsecutiveTimeSlots( "2026-08-03T09:00:00.000Z", diff --git a/__tests__/booking-algorithm/mode-parity.test.ts b/__tests__/booking-algorithm/mode-parity.test.ts index 3f16d18b2..2fafb3f69 100644 --- a/__tests__/booking-algorithm/mode-parity.test.ts +++ b/__tests__/booking-algorithm/mode-parity.test.ts @@ -12,18 +12,18 @@ import "./setup"; import { AllocationAlgorithms, type AllocationOptions, -} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationAlgorithms"; -import { AllocationService } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationService"; +} from "@/lib/scheduling/allocationAlgorithms"; +import { AllocationService } from "@/lib/scheduling/allocationService"; import { validateEventSlots, getEventConstraints, getSlotLimits, groupSlotsByDay, -} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/slotSelectionValidation"; +} from "@/lib/scheduling/slotSelectionValidation"; import { validateSlotDistribution, type TimeSlot, -} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/calendarUtils"; +} from "@/lib/scheduling/calendarUtils"; // eslint-disable-next-line jest/no-mocks-import -- shared fixture builders, not module mocks (suite-wide pattern) import { makeConsecutiveTimeSlots } from "./__mocks__/booking.mockData"; diff --git a/__tests__/booking-algorithm/slot-boundary-bucketing.test.ts b/__tests__/booking-algorithm/slot-boundary-bucketing.test.ts index 7a2f783ad..fdaef0b7c 100644 --- a/__tests__/booking-algorithm/slot-boundary-bucketing.test.ts +++ b/__tests__/booking-algorithm/slot-boundary-bucketing.test.ts @@ -21,10 +21,10 @@ import { dayKey, weekKey, type SlotLimits, -} from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/slotSelectionValidation"; +} from "@/lib/scheduling/slotSelectionValidation"; // eslint-disable-next-line jest/no-mocks-import -- shared fixture builders, not module mocks (suite-wide pattern) import { makeConsecutiveTimeSlots } from "./__mocks__/booking.mockData"; -import type { TimeSlot } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/calendarUtils"; +import type { TimeSlot } from "@/lib/scheduling/calendarUtils"; const limits = (slotsPerSession: number, maxSlots: number): SlotLimits => ({ minSlots: maxSlots * slotsPerSession, diff --git a/__tests__/booking-algorithm/toast-queue.test.ts b/__tests__/booking-algorithm/toast-queue.test.ts index 605b31276..a4766eb1c 100644 --- a/__tests__/booking-algorithm/toast-queue.test.ts +++ b/__tests__/booking-algorithm/toast-queue.test.ts @@ -7,8 +7,8 @@ import "./setup"; -import { enqueueToast } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/hooks/useSlotAllocation"; -import type { AllocationToast } from "@/app/dashboard/consultant/[consultantId]/(features)/shared/utils/allocationMessages"; +import { enqueueToast } from "@/hooks/scheduling/useSlotAllocation"; +import type { AllocationToast } from "@/lib/scheduling/allocationMessages"; const msg = (title: string, description = "d"): AllocationToast => ({ title, diff --git a/__tests__/dashboard/nav-targets-resolve.test.ts b/__tests__/dashboard/nav-targets-resolve.test.ts index 38227e755..a1cbb0fd3 100644 --- a/__tests__/dashboard/nav-targets-resolve.test.ts +++ b/__tests__/dashboard/nav-targets-resolve.test.ts @@ -55,6 +55,10 @@ describe("org nav targets resolve", () => { "my-program", "compensation", "appointments", + // Participant surface, org-scoped by route — see the layout comment. + "messages", + // Delivery surface: slot allocation for org-funded bookings. + "requests", "members", "collaborations", "contracts", diff --git a/actions/stream/chat/channel.action.ts b/actions/stream/chat/channel.action.ts index 1a392c1ca..ddc40db76 100644 --- a/actions/stream/chat/channel.action.ts +++ b/actions/stream/chat/channel.action.ts @@ -161,18 +161,26 @@ export async function createChannel(input: { export async function createDirectMessageChannel( currentUserId: string, targetUserId: string, + /** + * Context this conversation belongs to. Omitted (or null) means personal — + * the channel then lives in the B2C dashboards and carries no org tag. Pass + * an org id to open the thread inside that organization instead; the two are + * separate channels by design (see getDmChannelId). + */ + organizationId?: string | null, ) { // Validate inputs memberIdSchema.parse(currentUserId); memberIdSchema.parse(targetUserId); - const channelId = getDmChannelId(currentUserId, targetUserId); + const channelId = getDmChannelId(currentUserId, targetUserId, organizationId); return createChannel({ channelType: "messaging", channelId, members: [currentUserId, targetUserId], createdById: currentUserId, + organizationId, }); } @@ -415,12 +423,13 @@ export async function createConsultationChannel( null : organizationId; - // DM channel is per consultant-consultee pair (not per event). - // Per-event IDs are not stored on the channel since multiple - // consultations/subscriptions between the same pair share one DM. + // One DM per pair PER CONTEXT. Still not per event — multiple + // consultations/subscriptions between the same pair in the same context share + // one thread — but a personal booking and an org-funded one no longer collide + // into a single channel that can only live in one dashboard (ADR 19). return createChannel({ channelType: "messaging", - channelId: getDmChannelId(consultantId, consulteeId), + channelId: getDmChannelId(consultantId, consulteeId, resolvedOrgId), members: [consultantId, consulteeId], createdById: consultantId, additionalData: { @@ -501,12 +510,13 @@ export async function createSubscriptionChannel( null : organizationId; - // DM channel is per consultant-consultee pair (not per event). - // Per-event IDs are not stored on the channel since multiple - // consultations/subscriptions between the same pair share one DM. + // One DM per pair PER CONTEXT. Still not per event — multiple + // consultations/subscriptions between the same pair in the same context share + // one thread — but a personal booking and an org-funded one no longer collide + // into a single channel that can only live in one dashboard (ADR 19). return createChannel({ channelType: "messaging", - channelId: getDmChannelId(consultantId, consulteeId), + channelId: getDmChannelId(consultantId, consulteeId, resolvedOrgId), members: [consultantId, consulteeId], createdById: consultantId, additionalData: { diff --git a/actions/stream/chat/event-channel.action.ts b/actions/stream/chat/event-channel.action.ts index 405ec9277..5e6c98758 100644 --- a/actions/stream/chat/event-channel.action.ts +++ b/actions/stream/chat/event-channel.action.ts @@ -556,8 +556,8 @@ export async function syncUserEventChannels( // Build the set of channel IDs this user is expected to be in const expectedChannelIds = new Set([ ...eventIds.map(({ type, id }) => getChannelId(type, id)), - ...dmPairs.map(({ consultantUserId, consulteeUserId }) => - getDmChannelId(consultantUserId, consulteeUserId), + ...dmPairs.map(({ consultantUserId, consulteeUserId, organizationId }) => + getDmChannelId(consultantUserId, consulteeUserId, organizationId), ), ]); @@ -583,7 +583,9 @@ export async function syncUserEventChannels( } } - // --- DM pair add-pass: join/create one channel per consultant-consultee pair --- + // --- DM add-pass: one channel per pair PER FUNDING CONTEXT --- + // A pair working both B2C and through an org now has two threads, and this + // pass joins the user to each. `dmPairs` is already keyed that way. for (let i = 0; i < dmPairs.length; i += BATCH_SIZE) { const batch = dmPairs.slice(i, i + BATCH_SIZE); const results = await Promise.allSettled( @@ -592,6 +594,7 @@ export async function syncUserEventChannels( pair.consultantUserId, pair.consulteeUserId, userId, + pair.organizationId, ), ), ); @@ -696,17 +699,40 @@ export async function syncUserEventChannels( /** * Get unique consultant-consultee DM pairs for a user, across consultations and subscriptions. */ +/** A DM the user should be a member of, in one specific funding context. */ +interface DmPair { + consultantUserId: string; + consulteeUserId: string; + /** null = personal (B2C). Part of the channel key — see getDmChannelId. */ + organizationId: string | null; +} + +/** + * The org a booking was funded by. Consultations carry one appointment, + * subscriptions many — but a subscription is funded once, so every appointment + * under it shares the org and the first is representative. + */ +function bookingOrgId(booking: { + appointment?: { organizationId: string | null } | null; + appointments?: { organizationId: string | null }[]; +}): string | null { + return ( + booking.appointment?.organizationId ?? + booking.appointments?.[0]?.organizationId ?? + null + ); +} + async function getDmPairsForUser( userId: string, user: { consultantProfileId: string | null; consulteeProfileId: string | null; }, -): Promise<{ consultantUserId: string; consulteeUserId: string }[]> { - const pairMap = new Map< - string, - { consultantUserId: string; consulteeUserId: string } - >(); +): Promise { + // Keyed by channel id, so a pair working in two contexts yields two entries + // rather than one overwriting the other. + const pairMap = new Map(); if (user.consultantProfileId) { const [consultations, subscriptions] = await Promise.all([ @@ -717,6 +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({

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