Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
4 changes: 2 additions & 2 deletions __tests__/booking-algorithm/allocationAlgorithms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
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 {

Check failure on line 23 in __tests__/booking-algorithm/allocationAlgorithms.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Mocks should not be manually imported from a __mocks__ directory. Instead use `jest.mock` and import from the original module path
makeTimeSlot,
makeConsecutiveTimeSlots,
makeWeekOfAvailability,
Expand Down Expand Up @@ -398,8 +398,8 @@

if (result.success) {
// Should have distributed across weeks
expect(result.selectedSlots.length).toBeGreaterThan(0);

Check failure on line 401 in __tests__/booking-algorithm/allocationAlgorithms.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Avoid calling `expect` conditionally`
expect(result.strategy).toBe("optimal-distribution");

Check failure on line 402 in __tests__/booking-algorithm/allocationAlgorithms.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Avoid calling `expect` conditionally`
}
});

Expand Down Expand Up @@ -533,8 +533,8 @@
// Morning preference should only include 9-12 slots
result.selectedSlots.forEach((s) => {
const hour = s.startTime.getHours();
expect(hour).toBeGreaterThanOrEqual(9);

Check failure on line 536 in __tests__/booking-algorithm/allocationAlgorithms.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Avoid calling `expect` conditionally`
expect(hour).toBeLessThan(12);

Check failure on line 537 in __tests__/booking-algorithm/allocationAlgorithms.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Avoid calling `expect` conditionally`
});
}
});
Expand Down
2 changes: 1 addition & 1 deletion __tests__/booking-algorithm/calendarUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@
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 {

Check failure on line 43 in __tests__/booking-algorithm/calendarUtils.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Mocks should not be manually imported from a __mocks__ directory. Instead use `jest.mock` and import from the original module path
makeTimeSlot,
makeConsecutiveTimeSlots,
makeWeeklyAvailabilitySlot,
Expand Down Expand Up @@ -97,7 +97,7 @@
const slots = mapWeeklySlots(data as any, new Date("2025-01-06"), "week");
const mondaySlots = slots.filter((s) => s.startTime.getUTCDay() === 1);
if (mondaySlots.length > 0) {
expect(mondaySlots[0].startTime.getUTCHours()).toBe(14);

Check failure on line 100 in __tests__/booking-algorithm/calendarUtils.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Avoid calling `expect` conditionally`
}
});

Expand Down
4 changes: 2 additions & 2 deletions __tests__/booking-algorithm/idempotency-key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions __tests__/booking-algorithm/mode-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
4 changes: 2 additions & 2 deletions __tests__/booking-algorithm/slot-boundary-bucketing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions __tests__/booking-algorithm/toast-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions __tests__/dashboard/nav-targets-resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion __tests__/documents/revision-threading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
22 changes: 19 additions & 3 deletions __tests__/enterprise/list-appointments-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,29 @@ describe("buildWhere — personal scope (#org-appts)", () => {
expect(hasConsulteeUser).toBe(true);
});

it("org scope filters by orgId with no user OR", () => {
it("org scope covers events the org HOSTS or FUNDED, and never filters by user", () => {
const w = buildWhere({
scope: { kind: "org", orgId: "org1" },
userId: "u1",
}) as Record<string, unknown>;
expect(w.organizationId).toBe("org1");
expect(w.OR).toBeUndefined();

// Two columns, two questions. A group event shares ONE Appointment across
// every registrant and checkout tags it with the HOST's org, so filtering
// on `organizationId` alone hid every webinar a sponsor had paid into.
// Per-registrant funding lives on Payment.organizationId.
const or = w.OR as Record<string, unknown>[];
expect(or).toHaveLength(2);
expect(or).toContainEqual({ organizationId: "org1" });
expect(or).toContainEqual({
payment: { some: { organizationId: "org1" } },
});

// The property the previous version of this test was really protecting:
// the org arm carries NO user filter, which is why it requires
// `operations.read` and why a non-operator is downgraded to `orgMember`.
// Widening to hosted-or-funded must not have smuggled one in.
expect(JSON.stringify(w)).not.toContain('"u1"');
expect(JSON.stringify(w)).not.toContain("userId");
});

it("orgMember scope pins organizationId AND filters to the user's participation (#org-appts)", () => {
Expand Down
113 changes: 113 additions & 0 deletions __tests__/security/dm-channel-org-precedence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* @jest-environment node
*/

/**
* Four call sites compute a DM channel id, and every one of them has to agree
* with the creators in `actions/stream/chat/channel.action.ts` — they are
* recomputing an id those creators already used, so any divergence points at a
* channel that does not exist.
*
* The creators resolve context as:
*
* plan.organizationId ?? appointment.organizationId ?? null
*
* Two genuinely distinct cases sit behind that `??`: a plan can be org-HOSTED
* while the booking is self-funded, and a personal plan can be booked through an
* org-funded membership. Review found three consumers reading only the
* appointment, which treated every org-hosted-plan booking as personal. The
* reconcile set then looked for `dm-<a>-<b>` while the real channel was `dmo-…`
* — never re-joined at best, and at worst treated as stale so the user was
* removed from their own conversation.
*
* These assertions are source-level because the point is which expression each
* site uses, not what a mocked Prisma row would return.
*/

import { readFileSync } from "fs";
import { join } from "path";

import { getDmChannelId, STREAM_CHANNEL_ID_MAX } from "@/lib/stream-utils";

const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8");

describe("every consumer matches the creators' precedence", () => {
it("the creators put plan org first", () => {
const src = read("actions/stream/chat/channel.action.ts");
expect(src).toContain(
"consultation.consultationPlan.organizationId ??\n consultation.appointment?.organizationId ??",
);
});
Comment on lines +35 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exact-string precedence assertion is brittle against reformatting.

Matching a literal multi-line substring (with exact indentation/line breaks) means an unrelated Prettier/formatting pass on channel.action.ts could fail this test with no functional regression. Consider normalizing whitespace before comparing, or matching on a formatting-agnostic pattern (e.g. a regex ignoring whitespace/newlines), while keeping the source-level intent the docstring describes.

♻️ Example: whitespace-agnostic match
-    expect(src).toContain(
-      "consultation.consultationPlan.organizationId ??\n        consultation.appointment?.organizationId ??",
-    );
+    const normalized = src.replace(/\s+/g, " ");
+    expect(normalized).toContain(
+      "consultation.consultationPlan.organizationId ?? consultation.appointment?.organizationId ??",
+    );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("the creators put plan org first", () => {
const src = read("actions/stream/chat/channel.action.ts");
expect(src).toContain(
"consultation.consultationPlan.organizationId ??\n consultation.appointment?.organizationId ??",
);
});
it("the creators put plan org first", () => {
const src = read("actions/stream/chat/channel.action.ts");
const normalized = src.replace(/\s+/g, " ");
expect(normalized).toContain(
"consultation.consultationPlan.organizationId ?? consultation.appointment?.organizationId ??",
);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@__tests__/security/dm-channel-org-precedence.test.ts` around lines 35 - 40,
Update the source assertion in “the creators put plan org first” to avoid
matching exact indentation and line breaks. Normalize whitespace or use a
whitespace-agnostic pattern while still asserting that
consultation.consultationPlan.organizationId precedes
consultation.appointment?.organizationId in channel.action.ts.


it.each([
["reconcile", "actions/stream/chat/event-channel.action.ts", "consultationPlan?.organizationId"],
["search", "app/api/stream/channels/search-appointments/route.ts", "consultationPlan.organizationId ??"],
["backfill", "scripts/stream/backfill-channel-org.ts", "consultationPlan?.organizationId ??"],
])("%s reads the plan org before the appointment's", (_label, rel, needle) => {
expect(read(rel)).toContain(needle);
});

it.each([
["reconcile", "actions/stream/chat/event-channel.action.ts"],
["search", "app/api/stream/channels/search-appointments/route.ts"],
["backfill", "scripts/stream/backfill-channel-org.ts"],
])("%s loads the plan org it now depends on", (_label, rel) => {
// A precedence that reads a field the query never selected is silently
// `undefined`, which falls through to the appointment and reintroduces the
// bug without failing anything.
expect(read(rel)).toContain("organizationId: true");
});
});

