{cls.booked} / {cls.capacity} booked · {cls.checked_in} checked in
+ setSelected(cls)} disabled={cls.is_cancelled}>View roster for {cls.offering_name}
+
+ )}
+
+
+ ;
+}
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.