From 5c7f92da60ab713fe359fa3d5dacad036d8f7bba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 02:31:31 +0000 Subject: [PATCH 1/3] Initial plan From b618569d6cce25845d87352f09267cbd09313aa3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 02:44:59 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(E7):=20admin=20matching=20and=20assign?= =?UTF-8?q?ment=20=E2=80=94=20requests=20inbox,=20matching=20screen,=20ass?= =?UTF-8?q?ign/reassign=20tutor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- README.md | 15 +- app/admin/matches/[id]/MatchActions.tsx | 315 ++++++++++++++++++ app/admin/matches/[id]/page.tsx | 255 +++++++++++++- app/admin/matches/page.tsx | 178 +++++++++- app/admin/requests/RequestFilters.tsx | 69 ++++ app/admin/requests/[id]/AssignTutorForm.tsx | 234 +++++++++++++ app/admin/requests/[id]/page.tsx | 310 ++++++++++++++++- app/admin/requests/actions.ts | 200 +++++++++++ app/admin/requests/page.tsx | 239 ++++++++++++- lib/services/matching.ts | 10 +- .../20260225000001_create_matches_table.sql | 52 +++ 11 files changed, 1855 insertions(+), 22 deletions(-) create mode 100644 app/admin/matches/[id]/MatchActions.tsx create mode 100644 app/admin/requests/RequestFilters.tsx create mode 100644 app/admin/requests/[id]/AssignTutorForm.tsx create mode 100644 app/admin/requests/actions.ts create mode 100644 supabase/migrations/20260225000001_create_matches_table.sql diff --git a/README.md b/README.md index 7a4d46a..cb225a5 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Open [http://localhost:3000](http://localhost:3000). You'll see the CorvEd landi --- -### What the app can do right now (after E5) +### What the app can do right now (after E7) | Area | Status | |---|---| @@ -169,8 +169,16 @@ Open [http://localhost:3000](http://localhost:3000). You'll see the CorvEd landi | **Tutor approval workflow** | ✅ `app/admin/tutors/actions.ts` — `approveTutor` sets `approved = true`; `revokeTutorApproval` sets `approved = false`; both write audit log entries | | **DB: tutor tables** | ✅ `supabase/migrations/20260224000002_create_tutor_tables.sql` — `tutor_profiles` (approved, bio, timezone), `tutor_subjects` (subject_id × level per tutor), `tutor_availability` (JSONB windows); RLS policies for all three tables | | **Tutor Zod schema** | ✅ `lib/validators/tutor.ts` — validates bio (min 50 chars), timezone, subjects array, availability windows | -| **Matching query helper** | ✅ `lib/services/matching.ts` — `fetchApprovedTutors()` shared query filtered to `approved = true`; ready for E7 matching screen | -| Sessions | 🚧 Coming in E7–E10 | +| **Matching query helper** | ✅ `lib/services/matching.ts` — `fetchApprovedTutors()` correctly filters approved tutors by subject × level (same row); used in E7 matching screen | +| **Admin: requests inbox** | ✅ `app/admin/requests/page.tsx` — filterable list (status tabs, subject/level selects); priority sort (`ready_to_match` first); status badges; "Match →" CTA for actionable requests | +| **Admin: matching screen** | ✅ `app/admin/requests/[id]/page.tsx` — two-panel layout: request details (all fields) + eligible approved tutor cards filtered by subject × level; `AssignTutorForm` client component with Meet link + schedule fields | +| **Admin: assign tutor** | ✅ `assignTutor` server action — creates `matches` row (status=matched, optional meet_link + schedule_pattern), advances `requests.status → matched`, writes audit log | +| **Admin: matches list** | ✅ `app/admin/matches/page.tsx` — lists all matches with student, tutor, subject/level, status, meet link, assigned date; links to match detail | +| **Admin: match detail** | ✅ `app/admin/matches/[id]/page.tsx` — full match record; edit meet link + schedule pattern; reassign tutor with optional reason | +| **Admin: reassign tutor** | ✅ `reassignTutor` server action — updates `matches.tutor_user_id`, writes audit log with old/new tutor IDs + reason; RLS automatically updates session access | +| **Admin: update match details** | ✅ `updateMatchDetails` server action — updates meet_link and schedule_pattern on existing match; writes audit log | +| **DB: matches table + RLS** | ✅ `supabase/migrations/20260225000001_create_matches_table.sql` — matches table (request_id unique FK, tutor_user_id, status enum, meet_link, schedule_pattern JSONB, assigned_by/at); RLS: admin full access; tutor + request creator can select | +| Sessions | 🚧 Coming in E8–E10 | --- @@ -219,6 +227,7 @@ Recommended workflow | `20260223000007_create_requests_table.sql` | `requests` table with all fields from the data model; indexes on `(status, created_at desc)` and `created_by_user_id`; `updated_at` trigger; 4 RLS policies (creator insert, creator/admin select, creator update limited to `new`/`payment_pending`, admin update). | | `20260224000001_create_packages_payments.sql` | `packages` table (tier_sessions 8/12/20, start/end date, sessions_total/used, status enum, updated_at trigger, 3 RLS policies); `payments` table (amount_pkr, method, reference, proof_path, rejection_note, status enum, verified_by/at, updated_at trigger, 4 RLS policies); `audit_logs` table for admin payment actions. | | `20260224000002_create_tutor_tables.sql` | `tutor_profiles` (approved bool default false, bio, timezone, updated_at trigger); `tutor_subjects` (tutor × subject × level, composite PK); `tutor_availability` (JSONB windows array, updated_at trigger); RLS policies for all three tables — tutors manage own rows, admins read/update all. | +| `20260225000001_create_matches_table.sql` | `matches` table with unique `request_id` FK, `tutor_user_id`, `status` enum (matched/active/paused/ended), `meet_link`, `schedule_pattern` JSONB, `assigned_by_user_id`/`assigned_at`; updated_at trigger; RLS: admin full access, tutor and request creator can select. | > **Supabase Dashboard settings required for auth** (after running migrations): > diff --git a/app/admin/matches/[id]/MatchActions.tsx b/app/admin/matches/[id]/MatchActions.tsx new file mode 100644 index 0000000..004f8a9 --- /dev/null +++ b/app/admin/matches/[id]/MatchActions.tsx @@ -0,0 +1,315 @@ +// E7 T7.4 S7.2: Client components for match detail actions (reassign tutor + edit details) +// Closes #50 #46 + +'use client' + +import { useActionState, useState } from 'react' +import { reassignTutor, updateMatchDetails } from '../../requests/actions' + +const DAY_OPTIONS = [ + { label: 'Sun', value: 0 }, + { label: 'Mon', value: 1 }, + { label: 'Tue', value: 2 }, + { label: 'Wed', value: 3 }, + { label: 'Thu', value: 4 }, + { label: 'Fri', value: 5 }, + { label: 'Sat', value: 6 }, +] + +type AvailWindow = { day: number; start: string; end: string } + +export type EligibleTutor = { + tutor_user_id: string + bio: string | null + timezone: string + user_profiles: { display_name: string; whatsapp_number: string | null } | null + tutor_availability: { windows: AvailWindow[] } | null +} + +// ── Reassign Tutor Form ──────────────────────────────────────────────────────── + +type ReassignResult = { error?: string } | undefined + +async function reassignAction( + _prev: ReassignResult, + formData: FormData, +): Promise { + const matchId = formData.get('matchId') as string + const previousTutorUserId = formData.get('previousTutorUserId') as string + const newTutorUserId = formData.get('newTutorUserId') as string + const reason = (formData.get('reason') as string) || undefined + return reassignTutor({ matchId, previousTutorUserId, newTutorUserId, reason }) +} + +export function ReassignTutorForm({ + matchId, + currentTutorUserId, + eligibleTutors, +}: { + matchId: string + currentTutorUserId: string + eligibleTutors: EligibleTutor[] +}) { + const [open, setOpen] = useState(false) + const [selectedTutorId, setSelectedTutorId] = useState(null) + const [state, formAction, isPending] = useActionState(reassignAction, undefined) + + if (state && !state.error) { + return ( +
+ ✅ Tutor reassigned successfully. Refresh to see updated details. +
+ ) + } + + if (!open) { + return ( + + ) + } + + // Filter out current tutor from eligible list + const otherTutors = eligibleTutors.filter((t) => t.tutor_user_id !== currentTutorUserId) + + return ( +
+
+