describe("channel ids stay inside Stream's cap without throwing", () => {
const CUID_A = "cmqb1757m005stxyoe218odf1";
const CUID_B = "cmqb190qa00hotxyor367yjz1";
const UUID_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const UUID_B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";

it("leaves ordinary personal ids byte-identical, so no channel moves", () => {
expect(getDmChannelId(CUID_A, CUID_B)).toBe(`dm-${CUID_A}-${CUID_B}`);
});

it("is order-independent", () => {
expect(getDmChannelId(CUID_B, CUID_A)).toBe(getDmChannelId(CUID_A, CUID_B));
});

it("degrades legacy uuid pairs instead of throwing", () => {
// `dm-<36>-<36>` is 76 chars. Throwing here rejected the whole
// reconciliation — `getDmPairsForUser` and the `expectedChannelIds` map are
// not wrapped per-item — so one legacy account broke channel sync for
// everyone paired with it.
expect(`dm-${UUID_A}-${UUID_B}`.length).toBeGreaterThan(
STREAM_CHANNEL_ID_MAX,
);

const id = getDmChannelId(UUID_A, UUID_B);
expect(id.startsWith("dmh-")).toBe(true);
expect(id.length).toBeLessThanOrEqual(STREAM_CHANNEL_ID_MAX);
// Deterministic, or the fallback would strand the conversation.
expect(getDmChannelId(UUID_B, UUID_A)).toBe(id);
});

it("keeps the three namespaces distinct", () => {
const personal = getDmChannelId(CUID_A, CUID_B);
const org = getDmChannelId(CUID_A, CUID_B, "org-1");
const legacy = getDmChannelId(UUID_A, UUID_B);

expect(new Set([personal, org, legacy]).size).toBe(3);
expect(org.startsWith("dmo-")).toBe(true);
});

it("separates two orgs for the same pair, with room to spare", () => {
const a = getDmChannelId(CUID_A, CUID_B, "org-1");
const b = getDmChannelId(CUID_A, CUID_B, "org-2");
expect(a).not.toBe(b);

// The org segment is the ONLY differentiator between two orgs' otherwise
// identical pair digest, so a collision would merge two organizations' DM
// threads. 8 hex chars was 32 bits; this is 64.
const orgSegment = a.split("-")[1];
expect(orgSegment).toHaveLength(16);
expect(a.length).toBeLessThanOrEqual(STREAM_CHANNEL_ID_MAX);
});
});
70 changes: 70 additions & 0 deletions __tests__/security/org-appointment-detail-ownership.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* The org appointment detail page takes BOTH ids from the URL, and neither
* constrains the other: `/dashboard/organization/<orgId>/appointments/<apptId>`
* 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"');
});
});
Loading
Loading