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
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 E4)
### What the app can do right now (after E5)

| Area | Status |
|---|---|
Expand Down Expand Up @@ -149,13 +149,21 @@ Open [http://localhost:3000](http://localhost:3000). You'll see the CorvEd landi
| **DB: handle_new_user() trigger** | ✅ Auto-creates profile + `student` role on every signup |
| **DB: helper functions** | ✅ `has_role()`, `is_admin()`, `is_tutor()` — used in RLS policies |
| **DB: leads admin RLS** | ✅ `supabase/migrations/20260223000005_leads_admin_rls.sql` — admin-role users can read/update leads |
| **Student dashboard** | ✅ `app/dashboard/page.tsx` — lists all requests with status badges; "New Request" CTA |
| **Student dashboard** | ✅ `app/dashboard/page.tsx` — lists all requests with status badges; "New Request" CTA; package summary cards per request |
| **Tutoring request form** | ✅ `app/dashboard/requests/new/page.tsx` — React Hook Form + Zod; level, subject (from DB), exam board, availability, timezone (pre-filled), goals, preferred start date; duplicate request warning |
| **Request confirmation page** | ✅ `app/dashboard/requests/[id]/page.tsx` — read-only summary, status badge, status-aware "what's next" banner, "Select Package" CTA |
| **Request confirmation page** | ✅ `app/dashboard/requests/[id]/page.tsx` — read-only summary, status badge, status-aware "what's next" banner, "Select Package" CTA (links with requestId) |
| **DB: requests table + RLS** | ✅ `supabase/migrations/20260223000007_create_requests_table.sql` — full schema, indexes, updated_at trigger, 4 RLS policies (insert self, select creator/admin, update creator limited, admin update) |
| **Request status utilities** | ✅ `lib/utils/request.ts` — `STATUS_LABELS` + `STATUS_COLOURS` for all 7 request statuses |
| **Request Zod schema** | ✅ `lib/validators/request.ts` — validates all request fields |
| Packages, sessions | 🚧 Coming in E5–E10 |
| **Package selection page** | ✅ `app/dashboard/packages/new/page.tsx` — 3 package tier cards (8/12/20 sessions), PKR pricing, policy notes, creates package + payment rows, advances request to `payment_pending` |
| **Package payment page** | ✅ `app/dashboard/packages/[id]/page.tsx` — bank transfer instructions with personalised reference, optional proof upload (Supabase Storage), optional transaction reference, payment status display |
| **Package summary card** | ✅ `components/dashboards/PackageSummary.tsx` — shows package tier, month window, sessions remaining, progress bar; handles pending/active/expired states |
| **Admin: payments list** | ✅ `app/admin/payments/page.tsx` — lists payments with filter (pending/paid/rejected/all), student name, subject, tier, amount, date, proof indicator |
| **Admin: mark payment paid** | ✅ Updates `payments.status → paid`, `packages.status → active`, `requests.status → ready_to_match`, writes audit log |
| **Admin: mark payment rejected** | ✅ Updates `payments.status → rejected` with optional rejection note, writes audit log |
| **DB: packages + payments tables** | ✅ `supabase/migrations/20260224000001_create_packages_payments.sql` — packages (tier_sessions 8/12/20, start/end date, sessions_total/used, status), payments (amount_pkr, method, reference, proof_path, rejection_note, verified_by/at), audit_logs; all with RLS |
| **Pricing config** | ✅ `lib/config/pricing.ts` — `PACKAGES` array (8/12/20 tiers, PKR prices, typicalFrequency) + `PAYMENT_INSTRUCTIONS` (bank details, reference format) |
| Sessions | 🚧 Coming in E6–E10 |

---

Expand Down Expand Up @@ -202,11 +210,13 @@ Recommended workflow
| `20260223000005_leads_admin_rls.sql` | Adds admin-role RLS policies to `leads` table (now that `is_admin()` exists). |
| `20260223000006_user_profiles_insert_rls.sql` | Adds INSERT policy on `user_profiles` so authenticated users can upsert their own row during profile setup (safety net if trigger row is absent). |
| `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. |

> **Supabase Dashboard settings required for auth** (after running migrations):
>
> - **Auth → Settings**: enable email confirmations; set Site URL to your domain; add `http://localhost:3000/auth/callback` to Redirect URLs.
> - **Auth → Providers → Google**: enable Google OAuth with credentials from [Google Cloud Console](https://console.cloud.google.com). Authorized redirect URI: `https://<your-supabase-ref>.supabase.co/auth/v1/callback`.
> - **Storage → New Bucket**: create a bucket named `payment-proofs` with **Public: No** (private). This is required for payment proof uploads in E5.

## Operational model

Expand Down
6 changes: 6 additions & 0 deletions app/admin/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export default async function AdminLayout({
>
Requests
</Link>
<Link
href="/admin/payments"
className="hover:text-zinc-900 dark:hover:text-zinc-100"
>
Payments
</Link>
</nav>
</div>
<form action="/auth/sign-out" method="post">
Expand Down
94 changes: 94 additions & 0 deletions app/admin/payments/PaymentActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// E5 S5.2: Client component for admin payment action buttons with error feedback
// Addresses review comment: server action forms should display errors to admin

'use client'

import { useActionState } from 'react'
import { markPaymentPaid, markPaymentRejected } from './actions'

type ActionResult = { error?: string } | undefined

async function markPaidAction(
_prev: ActionResult,
formData: FormData,
): Promise<ActionResult> {
try {
const paymentId = formData.get('paymentId') as string
const packageId = formData.get('packageId') as string
const requestId = formData.get('requestId') as string
await markPaymentPaid(paymentId, packageId, requestId)
} catch (err) {
return { error: err instanceof Error ? err.message : 'An unexpected error occurred.' }
}
}

async function markRejectedAction(
_prev: ActionResult,
formData: FormData,
): Promise<ActionResult> {
try {
const paymentId = formData.get('paymentId') as string
const note = formData.get('note') as string
await markPaymentRejected(paymentId, note)
} catch (err) {
return { error: err instanceof Error ? err.message : 'An unexpected error occurred.' }
}
}

export function MarkPaidForm({
paymentId,
packageId,
requestId,
}: {
paymentId: string
packageId: string
requestId: string
}) {
const [state, formAction, isPending] = useActionState(markPaidAction, undefined)

return (
<form action={formAction}>
<input type="hidden" name="paymentId" value={paymentId} />
<input type="hidden" name="packageId" value={packageId} />
<input type="hidden" name="requestId" value={requestId} />
{state?.error && (
<p className="mb-1 text-xs text-red-600 dark:text-red-400">{state.error}</p>
)}
<button
type="submit"
disabled={isPending}
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-green-700 disabled:opacity-60"
>
{isPending ? 'Processing…' : '✅ Mark Paid'}
</button>
</form>
)
}

export function RejectForm({ paymentId }: { paymentId: string }) {
const [state, formAction, isPending] = useActionState(markRejectedAction, undefined)

return (
<div>
{state?.error && (
<p className="mb-1 text-xs text-red-600 dark:text-red-400">{state.error}</p>
)}
<form action={formAction} className="flex items-center gap-2">
<input type="hidden" name="paymentId" value={paymentId} />
<input
type="text"
name="note"
placeholder="Rejection note (optional)"
className="rounded-lg border border-zinc-300 px-2 py-1 text-xs shadow-sm focus:border-red-400 focus:outline-none dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100"
/>
<button
type="submit"
disabled={isPending}
className="rounded-lg bg-red-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-60"
>
{isPending ? 'Processing…' : '❌ Reject'}
</button>
</form>
</div>
)
}
162 changes: 162 additions & 0 deletions app/admin/payments/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// E5 T5.3 S5.2: Admin payment server actions — mark paid / rejected
// Closes #35 #32

'use server'

import { createAdminClient } from '@/lib/supabase/admin'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

async function requireAdmin(): Promise<string> {
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
}

export async function markPaymentPaid(
paymentId: string,
packageId: string,
requestId: string
) {
const adminUserId = await requireAdmin()
const admin = createAdminClient()

// Only update if payment is currently pending (prevents double-processing)
const { data: updatedPayments, error: paymentError } = await admin
.from('payments')
.update({
status: 'paid',
verified_by_user_id: adminUserId,
verified_at: new Date().toISOString(),
})
.eq('id', paymentId)
.eq('status', 'pending')
.select()

if (paymentError) throw new Error(`Failed to update payment: ${paymentError.message}`)
if (!updatedPayments || updatedPayments.length === 0) {
throw new Error('Payment is not in pending status — no update applied.')
}

const { error: pkgError } = await admin
.from('packages')
.update({ status: 'active' })
.eq('id', packageId)

if (pkgError) {
// Best-effort rollback: revert payment to pending
const { error: rollbackErr } = await admin
.from('payments')
.update({ status: 'pending', verified_by_user_id: null, verified_at: null })
.eq('id', paymentId)
if (rollbackErr) {
console.error('Rollback failed for payment', paymentId, rollbackErr.message)
}
throw new Error(`Failed to activate package: ${pkgError.message}`)
}

const { error: reqError } = await admin
.from('requests')
.update({ status: 'ready_to_match' })
.eq('id', requestId)

if (reqError) {
// Best-effort rollback: revert package and payment
const { error: rollbackPkgErr } = await admin
.from('packages')
.update({ status: 'pending' })
.eq('id', packageId)
if (rollbackPkgErr) {
console.error('Rollback failed for package', packageId, rollbackPkgErr.message)
}
const { error: rollbackPayErr } = await admin
.from('payments')
.update({ status: 'pending', verified_by_user_id: null, verified_at: null })
.eq('id', paymentId)
if (rollbackPayErr) {
console.error('Rollback failed for payment', paymentId, rollbackPayErr.message)
}
throw new Error(`Failed to advance request: ${reqError.message}`)
}

const { error: auditError } = await admin.from('audit_logs').insert([
{
actor_user_id: adminUserId,
action: 'payment_marked_paid',
entity_type: 'payment',
entity_id: paymentId,
details: { package_id: packageId, request_id: requestId },
},
])
if (auditError) {
console.error('Audit log insert failed (payment_marked_paid):', auditError.message)
}

revalidatePath('/admin/payments')
}

export async function markPaymentRejected(
paymentId: string,
rejectionNote: string
) {
const adminUserId = await requireAdmin()
const admin = createAdminClient()

// Only update if payment is currently pending (prevents double-processing)
const { data: updatedPayments, error } = await admin
.from('payments')
.update({
status: 'rejected',
rejection_note: rejectionNote || null,
verified_by_user_id: adminUserId,
verified_at: new Date().toISOString(),
})
.eq('id', paymentId)
.eq('status', 'pending')
.select()

if (error) throw new Error(`Failed to reject payment: ${error.message}`)
if (!updatedPayments || updatedPayments.length === 0) {
throw new Error('Payment is not in pending status — no update applied.')
}

const { error: auditError } = await admin.from('audit_logs').insert([
{
actor_user_id: adminUserId,
action: 'payment_marked_rejected',
entity_type: 'payment',
entity_id: paymentId,
details: { rejection_note: rejectionNote },
},
])
if (auditError) {
console.error('Audit log insert failed (payment_marked_rejected):', auditError.message)
}

revalidatePath('/admin/payments')
}

export async function getPaymentProofUrl(proofPath: string): Promise<string | null> {
await requireAdmin()
const admin = createAdminClient()

const { data } = await admin.storage
.from('payment-proofs')
.createSignedUrl(proofPath, 300)

return data?.signedUrl ?? null
}
Loading