Reassign Tutor

+ +
+ + {otherTutors.length === 0 ? ( +

+ No other approved tutors match this subject and level. +

+ ) : ( +
+ {otherTutors.map((tutor) => { + const isSelected = selectedTutorId === tutor.tutor_user_id + return ( +
setSelectedTutorId(tutor.tutor_user_id)} + className={`cursor-pointer rounded-lg border p-3 transition ${ + isSelected + ? 'border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-900/20' + : 'border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900' + }`} + > +
+
+

+ {tutor.user_profiles?.display_name ?? '—'} +

+

{tutor.timezone}

+
+ setSelectedTutorId(tutor.tutor_user_id)} + onClick={(e) => e.stopPropagation()} + className="h-4 w-4 accent-indigo-600" + /> +
+
+ ) + })} +
+ )} + + {selectedTutorId && ( +
+ + + + +
+ + +
+ + {state?.error && ( +

{state.error}

+ )} + + +
+ )} +
+ ) +} + +// ── Edit Match Details Form ─────────────────────────────────────────────────── + +type EditResult = { error?: string } | undefined + +async function editMatchAction( + _prev: EditResult, + formData: FormData, +): Promise { + const matchId = formData.get('matchId') as string + const meetLink = (formData.get('meetLink') as string) || undefined + const timezone = formData.get('timezone') as string + const time = formData.get('time') as string + const rawDays = formData.getAll('days').map(Number) + + const schedulePattern = + timezone && time && rawDays.length > 0 + ? { timezone, days: rawDays, time, duration_mins: 60 } + : undefined + + return updateMatchDetails({ matchId, meetLink, schedulePattern }) +} + +export function EditMatchForm({ + matchId, + currentMeetLink, + currentSchedule, +}: { + matchId: string + currentMeetLink: string | null + currentSchedule: { + timezone?: string + days?: number[] + time?: string + duration_mins?: number + } | null +}) { + const [open, setOpen] = useState(false) + const [state, formAction, isPending] = useActionState(editMatchAction, undefined) + + if (state && !state.error) { + return ( +
+ ✅ Match details updated. Refresh to see changes. +
+ ) + } + + if (!open) { + return ( + + ) + } + + return ( +
+
+

+ Edit Meet Link & Schedule +

+ +
+ + + +
+ + +
+ +
+ + +
+ +
+ +
+ {DAY_OPTIONS.map(({ label, value }) => ( + + ))} +
+
+ +
+ + +
+ + {state?.error && ( +

{state.error}

+ )} + + +
+ ) +} diff --git a/app/admin/matches/[id]/page.tsx b/app/admin/matches/[id]/page.tsx index e1125f5..0cc17b7 100644 --- a/app/admin/matches/[id]/page.tsx +++ b/app/admin/matches/[id]/page.tsx @@ -1 +1,254 @@ -export default function Page() { return

TODO

} +// E7 T7.4 S7.2: Admin match detail page — view match, reassign tutor, edit schedule +// Closes #50 #46 + +export const dynamic = 'force-dynamic' + +import { notFound } from 'next/navigation' +import Link from 'next/link' +import { createAdminClient } from '@/lib/supabase/admin' +import { fetchApprovedTutors } from '@/lib/services/matching' +import { LEVEL_LABELS } from '@/lib/utils/request' +import { ReassignTutorForm, EditMatchForm } from './MatchActions' + +const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +const MATCH_STATUS_COLOURS: Record = { + matched: 'bg-purple-100 text-purple-800', + active: 'bg-green-100 text-green-800', + paused: 'bg-orange-100 text-orange-800', + ended: 'bg-red-100 text-red-800', +} + +type SchedulePattern = { + timezone?: string + days?: number[] + time?: string + duration_mins?: number +} + +type MatchDetail = { + id: string + status: string + meet_link: string | null + schedule_pattern: SchedulePattern | null + assigned_at: string + created_at: string + updated_at: string + tutor_user_id: string + assigned_by_user_id: string | null + request_id: string + tutor_profiles: { + bio: string | null + timezone: string + user_profiles: { display_name: string; whatsapp_number: string | null } | null + } | null + requests: { + id: string + level: string + subject_id: number + goals: string | null + timezone: string + for_student_name: string | null + requester_role: string + subjects: { name: string } | null + user_profiles: { display_name: string; whatsapp_number: string | null } | null + } | null +} + +export default async function AdminMatchDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const admin = createAdminClient() + + const { data: matchData } = await admin + .from('matches') + .select( + `id, status, meet_link, schedule_pattern, assigned_at, created_at, updated_at, + tutor_user_id, assigned_by_user_id, request_id, + tutor_profiles!matches_tutor_user_id_fkey ( + bio, timezone, + user_profiles!tutor_user_id ( display_name, whatsapp_number ) + ), + requests!matches_request_id_fkey ( + id, level, subject_id, goals, timezone, for_student_name, requester_role, + subjects ( name ), + user_profiles!requests_created_by_user_id_fkey ( display_name, whatsapp_number ) + )` + ) + .eq('id', id) + .maybeSingle() + + if (!matchData) notFound() + + const match = matchData as unknown as MatchDetail + const request = match.requests + const tutorProfile = match.tutor_profiles + const tutorUserProfile = tutorProfile?.user_profiles + const studentProfile = request?.user_profiles + const subjectName = (request?.subjects as { name: string } | null)?.name ?? '—' + const levelLabel = request ? (LEVEL_LABELS[request.level] ?? request.level) : '—' + const schedule = match.schedule_pattern + + const studentName = + request?.requester_role === 'parent' && request?.for_student_name + ? request.for_student_name + : (studentProfile?.display_name ?? '—') + + const assignedDate = new Date(match.assigned_at).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + }) + + // Fetch eligible tutors for reassignment (same subject + level as the request) + const eligibleTutors = request + ? await fetchApprovedTutors(request.subject_id, request.level) + : [] + + return ( +
+ {/* Back link */} +
+ + ← Back to Matches + + {request && ( + + View Request → + + )} +
+ + {/* Match header */} +
+
+
+

