Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand All @@ -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` |
Expand Down Expand Up @@ -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 |
|---|---|
Expand All @@ -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 |
Expand All @@ -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` |
Expand Down Expand Up @@ -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):
>
Expand Down
14 changes: 8 additions & 6 deletions app/admin/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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 }),
},
Comment on lines 125 to +132
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

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

In updateUserProfile, the audit log entry sets actor_user_id to the target userId rather than the admin performing the update. This breaks audit attribution (and conflicts with the existing buildAdminUpdateUserProfileAuditEntry behavior/test in lib/admin/users.ts). Capture the admin id returned by requireAdmin() and use it as actor_user_id (keeping entity_id as the edited profile’s user id).

Copilot uses AI. Check for mistakes.
]);

revalidatePath("/admin/users");
Expand Down
16 changes: 15 additions & 1 deletion app/admin/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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 =
Expand All @@ -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}`)
Expand All @@ -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 (
Expand Down Expand Up @@ -239,6 +244,15 @@ export default async function AdminAnalyticsPage() {
href="/admin/tutors"
linkLabel="Review tutors →"
/>
<MetricCard
label="New Leads"
value={metrics.newLeads}
unit="awaiting follow-up"
icon="☎"
variant={metrics.newLeads > 0 ? 'attention' : 'normal'}
href="/admin/leads?status=new"
linkLabel="Review leads →"
/>
</div>
</section>
</div>
Expand Down
1 change: 1 addition & 0 deletions app/admin/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
61 changes: 61 additions & 0 deletions app/admin/leads/actions.ts
Original file line number Diff line number Diff line change
@@ -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')
}
Loading
Loading