From 16e5f553357f6d695b7c263e5900300fb27a5798 Mon Sep 17 00:00:00 2001 From: Berny-ft Date: Sun, 16 Aug 2026 23:23:17 -0400 Subject: [PATCH 1/6] feat(coaching): add availability schemas and database table for coordinator availability - Introduced Zod schemas for validating availability slots and weekly availability. - Implemented a function to check for overlapping slots within a day. - Added a new database table for storing coordinator availability with appropriate types and constraints. --- app/coaching/schema.ts | 7 +++++++ lib/db/schema.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 app/coaching/schema.ts diff --git a/app/coaching/schema.ts b/app/coaching/schema.ts new file mode 100644 index 0000000..44fddff --- /dev/null +++ b/app/coaching/schema.ts @@ -0,0 +1,7 @@ +import {z} from 'zod'; + +export const availabilitySlotSchema = z.object({ + dayOfWeek: z.number().int().min(0).max(6), + time: z.string().regex(/^\d{2}:\d{2}$/, "Invalid time"), + durationMinutes: z.number().int().min(1).max(24 * 60), + }); \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index bc1190e..2239cd0 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -259,3 +259,15 @@ export const formQuestionAnswers = pgTable("form_question_answers", { createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); + +export const coordinatorAvailability = pgTable("coordinatory_availability", { + id: uuid("id").primaryKey().defaultRandom(), + coordinatorId: uuid("coordinator_id") + .references(() => profiles.id, { onDelete: "cascade" }) + .notNull(), + dayOfWeek: integer("day_of_week").notNull(), + time: text("time").notNull(), + durationMinutes: integer("duration_minutes").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); \ No newline at end of file From af05ee8548e1271e974562a6cb9527d459e1f19a Mon Sep 17 00:00:00 2001 From: Berny-ft Date: Sun, 16 Aug 2026 23:23:53 -0400 Subject: [PATCH 2/6] feat(coaching): enhance availability management with new schemas and database structure - Added Zod schemas for validating weekly availability and updating coordinator availability. - Implemented a function to check for overlapping time slots within a day. - Updated the database schema to include a new structure for storing coordinator availability as JSON. --- app/coaching/schema.ts | 40 ++++++++++++++++++++++++++++++++++++++-- lib/db/schema.ts | 24 +++++++++++++++++++----- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/app/coaching/schema.ts b/app/coaching/schema.ts index 44fddff..bcc0c53 100644 --- a/app/coaching/schema.ts +++ b/app/coaching/schema.ts @@ -1,7 +1,43 @@ import {z} from 'zod'; export const availabilitySlotSchema = z.object({ - dayOfWeek: z.number().int().min(0).max(6), time: z.string().regex(/^\d{2}:\d{2}$/, "Invalid time"), durationMinutes: z.number().int().min(1).max(24 * 60), - }); \ No newline at end of file + }); + + export const weeklyAvailabilitySchema = z + .object({ + 0: z.array(availabilitySlotSchema), + 1: z.array(availabilitySlotSchema), + 2: z.array(availabilitySlotSchema), + 3: z.array(availabilitySlotSchema), + 4: z.array(availabilitySlotSchema), + 5: z.array(availabilitySlotSchema), + 6: z.array(availabilitySlotSchema), + }) + .refine((week) => !Object.values(week).some(daySlotsOverlap), { + message: "Slots on the same day must not overlap", + }); + + + export const updateCoordinatorAvailabilitySchema = z.object({ + coordinatorId: z.string().uuid(), + slots: weeklyAvailabilitySchema, + }); + + function daySlotsOverlap( + slots: z.infer[], + ): boolean { + const ranges = slots + .map((slot) => { + const [hours, minutes] = slot.time.split(":").map(Number); + const start = hours * 60 + minutes; + return { start, end: start + slot.durationMinutes }; + }) + .sort((a, b) => a.start - b.start); + + for (let i = 1; i < ranges.length; i++) { + if (ranges[i]!.start < ranges[i - 1]!.end) return true; + } + return false; + } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 2239cd0..5ab4730 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -260,14 +260,28 @@ export const formQuestionAnswers = pgTable("form_question_answers", { updatedAt: timestamp("updated_at").defaultNow().notNull(), }); -export const coordinatorAvailability = pgTable("coordinatory_availability", { +export type AvailabilitySlot = { + time: string; + durationMinutes: number; +}; + +export type CoordinatorWeeklyAvailability = { + 0: AvailabilitySlot[]; + 1: AvailabilitySlot[]; + 2: AvailabilitySlot[]; + 3: AvailabilitySlot[]; + 4: AvailabilitySlot[]; + 5: AvailabilitySlot[]; + 6: AvailabilitySlot[]; +}; + +export const coordinatorAvailability = pgTable("coordinator_availability", { id: uuid("id").primaryKey().defaultRandom(), coordinatorId: uuid("coordinator_id") .references(() => profiles.id, { onDelete: "cascade" }) - .notNull(), - dayOfWeek: integer("day_of_week").notNull(), - time: text("time").notNull(), - durationMinutes: integer("duration_minutes").notNull(), + .notNull() + .unique(), + slots: jsonb("slots").$type().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); \ No newline at end of file From 10e20abdc6bc3d8aa9b904cec11de7d7b70c64c2 Mon Sep 17 00:00:00 2001 From: Berny-ft Date: Sun, 16 Aug 2026 23:36:36 -0400 Subject: [PATCH 3/6] feat(coaching): implement coordinator availability update functionality - Added a new function to update coordinator availability with validation using Zod schema. - Implemented role-based access control to ensure only authorized users can edit availability. - Enhanced database interactions to handle conflicts when updating coordinator availability records. --- .../coordinator-availability-actions.test.ts | 55 ++++++++++++++++++ app/coaching/actions.ts | 57 ++++++++++++++++++- 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 __tests__/coordinator-availability-actions.test.ts diff --git a/__tests__/coordinator-availability-actions.test.ts b/__tests__/coordinator-availability-actions.test.ts new file mode 100644 index 0000000..0ca344d --- /dev/null +++ b/__tests__/coordinator-availability-actions.test.ts @@ -0,0 +1,55 @@ +/** + * @jest-environment node + */ +import { updateCoordinatorAvailability } from "@/app/coaching/actions"; + +const COORDINATOR_ID = "11111111-1111-1111-1111-111111111111"; + +const onConflictDoUpdate = jest.fn().mockResolvedValue(undefined); +const insertValues = jest.fn(() => ({ onConflictDoUpdate })); +const insert = jest.fn(() => ({ values: insertValues })) as jest.Mock; + +jest.mock("@/lib/db", () => ({ + db: { + insert: (...args: unknown[]) => insert(...args), + }, +})); + +const getUser = jest.fn(); +const getClaims = jest.fn(); +jest.mock("@/utils/supabase/server", () => ({ + createClient: async () => ({ auth: { getUser, getClaims } }), +})); + +const emptyWeek = { + 0: [], + 1: [], + 2: [], + 3: [], + 4: [], + 5: [], + 6: [], +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("updateCoordinatorAvailability", () => { + it("returns Unauthorized for a parent", async () => { + getUser.mockResolvedValue({ + data: { user: { id: COORDINATOR_ID } }, + }); + getClaims.mockResolvedValue({ + data: { claims: { user_role: "user" } }, + }); + + const result = await updateCoordinatorAvailability({ + coordinatorId: COORDINATOR_ID, + slots: emptyWeek, + }); + + expect(result).toEqual({ error: "Unauthorized" }); + expect(insert).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/app/coaching/actions.ts b/app/coaching/actions.ts index 623d11a..656e687 100644 --- a/app/coaching/actions.ts +++ b/app/coaching/actions.ts @@ -1,10 +1,17 @@ "use server"; +import { updateCoordinatorAvailabilitySchema } from "@/app/coaching/schema"; +import { ROLES } from "@/lib/roles" import { eq } from "drizzle-orm"; import { db } from "@/lib/db"; -import { coachingSessions, services } from "@/lib/db/schema"; +import { + coachingSessions, + coordinatorAvailability, + services, +} from "@/lib/db/schema"; import { createClient } from "@/utils/supabase/server"; +import { Schema } from "zod"; export type Availability = { start: string; end: string }; @@ -52,3 +59,51 @@ export async function submitAvailabilities({ return { coachingSessionId: row.id }; } + + +async function canEditCoordinatorAvailability( + coordinatorId: string, +): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return false; + + const { data } = await supabase.auth.getClaims(); + const role = data?.claims?.user_role; + + if (role === ROLES.ADMIN) return true; + if (role === ROLES.COORDINATOR && user.id === coordinatorId) return true; + return false; +} + +export type UpdateCoordinatorAvailabilityResult = + | { ok: true } + | { error: string }; + +export async function updateCoordinatorAvailability( + input: unknown, +): Promise { + const parsed = updateCoordinatorAvailabilitySchema.safeParse(input); + if (!parsed.success) { + return { + error: parsed.error.issues[0]?.message ?? "Invalid input", + }; + } + + const { coordinatorId, slots } = parsed.data; + if (!(await canEditCoordinatorAvailability(coordinatorId))) { + return { error: "Unauthorized" }; + } + + await db + .insert(coordinatorAvailability) + .values({ coordinatorId, slots }) + .onConflictDoUpdate({ + target: coordinatorAvailability.coordinatorId, + set: { slots, updatedAt: new Date() }, + }); + + return { ok: true }; +} \ No newline at end of file From b430572ba5cc2f19a539375ff56391ece6a112fd Mon Sep 17 00:00:00 2001 From: Berny-ft Date: Sun, 16 Aug 2026 23:42:24 -0400 Subject: [PATCH 4/6] feat(coaching): enhance coordinator availability update tests - Added tests to verify role-based access control for updating coordinator availability. - Implemented checks for unauthorized access for non-signed-in users and parents. - Ensured coordinators can only update their own slots and admins can update any coordinator's slots. - Added validation to reject overlapping time slots on the same day. --- .../coordinator-availability-actions.test.ts | 97 ++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/__tests__/coordinator-availability-actions.test.ts b/__tests__/coordinator-availability-actions.test.ts index 0ca344d..dc96f7d 100644 --- a/__tests__/coordinator-availability-actions.test.ts +++ b/__tests__/coordinator-availability-actions.test.ts @@ -4,6 +4,8 @@ import { updateCoordinatorAvailability } from "@/app/coaching/actions"; const COORDINATOR_ID = "11111111-1111-1111-1111-111111111111"; +const OTHER_COORDINATOR_ID = "22222222-2222-2222-2222-222222222222"; +const ADMIN_ID = "33333333-3333-3333-3333-333333333333"; const onConflictDoUpdate = jest.fn().mockResolvedValue(undefined); const insertValues = jest.fn(() => ({ onConflictDoUpdate })); @@ -36,6 +38,19 @@ beforeEach(() => { }); describe("updateCoordinatorAvailability", () => { + it("returns Unauthorized when not signed in", async () => { + getUser.mockResolvedValue({ data: { user: null } }); + getClaims.mockResolvedValue({ data: { claims: null } }); + + const result = await updateCoordinatorAvailability({ + coordinatorId: COORDINATOR_ID, + slots: emptyWeek, + }); + + expect(result).toEqual({ error: "Unauthorized" }); + expect(insert).not.toHaveBeenCalled(); + }); + it("returns Unauthorized for a parent", async () => { getUser.mockResolvedValue({ data: { user: { id: COORDINATOR_ID } }, @@ -52,4 +67,84 @@ describe("updateCoordinatorAvailability", () => { expect(result).toEqual({ error: "Unauthorized" }); expect(insert).not.toHaveBeenCalled(); }); -}); \ No newline at end of file + + it("lets a coordinator save their own slots", async () => { + getUser.mockResolvedValue({ + data: { user: { id: COORDINATOR_ID } }, + }); + getClaims.mockResolvedValue({ + data: { claims: { user_role: "coordinator" } }, + }); + + const result = await updateCoordinatorAvailability({ + coordinatorId: COORDINATOR_ID, + slots: emptyWeek, + }); + + expect(result).toEqual({ ok: true }); + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ coordinatorId: COORDINATOR_ID }), + ); + }); + + it("blocks a coordinator from saving another coordinator's slots", async () => { + getUser.mockResolvedValue({ + data: { user: { id: COORDINATOR_ID } }, + }); + getClaims.mockResolvedValue({ + data: { claims: { user_role: "coordinator" } }, + }); + + const result = await updateCoordinatorAvailability({ + coordinatorId: OTHER_COORDINATOR_ID, + slots: emptyWeek, + }); + + expect(result).toEqual({ error: "Unauthorized" }); + expect(insert).not.toHaveBeenCalled(); + }); + + it("lets an admin save any coordinator's slots", async () => { + getUser.mockResolvedValue({ + data: { user: { id: ADMIN_ID } }, + }); + getClaims.mockResolvedValue({ + data: { claims: { user_role: "admin" } }, + }); + + const result = await updateCoordinatorAvailability({ + coordinatorId: OTHER_COORDINATOR_ID, + slots: emptyWeek, + }); + + expect(result).toEqual({ ok: true }); + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ coordinatorId: OTHER_COORDINATOR_ID }), + ); + }); + + it("rejects overlapping slots on the same day", async () => { + getUser.mockResolvedValue({ + data: { user: { id: COORDINATOR_ID } }, + }); + getClaims.mockResolvedValue({ + data: { claims: { user_role: "coordinator" } }, + }); + + const result = await updateCoordinatorAvailability({ + coordinatorId: COORDINATOR_ID, + slots: { + ...emptyWeek, + 1: [ + { time: "10:00", durationMinutes: 60 }, + { time: "10:30", durationMinutes: 60 }, + ], + }, + }); + + expect(result).toEqual({ + error: "Slots on the same day must not overlap", + }); + expect(insert).not.toHaveBeenCalled(); + }); +}); From dbbb09d3050da1b153f6fb0cb285a112ed658e74 Mon Sep 17 00:00:00 2001 From: Berny-ft Date: Wed, 19 Aug 2026 17:38:33 -0400 Subject: [PATCH 5/6] feat(coaching): replace availability slots with weekly hours and overrides Introduces the Cal.com-style model (repeating weekly template plus per-date overrides), the range expander, and server actions with unit tests. Co-authored-by: Cursor --- __tests__/availability.test.ts | 108 ++++++++ .../coordinator-availability-actions.test.ts | 246 ++++++++++++++---- app/coaching/actions.ts | 156 +++++++++-- app/coaching/schema.ts | 161 +++++++++--- lib/availability.ts | 108 ++++++++ lib/db/schema.ts | 72 +++-- 6 files changed, 718 insertions(+), 133 deletions(-) create mode 100644 __tests__/availability.test.ts create mode 100644 lib/availability.ts diff --git a/__tests__/availability.test.ts b/__tests__/availability.test.ts new file mode 100644 index 0000000..e8ea734 --- /dev/null +++ b/__tests__/availability.test.ts @@ -0,0 +1,108 @@ +import { + availabilityForRange, + EMPTY_WEEKLY_HOURS, +} from "@/lib/availability"; +import type { CoordinatorWeeklyHours } from "@/lib/db/schema"; + +const mondayWeekly: CoordinatorWeeklyHours = { + ...EMPTY_WEEKLY_HOURS, + 1: [{ start: "09:00", end: "11:00", recurrence: "weekly" }], +}; + +describe("availabilityForRange", () => { + it("expands weekly hours onto matching weekdays", () => { + expect( + availabilityForRange({ + hours: mondayWeekly, + overrides: {}, + from: "2026-03-01", + to: "2026-03-07", + }), + ).toEqual([ + { + date: "2026-03-02", + start: "09:00", + end: "11:00", + source: "weekly", + }, + ]); + }); + + it("replaces weekly hours when an override exists for that date", () => { + expect( + availabilityForRange({ + hours: mondayWeekly, + overrides: { + "2026-03-02": [{ start: "13:00", end: "15:00" }], + }, + from: "2026-03-01", + to: "2026-03-07", + }), + ).toEqual([ + { + date: "2026-03-02", + start: "13:00", + end: "15:00", + source: "override", + }, + ]); + }); + + it("treats an empty override as a full day off", () => { + expect( + availabilityForRange({ + hours: mondayWeekly, + overrides: { "2026-03-02": [] }, + from: "2026-03-01", + to: "2026-03-07", + }), + ).toEqual([]); + }); + + it("skips biweekly windows on the off week", () => { + const hours: CoordinatorWeeklyHours = { + ...EMPTY_WEEKLY_HOURS, + 1: [ + { + start: "09:00", + end: "11:00", + recurrence: "biweekly", + anchorDate: "2026-03-02", + }, + ], + }; + + expect( + availabilityForRange({ + hours, + overrides: {}, + from: "2026-03-02", + to: "2026-03-16", + }), + ).toEqual([ + { + date: "2026-03-02", + start: "09:00", + end: "11:00", + source: "weekly", + }, + { + date: "2026-03-16", + start: "09:00", + end: "11:00", + source: "weekly", + }, + ]); + }); + + it("returns nothing when from is after to", () => { + expect( + availabilityForRange({ + hours: mondayWeekly, + overrides: {}, + from: "2026-03-07", + to: "2026-03-01", + }), + ).toEqual([]); + }); +}); diff --git a/__tests__/coordinator-availability-actions.test.ts b/__tests__/coordinator-availability-actions.test.ts index dc96f7d..ec41c34 100644 --- a/__tests__/coordinator-availability-actions.test.ts +++ b/__tests__/coordinator-availability-actions.test.ts @@ -1,7 +1,13 @@ /** * @jest-environment node */ -import { updateCoordinatorAvailability } from "@/app/coaching/actions"; +import { + saveCoordinatorWeeklyHours, + setCoordinatorAvailabilityOverride, + clearCoordinatorAvailabilityOverride, + listCoordinatorAvailability, +} from "@/app/coaching/actions"; +import { EMPTY_WEEKLY_HOURS } from "@/lib/availability"; const COORDINATOR_ID = "11111111-1111-1111-1111-111111111111"; const OTHER_COORDINATOR_ID = "22222222-2222-2222-2222-222222222222"; @@ -11,9 +17,28 @@ const onConflictDoUpdate = jest.fn().mockResolvedValue(undefined); const insertValues = jest.fn(() => ({ onConflictDoUpdate })); const insert = jest.fn(() => ({ values: insertValues })) as jest.Mock; +const deleteWhere = jest.fn().mockResolvedValue(undefined); +const deleteFn = jest.fn(() => ({ where: deleteWhere })) as jest.Mock; + +const selectLimit = jest.fn(); +let selectWhereResult: unknown[] = []; +const selectWhere = jest.fn(() => ({ + limit: selectLimit, + then( + onFulfilled?: (value: unknown) => unknown, + onRejected?: (reason: unknown) => unknown, + ) { + return Promise.resolve(selectWhereResult).then(onFulfilled, onRejected); + }, +})); +const selectFrom = jest.fn(() => ({ where: selectWhere })); +const select = jest.fn(() => ({ from: selectFrom })) as jest.Mock; + jest.mock("@/lib/db", () => ({ db: { insert: (...args: unknown[]) => insert(...args), + select: (...args: unknown[]) => select(...args), + delete: (...args: unknown[]) => deleteFn(...args), }, })); @@ -23,28 +48,39 @@ jest.mock("@/utils/supabase/server", () => ({ createClient: async () => ({ auth: { getUser, getClaims } }), })); -const emptyWeek = { - 0: [], - 1: [], - 2: [], - 3: [], - 4: [], - 5: [], - 6: [], +const mondayHours = { + ...EMPTY_WEEKLY_HOURS, + 1: [{ start: "09:00", end: "11:00", recurrence: "weekly" as const }], }; +function asCoordinator(id = COORDINATOR_ID) { + getUser.mockResolvedValue({ data: { user: { id } } }); + getClaims.mockResolvedValue({ + data: { claims: { user_role: "coordinator" } }, + }); +} + +function asAdmin() { + getUser.mockResolvedValue({ data: { user: { id: ADMIN_ID } } }); + getClaims.mockResolvedValue({ + data: { claims: { user_role: "admin" } }, + }); +} + beforeEach(() => { jest.clearAllMocks(); + selectWhereResult = []; + selectLimit.mockResolvedValue([]); }); -describe("updateCoordinatorAvailability", () => { +describe("saveCoordinatorWeeklyHours", () => { it("returns Unauthorized when not signed in", async () => { getUser.mockResolvedValue({ data: { user: null } }); getClaims.mockResolvedValue({ data: { claims: null } }); - const result = await updateCoordinatorAvailability({ + const result = await saveCoordinatorWeeklyHours({ coordinatorId: COORDINATOR_ID, - slots: emptyWeek, + hours: EMPTY_WEEKLY_HOURS, }); expect(result).toEqual({ error: "Unauthorized" }); @@ -59,62 +95,51 @@ describe("updateCoordinatorAvailability", () => { data: { claims: { user_role: "user" } }, }); - const result = await updateCoordinatorAvailability({ + const result = await saveCoordinatorWeeklyHours({ coordinatorId: COORDINATOR_ID, - slots: emptyWeek, + hours: EMPTY_WEEKLY_HOURS, }); expect(result).toEqual({ error: "Unauthorized" }); expect(insert).not.toHaveBeenCalled(); }); - it("lets a coordinator save their own slots", async () => { - getUser.mockResolvedValue({ - data: { user: { id: COORDINATOR_ID } }, - }); - getClaims.mockResolvedValue({ - data: { claims: { user_role: "coordinator" } }, - }); + it("lets a coordinator save their own hours", async () => { + asCoordinator(); - const result = await updateCoordinatorAvailability({ + const result = await saveCoordinatorWeeklyHours({ coordinatorId: COORDINATOR_ID, - slots: emptyWeek, + hours: EMPTY_WEEKLY_HOURS, }); expect(result).toEqual({ ok: true }); expect(insertValues).toHaveBeenCalledWith( - expect.objectContaining({ coordinatorId: COORDINATOR_ID }), + expect.objectContaining({ + coordinatorId: COORDINATOR_ID, + timezone: "America/Toronto", + hours: EMPTY_WEEKLY_HOURS, + }), ); }); - it("blocks a coordinator from saving another coordinator's slots", async () => { - getUser.mockResolvedValue({ - data: { user: { id: COORDINATOR_ID } }, - }); - getClaims.mockResolvedValue({ - data: { claims: { user_role: "coordinator" } }, - }); + it("blocks a coordinator from saving another coordinator's hours", async () => { + asCoordinator(); - const result = await updateCoordinatorAvailability({ + const result = await saveCoordinatorWeeklyHours({ coordinatorId: OTHER_COORDINATOR_ID, - slots: emptyWeek, + hours: EMPTY_WEEKLY_HOURS, }); expect(result).toEqual({ error: "Unauthorized" }); expect(insert).not.toHaveBeenCalled(); }); - it("lets an admin save any coordinator's slots", async () => { - getUser.mockResolvedValue({ - data: { user: { id: ADMIN_ID } }, - }); - getClaims.mockResolvedValue({ - data: { claims: { user_role: "admin" } }, - }); + it("lets an admin save any coordinator's hours", async () => { + asAdmin(); - const result = await updateCoordinatorAvailability({ + const result = await saveCoordinatorWeeklyHours({ coordinatorId: OTHER_COORDINATOR_ID, - slots: emptyWeek, + hours: EMPTY_WEEKLY_HOURS, }); expect(result).toEqual({ ok: true }); @@ -123,28 +148,141 @@ describe("updateCoordinatorAvailability", () => { ); }); - it("rejects overlapping slots on the same day", async () => { + it("rejects overlapping windows on the same day", async () => { + asCoordinator(); + + const result = await saveCoordinatorWeeklyHours({ + coordinatorId: COORDINATOR_ID, + hours: { + ...EMPTY_WEEKLY_HOURS, + 1: [ + { start: "10:00", end: "11:00", recurrence: "weekly" }, + { start: "10:30", end: "12:00", recurrence: "weekly" }, + ], + }, + }); + + expect(result).toEqual({ + error: "Windows on the same day must not overlap", + }); + expect(insert).not.toHaveBeenCalled(); + }); +}); + +describe("setCoordinatorAvailabilityOverride", () => { + it("upserts the date override", async () => { + asCoordinator(); + + const result = await setCoordinatorAvailabilityOverride({ + coordinatorId: COORDINATOR_ID, + date: "2026-03-02", + windows: [], + }); + + expect(result).toEqual({ ok: true }); + expect(insertValues).toHaveBeenCalledWith({ + coordinatorId: COORDINATOR_ID, + date: "2026-03-02", + windows: [], + }); + }); + + it("blocks a coordinator from setting another coordinator's override", async () => { + asCoordinator(); + + const result = await setCoordinatorAvailabilityOverride({ + coordinatorId: OTHER_COORDINATOR_ID, + date: "2026-03-02", + windows: [], + }); + + expect(result).toEqual({ error: "Unauthorized" }); + expect(insert).not.toHaveBeenCalled(); + }); +}); + +describe("clearCoordinatorAvailabilityOverride", () => { + it("deletes the override for that date", async () => { + asCoordinator(); + + const result = await clearCoordinatorAvailabilityOverride({ + coordinatorId: COORDINATOR_ID, + date: "2026-03-02", + }); + + expect(result).toEqual({ ok: true }); + expect(deleteFn).toHaveBeenCalled(); + expect(deleteWhere).toHaveBeenCalled(); + }); +}); + +describe("listCoordinatorAvailability", () => { + it("returns Unauthorized for a parent", async () => { getUser.mockResolvedValue({ data: { user: { id: COORDINATOR_ID } }, }); getClaims.mockResolvedValue({ - data: { claims: { user_role: "coordinator" } }, + data: { claims: { user_role: "user" } }, }); - const result = await updateCoordinatorAvailability({ + const result = await listCoordinatorAvailability({ coordinatorId: COORDINATOR_ID, - slots: { - ...emptyWeek, - 1: [ - { time: "10:00", durationMinutes: 60 }, - { time: "10:30", durationMinutes: 60 }, - ], - }, + from: "2026-03-01", + to: "2026-03-07", + }); + + expect(result).toEqual({ error: "Unauthorized" }); + expect(select).not.toHaveBeenCalled(); + }); + + it("expands weekly hours for the requested range", async () => { + asCoordinator(); + selectLimit.mockResolvedValue([{ hours: mondayHours }]); + + const result = await listCoordinatorAvailability({ + coordinatorId: COORDINATOR_ID, + from: "2026-03-01", + to: "2026-03-07", }); expect(result).toEqual({ - error: "Slots on the same day must not overlap", + occurrences: [ + { + date: "2026-03-02", + start: "09:00", + end: "11:00", + source: "weekly", + }, + ], }); - expect(insert).not.toHaveBeenCalled(); + }); + + it("lets an empty override hide weekly hours for that date", async () => { + asCoordinator(); + selectLimit.mockResolvedValue([{ hours: mondayHours }]); + selectWhereResult = [{ date: "2026-03-02", windows: [] }]; + + const result = await listCoordinatorAvailability({ + coordinatorId: COORDINATOR_ID, + from: "2026-03-01", + to: "2026-03-07", + }); + + expect(result).toEqual({ occurrences: [] }); + }); + + it("rejects a range longer than one year", async () => { + asCoordinator(); + + const result = await listCoordinatorAvailability({ + coordinatorId: COORDINATOR_ID, + from: "2026-01-01", + to: "2027-01-03", + }); + + expect(result).toEqual({ + error: "Range cannot be longer than one year", + }); + expect(select).not.toHaveBeenCalled(); }); }); diff --git a/app/coaching/actions.ts b/app/coaching/actions.ts index 656e687..50de41d 100644 --- a/app/coaching/actions.ts +++ b/app/coaching/actions.ts @@ -1,17 +1,28 @@ "use server"; -import { updateCoordinatorAvailabilitySchema } from "@/app/coaching/schema"; -import { ROLES } from "@/lib/roles" +import { + saveCoordinatorWeeklyHoursSchema, + setCoordinatorAvailabilityOverrideSchema, + clearCoordinatorAvailabilityOverrideSchema, + listCoordinatorAvailabilitySchema, +} from "@/app/coaching/schema"; +import { + availabilityForRange, + EMPTY_WEEKLY_HOURS, + type AvailabilityOccurrence, +} from "@/lib/availability"; +import { ROLES } from "@/lib/roles"; -import { eq } from "drizzle-orm"; +import { and, eq, gte, lte } from "drizzle-orm"; import { db } from "@/lib/db"; import { coachingSessions, - coordinatorAvailability, + coordinatorAvailabilityHours, + coordinatorAvailabilityOverrides, services, + type AvailabilityOverrideWindow, } from "@/lib/db/schema"; import { createClient } from "@/utils/supabase/server"; -import { Schema } from "zod"; export type Availability = { start: string; end: string }; @@ -60,7 +71,6 @@ export async function submitAvailabilities({ return { coachingSessionId: row.id }; } - async function canEditCoordinatorAvailability( coordinatorId: string, ): Promise { @@ -78,32 +88,148 @@ async function canEditCoordinatorAvailability( return false; } -export type UpdateCoordinatorAvailabilityResult = +export type SaveCoordinatorWeeklyHoursResult = | { ok: true } | { error: string }; -export async function updateCoordinatorAvailability( +export async function saveCoordinatorWeeklyHours( input: unknown, -): Promise { - const parsed = updateCoordinatorAvailabilitySchema.safeParse(input); +): Promise { + const parsed = saveCoordinatorWeeklyHoursSchema.safeParse(input); if (!parsed.success) { return { error: parsed.error.issues[0]?.message ?? "Invalid input", }; } - const { coordinatorId, slots } = parsed.data; + const { coordinatorId, timezone, hours } = parsed.data; if (!(await canEditCoordinatorAvailability(coordinatorId))) { return { error: "Unauthorized" }; } await db - .insert(coordinatorAvailability) - .values({ coordinatorId, slots }) + .insert(coordinatorAvailabilityHours) + .values({ coordinatorId, timezone, hours }) .onConflictDoUpdate({ - target: coordinatorAvailability.coordinatorId, - set: { slots, updatedAt: new Date() }, + target: coordinatorAvailabilityHours.coordinatorId, + set: { timezone, hours, updatedAt: new Date() }, }); return { ok: true }; +} + + +export type SetCoordinatorAvailabilityOverrideResult = + | { ok: true } + | { error: string }; + +export async function setCoordinatorAvailabilityOverride( + input: unknown, +): Promise { + const parsed = setCoordinatorAvailabilityOverrideSchema.safeParse(input); + if (!parsed.success) { + return { + error: parsed.error.issues[0]?.message ?? "Invalid input", + }; + } + + const { coordinatorId, date, windows } = parsed.data; + if (!(await canEditCoordinatorAvailability(coordinatorId))) { + return { error: "Unauthorized" }; + } + + await db + .insert(coordinatorAvailabilityOverrides) + .values({ coordinatorId, date, windows }) + .onConflictDoUpdate({ + target: [ + coordinatorAvailabilityOverrides.coordinatorId, + coordinatorAvailabilityOverrides.date, + ], + set: { windows, updatedAt: new Date() }, + }); + + return { ok: true }; +} + +export type ClearCoordinatorAvailabilityOverrideResult = + | { ok: true } + | { error: string }; + +export async function clearCoordinatorAvailabilityOverride( + input: unknown, +): Promise { + const parsed = clearCoordinatorAvailabilityOverrideSchema.safeParse(input); + if (!parsed.success) { + return { + error: parsed.error.issues[0]?.message ?? "Invalid input", + }; + } + + const { coordinatorId, date } = parsed.data; + if (!(await canEditCoordinatorAvailability(coordinatorId))) { + return { error: "Unauthorized" }; + } + + await db + .delete(coordinatorAvailabilityOverrides) + .where( + and( + eq(coordinatorAvailabilityOverrides.coordinatorId, coordinatorId), + eq(coordinatorAvailabilityOverrides.date, date), + ), + ); + + return { ok: true }; +} + +export type ListCoordinatorAvailabilityResult = + | { occurrences: AvailabilityOccurrence[] } + | { error: string }; + +export async function listCoordinatorAvailability( + input: unknown, +): Promise { + const parsed = listCoordinatorAvailabilitySchema.safeParse(input); + if (!parsed.success) { + return { + error: parsed.error.issues[0]?.message ?? "Invalid input", + }; + } + + const { coordinatorId, from, to } = parsed.data; + if (!(await canEditCoordinatorAvailability(coordinatorId))) { + return { error: "Unauthorized" }; + } + + const [hoursRow] = await db + .select() + .from(coordinatorAvailabilityHours) + .where(eq(coordinatorAvailabilityHours.coordinatorId, coordinatorId)) + .limit(1); + + const overrideRows = await db + .select() + .from(coordinatorAvailabilityOverrides) + .where( + and( + eq(coordinatorAvailabilityOverrides.coordinatorId, coordinatorId), + gte(coordinatorAvailabilityOverrides.date, from), + lte(coordinatorAvailabilityOverrides.date, to), + ), + ); + + const overrides: Record = {}; + for (const row of overrideRows) { + overrides[row.date] = row.windows; + } + + return { + occurrences: availabilityForRange({ + hours: hoursRow?.hours ?? EMPTY_WEEKLY_HOURS, + overrides, + from, + to, + }), + }; } \ No newline at end of file diff --git a/app/coaching/schema.ts b/app/coaching/schema.ts index bcc0c53..dd6cd9e 100644 --- a/app/coaching/schema.ts +++ b/app/coaching/schema.ts @@ -1,43 +1,118 @@ -import {z} from 'zod'; - -export const availabilitySlotSchema = z.object({ - time: z.string().regex(/^\d{2}:\d{2}$/, "Invalid time"), - durationMinutes: z.number().int().min(1).max(24 * 60), - }); - - export const weeklyAvailabilitySchema = z - .object({ - 0: z.array(availabilitySlotSchema), - 1: z.array(availabilitySlotSchema), - 2: z.array(availabilitySlotSchema), - 3: z.array(availabilitySlotSchema), - 4: z.array(availabilitySlotSchema), - 5: z.array(availabilitySlotSchema), - 6: z.array(availabilitySlotSchema), - }) - .refine((week) => !Object.values(week).some(daySlotsOverlap), { - message: "Slots on the same day must not overlap", - }); - - - export const updateCoordinatorAvailabilitySchema = z.object({ - coordinatorId: z.string().uuid(), - slots: weeklyAvailabilitySchema, - }); - - function daySlotsOverlap( - slots: z.infer[], - ): boolean { - const ranges = slots - .map((slot) => { - const [hours, minutes] = slot.time.split(":").map(Number); - const start = hours * 60 + minutes; - return { start, end: start + slot.durationMinutes }; - }) - .sort((a, b) => a.start - b.start); - - for (let i = 1; i < ranges.length; i++) { - if (ranges[i]!.start < ranges[i - 1]!.end) return true; - } - return false; - } \ No newline at end of file +import { z } from "zod"; + +const timeSchema = z.string().regex(/^\d{2}:\d{2}$/, "Invalid time"); +const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid date"); + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const MAX_RANGE_DAYS = 366; + +function toMinutes(time: string): number { + const [hours, minutes] = time.split(":").map(Number); + return hours * 60 + minutes; +} + +function utcMidnight(ymd: string): number { + const [year, month, day] = ymd.split("-").map(Number); + return Date.UTC(year, month - 1, day); +} + +function windowsOverlap( + windows: { start: string; end: string }[], +): boolean { + const ranges = windows + .map((window) => ({ + start: toMinutes(window.start), + end: toMinutes(window.end), + })) + .sort((a, b) => a.start - b.start); + + for (let i = 1; i < ranges.length; i++) { + if (ranges[i]!.start < ranges[i - 1]!.end) return true; + } + return false; +} + +export const availabilityWindowSchema = z + .object({ + start: timeSchema, + end: timeSchema, + recurrence: z.enum(["weekly", "biweekly"]), + anchorDate: dateSchema.optional(), + }) + .refine((window) => toMinutes(window.end) > toMinutes(window.start), { + message: "End must be after start", + path: ["end"], + }) + .refine( + (window) => + window.recurrence !== "biweekly" || window.anchorDate !== undefined, + { + message: "Every other week needs an anchor date", + path: ["anchorDate"], + }, + ); + +export const overrideWindowSchema = z + .object({ + start: timeSchema, + end: timeSchema, + }) + .refine((window) => toMinutes(window.end) > toMinutes(window.start), { + message: "End must be after start", + path: ["end"], + }); + +export const weeklyHoursSchema = z + .object({ + 0: z.array(availabilityWindowSchema), + 1: z.array(availabilityWindowSchema), + 2: z.array(availabilityWindowSchema), + 3: z.array(availabilityWindowSchema), + 4: z.array(availabilityWindowSchema), + 5: z.array(availabilityWindowSchema), + 6: z.array(availabilityWindowSchema), + }) + .refine((week) => !Object.values(week).some(windowsOverlap), { + message: "Windows on the same day must not overlap", + }); + +export const saveCoordinatorWeeklyHoursSchema = z.object({ + coordinatorId: z.string().uuid(), + timezone: z.string().min(1).default("America/Toronto"), + hours: weeklyHoursSchema, +}); + +export const setCoordinatorAvailabilityOverrideSchema = z.object({ + coordinatorId: z.string().uuid(), + date: dateSchema, + windows: z + .array(overrideWindowSchema) + .refine((windows) => !windowsOverlap(windows), { + message: "Windows on the same day must not overlap", + }), +}); + +export const clearCoordinatorAvailabilityOverrideSchema = z.object({ + coordinatorId: z.string().uuid(), + date: dateSchema, +}); + +export const listCoordinatorAvailabilitySchema = z + .object({ + coordinatorId: z.string().uuid(), + from: dateSchema, + to: dateSchema, + }) + .refine((range) => utcMidnight(range.from) <= utcMidnight(range.to), { + message: "from must be on or before to", + path: ["to"], + }) + .refine( + (range) => + utcMidnight(range.to) - utcMidnight(range.from) <= + MAX_RANGE_DAYS * MS_PER_DAY, + { + message: "Range cannot be longer than one year", + path: ["to"], + }, + ); \ No newline at end of file diff --git a/lib/availability.ts b/lib/availability.ts new file mode 100644 index 0000000..51a72be --- /dev/null +++ b/lib/availability.ts @@ -0,0 +1,108 @@ +import type { + AvailabilityOverrideWindow, + AvailabilityWindow, + CoordinatorWeeklyHours, + } from "@/lib/db/schema"; + + export const EMPTY_WEEKLY_HOURS: CoordinatorWeeklyHours = { + 0: [], + 1: [], + 2: [], + 3: [], + 4: [], + 5: [], + 6: [], + }; + + export type AvailabilityOccurrence = { + date: string; + start: string; + end: string; + source: "weekly" | "override"; + }; + + const MS_PER_DAY = 24 * 60 * 60 * 1000; + + function utcMidnight(ymd: string): number { + const [year, month, day] = ymd.split("-").map(Number); + return Date.UTC(year, month - 1, day); + } + + function ymdFromUtc(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); + } + + function weekday(ymd: string): 0 | 1 | 2 | 3 | 4 | 5 | 6 { + return new Date(utcMidnight(ymd)).getUTCDay() as 0 | 1 | 2 | 3 | 4 | 5 | 6; + } + + function startOfSundayWeek(ymd: string): number { + const ms = utcMidnight(ymd); + return ms - new Date(ms).getUTCDay() * MS_PER_DAY; + } + + function isBiweeklyOn(ymd: string, anchorDate: string): boolean { + const weeks = + (startOfSundayWeek(ymd) - startOfSundayWeek(anchorDate)) / + (7 * MS_PER_DAY); + return weeks % 2 === 0; + } + + function appliesOnDate(window: AvailabilityWindow, ymd: string): boolean { + if (window.recurrence === "weekly") return true; + if (!window.anchorDate) return false; + return isBiweeklyOn(ymd, window.anchorDate); + } + + function eachYmd(from: string, to: string): string[] { + const days: string[] = []; + let ms = utcMidnight(from); + const end = utcMidnight(to); + while (ms <= end) { + days.push(ymdFromUtc(ms)); + ms += MS_PER_DAY; + } + return days; + } + + export function availabilityForRange({ + hours, + overrides, + from, + to, + }: { + hours: CoordinatorWeeklyHours; + overrides: Record; + from: string; + to: string; + }): AvailabilityOccurrence[] { + if (utcMidnight(from) > utcMidnight(to)) return []; + + const result: AvailabilityOccurrence[] = []; + + for (const date of eachYmd(from, to)) { + if (Object.hasOwn(overrides, date)) { + for (const window of overrides[date] ?? []) { + result.push({ + date, + start: window.start, + end: window.end, + source: "override", + }); + } + continue; + } + + for (const window of hours[weekday(date)]) { + if (!appliesOnDate(window, date)) continue; + result.push({ + date, + start: window.start, + end: window.end, + source: "weekly", + }); + } + } + + return result; + } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 5ab4730..bd1312c 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -260,28 +260,58 @@ export const formQuestionAnswers = pgTable("form_question_answers", { updatedAt: timestamp("updated_at").defaultNow().notNull(), }); -export type AvailabilitySlot = { - time: string; - durationMinutes: number; +export type AvailabilityWindow = { + start: string; + end: string; + recurrence: "weekly" | "biweekly"; + anchorDate?: string; }; -export type CoordinatorWeeklyAvailability = { - 0: AvailabilitySlot[]; - 1: AvailabilitySlot[]; - 2: AvailabilitySlot[]; - 3: AvailabilitySlot[]; - 4: AvailabilitySlot[]; - 5: AvailabilitySlot[]; - 6: AvailabilitySlot[]; +export type CoordinatorWeeklyHours = { + 0: AvailabilityWindow[]; + 1: AvailabilityWindow[]; + 2: AvailabilityWindow[]; + 3: AvailabilityWindow[]; + 4: AvailabilityWindow[]; + 5: AvailabilityWindow[]; + 6: AvailabilityWindow[]; }; -export const coordinatorAvailability = pgTable("coordinator_availability", { - id: uuid("id").primaryKey().defaultRandom(), - coordinatorId: uuid("coordinator_id") - .references(() => profiles.id, { onDelete: "cascade" }) - .notNull() - .unique(), - slots: jsonb("slots").$type().notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), -}); \ No newline at end of file +export type AvailabilityOverrideWindow = { + start: string; + end: string; +}; + +export const coordinatorAvailabilityHours = pgTable( + "coordinator_availability_hours", + { + id: uuid("id").primaryKey().defaultRandom(), + coordinatorId: uuid("coordinator_id") + .references(() => profiles.id, { onDelete: "cascade" }) + .notNull() + .unique(), + timezone: text("timezone").notNull().default("America/Toronto"), + hours: jsonb("hours").$type().notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, +); + +export const coordinatorAvailabilityOverrides = pgTable( + "coordinator_availability_overrides", + { + id: uuid("id").primaryKey().defaultRandom(), + coordinatorId: uuid("coordinator_id") + .references(() => profiles.id, { onDelete: "cascade" }) + .notNull(), + date: date("date", { mode: "string" }).notNull(), + windows: jsonb("windows").$type().notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (t) => [ + uniqueIndex( + "coordinator_availability_overrides_coordinator_id_date_idx", + ).on(t.coordinatorId, t.date), + ], +); \ No newline at end of file From 362a2734f531c0738649d2b308b66c5794ed30cf Mon Sep 17 00:00:00 2001 From: Berny-ft Date: Wed, 19 Aug 2026 18:14:27 -0400 Subject: [PATCH 6/6] feat(coaching): add fetch action for availability editor state Server action to load weekly hours and date overrides for the test UI. Co-authored-by: Cursor --- app/coaching/actions.ts | 58 +++++++++++++++++++++++++++++++++++++++++ app/coaching/schema.ts | 5 ++++ 2 files changed, 63 insertions(+) diff --git a/app/coaching/actions.ts b/app/coaching/actions.ts index 50de41d..36466bc 100644 --- a/app/coaching/actions.ts +++ b/app/coaching/actions.ts @@ -4,6 +4,7 @@ import { setCoordinatorAvailabilityOverrideSchema, clearCoordinatorAvailabilityOverrideSchema, listCoordinatorAvailabilitySchema, + fetchCoordinatorAvailabilityEditorStateSchema, } from "@/app/coaching/schema"; import { availabilityForRange, @@ -21,6 +22,7 @@ import { coordinatorAvailabilityOverrides, services, type AvailabilityOverrideWindow, + type CoordinatorWeeklyHours, } from "@/lib/db/schema"; import { createClient } from "@/utils/supabase/server"; @@ -183,6 +185,62 @@ export async function clearCoordinatorAvailabilityOverride( return { ok: true }; } +export type FetchCoordinatorAvailabilityEditorStateResult = + | { + hours: CoordinatorWeeklyHours; + timezone: string; + /** null = no override row for that date; [] = explicit day off */ + override: AvailabilityOverrideWindow[] | null; + } + | { error: string }; + +export async function fetchCoordinatorAvailabilityEditorState( + input: unknown, +): Promise { + const parsed = + fetchCoordinatorAvailabilityEditorStateSchema.safeParse(input); + if (!parsed.success) { + return { + error: parsed.error.issues[0]?.message ?? "Invalid input", + }; + } + + const { coordinatorId, overrideDate } = parsed.data; + if (!(await canEditCoordinatorAvailability(coordinatorId))) { + return { error: "Unauthorized" }; + } + + const [hoursRow] = await db + .select() + .from(coordinatorAvailabilityHours) + .where(eq(coordinatorAvailabilityHours.coordinatorId, coordinatorId)) + .limit(1); + + let override: AvailabilityOverrideWindow[] | null = null; + if (overrideDate) { + const [overrideRow] = await db + .select() + .from(coordinatorAvailabilityOverrides) + .where( + and( + eq( + coordinatorAvailabilityOverrides.coordinatorId, + coordinatorId, + ), + eq(coordinatorAvailabilityOverrides.date, overrideDate), + ), + ) + .limit(1); + override = overrideRow ? overrideRow.windows : null; + } + + return { + hours: hoursRow?.hours ?? EMPTY_WEEKLY_HOURS, + timezone: hoursRow?.timezone ?? "America/Toronto", + override, + }; +} + export type ListCoordinatorAvailabilityResult = | { occurrences: AvailabilityOccurrence[] } | { error: string }; diff --git a/app/coaching/schema.ts b/app/coaching/schema.ts index dd6cd9e..5c4f211 100644 --- a/app/coaching/schema.ts +++ b/app/coaching/schema.ts @@ -97,6 +97,11 @@ export const clearCoordinatorAvailabilityOverrideSchema = z.object({ date: dateSchema, }); +export const fetchCoordinatorAvailabilityEditorStateSchema = z.object({ + coordinatorId: z.string().uuid(), + overrideDate: dateSchema.optional(), +}); + export const listCoordinatorAvailabilitySchema = z .object({ coordinatorId: z.string().uuid(),