Match Detail

+

Assigned {assignedDate}

+
+ + {match.status} + +
+ +
+ + {/* Match info grid */} +
+
+
Student
+
{studentName}
+ {studentProfile?.whatsapp_number && ( +
📱 {studentProfile.whatsapp_number}
+ )} +
+ +
+
Tutor
+
+ {tutorUserProfile?.display_name ?? '—'} +
+
{tutorProfile?.timezone}
+ {tutorUserProfile?.whatsapp_number && ( +
📱 {tutorUserProfile.whatsapp_number}
+ )} +
+ +
+
Subject
+
{subjectName}
+
+ +
+
Level
+
{levelLabel}
+
+ +
+
Google Meet Link
+
+ {match.meet_link ? ( + + {match.meet_link} + + ) : ( + Not set + )} +
+
+ +
+
Schedule
+
+ {schedule?.days && schedule.days.length > 0 ? ( + <> + {schedule.days.map((d) => DAY_NAMES[d]).join(', ')} + {schedule.time && ` at ${schedule.time}`} + {schedule.timezone && ` (${schedule.timezone})`} + + ) : ( + Not set + )} +
+
+
+
+ + {/* Admin actions */} +
+

+ Admin Actions +

+ + + + + + {schedule?.days && schedule.days.length > 0 && match.meet_link && ( +
+

+ 📅 Generate Sessions +

+

+ Session generation (E8) will be available once Epic E8 is implemented. +

+
+ )} +
+ + {/* Audit info */} +

+ Match ID: {match.id} · Last updated:{' '} + {new Date(match.updated_at).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + })} +

+
+ ) +} diff --git a/app/admin/matches/page.tsx b/app/admin/matches/page.tsx index 7bc988c..f8425cc 100644 --- a/app/admin/matches/page.tsx +++ b/app/admin/matches/page.tsx @@ -1,18 +1,170 @@ -// Matches index — placeholder until E8 (match creation + session generation) is implemented. -// Closes the 404 produced by the Admin Dashboard nav card linking to /admin/matches. +// E7 T7.1 T7.4: Admin matches index — list all matches with status and key details +// Closes #47 #50 + +export const dynamic = 'force-dynamic' + +import Link from 'next/link' +import { createAdminClient } from '@/lib/supabase/admin' +import { LEVEL_LABELS } from '@/lib/utils/request' + +const MATCH_STATUS_COLOURS: Record = { + matched: 'bg-purple-100 text-purple-800', + active: 'bg-green-100 text-green-800', + paused: 'bg-orange-100 text-orange-800', + ended: 'bg-red-100 text-red-800', +} + +type MatchRow = { + id: string + status: string + meet_link: string | null + assigned_at: string + tutor_user_id: string + tutor_profiles: { + user_profiles: { display_name: string } | null + } | null + requests: { + id: string + level: string + subjects: { name: string } | null + user_profiles: { display_name: string } | null + } | null +} + +export default async function AdminMatchesPage() { + const admin = createAdminClient() + + const { data: matchesData } = await admin + .from('matches') + .select( + `id, status, meet_link, assigned_at, tutor_user_id, + tutor_profiles!matches_tutor_user_id_fkey ( + user_profiles!tutor_user_id ( display_name ) + ), + requests!matches_request_id_fkey ( + id, level, + subjects ( name ), + user_profiles!requests_created_by_user_id_fkey ( display_name ) + )` + ) + .order('assigned_at', { ascending: false }) + + const matches = (matchesData ?? []) as unknown as MatchRow[] -export default function AdminMatchesPage() { return ( -
-

Matches

-

- Match management (assign tutor, set schedule, generate sessions) is implemented in Epic E8. - Individual match detail pages are available at{' '} - - /admin/matches/[id] - - . -

+
+
+

Matches

+

+ {matches.length} match{matches.length !== 1 ? 'es' : ''} +

