Skip to content

Epic E6: Tutor onboarding, approval workflow, and admin tutor directory - #98

Merged
Taleef7 merged 3 commits into
mainfrom
copilot/implement-tutor-onboarding-directory
Feb 25, 2026
Merged

Epic E6: Tutor onboarding, approval workflow, and admin tutor directory#98
Taleef7 merged 3 commits into
mainfrom
copilot/implement-tutor-onboarding-directory

Conversation

Copilot AI commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

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

  • Migration 20260224000002_create_tutor_tables.sql: tutor_profiles (approved bool default false, bio, timezone), tutor_subjects (composite PK tutor_user_id × subject_id × level), tutor_availability (JSONB windows: [{day,start,end}]); RLS on all three tables with the following security properties:
    • tutor_profiles_insert: enforces approved = false on non-admin inserts — tutors cannot self-approve on row creation
    • tutor_profiles_update_own: WITH CHECK uses a subquery to lock the approved column, preventing tutors from flipping it via any direct API call
    • tutor_subjects_admin_write and tutor_availability_admin_write: explicit admin write policies added so admins can manage these tables under the authenticated role
  • app/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-scoped createClient() to enforce RLS
  • app/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 call setValue to keep RHF state in sync with Zod validation; aria-describedby added to subjects and availability groups for accessibility
  • app/tutor/profile/actions.ts — server action: upserts tutor_profiles; updates tutor_subjects via upsert-then-targeted-delete (avoids data loss on partial failure; stale rows removed in a single OR-filter query); upserts tutor_availability
  • lib/validators/tutor.ts — Zod schema: bio min 50 chars, subjects array min 1, availability windows min 1
  • app/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/view
  • app/admin/tutors/TutorFilters.tsx — client component for subject/level dropdowns (extracted to avoid window in server component)
  • app/admin/tutors/[id]/page.tsx — full tutor detail: bio, subjects × levels, availability in human-readable format, WhatsApp, approve/revoke
  • app/admin/tutors/actions.tsapproveTutor / revokeTutorApproval server actions; both write audit_logs and call revalidatePath
  • app/admin/tutors/TutorActions.tsxApproveButton / RevokeButton client components via useActionState with inline error feedback
  • lib/services/matching.tsfetchApprovedTutors(subjectId?, level?) typed query helper filtering approved = true; ready for E7 to consume without duplicating the join
  • app/admin/layout.tsx — added Tutors nav link
  • README.md — E6 feature table + new migration row

Testing

  • Verified locally
  • Checked key flows manually

Notes

  • The approved flag is locked at the DB layer on two fronts: the INSERT policy requires approved = false, and the UPDATE policy's WITH CHECK subquery prevents any change to approved by the tutor — only the admin update policy (requires is_admin(auth.uid())) can flip it.
  • E7 matching screen should call fetchApprovedTutors() from lib/services/matching.ts to 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.

- 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>
Copilot AI changed the title [WIP] Implement tutor onboarding and directory functionality Epic E6: Tutor onboarding, approval workflow, and admin tutor directory Feb 24, 2026
Copilot AI requested a review from Taleef7 February 24, 2026 23:10
@Taleef7
Taleef7 marked this pull request as ready for review February 25, 2026 00:31
Copilot AI review requested due to automatic review settings February 25, 2026 00:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and tutor_availability tables with triggers + RLS policies.
  • Implemented /tutor/profile application page + form and a server action to persist tutor profile/subjects/availability.
  • Implemented /admin/tutors directory + tutor detail page with approve/revoke server actions and audit logging; added shared fetchApprovedTutors() 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.

Comment on lines +95 to +102
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());

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +81
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 ?? [],
},
})

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +21 to +50
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(),

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Comment thread app/tutor/profile/actions.ts Outdated
Comment on lines +33 to +44
// 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}` }

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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}` }
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
with check (tutor_user_id = auth.uid());
with check (
tutor_user_id = auth.uid()
and (approved = false or public.is_admin(auth.uid()))
);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +85 to +92
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());

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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()
)
);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Taleef7

Taleef7 commented Feb 25, 2026

Copy link
Copy Markdown
Owner

@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>
Copilot AI requested a review from Taleef7 February 25, 2026 00:48
@Taleef7
Taleef7 merged commit 2e7574d into main Feb 25, 2026
1 check passed
@Taleef7
Taleef7 deleted the copilot/implement-tutor-onboarding-directory branch February 25, 2026 01:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants