diff --git a/docs/plans/BACKLOG.md b/docs/plans/BACKLOG.md index 65964028..80605068 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 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 aa90f1ae..7d828cbc 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -147,3 +147,23 @@ 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. + +## 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. + +## 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 new file mode 100644 index 00000000..e115084e --- /dev/null +++ b/docs/plans/TEACHER_ROLLOUT.md @@ -0,0 +1,39 @@ +# 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). 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 + +| 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. 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. +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. + + +## 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. diff --git a/e2e/member-bookings.spec.ts b/e2e/member-bookings.spec.ts new file mode 100644 index 00000000..3ef3bef9 --- /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); +}); diff --git a/e2e/teacher-attendance.spec.ts b/e2e/teacher-attendance.spec.ts new file mode 100644 index 00000000..825d7028 --- /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/scripts/db/prod/apply-00044.sql b/scripts/db/prod/apply-00044.sql new file mode 100644 index 00000000..6720a7c4 --- /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 00000000..4d61ff28 --- /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); diff --git a/src/components/teach/TeachLayout.tsx b/src/components/teach/TeachLayout.tsx index 32b4ad90..8f826ece 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 00000000..6913668f --- /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 e939a3d8..97e8b7ae 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 56d2fd35..e8db132b 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 14d91b7b..4255e25e 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 5bf28960..fd1bef73 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 00000000..3364d8c7 --- /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 00000000..7498dbf8 --- /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 f0ba1457..e87a9097 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; }