+
+ + {matches.length === 0 ? ( +
+

No matches yet.

+

+ Assign tutors to{' '} + + ready-to-match requests + {' '} + to create matches. +

+
+ ) : ( +
+ + + + + + + + + + + + + + {matches.map((match) => { + const req = match.requests + const subjectName = (req?.subjects as { name: string } | null)?.name ?? '—' + const levelLabel = req ? (LEVEL_LABELS[req.level] ?? req.level) : '—' + const studentName = + (req?.user_profiles as { display_name: string } | null)?.display_name ?? '—' + const tutorName = + (match.tutor_profiles?.user_profiles as { display_name: string } | null) + ?.display_name ?? '—' + const assignedDate = new Date(match.assigned_at).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) + + return ( + + + + + + + + + + ) + })} + +
+ Student + + Subject / Level + + Tutor + + Status + + Meet Link + + Assigned + + Actions +
+ {studentName} + + {subjectName} · {levelLabel} + {tutorName} + + {match.status} + + + {match.meet_link ? ( + + Link ↗ + + ) : ( + Not set + )} + {assignedDate} + + Manage → + +
+
+ )}
) } + diff --git a/app/admin/requests/RequestFilters.tsx b/app/admin/requests/RequestFilters.tsx new file mode 100644 index 0000000..0b85d6a --- /dev/null +++ b/app/admin/requests/RequestFilters.tsx @@ -0,0 +1,69 @@ +// E7 T7.1: Client component for request inbox filter selects (subject + level) +// Closes #47 + +'use client' + +type Subject = { id: number; name: string } + +type RequestFiltersProps = { + subjects: Subject[] + activeStatus: string + activeSubject: string | undefined + activeLevel: string | undefined +} + +export function RequestFilters({ + subjects, + activeStatus, + activeSubject, + activeLevel, +}: RequestFiltersProps) { + function buildHref(params: Record): string { + const merged: Record = { + status: activeStatus !== 'all' ? activeStatus : undefined, + subject: activeSubject, + level: activeLevel, + ...params, + } + const qs = Object.entries(merged) + .filter(([, v]) => v !== undefined && v !== '') + .map(([k, v]) => `${k}=${encodeURIComponent(v!)}`) + .join('&') + return `/admin/requests${qs ? `?${qs}` : ''}` + } + + return ( +
+ {/* Subject filter */} + + + {/* Level filter */} + +
+ ) +} diff --git a/app/admin/requests/[id]/AssignTutorForm.tsx b/app/admin/requests/[id]/AssignTutorForm.tsx new file mode 100644 index 0000000..0a2afe2 --- /dev/null +++ b/app/admin/requests/[id]/AssignTutorForm.tsx @@ -0,0 +1,234 @@ +// E7 T7.2 T7.3: Client component for the tutor assignment form on the matching screen +// Closes #48 #49 + +'use client' + +import { useActionState, useState } from 'react' +import Link from 'next/link' +import { assignTutor } from '../actions' + +const DAY_OPTIONS = [ + { label: 'Sun', value: 0 }, + { label: 'Mon', value: 1 }, + { label: 'Tue', value: 2 }, + { label: 'Wed', value: 3 }, + { label: 'Thu', value: 4 }, + { label: 'Fri', value: 5 }, + { label: 'Sat', value: 6 }, +] + +type AvailWindow = { day: number; start: string; end: string } + +export type EligibleTutor = { + tutor_user_id: string + bio: string | null + timezone: string + user_profiles: { display_name: string; whatsapp_number: string | null } | null + tutor_availability: { windows: AvailWindow[] } | null +} + +type ActionResult = { error?: string; matchId?: string } | undefined + +async function assignAction( + _prev: ActionResult, + formData: FormData, +): Promise { + const requestId = formData.get('requestId') as string + const tutorUserId = formData.get('tutorUserId') as string + const meetLink = (formData.get('meetLink') as string) || undefined + const timezone = formData.get('timezone') as string + const time = formData.get('time') as string + const rawDays = formData.getAll('days').map(Number) + + const schedulePattern = + timezone && time && rawDays.length > 0 + ? { timezone, days: rawDays, time, duration_mins: 60 } + : undefined + + return assignTutor({ requestId, tutorUserId, meetLink, schedulePattern }) +} + +export function AssignTutorForm({ + requestId, + requestTimezone, + eligibleTutors, +}: { + requestId: string + requestTimezone: string + eligibleTutors: EligibleTutor[] +}) { + const [selectedTutorId, setSelectedTutorId] = useState(null) + const [state, formAction, isPending] = useActionState(assignAction, undefined) + + if (state?.matchId) { + return ( +
+

✅ Tutor assigned successfully!

+

+ The request has been moved to “Matched” status.{' '} + + View match → + +

