From 16e5f553357f6d695b7c263e5900300fb27a5798 Mon Sep 17 00:00:00 2001 From: Berny-ft Date: Sun, 16 Aug 2026 23:23:17 -0400 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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(); + }); +});