diff --git a/README.md b/README.md index 3bc1365..a946d00 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Out of scope for MVP ├── app/ # Next.js App Router routes (UI) ├── components/ # UI components ├── lib/ # Supabase clients, services, validators -├── middleware.ts # Next.js edge middleware: session refresh + route protection +├── proxy.ts # Next.js edge proxy: session refresh + route protection ├── supabase/ # Migrations, seed data, local Supabase config ├── docs/ # Product + ops + architecture docs │ ├── MVP.md @@ -130,6 +130,7 @@ Open [http://localhost:3000](http://localhost:3000). You'll see the CorvEd landi | WhatsApp CTA button | ✅ `wa.me` deep link with prefilled message (requires `NEXT_PUBLIC_WHATSAPP_NUMBER` env var) | | `POST /api/leads` route | ✅ Server-side validation + Supabase insert via admin client | | `leads` DB migration | ✅ `supabase/migrations/20260223000001_create_leads_table.sql` — RLS: anon insert allowed, auth read/update | +| **Admin: lead queue** | ✅ `app/admin/leads/page.tsx` — review Phase 0 intake records, open WhatsApp, update status, and store private admin notes | | Supabase clients wired up | ✅ `lib/supabase/client.ts`, `server.ts`, `admin.ts` | | **Auth: sign up (email/password)** | ✅ `app/auth/sign-up/page.tsx` — display name, email, password, timezone; min 8-char password | | **Auth: email verification** | ✅ `app/auth/verify/page.tsx` — instructions page; unverified users cannot reach dashboard | @@ -138,7 +139,7 @@ Open [http://localhost:3000](http://localhost:3000). You'll see the CorvEd landi | **Auth: callback handler** | ✅ `app/auth/callback/route.ts` — PKCE code exchange; redirects to profile-setup if profile incomplete | | **Auth: profile setup** | ✅ `app/auth/profile-setup/page.tsx` — display name, WhatsApp number (auto-normalized), timezone (auto-detected) | | **Auth: sign out** | ✅ `app/auth/sign-out/route.ts` — POST clears session, redirects to sign-in | -| **Route protection (middleware)** | ✅ `middleware.ts` — unauthenticated → sign-in for `/dashboard`, `/tutor`, `/admin`; authenticated → dashboard for auth pages | +| **Route protection (proxy)** | ✅ `proxy.ts` — unauthenticated → sign-in for `/dashboard`, `/tutor`, `/admin`; authenticated → dashboard for auth pages | | **Role-aware dashboard redirect** | ✅ `app/dashboard/page.tsx` — admin→`/admin`, tutor→`/tutor`, student/parent stays on dashboard | | **Admin route protection** | ✅ `app/admin/layout.tsx` — verifies `admin` role server-side; non-admins → `/dashboard` | | **Tutor route protection** | ✅ `app/tutor/layout.tsx` — verifies `tutor` or `admin` role; others → `/dashboard` | @@ -205,9 +206,9 @@ Open [http://localhost:3000](http://localhost:3000). You'll see the CorvEd landi | Admin: WhatsApp link on request detail (E11) | ✅ `/admin/requests/[id]` — "Open chat" link next to student's WhatsApp number | | Admin: WhatsApp link on tutor detail (E11) | ✅ `/admin/tutors/[id]` — "Open chat" link next to tutor's WhatsApp number | | **Policies page (E12 T12.1)** | ✅ `app/policies/page.tsx` — public page at `/policies`; covers reschedule (24h cutoff, exceptions), no-show (student/tutor/late-join), refund/expiry (no carryover, admin discretion), package terms (per subject, 60 min, assigned tutor), privacy; linked from landing page footer | -| **Tutor code of conduct (E12 T12.2)** | ✅ `app/tutor/conduct/page.tsx` — public page at `/tutor/conduct`; covers punctuality, session quality, communication, privacy, quality expectations, incidents; acknowledgement checkbox added to tutor profile/application form (required before submit) | -| **Admin: audit log (E12 T12.3)** | ✅ `app/admin/audit/page.tsx` — admin-only; shows recent 200 audit events newest-first; human-readable action labels; actor name, entity type/ID (truncated), details; uses `audit_logs` table (created in E5 migration) | -| **Admin: analytics dashboard (E12 T12.4)** | ✅ `app/admin/analytics/page.tsx` — admin-only; 7 metric cards: active students, active tutors, upcoming sessions (next 7d), missed sessions (last 7d), unmarked sessions (needs follow-up), pending payments, pending tutor approvals; attention metrics highlighted in amber/orange; clickable cards link to relevant admin pages | +| **Tutor code of conduct (E12 T12.2)** | ✅ `app/tutor/conduct/page.tsx` — public page at `/tutor/conduct`; acknowledgement is required on initial tutor signup and again in profile completion | +| **Admin: audit log (E12 T12.3)** | ✅ `app/admin/audit/page.tsx` — admin-only; audit detail writes use `sanitizeAuditDetails()` so notes, Meet links, payment references, WhatsApp/contact fields, and names are redacted before storage | +| **Admin: analytics dashboard (E12 T12.4)** | ✅ `app/admin/analytics/page.tsx` — admin-only; tracks active students/tutors, upcoming/missed/unmarked sessions, pending payments, pending tutors, and new lead intake follow-up | | Area | Status | |---|---| @@ -216,6 +217,7 @@ Open [http://localhost:3000](http://localhost:3000). You'll see the CorvEd landi | WhatsApp CTA button | ✅ `wa.me` deep link with prefilled message (requires `NEXT_PUBLIC_WHATSAPP_NUMBER` env var) | | `POST /api/leads` route | ✅ Server-side validation + Supabase insert via admin client | | `leads` DB migration | ✅ `supabase/migrations/20260223000001_create_leads_table.sql` — RLS: anon insert allowed, auth read/update | +| **Admin: lead queue** | ✅ `app/admin/leads/page.tsx` — review Phase 0 intake records, open WhatsApp, update status, and store private admin notes | | Supabase clients wired up | ✅ `lib/supabase/client.ts`, `server.ts`, `admin.ts` | | **Auth: sign up (email/password)** | ✅ `app/auth/sign-up/page.tsx` — display name, email, password, timezone; min 8-char password | | **Auth: email verification** | ✅ `app/auth/verify/page.tsx` — instructions page; unverified users cannot reach dashboard | @@ -224,7 +226,7 @@ Open [http://localhost:3000](http://localhost:3000). You'll see the CorvEd landi | **Auth: callback handler** | ✅ `app/auth/callback/route.ts` — PKCE code exchange; redirects to profile-setup if profile incomplete | | **Auth: profile setup** | ✅ `app/auth/profile-setup/page.tsx` — display name, WhatsApp number (auto-normalized), timezone (auto-detected) | | **Auth: sign out** | ✅ `app/auth/sign-out/route.ts` — POST clears session, redirects to sign-in | -| **Route protection (middleware)** | ✅ `middleware.ts` — unauthenticated → sign-in for `/dashboard`, `/tutor`, `/admin`; authenticated → dashboard for auth pages | +| **Route protection (proxy)** | ✅ `proxy.ts` — unauthenticated → sign-in for `/dashboard`, `/tutor`, `/admin`; authenticated → dashboard for auth pages | | **Role-aware dashboard redirect** | ✅ `app/dashboard/page.tsx` — admin→`/admin`, tutor→`/tutor`, student/parent stays on dashboard | | **Admin route protection** | ✅ `app/admin/layout.tsx` — verifies `admin` role server-side; non-admins → `/dashboard` | | **Tutor route protection** | ✅ `app/tutor/layout.tsx` — verifies `tutor` or `admin` role; others → `/dashboard` | @@ -341,6 +343,7 @@ Recommended workflow | `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. | | `20260225000002_create_sessions_table.sql` | `sessions` table (match_id FK, scheduled_start/end_utc, status enum, tutor_notes, updated_by_user_id); indexes on (match_id, start_utc) and (status, start_utc); 4 RLS policies (admin all, tutor select, student select via match→request, tutor update own); `increment_sessions_used(p_request_id)` RPC for atomic sessions_used increment; `tutor_update_session(p_session_id, p_status, p_notes)` security-definer RPC. | | `20260225000003_increment_sessions_used_guard.sql` | Adds `sessions_used < sessions_total` safety guard to `increment_sessions_used` RPC — prevents `sessions_used` from exceeding `sessions_total` (over-incrementing); sets safe `search_path`; restricts `EXECUTE` to `service_role` only. | +| `20260426000001_sanitize_session_audit_details.sql` | Replaces `tutor_update_session` so audit details keep status transitions but redact tutor notes from `audit_logs.details`. | > **Supabase Dashboard settings required for auth** (after running migrations): > diff --git a/app/admin/actions.ts b/app/admin/actions.ts index 3e975f8..4c928c2 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -7,7 +7,7 @@ import { createAdminClient } from "@/lib/supabase/admin"; import { requireAdmin } from "@/lib/auth/requireAdmin"; import { revalidatePath } from "next/cache"; import { z } from "zod"; -import { buildAdminUpdateUserProfileAuditEntry } from "@/lib/admin/users"; +import { sanitizeAuditDetails } from "@/lib/audit/sanitize"; const VALID_ROLES = ["student", "parent", "tutor", "admin"] as const; type Role = (typeof VALID_ROLES)[number]; @@ -123,11 +123,13 @@ export async function updateUserProfile(userId: string, formData: FormData) { if (error) throw new Error(`Failed to update profile: ${error.message}`); await admin.from("audit_logs").insert([ - buildAdminUpdateUserProfileAuditEntry({ - actorUserId: adminUserId, - targetUserId: userId, - displayName: parsed.data.display_name, - }), + { + actor_user_id: adminUserId, + action: "admin_update_user_profile", + entity_type: "user_profiles", + entity_id: userId, + details: sanitizeAuditDetails({ display_name: parsed.data.display_name }), + }, ]); revalidatePath("/admin/users"); diff --git a/app/admin/analytics/page.tsx b/app/admin/analytics/page.tsx index 5b340a0..615ccf1 100644 --- a/app/admin/analytics/page.tsx +++ b/app/admin/analytics/page.tsx @@ -57,6 +57,7 @@ export default async function AdminAnalyticsPage() { unmarkedSessions, pendingPayments, pendingTutors, + newLeads, ] = await Promise.all([ // Active students: unique users with active requests admin.from('requests').select('created_by_user_id').eq('status', 'active'), @@ -92,6 +93,8 @@ export default async function AdminAnalyticsPage() { .from('tutor_profiles') .select('tutor_user_id', { count: 'exact', head: true }) .eq('approved', false), + // Lead intake records waiting for first follow-up + admin.from('leads').select('id', { count: 'exact', head: true }).eq('status', 'new'), ]) const firstError = @@ -101,7 +104,8 @@ export default async function AdminAnalyticsPage() { missedSessions.error || unmarkedSessions.error || pendingPayments.error || - pendingTutors.error + pendingTutors.error || + newLeads.error if (firstError) { throw new Error(`Failed to load analytics metrics: ${firstError.message}`) @@ -117,6 +121,7 @@ export default async function AdminAnalyticsPage() { unmarkedSessions: unmarkedSessions.count ?? 0, pendingPayments: pendingPayments.count ?? 0, pendingTutors: pendingTutors.count ?? 0, + newLeads: newLeads.count ?? 0, } return ( @@ -239,6 +244,15 @@ export default async function AdminAnalyticsPage() { href="/admin/tutors" linkLabel="Review tutors →" /> + 0 ? 'attention' : 'normal'} + href="/admin/leads?status=new" + linkLabel="Review leads →" + /> diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 3930f29..bb8d7bd 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -33,6 +33,7 @@ export default async function AdminLayout({ const navLinks = [ { href: '/admin', label: 'Dashboard' }, + { href: '/admin/leads', label: 'Leads' }, { href: '/admin/users', label: 'Users' }, { href: '/admin/requests', label: 'Requests' }, { href: '/admin/payments', label: 'Payments' }, diff --git a/app/admin/leads/actions.ts b/app/admin/leads/actions.ts new file mode 100644 index 0000000..436ef2a --- /dev/null +++ b/app/admin/leads/actions.ts @@ -0,0 +1,61 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { createAdminClient } from '@/lib/supabase/admin' +import { requireAdmin } from '@/lib/auth/requireAdmin' +import { sanitizeAuditDetails } from '@/lib/audit/sanitize' +import { leadStatusUpdateSchema } from '@/lib/validators/lead-admin' + +export async function updateLeadStatus(formData: FormData) { + const adminUserId = await requireAdmin() + const parsed = leadStatusUpdateSchema.safeParse({ + leadId: formData.get('leadId'), + status: formData.get('status'), + admin_notes: formData.get('admin_notes') ?? '', + }) + + if (!parsed.success) { + throw new Error(parsed.error.issues.map((issue) => issue.message).join(', ')) + } + + const admin = createAdminClient() + const { leadId, status, admin_notes } = parsed.data + + const { data: previousLead, error: fetchError } = await admin + .from('leads') + .select('id, status') + .eq('id', leadId) + .maybeSingle() + + if (fetchError) throw new Error(`Failed to load lead: ${fetchError.message}`) + if (!previousLead) throw new Error('Lead not found.') + + const { error } = await admin + .from('leads') + .update({ + status, + admin_notes: admin_notes?.trim() ? admin_notes.trim() : null, + }) + .eq('id', leadId) + + if (error) throw new Error(`Failed to update lead: ${error.message}`) + + const { error: auditError } = await admin.from('audit_logs').insert([ + { + actor_user_id: adminUserId, + action: 'lead_status_updated', + entity_type: 'lead', + entity_id: leadId, + details: sanitizeAuditDetails({ + previous_status: previousLead.status, + status, + admin_notes, + }), + }, + ]) + if (auditError) throw new Error(`Failed to write audit log: ${auditError.message}`) + + revalidatePath('/admin') + revalidatePath('/admin/analytics') + revalidatePath('/admin/leads') +} diff --git a/app/admin/leads/page.tsx b/app/admin/leads/page.tsx new file mode 100644 index 0000000..e89ef3e --- /dev/null +++ b/app/admin/leads/page.tsx @@ -0,0 +1,213 @@ +export const dynamic = 'force-dynamic' + +import Link from 'next/link' +import { createAdminClient } from '@/lib/supabase/admin' +import { WhatsAppLink } from '@/components/WhatsAppLink' +import { leadStatusValues } from '@/lib/validators/lead-admin' +import { updateLeadStatus } from './actions' + +type LeadStatus = (typeof leadStatusValues)[number] + +type LeadRow = { + id: string + full_name: string + whatsapp_number: string + role: string + child_name: string | null + level: string + subject: string + exam_board: string + availability: string + city_timezone: string + goals: string | null + preferred_package: string | null + status: LeadStatus + admin_notes: string | null + created_at: string +} + +const STATUS_LABELS: Record = { + new: 'New', + contacted: 'Contacted', + qualified: 'Qualified', + disqualified: 'Disqualified', +} + +const SUBJECT_LABELS: Record = { + math: 'Math', + physics: 'Physics', + chemistry: 'Chemistry', + biology: 'Biology', + english: 'English', + cs: 'Computer Science', + pak_studies: 'Pakistan Studies', + islamiyat: 'Islamiyat', + urdu: 'Urdu', +} + +function formatLeadDate(value: string) { + return new Date(value).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) +} + +function buildStatusHref(status: LeadStatus | 'all') { + return status === 'all' ? '/admin/leads' : `/admin/leads?status=${status}` +} + +export default async function AdminLeadsPage({ + searchParams, +}: { + searchParams: Promise<{ status?: string }> +}) { + const params = await searchParams + const activeStatus = leadStatusValues.includes(params.status as LeadStatus) + ? (params.status as LeadStatus) + : 'all' + + const admin = createAdminClient() + let query = admin + .from('leads') + .select( + 'id, full_name, whatsapp_number, role, child_name, level, subject, exam_board, availability, city_timezone, goals, preferred_package, status, admin_notes, created_at', + ) + .order('created_at', { ascending: false }) + .limit(100) + + if (activeStatus !== 'all') { + query = query.eq('status', activeStatus) + } + + const { data, error } = await query + if (error) throw new Error(`Failed to load leads: ${error.message}`) + + const leads = (data ?? []) as LeadRow[] + const statusLinks: (LeadStatus | 'all')[] = ['all', ...leadStatusValues] + + return ( +
+
+
+