+
+ ) + } + + return ( +
+

Eligible Tutors

+ + {eligibleTutors.length === 0 ? ( +
+

+ No approved tutors currently match this subject and level. +

+

+ Approve a tutor at{' '} + + /admin/tutors + {' '} + and ensure they have this subject × level added. +

+
+ ) : ( +
+ {eligibleTutors.map((tutor) => { + const isSelected = selectedTutorId === tutor.tutor_user_id + const windows = tutor.tutor_availability?.windows ?? [] + const availSummary = windows + .sort((a, b) => a.day - b.day) + .map((w) => `${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][w.day]} ${w.start}–${w.end}`) + .join(', ') + + return ( +
setSelectedTutorId(tutor.tutor_user_id)} + className={`cursor-pointer rounded-xl border p-4 transition ${ + isSelected + ? 'border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-900/20' + : 'border-zinc-200 bg-white hover:border-indigo-300 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:border-indigo-600' + }`} + > +
+
+

+ {tutor.user_profiles?.display_name ?? '—'} +

+

Timezone: {tutor.timezone}

+ {availSummary && ( +

+ Available: {availSummary} +

+ )} + {tutor.bio && ( +

+ {tutor.bio} +

+ )} +
+ setSelectedTutorId(tutor.tutor_user_id)} + className="mt-1 h-4 w-4 accent-indigo-600" + onClick={(e) => e.stopPropagation()} + /> +
+
+ ) + })} +
+ )} + + {/* Assignment form — shown after tutor is selected */} + {selectedTutorId && ( +
+

Assignment Details

+ + + + +
+ + +
+ +
+ + +
+ +
+ +
+ {DAY_OPTIONS.map(({ label, value }) => ( + + ))} +
+
+ +
+ + +
+ + {state?.error && ( +

{state.error}

+ )} + + +
+ )} +
+ ) +} diff --git a/app/admin/requests/[id]/page.tsx b/app/admin/requests/[id]/page.tsx index e1125f5..f55bba9 100644 --- a/app/admin/requests/[id]/page.tsx +++ b/app/admin/requests/[id]/page.tsx @@ -1 +1,309 @@ -export default function Page() { return

TODO

} +// E7 T7.2: Admin request detail + matching screen +// Closes #48 + +export const dynamic = 'force-dynamic' + +import { notFound } from 'next/navigation' +import Link from 'next/link' +import { createAdminClient } from '@/lib/supabase/admin' +import { fetchApprovedTutors } from '@/lib/services/matching' +import { STATUS_COLOURS, STATUS_LABELS, LEVEL_LABELS } from '@/lib/utils/request' +import { AssignTutorForm } from './AssignTutorForm' + +const EXAM_BOARD_LABELS: Record = { + cambridge: 'Cambridge', + edexcel: 'Edexcel', + other: 'Other', + unspecified: 'Not specified', +} + +const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +type RequestData = { + id: string + status: string + level: string + subject_id: number + exam_board: string + goals: string | null + timezone: string + availability_windows: unknown + preferred_start_date: string | null + for_student_name: string | null + requester_role: string + created_at: string + subjects: { name: string } | null + user_profiles: { display_name: string; whatsapp_number: string | null } | null +} + +type MatchData = { + id: string + status: string + meet_link: string | null + schedule_pattern: { + timezone?: string + days?: number[] + time?: string + duration_mins?: number + } | null + assigned_at: string + tutor_user_id: string + user_profiles: { display_name: string } | null +} + +export default async function AdminRequestDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const admin = createAdminClient() + + const [{ data: requestData }, { data: matchData }] = await Promise.all([ + admin + .from('requests') + .select( + `id, status, level, subject_id, exam_board, goals, timezone, + availability_windows, preferred_start_date, for_student_name, + requester_role, created_at, + subjects ( name ), + user_profiles!requests_created_by_user_id_fkey ( display_name, whatsapp_number )` + ) + .eq('id', id) + .maybeSingle(), + admin + .from('matches') + .select( + `id, status, meet_link, schedule_pattern, assigned_at, tutor_user_id, + user_profiles!matches_tutor_user_id_fkey ( display_name )` + ) + .eq('request_id', id) + .maybeSingle(), + ]) + + if (!requestData) notFound() + + const request = requestData as unknown as RequestData + const match = matchData as unknown as MatchData | null + const profile = request.user_profiles + const subjectName = (request.subjects as { name: string } | null)?.name ?? '—' + const levelLabel = LEVEL_LABELS[request.level] ?? request.level + const requestStatus = request.status as keyof typeof STATUS_COLOURS + const submittedDate = new Date(request.created_at).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + }) + + // Fetch eligible tutors only for ready_to_match requests + const eligibleTutors = + request.status === 'ready_to_match' + ? await fetchApprovedTutors(request.subject_id, request.level) + : [] + + // Format availability for display + let availabilityDisplay: string = '—' + if (request.availability_windows) { + if (typeof request.availability_windows === 'string') { + availabilityDisplay = request.availability_windows + } else if (Array.isArray(request.availability_windows)) { + availabilityDisplay = JSON.stringify(request.availability_windows) + } + } + + return ( +
+ {/* Back link */} + + ← Back to Requests + + +
+ {/* Left panel: Request details */} +
+
+ {/* Header */} +
+
+

+ Request Details +

+

Submitted {submittedDate}

+
+ + {STATUS_LABELS[requestStatus] ?? request.status} + +
+ +
+ + {/* Student */} +
+

+ Student +

+

+ + {request.requester_role === 'parent' && request.for_student_name + ? request.for_student_name + : (profile?.display_name ?? '—')} + + {request.requester_role === 'parent' && ( + + (Parent: {profile?.display_name ?? '—'}) + + )} +

+ {profile?.whatsapp_number && ( +

📱 {profile.whatsapp_number}

+ )} +
+ +
+ + {/* Subject info */} +
+

+ Subject & Level +

+
+
+
Subject
+
{subjectName}
+
+
+
Level
+
{levelLabel}
+
+
+
Exam board
+
+ {EXAM_BOARD_LABELS[request.exam_board] ?? request.exam_board} +
+
+
+
+ +
+ + {/* Schedule preferences */} +
+

+ Schedule Preferences +

