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: 7 additions & 8 deletions app/admin/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +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";

const VALID_ROLES = ["student", "parent", "tutor", "admin"] as const;
type Role = (typeof VALID_ROLES)[number];
Expand Down Expand Up @@ -98,7 +99,7 @@ const updateProfileSchema = z.object({

/** Update a user's display name and WhatsApp number. */
export async function updateUserProfile(userId: string, formData: FormData) {
await requireAdmin();
const adminUserId = await requireAdmin();

const raw = {
display_name: formData.get("display_name") as string,
Expand All @@ -122,13 +123,11 @@ 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([
{
actor_user_id: userId,
action: "admin_update_user_profile",
entity_type: "user_profiles",
entity_id: userId,
details: { display_name: parsed.data.display_name },
},
buildAdminUpdateUserProfileAuditEntry({
actorUserId: adminUserId,
targetUserId: userId,
displayName: parsed.data.display_name,
}),
]);

revalidatePath("/admin/users");
Expand Down
12 changes: 8 additions & 4 deletions app/admin/matches/[id]/MatchActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,10 +260,11 @@ export function EditMatchForm({
<input type="hidden" name="matchId" value={matchId} />

<div>
<label className="block text-sm font-medium text-[#121212]/80">
<label htmlFor="edit-match-meet-link" className="block text-sm font-medium text-[#121212]/80">
Google Meet Link
</label>
<input
id="edit-match-meet-link"
type="url"
name="meetLink"
defaultValue={currentMeetLink ?? ''}
Expand All @@ -273,10 +274,11 @@ export function EditMatchForm({
</div>

<div>
<label className="block text-sm font-medium text-[#121212]/80">
<label htmlFor="edit-match-timezone" className="block text-sm font-medium text-[#121212]/80">
Schedule Timezone
</label>
<input
id="edit-match-timezone"
type="text"
name="timezone"
defaultValue={currentSchedule?.timezone ?? ''}
Expand All @@ -291,8 +293,9 @@ export function EditMatchForm({
</label>
<div className="flex flex-wrap gap-3">
{DAY_OPTIONS.map(({ label, value }) => (
<label key={value} className="flex cursor-pointer items-center gap-1.5 text-sm">
<label htmlFor={`edit-match-day-${value}`} key={value} className="flex cursor-pointer items-center gap-1.5 text-sm">
<input
id={`edit-match-day-${value}`}
type="checkbox"
name="days"
value={value}
Expand All @@ -306,10 +309,11 @@ export function EditMatchForm({
</div>

<div>
<label className="block text-sm font-medium text-[#121212]/80">
<label htmlFor="edit-match-time" className="block text-sm font-medium text-[#121212]/80">
Start Time
</label>
<input
id="edit-match-time"
type="time"
name="time"
defaultValue={currentSchedule?.time ?? ''}
Expand Down
12 changes: 12 additions & 0 deletions app/admin/matches/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,13 @@ type MatchDetail = {

export default async function AdminMatchDetailPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ assigned?: string }>
}) {
const { id } = await params
const { assigned } = await searchParams
const admin = createAdminClient()

const { data: matchData } = await admin
Expand Down Expand Up @@ -160,6 +163,15 @@ export default async function AdminMatchDetailPage({

return (
<div className="mx-auto max-w-3xl space-y-6">
{assigned === '1' && (
<div className="border-2 border-[#121212] bg-white px-6 py-4 text-[#121212]">
<p className="font-semibold">Tutor assigned successfully.</p>
<p className="mt-1 text-sm text-[#121212]/60">
The request is now matched and ready for session generation or follow-up updates.
</p>
</div>
)}

{/* Back link */}
<div className="flex items-center gap-4">
<Link
Expand Down
12 changes: 8 additions & 4 deletions app/admin/requests/[id]/AssignTutorForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,11 +242,12 @@ export function AssignTutorForm({
<input type="hidden" name="tutorUserId" value={selectedTutorId} />

<div>
<label className="block text-sm font-medium text-[#121212]/80">
<label htmlFor="assign-meet-link" className="block text-sm font-medium text-[#121212]/80">
Google Meet Link{' '}
<span className="font-normal text-[#121212]/40">(optional — can be added later)</span>
</label>
<input
id="assign-meet-link"
type="url"
name="meetLink"
placeholder="https://meet.google.com/xxx-xxxx-xxx"
Expand All @@ -255,11 +256,12 @@ export function AssignTutorForm({
</div>

<div>
<label className="block text-sm font-medium text-[#121212]/80">
<label htmlFor="assign-timezone" className="block text-sm font-medium text-[#121212]/80">
Schedule Timezone{' '}
<span className="font-normal text-[#121212]/40">(optional — defaults to student&apos;s)</span>
</label>
<input
id="assign-timezone"
type="text"
name="timezone"
defaultValue={requestTimezone}
Expand All @@ -275,8 +277,9 @@ export function AssignTutorForm({
</label>
<div className="flex flex-wrap gap-3">
{DAY_OPTIONS.map(({ label, value }) => (
<label key={value} className="flex cursor-pointer items-center gap-1.5 text-sm">
<label htmlFor={`assign-day-${value}`} key={value} className="flex cursor-pointer items-center gap-1.5 text-sm">
<input
id={`assign-day-${value}`}
type="checkbox"
name="days"
value={value}
Expand All @@ -289,11 +292,12 @@ export function AssignTutorForm({
</div>

<div>
<label className="block text-sm font-medium text-[#121212]/80">
<label htmlFor="assign-time" className="block text-sm font-medium text-[#121212]/80">
Start Time{' '}
<span className="font-normal text-[#121212]/40">(optional — 24 h format)</span>
</label>
<input
id="assign-time"
type="time"
name="time"
className="mt-1 border border-[#B0B0B0] px-3 py-2 text-sm focus:border-[#1040C0] focus:outline-none"
Expand Down
10 changes: 8 additions & 2 deletions app/admin/requests/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { createAdminClient } from '@/lib/supabase/admin'
import { requireAdmin } from '@/lib/auth/requireAdmin'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import type { Json } from '@/lib/supabase/database.types'
import type { Database } from '@/lib/supabase/database.types'

Expand All @@ -26,6 +27,8 @@ export async function assignTutor({
duration_mins: number
}
}): Promise<{ error?: string; matchId?: string }> {
let assignedMatchId: string | null = null

try {
const adminUserId = await requireAdmin()
const admin = createAdminClient()
Expand Down Expand Up @@ -92,10 +95,13 @@ export async function assignTutor({
revalidatePath(`/admin/requests/${requestId}`)
revalidatePath('/admin/requests')
revalidatePath('/admin/matches')
return { matchId: match.id }
revalidatePath(`/admin/matches/${match.id}`)
assignedMatchId = match.id
} catch (err) {
return { error: err instanceof Error ? err.message : 'An unexpected error occurred.' }
}

redirect(`/admin/matches/${assignedMatchId}?assigned=1`)
}
Comment on lines 29 to 105
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

assignTutor now always redirects on success, but the function signature still suggests it returns { matchId }, and assignedMatchId is typed as string | null when used in the redirect URL. To avoid confusion and accidental /admin/matches/null?... redirects, consider changing the return type to reflect the redirect (e.g. no matchId), and/or move the redirect() call inside the try after asserting match.id is present (or throw if it isn't).

Copilot uses AI. Check for mistakes.

/** Update match.tutor_user_id to a new tutor and write audit log (history kept). */
Expand Down Expand Up @@ -374,4 +380,4 @@ export async function adminUpdateRequestStatus(
} catch (err) {
return { error: err instanceof Error ? err.message : 'An unexpected error occurred.' }
}
}
}
10 changes: 2 additions & 8 deletions app/admin/sessions/SessionFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
'use client'

import { useRef } from 'react'
import { getAdminSessionStatusFilterOptions } from '@/lib/admin/sessions'

// ── Student-picker search bar ─────────────────────────────────────────────────

Expand Down Expand Up @@ -83,14 +84,7 @@ export function SessionViewControls({
{ value: 'past', label: 'Past', count: pastCount },
]

const statuses = [
{ value: '', label: 'All statuses' },
{ value: 'scheduled', label: 'Scheduled' },
{ value: 'done', label: 'Done' },
{ value: 'rescheduled', label: 'Rescheduled' },
{ value: 'no_show_student', label: 'No-show (student)' },
{ value: 'no_show_tutor', label: 'No-show (tutor)' },
]
const statuses = getAdminSessionStatusFilterOptions()

return (
<div className="flex flex-wrap items-center justify-between gap-3">
Expand Down
8 changes: 6 additions & 2 deletions app/admin/sessions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import Link from 'next/link'
import { CopyMessageButton } from '@/components/CopyMessageButton'
import { WhatsAppLink } from '@/components/WhatsAppLink'
import { templates } from '@/lib/whatsapp/templates'
import { resolveAdminSessionStatusFilter } from '@/lib/admin/sessions'

const ADMIN_TIMEZONE = 'Asia/Karachi'
const IMPOSSIBLE_ID = '00000000-0000-0000-0000-000000000000'
Expand All @@ -30,6 +31,7 @@ export default async function AdminSessionsPage({
const childParam = params.child // undefined = no filter, '' = self-request, name = parent's child
const view = (params.view === 'past' ? 'past' : 'upcoming') as SessionView
const filterStatus = params.status ?? ''
const resolvedStatusFilters = resolveAdminSessionStatusFilter(filterStatus)
const studentSearch = params.search ?? ''

const admin = createAdminClient()
Expand Down Expand Up @@ -291,8 +293,10 @@ export default async function AdminSessionsPage({
.in('match_id', safeMatchIds)
.order('scheduled_start_utc', { ascending: true })

if (filterStatus) {
sessionsQuery = sessionsQuery.eq('status', filterStatus as SessionStatus)
if (resolvedStatusFilters.length === 1) {
sessionsQuery = sessionsQuery.eq('status', resolvedStatusFilters[0] as SessionStatus)
} else if (resolvedStatusFilters.length > 1) {
sessionsQuery = sessionsQuery.in('status', resolvedStatusFilters)
}

const { data: sessionsData } = await sessionsQuery
Expand Down
16 changes: 4 additions & 12 deletions app/admin/users/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { WhatsAppLink } from '@/components/WhatsAppLink'
import { AdminPagination, PAGE_SIZE } from '@/components/AdminPagination'
import { UserFilters } from './UserFilters'
import { DeleteUserButton } from './DeleteUserButton'
import { getAdminUserPaymentBadge, getHighestPriorityPaymentStatus } from '@/lib/admin/users'

const ALL_ROLES = ['student', 'parent', 'tutor', 'admin'] as const
type Role = (typeof ALL_ROLES)[number]
Expand Down Expand Up @@ -163,15 +164,11 @@ export default async function AdminUsersPage({
}
}

// Best payment status per user: verified > pending > rejected
const PAYMENT_RANK: Record<string, number> = { verified: 3, pending: 2, rejected: 1 }
const paymentByUser = new Map<string, string>()
for (const pay of userPayments ?? []) {
const uid = pay.payer_user_id as string
const existing = paymentByUser.get(uid)
const existingRank = existing ? (PAYMENT_RANK[existing] ?? 0) : 0
const newRank = PAYMENT_RANK[pay.status as string] ?? 0
if (newRank > existingRank) paymentByUser.set(uid, pay.status as string)
const nextStatus = getHighestPriorityPaymentStatus([paymentByUser.get(uid), pay.status as string])
if (nextStatus) paymentByUser.set(uid, nextStatus)
}

const filtersActive = !!(activeSearch || activeLevel || activeSubject || activePayment)
Expand Down Expand Up @@ -412,12 +409,7 @@ function RequestStatusBadge({ status }: { status: string }) {
}

function PaymentBadge({ status }: { status: string }) {
const map: Record<string, { label: string; cls: string }> = {
verified: { label: 'Verified', cls: 'bg-green-100 text-green-800 border border-green-300' },
pending: { label: 'Pending', cls: 'bg-yellow-100 text-yellow-800 border border-yellow-300' },
rejected: { label: 'Rejected', cls: 'bg-red-100 text-red-800 border border-red-300' },
}
const cfg = map[status] ?? { label: status, cls: 'bg-[#E0E0E0] text-[#121212]/60' }
const cfg = getAdminUserPaymentBadge(status)
return (
<span
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${cfg.cls}`}
Expand Down
44 changes: 37 additions & 7 deletions app/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { NextResponse, type NextRequest } from 'next/server'
import { safeNext } from '@/lib/auth/utils'
import { createAdminClient } from '@/lib/supabase/admin'
import { safeNext, shouldPromoteOAuthParentSignup } from '@/lib/auth/utils'

export const dynamic = 'force-dynamic'

export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url)
const { searchParams, origin } = request.nextUrl
const code = searchParams.get('code')
const next = safeNext(searchParams.get('next'))
const flow = searchParams.get('flow')
const accountType = searchParams.get('account_type')

if (code) {
const cookieStore = await cookies()
Expand Down Expand Up @@ -40,11 +43,38 @@ export async function GET(request: NextRequest) {
} = await supabase.auth.getUser()

if (user) {
const { data: profile } = await supabase
.from('user_profiles')
.select('whatsapp_number')
.eq('user_id', user.id)
.single()
const [{ data: profile }, { data: roleRows }] = await Promise.all([
supabase
.from('user_profiles')
.select('primary_role, whatsapp_number, created_at')
.eq('user_id', user.id)
.single(),
supabase.from('user_roles').select('role').eq('user_id', user.id),
])

if (
profile &&
shouldPromoteOAuthParentSignup({
flow,
accountType,
primaryRole: profile.primary_role,
assignedRoles: (roleRows ?? []).map((row) => row.role),
whatsappNumber: profile.whatsapp_number,
profileCreatedAt: profile.created_at,
})
) {
const admin = createAdminClient()
const [{ error: profileUpdateError }, { error: parentRoleError }, { error: removeStudentError }] =
await Promise.all([
admin.from('user_profiles').update({ primary_role: 'parent' }).eq('user_id', user.id),
admin.from('user_roles').upsert({ user_id: user.id, role: 'parent' }),
admin.from('user_roles').delete().eq('user_id', user.id).eq('role', 'student'),
])

if (profileUpdateError || parentRoleError || removeStudentError) {
return NextResponse.redirect(`${origin}/auth/sign-in?error=auth_callback_failed`)
}
}

// New users who haven't set their WhatsApp number yet go to profile-setup
if (!profile?.whatsapp_number) {
Expand Down
11 changes: 4 additions & 7 deletions app/auth/sign-in/SignInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { z } from 'zod'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { safeNext } from '@/lib/auth/utils'
import { buildAuthCallbackUrl, safeNext } from '@/lib/auth/utils'
import {
BauhausLogo,
BauhausLabel,
Expand Down Expand Up @@ -58,14 +58,11 @@ export function SignInForm() {
async function signInWithGoogle() {
setGoogleLoading(true)
const supabase = createClient()
const baseRedirect = `${window.location.origin}/auth/callback`
const redirectTo =
next !== '/dashboard'
? `${baseRedirect}?next=${encodeURIComponent(next)}`
: baseRedirect
await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo },
options: {
redirectTo: buildAuthCallbackUrl(window.location.origin, { next }),
},
})
}

Expand Down
Loading
Loading