Epic E4: Student/parent tutoring request intake flow - #94
Conversation
There was a problem hiding this comment.
Pull request overview
This PR implements a complete authenticated tutoring request intake flow for students and parents, allowing them to submit structured requests that can be acted upon by admins. The implementation bridges the lead capture system (E2) with structured request records, establishing the foundation for the matching and session management workflow.
Changes:
- Added
requeststable with full RLS policies and indexes to store tutoring requests - Created validated request form with React Hook Form + Zod, including subject selection, availability windows, and timezone handling
- Implemented request detail/confirmation page with status-aware UI and next-step guidance
- Updated student dashboard to display request list with status badges and quick access to create new requests
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
supabase/migrations/20260223000007_create_requests_table.sql |
Creates requests table with appropriate indexes, RLS policies (creator insert, creator/admin select, limited creator update, unrestricted admin update), and updated_at trigger |
lib/validators/request.ts |
Zod schema for request form validation covering all required and optional fields |
app/dashboard/requests/new/page.tsx |
Client-side form component with live subject fetching, timezone pre-fill from profile, parent-conditional child name field, and duplicate request warning |
app/dashboard/requests/[id]/page.tsx |
Server component showing read-only request summary with status badge and contextual next-step banners for all lifecycle states |
app/dashboard/page.tsx |
Enhanced student dashboard showing list of all requests with status badges and "New Request" CTA, replacing placeholder content |
lib/utils/request.ts |
Utility constants for request status labels and badge colors used across dashboard and detail pages |
README.md |
Updated capability table to reflect E4 completion status and document new features |
Comments suppressed due to low confidence (7)
app/dashboard/requests/[id]/page.tsx:130
- The authorization check on line 130 is redundant because Row Level Security (RLS) is enabled on the requests table (line 38 of the migration). The RLS policy "requests_select_creator_or_admin" (lines 44-46) already prevents users from seeing requests they don't own unless they're an admin. If a user tries to access a request they don't own, the query on lines 123-127 will return null, triggering the notFound() on line 129. The additional check on line 130 will never execute in the unauthorized case.
if (request.created_by_user_id !== user.id) notFound()
app/dashboard/page.tsx:91
- The subjects handling logic on lines 88-91 attempts to handle both array and object cases, but the Supabase query with
.select('..., subjects(name)')and no array relationship will always return an object (or null), never an array. The array checkArray.isArray(subj)will always be false. Simplify this to just handle the object case:const subjectName = (req.subjects as { name: string } | null)?.name ?? 'Subject #${req.subject_id}'
const subj = req.subjects
const subjectName =
(Array.isArray(subj) ? subj[0]?.name : (subj as { name: string } | null)?.name) ??
`Subject #${req.subject_id}`
app/dashboard/requests/[id]/page.tsx:209
- The preferred_start_date is displayed as-is (line 209) without any date formatting, which will show the raw date string in ISO format (YYYY-MM-DD). For consistency with the created_at date formatting (lines 134-138), consider formatting this date as well using
new Date(request.preferred_start_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).
{request.preferred_start_date}
app/dashboard/page.tsx:15
- The LEVEL_LABELS constant is duplicated between this file (lines 12-15) and app/dashboard/requests/[id]/page.tsx (lines 11-14). Consider extracting this to a shared utility file (e.g., lib/utils/levels.ts or adding it to lib/utils/request.ts alongside STATUS_LABELS) to avoid duplication.
const LEVEL_LABELS: Record<string, string> = {
o_levels: 'O Levels',
a_levels: 'A Levels',
}
app/dashboard/requests/new/page.tsx:30
- The TIMEZONES constant is duplicated across multiple files (app/auth/sign-up/page.tsx:30-46, app/auth/profile-setup/page.tsx:33-49, and here). Consider extracting this to a shared constant file (e.g., lib/constants/timezones.ts) to maintain consistency and make updates easier.
const TIMEZONES = [
{ value: 'Asia/Karachi', label: 'Asia/Karachi (PKT, UTC+5)' },
{ value: 'Asia/Dubai', label: 'Asia/Dubai (GST, UTC+4)' },
{ value: 'Asia/Riyadh', label: 'Asia/Riyadh (AST, UTC+3)' },
{ value: 'Europe/London', label: 'Europe/London (GMT/BST)' },
{ value: 'Europe/Paris', label: 'Europe/Paris (CET/CEST)' },
{ value: 'America/New_York', label: 'America/New_York (EST/EDT)' },
{ value: 'America/Chicago', label: 'America/Chicago (CST/CDT)' },
{ value: 'America/Denver', label: 'America/Denver (MST/MDT)' },
{ value: 'America/Los_Angeles', label: 'America/Los_Angeles (PST/PDT)' },
{ value: 'America/Toronto', label: 'America/Toronto (EST/EDT)' },
{ value: 'America/Vancouver', label: 'America/Vancouver (PST/PDT)' },
{ value: 'Asia/Singapore', label: 'Asia/Singapore (SGT, UTC+8)' },
{ value: 'Asia/Tokyo', label: 'Asia/Tokyo (JST, UTC+9)' },
{ value: 'Australia/Sydney', label: 'Australia/Sydney (AEDT/AEST)' },
{ value: 'Pacific/Auckland', label: 'Pacific/Auckland (NZST/NZDT)' },
]
lib/validators/request.ts:13
- The exam_board field is marked as
.optional()in the Zod schema, but the form provides a default value of 'unspecified' (line 50), and the database column also hasdefault 'unspecified'(line 11 of the migration). This means the field will never actually be undefined/null. Consider removing.optional()from the Zod schema and using.default('unspecified')instead, or remove the default from the form to allow true optional behavior.
exam_board: z
.enum(['cambridge', 'edexcel', 'other', 'unspecified'])
.optional(),
app/dashboard/page.tsx:25
- The StatusBadge component is duplicated between this file (lines 17-25) and app/dashboard/requests/[id]/page.tsx (lines 23-31). Consider extracting this to a shared component in the components directory (e.g., components/requests/StatusBadge.tsx) to avoid code duplication and ensure consistent styling.
function StatusBadge({ status }: { status: RequestStatus }) {
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${STATUS_COLOURS[status]}`}
>
{STATUS_LABELS[status]}
</span>
)
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| setDuplicateWarning( | ||
| 'You already have an active request for this level and subject. Are you sure you want to create another?' | ||
| ) | ||
| // Allow the user to proceed anyway — they've been warned |
There was a problem hiding this comment.
The duplicate warning is shown but doesn't prevent form submission. After the warning is set on line 116-118, the code immediately proceeds to insert the new request on line 122. This means the warning is displayed after the request has already been created, which is too late. The warning should either: (1) prevent submission until explicitly confirmed by the user, or (2) be shown before the first submission attempt, not during it.
| setDuplicateWarning( | |
| 'You already have an active request for this level and subject. Are you sure you want to create another?' | |
| ) | |
| // Allow the user to proceed anyway — they've been warned | |
| const warningMessage = | |
| 'You already have an active request for this level and subject. Are you sure you want to create another?' | |
| setDuplicateWarning(warningMessage) | |
| const proceed = window.confirm(warningMessage) | |
| if (!proceed) { | |
| // User chose not to create a duplicate request | |
| return | |
| } |
There was a problem hiding this comment.
Fixed in 5d153a4. The duplicate check now calls window.confirm() with the warning message and returns early if the user declines, so no insert happens until they explicitly confirm.
| exam_board: data.exam_board ?? 'unspecified', | ||
| goals: data.goals || null, | ||
| timezone: data.timezone, | ||
| availability_windows: JSON.stringify(data.availability_windows), |
There was a problem hiding this comment.
There's a mismatch between the database schema and the application code for the availability_windows field. The database schema defines it as jsonb (line 14 of the migration), but the form submission code stores it as JSON.stringify(data.availability_windows) (line 134), which will create a JSON-encoded string inside the JSONB column. This should either be stored directly as the string value without JSON.stringify(), or the database column should be text instead of jsonb.
| availability_windows: JSON.stringify(data.availability_windows), | |
| availability_windows: data.availability_windows, |
There was a problem hiding this comment.
Fixed in 5d153a4. Removed JSON.stringify() — the raw string is passed directly. PostgREST serialises it as a JSON string value which Postgres stores natively in the JSONB column, so there's no double-encoding.
| availability_windows: z | ||
| .string() | ||
| .min(10, 'Please describe your availability (at least 10 characters)'), |
There was a problem hiding this comment.
The validation schema defines availability_windows as a string with a minimum length of 10 characters, but then this string is wrapped with JSON.stringify() before being stored in a JSONB column. This creates confusion about the data type. For consistency and clarity, either: (1) keep it as a plain string in the database (change the column to text), or (2) accept it as a structured object in the form and remove the JSON.stringify() call so it's stored natively as JSONB.
| availability_windows: z | |
| .string() | |
| .min(10, 'Please describe your availability (at least 10 characters)'), | |
| availability_windows: z.union([ | |
| z | |
| .string() | |
| .min(10, 'Please describe your availability (at least 10 characters)'), | |
| z.record(z.unknown()), | |
| ]), |
There was a problem hiding this comment.
Addressed in 5d153a4. By removing JSON.stringify() from the insert, the z.string().min(10) validator and the stored JSONB value are now consistent — the text the user types is stored as a JSONB string node without any extra wrapping.
|
@copilot apply changes based on the comments in this thread |
…availability_windows Co-authored-by: Taleef7 <89072337+Taleef7@users.noreply.github.com>
All three review comments are addressed in commit 5d153a4: duplicate-request guard now blocks submission via |
Implements the full authenticated tutoring request creation flow — from form submission through confirmation — bridging lead capture (E2) to structured request records that admins can act on.
DB
supabase/migrations/20260223000007_create_requests_table.sql:requeststable matching the architecture schema; composite index on(status, created_at desc);updated_attrigger; 4 RLS policies — creator insert, creator/admin select, creator update gated tonew/payment_pending, admin update unrestrictedValidation
lib/validators/request.ts: Zod schema for all request fields (requester_role,level,subject_id,exam_board,availability_windows,timezone,goals,preferred_start_date)Pages
app/dashboard/requests/new/page.tsx('use client'): RHF + Zod form; subjects fetched live fromsubjectstable; timezone pre-filled fromuser_profiles; parent-conditional child name field; level+subject summary badge; duplicate-request check useswindow.confirm()to block insert if user declines; inserts withstatus = 'new'→ redirects to detail pageapp/dashboard/requests/[id]/page.tsx(server component): read-only summary, colour-coded status badge, status-aware "what's next" banners for all 7 lifecycle states (new → ended); 404 on non-owner accessapp/dashboard/page.tsx: replaces placeholder with live requests list (status badges, "New Request" CTA)Shared utilities
lib/utils/request.ts:STATUS_LABELS+STATUS_COLOURSrecords keyed onRequestStatus— consumed by both the dashboard list and the detail pageDocs
README.md: capability table updated to reflect E4 statusTesting
Notes
availability_windowsis stored as a plain string value in the JSONB column — the raw textarea input is passed directly to Supabase/PostgREST which handles the JSONB text-node insertion, avoiding double-encoding; schema remains JSONB-ready for a structured upgrade post-MVPwindow.confirm()to give the user an explicit accept/cancel choice before any insert is attemptednew → payment_pendingare stubs for E5/E7/E8; the RLS contract is in place💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.