+
+
+
Timezone
+
+ {request.timezone} +
+
+
+
Availability
+
+ {availabilityDisplay} +
+
+ {request.preferred_start_date && ( +
+
Preferred start
+
+ {request.preferred_start_date} +
+
+ )} +
+
+ + {request.goals && ( + <> +
+
+

+ Goals +

+

+ {request.goals} +

+
+ + )} +
+ + {/* Matched state: show match info */} + {match && ( +
+

+ ✅ Matched +

+

+ Tutor:{' '} + + {(match.user_profiles as { display_name: string } | null)?.display_name ?? '—'} + +

+ {match.meet_link && ( +

+ Meet:{' '} + + {match.meet_link} + +

+ )} + {match.schedule_pattern?.days && match.schedule_pattern.days.length > 0 && ( +

+ Schedule:{' '} + {match.schedule_pattern.days.map((d) => DAY_NAMES[d]).join(', ')}{' '} + {match.schedule_pattern.time && `at ${match.schedule_pattern.time}`}{' '} + {match.schedule_pattern.timezone && `(${match.schedule_pattern.timezone})`} +

+ )} + + View Match → + +
+ )} +
+ + {/* Right panel: Eligible tutors + assignment form */} +
+ {request.status === 'ready_to_match' ? ( + + ) : request.status === 'matched' || request.status === 'active' ? ( +
+

+ This request has already been matched. Use the match detail page to manage the + tutor assignment. +

+
+ ) : ( +
+

+ This request is in {STATUS_LABELS[requestStatus] ?? request.status}{' '} + status. Matching is available once payment is verified and status advances to + “Ready to Match”. +

+
+ )} +
+
+
+ ) +} diff --git a/app/admin/requests/actions.ts b/app/admin/requests/actions.ts new file mode 100644 index 0000000..593d5b5 --- /dev/null +++ b/app/admin/requests/actions.ts @@ -0,0 +1,200 @@ +// E7 T7.3 T7.4: Admin server actions for tutor assignment and reassignment +// Closes #49 #50 + +'use server' + +import { createAdminClient } from '@/lib/supabase/admin' +import { createClient } from '@/lib/supabase/server' +import { revalidatePath } from 'next/cache' + +async function requireAdmin(): Promise { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) throw new Error('Unauthorized: not authenticated') + + const admin = createAdminClient() + const { data: roles } = await admin + .from('user_roles') + .select('role') + .eq('user_id', user.id) + + const isAdmin = roles?.some((r) => r.role === 'admin') ?? false + if (!isAdmin) throw new Error('Unauthorized: admin role required') + + return user.id +} + +/** Create a match record, advance request to 'matched', and write audit log. */ +export async function assignTutor({ + requestId, + tutorUserId, + meetLink, + schedulePattern, +}: { + requestId: string + tutorUserId: string + meetLink?: string + schedulePattern?: { + timezone: string + days: number[] + time: string + duration_mins: number + } +}): Promise<{ error?: string; matchId?: string }> { + try { + const adminUserId = await requireAdmin() + const admin = createAdminClient() + + 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}`) + + // Advance request status to 'matched' + const { error: reqError } = await admin + .from('requests') + .update({ status: 'matched', updated_at: new Date().toISOString() }) + .eq('id', requestId) + + if (reqError) throw new Error(`Failed to advance request: ${reqError.message}`) + + // Audit log + const { error: auditError } = await admin.from('audit_logs').insert([ + { + actor_user_id: adminUserId, + action: 'tutor_assigned', + entity_type: 'match', + entity_id: match.id, + details: { tutor_user_id: tutorUserId, request_id: requestId }, + }, + ]) + if (auditError) { + console.error('Audit log insert failed (tutor_assigned):', auditError.message) + } + + revalidatePath(`/admin/requests/${requestId}`) + revalidatePath('/admin/requests') + revalidatePath('/admin/matches') + return { matchId: match.id } + } catch (err) { + return { error: err instanceof Error ? err.message : 'An unexpected error occurred.' } + } +} + +/** Update match.tutor_user_id to a new tutor and write audit log (history kept). */ +export async function reassignTutor({ + matchId, + previousTutorUserId, + newTutorUserId, + reason, +}: { + matchId: string + previousTutorUserId: string + newTutorUserId: string + reason?: string +}): Promise<{ error?: string }> { + try { + const adminUserId = await requireAdmin() + const admin = createAdminClient() + + const { error } = await admin + .from('matches') + .update({ + tutor_user_id: newTutorUserId, + updated_at: new Date().toISOString(), + }) + .eq('id', matchId) + + if (error) throw new Error(`Failed to reassign tutor: ${error.message}`) + + const { error: auditError } = await admin.from('audit_logs').insert([ + { + actor_user_id: adminUserId, + action: 'tutor_reassigned', + entity_type: 'match', + entity_id: matchId, + details: { + old_tutor_user_id: previousTutorUserId, + new_tutor_user_id: newTutorUserId, + reason: reason || null, + }, + }, + ]) + if (auditError) { + console.error('Audit log insert failed (tutor_reassigned):', auditError.message) + } + + revalidatePath(`/admin/matches/${matchId}`) + revalidatePath('/admin/requests') + revalidatePath('/admin/matches') + return {} + } catch (err) { + return { error: err instanceof Error ? err.message : 'An unexpected error occurred.' } + } +} + +/** Update the meet_link and/or schedule_pattern on an existing match. */ +export async function updateMatchDetails({ + matchId, + meetLink, + schedulePattern, +}: { + matchId: string + meetLink?: string + schedulePattern?: { + timezone: string + days: number[] + time: string + duration_mins: number + } +}): Promise<{ error?: string }> { + try { + const adminUserId = await requireAdmin() + const admin = createAdminClient() + + 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 }, + }, + ]) + if (auditError) { + console.error('Audit log insert failed (match_details_updated):', auditError.message) + } + + revalidatePath(`/admin/matches/${matchId}`) + return {} + } catch (err) { + return { error: err instanceof Error ? err.message : 'An unexpected error occurred.' } + } +} diff --git a/app/admin/requests/page.tsx b/app/admin/requests/page.tsx index e1125f5..cbc907c 100644 --- a/app/admin/requests/page.tsx +++ b/app/admin/requests/page.tsx @@ -1 +1,238 @@ -export default function Page() { return

TODO

} +// E7 T7.1: Admin requests inbox — filterable list of all requests +// Closes #47 + +export const dynamic = 'force-dynamic' + +import Link from 'next/link' +import { createAdminClient } from '@/lib/supabase/admin' +import { STATUS_COLOURS, STATUS_LABELS, LEVEL_LABELS } from '@/lib/utils/request' +import { RequestFilters } from './RequestFilters' + +const STATUS_PRIORITY: Record = { + ready_to_match: 0, + new: 1, + payment_pending: 2, + matched: 3, + active: 4, + paused: 5, + ended: 6, +} + +const ALL_STATUSES = [ + 'new', + 'payment_pending', + 'ready_to_match', + 'matched', + 'active', + 'paused', + 'ended', +] + +type PackageRow = { tier_sessions: number; status: string } + +type RequestRow = { + id: string + status: string + level: string + subject_id: number + created_at: string + subjects: { name: string } | null + user_profiles: { display_name: string; whatsapp_number: string | null } | null + packages: PackageRow[] +} + +type FilterStatus = 'all' | (typeof ALL_STATUSES)[number] + +export default async function AdminRequestsPage({ + searchParams, +}: { + searchParams: Promise<{ status?: string; subject?: string; level?: string }> +}) { + const { status, subject, level } = await searchParams + const activeStatus: FilterStatus = ALL_STATUSES.includes(status ?? '') ? status! : 'all' + + const admin = createAdminClient() + + const [{ data: requestsData }, { data: subjectsData }] = await Promise.all([ + admin + .from('requests') + .select( + `id, status, level, subject_id, created_at, + subjects ( name ), + user_profiles!requests_created_by_user_id_fkey ( display_name, whatsapp_number ), + packages ( tier_sessions, status )` + ) + .order('created_at', { ascending: true }), + admin.from('subjects').select('id, name').eq('active', true).order('sort_order'), + ]) + + let requests = (requestsData ?? []) as unknown as RequestRow[] + const subjects = (subjectsData ?? []) as { id: number; name: string }[] + + // Apply filters + if (activeStatus !== 'all') { + requests = requests.filter((r) => r.status === activeStatus) + } + if (subject) { + requests = requests.filter((r) => String(r.subject_id) === subject) + } + if (level) { + requests = requests.filter((r) => r.level === level) + } + + // Sort: ready_to_match first, then by created_at within each status + requests.sort((a, b) => { + const pa = STATUS_PRIORITY[a.status] ?? 99 + const pb = STATUS_PRIORITY[b.status] ?? 99 + if (pa !== pb) return pa - pb + return new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + }) + + const statusLinks: { label: string; value: FilterStatus }[] = [ + { label: 'All', value: 'all' }, + { label: 'New', value: 'new' }, + { label: 'Payment Pending', value: 'payment_pending' }, + { label: 'Ready to Match', value: 'ready_to_match' }, + { label: 'Matched', value: 'matched' }, + { label: 'Active', value: 'active' }, + { label: 'Paused', value: 'paused' }, + { label: 'Ended', value: 'ended' }, + ] + + function buildStatusHref(newStatus: FilterStatus) { + const qs = Object.entries({ status: newStatus !== 'all' ? newStatus : undefined, subject, level }) + .filter(([, v]) => v !== undefined && v !== '') + .map(([k, v]) => `${k}=${encodeURIComponent(v!)}`) + .join('&') + return `/admin/requests${qs ? `?${qs}` : ''}` + } + + return ( +
+
+

