Not available yet
This part of the teacher portal is not available for live studios yet.
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"}
{profileName}
-E-RYT 500
+{isDemoMode ? "E-RYT 500" : "Instructor"}
This part of the teacher portal is not available for live studios yet.
Your assigned classes in the next 14 days. Times are shown in each studio's timezone.
Loading your classes…
+ : classes.error ?Couldn't load your teaching schedule.
No assigned classes in the next 14 days.
+ : classes.data.map(cls =>{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
+