From 82c613ee58c8bc57fddb274566bcd85c6c829205 Mon Sep 17 00:00:00 2001 From: Taylor O'Neal Date: Sun, 18 Oct 2026 18:56:59 +0800 Subject: [PATCH 1/4] test: verify signed-in member booking cancellation recovery --- e2e/member-bookings.spec.ts | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 e2e/member-bookings.spec.ts diff --git a/e2e/member-bookings.spec.ts b/e2e/member-bookings.spec.ts new file mode 100644 index 0000000..3ef3bef --- /dev/null +++ b/e2e/member-bookings.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from "@playwright/test"; + +test("member sees their bookings and cancels only after server confirmation", async ({ page }) => { + const user = { id: "0a000000-0000-0000-0000-00000000000b", email: "member@test.dev", aud: "authenticated", role: "authenticated", app_metadata: {}, user_metadata: {} }; + await page.addInitScript((user) => localStorage.setItem("sb-127-auth-token", JSON.stringify({ + access_token: "e2e-token", refresh_token: "e2e-refresh", token_type: "bearer", expires_in: 3600, + expires_at: Math.floor(Date.now() / 1000) + 3600, user, + })), user); + let cancelled = false; + let rejectCancellation = true; + const booking = { + booking_id: "22222222-2222-2222-2222-222222222222", occurrence_id: "33333333-3333-3333-3333-333333333333", + status: "confirmed", starts_at: new Date(Date.now() + 86400000).toISOString(), + ends_at: new Date(Date.now() + 90000000).toISOString(), is_cancelled: false, + offering_name: "Member Vinyasa", teacher_name: "Sam", room: "Main", location_name: "Downtown", + studio_name: "Test Studio", studio_slug: "test-studio", studio_timezone: "America/Chicago", cancellation_minutes: 120, + }; + await page.route("http://127.0.0.1:54321/**", async route => { + const req = route.request(); + const url = req.url(); + const headers = { "access-control-allow-origin": "*", "access-control-allow-headers": "*", "access-control-allow-methods": "*" }; + const json = (body: unknown, status = 200) => route.fulfill({ status, headers, contentType: "application/json", body: JSON.stringify(body) }); + if (req.method() === "OPTIONS") return route.fulfill({ status: 204, headers }); + if (url.includes("/rpc/get_my_effective_role")) return json("student"); + if (url.includes("/rest/v1/profiles")) return json({ ...user, first_name: "Test", last_name: "Member" }); + if (url.includes("/rpc/get_my_bookings")) return json(cancelled ? [] : [booking]); + if (url.includes("/rpc/cancel_booking")) { + expect(req.postDataJSON()).toEqual({ p_booking_id: booking.booking_id }); + if (rejectCancellation) return json({ message: "Please retry cancellation" }, 400); + cancelled = true; + return json({ ...booking, status: "cancelled" }); + } + if (url.includes("/auth/v1/user")) return json(user); + return json([]); + }); + await page.goto("/my-schedule"); + await expect(page.getByRole("heading", { name: "Member Vinyasa" })).toBeVisible(); + await expect(page.getByText("Power Vinyasa Flow", { exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await page.getByRole("button", { name: "Cancel Booking", exact: true }).click(); + await expect(page.getByText("Please retry cancellation").first()).toBeVisible(); + expect(cancelled).toBe(false); + rejectCancellation = false; + await page.getByRole("button", { name: "Cancel Booking", exact: true }).click(); + await expect(page.getByRole("heading", { name: "No upcoming bookings" })).toBeVisible(); + expect(cancelled).toBe(true); +}); From 209778a569ce3608e14e5b292d65dc0bba404178 Mon Sep 17 00:00:00 2001 From: Taylor O'Neal Date: Sun, 18 Oct 2026 19:06:48 +0800 Subject: [PATCH 2/4] feat: connect teacher schedule roster and attendance --- docs/plans/BACKLOG.md | 7 + docs/plans/PROGRESS.md | 9 ++ e2e/teacher-attendance.spec.ts | 123 ++++++++++++++++++ src/components/teach/TeachLayout.tsx | 50 ++++--- src/components/teach/TeachingClasses.tsx | 108 +++++++++++++++ src/lib/backend/supabase.ts | 17 ++- src/lib/backend/types.ts | 24 ++++ src/pages/teach/Dashboard.tsx | 9 +- src/pages/teach/Schedule.tsx | 9 +- .../migrations/00044_teacher_attendance.sql | 63 +++++++++ .../tests/024_teacher_attendance.test.sql | 106 +++++++++++++++ supabase/tests/run.sh | 2 +- 12 files changed, 504 insertions(+), 23 deletions(-) create mode 100644 e2e/teacher-attendance.spec.ts create mode 100644 src/components/teach/TeachingClasses.tsx create mode 100644 supabase/migrations/00044_teacher_attendance.sql create mode 100644 supabase/tests/024_teacher_attendance.test.sql diff --git a/docs/plans/BACKLOG.md b/docs/plans/BACKLOG.md index 6596402..fbb17c1 100644 --- a/docs/plans/BACKLOG.md +++ b/docs/plans/BACKLOG.md @@ -138,3 +138,10 @@ W1 -> W3 -> W4-1 -> W4-3 -> W4-4 -> W5-2 -> W7-3 -> W7-4. W2 gates W4-3 and W5-2 - NEXT: after sign-in a real owner lands on `/schedule` (sample data). Send owners to `/manage`, students to their real bookings; the manage header must show the studio name, not "Tandava Yoga", and no sample notifications. - NEXT: Turnstile fails in Taylor's Chrome ("Verification failed"). Check widget hostnames (tandavastudio.com, www) before enforcing captcha in Supabase Auth. - LATER: `monthly` schedule rules are not generated (no UI creates them). + +## Teacher portal first working slice (2026-10-18) + +| Task | State | Acceptance / remaining gate | +|---|---|---| +| Assigned teacher schedule → roster → recorded attendance | Implemented locally, pending SQL validation and deployment | 00044: active assigned teacher/owner/admin only; narrow roster; existing check_in_booking authoritative window/status. Browser mocks cover failure, recovery, duplicate clicks and student denial. Run TEACH-01..15 with full DB suite, then review/apply migration before deploying UI. | +| Teacher availability, substitutes, earnings and teacher profile | Deferred, explicitly unavailable in live UI | Demo unchanged. Separate real contracts needed; no false-save controls exposed live. | diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index aa90f1a..626cc94 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -147,3 +147,12 @@ supabase start && supabase db reset && npm run test:db # throwaway local DB, n - LP-9: Terms, Privacy and Refund Policy pages (operator Purafield Studio, contact hello@purafieldstudio.com, Texas law). Sign-up linked to /terms and /waiver, neither of which existed; it now links Terms and Privacy. Checkout entry points show what the buyer agrees to. The app footer no longer says "Oxatl Yoga" outside the demo. - Refund defaults Taylor should confirm: 2-hour cancellation window unless the studio sets one; unused packs refundable within 14 days; no partial membership refunds. + +## 2026-10-18: teacher schedule and attendance (local implementation) +- Baseline reconciled to remote main 7353a2a; obsolete launch-v1 checkout left untouched. Member cancellation failure/retry regression committed locally (82c613e). +- /teach and /teach/schedule now use assigned classes and roster RPCs for live sessions; demo retains existing sample screens. Live unsupported teacher routes show an explicit unavailable page, and no fake studio name, qualification or notification count is shown. +- Scope follows ROLE_ACCESS_CONTROL teacher own-schedule/own-roster/own-check-in and PRD-001 two-week default; earnings/substitution remain deferred. New DataProvider methods: getMyTeachingClasses, getTeachingRoster, checkInTeachingBooking. Narrow 00044 read RPCs require current assignment plus active teacher/owner/admin staff in the same studio. Roster returns only booking ID, display name and status. The check-in wrapper locks the class before assignment validation, then delegates to existing check_in_booking for status, cancellation and time-window enforcement. Existing general staff check-in permissions are unchanged. +- UI records attendance only after matching booking ID and checked_in response, prevents concurrent clicks, disables roster dismissal during writes, refreshes after ambiguous failures and offers explicit roster refresh. Waitlisted people cannot be checked in from the portal; attendance undo, substitution, payroll and bulk attendance are outside this slice. +- Added browser coverage for failed/successful check-in, duplicate clicks, persisted roster refresh, lost response recovery, schedule/roster errors, direct navigation to unavailable features and student route denial without teacher RPC requests. +- Added SQL TEACH-01..15 (assignment, same/cross-studio denial, disabled/unexpected roles, anon denial, time window, duplicate attendance, cancelled class). Included in test runner. **SQL tests NOT EXECUTED locally:** no PostgreSQL server installed/running. Migration00044 NOT APPLIED to any hosted database. Deployment is blocked on a clean database test run and explicit deployment authorization; mocks do not prove production RLS/function execution. +- Validation: TypeScript passed; 526 application tests and 32 script tests passed; final full browser suite 23/23 passed with 2 workers, including 390×844 mobile attendance and desktop recovery/sign-out. Earlier 5-worker run had one existing owner cancellation follow-up failure; focused 12/12 and final 23/23 reruns passed without modifying that flow. Production build/postbuild passed through E2E setup. Sign-out now waits for the backend and reports failures rather than navigating on an unconfirmed logout. diff --git a/e2e/teacher-attendance.spec.ts b/e2e/teacher-attendance.spec.ts new file mode 100644 index 0000000..825d702 --- /dev/null +++ b/e2e/teacher-attendance.spec.ts @@ -0,0 +1,123 @@ +import { expect, test, type Page } from "@playwright/test"; + +async function mockTeacher(page: Page, role = "teacher") { + const user = { id: "0a000000-0000-0000-0000-00000000000c", email: "teacher@test.dev", aud: "authenticated", role: "authenticated", app_metadata: {}, user_metadata: {} }; + await page.addInitScript(user => localStorage.setItem("sb-127-auth-token", JSON.stringify({ access_token: "e2e-token", refresh_token: "e2e-refresh", token_type: "bearer", expires_at: Math.floor(Date.now()/1000)+3600, expires_in: 3600, user })), user); + const state = { rejectLogout: false, reads: 0, checkedIn: false, calls: 0, failSchedule: false, failRoster: false, reject: false, loseResponse: false, delay: 0 }; + await page.route("http://127.0.0.1:54321/**", async route => { + const req = route.request(); + const url = req.url(); + const headers = { "access-control-allow-origin": "*", "access-control-allow-headers": "*", "access-control-allow-methods": "*" }; + const json = (body: unknown, status = 200) => route.fulfill({ status, headers, contentType: "application/json", body: JSON.stringify(body) }); + if (req.method() === "OPTIONS") return route.fulfill({ status: 204, headers }); + if (url.includes("/rpc/get_my_effective_role")) return json(role); + if (url.includes("/rest/v1/profiles")) return json({ ...user, first_name: "Taylor", last_name: "Teacher" }); + if (url.includes("/rpc/get_my_teaching_classes") || url.includes("/rpc/get_teaching_roster")) state.reads++; + if (url.includes("/rpc/get_my_teaching_classes")) return state.failSchedule ? json({ message: "Schedule unavailable" }, 500) : json([{ + occurrence_id: "11111111-1111-1111-1111-111111111111", offering_name: "Assigned Vinyasa", studio_name: "Real Studio", studio_timezone: "America/Chicago", + location_name: "Downtown", room: "Main", starts_at: new Date(Date.now()+600000).toISOString(), ends_at: new Date(Date.now()+4200000).toISOString(), + capacity: 12, is_cancelled: false, booked: 1, checked_in: state.checkedIn ? 1 : 0, + }]); + if (url.includes("/rpc/get_teaching_roster")) { + expect(req.postDataJSON()).toEqual({ p_occurrence_id: "11111111-1111-1111-1111-111111111111" }); + return state.failRoster ? json({ message: "Roster unavailable" }, 500) : json([ + { booking_id: "22222222-2222-2222-2222-222222222222", student_name: "Alex Student", status: state.checkedIn ? "checked_in" : "confirmed" }, + { booking_id: "33333333-3333-3333-3333-333333333333", student_name: "Waiting Student", status: "waitlisted" }, + ]); + } + if (url.includes("/rpc/check_in_teaching_booking")) { + state.calls++; + expect(req.postDataJSON()).toEqual({ p_booking_id: "22222222-2222-2222-2222-222222222222" }); + if (state.delay) await new Promise(resolve => setTimeout(resolve, state.delay)); + if (state.reject) return json({ message: "Check-in is not open yet" }, 400); + state.checkedIn = true; + if (state.loseResponse) return route.abort("failed"); + return json({ id: "22222222-2222-2222-2222-222222222222", status: "checked_in" }); + } + if (url.includes("/auth/v1/logout")) return state.rejectLogout ? json({ message: "Sign out unavailable" }, 500) : json({}); + if (url.includes("/auth/v1/user")) return json(user); + return json([]); + }); + return state; +} + +async function openRoster(page: Page) { + await page.goto("/teach"); + await expect(page.getByRole("heading", { name: "Teaching dashboard" })).toBeVisible(); + await page.getByRole("button", { name: "View roster for Assigned Vinyasa" }).click(); + await expect(page.getByText("Alex Student", { exact: true })).toBeVisible(); +} + +test("teacher records real attendance, prevents duplicate clicks and preserves server errors", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + const state = await mockTeacher(page); + state.reject = true; + await openRoster(page); + await expect(page.getByRole("button", { name: "Check in Waiting Student" })).toHaveCount(0); + await page.getByRole("button", { name: "Check in Alex Student" }).click(); + await expect(page.getByText("Could not confirm check-in").first()).toBeVisible(); + await expect(page.getByRole("button", { name: "Check in Alex Student" })).toBeEnabled(); + state.reject = false; state.delay = 1000; + await page.getByRole("button", { name: "Check in Alex Student" }).dblclick(); + await expect(page.getByRole("button", { name: "Check in Alex Student" })).toBeDisabled(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(page.getByText("Checked in", { exact: true })).toBeVisible(); + expect(state.calls).toBe(2); + await page.reload(); + await page.getByRole("button", { name: "View roster for Assigned Vinyasa" }).click(); + await expect(page.getByText("Checked in", { exact: true })).toBeVisible(); +}); + +test("lost check-in response recovers committed attendance without a second mutation", async ({ page }) => { + const state = await mockTeacher(page); state.loseResponse = true; + await openRoster(page); + await page.getByRole("button", { name: "Check in Alex Student" }).click(); + await expect(page.getByText("Checked in", { exact: true })).toBeVisible(); + await expect(page.getByText("Attendance confirmed from roster").first()).toBeVisible(); + expect(state.calls).toBe(1); +}); + +test("teacher can retry schedule and roster failures", async ({ page }) => { + const state = await mockTeacher(page); state.failSchedule = true; + await page.goto("/teach/schedule"); + await expect(page.getByText("Couldn't load your teaching schedule.")).toBeVisible(); + state.failSchedule = false; + await page.getByRole("button", { name: "Retry schedule" }).click(); + state.failRoster = true; + await page.getByRole("button", { name: "View roster for Assigned Vinyasa" }).click(); + await expect(page.getByText("Couldn't load this roster.")).toBeVisible(); + state.failRoster = false; + await page.getByRole("button", { name: "Retry roster" }).click(); + await expect(page.getByText("Alex Student", { exact: true })).toBeVisible(); +}); + +test("live teacher routes do not expose sample earnings or unsupported controls", async ({ page }) => { + await mockTeacher(page); + await page.goto("/teach/earnings"); + await expect(page.getByRole("heading", { name: "Not available yet" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Earnings", exact: true })).toHaveCount(0); + await expect(page.getByText("Oxatl Yoga", { exact: true })).toHaveCount(0); +}); + +test("student cannot enter the teacher portal", async ({ page }) => { + const state = await mockTeacher(page, "student"); + await page.goto("/teach"); + await expect(page).toHaveURL(/\/(discover)?$/); + await expect(page.getByRole("heading", { name: "Teaching dashboard" })).toHaveCount(0); + expect(state.reads).toBe(0); +}); + + +test("teacher sign-out reports errors and navigates only after success", async ({ page }) => { + const state = await mockTeacher(page); state.rejectLogout = true; + await page.goto("/teach"); + await page.getByRole("button", { name: "Teacher account menu" }).click(); + await page.getByRole("menuitem", { name: "Sign out" }).click(); + await expect(page.getByText("Couldn't sign out").first()).toBeVisible(); + await expect(page).toHaveURL(/\/teach$/); + state.rejectLogout = false; + await page.getByRole("button", { name: "Teacher account menu" }).click(); + await page.getByRole("menuitem", { name: "Sign out" }).click(); + await expect(page).toHaveURL(/\/auth\/login/); +}); diff --git a/src/components/teach/TeachLayout.tsx b/src/components/teach/TeachLayout.tsx index 32b4ad9..8f826ec 100644 --- a/src/components/teach/TeachLayout.tsx +++ b/src/components/teach/TeachLayout.tsx @@ -1,5 +1,5 @@ import { ReactNode, useState } from "react"; -import { Link, useLocation } from "react-router-dom"; +import { Link, useLocation, useNavigate } from "react-router-dom"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -24,6 +24,7 @@ import { X, Settings, } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; import { useAuth } from "@/contexts/AuthContext"; interface TeachLayoutProps { @@ -41,8 +42,21 @@ const teachNavigation = [ export function TeachLayout({ children }: TeachLayoutProps) { const location = useLocation(); - const { profile } = useAuth(); + const { profile, isDemoMode, signOut } = useAuth(); const [sidebarOpen, setSidebarOpen] = useState(false); + const [signingOut, setSigningOut] = useState(false); + const navigate = useNavigate(); + const { toast } = useToast(); + const handleSignOut = async () => { + if (signingOut) return; + setSigningOut(true); + try { + await signOut(); + navigate("/auth/login", { replace: true }); + } catch (error) { + toast({ title: "Couldn't sign out", description: error instanceof Error ? error.message : "Please try again.", variant: "destructive" }); + } finally { setSigningOut(false); } + }; const isActive = (href: string) => { if (href === "/teach") return location.pathname === "/teach"; @@ -50,7 +64,8 @@ export function TeachLayout({ children }: TeachLayoutProps) { }; const profileName = profile?.display_name - || (profile ? `${profile.first_name} ${profile.last_name}` : "Instructor"); + || [profile?.first_name, profile?.last_name].filter(Boolean).join(" ") + || "Instructor"; const initials = profileName .split(" ") @@ -85,23 +100,23 @@ export function TeachLayout({ children }: TeachLayoutProps) { Teacher Portal