Leads

+

+ Phase 0 intake records for manual WhatsApp follow-up. +

+
+

+ {leads.length} lead{leads.length !== 1 ? 's' : ''} +

+
+ +
+ {statusLinks.map((status) => { + const isActive = activeStatus === status + return ( + + {status === 'all' ? 'All' : STATUS_LABELS[status]} + + ) + })} +
+ + {leads.length === 0 ? ( +
+

No leads found for this filter.

+
+ ) : ( +
+ {leads.map((lead) => ( +
+
+
+
+

+ {lead.full_name} +

+ + {STATUS_LABELS[lead.status]} + +
+

+ {formatLeadDate(lead.created_at)} · {lead.role} + {lead.child_name ? ` for ${lead.child_name}` : ''} · {lead.city_timezone} +

+
+ +
+ +
+
+
Subject
+
{SUBJECT_LABELS[lead.subject] ?? lead.subject}
+
+
+
Level
+
{lead.level === 'o_levels' ? 'O Levels' : 'A Levels'}
+
+
+
Exam Board
+
{lead.exam_board.replace(/_/g, ' ')}
+
+
+
Package
+
{lead.preferred_package ? `${lead.preferred_package} sessions` : 'Not sure'}
+
+
+ +
+
+

Availability

+

{lead.availability}

+
+
+

Goals

+

{lead.goals || 'No goals provided.'}

+
+
+ +
+ + +