Epic E6: Tutor onboarding, approval workflow, and admin tutor directory - #98
Conversation
- Migration: tutor_profiles, tutor_subjects, tutor_availability + RLS - Tutor profile form with subjects×levels, availability grid, bio, timezone - Server action to save/update tutor profile (upsert pattern) - Admin approve/revoke server actions with audit log - Admin tutor directory (/admin/tutors) with status/subject/level filters - Admin tutor detail page (/admin/tutors/[id]) - Shared fetchApprovedTutors() query helper for E7 matching - Updated admin nav and README Closes #37 #38 #39 #40 #41 #42 #43 Co-authored-by: Taleef7 <89072337+Taleef7@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Implements Epic E6’s tutor onboarding flow and admin approval workflow, adding new tutor-specific tables + RLS, a tutor-facing profile/application form, and an admin tutor directory with approve/revoke actions. This fits into the broader pipeline by producing “approved tutors” that E7 matching can consume.
Changes:
- Added
tutor_profiles,tutor_subjects, andtutor_availabilitytables with triggers + RLS policies. - Implemented
/tutor/profileapplication page + form and a server action to persist tutor profile/subjects/availability. - Implemented
/admin/tutorsdirectory + tutor detail page with approve/revoke server actions and audit logging; added sharedfetchApprovedTutors()helper.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| supabase/migrations/20260224000002_create_tutor_tables.sql | Creates tutor tables, triggers, and RLS policies for tutor/admin access. |
| lib/validators/tutor.ts | Adds Zod schema + inferred TS type for tutor profile form validation. |
| lib/services/matching.ts | Adds shared admin-query helper to fetch approved tutors (optionally filtered). |
| app/tutor/profile/page.tsx | Server page that loads subjects + existing tutor data and renders the form. |
| app/tutor/profile/actions.ts | Server action to upsert profile, replace subjects, and upsert availability. |
| app/tutor/profile/TutorProfileForm.tsx | Client form UI for bio/timezone + subjects×levels + availability grid. |
| app/admin/tutors/page.tsx | Admin directory listing and filtering UI with approve/revoke actions. |
| app/admin/tutors/actions.ts | Admin-only approve/revoke server actions + audit log writes + revalidation. |
| app/admin/tutors/[id]/page.tsx | Admin tutor detail page with full profile view + approve/revoke. |
| app/admin/tutors/TutorFilters.tsx | Client-side dropdown filters for subject/level. |
| app/admin/tutors/TutorActions.tsx | Client-side approve/revoke buttons using useActionState. |
| app/admin/layout.tsx | Adds Tutors link to the admin navigation. |
| README.md | Documents E6 tutor onboarding features and adds migration row. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| create policy "tutor_availability_select" | ||
| on public.tutor_availability for select to authenticated | ||
| using (tutor_user_id = auth.uid() or public.is_admin(auth.uid())); | ||
|
|
||
| create policy "tutor_availability_write_own" | ||
| on public.tutor_availability for all to authenticated | ||
| using (tutor_user_id = auth.uid()) | ||
| with check (tutor_user_id = auth.uid()); |
There was a problem hiding this comment.
Same issue for tutor_availability: the only write policy is tutor_availability_write_own (tutor-only). If admins are intended to manage availability via RLS (as described), add an admin write policy using public.is_admin(auth.uid()) (or update the existing policy to allow admins).
| const { | ||
| register, | ||
| handleSubmit, | ||
| formState: { errors, isSubmitting }, | ||
| } = useForm<TutorProfileFormData>({ | ||
| resolver: zodResolver(tutorProfileSchema), | ||
| defaultValues: { | ||
| bio: defaultValues?.bio ?? '', | ||
| timezone: defaultValues?.timezone ?? 'Asia/Karachi', | ||
| subjects: defaultValues?.subjectEntries ?? [], | ||
| availability: defaultValues?.availWindows ?? [], | ||
| }, | ||
| }) |
There was a problem hiding this comment.
React Hook Form + zodResolver will validate the subjects and availability fields from form state, but the UI checkboxes are managed only via local Set state and never written back into RHF (setValue/Controller). For new profiles this will keep subjects/availability as empty arrays, causing validation to fail and preventing onSubmit from running. Wire the checkbox/grid selections into RHF (e.g., setValue for subjects/availability on toggle) or remove them from the resolver and validate them separately.
There was a problem hiding this comment.
Fixed in 04aeb96. Both toggleSubject and toggleAvail now call setValue('subjects', ...) and setValue('availability', ...) respectively inside each Set state updater, so zodResolver always sees the current selections. onSubmit now reads directly from data.subjects / data.availability — no more parallel Set state rebuild needed.
| const adminClient = createAdminClient() | ||
|
|
||
| // Fetch subjects list and existing tutor profile in parallel | ||
| const [ | ||
| { data: subjectsData }, | ||
| { data: profileData }, | ||
| { data: subjectsData2 }, | ||
| { data: availData }, | ||
| { data: userProfile }, | ||
| ] = await Promise.all([ | ||
| adminClient.from('subjects').select('id, name, code').eq('active', true).order('sort_order'), | ||
| adminClient | ||
| .from('tutor_profiles') | ||
| .select('approved, bio, timezone') | ||
| .eq('tutor_user_id', user.id) | ||
| .maybeSingle(), | ||
| adminClient | ||
| .from('tutor_subjects') | ||
| .select('subject_id, level') | ||
| .eq('tutor_user_id', user.id), | ||
| adminClient | ||
| .from('tutor_availability') | ||
| .select('windows') | ||
| .eq('tutor_user_id', user.id) | ||
| .maybeSingle(), | ||
| adminClient | ||
| .from('user_profiles') | ||
| .select('display_name, timezone') | ||
| .eq('user_id', user.id) | ||
| .maybeSingle(), |
There was a problem hiding this comment.
This page uses createAdminClient() (service role) to read tutor-owned data (tutor_profiles / tutor_subjects / tutor_availability / user_profiles). Since the logged-in tutor can already read/update their own rows via RLS, using the admin client here increases blast radius if a future change accidentally removes the .eq(user.id) filters. Prefer using the user-scoped server client (createClient()) for tutor-owned queries, and reserve createAdminClient() for admin-only routes.
There was a problem hiding this comment.
Fixed in 04aeb96. All tutor-owned tables (tutor_profiles, tutor_subjects, tutor_availability, user_profiles) are now fetched via the user-scoped createClient() which enforces RLS. Only the subjects reference table (no RLS) still uses createAdminClient().
| // 2. Replace tutor_subjects: delete existing rows then re-insert | ||
| const { error: deleteError } = await supabase | ||
| .from('tutor_subjects') | ||
| .delete() | ||
| .eq('tutor_user_id', user.id) | ||
| if (deleteError) return { error: `Failed to update subjects: ${deleteError.message}` } | ||
|
|
||
| if (subjects.length > 0) { | ||
| const { error: insertError } = await supabase.from('tutor_subjects').insert( | ||
| subjects.map((s) => ({ tutor_user_id: user.id, subject_id: s.subject_id, level: s.level })) | ||
| ) | ||
| if (insertError) return { error: `Failed to save subjects: ${insertError.message}` } |
There was a problem hiding this comment.
Replacing tutor_subjects via delete-then-insert is not atomic across the multiple Supabase calls. If the insert fails after the delete succeeds, the tutor can lose all subject selections. Consider moving this into a single DB function/transaction (RPC) or using an upsert + targeted delete strategy so the update is all-or-nothing.
| // 2. Replace tutor_subjects: delete existing rows then re-insert | |
| const { error: deleteError } = await supabase | |
| .from('tutor_subjects') | |
| .delete() | |
| .eq('tutor_user_id', user.id) | |
| if (deleteError) return { error: `Failed to update subjects: ${deleteError.message}` } | |
| if (subjects.length > 0) { | |
| const { error: insertError } = await supabase.from('tutor_subjects').insert( | |
| subjects.map((s) => ({ tutor_user_id: user.id, subject_id: s.subject_id, level: s.level })) | |
| ) | |
| if (insertError) return { error: `Failed to save subjects: ${insertError.message}` } | |
| // 2. Update tutor_subjects in a way that avoids losing all rows on partial failure | |
| if (subjects.length === 0) { | |
| // If no subjects are provided, clear all subjects for this tutor (explicit user intent) | |
| const { error: deleteAllError } = await supabase | |
| .from('tutor_subjects') | |
| .delete() | |
| .eq('tutor_user_id', user.id) | |
| if (deleteAllError) return { error: `Failed to update subjects: ${deleteAllError.message}` } | |
| } else { | |
| // 2a. Upsert the provided subjects for this tutor | |
| const mappedSubjects = subjects.map((s) => ({ | |
| tutor_user_id: user.id, | |
| subject_id: s.subject_id, | |
| level: s.level, | |
| })) | |
| const { error: upsertError } = await supabase | |
| .from('tutor_subjects') | |
| .upsert(mappedSubjects, { onConflict: 'tutor_user_id,subject_id,level' }) | |
| if (upsertError) return { error: `Failed to save subjects: ${upsertError.message}` } | |
| // 2b. Delete any existing subjects for this tutor that are no longer in the provided list | |
| const subjectIds = subjects.map((s) => s.subject_id) | |
| const { error: deleteStaleError } = await supabase | |
| .from('tutor_subjects') | |
| .delete() | |
| .eq('tutor_user_id', user.id) | |
| .not('subject_id', 'in', `(${subjectIds.join(',')})`) | |
| if (deleteStaleError) { | |
| return { error: `Failed to remove old subjects: ${deleteStaleError.message}` } | |
| } |
There was a problem hiding this comment.
Fixed in 04aeb96. The action now: (1) upserts all new subjects (safe even if insert fails partway — no data loss), (2) fetches the current subjects, (3) computes the stale diff, and (4) deletes all stale (subject_id, level) pairs in a single OR-filter query instead of N separate round-trips.
|
|
||
| create policy "tutor_profiles_insert" | ||
| on public.tutor_profiles for insert to authenticated | ||
| with check (tutor_user_id = auth.uid()); |
There was a problem hiding this comment.
The current RLS policy for tutor_profiles INSERT only checks tutor_user_id = auth.uid(), which still allows a tutor to insert their row with approved = true (or any other value). If approved is meant to be admin-only, the INSERT policy should enforce approved = false (or otherwise prevent non-admins from setting it).
| with check (tutor_user_id = auth.uid()); | |
| with check ( | |
| tutor_user_id = auth.uid() | |
| and (approved = false or public.is_admin(auth.uid())) | |
| ); |
There was a problem hiding this comment.
Fixed in 04aeb96. The tutor_profiles_insert policy now includes and (approved = false or public.is_admin(auth.uid())) in its WITH CHECK, so non-admin tutors can only insert with approved = false.
| create policy "tutor_subjects_select" | ||
| on public.tutor_subjects for select to authenticated | ||
| using (tutor_user_id = auth.uid() or public.is_admin(auth.uid())); | ||
|
|
||
| create policy "tutor_subjects_write_own" | ||
| on public.tutor_subjects for all to authenticated | ||
| using (tutor_user_id = auth.uid()) | ||
| with check (tutor_user_id = auth.uid()); |
There was a problem hiding this comment.
The PR description says admins can read/write tutor_subjects via RLS, but the only write policy here is tutor_subjects_write_own (restricted to tutor_user_id = auth.uid()). If you want admin writes under the authenticated role (non-service-role), add a separate admin write policy (or expand this one) using public.is_admin(auth.uid()).
There was a problem hiding this comment.
Fixed in 04aeb96. Added tutor_subjects_admin_write policy using public.is_admin(auth.uid()) for both USING and WITH CHECK, giving admins full write access to tutor_subjects.
| create policy "tutor_profiles_update_own" | ||
| on public.tutor_profiles for update to authenticated | ||
| using (tutor_user_id = auth.uid()) | ||
| with check (tutor_user_id = auth.uid()); |
There was a problem hiding this comment.
The tutor_profiles_update_own RLS policy lets any authenticated tutor update all columns of their own tutor_profiles row, including the approved flag. An attacker can bypass the intended approval workflow by using the Supabase client (or any direct PostgREST call) to run an update on tutor_profiles where tutor_user_id = auth.uid() and set approved = true, effectively self‑approving without admin review. Tighten this policy so non‑admin users cannot modify approved (for example, by adding a WITH CHECK condition that constrains approved or by moving approval changes into an admin‑only path) while keeping other profile fields editable by tutors.
| with check (tutor_user_id = auth.uid()); | |
| with check ( | |
| tutor_user_id = auth.uid() | |
| and approved = ( | |
| select p.approved | |
| from public.tutor_profiles p | |
| where p.tutor_user_id = auth.uid() | |
| ) | |
| ); |
There was a problem hiding this comment.
Fixed in 04aeb96. The tutor_profiles_update_own policy now has a WITH CHECK that uses a subquery to assert the new approved value equals the existing one — any attempt by a tutor to flip approved = true via the API is blocked at the DB layer. Only the tutor_profiles_admin_update policy (which requires is_admin(auth.uid())) can change approved.
|
@copilot apply changes based on the comments in this thread |
…ect update, a11y - Migration: enforce approved=false on INSERT; block tutor from self-approving via UPDATE WITH CHECK subquery; add admin write policies for tutor_subjects and tutor_availability - TutorProfileForm: sync checkbox/grid state into RHF setValue so zodResolver validates subjects/availability correctly; read from data.subjects/availability in onSubmit - page.tsx: tutor-owned tables now fetched via user-scoped createClient() (RLS-enforced); only subjects table (no RLS) uses adminClient - actions.ts: replace non-atomic delete+insert with upsert+diff-delete; batch stale subject removal in one OR-filter query instead of N round-trips - Accessibility: add aria-describedby linking error messages to subjects/availability groups" Co-authored-by: Taleef7 <89072337+Taleef7@users.noreply.github.com>
Implements the full tutor onboarding and admin approval pipeline (Epic E6). Tutors apply via a structured profile form; admins review, approve, or revoke via a filterable directory. Approved tutors gate into E7 matching.
Summary
Full implementation of Epic E6: tutor profile/application form, admin approval workflow with audit logging, and a filterable admin tutor directory. Includes security hardening of RLS policies based on review feedback.
Changes
20260224000002_create_tutor_tables.sql:tutor_profiles(approved bool default false,bio,timezone),tutor_subjects(composite PKtutor_user_id × subject_id × level),tutor_availability(JSONBwindows: [{day,start,end}]); RLS on all three tables with the following security properties:tutor_profiles_insert: enforcesapproved = falseon non-admin inserts — tutors cannot self-approve on row creationtutor_profiles_update_own:WITH CHECKuses a subquery to lock theapprovedcolumn, preventing tutors from flipping it via any direct API calltutor_subjects_admin_writeandtutor_availability_admin_write: explicit admin write policies added so admins can manage these tables under the authenticated roleapp/tutor/profile/page.tsx— server component; fetches subjects list via admin client; all tutor-owned tables (tutor_profiles,tutor_subjects,tutor_availability,user_profiles) fetched via user-scopedcreateClient()to enforce RLSapp/tutor/profile/TutorProfileForm.tsx— client form (React Hook Form + Zod): subjects × levels checkbox table (9 × 2), weekly availability grid (7 days × 4 time blocks), bio textarea, timezone select, pending/approved status badge; checkbox/grid toggles callsetValueto keep RHF state in sync with Zod validation;aria-describedbyadded to subjects and availability groups for accessibilityapp/tutor/profile/actions.ts— server action: upsertstutor_profiles; updatestutor_subjectsvia upsert-then-targeted-delete (avoids data loss on partial failure; stale rows removed in a single OR-filter query); upsertstutor_availabilitylib/validators/tutor.ts— Zod schema:biomin 50 chars, subjects array min 1, availability windows min 1app/admin/tutors/page.tsx— server component; filterable by status (all/pending/approved), subject, level; table with name, subjects+levels grouped, timezone, applied date, approve/revoke/viewapp/admin/tutors/TutorFilters.tsx— client component for subject/level dropdowns (extracted to avoidwindowin server component)app/admin/tutors/[id]/page.tsx— full tutor detail: bio, subjects × levels, availability in human-readable format, WhatsApp, approve/revokeapp/admin/tutors/actions.ts—approveTutor/revokeTutorApprovalserver actions; both writeaudit_logsand callrevalidatePathapp/admin/tutors/TutorActions.tsx—ApproveButton/RevokeButtonclient components viauseActionStatewith inline error feedbacklib/services/matching.ts—fetchApprovedTutors(subjectId?, level?)typed query helper filteringapproved = true; ready for E7 to consume without duplicating the joinapp/admin/layout.tsx— added Tutors nav linkREADME.md— E6 feature table + new migration rowTesting
Notes
approvedflag is locked at the DB layer on two fronts: the INSERT policy requiresapproved = false, and the UPDATE policy'sWITH CHECKsubquery prevents any change toapprovedby the tutor — only the admin update policy (requiresis_admin(auth.uid())) can flip it.fetchApprovedTutors()fromlib/services/matching.tsto avoid query duplication.🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.