- Oxatl Yoga + {isDemoMode ? "Oxatl Yoga" : "Your teaching studios"}

- + } -
- {teachNavigation.map((item) => ( + {teachNavigation.filter(item => isDemoMode || ["/teach", "/teach/schedule"].includes(item.href)).map((item) => ( {children} +
{isDemoMode || ["/teach", "/teach/schedule"].includes(location.pathname.replace(/\/$/, "")) + ? children + :

Not available yet

This part of the teacher portal is not available for live studios yet.

}
); diff --git a/src/components/teach/TeachingClasses.tsx b/src/components/teach/TeachingClasses.tsx new file mode 100644 index 0000000..6913668 --- /dev/null +++ b/src/components/teach/TeachingClasses.tsx @@ -0,0 +1,108 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { data } from "@/lib/backend"; +import { useAuth } from "@/contexts/AuthContext"; +import { TeachLayout } from "@/components/teach/TeachLayout"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useToast } from "@/hooks/use-toast"; +import type { TeachingClass } from "@/lib/backend/types"; + +function classTime(value: string, timeZone: string) { + return new Intl.DateTimeFormat(undefined, { + weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", + timeZone: timeZone || "UTC", timeZoneName: "short", + }).format(new Date(value)); +} + +export function TeachingClasses({ title }: { title: string }) { + const { user } = useAuth(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [selected, setSelected] = useState(null); + const [pendingId, setPendingId] = useState(null); + const inFlight = useRef(false); + const classes = useQuery({ + queryKey: ["teaching-classes", user?.id], enabled: Boolean(user), retry: 1, + queryFn: async () => { + const result = await data.getMyTeachingClasses(); + if (result.error) throw new Error(result.error.message); + return result.data ?? []; + }, + }); + const roster = useQuery({ + queryKey: ["teaching-roster", user?.id, selected?.occurrence_id], + enabled: Boolean(user && selected), retry: 1, + queryFn: async () => { + const result = await data.getTeachingRoster(selected!.occurrence_id); + if (result.error) throw new Error(result.error.message); + return result.data ?? []; + }, + }); + + const checkIn = async (bookingId: string) => { + if (inFlight.current || !selected) return; + inFlight.current = true; + setPendingId(bookingId); + try { + const result = await data.checkInTeachingBooking(bookingId); + if (result.error) throw new Error(result.error.message); + if (!result.data || result.data.id !== bookingId || result.data.status !== "checked_in") throw new Error("Attendance was not confirmed. Refresh the roster and try again."); + // Show only attendance acknowledged by the server, even if the refresh then fails. + queryClient.setQueryData(["teaching-roster", user?.id, selected.occurrence_id], + rows => rows?.map(row => row.booking_id === bookingId ? { ...row, status: "checked_in" } : row)); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["teaching-roster", user?.id, selected.occurrence_id] }), + queryClient.invalidateQueries({ queryKey: ["teaching-classes", user?.id] }), + ]); + toast({ title: "Attendance recorded" }); + } catch (error) { + const refreshed = await roster.refetch(); + if (!refreshed.error && refreshed.data?.some(row => row.booking_id === bookingId && row.status === "checked_in")) { + await queryClient.invalidateQueries({ queryKey: ["teaching-classes", user?.id] }); + toast({ title: "Attendance confirmed from roster" }); + } else { + toast({ title: "Could not confirm check-in", description: error instanceof Error ? error.message : "Refresh the roster before retrying.", variant: "destructive" }); + } + } finally { + inFlight.current = false; + setPendingId(null); + } + }; + + return +
+

{title}

+

Your assigned classes in the next 14 days. Times are shown in each studio's timezone.

+ {classes.isPending ?

Loading your classes…

+ : classes.error ?

Couldn't load your teaching schedule.

+ : !classes.data?.length ?

No assigned classes in the next 14 days.

+ : classes.data.map(cls => + {cls.offering_name}{cls.is_cancelled ? " — Cancelled" : ""} + +

{cls.studio_name} · {[cls.location_name, cls.room].filter(Boolean).join(" · ")}

+

{classTime(cls.starts_at, cls.studio_timezone)}

+

{cls.booked} / {cls.capacity} booked · {cls.checked_in} checked in

+ +
+
)} +
+ { if (!open && !inFlight.current) setSelected(null); }}> + { if (inFlight.current) event.preventDefault(); }} onPointerDownOutside={event => { if (inFlight.current) event.preventDefault(); }}> + {selected?.offering_name} roster + Check-in opens 60 minutes before class and closes when class ends. Recorded attendance cannot be undone here. + + {roster.isPending ?

Loading roster…

+ : roster.error ?

Couldn't load this roster.

+ : !roster.data?.length ?

No attendees available for this class.

+ :
    {roster.data.map(attendee =>
  • + {attendee.student_name} + {attendee.status === "confirmed" + ? + : {attendee.status === "checked_in" ? "Checked in" : "Waitlisted"}} +
  • )}
} +
+
+
; +} diff --git a/src/lib/backend/supabase.ts b/src/lib/backend/supabase.ts index e939a3d..97e8b7a 100644 --- a/src/lib/backend/supabase.ts +++ b/src/lib/backend/supabase.ts @@ -25,6 +25,8 @@ import type { AuthError, SignUpMetadata, DataProvider, + TeachingClass, + TeachingAttendee, DataResult, MutationResult, CreateMessageInput, @@ -133,7 +135,8 @@ const supabaseAuth: AuthProvider = { }, async signOut() { - await getClient().auth.signOut(); + const { error } = await getClient().auth.signOut(); + if (error) throw new Error(error.message); }, async resetPassword(email, options) { @@ -184,6 +187,18 @@ const supabaseAuth: AuthProvider = { // --------------------------------------------------------------------------- const supabaseData: DataProvider = { + async getMyTeachingClasses(): Promise> { + const { data, error } = await getClient().rpc("get_my_teaching_classes" as never); + return { data: data as TeachingClass[] | null, error: error ? { message: error.message } : null }; + }, + async getTeachingRoster(occurrenceId): Promise> { + const { data, error } = await getClient().rpc("get_teaching_roster" as never, { p_occurrence_id: occurrenceId } as never); + return { data: data as TeachingAttendee[] | null, error: error ? { message: error.message } : null }; + }, + async checkInTeachingBooking(bookingId): Promise> { + const { data, error } = await getClient().rpc("check_in_teaching_booking" as never, { p_booking_id: bookingId } as never); + return { data: data as Booking | null, error: error ? { message: error.message } : null }; + }, async markProfileClaimed(userId): Promise { // RLS "Users can update own profile" scopes this to the caller. The // is_guest filter keeps claimed_at the first-claim time. diff --git a/src/lib/backend/types.ts b/src/lib/backend/types.ts index 56d2fd3..e8db132 100644 --- a/src/lib/backend/types.ts +++ b/src/lib/backend/types.ts @@ -22,6 +22,27 @@ import type { AttributionModel, AttributionSourceRow, AutomationSettingsRow, Mem /** Minimal user identity — what the app needs from any auth system */ import type { CancelFollowup, CancelPerson } from "@/lib/classCancel"; +export interface TeachingClass { + occurrence_id: string; + offering_name: string; + studio_name: string; + studio_timezone: string; + location_name: string | null; + room: string | null; + starts_at: string; + ends_at: string; + capacity: number; + is_cancelled: boolean; + booked: number; + checked_in: number; +} + +export interface TeachingAttendee { + booking_id: string; + student_name: string; + status: "confirmed" | "checked_in" | "waitlisted"; +} + export interface UpcomingClass { id: string; starts_at: string; @@ -195,6 +216,9 @@ export interface MemberEntitlements { } export interface DataProvider { + getMyTeachingClasses(): Promise>; + getTeachingRoster(occurrenceId: string): Promise>; + checkInTeachingBooking(bookingId: string): Promise>; /** Fetch a user profile by ID */ getProfile(userId: string): Promise>; diff --git a/src/pages/teach/Dashboard.tsx b/src/pages/teach/Dashboard.tsx index 14d91b7..4255e25 100644 --- a/src/pages/teach/Dashboard.tsx +++ b/src/pages/teach/Dashboard.tsx @@ -1,3 +1,5 @@ +import { useAuth } from "@/contexts/AuthContext"; +import { TeachingClasses } from "@/components/teach/TeachingClasses"; import { useState } from "react"; import { TeachLayout } from "@/components/teach/TeachLayout"; import { useToast } from "@/hooks/use-toast"; @@ -203,7 +205,7 @@ const earningsSummary = { // Studio setting: tips enabled (in production this would come from studio settings) const TIPS_ENABLED = false; -export default function TeachDashboard() { +function DemoTeachDashboard() { const { toast } = useToast(); const [checkInClassId, setCheckInClassId] = useState(null); const [students, setStudents] = useState>(mockStudents); @@ -760,3 +762,8 @@ export default function TeachDashboard() { ); } + +export default function TeachDashboard() { + const { isDemoMode } = useAuth(); + return isDemoMode ? : ; +} diff --git a/src/pages/teach/Schedule.tsx b/src/pages/teach/Schedule.tsx index 5bf2896..fd1bef7 100644 --- a/src/pages/teach/Schedule.tsx +++ b/src/pages/teach/Schedule.tsx @@ -1,3 +1,5 @@ +import { useAuth } from "@/contexts/AuthContext"; +import { TeachingClasses } from "@/components/teach/TeachingClasses"; import { useState } from "react"; import { TeachLayout } from "@/components/teach/TeachLayout"; import { useToast } from "@/hooks/use-toast"; @@ -178,7 +180,7 @@ const mockSchedule: ScheduledClass[] = [ }, ]; -export default function TeachSchedule() { +function DemoTeachSchedule() { const { toast } = useToast(); const [viewMode, setViewMode] = useState("week"); const [currentWeekOffset, setCurrentWeekOffset] = useState(0); @@ -529,3 +531,8 @@ export default function TeachSchedule() { ); } + +export default function TeachSchedule() { + const { isDemoMode } = useAuth(); + return isDemoMode ? : ; +} diff --git a/supabase/migrations/00044_teacher_attendance.sql b/supabase/migrations/00044_teacher_attendance.sql new file mode 100644 index 0000000..3364d8c --- /dev/null +++ b/supabase/migrations/00044_teacher_attendance.sql @@ -0,0 +1,63 @@ +-- Teacher portal: read only assigned classes/rosters; attendance uses the existing ledger path. +CREATE OR REPLACE FUNCTION get_my_teaching_classes() +RETURNS TABLE ( + occurrence_id UUID, offering_name TEXT, studio_name TEXT, studio_timezone TEXT, + location_name TEXT, room TEXT, starts_at TIMESTAMPTZ, ends_at TIMESTAMPTZ, + capacity INTEGER, is_cancelled BOOLEAN, booked BIGINT, checked_in BIGINT +) +LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$ + SELECT co.id, o.name, s.name, s.timezone, l.name, co.room, co.starts_at, co.ends_at, + co.capacity, co.is_cancelled, + (SELECT count(*) FROM bookings b WHERE b.class_occurrence_id = co.id AND b.status IN ('confirmed', 'checked_in')), + (SELECT count(*) FROM bookings b WHERE b.class_occurrence_id = co.id AND b.status = 'checked_in') + FROM class_occurrences co + JOIN offerings o ON o.id = co.offering_id + JOIN studios s ON s.id = co.studio_id + LEFT JOIN locations l ON l.id = co.location_id + WHERE co.teacher_id = (SELECT auth.uid()) + AND co.ends_at >= NOW() AND co.starts_at < NOW() + INTERVAL '14 days' + AND EXISTS (SELECT 1 FROM studio_staff st WHERE st.studio_id = co.studio_id + AND st.profile_id = (SELECT auth.uid()) AND st.is_active AND st.role IN ('teacher', 'owner', 'admin')) + ORDER BY co.starts_at, co.id; +$$; + +CREATE OR REPLACE FUNCTION get_teaching_roster(p_occurrence_id UUID) +RETURNS TABLE (booking_id UUID, student_name TEXT, status booking_status) +LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$ + SELECT b.id, COALESCE(NULLIF(p.display_name, ''), NULLIF(trim(concat_ws(' ', p.first_name, p.last_name)), ''), 'Student'), b.status + FROM class_occurrences co + JOIN bookings b ON b.class_occurrence_id = co.id AND b.studio_id = co.studio_id + JOIN profiles p ON p.id = b.profile_id + WHERE co.id = p_occurrence_id AND co.teacher_id = (SELECT auth.uid()) + AND b.status IN ('confirmed', 'checked_in', 'waitlisted') + AND EXISTS (SELECT 1 FROM studio_staff st WHERE st.studio_id = co.studio_id + AND st.profile_id = (SELECT auth.uid()) AND st.is_active AND st.role IN ('teacher', 'owner', 'admin')) + ORDER BY p.display_name NULLS LAST, b.id; +$$; + +CREATE OR REPLACE FUNCTION check_in_teaching_booking(p_booking_id UUID) +RETURNS bookings LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $$ +DECLARE v_occ class_occurrences%ROWTYPE; +BEGIN + SELECT co.* INTO v_occ FROM class_occurrences co + JOIN bookings b ON b.class_occurrence_id = co.id AND b.studio_id = co.studio_id + WHERE b.id = p_booking_id FOR UPDATE OF co; + IF NOT FOUND OR v_occ.teacher_id IS DISTINCT FROM auth.uid() OR auth.uid() IS NULL THEN + RAISE EXCEPTION 'Not authorized to check in this class'; + END IF; + PERFORM 1 FROM studio_staff st WHERE st.studio_id = v_occ.studio_id + AND st.profile_id = auth.uid() AND st.is_active AND st.role IN ('teacher', 'owner', 'admin') FOR SHARE; + IF NOT FOUND THEN RAISE EXCEPTION 'Not authorized to check in this class'; END IF; + PERFORM 1 FROM bookings b WHERE b.id = p_booking_id + AND b.class_occurrence_id = v_occ.id AND b.studio_id = v_occ.studio_id FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'Not authorized to check in this class'; END IF; + RETURN check_in_booking(p_booking_id, 'staff_manual'); +END; +$$; + +REVOKE ALL ON FUNCTION get_my_teaching_classes() FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION get_teaching_roster(UUID) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION check_in_teaching_booking(UUID) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION get_my_teaching_classes() TO authenticated; +GRANT EXECUTE ON FUNCTION get_teaching_roster(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION check_in_teaching_booking(UUID) TO authenticated; diff --git a/supabase/tests/024_teacher_attendance.test.sql b/supabase/tests/024_teacher_attendance.test.sql new file mode 100644 index 0000000..7498dbf --- /dev/null +++ b/supabase/tests/024_teacher_attendance.test.sql @@ -0,0 +1,106 @@ +\i helpers.sql +UPDATE studio_staff SET role = 'teacher' WHERE profile_id = pg_temp.id('staff_a'); +UPDATE class_occurrences SET teacher_id = pg_temp.id('staff_a') WHERE id IN (pg_temp.id('occ_a_open'), pg_temp.id('occ_a_cancelled')); +UPDATE class_occurrences SET starts_at = NOW() + interval '10 minutes', ends_at = NOW() + interval '70 minutes' WHERE id = pg_temp.id('occ_a_open'); +INSERT INTO bookings (studio_id, class_occurrence_id, profile_id, status) VALUES + (pg_temp.id('studio_a'), pg_temp.id('occ_a_open'), pg_temp.id('student_a1'), 'confirmed'), + (pg_temp.id('studio_a'), pg_temp.id('occ_a_cancelled'), pg_temp.id('student_a2'), 'confirmed'), + (pg_temp.id('studio_b'), pg_temp.id('occ_b_open'), pg_temp.id('student_b1'), 'confirmed'); + +DO $$ +DECLARE n int; msg text; bk uuid; other_bk uuid; cancelled_bk uuid; result bookings; +BEGIN + SELECT id INTO bk FROM bookings WHERE class_occurrence_id = pg_temp.id('occ_a_open'); + SELECT id INTO other_bk FROM bookings WHERE class_occurrence_id = pg_temp.id('occ_b_open'); + SELECT id INTO cancelled_bk FROM bookings WHERE class_occurrence_id = pg_temp.id('occ_a_cancelled'); + PERFORM pg_temp.as_user(pg_temp.id('staff_a')); + SELECT count(*) INTO n FROM get_my_teaching_classes(); + EXECUTE 'RESET ROLE'; + PERFORM pg_temp.ok(n = 2, 'TEACH-01', 'assigned active teacher sees only own two classes'); + PERFORM pg_temp.as_user(pg_temp.id('staff_a')); + SELECT count(*) INTO n FROM get_teaching_roster(pg_temp.id('occ_a_open')); + EXECUTE 'RESET ROLE'; + PERFORM pg_temp.ok(n = 1, 'TEACH-02', 'assigned teacher sees roster'); + PERFORM pg_temp.as_user(pg_temp.id('staff_a')); + SELECT count(*) INTO n FROM get_teaching_roster(pg_temp.id('occ_b_open')); + msg := NULL; + BEGIN PERFORM check_in_teaching_booking(other_bk); EXCEPTION WHEN OTHERS THEN msg := SQLERRM; END; + EXECUTE 'RESET ROLE'; + PERFORM pg_temp.ok(n = 0 AND msg LIKE 'Not authorized%', 'TEACH-03', 'cross-studio roster and check-in denied'); + + INSERT INTO studio_staff (studio_id, profile_id, role) VALUES (pg_temp.id('studio_a'), pg_temp.id('staff_b'), 'teacher'); + PERFORM pg_temp.as_user(pg_temp.id('staff_b')); + SELECT count(*) INTO n FROM get_teaching_roster(pg_temp.id('occ_a_open')); + msg := NULL; + BEGIN PERFORM check_in_teaching_booking(bk); EXCEPTION WHEN OTHERS THEN msg := SQLERRM; END; + EXECUTE 'RESET ROLE'; + PERFORM pg_temp.ok(n = 0 AND msg LIKE 'Not authorized%', 'TEACH-14', 'other active teacher in same studio cannot read roster or check in'); + PERFORM pg_temp.as_user(pg_temp.id('staff_a')); + msg := NULL; + BEGIN PERFORM check_in_teaching_booking(cancelled_bk); EXCEPTION WHEN OTHERS THEN msg := SQLERRM; END; + EXECUTE 'RESET ROLE'; + PERFORM pg_temp.ok(msg = 'Class is cancelled', 'TEACH-15', 'cancelled class cannot accept attendance'); + + PERFORM pg_temp.as_user(pg_temp.id('student_a1')); + SELECT count(*) INTO n FROM get_my_teaching_classes(); + SELECT n + count(*) INTO n FROM get_teaching_roster(pg_temp.id('occ_a_open')); + msg := NULL; + BEGIN PERFORM check_in_teaching_booking(bk); EXCEPTION WHEN OTHERS THEN msg := SQLERRM; END; + EXECUTE 'RESET ROLE'; + PERFORM pg_temp.ok(n = 0 AND msg LIKE 'Not authorized%', 'TEACH-04', 'student cannot read teacher data or check in'); + + UPDATE studio_staff SET is_active = false WHERE profile_id = pg_temp.id('staff_a'); + PERFORM pg_temp.as_user(pg_temp.id('staff_a')); + SELECT count(*) INTO n FROM get_my_teaching_classes(); + SELECT n + count(*) INTO n FROM get_teaching_roster(pg_temp.id('occ_a_open')); + msg := NULL; + BEGIN PERFORM check_in_teaching_booking(bk); EXCEPTION WHEN OTHERS THEN msg := SQLERRM; END; + EXECUTE 'RESET ROLE'; + PERFORM pg_temp.ok(n = 0 AND msg LIKE 'Not authorized%', 'TEACH-05', 'disabled staff cannot read or mutate'); + UPDATE studio_staff SET is_active = true, role = 'front_desk' WHERE profile_id = pg_temp.id('staff_a'); + PERFORM pg_temp.as_user(pg_temp.id('staff_a')); + SELECT count(*) INTO n FROM get_my_teaching_classes(); + SELECT n + count(*) INTO n FROM get_teaching_roster(pg_temp.id('occ_a_open')); + msg := NULL; + BEGIN PERFORM check_in_teaching_booking(bk); EXCEPTION WHEN OTHERS THEN msg := SQLERRM; END; + EXECUTE 'RESET ROLE'; + PERFORM pg_temp.ok(n = 0 AND msg LIKE 'Not authorized%', 'TEACH-06', 'unexpected staff role denied despite assignment'); + UPDATE studio_staff SET role = 'teacher' WHERE profile_id = pg_temp.id('staff_a'); + + UPDATE class_occurrences SET teacher_id = NULL WHERE id = pg_temp.id('occ_a_open'); + PERFORM pg_temp.as_user(pg_temp.id('staff_a')); + SELECT count(*) INTO n FROM get_teaching_roster(pg_temp.id('occ_a_open')); + msg := NULL; + BEGIN PERFORM check_in_teaching_booking(bk); EXCEPTION WHEN OTHERS THEN msg := SQLERRM; END; + EXECUTE 'RESET ROLE'; + PERFORM pg_temp.ok(n = 0 AND msg LIKE 'Not authorized%', 'TEACH-07', 'removed assignment loses access'); + UPDATE class_occurrences SET teacher_id = pg_temp.id('staff_a'), starts_at = NOW() + interval '2 days', ends_at = NOW() + interval '2 days 1 hour' WHERE id = pg_temp.id('occ_a_open'); + PERFORM pg_temp.as_user(pg_temp.id('staff_a')); + msg := NULL; + BEGIN PERFORM check_in_teaching_booking(bk); EXCEPTION WHEN OTHERS THEN msg := SQLERRM; END; + EXECUTE 'RESET ROLE'; + PERFORM pg_temp.ok(msg LIKE 'Check-in is open%', 'TEACH-08', 'existing check-in window enforced'); + UPDATE class_occurrences SET starts_at = NOW() + interval '10 minutes', ends_at = NOW() + interval '70 minutes' WHERE id = pg_temp.id('occ_a_open'); + PERFORM pg_temp.as_user(pg_temp.id('staff_a')); + result := check_in_teaching_booking(bk); + EXECUTE 'RESET ROLE'; + PERFORM pg_temp.ok(result.status = 'checked_in' AND result.checked_in_by = pg_temp.id('staff_a'), 'TEACH-09', 'attendance records authenticated teacher'); + PERFORM pg_temp.as_user(pg_temp.id('staff_a')); + msg := NULL; + BEGIN PERFORM check_in_teaching_booking(bk); EXCEPTION WHEN OTHERS THEN msg := SQLERRM; END; + EXECUTE 'RESET ROLE'; + PERFORM pg_temp.ok(msg LIKE 'Only confirmed%', 'TEACH-10', 'duplicate check-in does not change attendance'); + + BEGIN + PERFORM pg_temp.as_anon(); PERFORM get_my_teaching_classes(); + EXECUTE 'RESET ROLE'; PERFORM pg_temp.ok(false, 'TEACH-11', 'anon schedule denied'); + EXCEPTION WHEN insufficient_privilege THEN EXECUTE 'RESET ROLE'; PERFORM pg_temp.ok(true, 'TEACH-11', 'anon schedule denied'); END; + BEGIN + PERFORM pg_temp.as_anon(); PERFORM get_teaching_roster(pg_temp.id('occ_a_open')); + EXECUTE 'RESET ROLE'; PERFORM pg_temp.ok(false, 'TEACH-12', 'anon roster denied'); + EXCEPTION WHEN insufficient_privilege THEN EXECUTE 'RESET ROLE'; PERFORM pg_temp.ok(true, 'TEACH-12', 'anon roster denied'); END; + BEGIN + PERFORM pg_temp.as_anon(); PERFORM check_in_teaching_booking(bk); + EXECUTE 'RESET ROLE'; PERFORM pg_temp.ok(false, 'TEACH-13', 'anon check-in denied'); + EXCEPTION WHEN insufficient_privilege THEN EXECUTE 'RESET ROLE'; PERFORM pg_temp.ok(true, 'TEACH-13', 'anon check-in denied'); END; +END $$; diff --git a/supabase/tests/run.sh b/supabase/tests/run.sh index f0ba145..e87a909 100755 --- a/supabase/tests/run.sh +++ b/supabase/tests/run.sh @@ -20,7 +20,7 @@ cleanup() { q -c "$CLEAN" >/dev/null 2>&1 || true; } trap cleanup EXIT failed=0 -for f in 010_isolation 015_baseline 016_studio_settings 017_schedule_generation 018_page_live 019_my_bookings 020_discover 021_function_hardening 022_catalog_edits 023_class_cancel 030_booking 035_booking_integrity 037_book_auto 038_seat_holds 060_payments; do +for f in 010_isolation 015_baseline 016_studio_settings 017_schedule_generation 018_page_live 019_my_bookings 020_discover 021_function_hardening 022_catalog_edits 023_class_cancel 024_teacher_attendance 030_booking 035_booking_integrity 037_book_auto 038_seat_holds 060_payments; do echo "== $f" cleanup q -f fixtures.sql >/dev/null || { echo "fixtures failed"; exit 2; } From 4c73f6495f09e710e88a4de8fceb8de910f98dd8 Mon Sep 17 00:00:00 2001 From: Taylor O'Neal Date: Sun, 18 Oct 2026 21:39:32 +0800 Subject: [PATCH 3/4] docs: prepare teacher rollout and hosted verification packet --- docs/plans/PROGRESS.md | 6 +++ docs/plans/TEACHER_ROLLOUT.md | 34 +++++++++++++ scripts/db/prod/apply-00044.sql | 69 ++++++++++++++++++++++++++ scripts/db/prod/verify-00043-00044.sql | 23 +++++++++ 4 files changed, 132 insertions(+) create mode 100644 docs/plans/TEACHER_ROLLOUT.md create mode 100644 scripts/db/prod/apply-00044.sql create mode 100644 scripts/db/prod/verify-00043-00044.sql diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 626cc94..6597e9b 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -156,3 +156,9 @@ supabase start && supabase db reset && npm run test:db # throwaway local DB, n - Added browser coverage for failed/successful check-in, duplicate clicks, persisted roster refresh, lost response recovery, schedule/roster errors, direct navigation to unavailable features and student route denial without teacher RPC requests. - Added SQL TEACH-01..15 (assignment, same/cross-studio denial, disabled/unexpected roles, anon denial, time window, duplicate attendance, cancelled class). Included in test runner. **SQL tests NOT EXECUTED locally:** no PostgreSQL server installed/running. Migration00044 NOT APPLIED to any hosted database. Deployment is blocked on a clean database test run and explicit deployment authorization; mocks do not prove production RLS/function execution. - Validation: TypeScript passed; 526 application tests and 32 script tests passed; final full browser suite 23/23 passed with 2 workers, including 390×844 mobile attendance and desktop recovery/sign-out. Earlier 5-worker run had one existing owner cancellation follow-up failure; focused 12/12 and final 23/23 reruns passed without modifying that flow. Production build/postbuild passed through E2E setup. Sign-out now waits for the backend and reports failures rather than navigating on an unconfirmed logout. + +## 2026-10-18: upstream and hosted reconciliation (read-only) +- Fetched upstream: main still7353a2a; no open PRs. Local82c613e and209778a remain unpushed. GitHub's Vercel status is success for deployment HrDS9DXSLc6xvqZfgxUhi7s1jaW8. Public /teach serves HTTP200 SPA shell (main--tUT3jD_.js); authenticated teacher behavior cannot be inferred from that shell. +- Supabase CLI function inventory for tandava-prod returned403 (connected account lacks privileges). No source download, secret access or production query followed. Vercel connector403 and scoped CLI inspect could not resolve the deployment; GitHub/public HTTP evidence is available, direct deployment metadata remains unavailable. +- Latest Stripe smoke39131170968 (Oct18) still fails: Missing SUPABASE_SERVICE_ROLE_KEY. No dispatch or credentials configured. +- Prepared TEACHER_ROLLOUT.md, exact transactional apply-00044.sql bundle, and read-only verify-00043-00044.sql. Migration00043/function deployment status remains unverified;00044 is local only. Full SQL validation and explicit rollout approval remain required. diff --git a/docs/plans/TEACHER_ROLLOUT.md b/docs/plans/TEACHER_ROLLOUT.md new file mode 100644 index 0000000..8927931 --- /dev/null +++ b/docs/plans/TEACHER_ROLLOUT.md @@ -0,0 +1,34 @@ +# Teacher attendance rollout packet — 2026-10-18 + +Prepared only. No hosted SQL, deployment, credentials, Stripe events, or workflow runs were changed. + +## Source and current evidence + +- Remote `main` remains `7353a2a16be3d5f19f19231d587677011264897a`; no open PRs when rechecked. Isolated local branch adds member regression `82c613e` and teacher implementation `209778a`. +- GitHub Vercel commit status reports success for main at deployment `HrDS9DXSLc6xvqZfgxUhi7s1jaW8`: https://vercel.com/tayloroneal-1467s-projects/tandava/HrDS9DXSLc6xvqZfgxUhi7s1jaW8 . Public `/teach` returns HTTP200 SPA HTML; this alone does not verify authenticated operation. +- Existing Supabase CLI authentication returns403 for `functions list --project-ref mkaixgjwakfufmmwembn`. No source download was attempted after denial. Migration00043 and deployed function versions remain unverified, not assumed absent. +- Migration00044 exists only locally; its three teacher RPCs are not on upstream main. New teacher frontend has not been deployed. +- Local checks: TypeScript and focused ESLint pass;526 application+32 script tests pass;23 browser tests pass with2 workers (including390×844 teacher attendance). Database tests remain unexecuted: PostgreSQL server tooling is unavailable. Do not deploy on browser mocks alone. + +## Reviewable artifacts + +| Artifact | Purpose | +|---|---| +| `scripts/db/prod/verify-00043-00044.sql` | Read-only function/table presence, grants, search paths and definition fingerprints; no user rows or secret values | +| Existing `scripts/db/prod/apply-00043.sql` | Existing owner class-cancellation migration bundle, only if owner verification proves needed | +| `scripts/db/prod/apply-00044.sql` | Exact migration00044 wrapped in BEGIN/COMMIT; execute only after DB validation and deployment approval | +| `supabase/tests/024_teacher_attendance.test.sql` |15 assignment/authorization/attendance cases, included in full test runner | +| `supabase/functions/cancel-class/index.ts` and `stripe-webhook/index.ts` | Existing main owner cancellation/refund code; separate from teacher RPC feature | + +## Dependency order after approval + +1. Complete local clean-database migration+full SQL suite using a disposable database. Preserve existing production migration history; earlier production SQL was manually applied, so do not issue blind `db push` or migration repair. +2. Project owner runs the read-only verification query and supplies function metadata (name/version/JWT mode) for `cancel-class` and `stripe-webhook`. Compare deployed source/definitions to main. Presence alone is not proof of matching implementation. Any absent or drifted prerequisite must be reviewed before writing. +3. If00043 is not installed, review and apply its established bundle first. Deploy matching `cancel-class` with JWT verification and `stripe-webhook` with Stripe signature verification (no JWT). Owner confirms webhook subscriptions include `refund.updated`, `refund.failed`, `charge.refund.updated`. These are state changes requiring approval. Do not run the broad deploy-functions script merely to update these two functions. +4. Review and apply00044 after its tests pass. The teacher feature requires no Edge Function deployment, new credential, or global role-permission change. New RPCs require active assigned teacher/owner/admin; existing general staff check-in RPC permissions remain unchanged. +5. Publish/review the local commits, then release the frontend after SQL is confirmed. Neither push nor merge nor deployment is authorized by this packet. +6. In an approved sandbox, verify assigned teacher sign-in → class list → roster → single committed check-in → refresh; verify student/unassigned/inactive callers cannot use new paths. Owner price/schedule and member booking flows remain part of the regression set. + +## Payment proof remains separate + +Latest scheduled Stripe smoke (2026-10-18): https://github.com/TaylorONeal/tandava/actions/runs/39131170968 . It fails before the flow starts because `SUPABASE_SERVICE_ROLE_KEY` is missing, mapped from GitHub environment `stripe-smoke` secret `SMOKE_SUPABASE_SECRET_KEY`. Also required: `SMOKE_STRIPE_TEST_KEY`, `SMOKE_STRIPE_WEBHOOK_SECRET` (presence unverified). Supply values directly through GitHub environment settings, never chat. This workflow mutates hosted sandbox test data and requires authorization before manual dispatch. A passing mock flow does not prove checkout/refund/webhook behavior live. diff --git a/scripts/db/prod/apply-00044.sql b/scripts/db/prod/apply-00044.sql new file mode 100644 index 0000000..6720a7c --- /dev/null +++ b/scripts/db/prod/apply-00044.sql @@ -0,0 +1,69 @@ +-- Review-only rollout bundle: teacher assigned schedule, roster and check-in. +-- Target: tandava-prod mkaixgjwakfufmmwembn. Requires existing schema through 00043. +-- DO NOT APPLY until full database tests pass and deployment is explicitly authorized. +-- Apply before the teacher frontend is released. No new Edge Function is needed for 00044. +BEGIN; +-- Teacher portal: read only assigned classes/rosters; attendance uses the existing ledger path. +CREATE OR REPLACE FUNCTION get_my_teaching_classes() +RETURNS TABLE ( + occurrence_id UUID, offering_name TEXT, studio_name TEXT, studio_timezone TEXT, + location_name TEXT, room TEXT, starts_at TIMESTAMPTZ, ends_at TIMESTAMPTZ, + capacity INTEGER, is_cancelled BOOLEAN, booked BIGINT, checked_in BIGINT +) +LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$ + SELECT co.id, o.name, s.name, s.timezone, l.name, co.room, co.starts_at, co.ends_at, + co.capacity, co.is_cancelled, + (SELECT count(*) FROM bookings b WHERE b.class_occurrence_id = co.id AND b.status IN ('confirmed', 'checked_in')), + (SELECT count(*) FROM bookings b WHERE b.class_occurrence_id = co.id AND b.status = 'checked_in') + FROM class_occurrences co + JOIN offerings o ON o.id = co.offering_id + JOIN studios s ON s.id = co.studio_id + LEFT JOIN locations l ON l.id = co.location_id + WHERE co.teacher_id = (SELECT auth.uid()) + AND co.ends_at >= NOW() AND co.starts_at < NOW() + INTERVAL '14 days' + AND EXISTS (SELECT 1 FROM studio_staff st WHERE st.studio_id = co.studio_id + AND st.profile_id = (SELECT auth.uid()) AND st.is_active AND st.role IN ('teacher', 'owner', 'admin')) + ORDER BY co.starts_at, co.id; +$$; + +CREATE OR REPLACE FUNCTION get_teaching_roster(p_occurrence_id UUID) +RETURNS TABLE (booking_id UUID, student_name TEXT, status booking_status) +LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$ + SELECT b.id, COALESCE(NULLIF(p.display_name, ''), NULLIF(trim(concat_ws(' ', p.first_name, p.last_name)), ''), 'Student'), b.status + FROM class_occurrences co + JOIN bookings b ON b.class_occurrence_id = co.id AND b.studio_id = co.studio_id + JOIN profiles p ON p.id = b.profile_id + WHERE co.id = p_occurrence_id AND co.teacher_id = (SELECT auth.uid()) + AND b.status IN ('confirmed', 'checked_in', 'waitlisted') + AND EXISTS (SELECT 1 FROM studio_staff st WHERE st.studio_id = co.studio_id + AND st.profile_id = (SELECT auth.uid()) AND st.is_active AND st.role IN ('teacher', 'owner', 'admin')) + ORDER BY p.display_name NULLS LAST, b.id; +$$; + +CREATE OR REPLACE FUNCTION check_in_teaching_booking(p_booking_id UUID) +RETURNS bookings LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $$ +DECLARE v_occ class_occurrences%ROWTYPE; +BEGIN + SELECT co.* INTO v_occ FROM class_occurrences co + JOIN bookings b ON b.class_occurrence_id = co.id AND b.studio_id = co.studio_id + WHERE b.id = p_booking_id FOR UPDATE OF co; + IF NOT FOUND OR v_occ.teacher_id IS DISTINCT FROM auth.uid() OR auth.uid() IS NULL THEN + RAISE EXCEPTION 'Not authorized to check in this class'; + END IF; + PERFORM 1 FROM studio_staff st WHERE st.studio_id = v_occ.studio_id + AND st.profile_id = auth.uid() AND st.is_active AND st.role IN ('teacher', 'owner', 'admin') FOR SHARE; + IF NOT FOUND THEN RAISE EXCEPTION 'Not authorized to check in this class'; END IF; + PERFORM 1 FROM bookings b WHERE b.id = p_booking_id + AND b.class_occurrence_id = v_occ.id AND b.studio_id = v_occ.studio_id FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'Not authorized to check in this class'; END IF; + RETURN check_in_booking(p_booking_id, 'staff_manual'); +END; +$$; + +REVOKE ALL ON FUNCTION get_my_teaching_classes() FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION get_teaching_roster(UUID) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION check_in_teaching_booking(UUID) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION get_my_teaching_classes() TO authenticated; +GRANT EXECUTE ON FUNCTION get_teaching_roster(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION check_in_teaching_booking(UUID) TO authenticated; +COMMIT; diff --git a/scripts/db/prod/verify-00043-00044.sql b/scripts/db/prod/verify-00043-00044.sql new file mode 100644 index 0000000..4d61ff2 --- /dev/null +++ b/scripts/db/prod/verify-00043-00044.sql @@ -0,0 +1,23 @@ +-- Read-only catalog inspection. Does not invoke application RPCs or expose user data/secrets. +-- Run against the intended Tandava database before planning any migration/deployment. +SELECT current_database() AS database_name; + +SELECT relation_name, to_regclass(relation_name) IS NOT NULL AS present +FROM (VALUES ('public.refund_requests'), ('public.class_cancel_notices')) AS expected(relation_name); + +WITH expected(signature) AS (VALUES + ('public.preview_class_cancellation(uuid)'), + ('public.cancel_class(uuid,text,text)'), + ('public.class_cancel_followup(uuid)'), + ('public.class_cancel_mark_handled(uuid)'), + ('public.class_cancel_reconcile_notices(uuid)'), + ('public.get_my_teaching_classes()'), + ('public.get_teaching_roster(uuid)'), + ('public.check_in_teaching_booking(uuid)') +) +SELECT e.signature, p.oid IS NOT NULL AS present, + p.prosecdef AS security_definer, p.proconfig AS function_settings, + CASE WHEN p.oid IS NOT NULL THEN has_function_privilege('anon', p.oid, 'EXECUTE') END AS anon_can_execute, + CASE WHEN p.oid IS NOT NULL THEN has_function_privilege('authenticated', p.oid, 'EXECUTE') END AS authenticated_can_execute, + CASE WHEN p.oid IS NOT NULL THEN md5(pg_get_functiondef(p.oid)) END AS definition_fingerprint +FROM expected e LEFT JOIN pg_proc p ON p.oid = to_regprocedure(e.signature); From 0245c75a1fc4d88013503a3180d68936c17cb22e Mon Sep 17 00:00:00 2001 From: Taylor O'Neal Date: Mon, 19 Oct 2026 07:35:31 +0800 Subject: [PATCH 4/4] docs: record passing isolated SQL and PR validation --- docs/plans/BACKLOG.md | 2 +- docs/plans/PROGRESS.md | 5 +++++ docs/plans/TEACHER_ROLLOUT.md | 9 +++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/plans/BACKLOG.md b/docs/plans/BACKLOG.md index fbb17c1..8060506 100644 --- a/docs/plans/BACKLOG.md +++ b/docs/plans/BACKLOG.md @@ -143,5 +143,5 @@ W1 -> W3 -> W4-1 -> W4-3 -> W4-4 -> W5-2 -> W7-3 -> W7-4. W2 gates W4-3 and W5-2 | Task | State | Acceptance / remaining gate | |---|---|---| -| Assigned teacher schedule → roster → recorded attendance | Implemented locally, pending SQL validation and deployment | 00044: active assigned teacher/owner/admin only; narrow roster; existing check_in_booking authoritative window/status. Browser mocks cover failure, recovery, duplicate clicks and student denial. Run TEACH-01..15 with full DB suite, then review/apply migration before deploying UI. | +| Assigned teacher schedule → roster → recorded attendance | Implemented and locally/CI validated, pending deployment | 00044: active assigned teacher/owner/admin only; narrow roster; existing check_in_booking authoritative window/status. Browser mocks cover failure, recovery, duplicate clicks and student denial. TEACH-01..15 and full DB suite passed locally and in PR100 CI; review/apply migration before deploying UI. | | Teacher availability, substitutes, earnings and teacher profile | Deferred, explicitly unavailable in live UI | Demo unchanged. Separate real contracts needed; no false-save controls exposed live. | diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 6597e9b..7d828cb 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -162,3 +162,8 @@ supabase start && supabase db reset && npm run test:db # throwaway local DB, n - Supabase CLI function inventory for tandava-prod returned403 (connected account lacks privileges). No source download, secret access or production query followed. Vercel connector403 and scoped CLI inspect could not resolve the deployment; GitHub/public HTTP evidence is available, direct deployment metadata remains unavailable. - Latest Stripe smoke39131170968 (Oct18) still fails: Missing SUPABASE_SERVICE_ROLE_KEY. No dispatch or credentials configured. - Prepared TEACHER_ROLLOUT.md, exact transactional apply-00044.sql bundle, and read-only verify-00043-00044.sql. Migration00043/function deployment status remains unverified;00044 is local only. Full SQL validation and explicit rollout approval remain required. + +## 2026-10-18: database and PR validation completed +- PostgreSQL16 installed after explicit approval. Fresh isolated Tandava cluster applied every migration through00044 and completed the full SQL suite:251 PASS lines, including TEACH-01..15, payment/refund/idempotency tests, attribution39 blocks, last-seat and last-credit races. Temporary socket-only server stopped and data removed. +- Initial combined cross-app runner produced a false PAY-01 failure: TeacherTree's test creates cluster-global service_role without BYPASSRLS; Tandava's stub preserved it on duplicate-object. Separate fresh clusters per app restore the intended test environment without changing role permissions. No production/payment-code defect was found, and the temporary diagnostic change was reverted. +- PR100 CI run39176946297 passed all four jobs: migrations+SQL, Edge Functions typecheck, browser E2E, and web typecheck/lint/test/build. Local and CI database gates are now complete; prior notes that SQL was unexecuted describe earlier state. No hosted migration or deployment has been performed. diff --git a/docs/plans/TEACHER_ROLLOUT.md b/docs/plans/TEACHER_ROLLOUT.md index 8927931..e115084 100644 --- a/docs/plans/TEACHER_ROLLOUT.md +++ b/docs/plans/TEACHER_ROLLOUT.md @@ -8,7 +8,7 @@ Prepared only. No hosted SQL, deployment, credentials, Stripe events, or workflo - GitHub Vercel commit status reports success for main at deployment `HrDS9DXSLc6xvqZfgxUhi7s1jaW8`: https://vercel.com/tayloroneal-1467s-projects/tandava/HrDS9DXSLc6xvqZfgxUhi7s1jaW8 . Public `/teach` returns HTTP200 SPA HTML; this alone does not verify authenticated operation. - Existing Supabase CLI authentication returns403 for `functions list --project-ref mkaixgjwakfufmmwembn`. No source download was attempted after denial. Migration00043 and deployed function versions remain unverified, not assumed absent. - Migration00044 exists only locally; its three teacher RPCs are not on upstream main. New teacher frontend has not been deployed. -- Local checks: TypeScript and focused ESLint pass;526 application+32 script tests pass;23 browser tests pass with2 workers (including390×844 teacher attendance). Database tests remain unexecuted: PostgreSQL server tooling is unavailable. Do not deploy on browser mocks alone. +- Local checks: TypeScript and focused ESLint pass;526 application+32 script tests pass;23 browser tests pass with2 workers (including390×844 teacher attendance). Full SQL suite now passes in a fresh isolated PostgreSQL16 cluster:251 PASS lines including all15 teacher cases; PR100 CI also passes all four jobs. Temporary server/data were cleaned up. ## Reviewable artifacts @@ -22,7 +22,7 @@ Prepared only. No hosted SQL, deployment, credentials, Stripe events, or workflo ## Dependency order after approval -1. Complete local clean-database migration+full SQL suite using a disposable database. Preserve existing production migration history; earlier production SQL was manually applied, so do not issue blind `db push` or migration repair. +1. Local clean-database migration+full SQL suite completed successfully in a disposable PostgreSQL16 cluster; PR100 CI independently passed. Preserve existing production migration history; earlier production SQL was manually applied, so do not issue blind `db push` or migration repair. 2. Project owner runs the read-only verification query and supplies function metadata (name/version/JWT mode) for `cancel-class` and `stripe-webhook`. Compare deployed source/definitions to main. Presence alone is not proof of matching implementation. Any absent or drifted prerequisite must be reviewed before writing. 3. If00043 is not installed, review and apply its established bundle first. Deploy matching `cancel-class` with JWT verification and `stripe-webhook` with Stripe signature verification (no JWT). Owner confirms webhook subscriptions include `refund.updated`, `refund.failed`, `charge.refund.updated`. These are state changes requiring approval. Do not run the broad deploy-functions script merely to update these two functions. 4. Review and apply00044 after its tests pass. The teacher feature requires no Edge Function deployment, new credential, or global role-permission change. New RPCs require active assigned teacher/owner/admin; existing general staff check-in RPC permissions remain unchanged. @@ -32,3 +32,8 @@ Prepared only. No hosted SQL, deployment, credentials, Stripe events, or workflo ## Payment proof remains separate Latest scheduled Stripe smoke (2026-10-18): https://github.com/TaylorONeal/tandava/actions/runs/39131170968 . It fails before the flow starts because `SUPABASE_SERVICE_ROLE_KEY` is missing, mapped from GitHub environment `stripe-smoke` secret `SMOKE_SUPABASE_SECRET_KEY`. Also required: `SMOKE_STRIPE_TEST_KEY`, `SMOKE_STRIPE_WEBHOOK_SECRET` (presence unverified). Supply values directly through GitHub environment settings, never chat. This workflow mutates hosted sandbox test data and requires authorization before manual dispatch. A passing mock flow does not prove checkout/refund/webhook behavior live. + + +## Validated PR + +https://github.com/TaylorONeal/tandava/pull/100 — CI https://github.com/TaylorONeal/tandava/actions/runs/39176946297 passed all jobs. PostgreSQL tests must use a fresh cluster per app: role objects are cluster-global even when app databases differ. The initial shared-cluster fixture mismatch was corrected by isolating test clusters, not by changing production roles or payment logic.