Skip to content

Epic E4: Student/parent tutoring request intake flow - #94

Merged
Taleef7 merged 3 commits into
mainfrom
copilot/implement-student-parent-intake-flow
Feb 24, 2026
Merged

Epic E4: Student/parent tutoring request intake flow#94
Taleef7 merged 3 commits into
mainfrom
copilot/implement-student-parent-intake-flow

Conversation

Copilot AI commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

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: requests table matching the architecture schema; composite index on (status, created_at desc); updated_at trigger; 4 RLS policies — creator insert, creator/admin select, creator update gated to new/payment_pending, admin update unrestricted

Validation

  • 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 from subjects table; timezone pre-filled from user_profiles; parent-conditional child name field; level+subject summary badge; duplicate-request check uses window.confirm() to block insert if user declines; inserts with status = 'new' → redirects to detail page

  • app/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 access

  • app/dashboard/page.tsx: replaces placeholder with live requests list (status badges, "New Request" CTA)

Shared utilities

  • lib/utils/request.ts: STATUS_LABELS + STATUS_COLOURS records keyed on RequestStatus — consumed by both the dashboard list and the detail page

Docs

  • README.md: capability table updated to reflect E4 status

Testing

  • Verified locally
  • Checked key flows manually

Notes

  • availability_windows is 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-MVP
  • Duplicate-request guard now uses window.confirm() to give the user an explicit accept/cancel choice before any insert is attempted
  • Status transitions beyond new → payment_pending are 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.

…#27 #28 #29)

Co-authored-by: Taleef7 <89072337+Taleef7@users.noreply.github.com>
Copilot AI changed the title [WIP] Implement student/parent intake flow as per Epic E4 Epic E4: Student/parent tutoring request intake flow Feb 23, 2026
Copilot AI requested a review from Taleef7 February 23, 2026 19:24
@Taleef7
Taleef7 marked this pull request as ready for review February 24, 2026 02:34
Copilot AI review requested due to automatic review settings February 24, 2026 02:34

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

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 requests table 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 check Array.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 has default '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.

Comment thread app/dashboard/requests/new/page.tsx Outdated
Comment on lines +116 to +119
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

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

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.

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

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

Comment thread app/dashboard/requests/new/page.tsx Outdated
exam_board: data.exam_board ?? 'unspecified',
goals: data.goals || null,
timezone: data.timezone,
availability_windows: JSON.stringify(data.availability_windows),

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
availability_windows: JSON.stringify(data.availability_windows),
availability_windows: data.availability_windows,

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

Comment thread lib/validators/request.ts
Comment on lines +16 to +18
availability_windows: z
.string()
.min(10, 'Please describe your availability (at least 10 characters)'),

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

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

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.

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

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.

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.

@Taleef7

Taleef7 commented Feb 24, 2026

Copy link
Copy Markdown
Owner

@copilot apply changes based on the comments in this thread

…availability_windows

Co-authored-by: Taleef7 <89072337+Taleef7@users.noreply.github.com>

Copilot AI commented Feb 24, 2026

Copy link
Copy Markdown
Contributor Author

@copilot apply changes based on the comments in this thread

All three review comments are addressed in commit 5d153a4: duplicate-request guard now blocks submission via window.confirm(), and JSON.stringify() wrapping on availability_windows has been removed.

Copilot AI requested a review from Taleef7 February 24, 2026 02:49
@Taleef7
Taleef7 merged commit d2c1ab5 into main Feb 24, 2026
1 check passed
@Taleef7
Taleef7 deleted the copilot/implement-student-parent-intake-flow branch February 24, 2026 03:02
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