From 95fe5f9cf794e0a9ba8ac89ba1976b7563a46208 Mon Sep 17 00:00:00 2001 From: Jia Xuan Li <77560326+Jxl-s@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:47:08 -0700 Subject: [PATCH 01/52] feat(schema): add forms table and update service bookings with childId and unique index --- lib/db/schema.ts | 63 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 9a6d34a..b301118 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -8,7 +8,9 @@ import { boolean, jsonb, date, + uniqueIndex, } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; export type ProgramSlot = { dayOfWeek: number; time: string }; @@ -62,6 +64,13 @@ export const profiles = pgTable("profiles", { lastLoginAt: timestamp('last_login_at').defaultNow().notNull() }); +export const forms = pgTable("forms", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); + export const services = pgTable("services", { id: uuid("id").primaryKey().defaultRandom(), type: serviceTypeEnum("type").notNull(), @@ -74,25 +83,38 @@ export const services = pgTable("services", { coachId: uuid("coach_id").references(() => profiles.id, { onDelete: "set null", }), + formId: uuid("form_id").references(() => forms.id, { onDelete: "set null" }), + isForChildren: boolean("is_for_children").notNull().default(false), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); -export const serviceBookings = pgTable("service_bookings", { - id: uuid("id").primaryKey().defaultRandom(), - userId: uuid("user_id") - .references(() => profiles.id, { onDelete: "cascade" }) - .notNull(), - serviceId: uuid("service_id") - .references(() => services.id, { onDelete: "cascade" }) - .notNull(), - status: bookingStatusEnum("status").notNull().default("pending"), - notes: text("notes"), - isActive: boolean("is_active").notNull().default(true), - stripeOrderId: text("stripe_order_id").unique(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), -}); +export const serviceBookings = pgTable( + "service_bookings", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .references(() => profiles.id, { onDelete: "cascade" }) + .notNull(), + serviceId: uuid("service_id") + .references(() => services.id, { onDelete: "cascade" }) + .notNull(), + childId: uuid("child_id").references(() => children.id, { + onDelete: "cascade", + }), + status: bookingStatusEnum("status").notNull().default("pending"), + notes: text("notes"), + isActive: boolean("is_active").notNull().default(true), + stripeOrderId: text("stripe_order_id").unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (t) => [ + uniqueIndex("service_bookings_service_id_child_id_idx") + .on(t.serviceId, t.childId) + .where(sql`${t.childId} is not null`), + ], +); export const webinars = pgTable("webinars", { id: uuid("id").primaryKey().defaultRandom(), @@ -117,11 +139,17 @@ export const coachingSessions = pgTable("coaching_sessions", { userId: uuid("user_id") .references(() => profiles.id, { onDelete: "cascade" }) .notNull(), + childId: uuid("child_id").references(() => children.id, { + onDelete: "cascade", + }), scheduledAt: timestamp("scheduled_at"), status: sessionStatusEnum("status").notNull().default("pending"), meetingUrl: text("meeting_url"), notes: text("notes"), selectedTimeSlots: jsonb("selected_time_slots").notNull(), + coachTimeSlots: jsonb("coach_time_slots"), + coachToken: text("coach_token").unique(), + clientToken: text("client_token").unique(), stripeOrderId: text("stripe_order_id").unique(), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), @@ -188,12 +216,13 @@ export const emergencyContacts = pgTable("emergency_contacts", { export const extraQuestions = pgTable("extra_questions", { id: uuid("id").primaryKey().defaultRandom(), - serviceId: uuid("service_id") - .references(() => services.id, { onDelete: "cascade" }) + formId: uuid("form_id") + .references(() => forms.id, { onDelete: "cascade" }) .notNull(), type: extraQuestionTypeEnum("type").notNull(), prompt: text("prompt").notNull(), options: jsonb("options").$type(), + sortOrder: integer("sort_order").notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); From 76a67bb4ca2cebfb19f3918f8ff8b742228710d9 Mon Sep 17 00:00:00 2001 From: Jia Xuan Li <77560326+Jxl-s@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:49:19 -0700 Subject: [PATCH 02/52] feat(schema): enhance schema with new fields for users, services, and bookings --- docs/schema-overview.md | 81 +++++++++++++++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 12 deletions(-) diff --git a/docs/schema-overview.md b/docs/schema-overview.md index 1bbfdcf..03ea2ac 100644 --- a/docs/schema-overview.md +++ b/docs/schema-overview.md @@ -7,19 +7,31 @@ erDiagram text first_name text last_name role role + text stripe_customer_id + timestamp last_login_at + timestamp created_at + timestamp updated_at + } + + forms { + uuid id PK + text name timestamp created_at timestamp updated_at } services { uuid id PK - text title - text description service_type type - jsonb scheduled_at "null for coaching_session" + date start_date "null for private_lessons" + date end_date "null for private_lessons" + jsonb slots "array of {dayOfWeek, time}; null for private_lessons" int duration_minutes - int price - boolean is_active + text stripe_product_id + service_status status + uuid coach_id FK "null for programs" + uuid form_id FK "nullable" + boolean is_for_children timestamp created_at timestamp updated_at } @@ -28,9 +40,11 @@ erDiagram uuid id PK uuid user_id FK uuid service_id FK + uuid child_id FK "nullable; null means adult registration" booking_status status text notes boolean is_active + text stripe_order_id timestamp created_at timestamp updated_at } @@ -52,12 +66,41 @@ erDiagram uuid service_id FK uuid coach_id FK uuid user_id FK - timestamp scheduled_at - int duration_minutes + uuid child_id FK "nullable; null means adult registration" + timestamp scheduled_at "set when slot is confirmed" session_status status text meeting_url text notes jsonb selected_time_slots "array of {start, end} objects" + jsonb coach_time_slots + text coach_token + text client_token + text stripe_order_id + timestamp created_at + timestamp updated_at + } + + subscriptions { + uuid id PK + uuid user_id FK + text stripe_subscription_id + text status + text stripe_price_id + boolean cancel_at_period_end + text payment_method_brand + text payment_method_last4 + timestamp created_at + timestamp updated_at + } + + purchases { + uuid id PK + uuid user_id FK + text stripe_price_id + text stripe_session_id + text product_name + int amount + text currency timestamp created_at timestamp updated_at } @@ -89,10 +132,11 @@ erDiagram extra_questions { uuid id PK - uuid service_id FK + uuid form_id FK extra_question_type type text prompt jsonb options + int sort_order timestamp created_at timestamp updated_at } @@ -108,24 +152,37 @@ erDiagram profiles ||--o{ service_bookings : "books" services ||--o{ service_bookings : "booked via" + children |o--o{ service_bookings : "registered for" profiles ||--o{ coaching_sessions : "coaches" profiles ||--o{ coaching_sessions : "attends" services ||--o{ coaching_sessions : "fulfilled by" + children |o--o{ coaching_sessions : "registered for" profiles ||--o{ children : "parent of" children ||--o{ emergency_contacts : "has" - services ||--o{ extra_questions : "defines" + profiles ||--o| subscriptions : "has" + profiles ||--o{ purchases : "makes" + profiles |o--o{ services : "coaches" + forms ||--o{ extra_questions : "contains" + services }o--o| forms : "uses" extra_questions ||--o{ extra_question_answers : "answered via" children ||--o{ extra_question_answers : "submits" ``` +## Indexes + +| Table | Index | Type | Condition | +|---|---|---|---| +| `service_bookings` | `service_bookings_service_id_child_id_idx` | Unique (partial) | `WHERE child_id IS NOT NULL` — prevents the same child from registering for the same program twice | + ## Enums | Enum | Values | |---|---| | `role` | `user`, `admin`, `coach` | -| `service_type` | `coaching_session`, `booking` | -| `booking_status` | `pending`, `confirmed`, `cancelled` | +| `service_type` | `private_lessons`, `programs` | +| `service_status` | `active`, `disabled`, `archived`, `deleted` | +| `booking_status` | `awaiting_payment`, `pending`, `confirmed`, `cancelled` | +| `session_status` | `awaiting_payment`, `pending`, `confirmed`, `cancelled`, `completed` | | `webinar_tier` | `free`, `premium` | -| `session_status` | `pending`, `confirmed`, `cancelled`, `completed` | | `gender` | `male`, `female`, `prefer_not_to_say` | | `extra_question_type` | `text`, `multiple_choices`, `checkboxes`, `user_agreement` | From cacddffc767f53c8bcda827c7f520285a09e6cac Mon Sep 17 00:00:00 2001 From: maxiDeee Date: Sat, 6 Jun 2026 12:43:05 -0400 Subject: [PATCH 03/52] feat(services): make coach selection mandatory for private lessons --- __tests__/services-actions.test.ts | 218 ++++++++++++++++++ __tests__/users-actions.test.ts | 90 ++++++++ app/(authenticated)/services/actions.ts | 28 ++- app/(authenticated)/services/queries.ts | 2 + .../services/service-dialog.tsx | 13 +- .../users/_components/user-actions-cell.tsx | 75 +++++- app/(authenticated)/users/actions.ts | 63 ++++- lib/db/schema.ts | 49 ++-- 8 files changed, 511 insertions(+), 27 deletions(-) create mode 100644 __tests__/services-actions.test.ts create mode 100644 __tests__/users-actions.test.ts diff --git a/__tests__/services-actions.test.ts b/__tests__/services-actions.test.ts new file mode 100644 index 0000000..016b18c --- /dev/null +++ b/__tests__/services-actions.test.ts @@ -0,0 +1,218 @@ +/** + * @jest-environment node + */ +import { + createService, + updateService, +} from "@/app/(authenticated)/services/actions"; + +// --- db mock (chainable query builder) ------------------------------------- +const insertValues = jest.fn().mockResolvedValue(undefined); +const insert = jest.fn(() => ({ values: insertValues })) as jest.Mock; + +const selectLimit = jest.fn(); +const selectWhere = jest.fn(() => ({ limit: selectLimit })); +const selectFrom = jest.fn(() => ({ where: selectWhere })); +const select = jest.fn(() => ({ from: selectFrom })) as jest.Mock; + +const updateWhere = jest.fn().mockResolvedValue(undefined); +const updateSet = jest.fn(() => ({ where: updateWhere })); +const update = jest.fn(() => ({ set: updateSet })) as jest.Mock; + +jest.mock("@/lib/db", () => ({ + db: { + insert: (...args: unknown[]) => insert(...args), + select: (...args: unknown[]) => select(...args), + update: (...args: unknown[]) => update(...args), + }, +})); + +// --- stripe mock ------------------------------------------------------------ +const createProduct = jest.fn(); +const createPrice = jest.fn(); +const updateProduct = jest.fn(); +const deactivateActivePricesForProduct = jest.fn(); +const getStripeServiceData = jest.fn(); + +jest.mock("@/lib/stripe", () => ({ + createProduct: (...args: unknown[]) => createProduct(...args), + createPrice: (...args: unknown[]) => createPrice(...args), + updateProduct: (...args: unknown[]) => updateProduct(...args), + deactivateActivePricesForProduct: (...args: unknown[]) => + deactivateActivePricesForProduct(...args), + getStripeServiceData: (...args: unknown[]) => getStripeServiceData(...args), +})); + +// --- auth + cache mocks ----------------------------------------------------- +const requireAdmin = jest.fn(); +jest.mock("@/lib/auth/require-admin", () => ({ + requireAdmin: (...args: unknown[]) => requireAdmin(...args), +})); + +jest.mock("next/cache", () => ({ + revalidatePath: jest.fn(), + updateTag: jest.fn(), +})); + +const COACH_A = "11111111-1111-1111-1111-111111111111"; +const COACH_B = "22222222-2222-2222-2222-222222222222"; +const SERVICE_ID = "33333333-3333-3333-3333-333333333333"; + +function fd(obj: Record): FormData { + const f = new FormData(); + for (const [k, v] of Object.entries(obj)) f.append(k, v); + return f; +} + +beforeEach(() => { + jest.clearAllMocks(); + requireAdmin.mockResolvedValue(undefined); + createProduct.mockResolvedValue({ productId: "prod_1" }); + createPrice.mockResolvedValue(undefined); + updateProduct.mockResolvedValue(undefined); + deactivateActivePricesForProduct.mockResolvedValue(undefined); + getStripeServiceData.mockResolvedValue({ priceCents: 5000 }); +}); + +describe("createService", () => { + it("rejects a private lesson without a coach", async () => { + const result = await createService( + null, + fd({ + title: "1:1 Lesson", + description: "A private session", + type: "private_lessons", + duration_minutes: "60", + price_cad: "50.00", + }), + ); + + expect(result?.errors?.coach_id).toBeDefined(); + expect(insert).not.toHaveBeenCalled(); + expect(createProduct).not.toHaveBeenCalled(); + }); + + it("persists the coach when creating a private lesson", async () => { + const result = await createService( + null, + fd({ + title: "1:1 Lesson", + description: "A private session", + type: "private_lessons", + duration_minutes: "60", + price_cad: "50.00", + coach_id: COACH_A, + }), + ); + + expect(result).toEqual({ message: "Service created." }); + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ type: "private_lessons", coachId: COACH_A }), + ); + }); + + it("creates a program with no coach", async () => { + const result = await createService( + null, + fd({ + title: "Summer Program", + description: "Group program", + type: "programs", + duration_minutes: "60", + price_cad: "100.00", + start_date: "2026-01-01", + end_date: "2026-02-01", + slots: JSON.stringify([{ dayOfWeek: 1, time: "10:00" }]), + }), + ); + + expect(result).toEqual({ message: "Service created." }); + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ type: "programs", coachId: null }), + ); + }); +}); + +describe("updateService", () => { + it("reassigns the coach on a private lesson", async () => { + selectLimit.mockResolvedValue([ + { + id: SERVICE_ID, + type: "private_lessons", + status: "active", + stripeProductId: "prod_1", + }, + ]); + + const result = await updateService( + null, + fd({ service_id: SERVICE_ID, coach_id: COACH_B }), + ); + + expect(result).toEqual({ message: "Service updated." }); + expect(updateSet).toHaveBeenCalledWith( + expect.objectContaining({ coachId: COACH_B }), + ); + }); + + it("does not recreate the Stripe price when the amount is unchanged", async () => { + selectLimit.mockResolvedValue([ + { + id: SERVICE_ID, + type: "private_lessons", + status: "active", + stripeProductId: "prod_1", + }, + ]); + getStripeServiceData.mockResolvedValue({ priceCents: 5000 }); + + const result = await updateService( + null, + fd({ service_id: SERVICE_ID, coach_id: COACH_B, price_cad: "50.00" }), + ); + + expect(result).toEqual({ message: "Service updated." }); + expect(deactivateActivePricesForProduct).not.toHaveBeenCalled(); + expect(createPrice).not.toHaveBeenCalled(); + }); + + it("recreates the Stripe price when the amount changes", async () => { + selectLimit.mockResolvedValue([ + { + id: SERVICE_ID, + type: "private_lessons", + status: "active", + stripeProductId: "prod_1", + }, + ]); + getStripeServiceData.mockResolvedValue({ priceCents: 5000 }); + + const result = await updateService( + null, + fd({ service_id: SERVICE_ID, price_cad: "75.00" }), + ); + + expect(result).toEqual({ message: "Service updated." }); + expect(deactivateActivePricesForProduct).toHaveBeenCalled(); + expect(createPrice).toHaveBeenCalledWith("prod_1", 7500); + }); + + it("rejects clearing the coach on a private lesson", async () => { + selectLimit.mockResolvedValue([ + { + id: SERVICE_ID, + type: "private_lessons", + status: "active", + stripeProductId: "prod_1", + }, + ]); + + const result = await updateService( + null, + fd({ service_id: SERVICE_ID, coach_id: "" }), + ); + + expect(result?.errors?.coach_id).toBeDefined(); + expect(updateSet).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/users-actions.test.ts b/__tests__/users-actions.test.ts new file mode 100644 index 0000000..e7a2f8f --- /dev/null +++ b/__tests__/users-actions.test.ts @@ -0,0 +1,90 @@ +/** + * @jest-environment node + */ +import { deleteUserAdmin } from "@/app/(authenticated)/users/actions"; + +// --- db mock (chainable select builder) ------------------------------------ +const selectLimit = jest.fn(); +const selectWhere = jest.fn(() => ({ limit: selectLimit })); +const selectFrom = jest.fn(() => ({ where: selectWhere })); +const select = jest.fn(() => ({ from: selectFrom })) as jest.Mock; + +jest.mock("@/lib/db", () => ({ + db: { + select: (...args: unknown[]) => select(...args), + }, +})); + +// --- supabase admin mock ---------------------------------------------------- +const deleteUser = jest.fn(); +jest.mock("@/utils/supabase/admin", () => ({ + createAdminClient: () => ({ + auth: { admin: { deleteUser: (...args: unknown[]) => deleteUser(...args) } }, + }), +})); + +// --- auth + cache + stripe mocks ------------------------------------------- +const requireAdmin = jest.fn(); +jest.mock("@/lib/auth/require-admin", () => ({ + requireAdmin: (...args: unknown[]) => requireAdmin(...args), +})); + +jest.mock("next/cache", () => ({ revalidatePath: jest.fn() })); + +// actions.ts imports grantComplimentarySubscription from @/lib/stripe, which +// instantiates the Stripe client at module load — mock it away. +jest.mock("@/lib/stripe", () => ({ + grantComplimentarySubscription: jest.fn(), +})); + +const COACH_ID = "11111111-1111-1111-1111-111111111111"; + +function fd(obj: Record): FormData { + const f = new FormData(); + for (const [k, v] of Object.entries(obj)) f.append(k, v); + return f; +} + +beforeEach(() => { + jest.clearAllMocks(); + requireAdmin.mockResolvedValue(undefined); + selectLimit.mockResolvedValue([]); + deleteUser.mockResolvedValue({ error: null }); +}); + +describe("deleteUserAdmin", () => { + it("returns Unauthorized when the caller is not an admin", async () => { + requireAdmin.mockRejectedValue(new Error("Forbidden")); + + const result = await deleteUserAdmin(null, fd({ user_id: COACH_ID })); + + expect(result).toEqual({ errors: { _form: ["Unauthorized"] } }); + expect(deleteUser).not.toHaveBeenCalled(); + }); + + it("blocks deletion when the coach is assigned to a private lesson", async () => { + selectLimit.mockResolvedValue([{ id: "svc-1" }]); + + const result = await deleteUserAdmin(null, fd({ user_id: COACH_ID })); + + expect(result?.errors?._form?.[0]).toMatch(/private lesson/i); + expect(deleteUser).not.toHaveBeenCalled(); + }); + + it("deletes the user when not assigned to any private lesson", async () => { + selectLimit.mockResolvedValue([]); + + const result = await deleteUserAdmin(null, fd({ user_id: COACH_ID })); + + expect(result).toEqual({ message: "User deleted." }); + expect(deleteUser).toHaveBeenCalledWith(COACH_ID); + }); + + it("surfaces an auth error from Supabase", async () => { + deleteUser.mockResolvedValue({ error: { message: "boom" } }); + + const result = await deleteUserAdmin(null, fd({ user_id: COACH_ID })); + + expect(result).toEqual({ errors: { _form: ["boom"] } }); + }); +}); diff --git a/app/(authenticated)/services/actions.ts b/app/(authenticated)/services/actions.ts index 635ea5b..fe08af8 100644 --- a/app/(authenticated)/services/actions.ts +++ b/app/(authenticated)/services/actions.ts @@ -11,6 +11,7 @@ import { createPrice, createProduct, deactivateActivePricesForProduct, + getStripeServiceData, updateProduct, } from "@/lib/stripe"; @@ -165,6 +166,7 @@ export async function createService( // so they still run when baseFields fails on unrelated fields. const typeRaw = formData.get("type")?.toString(); let scheduledAtValue: ProgramSchedule | null = null; + let coachIdValue: string | null = null; if (typeRaw === "programs") { const result = parseProgramSchedule(formData); if (!result.ok) { @@ -175,6 +177,7 @@ export async function createService( } else if (typeRaw === "private_lessons") { const coach = parseCoachId(formData); if (!coach.ok) Object.assign(errors, coach.errors); + else coachIdValue = coach.value; } if (Object.keys(errors).length > 0) { @@ -203,6 +206,7 @@ export async function createService( slots: scheduledAtValue?.slots ?? null, durationMinutes: duration_minutes, stripeProductId: productId, + coachId: coachIdValue, status: "active", }); } catch (e) { @@ -307,6 +311,15 @@ export async function updateService( } } + // Private lessons can be reassigned to a different coach, but the coach + // remains mandatory: an empty/invalid value is rejected. + let coachIdValue: string | undefined; + if (row.type === "private_lessons" && formData.has("coach_id")) { + const coach = parseCoachId(formData); + if (!coach.ok) Object.assign(errors, coach.errors); + else coachIdValue = coach.value; + } + if (Object.keys(errors).length > 0) { return { errors }; } @@ -321,14 +334,21 @@ export async function updateService( }); if (cents !== undefined) { - // Stripe Prices are immutable: deactivate the current active - // price(s) and create a new one at the new amount. - await deactivateActivePricesForProduct(row.stripeProductId); - await createPrice(row.stripeProductId, cents); + // The edit form always submits the price, so only swap it when the + // amount actually changed. Recreating an unchanged price needlessly + // archives the product's default price, which Stripe rejects. + const current = await getStripeServiceData(row.stripeProductId); + if (current?.priceCents !== cents) { + // Stripe Prices are immutable: deactivate the current active + // price(s) and create a new one at the new amount. + await deactivateActivePricesForProduct(row.stripeProductId); + await createPrice(row.stripeProductId, cents); + } } const dbPatch: Partial = {}; if (duration_minutes !== undefined) dbPatch.durationMinutes = duration_minutes; + if (coachIdValue !== undefined) dbPatch.coachId = coachIdValue; if (scheduledAtValue !== undefined) { dbPatch.startDate = scheduledAtValue.startDate; dbPatch.endDate = scheduledAtValue.endDate; diff --git a/app/(authenticated)/services/queries.ts b/app/(authenticated)/services/queries.ts index 1a62d40..f45293b 100644 --- a/app/(authenticated)/services/queries.ts +++ b/app/(authenticated)/services/queries.ts @@ -18,6 +18,7 @@ export type ServiceView = { durationMinutes: number; status: ServiceStatus; stripeProductId: string; + coachId: string | null; createdAt: Date; updatedAt: Date; title: string | null; @@ -46,6 +47,7 @@ async function buildServiceView(row: typeof services.$inferSelect): Promise( service?.type ?? "programs", ); - const [coachId, setCoachId] = React.useState(""); + const [coachId, setCoachId] = React.useState(service?.coachId ?? ""); const [title, setTitle] = React.useState(service?.title ?? ""); const [description, setDescription] = React.useState( service?.description ?? "", @@ -267,7 +267,7 @@ export function ServiceDialog(props: Props) { React.useEffect(() => { if (service) { setType(service.type); - setCoachId(""); + setCoachId(service.coachId ?? ""); setTitle(service.title ?? ""); setDescription(service.description ?? ""); setDurationMinutes(String(service.durationMinutes ?? 60)); @@ -436,9 +436,16 @@ export function ServiceDialog(props: Props) { {type === "private_lessons" && (
- diff --git a/app/(authenticated)/users/_components/user-actions-cell.tsx b/app/(authenticated)/users/_components/user-actions-cell.tsx index 2a826e2..6285142 100644 --- a/app/(authenticated)/users/_components/user-actions-cell.tsx +++ b/app/(authenticated)/users/_components/user-actions-cell.tsx @@ -1,15 +1,27 @@ "use client"; import { useRef, useState } from "react"; -import { Pencil, Tag } from "lucide-react"; +import { Pencil, Tag, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { DiscountModal, type ActiveDiscount, type DiscountService } from "@/components/discount-modal"; import { getUserDiscountModalData, applyDiscountToCustomerProduct, removeCouponById, } from "@/app/(authenticated)/discounts/actions"; +import { deleteUserAdmin } from "@/app/(authenticated)/users/actions"; import { toast } from "sonner"; import { profileRoleLabel, type UserRow } from "../profile-role-label"; @@ -23,6 +35,8 @@ export function UserActionsCell({ user, onEdit }: UserActionsCellProps) { const [services, setServices] = useState([]); const [discounts, setDiscounts] = useState([]); const [loading, setLoading] = useState(false); + const [deleting, setDeleting] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); const servicesFetched = useRef(false); const fetchModalData = async (forceServices = false) => { @@ -71,6 +85,25 @@ export function UserActionsCell({ user, onEdit }: UserActionsCellProps) { } }; + const handleDelete = async () => { + setDeleting(true); + try { + const fd = new FormData(); + fd.append("user_id", user.id); + const result = await deleteUserAdmin(null, fd); + if (result?.errors) { + toast.error("Failed to delete user", { + description: Object.values(result.errors).flat().join(" "), + }); + } else { + toast.success("User deleted"); + setConfirmOpen(false); + } + } finally { + setDeleting(false); + } + }; + const handleRemove = async (couponId: string) => { if (!user.stripeCustomerId) return; const result = await removeCouponById(couponId, user.stripeCustomerId); @@ -115,6 +148,46 @@ export function UserActionsCell({ user, onEdit }: UserActionsCellProps) { {user.stripeCustomerId ? "Manage discounts" : "No Stripe customer"} + + + + + + + + Delete user + + + + Delete user? + + This permanently deletes {user.firstName} {user.lastName} + ’s account. This action cannot be undone. + + + + + Cancel + + { + e.preventDefault(); + handleDelete(); + }} + disabled={deleting} + > + {deleting ? "Deleting..." : "Delete"} + + + + { + try { + await requireAdmin(); + } catch { + return { errors: { _form: ["Unauthorized"] } }; + } + + const parsed = deleteUserAdminSchema.safeParse({ + user_id: formData.get("user_id"), + }); + + if (!parsed.success) { + return { errors: parsed.error.flatten().fieldErrors }; + } + + const { user_id } = parsed.data; + + // A coach assigned to a private lesson cannot be deleted: the lesson must + // always have a coach (enforced in the DB too via FK restrict + check). + const assigned = await db + .select({ id: services.id }) + .from(services) + .where( + and( + eq(services.coachId, user_id), + eq(services.type, "private_lessons"), + ), + ) + .limit(1); + + if (assigned.length > 0) { + return { + errors: { + _form: [ + "This coach is assigned to one or more private lessons. Reassign those lessons to another coach before deleting the account.", + ], + }, + }; + } + + const admin = createAdminClient(); + const { error: authError } = await admin.auth.admin.deleteUser(user_id); + + if (authError) { + return { errors: { _form: [authError.message] } }; + } + + revalidatePath(USERS_PATH); + return { message: "User deleted." }; +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 82a0871..30f1cf3 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -8,7 +8,9 @@ import { boolean, jsonb, date, + check, } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; export type ProgramSlot = { dayOfWeek: number; time: string }; @@ -62,23 +64,36 @@ export const profiles = pgTable("profiles", { lastLoginAt: timestamp('last_login_at').defaultNow().notNull() }); -export const services = pgTable("services", { - id: uuid("id").primaryKey().defaultRandom(), - type: serviceTypeEnum("type").notNull(), - startDate: date("start_date", { mode: "string" }), - endDate: date("end_date", { mode: "string" }), - slots: jsonb("slots").$type(), - durationMinutes: integer("duration_minutes").notNull(), - stripeProductId: text("stripe_product_id").notNull(), - status: serviceStatusEnum("status").notNull().default("active"), - coachId: uuid("coach_id").references(() => profiles.id, { - onDelete: "set null", - }), - formId: uuid("form_id") - .references(() => forms.id, { onDelete: "set null" }), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), -}); +export const services = pgTable( + "services", + { + id: uuid("id").primaryKey().defaultRandom(), + type: serviceTypeEnum("type").notNull(), + startDate: date("start_date", { mode: "string" }), + endDate: date("end_date", { mode: "string" }), + slots: jsonb("slots").$type(), + durationMinutes: integer("duration_minutes").notNull(), + stripeProductId: text("stripe_product_id").notNull(), + status: serviceStatusEnum("status").notNull().default("active"), + // restrict: a coach assigned to any service cannot be deleted while + // referenced. Combined with the check below this guarantees a private + // lesson always has a coach. + coachId: uuid("coach_id").references(() => profiles.id, { + onDelete: "restrict", + }), + formId: uuid("form_id") + .references(() => forms.id, { onDelete: "set null" }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (t) => [ + // Private lessons must have a coach; programs may be coachless. + check( + "services_private_lessons_require_coach", + sql`${t.type} <> 'private_lessons' OR ${t.coachId} IS NOT NULL`, + ), + ], +); export const serviceBookings = pgTable("service_bookings", { id: uuid("id").primaryKey().defaultRandom(), From 4842a8a064a003185a8f2180a4205934bb612b1f Mon Sep 17 00:00:00 2001 From: maxiDeee Date: Sat, 6 Jun 2026 17:27:39 -0400 Subject: [PATCH 04/52] fix(services): keep coach error showing on repeated private-lesson submits --- __tests__/services-actions.test.ts | 6 +++--- __tests__/users-actions.test.ts | 8 ++++---- app/(authenticated)/services/actions.ts | 6 +++++- app/(authenticated)/services/service-dialog.tsx | 5 ++++- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/__tests__/services-actions.test.ts b/__tests__/services-actions.test.ts index 016b18c..e2b9c6d 100644 --- a/__tests__/services-actions.test.ts +++ b/__tests__/services-actions.test.ts @@ -6,7 +6,7 @@ import { updateService, } from "@/app/(authenticated)/services/actions"; -// --- db mock (chainable query builder) ------------------------------------- +// db mock const insertValues = jest.fn().mockResolvedValue(undefined); const insert = jest.fn(() => ({ values: insertValues })) as jest.Mock; @@ -27,7 +27,7 @@ jest.mock("@/lib/db", () => ({ }, })); -// --- stripe mock ------------------------------------------------------------ +// stripe mock const createProduct = jest.fn(); const createPrice = jest.fn(); const updateProduct = jest.fn(); @@ -43,7 +43,7 @@ jest.mock("@/lib/stripe", () => ({ getStripeServiceData: (...args: unknown[]) => getStripeServiceData(...args), })); -// --- auth + cache mocks ----------------------------------------------------- +// auth + cache mocks const requireAdmin = jest.fn(); jest.mock("@/lib/auth/require-admin", () => ({ requireAdmin: (...args: unknown[]) => requireAdmin(...args), diff --git a/__tests__/users-actions.test.ts b/__tests__/users-actions.test.ts index e7a2f8f..ab059bd 100644 --- a/__tests__/users-actions.test.ts +++ b/__tests__/users-actions.test.ts @@ -3,7 +3,7 @@ */ import { deleteUserAdmin } from "@/app/(authenticated)/users/actions"; -// --- db mock (chainable select builder) ------------------------------------ +// db mock const selectLimit = jest.fn(); const selectWhere = jest.fn(() => ({ limit: selectLimit })); const selectFrom = jest.fn(() => ({ where: selectWhere })); @@ -15,7 +15,7 @@ jest.mock("@/lib/db", () => ({ }, })); -// --- supabase admin mock ---------------------------------------------------- +// supabase admin mock const deleteUser = jest.fn(); jest.mock("@/utils/supabase/admin", () => ({ createAdminClient: () => ({ @@ -23,7 +23,7 @@ jest.mock("@/utils/supabase/admin", () => ({ }), })); -// --- auth + cache + stripe mocks ------------------------------------------- +// auth + cache + stripe mocks const requireAdmin = jest.fn(); jest.mock("@/lib/auth/require-admin", () => ({ requireAdmin: (...args: unknown[]) => requireAdmin(...args), @@ -32,7 +32,7 @@ jest.mock("@/lib/auth/require-admin", () => ({ jest.mock("next/cache", () => ({ revalidatePath: jest.fn() })); // actions.ts imports grantComplimentarySubscription from @/lib/stripe, which -// instantiates the Stripe client at module load — mock it away. +// instantiates the Stripe client at module load. jest.mock("@/lib/stripe", () => ({ grantComplimentarySubscription: jest.fn(), })); diff --git a/app/(authenticated)/services/actions.ts b/app/(authenticated)/services/actions.ts index fe08af8..1a83436 100644 --- a/app/(authenticated)/services/actions.ts +++ b/app/(authenticated)/services/actions.ts @@ -120,7 +120,11 @@ function parseProgramSchedule(formData: FormData): ParseResult function parseCoachId(formData: FormData): ParseResult { const raw = field(formData, "coach_id"); - if (!raw) return { ok: false, errors: { coach_id: ["Select a coach"] } }; + if (!raw) + return { + ok: false, + errors: { coach_id: ["A coach is required for private lessons"] }, + }; const result = z.string().uuid().safeParse(raw); if (!result.success) { return { ok: false, errors: { coach_id: ["Invalid coach"] } }; diff --git a/app/(authenticated)/services/service-dialog.tsx b/app/(authenticated)/services/service-dialog.tsx index 3c35e33..7810c57 100644 --- a/app/(authenticated)/services/service-dialog.tsx +++ b/app/(authenticated)/services/service-dialog.tsx @@ -370,8 +370,11 @@ export function ServiceDialog(props: Props) {
+ {/* React 19 resets the
after every action, + which reverts a Radix Select's native { - const type = v as ExtraQuestionType; + const type = v as FormQuestionType; setDraft((prev) => { if (!prev) return prev; return { @@ -227,7 +227,7 @@ export function QuestionEditDialog({ {( - Object.keys(QUESTION_TYPE_LABELS) as ExtraQuestionType[] + Object.keys(QUESTION_TYPE_LABELS) as FormQuestionType[] ).map((t) => ( {QUESTION_TYPE_LABELS[t]} diff --git a/app/(authenticated)/forms/actions.ts b/app/(authenticated)/forms/actions.ts index 1447328..9ade36c 100644 --- a/app/(authenticated)/forms/actions.ts +++ b/app/(authenticated)/forms/actions.ts @@ -4,7 +4,7 @@ import { revalidatePath, updateTag } from "next/cache"; import { count, eq } from "drizzle-orm"; import { z } from "zod"; import { db } from "@/lib/db"; -import { extraQuestions, forms, services } from "@/lib/db/schema"; +import { formQuestions, forms, services } from "@/lib/db/schema"; import { requireAdmin } from "@/lib/auth/require-admin"; import { createFormSchema, @@ -93,7 +93,7 @@ async function insertQuestions( ) { if (questions.length === 0) return; - await client.insert(extraQuestions).values( + await client.insert(formQuestions).values( questions.map((q, index) => ({ formId, sortOrder: index, @@ -185,8 +185,8 @@ export async function updateForm( .where(eq(forms.id, form_id)); await tx - .delete(extraQuestions) - .where(eq(extraQuestions.formId, form_id)); + .delete(formQuestions) + .where(eq(formQuestions.formId, form_id)); await insertQuestions(form_id, questions, tx); }); } catch { diff --git a/app/(authenticated)/forms/queries.ts b/app/(authenticated)/forms/queries.ts index 08f4fc0..eff8ed0 100644 --- a/app/(authenticated)/forms/queries.ts +++ b/app/(authenticated)/forms/queries.ts @@ -1,6 +1,6 @@ import { - ExtraQuestionOption, - extraQuestions, + FormQuestionOption, + formQuestions, forms, services, } from "@/lib/db/schema"; @@ -8,14 +8,14 @@ import { cacheTag } from "next/cache"; import { db } from "@/lib/db"; import { asc, count, desc, eq, sql } from "drizzle-orm"; -export type ExtraQuestionType = "text" | "multiple_choices" | "checkboxes" | "user_agreement"; +export type FormQuestionType = "text" | "multiple_choices" | "checkboxes" | "user_agreement"; export type QuestionView = { id: string; sortOrder: number; - type: ExtraQuestionType; + type: FormQuestionType; prompt: string; - options: ExtraQuestionOption[] | null; + options: FormQuestionOption[] | null; } export type FormListItem = { @@ -42,11 +42,11 @@ export async function listForms(): Promise { name: forms.name, createdAt: forms.createdAt, updatedAt: forms.updatedAt, - questionCount: sql`cast(count(distinct ${extraQuestions.id}) as integer)`, + questionCount: sql`cast(count(distinct ${formQuestions.id}) as integer)`, attachedServiceCount: sql`cast(count(distinct ${services.id}) as integer)`, }) .from(forms) - .leftJoin(extraQuestions, eq(extraQuestions.formId, forms.id)) + .leftJoin(formQuestions, eq(formQuestions.formId, forms.id)) .leftJoin(services, eq(services.formId, forms.id)) .groupBy(forms.id, forms.name, forms.createdAt, forms.updatedAt) .orderBy(desc(forms.createdAt)); @@ -76,15 +76,15 @@ export async function getForm(id: string): Promise { const questionRows = await db .select({ - id: extraQuestions.id, - sortOrder: extraQuestions.sortOrder, - type: extraQuestions.type, - prompt: extraQuestions.prompt, - options: extraQuestions.options, + id: formQuestions.id, + sortOrder: formQuestions.sortOrder, + type: formQuestions.type, + prompt: formQuestions.prompt, + options: formQuestions.options, }) - .from(extraQuestions) - .where(eq(extraQuestions.formId, id)) - .orderBy(asc(extraQuestions.sortOrder)); + .from(formQuestions) + .where(eq(formQuestions.formId, id)) + .orderBy(asc(formQuestions.sortOrder)); const [serviceRow] = await db .select({ count: count() }) diff --git a/package-lock.json b/package-lock.json index c1d0a1d..348f3a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "mcld-project", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@stripe/stripe-js": "^9.0.0", "@supabase/ssr": "^0.9.0", "@supabase/supabase-js": "^2.99.3", @@ -20,6 +23,7 @@ "lucide-react": "^0.577.0", "next": "16.2.1", "next-themes": "^0.4.6", + "nodemailer": "^8.0.10", "postgres": "^3.4.8", "radix-ui": "^1.4.3", "react": "19.2.4", @@ -39,6 +43,7 @@ "@testing-library/react": "^16.3.2", "@types/jest": "^29.5.14", "@types/node": "^20", + "@types/nodemailer": "^8.0.0", "@types/react": "^19", "@types/react-dom": "^19", "drizzle-kit": "^0.31.10", @@ -707,6 +712,59 @@ "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", "license": "MIT" }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.57.2", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.57.2.tgz", @@ -5850,6 +5908,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -13136,6 +13204,15 @@ "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", + "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", From 3c28267f499c0590e57bf86fe612fe09e059860e Mon Sep 17 00:00:00 2001 From: Jia Xuan Li <77560326+Jxl-s@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:04:38 -0700 Subject: [PATCH 12/52] feat(services): add service registration views and queries for adults and kids --- .../services/[id]/_components/adult-table.tsx | 77 +++++++ .../[id]/_components/child-info-modal.tsx | 123 +++++++++++ .../services/[id]/_components/kid-table.tsx | 132 ++++++++++++ .../[id]/_components/registered-view.tsx | 46 ++++ app/(authenticated)/services/[id]/page.tsx | 29 +++ app/(authenticated)/services/[id]/queries.ts | 202 ++++++++++++++++++ app/(authenticated)/services/queries.ts | 4 + .../services/services-data-table.tsx | 13 +- 8 files changed, 625 insertions(+), 1 deletion(-) create mode 100644 app/(authenticated)/services/[id]/_components/adult-table.tsx create mode 100644 app/(authenticated)/services/[id]/_components/child-info-modal.tsx create mode 100644 app/(authenticated)/services/[id]/_components/kid-table.tsx create mode 100644 app/(authenticated)/services/[id]/_components/registered-view.tsx create mode 100644 app/(authenticated)/services/[id]/page.tsx create mode 100644 app/(authenticated)/services/[id]/queries.ts diff --git a/app/(authenticated)/services/[id]/_components/adult-table.tsx b/app/(authenticated)/services/[id]/_components/adult-table.tsx new file mode 100644 index 0000000..eadacbf --- /dev/null +++ b/app/(authenticated)/services/[id]/_components/adult-table.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useMemo } from "react"; +import { ColumnDef } from "@tanstack/react-table"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { UsersDataTable } from "@/app/(authenticated)/users/_components/users-data-table"; +import { formatDate } from "@/lib/format"; +import type { AdultRegistration } from "../queries"; + +const STATUS_VARIANT: Record = { + confirmed: "default", + pending: "secondary", + awaiting_payment: "outline", + cancelled: "destructive", +}; + +export function AdultTable({ registrations }: { registrations: AdultRegistration[] }) { + const columns = useMemo[]>( + () => [ + { + id: "profile", + header: "User", + meta: { colWidth: "40%", tdClassName: "whitespace-normal align-middle" }, + cell: ({ row }) => { + const p = row.original.profile; + const initials = `${p.firstName[0] ?? ""}${p.lastName[0] ?? ""}`.toUpperCase(); + return ( +
+ + + {initials} + + +
+ + {p.firstName} {p.lastName} + + {p.email} +
+
+ ); + }, + }, + { + id: "status", + header: "Status", + meta: { colWidth: "20%" }, + cell: ({ row }) => ( + + {row.original.status.replace(/_/g, " ")} + + ), + }, + { + id: "registeredAt", + header: "Registered", + meta: { colWidth: "20%" }, + cell: ({ row }) => ( + + {formatDate(row.original.registeredAt.toISOString().slice(0, 10))} + + ), + }, + ], + [], + ); + + return ( + `${n} registration${n === 1 ? "" : "s"}`} + /> + ); +} diff --git a/app/(authenticated)/services/[id]/_components/child-info-modal.tsx b/app/(authenticated)/services/[id]/_components/child-info-modal.tsx new file mode 100644 index 0000000..924efee --- /dev/null +++ b/app/(authenticated)/services/[id]/_components/child-info-modal.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { formatDate } from "@/lib/format"; +import type { KidRegistration } from "../queries"; + +const GENDER_LABELS: Record = { + male: "Male", + female: "Female", + prefer_not_to_say: "Prefer not to say", +}; + +export function ChildInfoModal({ + registration, + open, + onOpenChange, +}: { + registration: KidRegistration | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + if (!registration) return null; + const { child, formAnswers } = registration; + + return ( + + + + + {child.firstName} {child.lastName} + + + +
+ {/* Profile */} +
+

+ Profile +

+
+
Date of birth
+
{formatDate(child.dob)}
+
Gender
+
{GENDER_LABELS[child.gender] ?? child.gender}
+ {child.allergies && ( + <> +
Allergies
+
{child.allergies}
+ + )} + {child.medicalConditions && ( + <> +
Medical conditions
+
{child.medicalConditions}
+ + )} + {child.medications && ( + <> +
Medications
+
{child.medications}
+ + )} +
+
+ + {/* Emergency contacts */} + {child.emergencyContacts.length > 0 && ( + <> + +
+

+ Emergency Contacts +

+
    + {child.emergencyContacts.map((ec, i) => ( +
  • + Name + {ec.fullName} + Relationship + {ec.relationship} + Email + {ec.emailAddress} + Phone + {ec.phoneNumber} +
  • + ))} +
+
+ + )} + + {/* Form answers */} + {formAnswers.length > 0 && ( + <> + +
+

+ Form Answers +

+
    + {formAnswers.map((qa, i) => ( +
  • + {qa.prompt} + + {qa.answer.join(", ")} + +
  • + ))} +
+
+ + )} +
+
+
+ ); +} diff --git a/app/(authenticated)/services/[id]/_components/kid-table.tsx b/app/(authenticated)/services/[id]/_components/kid-table.tsx new file mode 100644 index 0000000..e5609f5 --- /dev/null +++ b/app/(authenticated)/services/[id]/_components/kid-table.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ColumnDef } from "@tanstack/react-table"; +import { Info } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { UsersDataTable } from "@/app/(authenticated)/users/_components/users-data-table"; +import { formatDate } from "@/lib/format"; +import type { KidRegistration } from "../queries"; +import { ChildInfoModal } from "./child-info-modal"; + +const STATUS_VARIANT: Record = { + confirmed: "default", + pending: "secondary", + awaiting_payment: "outline", + cancelled: "destructive", +}; + +const GENDER_LABELS: Record = { + male: "Male", + female: "Female", + prefer_not_to_say: "Prefer not to say", +}; + +export function KidTable({ registrations }: { registrations: KidRegistration[] }) { + const [selected, setSelected] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + + const columns = useMemo[]>( + () => [ + { + id: "child", + header: "Child", + meta: { colWidth: "22%" }, + cell: ({ row }) => ( + + {row.original.child.firstName} {row.original.child.lastName} + + ), + }, + { + id: "dob", + header: "Date of Birth", + meta: { colWidth: "16%" }, + cell: ({ row }) => ( + + {formatDate(row.original.child.dob)} + + ), + }, + { + id: "gender", + header: "Gender", + meta: { colWidth: "16%" }, + cell: ({ row }) => ( + + {GENDER_LABELS[row.original.child.gender] ?? row.original.child.gender} + + ), + }, + { + id: "parent", + header: "Parent", + meta: { colWidth: "20%" }, + cell: ({ row }) => ( + + {row.original.parent.firstName} {row.original.parent.lastName} + + ), + }, + { + id: "status", + header: "Status", + meta: { colWidth: "14%" }, + cell: ({ row }) => ( + + {row.original.status.replace(/_/g, " ")} + + ), + }, + { + id: "info", + header: () =>
Info
, + meta: { colWidth: "12%", thClassName: "text-right", tdClassName: "text-right" }, + cell: ({ row }) => ( + + + + + View profile + + ), + }, + ], + [], + ); + + return ( + <> + `${n} registration${n === 1 ? "" : "s"}`} + /> + + + ); +} diff --git a/app/(authenticated)/services/[id]/_components/registered-view.tsx b/app/(authenticated)/services/[id]/_components/registered-view.tsx new file mode 100644 index 0000000..1f63a96 --- /dev/null +++ b/app/(authenticated)/services/[id]/_components/registered-view.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Link from "next/link"; +import { ChevronLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import type { ServiceView } from "@/app/(authenticated)/services/queries"; +import type { ServiceRegistrations } from "../queries"; +import { AdultTable } from "./adult-table"; +import { KidTable } from "./kid-table"; + +export function RegisteredView({ + service, + data, +}: { + service: ServiceView; + data: ServiceRegistrations; +}) { + const count = data.registrations.length; + + return ( +
+
+ +
+ +
+

{service.title ?? "Service"}

+ + {count} registered + +
+ + {data.kind === "adult" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/app/(authenticated)/services/[id]/page.tsx b/app/(authenticated)/services/[id]/page.tsx new file mode 100644 index 0000000..bb0b0f5 --- /dev/null +++ b/app/(authenticated)/services/[id]/page.tsx @@ -0,0 +1,29 @@ +import { redirect } from "next/navigation"; +import { requireAdmin } from "@/lib/auth/require-admin"; +import { getService } from "@/app/(authenticated)/services/queries"; +import { getServiceRegistrations } from "./queries"; +import { RegisteredView } from "./_components/registered-view"; + +export default async function ServiceRegisteredPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + try { + await requireAdmin(); + } catch { + redirect("/"); + } + + const { id } = await params; + const service = await getService(id); + if (!service) redirect("/services"); + + const registrations = await getServiceRegistrations(id); + + return ( +
+ +
+ ); +} diff --git a/app/(authenticated)/services/[id]/queries.ts b/app/(authenticated)/services/[id]/queries.ts new file mode 100644 index 0000000..c50428f --- /dev/null +++ b/app/(authenticated)/services/[id]/queries.ts @@ -0,0 +1,202 @@ +import { cacheTag } from "next/cache"; +import { eq, inArray } from "drizzle-orm"; +import { pgSchema, uuid, text } from "drizzle-orm/pg-core"; + +import { db } from "@/lib/db"; +import { + children, + emergencyContacts, + formQuestionAnswers, + formQuestions, + profiles, + serviceBookings, + services, +} from "@/lib/db/schema"; + +const auth = pgSchema("auth"); +const authUsers = auth.table("users", { + id: uuid("id").primaryKey(), + email: text("email"), +}); + +const SERVICES_TAG = "services"; + +export type AdultRegistration = { + bookingId: string; + status: string; + registeredAt: Date; + profile: { id: string; firstName: string; lastName: string; email: string }; +}; + +export type KidRegistration = { + bookingId: string; + status: string; + registeredAt: Date; + child: { + id: string; + firstName: string; + lastName: string; + dob: string; + gender: string; + allergies: string | null; + medicalConditions: string | null; + medications: string | null; + emergencyContacts: { + fullName: string; + emailAddress: string; + phoneNumber: string; + relationship: string; + }[]; + }; + parent: { firstName: string; lastName: string; email: string }; + formAnswers: { prompt: string; answer: string[] }[]; +}; + +export type ServiceRegistrations = + | { kind: "adult"; registrations: AdultRegistration[] } + | { kind: "kid"; registrations: KidRegistration[] }; + +export async function getServiceRegistrations( + serviceId: string, +): Promise { + "use cache"; + cacheTag(SERVICES_TAG); + + const [service] = await db + .select({ isForChildren: services.isForChildren, formId: services.formId }) + .from(services) + .where(eq(services.id, serviceId)) + .limit(1); + + if (!service) return { kind: "adult", registrations: [] }; + + if (!service.isForChildren) { + const rows = await db + .select({ + bookingId: serviceBookings.id, + status: serviceBookings.status, + registeredAt: serviceBookings.createdAt, + profileId: profiles.id, + firstName: profiles.firstName, + lastName: profiles.lastName, + email: authUsers.email, + }) + .from(serviceBookings) + .innerJoin(profiles, eq(profiles.id, serviceBookings.userId)) + .innerJoin(authUsers, eq(authUsers.id, serviceBookings.userId)) + .where(eq(serviceBookings.serviceId, serviceId)); + + return { + kind: "adult", + registrations: rows.map((r) => ({ + bookingId: r.bookingId, + status: r.status, + registeredAt: r.registeredAt, + profile: { + id: r.profileId, + firstName: r.firstName, + lastName: r.lastName, + email: r.email ?? "", + }, + })), + }; + } + + // Kid service: bookings link to a child via childId + const bookingRows = await db + .select({ + bookingId: serviceBookings.id, + status: serviceBookings.status, + registeredAt: serviceBookings.createdAt, + childId: children.id, + childFirstName: children.firstName, + childLastName: children.lastName, + childDob: children.dob, + childGender: children.gender, + childAllergies: children.allergies, + childMedicalConditions: children.medicalConditions, + childMedications: children.medications, + parentFirstName: profiles.firstName, + parentLastName: profiles.lastName, + parentEmail: authUsers.email, + }) + .from(serviceBookings) + .innerJoin(children, eq(children.id, serviceBookings.childId)) + .innerJoin(profiles, eq(profiles.id, children.parentId)) + .innerJoin(authUsers, eq(authUsers.id, children.parentId)) + .where(eq(serviceBookings.serviceId, serviceId)); + + if (bookingRows.length === 0) return { kind: "kid", registrations: [] }; + + const childIds = [...new Set(bookingRows.map((r) => r.childId))]; + + const contactRows = await db + .select({ + childId: emergencyContacts.childId, + fullName: emergencyContacts.fullName, + emailAddress: emergencyContacts.emailAddress, + phoneNumber: emergencyContacts.phoneNumber, + relationship: emergencyContacts.relationship, + }) + .from(emergencyContacts) + .where(inArray(emergencyContacts.childId, childIds)); + + const contactsByChild = new Map< + string, + { fullName: string; emailAddress: string; phoneNumber: string; relationship: string }[] + >(); + for (const c of contactRows) { + const list = contactsByChild.get(c.childId) ?? []; + list.push(c); + contactsByChild.set(c.childId, list); + } + + const answersByChild = new Map(); + if (service.formId) { + const answerRows = await db + .select({ + childId: formQuestionAnswers.childId, + prompt: formQuestions.prompt, + answer: formQuestionAnswers.answer, + }) + .from(formQuestionAnswers) + .innerJoin( + formQuestions, + eq(formQuestions.id, formQuestionAnswers.formQuestionId), + ) + .where(eq(formQuestions.formId, service.formId)); + + for (const a of answerRows) { + if (!childIds.includes(a.childId)) continue; + const list = answersByChild.get(a.childId) ?? []; + list.push({ prompt: a.prompt, answer: a.answer }); + answersByChild.set(a.childId, list); + } + } + + return { + kind: "kid", + registrations: bookingRows.map((r) => ({ + bookingId: r.bookingId, + status: r.status, + registeredAt: r.registeredAt, + child: { + id: r.childId, + firstName: r.childFirstName, + lastName: r.childLastName, + dob: r.childDob, + gender: r.childGender, + allergies: r.childAllergies, + medicalConditions: r.childMedicalConditions, + medications: r.childMedications, + emergencyContacts: contactsByChild.get(r.childId) ?? [], + }, + parent: { + firstName: r.parentFirstName, + lastName: r.parentLastName, + email: r.parentEmail ?? "", + }, + formAnswers: answersByChild.get(r.childId) ?? [], + })), + }; +} diff --git a/app/(authenticated)/services/queries.ts b/app/(authenticated)/services/queries.ts index db7b6d1..c67c565 100644 --- a/app/(authenticated)/services/queries.ts +++ b/app/(authenticated)/services/queries.ts @@ -17,6 +17,8 @@ export type ServiceType = "private_lessons" | "programs"; export type ServiceView = { id: string; type: ServiceType; + isForChildren: boolean; + formId: string | null; scheduledAt: ProgramSchedule | null; durationMinutes: number; status: ServiceStatus; @@ -48,6 +50,8 @@ async function buildServiceView( return { id: row.id, type: row.type, + isForChildren: row.isForChildren, + formId: row.formId, scheduledAt: rowToSchedule(row), durationMinutes: row.durationMinutes, status: row.status, diff --git a/app/(authenticated)/services/services-data-table.tsx b/app/(authenticated)/services/services-data-table.tsx index 268ce43..1c479e8 100644 --- a/app/(authenticated)/services/services-data-table.tsx +++ b/app/(authenticated)/services/services-data-table.tsx @@ -1,8 +1,9 @@ "use client"; import * as React from "react"; +import Link from "next/link"; import { ColumnDef } from "@tanstack/react-table"; -import { Archive, ArchiveRestore, Ban, Pencil, Power } from "lucide-react"; +import { Archive, ArchiveRestore, Ban, Pencil, Power, Users } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -84,6 +85,16 @@ export function ServicesDataTable({ const s = row.original; return (
+ + + + + View registered + {(s.status === "active" || s.status === "disabled") && ( From 313afdb9880568d1e2596d5a8d6093bffa4cc372 Mon Sep 17 00:00:00 2001 From: Jia Xuan Li <77560326+Jxl-s@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:23:24 -0700 Subject: [PATCH 13/52] feat(layout): wrap AppSidebar in Suspense for improved loading experience feat(service): add Suspense wrapper with spinner for ServiceRegisteredPage --- app/(authenticated)/layout.tsx | 4 +++- app/(authenticated)/services/[id]/page.tsx | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/(authenticated)/layout.tsx b/app/(authenticated)/layout.tsx index 9af0930..d356f6c 100644 --- a/app/(authenticated)/layout.tsx +++ b/app/(authenticated)/layout.tsx @@ -27,7 +27,9 @@ export default function AuthenticatedLayout({ return ( - + + +
diff --git a/app/(authenticated)/services/[id]/page.tsx b/app/(authenticated)/services/[id]/page.tsx index bb0b0f5..5f9ded7 100644 --- a/app/(authenticated)/services/[id]/page.tsx +++ b/app/(authenticated)/services/[id]/page.tsx @@ -1,14 +1,27 @@ +import { Suspense } from "react"; +import { connection } from "next/server"; import { redirect } from "next/navigation"; +import { Spinner } from "@/components/ui/spinner"; import { requireAdmin } from "@/lib/auth/require-admin"; import { getService } from "@/app/(authenticated)/services/queries"; import { getServiceRegistrations } from "./queries"; import { RegisteredView } from "./_components/registered-view"; -export default async function ServiceRegisteredPage({ +export default function ServiceRegisteredPage({ params, }: { params: Promise<{ id: string }>; }) { + return ( + }> + + + ); +} + +async function PageContent({ params }: { params: Promise<{ id: string }> }) { + await connection(); + try { await requireAdmin(); } catch { From 7728d25a1ff45543d73ff91fdeb6033dfb4e8455 Mon Sep 17 00:00:00 2001 From: Jia Xuan Li <77560326+Jxl-s@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:45:22 -0700 Subject: [PATCH 14/52] feat(services): integrate forms into ServicesPage, ServiceDialog, and ServicesTable components --- app/(authenticated)/services/page.tsx | 6 +- .../services/service-dialog.tsx | 55 ++++++++++++++++++- .../services/services-table.tsx | 6 +- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/app/(authenticated)/services/page.tsx b/app/(authenticated)/services/page.tsx index be7d832..2559d7e 100644 --- a/app/(authenticated)/services/page.tsx +++ b/app/(authenticated)/services/page.tsx @@ -1,16 +1,18 @@ import { listCoaches, listServices } from "./queries"; +import { listForms } from "@/app/(authenticated)/forms/queries"; import { ServicesTable } from "./services-table"; export default async function ServicesPage() { - const [services, coaches] = await Promise.all([ + const [services, coaches, forms] = await Promise.all([ listServices(), listCoaches(), + listForms(), ]); return (

Services

- +
); } diff --git a/app/(authenticated)/services/service-dialog.tsx b/app/(authenticated)/services/service-dialog.tsx index ddb2ed6..86a34c4 100644 --- a/app/(authenticated)/services/service-dialog.tsx +++ b/app/(authenticated)/services/service-dialog.tsx @@ -48,7 +48,9 @@ import type { ServiceView, } from "@/app/(authenticated)/services/queries"; -type Props = { coaches: CoachOption[] } & ( +type FormOption = { id: string; name: string }; + +type Props = { coaches: CoachOption[]; forms: FormOption[] } & ( | { mode: "add" } | { mode: "edit"; @@ -243,12 +245,16 @@ function ProgramScheduleFields({ export function ServiceDialog(props: Props) { const isEdit = props.mode === "edit"; const service = isEdit ? props.service : null; - const { coaches } = props; + const { coaches, forms } = props; const [type, setType] = React.useState<"programs" | "private_lessons">( service?.type ?? "programs", ); const [coachId, setCoachId] = React.useState(service?.coachId ?? ""); + const [isForChildren, setIsForChildren] = React.useState( + service?.isForChildren ?? false, + ); + const [formId, setFormId] = React.useState(service?.formId ?? ""); const [title, setTitle] = React.useState(service?.title ?? ""); const [description, setDescription] = React.useState( service?.description ?? "", @@ -268,6 +274,8 @@ export function ServiceDialog(props: Props) { if (service) { setType(service.type); setCoachId(service.coachId ?? ""); + setIsForChildren(service.isForChildren ?? false); + setFormId(service.formId ?? ""); setTitle(service.title ?? ""); setDescription(service.description ?? ""); setDurationMinutes(String(service.durationMinutes ?? 60)); @@ -287,6 +295,8 @@ export function ServiceDialog(props: Props) { closeRef.current?.click(); setType("programs"); setCoachId(""); + setIsForChildren(false); + setFormId(""); setTitle(""); setDescription(""); setDurationMinutes("60"); @@ -429,6 +439,47 @@ export function ServiceDialog(props: Props) {
+
+ +
+ setIsForChildren(e.target.checked)} + className="size-4 rounded border-input accent-primary" + /> + +
+
+ + {isForChildren && ( +
+ + + +
+ )} + {type === "programs" && ( ("active"); const [editing, setEditing] = React.useState(null); @@ -42,7 +45,7 @@ export function ServicesTable({ ))} - +
@@ -52,6 +55,7 @@ export function ServicesTable({ { From 818f2a38da1b5205da77e7ce45d36a74bb180997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CF=BBartin?= <51332188+martin0024@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:53:31 +0200 Subject: [PATCH 15/52] refactor(checkout): remove checkout and scheduling actions, tests, and related components. --- app/(authenticated)/checkout/actions.test.ts | 103 ------- app/(authenticated)/checkout/actions.ts | 198 ------------ app/scheduling/actions.ts | 287 ------------------ .../coach/[token]/coach-scheduler.tsx | 190 ------------ app/scheduling/coach/[token]/page.tsx | 92 ------ .../confirm/[token]/client-confirm.tsx | 201 ------------ app/scheduling/confirm/[token]/page.tsx | 106 ------- app/scheduling/notifications.ts | 163 ---------- app/scheduling/queries.ts | 114 ------- app/scheduling/token-actions.ts | 184 ----------- components/scheduling/availability-form.tsx | 145 --------- components/scheduling/message-your-coach.tsx | 42 --- lib/scheduling/overlap.ts | 63 ---- lib/scheduling/token.ts | 5 - 14 files changed, 1893 deletions(-) delete mode 100644 app/(authenticated)/checkout/actions.test.ts delete mode 100644 app/(authenticated)/checkout/actions.ts delete mode 100644 app/scheduling/actions.ts delete mode 100644 app/scheduling/coach/[token]/coach-scheduler.tsx delete mode 100644 app/scheduling/coach/[token]/page.tsx delete mode 100644 app/scheduling/confirm/[token]/client-confirm.tsx delete mode 100644 app/scheduling/confirm/[token]/page.tsx delete mode 100644 app/scheduling/notifications.ts delete mode 100644 app/scheduling/queries.ts delete mode 100644 app/scheduling/token-actions.ts delete mode 100644 components/scheduling/availability-form.tsx delete mode 100644 components/scheduling/message-your-coach.tsx delete mode 100644 lib/scheduling/overlap.ts delete mode 100644 lib/scheduling/token.ts diff --git a/app/(authenticated)/checkout/actions.test.ts b/app/(authenticated)/checkout/actions.test.ts deleted file mode 100644 index d438a0f..0000000 --- a/app/(authenticated)/checkout/actions.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * @jest-environment node - */ -import { checkoutDonation } from "./actions"; - -const createSession = jest.fn(); -const retrieveProduct = jest.fn(); -const retrievePrice = jest.fn(); -const getUser = jest.fn(); -const getOrCreateStripeCustomer = jest.fn(); -const headersGet = jest.fn(); - -jest.mock("@/lib/stripe", () => ({ - stripe: { - checkout: { sessions: { create: (...args: unknown[]) => createSession(...args) } }, - products: { retrieve: (...args: unknown[]) => retrieveProduct(...args) }, - prices: { retrieve: (...args: unknown[]) => retrievePrice(...args) }, - }, - getOrCreateStripeCustomer: (...args: unknown[]) => - getOrCreateStripeCustomer(...args), -})); - -jest.mock("@/utils/supabase/server", () => ({ - createClient: async () => ({ auth: { getUser } }), -})); - -jest.mock("next/headers", () => ({ - headers: async () => ({ get: (...args: unknown[]) => headersGet(...args) }), -})); - -const authedUser = { - data: { user: { id: "user-1", email: "donor@test.com" } }, -}; - -beforeEach(() => { - jest.clearAllMocks(); - process.env.STRIPE_DONATION_PRODUCT_ID = "prod_test123"; - getUser.mockResolvedValue(authedUser); - getOrCreateStripeCustomer.mockResolvedValue("cus_123"); - retrieveProduct.mockResolvedValue({ default_price: "price_donation" }); - retrievePrice.mockResolvedValue({ custom_unit_amount: { preset: 1000 } }); - createSession.mockResolvedValue({ url: "https://stripe.test/session" }); - headersGet.mockReturnValue("https://app.test"); -}); - -describe("checkoutDonation", () => { - it("requires an authenticated user", async () => { - getUser.mockResolvedValue({ data: { user: null } }); - const result = await checkoutDonation(); - expect(result).toEqual({ error: "Not authenticated" }); - expect(createSession).not.toHaveBeenCalled(); - }); - - it("returns an error when the donation product env var is not set", async () => { - delete process.env.STRIPE_DONATION_PRODUCT_ID; - const result = await checkoutDonation(); - expect(result).toEqual({ error: "Donation product not configured" }); - expect(createSession).not.toHaveBeenCalled(); - }); - - it("creates a checkout session using the donation product's default price", async () => { - const result = await checkoutDonation(); - - expect(retrieveProduct).toHaveBeenCalledWith("prod_test123"); - expect(getOrCreateStripeCustomer).toHaveBeenCalledWith( - "user-1", - "donor@test.com", - ); - expect(createSession).toHaveBeenCalledWith( - expect.objectContaining({ - customer: "cus_123", - mode: "payment", - metadata: { type: "donation" }, - line_items: [{ price: "price_donation", quantity: 1 }], - }), - ); - expect(result).toEqual({ url: "https://stripe.test/session" }); - }); - - it("derives success/cancel URLs from the request origin", async () => { - await checkoutDonation(); - expect(createSession).toHaveBeenCalledWith( - expect.objectContaining({ - success_url: "https://app.test/checkout/success", - cancel_url: "https://app.test/checkout/cancel", - }), - ); - }); - - it("returns an error when Stripe omits the checkout URL", async () => { - createSession.mockResolvedValue({ url: null }); - const result = await checkoutDonation(); - expect(result).toEqual({ error: "Stripe did not return a checkout URL" }); - }); - - it("throws when the request origin is missing", async () => { - headersGet.mockReturnValue(null); - await expect(checkoutDonation()).rejects.toThrow( - "Missing request origin", - ); - expect(createSession).not.toHaveBeenCalled(); - }); -}); diff --git a/app/(authenticated)/checkout/actions.ts b/app/(authenticated)/checkout/actions.ts deleted file mode 100644 index 7cdd042..0000000 --- a/app/(authenticated)/checkout/actions.ts +++ /dev/null @@ -1,198 +0,0 @@ -"use server"; - -import { headers } from "next/headers"; -import { and, eq } from "drizzle-orm"; -import type Stripe from "stripe"; - -import { db } from "@/lib/db"; -import { coachingSessions, serviceBookings, services } from "@/lib/db/schema"; -import { getOrCreateStripeCustomer, stripe } from "@/lib/stripe"; -import { createClient } from "@/utils/supabase/server"; - -export type CheckoutResult = { url: string } | { error: string }; - -async function getDefaultPriceId(stripeProductId: string): Promise { - const product = await stripe.products.retrieve(stripeProductId); - const defaultPriceId = product.default_price; - if (!defaultPriceId) { - throw new Error(`Stripe product ${stripeProductId} has no default price`); - } - return defaultPriceId as string; -} - -async function getRequestOrigin(): Promise { - const origin = (await headers()).get("origin"); - if (!origin) { - throw new Error("Missing request origin"); - } - return origin; -} - -type CreateSessionResult = - | { session: Stripe.Checkout.Session } - | { error: string }; - -async function createStripeCheckoutSession(params: { - userId: string; - email: string; - stripeProductId: string; - metadata: Record; -}): Promise { - const priceId = await getDefaultPriceId(params.stripeProductId); - - const customerId = await getOrCreateStripeCustomer( - params.userId, - params.email, - ); - const origin = await getRequestOrigin(); - - const session = await stripe.checkout.sessions.create({ - customer: customerId, - mode: "payment", - payment_method_types: ["card"], - line_items: [{ price: priceId, quantity: 1 }], - success_url: `${origin}/checkout/success`, - cancel_url: `${origin}/checkout/cancel`, - metadata: params.metadata, - }); - - if (!session.url) - return { error: "Stripe did not return a checkout URL" }; - return { session }; -} - -export async function checkoutServiceBooking({ - serviceId, -}: { - serviceId: string; -}): Promise { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) return { error: "Not authenticated" }; - - const service = await db.query.services.findFirst({ - where: eq(services.id, serviceId), - }); - if (!service) return { error: "Service not found" }; - if (service.status !== "active") - return { error: "Service is not available" }; - if (service.type !== "programs") - return { error: "Service is not a program" }; - - const [row] = await db - .insert(serviceBookings) - .values({ - userId: user.id, - serviceId: service.id, - status: "awaiting_payment", - }) - .returning({ id: serviceBookings.id }); - - const result = await createStripeCheckoutSession({ - userId: user.id, - email: user.email!, - stripeProductId: service.stripeProductId, - metadata: { - type: "program", - bookingId: row.id, - }, - }); - if ("error" in result) { - await db.delete(serviceBookings).where(eq(serviceBookings.id, row.id)); - return { error: result.error }; - } - - await db - .update(serviceBookings) - .set({ stripeOrderId: result.session.id }) - .where(eq(serviceBookings.id, row.id)); - - return { url: result.session.url! }; -} - -export async function checkoutDonation(): Promise { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) return { error: "Not authenticated" }; - - // This product can have a variable price, adjusted by Stripe Checkout - const donationProductId = process.env.STRIPE_DONATION_PRODUCT_ID; - if (!donationProductId) return { error: "Donation product not configured" }; - - const product = await stripe.products.retrieve(donationProductId); - const priceId = product.default_price; - - if (!priceId || typeof priceId !== "string") { - return { error: "Donation product has no default price" }; - } - - const price = await stripe.prices.retrieve(priceId); - if (!price.custom_unit_amount) { - return { error: "Donation price is not configured for customer chosen amounts" }; - } - - const result = await createStripeCheckoutSession({ - userId: user.id, - email: user.email!, - stripeProductId: donationProductId, - metadata: { type: "donation" }, - }); - if ("error" in result) return { error: result.error }; - - return { url: result.session.url! }; -} - -export async function checkoutCoachingSession({ - coachingSessionId, -}: { - coachingSessionId: string; -}): Promise { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) return { error: "Not authenticated" }; - - const row = await db.query.coachingSessions.findFirst({ - where: and( - eq(coachingSessions.id, coachingSessionId), - eq(coachingSessions.userId, user.id), - ), - }); - if (!row) return { error: "Coaching session not found" }; - if (row.status !== "awaiting_payment") - return { error: "Coaching session is not awaiting payment" }; - - const service = await db.query.services.findFirst({ - where: eq(services.id, row.serviceId), - }); - if (!service) return { error: "Service not found" }; - - const result = await createStripeCheckoutSession({ - userId: user.id, - email: user.email!, - stripeProductId: service.stripeProductId, - metadata: { - type: "private_lesson", - coachingSessionId: row.id, - }, - }); - if ("error" in result) { - await db - .delete(coachingSessions) - .where(eq(coachingSessions.id, row.id)); - return { error: result.error }; - } - - await db - .update(coachingSessions) - .set({ stripeOrderId: result.session.id }) - .where(eq(coachingSessions.id, row.id)); - - return { url: result.session.url! }; -} - diff --git a/app/scheduling/actions.ts b/app/scheduling/actions.ts deleted file mode 100644 index 38f1fdb..0000000 --- a/app/scheduling/actions.ts +++ /dev/null @@ -1,287 +0,0 @@ -"use server"; - -import { eq,and } from "drizzle-orm"; -import { z } from "zod"; -import { db } from "@/lib/db"; -import { coachingSessions } from "@/lib/db/schema"; -import { createClient } from "@/utils/supabase/server"; - -export type TimeSlot = { start: string; end: string }; - -export type SchedulingActionState = { - errors?: Record; - message?: string; -} | null; - -const iso8601 = z.string().datetime({offset: true, message: "Must be an ISO 8601 datetime with timezone"}); - -const timeSlotSchema = z - .object({ - start: iso8601, - end: iso8601, - }) - .refine((slot) => new Date(slot.end) > new Date(slot.start), { - message: "End time must be after start time", - }); - -const selectAvailabilitiesSchema = z.object({ - session_id: z.string().uuid("Invalid session ID"), - time_slots: z - .array(timeSlotSchema) - .min(1, "At least one time slot is required") - .max(50,"You can select up to 50 time slots"), - notes: z - .string() - .max(2000, "Message must be 2000 characters or fewer") - .optional(), -}); - -const selectTimeSlotSchema = z.object({ - session_id: z.string().uuid("Invalid session ID"), - start: iso8601, - end: iso8601, -}); - -async function getAuthenticatedUserId(): Promise { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - return user?.id ?? null; -} - -/** - * Fetch a coaching session and verify the caller is either the coach or user. - * Returns the session row or an error state. - */ -async function getAuthorizedSession( - sessionId: string, - userId: string, -): Promise< - | { ok: true; session: typeof coachingSessions.$inferSelect } - | { ok: false; state: SchedulingActionState } -> { - const [session] = await db - .select() - .from(coachingSessions) - .where(eq(coachingSessions.id, sessionId)) - .limit(1); - - if (!session) { - return { - ok: false, - state: { errors: { _form: ["Coaching session not found"] } }, - }; - } - - if (session.coachId !== userId && session.userId !== userId) { - return { - ok: false, - state: { errors: { _form: ["You are not authorized to modify this session"] } }, - }; - } - - return { ok: true, session }; -} - - -function parseJsonField(formData: FormData, key:string): T | null { - const value = formData.get(key); - if (typeof value !== "string") return null; - try { - return JSON.parse(value) as T; - } catch { - return null - } -} - -function normalizeSlots(slot: TimeSlot): TimeSlot { - return { - start: new Date(slot.start).toISOString(), - end: new Date(slot.end).toISOString() - } -} - - -/** - * Set (or replace) the available time slots on an existing coaching session. - * - * The payload is an array of `{ start, end }` ISO 8601 strings. - */ -export async function selectAvailabilities( - _prev: SchedulingActionState, - formData: FormData, -): Promise { - // 1. Auth gate - const callerId = await getAuthenticatedUserId(); - if (!callerId) { - return { errors: { _form: ["You must be logged in"] } }; - } - - // 2. Parse & validate input - const rawTimeSlots = parseJsonField(formData, "time_slots"); - if (!rawTimeSlots) { - return {errors: {time_slots: ["Invalid time slots format"]}}; - } - - const rawNotes = formData.get("notes"); - const parsed = selectAvailabilitiesSchema.safeParse({ - session_id: formData.get("session_id")?.toString(), - time_slots: rawTimeSlots, - notes: - typeof rawNotes === "string" && rawNotes.trim().length > 0 - ? rawNotes - : undefined, - }) - - if (!parsed.success) { - return {errors: parsed.error.flatten().fieldErrors} - } - - const sessionId = parsed.data.session_id; - const timeSlots = parsed.data.time_slots.map(normalizeSlots) - const notes = parsed.data.notes ?? null; - - // 3. Authorization: caller must be the coach or user on this session - const result = await getAuthorizedSession(sessionId, callerId); - if (!result.ok) { - return result.state; - } - - // 4. Only pending sessions can have their availabilities changed - if (result.session.status !== "pending") { - return { - errors: { - _form: ["Availabilities can only be set for pending sessions"], - }, - }; - } - - // 5. Update the session - try { - await db - .update(coachingSessions) - .set({ - selectedTimeSlots: timeSlots, - notes, - updatedAt: new Date(), - }) - .where(eq(coachingSessions.id, sessionId)); - } catch (e) { - console.error("[SELECT_AVAILABILITIES]", e); - return { - errors: { - _form: ["Could not update availabilities"], - }, - }; - } - - return { message: "Availabilities updated." }; -} - -/** - * Select a confirmed time slot for an existing coaching session. - * - * The chosen `{ start, end }` must be one of the `selected_time_slots` - * already stored on the session. On success, `scheduled_at` is set to the - * slot's start time and the session status becomes `confirmed`. - */ -export async function selectTimeSlot( - _prev: SchedulingActionState, - formData: FormData, -): Promise { - // 1. Auth gate - const callerId = await getAuthenticatedUserId(); - if (!callerId) { - return { errors: { _form: ["You must be logged in"] } }; - } - - // 2. Parse & validate input - const parsed = selectTimeSlotSchema.safeParse({ - session_id: formData.get("session_id")?.toString(), - start: formData.get("start")?.toString(), - end: formData.get("end")?.toString(), - }); - - if (!parsed.success) { - return { errors: parsed.error.flatten().fieldErrors }; - } - - const { session_id} = parsed.data; - const {start, end} = normalizeSlots({start: parsed.data.start, end: parsed.data.end}) - - // 3. Authorization - const result = await getAuthorizedSession(session_id, callerId); - if (!result.ok) { - return result.state; - } - - const { session } = result; - - // 4. Only pending sessions can be confirmed - if (session.status !== "pending") { - return { - errors: { - _form: ["Only pending sessions can be confirmed"], - }, - }; - } - - // 5. Verify the slot exists in the session's available time slots - const slots = session.selectedTimeSlots as TimeSlot[] | null; - if (!slots || !Array.isArray(slots)) { - return { - errors: { - _form: ["No available time slots have been set for this session"], - }, - }; - } - - const matchingSlot = slots.find( - (s) => s.start === start && s.end === end, - ); - if (!matchingSlot) { - return { - errors: { - _form: [ - "The selected time slot is not among the available options", - ], - }, - }; - } - - // 5. Confirm the session with the chosen slot - try { - const updatedRows = await db - .update(coachingSessions) - .set({ - scheduledAt: new Date(start), - status: "confirmed", - updatedAt: new Date(), - }) - .where( - and( - eq(coachingSessions.id, session_id), - eq(coachingSessions.status, "pending"), - ), - ) - .returning({ id: coachingSessions.id }); - - if (updatedRows.length === 0) { - return { - errors: { - _form: ["This session is no longer available for confirmation"], - }, - }; - } - } catch (e) { - console.error("[SELECT_TIME_SLOT]", e); - return { - errors: { - _form: ["Could not confirm the time slot"], - }, - }; - } - - return { message: "Time slot confirmed." }; -} \ No newline at end of file diff --git a/app/scheduling/coach/[token]/coach-scheduler.tsx b/app/scheduling/coach/[token]/coach-scheduler.tsx deleted file mode 100644 index d570e1e..0000000 --- a/app/scheduling/coach/[token]/coach-scheduler.tsx +++ /dev/null @@ -1,190 +0,0 @@ -"use client"; - -import * as React from "react"; - -import { AvailabilityCalendar } from "@/components/scheduling/availability-calendar"; -import { Button } from "@/components/ui/button"; -import { intersectTimeSlots } from "@/lib/scheduling/overlap"; -import type { TimeSlot, Weekday } from "@/lib/scheduling/time-slot"; - -import { submitCoachAvailabilities } from "@/app/scheduling/token-actions"; - -const ALL_DAYS: Weekday[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; - -type CalendarWindow = { - anchor: Date; - weeks: number; - startHour: number; - endHour: number; -}; - -function deriveWindow(slots: TimeSlot[]): CalendarWindow { - if (slots.length === 0) { - const anchor = startOfWeek(new Date()); - return { anchor, weeks: 2, startHour: 8, endHour: 20 }; - } - - const starts = slots.map((s) => new Date(s.start)); - const ends = slots.map((s) => new Date(s.end)); - const min = new Date(Math.min(...starts.map((d) => d.getTime()))); - const max = new Date(Math.max(...ends.map((d) => d.getTime()))); - - const anchor = startOfWeek(min); - const dayMs = 86_400_000; - const spanDays = - Math.floor((startOfDay(max).getTime() - anchor.getTime()) / dayMs) + 1; - const weeks = Math.max(1, Math.ceil(spanDays / 7)); - - const startHour = Math.min(...starts.map((d) => d.getHours())); - const endHour = Math.max( - ...ends.map((d) => d.getHours() + (d.getMinutes() > 0 ? 1 : 0)), - ); - - return { - anchor, - weeks, - startHour: Math.max(0, Math.min(startHour, 23)), - endHour: Math.min(24, Math.max(endHour, startHour + 1)), - }; -} - -function startOfDay(d: Date): Date { - const x = new Date(d); - x.setHours(0, 0, 0, 0); - return x; -} - -function startOfWeek(d: Date): Date { - const x = startOfDay(d); - x.setDate(x.getDate() - x.getDay()); - return x; -} - -type SubmitState = - | { kind: "idle" } - | { kind: "submitting" } - | { kind: "error"; message: string } - | { kind: "done" }; - -export function CoachScheduler({ - token, - clientName, - clientSlots, - initialCoachSlots, -}: { - token: string; - clientName: string; - clientSlots: TimeSlot[]; - initialCoachSlots: TimeSlot[]; -}) { - const calendarWindow = React.useMemo( - () => deriveWindow(clientSlots), - [clientSlots], - ); - const [slots, setSlots] = React.useState(initialCoachSlots); - const [state, setState] = React.useState({ kind: "idle" }); - - const overlap = React.useMemo( - () => intersectTimeSlots(clientSlots, slots), - [clientSlots, slots], - ); - const matchCount = overlap.length; - - async function handleSubmit() { - setState({ kind: "submitting" }); - const result = await submitCoachAvailabilities({ token, slots }); - if (result.ok) { - setState({ kind: "done" }); - } else { - setState({ kind: "error", message: result.error }); - } - } - - if (state.kind === "done") { - return ( -
-

- Availability sent -

-

- We've emailed {clientName} the {matchCount} matching{" "} - {matchCount === 1 ? "window" : "windows"} so they can confirm a - time. You can close this page. -

-
- ); - } - - return ( -
- - -
- -
- -
-

- {matchCount > 0 ? ( - <> - - {matchCount} - {" "} - matching {matchCount === 1 ? "window" : "windows"} with{" "} - {clientName} - - ) : ( - "No overlap yet" - )} -

- -
- - {state.kind === "error" ? ( -

{state.message}

- ) : null} -
- ); -} - -function Legend() { - return ( -
- - - -
- ); -} - -function LegendSwatch({ - className, - label, -}: { - className: string; - label: string; -}) { - return ( - - - {label} - - ); -} diff --git a/app/scheduling/coach/[token]/page.tsx b/app/scheduling/coach/[token]/page.tsx deleted file mode 100644 index affaa44..0000000 --- a/app/scheduling/coach/[token]/page.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Suspense } from "react"; -import { Loader2 } from "lucide-react"; - -import { getSessionByToken } from "@/app/scheduling/queries"; - -import { CoachScheduler } from "./coach-scheduler"; - -export default function CoachSchedulingPage({ - params, -}: { - params: Promise<{ token: string }>; -}) { - return ( - }> - - - ); -} - -async function CoachSchedulingContent({ - params, -}: { - params: Promise<{ token: string }>; -}) { - const { token } = await params; - const view = await getSessionByToken(token); - - if (!view || view.role !== "coach") { - return ( - - ); - } - - if (view.status !== "pending") { - return ( - - ); - } - if (view.coachSlots.length > 0) { - return ( - - ); - } - - return ( -
-
-

- Set your availability, {view.coachName} -

-

- {view.clientName} booked {view.serviceTitle}. - Select the times that also work for you, and the matching windows - will turn solid green. -

-
- - -
- ); -} - -function SchedulingFallback() { - return ( -
- -
- ); -} - -function Notice({ title, message }: { title: string; message: string }) { - return ( -
-

{title}

-

{message}

-
- ); -} diff --git a/app/scheduling/confirm/[token]/client-confirm.tsx b/app/scheduling/confirm/[token]/client-confirm.tsx deleted file mode 100644 index 1fa08d7..0000000 --- a/app/scheduling/confirm/[token]/client-confirm.tsx +++ /dev/null @@ -1,201 +0,0 @@ -"use client"; - -import * as React from "react"; - -import { confirmFinalSlot } from "@/app/scheduling/token-actions"; -import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { expandToBookableSlots } from "@/lib/scheduling/overlap"; -import type { TimeSlot } from "@/lib/scheduling/time-slot"; -import { cn } from "@/lib/utils"; - -function formatDay(slot: TimeSlot): string { - return new Date(slot.start).toLocaleDateString("en-US", { - weekday: "long", - month: "short", - day: "numeric", - }); -} - -function formatTime(d: Date): string { - return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); -} - -function formatWindow(slot: TimeSlot): string { - const start = new Date(slot.start); - const end = new Date(slot.end); - return `${formatTime(start)} – ${formatTime(end)}`; -} - -function formatConfirmed(slot: TimeSlot): string { - const start = new Date(slot.start); - const end = new Date(slot.end); - const day = formatDay(slot); - return `${day} · ${formatTime(start)} – ${formatTime(end)}`; -} - -type State = - | { kind: "idle" } - | { kind: "submitting" } - | { kind: "error"; message: string } - | { kind: "done"; slot: TimeSlot }; - -export function ClientConfirm({ - token, - serviceTitle, - coachName, - windows, - durationMinutes, -}: { - token: string; - serviceTitle: string; - coachName: string; - windows: TimeSlot[]; - durationMinutes: number; -}) { - const bookableByWindow = React.useMemo( - () => - windows.map((window) => - expandToBookableSlots([window], durationMinutes), - ), - [windows, durationMinutes], - ); - - const [selectedWindow, setSelectedWindow] = React.useState(0); - const [selectedStart, setSelectedStart] = React.useState( - null, - ); - const [state, setState] = React.useState({ kind: "idle" }); - - const options = bookableByWindow[selectedWindow] ?? []; - - React.useEffect(() => { - setSelectedStart(null); - }, [selectedWindow]); - - const selectedSlot = React.useMemo(() => { - if (!selectedStart) return null; - return options.find((s) => s.start === selectedStart) ?? null; - }, [options, selectedStart]); - - async function handleConfirm() { - if (!selectedSlot) return; - setState({ kind: "submitting" }); - const result = await confirmFinalSlot({ token, slot: selectedSlot }); - if (result.ok) { - setState({ kind: "done", slot: selectedSlot }); - } else { - setState({ kind: "error", message: result.error }); - } - } - - if (state.kind === "done") { - return ( -
-

- Session confirmed -

-

- Your {serviceTitle} session with {coachName} is booked for{" "} - {formatConfirmed(state.slot)}. A confirmation - email is on its way. -

-
- ); - } - - return ( -
-

- Each session is {durationMinutes} minutes. Pick a day, then choose - your exact start time. -

- -
- Available days -
- {windows.map((window, i) => { - const isSelected = selectedWindow === i; - const count = bookableByWindow[i]?.length ?? 0; - return ( - - ); - })} -
-
- - {options.length > 0 ? ( -
- - -
- ) : null} - - {selectedSlot ? ( -
- Your session: - {formatConfirmed(selectedSlot)} -
- ) : null} - - - - {state.kind === "error" ? ( -

{state.message}

- ) : null} -
- ); -} diff --git a/app/scheduling/confirm/[token]/page.tsx b/app/scheduling/confirm/[token]/page.tsx deleted file mode 100644 index 53fa365..0000000 --- a/app/scheduling/confirm/[token]/page.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { Suspense } from "react"; -import { Loader2 } from "lucide-react"; - -import { getSessionByToken } from "@/app/scheduling/queries"; - -import { ClientConfirm } from "./client-confirm"; - -export default function ConfirmSchedulingPage({ - params, -}: { - params: Promise<{ token: string }>; -}) { - return ( - }> - - - ); -} - -async function ConfirmSchedulingContent({ - params, -}: { - params: Promise<{ token: string }>; -}) { - const { token } = await params; - const view = await getSessionByToken(token); - - if (!view || view.role !== "client") { - return ( - - ); - } - - if (view.status === "confirmed") { - return ( - - ); - } - - if (view.status !== "pending" || view.overlap.length === 0) { - return ( - - ); - } - - return ( -
-
-

- Pick your time -

-

- {view.coachName} is available on these days for{" "} - {view.serviceTitle} ({view.durationMinutes}{" "} - min). Pick the exact time that works best for you. -

-
- - -
- ); -} - -function formatDateTime(iso: string): string { - return new Date(iso).toLocaleString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }); -} - -function ConfirmFallback() { - return ( -
- -
- ); -} - -function Notice({ title, message }: { title: string; message: string }) { - return ( -
-

{title}

-

{message}

-
- ); -} diff --git a/app/scheduling/notifications.ts b/app/scheduling/notifications.ts deleted file mode 100644 index 971f176..0000000 --- a/app/scheduling/notifications.ts +++ /dev/null @@ -1,163 +0,0 @@ -import "server-only"; - -import { eq } from "drizzle-orm"; - -import { db } from "@/lib/db"; -import { coachingSessions } from "@/lib/db/schema"; -import { getService } from "@/app/(authenticated)/services/queries"; -import { appUrl, formatAddress, sendEmail } from "@/lib/email/client"; -import { - clientPickEmail, - coachInviteEmail, - confirmationEmail, -} from "@/lib/email/templates"; -import { intersectTimeSlots } from "@/lib/scheduling/overlap"; -import { generateScheduleToken } from "@/lib/scheduling/token"; -import type { TimeSlot } from "@/lib/scheduling/time-slot"; - -import { getProfileContact } from "./queries"; - -async function loadSession(sessionId: string) { - const [session] = await db - .select() - .from(coachingSessions) - .where(eq(coachingSessions.id, sessionId)) - .limit(1); - return session ?? null; -} - -export async function notifyCoachOfBooking(sessionId: string): Promise { - try { - const session = await loadSession(sessionId); - if (!session) return; - - let token = session.coachToken; - if (!token) { - token = generateScheduleToken(); - await db - .update(coachingSessions) - .set({ coachToken: token }) - .where(eq(coachingSessions.id, sessionId)); - } - - const [coach, client, service] = await Promise.all([ - getProfileContact(session.coachId), - getProfileContact(session.userId), - getService(session.serviceId), - ]); - if (!coach) { - console.error( - "[notifyCoachOfBooking] coach contact missing", - sessionId, - ); - return; - } - - const email = coachInviteEmail({ - coachName: coach.name, - clientName: client?.name ?? "Your client", - serviceTitle: service?.title ?? "Private lesson", - clientSlots: (session.selectedTimeSlots as TimeSlot[]) ?? [], - url: appUrl(`/scheduling/coach/${token}`), - }); - await sendEmail({ - to: coach.email, - replyTo: client ? formatAddress(client.name, client.email) : undefined, - ...email, - }); - } catch (e) { - console.error("[notifyCoachOfBooking]", e); - } -} - -export async function notifyClientToPick(sessionId: string): Promise { - try { - const session = await loadSession(sessionId); - if (!session) return; - - const overlap = intersectTimeSlots( - (session.selectedTimeSlots as TimeSlot[]) ?? [], - (session.coachTimeSlots as TimeSlot[] | null) ?? [], - ); - if (overlap.length === 0) return; - - let token = session.clientToken; - if (!token) { - token = generateScheduleToken(); - await db - .update(coachingSessions) - .set({ clientToken: token }) - .where(eq(coachingSessions.id, sessionId)); - } - - const [client, coach, service] = await Promise.all([ - getProfileContact(session.userId), - getProfileContact(session.coachId), - getService(session.serviceId), - ]); - if (!client) { - console.error( - "[notifyClientToPick] client contact missing", - sessionId, - ); - return; - } - - const email = clientPickEmail({ - clientName: client.name, - coachName: coach?.name ?? "Your coach", - serviceTitle: service?.title ?? "Private lesson", - overlap, - url: appUrl(`/scheduling/confirm/${token}`), - }); - await sendEmail({ - to: client.email, - replyTo: coach ? formatAddress(coach.name, coach.email) : undefined, - ...email, - }); - } catch (e) { - console.error("[notifyClientToPick]", e); - } -} - -export async function notifyBothConfirmed( - sessionId: string, - slot: TimeSlot, -): Promise { - try { - const session = await loadSession(sessionId); - if (!session) return; - - const [client, coach, service] = await Promise.all([ - getProfileContact(session.userId), - getProfileContact(session.coachId), - getService(session.serviceId), - ]); - const serviceTitle = service?.title ?? "Private lesson"; - - if (client && coach) { - await sendEmail({ - to: client.email, - replyTo: formatAddress(coach.name, coach.email), - ...confirmationEmail({ - recipientName: client.name, - otherPartyName: coach.name, - serviceTitle, - slot, - }), - }); - await sendEmail({ - to: coach.email, - replyTo: formatAddress(client.name, client.email), - ...confirmationEmail({ - recipientName: coach.name, - otherPartyName: client.name, - serviceTitle, - slot, - }), - }); - } - } catch (e) { - console.error("[notifyBothConfirmed]", e); - } -} diff --git a/app/scheduling/queries.ts b/app/scheduling/queries.ts deleted file mode 100644 index e3c67fc..0000000 --- a/app/scheduling/queries.ts +++ /dev/null @@ -1,114 +0,0 @@ -import "server-only"; - -import { eq, or } from "drizzle-orm"; -import { pgSchema, uuid, text } from "drizzle-orm/pg-core"; - -import { db } from "@/lib/db"; -import { coachingSessions, profiles } from "@/lib/db/schema"; -import { getService } from "@/app/(authenticated)/services/queries"; -import { intersectTimeSlots } from "@/lib/scheduling/overlap"; -import type { TimeSlot } from "@/lib/scheduling/time-slot"; - -const authSchema = pgSchema("auth"); -const authUsers = authSchema.table("users", { - id: uuid("id").primaryKey(), - email: text("email"), -}); - -export type ProfileContact = { name: string; email: string }; -export type CoachScheduleView = { - role: "coach"; - sessionId: string; - status: string; - serviceTitle: string; - coachName: string; - clientName: string; - clientSlots: TimeSlot[]; - coachSlots: TimeSlot[]; -}; - -export type ClientScheduleView = { - role: "client"; - sessionId: string; - status: string; - serviceTitle: string; - coachName: string; - overlap: TimeSlot[]; - durationMinutes: number; - scheduledAt: string | null; -}; - -export async function getProfileContact( - profileId: string, -): Promise { - const [row] = await db - .select({ - firstName: profiles.firstName, - lastName: profiles.lastName, - email: authUsers.email, - }) - .from(profiles) - .innerJoin(authUsers, eq(authUsers.id, profiles.id)) - .where(eq(profiles.id, profileId)) - .limit(1); - - if (!row || !row.email) return null; - return { name: `${row.firstName} ${row.lastName}`.trim(), email: row.email }; -} - -export type ScheduleView = CoachScheduleView | ClientScheduleView; - -export async function getSessionByToken( - token: string, -): Promise { - if (!token) return null; - - const [session] = await db - .select() - .from(coachingSessions) - .where( - or( - eq(coachingSessions.coachToken, token), - eq(coachingSessions.clientToken, token), - ), - ) - .limit(1); - - if (!session) return null; - - const service = await getService(session.serviceId); - const serviceTitle = service?.title ?? "Private lesson"; - const clientSlots = (session.selectedTimeSlots as TimeSlot[]) ?? []; - const coachSlots = (session.coachTimeSlots as TimeSlot[] | null) ?? []; - - if (session.coachToken === token) { - const [clientContact, coachContact] = await Promise.all([ - getProfileContact(session.userId), - getProfileContact(session.coachId), - ]); - return { - role: "coach", - sessionId: session.id, - status: session.status, - serviceTitle, - coachName: coachContact?.name ?? "Coach", - clientName: clientContact?.name ?? "Your client", - clientSlots, - coachSlots, - }; - } - - const contact = await getProfileContact(session.coachId); - return { - role: "client", - sessionId: session.id, - status: session.status, - serviceTitle, - coachName: contact?.name ?? "Your coach", - overlap: intersectTimeSlots(clientSlots, coachSlots), - durationMinutes: service?.durationMinutes ?? 60, - scheduledAt: session.scheduledAt - ? session.scheduledAt.toISOString() - : null, - }; -} diff --git a/app/scheduling/token-actions.ts b/app/scheduling/token-actions.ts deleted file mode 100644 index adec006..0000000 --- a/app/scheduling/token-actions.ts +++ /dev/null @@ -1,184 +0,0 @@ -"use server"; - -import { and, eq } from "drizzle-orm"; -import { z } from "zod"; - -import { db } from "@/lib/db"; -import { coachingSessions } from "@/lib/db/schema"; -import { intersectTimeSlots, isBookableSlot } from "@/lib/scheduling/overlap"; -import { getService } from "@/app/(authenticated)/services/queries"; -import type { TimeSlot } from "@/lib/scheduling/time-slot"; - -import { notifyBothConfirmed, notifyClientToPick } from "./notifications"; - -const iso8601 = z - .string() - .datetime({ offset: true, message: "Must be an ISO 8601 datetime" }); - -const slotSchema = z - .object({ start: iso8601, end: iso8601 }) - .refine((s) => new Date(s.end) > new Date(s.start), { - message: "End must be after start", - }); - -const submitSchema = z.object({ - token: z.string().min(1), - slots: z.array(slotSchema).min(1, "Select at least one availability window"), -}); - -const confirmSchema = z.object({ - token: z.string().min(1), - slot: slotSchema, -}); - -export type CoachSubmitResult = - | { ok: true; overlapCount: number } - | { ok: false; error: string }; - -export type ConfirmResult = { ok: true } | { ok: false; error: string }; - -function normalize(slot: TimeSlot): TimeSlot { - return { - start: new Date(slot.start).toISOString(), - end: new Date(slot.end).toISOString(), - }; -} - -export async function submitCoachAvailabilities( - input: z.input, -): Promise { - const parsed = submitSchema.safeParse(input); - if (!parsed.success) { - return { - ok: false, - error: parsed.error.issues[0]?.message ?? "Invalid input", - }; - } - const { token } = parsed.data; - const slots = parsed.data.slots.map(normalize); - - const [session] = await db - .select() - .from(coachingSessions) - .where(eq(coachingSessions.coachToken, token)) - .limit(1); - - if (!session) - return { ok: false, error: "This scheduling link is invalid." }; - if (session.status !== "pending") { - return { - ok: false, - error: "This session can no longer be edited.", - }; - } - - const overlap = intersectTimeSlots( - (session.selectedTimeSlots as TimeSlot[]) ?? [], - slots, - ); - if (overlap.length === 0) { - return { - ok: false, - error: "None of your times overlap with the client's availability. Pick at least one window that matches theirs.", - }; - } - - try { - const updated = await db - .update(coachingSessions) - .set({ - coachTimeSlots: slots, - coachToken: null, - updatedAt: new Date(), - }) - .where( - and( - eq(coachingSessions.id, session.id), - eq(coachingSessions.coachToken, token), - ), - ) - .returning({ id: coachingSessions.id }); - - if (updated.length === 0) { - return { - ok: false, - error: "Your availability has already been submitted.", - }; - } - } catch (e) { - console.error("[submitCoachAvailabilities]", e); - return { ok: false, error: "Could not save your availability." }; - } - - await notifyClientToPick(session.id); - return { ok: true, overlapCount: overlap.length }; -} - -export async function confirmFinalSlot( - input: z.input, -): Promise { - const parsed = confirmSchema.safeParse(input); - if (!parsed.success) { - return { - ok: false, - error: parsed.error.issues[0]?.message ?? "Invalid input", - }; - } - const { token } = parsed.data; - const slot = normalize(parsed.data.slot); - - const [session] = await db - .select() - .from(coachingSessions) - .where(eq(coachingSessions.clientToken, token)) - .limit(1); - - if (!session) - return { ok: false, error: "This scheduling link is invalid." }; - if (session.status !== "pending") { - return { ok: false, error: "This session has already been confirmed." }; - } - - const overlap = intersectTimeSlots( - (session.selectedTimeSlots as TimeSlot[]) ?? [], - (session.coachTimeSlots as TimeSlot[] | null) ?? [], - ); - const service = await getService(session.serviceId); - const durationMinutes = service?.durationMinutes ?? 60; - if (!isBookableSlot(slot, overlap, durationMinutes)) { - return { - ok: false, - error: "That time isn't one of the available options.", - }; - } - - try { - const updated = await db - .update(coachingSessions) - .set({ - scheduledAt: new Date(slot.start), - status: "confirmed", - updatedAt: new Date(), - }) - .where( - and( - eq(coachingSessions.id, session.id), - eq(coachingSessions.status, "pending"), - ), - ) - .returning({ id: coachingSessions.id }); - - if (updated.length === 0) { - return { - ok: false, - error: "This session is no longer available for confirmation.", - }; - } - } catch (e) { - console.error("[confirmFinalSlot]", e); - return { ok: false, error: "Could not confirm your time slot." }; - } - - await notifyBothConfirmed(session.id, slot); - return { ok: true }; -} diff --git a/components/scheduling/availability-form.tsx b/components/scheduling/availability-form.tsx deleted file mode 100644 index e004484..0000000 --- a/components/scheduling/availability-form.tsx +++ /dev/null @@ -1,145 +0,0 @@ -"use client"; - -import * as React from "react"; -import { useActionState } from "react"; - -import { AvailabilityCalendar } from "@/components/scheduling/availability-calendar"; -import { MessageYourCoach } from "@/components/scheduling/message-your-coach"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import type { TimeSlot, Weekday } from "@/lib/scheduling/time-slot"; -import { - selectAvailabilities, - type SchedulingActionState, -} from "@/app/scheduling/actions"; - -type AvailabilityFormProps = { - sessionId: string; - weeks?: number; - daysOfWeek?: Weekday[]; - startHour?: number; - endHour?: number; - className?: string; -}; - -const DEFAULT_DAYS: Weekday[] = ["mon", "tue", "wed", "thu", "fri"]; - -export function AvailabilityForm({ - sessionId, - weeks = 2, - daysOfWeek = DEFAULT_DAYS, - startHour = 8, - endHour = 20, - className, -}: AvailabilityFormProps) { - const [slots, setSlots] = React.useState([]); - const [coachMessage, setCoachMessage] = React.useState(""); - const [anchor, setAnchor] = React.useState(null); - - React.useEffect(() => { - setAnchor(new Date()); - }, []); - - const [state, formAction, pending] = useActionState< - SchedulingActionState, - FormData - >(selectAvailabilities, null); - - const errors = state?.errors; - - const hours = React.useMemo(() => { - const ms = slots.reduce( - (acc, s) => - acc + (new Date(s.end).getTime() - new Date(s.start).getTime()), - 0, - ); - return ms / 3_600_000; - }, [slots]); - - const hasSelection = slots.length > 0; - - return ( - - - - - - -
- - - - - {state?.message && !errors ? ( -

{state.message}

- ) : null} -
- -
-

- {hasSelection ? ( - <> - - {hours.toLocaleString(undefined, { - maximumFractionDigits: 1, - })} - {" "} - hours selected - - ) : ( - "Select your availability" - )} -

-
- - -
-
- - ); -} - -function FormErrors({ errors }: { errors?: Record }) { - if (!errors) return null; - const messages = Object.values(errors).flat(); - if (messages.length === 0) return null; - return ( -
    - {messages.map((m, i) => ( -
  • {m}
  • - ))} -
- ); -} diff --git a/components/scheduling/message-your-coach.tsx b/components/scheduling/message-your-coach.tsx deleted file mode 100644 index 28c6328..0000000 --- a/components/scheduling/message-your-coach.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import * as React from "react"; - -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; - -type MessageYourCoachProps = { - name: string; - value: string; - onChange: (value: string) => void; - className?: string; -}; - -export function MessageYourCoach({ - name, - value, - onChange, - className, -}: MessageYourCoachProps) { - const id = React.useId(); - return ( -
- -