Requests

+

+ {requests.length} request{requests.length !== 1 ? 's' : ''} +

+
+ + {/* Status filter tabs */} +
+ {statusLinks.map(({ label, value }) => ( + + {label} + + ))} +
+ + {/* Subject / level client filters */} + + + {/* Table */} + {requests.length === 0 ? ( +
+

No requests match the current filters.

+
+ ) : ( +
+ + + + + + + + + + + + + + {requests.map((req) => { + const profile = req.user_profiles + const subjectName = (req.subjects as { name: string } | null)?.name ?? '—' + const levelLabel = LEVEL_LABELS[req.level] ?? req.level + const pkgArray = (req.packages ?? []) as PackageRow[] + const activePkg = + pkgArray.find((p) => p.status === 'active') ?? pkgArray[0] ?? null + const requestStatus = req.status as keyof typeof STATUS_COLOURS + const dateLabel = new Date(req.created_at).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) + + return ( + + + + + + + + + + ) + })} + +
+ Student + + Level + + Subject + + Package + + Status + + Date + + Actions +
+ {profile?.display_name ?? '—'} + {levelLabel}{subjectName} + {activePkg ? `${activePkg.tier_sessions} sessions` : '—'} + + + {STATUS_LABELS[requestStatus] ?? req.status} + + {dateLabel} +
+ {req.status === 'ready_to_match' ? ( + + Match → + + ) : ( + + View + + )} +
+
+
+ )} +
+ ) +} diff --git a/lib/services/matching.ts b/lib/services/matching.ts index 6d79b20..5984575 100644 --- a/lib/services/matching.ts +++ b/lib/services/matching.ts @@ -46,12 +46,16 @@ export async function fetchApprovedTutors(subjectId?: number, level?: string) { let tutors = (data ?? []) as unknown as TutorWithSubjects[] // Client-side filtering by subject/level (small dataset for MVP) - if (subjectId) { + // When both are provided, require the same row to match both (correct composite filter) + if (subjectId && level) { + tutors = tutors.filter((t) => + t.tutor_subjects.some((s) => s.subject_id === subjectId && s.level === level) + ) + } else if (subjectId) { tutors = tutors.filter((t) => t.tutor_subjects.some((s) => s.subject_id === subjectId) ) - } - if (level) { + } else if (level) { tutors = tutors.filter((t) => t.tutor_subjects.some((s) => s.level === level) ) diff --git a/supabase/migrations/20260225000001_create_matches_table.sql b/supabase/migrations/20260225000001_create_matches_table.sql new file mode 100644 index 0000000..9c3c286 --- /dev/null +++ b/supabase/migrations/20260225000001_create_matches_table.sql @@ -0,0 +1,52 @@ +-- E7 T7.3: matches table with RLS policies +-- Closes #49 + +create table public.matches ( + id uuid primary key default gen_random_uuid(), + request_id uuid not null unique references public.requests(id) on delete cascade, + tutor_user_id uuid not null references public.tutor_profiles(tutor_user_id) on delete restrict, + status public.match_status_enum not null default 'matched', + meet_link text, + schedule_pattern jsonb, + assigned_by_user_id uuid references public.user_profiles(user_id), + assigned_at timestamptz not null default now(), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index on public.matches (status); +create index on public.matches (tutor_user_id); +create index on public.matches (request_id); + +-- updated_at trigger +create or replace function public.matches_set_updated_at() +returns trigger language plpgsql as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +create trigger matches_updated_at + before update on public.matches + for each row execute function public.matches_set_updated_at(); + +alter table public.matches enable row level security; + +-- Admin can do everything +create policy "matches_admin_all" + on public.matches for all to authenticated + using (public.is_admin(auth.uid())) + with check (public.is_admin(auth.uid())); + +-- Tutor assigned to the match or the request creator can select +create policy "matches_select_participants" + on public.matches for select to authenticated + using ( + tutor_user_id = auth.uid() + or exists ( + select 1 from public.requests r + where r.id = matches.request_id + and r.created_by_user_id = auth.uid() + ) + ); From ac56731cc1e44461cb1abc75e8a3e12f5858da35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 03:45:04 +0000 Subject: [PATCH 3/3] fix(E7): address review comments on server actions and form validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- app/admin/matches/[id]/MatchActions.tsx | 27 +++++-- app/admin/requests/[id]/AssignTutorForm.tsx | 9 +++ app/admin/requests/actions.ts | 80 ++++++++++++++++----- app/admin/requests/page.tsx | 3 +- 4 files changed, 96 insertions(+), 23 deletions(-) diff --git a/app/admin/matches/[id]/MatchActions.tsx b/app/admin/matches/[id]/MatchActions.tsx index 004f8a9..c7c7edc 100644 --- a/app/admin/matches/[id]/MatchActions.tsx +++ b/app/admin/matches/[id]/MatchActions.tsx @@ -177,12 +177,29 @@ async function editMatchAction( const time = formData.get('time') as string const rawDays = formData.getAll('days').map(Number) - const schedulePattern = - timezone && time && rawDays.length > 0 - ? { timezone, days: rawDays, time, duration_mins: 60 } - : undefined + // Only build a schedule pattern if all three fields are present; if any partial, error. + const hasAnyScheduleField = !!timezone || !!time || rawDays.length > 0 + const hasAllScheduleFields = !!timezone && !!time && rawDays.length > 0 + if (hasAnyScheduleField && !hasAllScheduleFields) { + return { + error: 'Please provide timezone, start time, and at least one day — or leave all schedule fields empty.', + } + } + + // Pass schedulePattern only when all fields are present; omit (undefined) otherwise + // so that the server action does not overwrite an existing schedule. + const payload: { + matchId: string + meetLink?: string + schedulePattern?: { timezone: string; days: number[]; time: string; duration_mins: number } | null + } = { matchId } + + if (typeof meetLink !== 'undefined') payload.meetLink = meetLink + if (hasAllScheduleFields) { + payload.schedulePattern = { timezone, days: rawDays, time, duration_mins: 60 } + } - return updateMatchDetails({ matchId, meetLink, schedulePattern }) + return updateMatchDetails(payload) } export function EditMatchForm({ diff --git a/app/admin/requests/[id]/AssignTutorForm.tsx b/app/admin/requests/[id]/AssignTutorForm.tsx index 0a2afe2..448246f 100644 --- a/app/admin/requests/[id]/AssignTutorForm.tsx +++ b/app/admin/requests/[id]/AssignTutorForm.tsx @@ -45,6 +45,15 @@ async function assignAction( ? { timezone, days: rawDays, time, duration_mins: 60 } : undefined + // Partial schedule: if any schedule field is filled but not all three, reject early + const hasAnyScheduleField = !!timezone || !!time || rawDays.length > 0 + const hasAllScheduleFields = !!timezone && !!time && rawDays.length > 0 + if (hasAnyScheduleField && !hasAllScheduleFields) { + return { + error: 'Please provide timezone, start time, and at least one day — or leave all schedule fields empty to save the schedule later.', + } + } + return assignTutor({ requestId, tutorUserId, meetLink, schedulePattern }) } diff --git a/app/admin/requests/actions.ts b/app/admin/requests/actions.ts index 593d5b5..78d04b2 100644 --- a/app/admin/requests/actions.ts +++ b/app/admin/requests/actions.ts @@ -48,6 +48,20 @@ export async function assignTutor({ const adminUserId = await requireAdmin() const admin = createAdminClient() + // Validate the request exists and is in a matchable state + const { data: request } = await admin + .from('requests') + .select('id, status') + .eq('id', requestId) + .maybeSingle() + + if (!request) throw new Error('Request not found.') + if (request.status !== 'ready_to_match') { + throw new Error( + `Cannot assign a tutor: request is currently in "${request.status}" status. Only "ready_to_match" requests can be assigned.` + ) + } + const { data: match, error } = await admin .from('matches') .insert([ @@ -58,18 +72,23 @@ export async function assignTutor({ meet_link: meetLink || null, schedule_pattern: schedulePattern ?? null, assigned_by_user_id: adminUserId, - assigned_at: new Date().toISOString(), + // assigned_at has a DB default of now() }, ]) .select() .single() - if (error) throw new Error(`Failed to create match: ${error.message}`) + if (error) { + if (error.code === '23505') { + throw new Error('This request already has a match assigned. Use the match detail page to reassign the tutor.') + } + throw new Error(`Failed to create match: ${error.message}`) + } - // Advance request status to 'matched' + // Advance request status to 'matched' (updated_at is managed by DB trigger) const { error: reqError } = await admin .from('requests') - .update({ status: 'matched', updated_at: new Date().toISOString() }) + .update({ status: 'matched' }) .eq('id', requestId) if (reqError) throw new Error(`Failed to advance request: ${reqError.message}`) @@ -113,12 +132,22 @@ export async function reassignTutor({ const adminUserId = await requireAdmin() const admin = createAdminClient() + // Validate match exists and new tutor is actually different + const { data: existingMatch } = await admin + .from('matches') + .select('id, tutor_user_id') + .eq('id', matchId) + .maybeSingle() + + if (!existingMatch) throw new Error('Match not found.') + if (existingMatch.tutor_user_id === newTutorUserId) { + throw new Error('The selected tutor is already assigned to this match.') + } + + // updated_at is managed by the DB trigger const { error } = await admin .from('matches') - .update({ - tutor_user_id: newTutorUserId, - updated_at: new Date().toISOString(), - }) + .update({ tutor_user_id: newTutorUserId }) .eq('id', matchId) if (error) throw new Error(`Failed to reassign tutor: ${error.message}`) @@ -149,7 +178,10 @@ export async function reassignTutor({ } } -/** Update the meet_link and/or schedule_pattern on an existing match. */ +/** Update the meet_link and/or schedule_pattern on an existing match. + * Only fields that are explicitly passed (not undefined) are written; + * passing undefined for a field leaves it unchanged in the database. + */ export async function updateMatchDetails({ matchId, meetLink, @@ -162,30 +194,45 @@ export async function updateMatchDetails({ days: number[] time: string duration_mins: number - } + } | null }): Promise<{ error?: string }> { try { const adminUserId = await requireAdmin() const admin = createAdminClient() + // Build update payload dynamically — only include fields that were explicitly provided. + // updated_at is managed by the DB trigger. + const updateData: Record = {} + if (typeof meetLink !== 'undefined') { + updateData.meet_link = meetLink || null + } + if (typeof schedulePattern !== 'undefined') { + updateData.schedule_pattern = schedulePattern + } + + if (Object.keys(updateData).length === 0) { + return {} // Nothing to update + } + const { error } = await admin .from('matches') - .update({ - meet_link: meetLink ?? null, - schedule_pattern: schedulePattern ?? null, - updated_at: new Date().toISOString(), - }) + .update(updateData) .eq('id', matchId) if (error) throw new Error(`Failed to update match: ${error.message}`) + // Audit log — record whichever fields changed + const auditDetails: Record = {} + if (typeof meetLink !== 'undefined') auditDetails.meet_link = meetLink || null + 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: { meet_link: meetLink ?? null }, + details: auditDetails, }, ]) if (auditError) { @@ -198,3 +245,4 @@ export async function updateMatchDetails({ return { error: err instanceof Error ? err.message : 'An unexpected error occurred.' } } } + diff --git a/app/admin/requests/page.tsx b/app/admin/requests/page.tsx index cbc907c..b00da83 100644 --- a/app/admin/requests/page.tsx +++ b/app/admin/requests/page.tsx @@ -61,8 +61,7 @@ export default async function AdminRequestsPage({ subjects ( name ), user_profiles!requests_created_by_user_id_fkey ( display_name, whatsapp_number ), packages ( tier_sessions, status )` - ) - .order('created_at', { ascending: true }), + ), admin.from('subjects').select('id, name').eq('active', true).order('sort_order'), ])