diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..bd330a5 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,25 @@ +# Copy to .env.local and fill in. + +# --- Supabase --- +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +# Service-role key — bypasses RLS. Used server-only (src/lib/server/serviceRole.ts). +SUPABASE_SECRET_KEY= + +# --- Auth --- +NEXT_PUBLIC_AUTH_CALLBACK_URL= + +# --- Feature flags --- +NEXT_PUBLIC_ENABLE_MATCH_REQUESTS= + +# --- Matching algorithm --- +# Server-only — never exposed to the browser. The admin "Run Matching" page +# (src/app/admin/matching) calls this through this app's own /api/admin/matching/* +# routes, which proxy to it; the browser never talks to it directly. +# The algorithm itself runs as a separate AWS Lambda function (pickup-matching), +# not in this Netlify app. Its code and deploy steps live outside this repo. +# The Lambda's Function URL, e.g. https://abc123.lambda-url.us-west-1.on.aws +# (or http://localhost:8000 when running the matching service locally with uvicorn). +ALGORITHM_SERVICE_URL= +# Must match SERVICE_API_KEY set on the Lambda function. +ALGORITHM_SERVICE_API_KEY= diff --git a/frontend/src/app/admin/matching/page.tsx b/frontend/src/app/admin/matching/page.tsx new file mode 100644 index 0000000..d165487 --- /dev/null +++ b/frontend/src/app/admin/matching/page.tsx @@ -0,0 +1,5 @@ +import MatchingAlgorithm from '@/components/admin/MatchingAlgorithm' + +export default async function MatchingPage() { + return +} diff --git a/frontend/src/app/api/admin/matching/run/route.ts b/frontend/src/app/api/admin/matching/run/route.ts new file mode 100644 index 0000000..852437c --- /dev/null +++ b/frontend/src/app/api/admin/matching/run/route.ts @@ -0,0 +1,75 @@ +import { getSubsidizedPeriodRanges } from '@/config/servicePeriodHelpers' +import { badRequestJson, withAdminRoute } from '@/lib/server/auth' +import { NextResponse } from 'next/server' + +export const POST = withAdminRoute(async (request) => { + // Function URLs are shown with a trailing slash; strip it so `${serviceUrl}/run` + // doesn't become `//run`, which the service 404s. + const serviceUrl = process.env.ALGORITHM_SERVICE_URL?.replace(/\/+$/, '') + + if (!serviceUrl) { + return NextResponse.json( + { error: 'ALGORITHM_SERVICE_URL is not configured on the server.' }, + { status: 503 }, + ) + } + + let body: any + try { + body = await request.json() + } catch { + return badRequestJson('Invalid JSON body.') + } + + const { daysAhead, daysAheadStart } = body || {} + if (daysAhead !== undefined && typeof daysAhead !== 'number') { + return badRequestJson('daysAhead must be a number.') + } + if (daysAheadStart !== undefined && typeof daysAheadStart !== 'number') { + return badRequestJson('daysAheadStart must be a number.') + } + + const apiKey = process.env.ALGORITHM_SERVICE_API_KEY + + try { + // /run matches riders from the database and commits the result to + // Supabase for real (Rides/Matches, voucher consumption) — unlike the + // old /match-based preview, this writes production data. + const response = await fetch(`${serviceUrl}/run`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { 'X-API-Key': apiKey } : {}), + }, + body: JSON.stringify({ + days_ahead: daysAhead, + days_ahead_start: daysAheadStart, + subsidized_periods: getSubsidizedPeriodRanges(), + }), + signal: AbortSignal.timeout(60000), + }) + + const data = await response.json().catch(() => null) + + if (!response.ok) { + return NextResponse.json( + { + // FastAPI returns validation errors (422) as a list; only pass strings through. + error: + typeof data?.detail === 'string' + ? data.detail + : 'The algorithm service rejected the request.', + }, + { status: response.status }, + ) + } + + return NextResponse.json(data) + } catch (error) { + console.error('Algorithm service run failed:', error) + return NextResponse.json( + { error: 'Failed to reach the algorithm service.' }, + { status: 502 }, + ) + } +}) diff --git a/frontend/src/app/api/admin/matching/status/route.ts b/frontend/src/app/api/admin/matching/status/route.ts new file mode 100644 index 0000000..12e0363 --- /dev/null +++ b/frontend/src/app/api/admin/matching/status/route.ts @@ -0,0 +1,46 @@ +import { withAdminRoute } from '@/lib/server/auth' +import { NextResponse } from 'next/server' + +export const GET = withAdminRoute(async () => { + const serviceUrl = process.env.ALGORITHM_SERVICE_URL + + if (!serviceUrl) { + return NextResponse.json({ + configured: false, + reachable: false, + message: 'ALGORITHM_SERVICE_URL is not set on the server.', + }) + } + + const apiKey = process.env.ALGORITHM_SERVICE_API_KEY + + try { + const response = await fetch(serviceUrl, { + headers: apiKey ? { 'X-API-Key': apiKey } : undefined, + signal: AbortSignal.timeout(5000), + }) + + if (!response.ok) { + return NextResponse.json({ + configured: true, + reachable: false, + message: `Algorithm service responded with ${response.status}.`, + }) + } + + const data = await response.json() + + return NextResponse.json({ + configured: true, + reachable: true, + supabaseConfigured: Boolean(data?.supabase_configured), + }) + } catch (error) { + console.error('Algorithm service unreachable:', error) + return NextResponse.json({ + configured: true, + reachable: false, + message: 'Could not reach the algorithm service.', + }) + } +}) diff --git a/frontend/src/components/admin/AdminDashboard.tsx b/frontend/src/components/admin/AdminDashboard.tsx index 5d2cdca..0ea137f 100644 --- a/frontend/src/components/admin/AdminDashboard.tsx +++ b/frontend/src/components/admin/AdminDashboard.tsx @@ -384,6 +384,11 @@ export default function AdminDashboard() { route="/admin/groups" variant="cta" /> + {/* Match no-shows — warm orange–yellow banner (Cancellations keeps red–orange) */} diff --git a/frontend/src/components/admin/MatchingAlgorithm.tsx b/frontend/src/components/admin/MatchingAlgorithm.tsx new file mode 100644 index 0000000..bf2e02d --- /dev/null +++ b/frontend/src/components/admin/MatchingAlgorithm.tsx @@ -0,0 +1,466 @@ +'use client' + +import SimpleRedirectButton from '@/components/buttons/SimpleRedirectButton' +import { postJson, requestJson } from '@/utils/api' +import { useEffect, useState } from 'react' + +interface RideMatchServiceStatus { + configured: boolean + reachable: boolean + supabaseConfigured?: boolean + message?: string +} + +interface RideMatchRider { + user_id: string + flight_id: number + name: string | null +} + +interface RideMatchGroup { + ride_id: number + bucket_key: string + date: string + num_riders: number + suggested_time: string + earliest_time: string + latest_time: string + considered_bags: number + bags_total: number + riders: RideMatchRider[] + subsidized: boolean + ride_type: string | null + uber_type: string | null +} + +interface UnmatchedRider { + user_id: string + flight_id: number + name: string | null + airport: string + to_airport: boolean + date: string + earliest_time: string + latest_time: string + bucket_key: string + reason: string + details: Record +} + +interface CommitResult { + run_id: string + groups_inserted: number + matches_inserted: number + matched_flights_updated: number + unmatched_flights_updated: number + group_vouchers_used: number + contingency_vouchers_used: number + connect_cleanup_flights: number + connect_cleanup_rides_touched: number + idempotent_replay: boolean +} + +interface RideMatchResponse { + status: string + run_id: string | null + commit: CommitResult | null + summary: { + buckets: number + groups: number + matched_riders: number + unmatched_riders: number + } + matches: RideMatchGroup[] + unmatched: UnmatchedRider[] +} + +const REASON_LABELS: Record = { + no_time_overlap: "No time overlap with other riders' windows", + terminal_mismatch: 'Terminal mismatch with nearby riders', + bag_capacity: 'Group would exceed bag capacity', + dominated_by_better_pair: 'A better-matching pair took priority', + singleton_bucket: + 'No other riders in the same airport/direction/school group', + unknown: 'No reason recorded', +} + +const humanizeReason = (reason: string): string => + REASON_LABELS[reason] ?? reason.replaceAll('_', ' ') + +const formatTime = (value: string): string => { + if (!value) return '—' + const [h, m] = value.split(':') + const hour = parseInt(h, 10) + if (Number.isNaN(hour)) return value + const period = hour >= 12 ? 'PM' : 'AM' + const hour12 = hour % 12 === 0 ? 12 : hour % 12 + return `${hour12}:${m} ${period}` +} + +const spinner = ( + + + + +) + +export default function MatchingAlgorithm() { + const [status, setStatus] = useState(null) + const [statusLoading, setStatusLoading] = useState(true) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + const [result, setResult] = useState(null) + + const [daysAheadStart, setDaysAheadStart] = useState('') + const [daysAhead, setDaysAhead] = useState('10') + + useEffect(() => { + const loadStatus = async () => { + try { + const data = await requestJson( + '/api/admin/matching/status', + ) + setStatus(data) + } catch (err) { + console.error('Error checking algorithm service status:', err) + } finally { + setStatusLoading(false) + } + } + loadStatus() + }, []) + + const handleRun = async () => { + const fromLabel = daysAheadStart + ? `${daysAheadStart} days from today` + : 'tomorrow' + if ( + !confirm( + `Run matching for riders from ${fromLabel} through ${daysAhead} days from today? This writes real ride groups to the database and consumes vouchers — it cannot be previewed or undone from here.`, + ) + ) { + return + } + + setError('') + setResult(null) + + try { + setIsLoading(true) + const data = await postJson( + '/api/admin/matching/run', + { + daysAhead: daysAhead ? Number(daysAhead) : undefined, + daysAheadStart: daysAheadStart ? Number(daysAheadStart) : undefined, + }, + ) + setResult(data) + } catch (err) { + console.error('Error running matching algorithm:', err) + setError( + err instanceof Error ? err.message : 'Matching algorithm run failed', + ) + } finally { + setIsLoading(false) + } + } + + const canRun = + !!status?.configured && !!status?.reachable && !!status?.supabaseConfigured + + const fromDescription = daysAheadStart ? `${daysAheadStart}` : 'tomorrow (0)' + + if (statusLoading) { + return ( +
+
+
+
+
+ + Checking algorithm service... + +
+
+
+ ) + } + + return ( +
+
+
+

Run Matching

+ +
+ +

+ Match all the riders from {fromDescription} to {daysAhead} days ago. +

+ + {status && !status.configured && ( +
+ The algorithm service isn't configured on the server yet. Set{' '} + ALGORITHM_SERVICE_URL and redeploy. +
+ )} + {status?.configured && !status.reachable && ( +
+ {status.message || 'Could not reach the algorithm service.'} +
+ )} + {status?.configured && + status.reachable && + !status.supabaseConfigured && ( +
+ Connected to the algorithm service, but it has no database + credentials configured, so there are no riders to match. +
+ )} + +
+
+

Match & commit

+

+ Run matching algorithm for all users between these two dates. +

+
+ +
+
+
+ + setDaysAheadStart(e.target.value)} + placeholder="tomorrow" + className="w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-gray-900 focus:border-teal-500 focus:outline-none focus:ring-2 focus:ring-teal-500" + /> +
+
+ + setDaysAhead(e.target.value)} + className="w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-gray-900 focus:border-teal-500 focus:outline-none focus:ring-2 focus:ring-teal-500" + /> +
+
+ + + + {error &&

{error}

} +
+
+ + {result && ( +
+ {!result.run_id ? ( +
+ No unmatched riders found in this window — nothing to commit. +
+ ) : ( +
+
+

+ Committed{' '} + {result.commit?.idempotent_replay && + '(already committed — safe replay)'} +

+
+
+ {[ + ['Groups inserted', result.commit?.groups_inserted], + ['Matches inserted', result.commit?.matches_inserted], + ['Flights matched', result.commit?.matched_flights_updated], + [ + 'Flights left unmatched', + result.commit?.unmatched_flights_updated, + ], + ['Group vouchers used', result.commit?.group_vouchers_used], + [ + 'Contingency vouchers used', + result.commit?.contingency_vouchers_used, + ], + ].map(([label, value]) => ( +
+

+ {value ?? 0} +

+

{label}

+
+ ))} +
+
+ )} + +
+
+

+ Committed groups ({result.matches.length}) +

+
+
+ + + + + + + + + + + + + + + {result.matches.map((m) => ( + + + + + + + + + + + ))} + {result.matches.length === 0 && ( + + + + )} + +
#BucketDatePickup timeRidersBagsUber typeSubsidized
{m.ride_id}{m.bucket_key}{m.date} + {formatTime( + m.suggested_time.includes('T') + ? m.suggested_time.split('T')[1] + : m.suggested_time, + )} + + {m.riders.map((r) => r.name || r.user_id).join(', ')} + {m.bags_total} + {m.ride_type || m.uber_type || '—'} + + {m.subsidized ? ( + + Yes + + ) : ( + 'No' + )} +
+ No groups formed. +
+
+
+ +
+
+

+ Left unmatched ({result.unmatched.length}) +

+
+
+ + + + + + + + + + + + {result.unmatched.map((r) => ( + + + + + + + + ))} + {result.unmatched.length === 0 && ( + + + + )} + +
RiderAirportDateWindowWhy unmatched
{r.name || r.user_id} + {r.airport} {r.to_airport ? '(to)' : '(from)'} + {r.date} + {formatTime(r.earliest_time)} –{' '} + {formatTime(r.latest_time)} + + {humanizeReason(r.reason)} +
+ Everyone was matched. +
+
+
+
+ )} +
+
+ ) +} diff --git a/frontend/src/config/servicePeriodHelpers.test.ts b/frontend/src/config/servicePeriodHelpers.test.ts index cd221c9..a972209 100644 --- a/frontend/src/config/servicePeriodHelpers.test.ts +++ b/frontend/src/config/servicePeriodHelpers.test.ts @@ -10,6 +10,7 @@ import { formatSubsidizedWindowsForDisplay, getAllowedDirectionsForDate, getDerivedCoveredDates, + getSubsidizedPeriodRanges, isDateCovered, isDirectionAllowedForDate, isFlightPastDeadline, @@ -370,3 +371,31 @@ describe('formatSubsidizedWindowsForDisplay', () => { ]) }) }) + +describe('getSubsidizedPeriodRanges', () => { + it('lists every allowed subsidized range with its direction', () => { + const expected = SERVICE_PERIODS.flatMap((period) => + (['outbound', 'inbound'] as const) + .filter( + (direction) => + period.subsidized[direction] && + period.allowedDirections.includes(direction), + ) + .map((direction) => ({ + direction, + start: period.subsidized[direction]!.start, + end: period.subsidized[direction]!.end, + })), + ) + + expect(getSubsidizedPeriodRanges()).toEqual(expected) + }) + + it('agrees with isDateCovered on every range boundary', () => { + for (const range of getSubsidizedPeriodRanges()) { + const toAirport = range.direction === 'outbound' + expect(isDateCovered(range.start, toAirport)).toBe(true) + expect(isDateCovered(range.end, toAirport)).toBe(true) + } + }) +}) diff --git a/frontend/src/config/servicePeriodHelpers.ts b/frontend/src/config/servicePeriodHelpers.ts index 08813bc..5120125 100644 --- a/frontend/src/config/servicePeriodHelpers.ts +++ b/frontend/src/config/servicePeriodHelpers.ts @@ -63,6 +63,30 @@ export function getDerivedCoveredDates(): { } } +export interface SubsidizedPeriodRange extends DateRange { + direction: TripDirection +} + +/** + * Every subsidized range as a flat list, in the shape the matching Lambda + * expects as `subsidized_periods` — so the matcher subsidizes exactly the + * dates students are shown here, instead of keeping its own list. + */ +export function getSubsidizedPeriodRanges(): SubsidizedPeriodRange[] { + const ranges: SubsidizedPeriodRange[] = [] + + for (const period of SERVICE_PERIODS) { + for (const direction of ['outbound', 'inbound'] as const) { + const range = period.subsidized[direction] + if (range && period.allowedDirections.includes(direction)) { + ranges.push({ direction, start: range.start, end: range.end }) + } + } + } + + return ranges +} + export function isDateCovered(date: string, toAirport: boolean): boolean { if (!date) { return false