From a270d83c1ad48b3e68bb4e127421cc2658870faa Mon Sep 17 00:00:00 2001 From: noisedestroyers Date: Sat, 2 May 2026 09:35:31 -0400 Subject: [PATCH] phase B therapist self-serve: /therapist (schedule + availability) --- .../2026-05-02-therapist-self-serve.md | 86 +++++++ .../therapists/[id]/availability/page.tsx | 197 +-------------- src/app/therapist/availability/page.tsx | 133 ++++++++++ src/app/therapist/bookings/page.tsx | 142 +++++++++++ src/app/therapist/layout.tsx | 81 +++++++ src/app/therapist/page.tsx | 5 + src/components/AvailabilityEditor.tsx | 229 ++++++++++++++++++ 7 files changed, 687 insertions(+), 186 deletions(-) create mode 100644 docs/progress/2026-05-02-therapist-self-serve.md create mode 100644 src/app/therapist/availability/page.tsx create mode 100644 src/app/therapist/bookings/page.tsx create mode 100644 src/app/therapist/layout.tsx create mode 100644 src/app/therapist/page.tsx create mode 100644 src/components/AvailabilityEditor.tsx diff --git a/docs/progress/2026-05-02-therapist-self-serve.md b/docs/progress/2026-05-02-therapist-self-serve.md new file mode 100644 index 0000000..277b082 --- /dev/null +++ b/docs/progress/2026-05-02-therapist-self-serve.md @@ -0,0 +1,86 @@ +# 2026-05-02 — Therapist Self-Serve (Phase B) + +> Companion to `Initial.md`. Predecessor: `2026-05-02-admin-crud.md`. Closes Phase B of the post-payments UX plan. + +## Milestone + +Therapists can now sign in and manage their own availability without involving admin. They also see their own schedule (upcoming + recent appointments) with customer contact info. The admin-side per-therapist availability editor and the new therapist self-serve editor share the same component, so any future improvement to the editor benefits both surfaces. + +## What landed + +| Path | Role | +|---|---| +| `src/components/AvailabilityEditor.tsx` | Reusable server component (no client state) — weekly working-hours grid + overrides list/add/delete. Caller supplies the data + 3 server-action callbacks + optional hidden form fields. | +| `src/app/admin/therapists/[id]/availability/page.tsx` (refactored) | Now uses `AvailabilityEditor` with `hiddenFields={{ therapistId: id }}`. Same behavior, less code. | +| `src/app/therapist/layout.tsx` | Auth-required, role-gated to THERAPIST. Friendly redirect/explanation for ADMIN or CUSTOMER hitting the route. Header with "My schedule" + "Availability" + sign-out. | +| `src/app/therapist/page.tsx` | Redirects to `/therapist/bookings` (most-useful default). | +| `src/app/therapist/availability/page.tsx` | Same editor as the admin page, scoped to `session.user.id`. Server actions read therapistId from the session and **ignore any form-supplied therapistId** — defense in depth. Delete-override action also re-checks ownership. | +| `src/app/therapist/bookings/page.tsx` | Read-only schedule. Upcoming table (When, Service+duration, Customer name+email+phone, Room) + recent list with status pills. | + +## What's verified + +- `pnpm test` — 71/71 (no new tests) +- `pnpm lint` — clean +- `pnpm exec tsc --noEmit` — clean +- Live smoke test as `mei@touchbase.local` (THERAPIST role from seed): + - Magic-link sign-in with `callbackUrl=/therapist` → lands on `/therapist/bookings` + - `/therapist` (no trailing path) → 307 → `/therapist/bookings` + - `/therapist/bookings` shows the two seeded-CLI bookings with customer name + email + - `/therapist/availability` renders working-hours editor (Tuesday-Saturday from seed) + override form + - `/admin/bookings` (as therapist) renders "Not authorized" — correctly gated + +## Decisions ratified + +| Decision | Resolution | +|---|---| +| Editor reuse | Extracted to `src/components/AvailabilityEditor.tsx`. Caller passes data + actions + optional hidden fields. Reason: avoid 280 LOC of duplication; one place to fix bugs. | +| Therapist actions ignore form-supplied therapistId | Server actions read therapist id from `auth()` session, never from FormData. Reason: a malicious THERAPIST could otherwise edit another therapist's schedule by tampering with the form. The component still has a `hiddenFields` prop for the admin route, but the therapist route doesn't pass any. | +| Delete-override ownership check | Action looks up the override and aborts if `therapistId !== session.user.id`. Reason: defense in depth; the page only shows own overrides but the action could be replayed with someone else's id. | +| Therapist landing | `/therapist` redirects to `/therapist/bookings` (the schedule), not `/therapist/availability`. Reason: most therapists open the app to see "what's next today" — the schedule is the more useful default. | +| Therapist sees customer contact info | Name + email + phone exposed in the schedule table. Reason: legitimate operational need (running late, customer no-show, etc.). Not HIPAA in our scope, but worth being mindful of. | +| Cross-role friendly errors | ADMIN visiting `/therapist` sees "Therapists only" with link to `/admin/therapists`. CUSTOMER sees link to `/account/bookings`. Reason: mistaken navigation should explain itself. | + +## Gotchas hit + +None this session — clean refactor + extension. + +## Open questions + +1. Customer-visible brand name (still pending) +2. Currency +3. Stripe account ownership +4. **NEW**: should the therapist schedule include a "mark complete / no-show" button, or do we keep that admin-only? Coming up in Phase C. +5. **NEW**: today's-day highlight in the therapist schedule? Defer until requested. + +## Roadmap status + +UX-completeness: + +- A — Admin CRUD: done 2026-05-02 +- **B — Therapist self-serve: done 2026-05-02 (this session)** +- C — Customer reschedule + admin "mark complete/no-show" + booking detail pages +- D — PWA shell +- E — Polish + +## Recommended next step + +**Phase C — booking lifecycle UX**: + +1. **Booking detail page** at `/admin/bookings/[id]` (and maybe `/account/bookings/[id]` for customers): all fields, history, actions. +2. **Reschedule action** for customers (and admin): cancel + rebook in one transaction. Likely add `rescheduleBooking()` to `src/lib/booking.ts` that wraps the operation atomically. +3. **Admin "mark complete / no-show"** buttons on the admin booking detail (or directly on the row in `/admin/bookings`). Adds two more state transitions to handle. + +Estimate ~1 day. Mostly composition over existing primitives. + +## How to resume + +```bash +cd /Users/noise/Documents/code/touchbase +docker-compose up -d postgres mailpit +pnpm db:seed +pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00 +pnpm dev +# Sign in as a therapist (e.g. mei@touchbase.local) → lands on /therapist/bookings +# Click "Availability" → edit working hours, add an override +# Sign out, sign in as admin@touchbase.local → /admin/* still works as before +``` diff --git a/src/app/admin/therapists/[id]/availability/page.tsx b/src/app/admin/therapists/[id]/availability/page.tsx index e32491f..4181f71 100644 --- a/src/app/admin/therapists/[id]/availability/page.tsx +++ b/src/app/admin/therapists/[id]/availability/page.tsx @@ -3,7 +3,8 @@ import { notFound, redirect } from "next/navigation"; import { fromZonedTime } from "date-fns-tz"; import { db } from "@/lib/db"; import { parseBool, parseStringTrim } from "@/lib/forms"; -import { minToTime, timeToMin, WEEKDAYS } from "@/lib/working-hours"; +import { timeToMin, WEEKDAYS } from "@/lib/working-hours"; +import { AvailabilityEditor } from "@/components/AvailabilityEditor"; export const metadata = { title: "Therapist availability — TouchBase" }; export const dynamic = "force-dynamic"; @@ -12,23 +13,11 @@ const TZ = process.env.APP_TZ ?? "America/Detroit"; type Params = Promise<{ id: string }>; -function formatLocal(d: Date): string { - return new Intl.DateTimeFormat("en-US", { - timeZone: TZ, - weekday: "short", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }).format(d); -} - async function saveWorkingHours(formData: FormData): Promise { "use server"; const therapistId = parseStringTrim(formData.get("therapistId")); if (!therapistId) return; - // For each weekday, read open/start/end. Replace all existing rows in one tx. const newRows: { weekday: number; startMin: number; endMin: number }[] = []; for (const { weekday } of WEEKDAYS) { const open = parseBool(formData.get(`open_${weekday}`)); @@ -94,12 +83,6 @@ export default async function TherapistAvailabilityPage({ }); if (!therapist) notFound(); - // Map weekday → existing WorkingHours row (single shift per day in v1) - const whByDay = new Map(); - for (const w of therapist.workingHours) { - whByDay.set(w.weekday, { startMin: w.startMin, endMin: w.endMin }); - } - return (

