feat(E7): Admin matching and assignment — requests inbox, matching screen, assign/reassign tutor - #100
Conversation
…reen, assign/reassign tutor Closes #44 #45 #46 #47 #48 #49 #50 T7.1: Admin requests inbox at /admin/requests - Full filterable table (status tabs + subject/level selects) - Priority sort: ready_to_match first, then by created_at - Status badges using STATUS_COLOURS, Match → CTA for actionable requests - RequestFilters.tsx client component for subject/level dropdowns T7.2: Matching screen at /admin/requests/[id] - Two-panel layout: request details + eligible tutor cards - Eligible tutors filtered by approved=true + subject × level (same row) - AssignTutorForm.tsx with tutor selection, meet link, timezone, days, start time T7.3: matches table migration + assignTutor server action - supabase/migrations/20260225000001_create_matches_table.sql (request_id unique FK, tutor_user_id, status enum, meet_link, schedule_pattern JSONB, assigned_by/at; updated_at trigger; admin all + participants select RLS) - assignTutor: creates match, advances request to matched, writes audit log - updateMatchDetails: edits meet_link/schedule_pattern, writes audit log T7.4: Match detail page + reassignTutor server action - /admin/matches/page.tsx: real match list with status, student, tutor, meet link - /admin/matches/[id]/page.tsx: full match detail + admin actions - ReassignTutorForm.tsx: select new tutor + optional reason - EditMatchForm.tsx: edit meet link + schedule pattern - reassignTutor: updates tutor_user_id, writes audit log (old/new IDs + reason) Also fixed: - lib/services/matching.ts: joint subject+level filter now uses same row (correct composite filter) - README.md: updated to after E7, added all new capabilities and migration Co-authored-by: Taleef7 <89072337+Taleef7@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR implements Epic E7, delivering the complete admin manual matching workflow that connects ready-to-match student requests with approved tutors. The implementation includes a filterable requests inbox, a two-panel matching screen showing request details alongside eligible tutors, tutor assignment with meet link and schedule configuration, match listing, match detail management, and support for reassigning tutors with full audit history.
Changes:
- Database: Created
matchestable with RLS policies for admin full access and participant read access - Server actions: Implemented
assignTutor,reassignTutor, andupdateMatchDetailswith admin authorization and audit logging - Admin UI: Built requests inbox with status/subject/level filters, matching screen with eligible tutor cards, and match management pages
- Bug fix: Fixed composite subject+level filter in matching service that could incorrectly match tutors
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
supabase/migrations/20260225000001_create_matches_table.sql |
Creates matches table with unique request_id FK, tutor assignment tracking, schedule pattern JSONB, and RLS policies |
lib/services/matching.ts |
Fixes bug where subject AND level filters could match on separate tutor_subjects rows instead of requiring same row |
app/admin/requests/actions.ts |
Implements assignTutor, reassignTutor, and updateMatchDetails server actions with admin auth and audit logging |
app/admin/requests/page.tsx |
Requests inbox with status tabs, subject/level filters, priority sorting, and actionable CTAs |
app/admin/requests/[id]/page.tsx |
Two-panel matching screen showing request details and eligible tutors filtered by subject × level |
app/admin/requests/[id]/AssignTutorForm.tsx |
Client form for tutor selection with optional meet link and schedule pattern fields |
app/admin/requests/RequestFilters.tsx |
Client component for subject and level dropdown filters |
app/admin/matches/page.tsx |
Matches list showing student, tutor, subject/level, status, meet link, and assigned date |
app/admin/matches/[id]/page.tsx |
Match detail page with full match information and admin action forms |
app/admin/matches/[id]/MatchActions.tsx |
Client components for reassigning tutors and editing match details (meet link + schedule) |
README.md |
Updated feature status table and migrations list to reflect E7 completion |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const { error } = await admin | ||
| .from('matches') | ||
| .update({ | ||
| meet_link: meetLink ?? null, | ||
| schedule_pattern: schedulePattern ?? null, | ||
| updated_at: new Date().toISOString(), | ||
| }) | ||
| .eq('id', matchId) | ||
|
|
||
| if (error) throw new Error(`Failed to update match: ${error.message}`) | ||
|
|
||
| const { error: auditError } = await admin.from('audit_logs').insert([ | ||
| { | ||
| actor_user_id: adminUserId, | ||
| action: 'match_details_updated', | ||
| entity_type: 'match', | ||
| entity_id: matchId, | ||
| details: { meet_link: meetLink ?? null }, |
There was a problem hiding this comment.
The updateMatchDetails function always updates both meet_link and schedule_pattern fields, setting them to null when undefined is passed. This means if an admin only wants to update the meet_link and leaves schedule fields empty, it will clear any existing schedule_pattern. Consider building the update object dynamically to only include fields that should be changed, or provide separate update functions for meet_link and schedule_pattern.
| const { error } = await admin | |
| .from('matches') | |
| .update({ | |
| meet_link: meetLink ?? null, | |
| schedule_pattern: schedulePattern ?? null, | |
| updated_at: new Date().toISOString(), | |
| }) | |
| .eq('id', matchId) | |
| if (error) throw new Error(`Failed to update match: ${error.message}`) | |
| const { error: auditError } = await admin.from('audit_logs').insert([ | |
| { | |
| actor_user_id: adminUserId, | |
| action: 'match_details_updated', | |
| entity_type: 'match', | |
| entity_id: matchId, | |
| details: { meet_link: meetLink ?? null }, | |
| // Build update payload dynamically so that only explicitly provided fields are changed. | |
| const updateData: { [key: string]: any } = { | |
| updated_at: new Date().toISOString(), | |
| } | |
| if (typeof meetLink !== 'undefined') { | |
| // Allow explicit null to clear the field, while undefined means "no change". | |
| updateData.meet_link = meetLink | |
| } | |
| if (typeof schedulePattern !== 'undefined') { | |
| // Allow explicit null to clear the field, while undefined means "no change". | |
| updateData.schedule_pattern = schedulePattern | |
| } | |
| const { error } = await admin | |
| .from('matches') | |
| .update(updateData) | |
| .eq('id', matchId) | |
| if (error) throw new Error(`Failed to update match: ${error.message}`) | |
| const auditDetails: { [key: string]: any } = {} | |
| if (typeof meetLink !== 'undefined') { | |
| auditDetails.meet_link = meetLink | |
| } | |
| if (typeof schedulePattern !== 'undefined') { | |
| auditDetails.schedule_pattern = schedulePattern | |
| } | |
| const { error: auditError } = await admin.from('audit_logs').insert([ | |
| { | |
| actor_user_id: adminUserId, | |
| action: 'match_details_updated', | |
| entity_type: 'match', | |
| entity_id: matchId, | |
| details: auditDetails, |
There was a problem hiding this comment.
Fixed in ac56731. updateMatchDetails now builds the update payload dynamically — meet_link and schedule_pattern are only included in the UPDATE statement when they are explicitly passed (not undefined), so updating only the meet link will never touch an existing schedule, and vice versa. The audit log now records all changed fields.
| .from('matches') | ||
| .update({ | ||
| tutor_user_id: newTutorUserId, | ||
| updated_at: new Date().toISOString(), |
There was a problem hiding this comment.
The updated_at field is automatically managed by the matches_updated_at trigger in the database. Explicitly setting it here is unnecessary and could potentially conflict with the trigger behavior. Consider removing this field from the update statement.
There was a problem hiding this comment.
Fixed in ac56731. Removed the explicit updated_at from reassignTutor — the matches_updated_at trigger handles it automatically.
| .update({ | ||
| meet_link: meetLink ?? null, | ||
| schedule_pattern: schedulePattern ?? null, | ||
| updated_at: new Date().toISOString(), |
There was a problem hiding this comment.
The updated_at field is automatically managed by the matches_updated_at trigger in the database. Explicitly setting it here is unnecessary and could potentially conflict with the trigger behavior. Consider removing this field from the update statement.
| updated_at: new Date().toISOString(), |
There was a problem hiding this comment.
Fixed in ac56731. Removed the explicit updated_at from updateMatchDetails — the DB trigger manages it.
| action: 'match_details_updated', | ||
| entity_type: 'match', | ||
| entity_id: matchId, | ||
| details: { meet_link: meetLink ?? null }, |
There was a problem hiding this comment.
The audit log details only include meet_link, but schedule_pattern can also be updated by this function. Consider including schedule_pattern in the audit log details for complete change tracking.
| details: { meet_link: meetLink ?? null }, | |
| details: { meet_link: meetLink ?? null, schedule_pattern: schedulePattern ?? null }, |
There was a problem hiding this comment.
Fixed in ac56731. The audit log details now include both meet_link and schedule_pattern (only whichever fields were actually changed, using the same dynamic payload approach).
| const { error } = await admin | ||
| .from('matches') | ||
| .update({ | ||
| tutor_user_id: newTutorUserId, | ||
| updated_at: new Date().toISOString(), | ||
| }) | ||
| .eq('id', matchId) |
There was a problem hiding this comment.
Consider validating that the match exists and the new tutor is different from the previous tutor before performing the reassignment. This would prevent redundant updates and provide clearer error messages.
There was a problem hiding this comment.
Fixed in ac56731. reassignTutor now fetches the existing match first and throws a clear error if it doesn't exist, and returns an early error ("The selected tutor is already assigned to this match.") if newTutorUserId === existingMatch.tutor_user_id.
| const { data: match, error } = await admin | ||
| .from('matches') | ||
| .insert([ | ||
| { | ||
| request_id: requestId, | ||
| tutor_user_id: tutorUserId, | ||
| status: 'matched', | ||
| meet_link: meetLink || null, | ||
| schedule_pattern: schedulePattern ?? null, | ||
| assigned_by_user_id: adminUserId, | ||
| assigned_at: new Date().toISOString(), | ||
| }, | ||
| ]) | ||
| .select() | ||
| .single() | ||
|
|
||
| if (error) throw new Error(`Failed to create match: ${error.message}`) |
There was a problem hiding this comment.
The unique constraint on request_id is good for preventing duplicate matches. However, consider what happens if an admin accidentally tries to assign a tutor to a request that's already matched. The database will return a unique constraint violation, but the error message in the UI might not be clear to the admin. Consider adding a validation check before the insert to provide a more user-friendly error message.
There was a problem hiding this comment.
Fixed in ac56731. assignTutor now checks the request status before inserting. Additionally, if the unique constraint is still somehow hit, the error.code === '23505' branch catches it and returns a clear message: "This request already has a match assigned. Use the match detail page to reassign the tutor."
| meet_link: meetLink || null, | ||
| schedule_pattern: schedulePattern ?? null, | ||
| assigned_by_user_id: adminUserId, | ||
| assigned_at: new Date().toISOString(), |
There was a problem hiding this comment.
The assigned_at field already has a default value of now() in the database schema, so setting it explicitly here is redundant. Consider removing this line and letting the database default handle it.
| assigned_at: new Date().toISOString(), |
There was a problem hiding this comment.
Fixed in ac56731. Removed the explicit assigned_at from the insert — the column has DEFAULT now() in the migration so the DB sets it automatically.
| // Advance request status to 'matched' | ||
| const { error: reqError } = await admin | ||
| .from('requests') | ||
| .update({ status: 'matched', updated_at: new Date().toISOString() }) |
There was a problem hiding this comment.
The updated_at field is automatically managed by the matches_updated_at trigger in the database. Explicitly setting it here is unnecessary and could potentially conflict with the trigger behavior. Consider removing this field from the update statement.
| .update({ status: 'matched', updated_at: new Date().toISOString() }) | |
| .update({ status: 'matched' }) |
There was a problem hiding this comment.
Fixed in ac56731. The requests update in assignTutor now only sets { status: 'matched' } — updated_at is handled by the requests_updated_at trigger.
| const { data: match, error } = await admin | ||
| .from('matches') | ||
| .insert([ | ||
| { | ||
| request_id: requestId, | ||
| tutor_user_id: tutorUserId, | ||
| status: 'matched', | ||
| meet_link: meetLink || null, | ||
| schedule_pattern: schedulePattern ?? null, | ||
| assigned_by_user_id: adminUserId, | ||
| assigned_at: new Date().toISOString(), | ||
| }, | ||
| ]) | ||
| .select() | ||
| .single() |
There was a problem hiding this comment.
Consider validating that the request exists and is in an appropriate status (e.g., ready_to_match) before creating a match. Currently, if the request doesn't exist or is already matched, the database constraint will fail, but a more user-friendly error message would improve the admin experience.
There was a problem hiding this comment.
Fixed in ac56731. assignTutor now validates the request before inserting: fetches id, status via maybeSingle(), throws 'Request not found.' if absent, and throws a descriptive error if the status is anything other than ready_to_match.
| ? { timezone, days: rawDays, time, duration_mins: 60 } | ||
| : undefined | ||
|
|
||
| return updateMatchDetails({ matchId, meetLink, schedulePattern }) |
There was a problem hiding this comment.
The updateMatchDetails function will set both meet_link and schedule_pattern to null if any of the schedule fields (timezone, time, days) is missing, even if the user only intended to update the meet_link. This could unintentionally clear existing schedule data. Consider preserving existing values when only updating one field, or provide separate actions for updating meet_link vs schedule_pattern.
| return updateMatchDetails({ matchId, meetLink, schedulePattern }) | |
| const payload: { | |
| matchId: string | |
| meetLink?: string | |
| schedulePattern?: { | |
| timezone: string | |
| days: number[] | |
| time: string | |
| duration_mins: number | |
| } | |
| } = { matchId } | |
| if (meetLink !== undefined) { | |
| payload.meetLink = meetLink | |
| } | |
| if (schedulePattern) { | |
| payload.schedulePattern = schedulePattern | |
| } | |
| return updateMatchDetails(payload) |
There was a problem hiding this comment.
Fixed in ac56731. editMatchAction now applies the same partial-schedule validation and builds the payload selectively — meetLink and schedulePattern are only included in the call to updateMatchDetails when they are explicitly set, so a meet-link-only edit will never touch an existing schedule pattern.
|
@copilot apply changes based on the comments in this thread |
- assignTutor: validate request exists and is ready_to_match before insert; friendly error for unique constraint (already matched); remove redundant assigned_at and updated_at (both have DB defaults/triggers) - reassignTutor: validate match exists and new tutor differs from current; remove redundant updated_at (trigger handles it) - updateMatchDetails: build update payload dynamically so only explicitly provided fields are written (prevents accidental null-out of schedule when only meet_link is being updated); include schedule_pattern in audit log - AssignTutorForm: validate partial schedule — if any schedule field is set but not all three (timezone + time + days), return error before submitting - MatchActions editMatchAction: same partial-schedule validation; pass meetLink/schedulePattern selectively so server action preserves unchanged fields - requests/page.tsx: remove redundant DB .order() (JS sort overrides it) Co-authored-by: Taleef7 <89072337+Taleef7@users.noreply.github.com>
Implements Epic E7 end-to-end: the admin manual matching workflow that connects a
ready_to_matchrequest to an approved tutor, creates amatchesrecord, and supports reassignment with full audit history.Summary
matchestable with RLS,assignTutor/reassignTutor/updateMatchDetailsserver actions, filterable requests inbox, two-panel matching screen, match list and match detail pageslib/services/matching.tsnow correctly requires both conditions on the sametutor_subjectsrowChanges
supabase/migrations/20260225000001_create_matches_table.sql:matchestable with uniquerequest_idFK,tutor_user_id,match_status_enum,meet_link,schedule_pattern(JSONB),assigned_by_user_id/assigned_at,updated_attrigger; RLS: admin full access, tutor + request creator can SELECTapp/admin/requests/actions.ts:assignTutor: validates request exists and isready_to_matchbefore insert; friendly error for duplicate-match unique constraint; removes redundantassigned_atandupdated_at(both managed by DB defaults/triggers)reassignTutor: validates match exists and new tutor differs from current before updating; removes redundantupdated_atupdateMatchDetails: builds update payload dynamically — only explicitly-provided fields are written, so updating onlymeet_linknever touches an existingschedule_pattern; audit log now records all changed fields includingschedule_patternapp/admin/requests/[id]/AssignTutorForm.tsx: partial-schedule validation — if any of timezone/time/days is set but not all three, returns an error before submittingapp/admin/matches/[id]/MatchActions.tsx:editMatchActionapplies same partial-schedule validation and passesmeetLink/schedulePatternselectively so the server action preserves unchanged fieldsapp/admin/requests/page.tsx: removed redundant.order('created_at')DB call (JavaScript sort by status priority handles ordering)/admin/requests(T7.1): Filterable table — status tabs + subject/level selects; priority sort putsready_to_matchfirst; "Match →" CTA for actionable rows/admin/requests/[id](T7.2): Two-panel layout — full request detail + eligible tutor cards filtered byapproved = trueAND subject × level on the sametutor_subjectsrow/admin/matches(T7.4): Real match list replacing the old placeholder/admin/matches/[id](T7.4): Match detail withEditMatchFormandReassignTutorFormTesting
Notes
schedule_pattern.daysuses 0=Sunday … 6=Saturday,duration_minsis always 60 in MVPupdated_atonmatchesandrequestsis always managed by DB triggers — server actions no longer set it explicitly💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.