Availability — {TZ}

- {/* Weekly working hours editor */} -
-

- Weekly working hours -

-
- -
- {WEEKDAYS.map(({ weekday, name }) => { - const existing = whByDay.get(weekday); - return ( -
- - - to - -
- ); - })} -
-

- One shift per day. For split shifts, add an EXTRA_HOURS override below. -

-
- -
-
-
- - {/* Overrides */} -
-

- Overrides (PTO, extra hours) -

- - {therapist.overrides.length > 0 && ( -
    - {therapist.overrides.map((o) => ( -
  • -
    - - {o.kind === "BLOCK" ? "BLOCK" : "EXTRA"} - - {formatLocal(o.startsAt)} – {formatLocal(o.endsAt)} - {o.reason && ( - — {o.reason} - )} -
    -
    - - - -
    -
  • - ))} -
- )} - -
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
-
+
); } diff --git a/src/app/therapist/availability/page.tsx b/src/app/therapist/availability/page.tsx new file mode 100644 index 0000000..169cc43 --- /dev/null +++ b/src/app/therapist/availability/page.tsx @@ -0,0 +1,133 @@ +import { redirect } from "next/navigation"; +import { fromZonedTime } from "date-fns-tz"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { parseBool, parseStringTrim } from "@/lib/forms"; +import { timeToMin, WEEKDAYS } from "@/lib/working-hours"; +import { AvailabilityEditor } from "@/components/AvailabilityEditor"; + +export const metadata = { title: "My availability — TouchBase" }; +export const dynamic = "force-dynamic"; + +const TZ = process.env.APP_TZ ?? "America/Detroit"; + +async function saveWorkingHours(formData: FormData): Promise { + "use server"; + // Therapist can ONLY edit their own — read id from session, ignore form values. + const session = await auth(); + if (!session?.user || session.user.role !== "THERAPIST") { + redirect("/login?callbackUrl=/therapist/availability"); + } + const therapistId = session!.user.id; + + const newRows: { weekday: number; startMin: number; endMin: number }[] = []; + for (const { weekday } of WEEKDAYS) { + const open = parseBool(formData.get(`open_${weekday}`)); + if (!open) continue; + const start = timeToMin(parseStringTrim(formData.get(`start_${weekday}`))); + const end = timeToMin(parseStringTrim(formData.get(`end_${weekday}`))); + if (start === null || end === null || end <= start) continue; + newRows.push({ weekday, startMin: start, endMin: end }); + } + + await db.$transaction([ + db.workingHours.deleteMany({ where: { therapistId } }), + db.workingHours.createMany({ + data: newRows.map((r) => ({ ...r, therapistId })), + }), + ]); + redirect("/therapist/availability"); +} + +async function addOverride(formData: FormData): Promise { + "use server"; + const session = await auth(); + if (!session?.user || session.user.role !== "THERAPIST") { + redirect("/login?callbackUrl=/therapist/availability"); + } + const therapistId = session!.user.id; + + const kindRaw = parseStringTrim(formData.get("kind")); + const kind = kindRaw === "EXTRA_HOURS" ? "EXTRA_HOURS" : "BLOCK"; + const startsLocal = parseStringTrim(formData.get("startsAt")); + const endsLocal = parseStringTrim(formData.get("endsAt")); + const reason = parseStringTrim(formData.get("reason")) || null; + if (!startsLocal || !endsLocal) return; + + const startsAt = fromZonedTime(startsLocal, TZ); + const endsAt = fromZonedTime(endsLocal, TZ); + if (endsAt <= startsAt) return; + + await db.availabilityOverride.create({ + data: { therapistId, kind, startsAt, endsAt, reason }, + }); + redirect("/therapist/availability"); +} + +async function deleteOverride(formData: FormData): Promise { + "use server"; + const session = await auth(); + if (!session?.user || session.user.role !== "THERAPIST") { + redirect("/login?callbackUrl=/therapist/availability"); + } + const id = parseStringTrim(formData.get("id")); + if (!id) return; + // Only allow deleting own overrides + const ov = await db.availabilityOverride.findUnique({ + where: { id }, + select: { therapistId: true }, + }); + if (!ov || ov.therapistId !== session!.user.id) { + redirect("/therapist/availability"); + } + await db.availabilityOverride.delete({ where: { id } }); + redirect("/therapist/availability"); +} + +export default async function MyAvailabilityPage() { + const session = await auth(); + if (!session?.user || session.user.role !== "THERAPIST") { + redirect("/login?callbackUrl=/therapist/availability"); + } + const userId = session!.user.id; + + const therapist = await db.therapist.findUnique({ + where: { userId }, + include: { + user: { select: { name: true, email: true } }, + workingHours: true, + overrides: { orderBy: { startsAt: "asc" } }, + }, + }); + if (!therapist) { + return ( +
+

+ Account not set up +

+

+ Your user has the THERAPIST role but no therapist profile. Ask the + practice admin to complete your setup. +

+
+ ); + } + + return ( +
+

+ My availability +

+

{TZ}

+ + +
+ ); +} diff --git a/src/app/therapist/bookings/page.tsx b/src/app/therapist/bookings/page.tsx new file mode 100644 index 0000000..65027eb --- /dev/null +++ b/src/app/therapist/bookings/page.tsx @@ -0,0 +1,142 @@ +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; + +export const metadata = { title: "My schedule — TouchBase" }; +export const dynamic = "force-dynamic"; + +const TZ = process.env.APP_TZ ?? "America/Detroit"; + +function formatLocal(d: Date): string { + return new Intl.DateTimeFormat("en-US", { + timeZone: TZ, + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(d); +} + +export default async function TherapistBookingsPage() { + const session = await auth(); + if (!session?.user || session.user.role !== "THERAPIST") { + redirect("/login?callbackUrl=/therapist/bookings"); + } + const therapistId = session!.user.id; + const now = new Date(); + + const [upcoming, past] = await Promise.all([ + db.booking.findMany({ + where: { + therapistId, + status: { in: ["HOLD", "CONFIRMED"] }, + startsAt: { gte: now }, + }, + include: { + customer: { select: { name: true, email: true, phone: true } }, + service: { select: { name: true, durationMin: true } }, + room: { select: { name: true } }, + }, + orderBy: { startsAt: "asc" }, + }), + db.booking.findMany({ + where: { + therapistId, + OR: [ + { startsAt: { lt: now } }, + { status: { in: ["CANCELLED", "COMPLETED", "NO_SHOW"] } }, + ], + }, + include: { + customer: { select: { name: true, email: true } }, + service: { select: { name: true } }, + }, + orderBy: { startsAt: "desc" }, + take: 20, + }), + ]); + + return ( +
+

My schedule

+ +
+

+ Upcoming +

+ {upcoming.length === 0 ? ( +

+ No upcoming appointments. +

+ ) : ( +
+ + + + + + + + + + + {upcoming.map((b) => ( + + + + + + + ))} + +
WhenServiceCustomerRoom
+ {formatLocal(b.startsAt)} + + {b.service.name} +
+ {b.service.durationMin} min +
+
+
{b.customer.name ?? unnamed}
+
{b.customer.email}
+ {b.customer.phone && ( +
{b.customer.phone}
+ )} +
{b.room.name}
+
+ )} +
+ +
+

+ Recent +

+ {past.length === 0 ? ( +

+ Nothing yet. +

+ ) : ( +
    + {past.map((b) => ( +
  • +
    +
    {b.service.name}
    +
    + {formatLocal(b.startsAt)} · {b.customer.name ?? b.customer.email} +
    +
    + + {b.status} + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/app/therapist/layout.tsx b/src/app/therapist/layout.tsx new file mode 100644 index 0000000..98ec8ec --- /dev/null +++ b/src/app/therapist/layout.tsx @@ -0,0 +1,81 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { auth, signOut } from "@/auth"; + +export const dynamic = "force-dynamic"; + +async function logout() { + "use server"; + await signOut({ redirectTo: "/" }); +} + +export default async function TherapistLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await auth(); + if (!session?.user) redirect("/login?callbackUrl=/therapist"); + if (session.user.role !== "THERAPIST") { + return ( +
+

Therapists only

+

+ This area is for therapists. {session.user.role === "ADMIN" ? ( + <>Admins manage therapist availability via{" "} + + Admin → Therapists + . + + ) : ( + <>If you book here, your bookings are at{" "} + + My bookings + . + + )} +

+
+ +
+
+ ); + } + + return ( +
+
+
+ + TouchBase + + +
+
+ {session.user.email} +
+ +
+
+
+
{children}
+
+ ); +} diff --git a/src/app/therapist/page.tsx b/src/app/therapist/page.tsx new file mode 100644 index 0000000..1263915 --- /dev/null +++ b/src/app/therapist/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function TherapistIndex() { + redirect("/therapist/bookings"); +} diff --git a/src/components/AvailabilityEditor.tsx b/src/components/AvailabilityEditor.tsx new file mode 100644 index 0000000..77ab465 --- /dev/null +++ b/src/components/AvailabilityEditor.tsx @@ -0,0 +1,229 @@ +// Reusable availability editor — used by admin (per-therapist) and therapist (own). +// Caller supplies the data + three server actions; the component renders the UI. +// +// All time values are in the practice TZ (`tz` prop). Overrides are stored as +// UTC instants in the DB; this component receives them as Date objects. + +import { minToTime, WEEKDAYS } from "@/lib/working-hours"; + +export type WorkingHourEntry = { weekday: number; startMin: number; endMin: number }; + +export type OverrideEntry = { + id: string; + kind: "BLOCK" | "EXTRA_HOURS"; + startsAt: Date; + endsAt: Date; + reason: string | null; +}; + +export function AvailabilityEditor({ + tz, + workingHours, + overrides, + saveWorkingHoursAction, + addOverrideAction, + deleteOverrideAction, + hiddenFields, // extra hidden inputs for routes that need them (e.g. admin needs therapistId) +}: { + tz: string; + workingHours: WorkingHourEntry[]; + overrides: OverrideEntry[]; + saveWorkingHoursAction: (formData: FormData) => void | Promise; + addOverrideAction: (formData: FormData) => void | Promise; + deleteOverrideAction: (formData: FormData) => void | Promise; + hiddenFields?: Record; +}) { + const whByDay = new Map(); + for (const w of workingHours) { + whByDay.set(w.weekday, { startMin: w.startMin, endMin: w.endMin }); + } + + const formatLocal = (d: Date) => + new Intl.DateTimeFormat("en-US", { + timeZone: tz, + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(d); + + const renderHidden = () => + hiddenFields + ? Object.entries(hiddenFields).map(([k, v]) => ( + + )) + : null; + + return ( + <> + {/* Weekly working hours */} +
+

+ Weekly working hours +

+
+ {renderHidden()} +
+ {WEEKDAYS.map(({ weekday, name }) => { + const existing = whByDay.get(weekday); + return ( +
+ + + to + +
+ ); + })} +
+

+ One shift per day. For split shifts, add an EXTRA_HOURS override below. +

+
+ +
+
+
+ + {/* Overrides */} +
+

+ Overrides (PTO, extra hours) +

+ + {overrides.length > 0 && ( +
    + {overrides.map((o) => ( +
  • +
    + + {o.kind === "BLOCK" ? "BLOCK" : "EXTRA"} + + {formatLocal(o.startsAt)} – {formatLocal(o.endsAt)} + {o.reason && ( + — {o.reason} + )} +
    +
    + {renderHidden()} + + +
    +
  • + ))} +
+ )} + +
+ {renderHidden()} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + ); +}