Files
school_compare/nextjs-app/lib/api.ts
T
TudorandClaude Opus 5 1d8858fbda
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m2s
chore: remove the code the legacy CSV importer left behind
`backend/migration.py` and `scripts/migrate_csv_to_db.py` import `School`,
`SchoolResult`, `init_db` and `set_db_schema_version` — names that no longer
exist. `scripts/geocode_schools.py` imports the same removed ORM model. None of
the three can be imported against the current backend, so they were not dormant
utilities anyone could fall back on; they were files that would fail on the
first line. `backend/version.py` existed only to hand `SCHEMA_VERSION` to that
importer, and the FastAPI lifespan performs no version-triggered import.

Three symbols go with them, each confirmed to have no caller: the unvectorised
`haversine_distance`, superseded by the inline NumPy calculation in search;
`fetcher`, an SWR helper for a dependency this project does not install; and
`kmToMiles`. `calculateDistance` stays — CutoffMapPanel uses it.

Two comments pointed at `migrate_csv_to_db.py --drop` to explain why Payload
owns its own schema. The reason survives the script: blog content must stay
clear of the school marts and Airflow's metadata. Reworded rather than deleted,
so the constraint keeps its justification.

docs/LEGACY_CODE.md records what was removed and where to find it in history. It
also records what was deliberately *not* removed, which is the more useful half:
unused UI components awaiting a design decision, manual data utilities whose
operators a repository search cannot see, and fallbacks that look obsolete but
are load-bearing — `data_loader.py`'s older-mart branches, the generated GIAS
dictionary copies, and the `legacy`-named dbt models that annual DAG selectors
explicitly include. A zero-import count is evidence, not a verdict.

The scripts that fetch DfE CSVs are marked historical and kept, pending
confirmation that nobody runs them by hand.

Checked: 190 backend tests, 429 frontend tests, `tsc --noEmit` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016y2J6bs8gbuSJbH18w7Tan
2026-09-14 23:01:22 +01:00

379 lines
9.9 KiB
TypeScript

/**
* API Client for SchoolCompare FastAPI Backend
* Handles all data fetching from the API with proper error handling and caching
*/
import type {
SchoolsResponse,
SchoolDetailsResponse,
ComparisonResponse,
RankingsResponse,
FiltersResponse,
MetricsResponse,
DataInfoResponse,
SchoolSearchParams,
RankingsParams,
APIError,
LAaveragesResponse,
NationalAverages,
} from './types';
// ============================================================================
// Configuration
// ============================================================================
// Use FASTAPI_URL for server-side requests (internal Docker network)
// Use NEXT_PUBLIC_API_URL for client-side requests (browser)
const API_BASE_URL = typeof window === 'undefined'
? (process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || '/api')
: (process.env.NEXT_PUBLIC_API_URL || '/api');
// Cache configuration for server-side fetching (Next.js revalidate)
export const CACHE_DURATION = {
FILTERS: 3600, // 1 hour
METRICS: 3600, // 1 hour
SCHOOLS_LIST: 60, // 1 minute
SCHOOL_DETAILS: 300, // 5 minutes
RANKINGS: 300, // 5 minutes
COMPARISON: 60, // 1 minute
DATA_INFO: 600, // 10 minutes
};
// ============================================================================
// Error Handling
// ============================================================================
export class APIFetchError extends Error {
constructor(
message: string,
public status?: number,
public detail?: string
) {
super(message);
this.name = 'APIFetchError';
}
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
let errorDetail = `HTTP ${response.status}: ${response.statusText}`;
try {
const errorData: APIError = await response.json();
errorDetail = errorData.detail || errorDetail;
} catch {
// If parsing JSON fails, use the default error
}
// Client-side: report to analytics so we can spot silent failures.
// No-ops on SSR (track guards against missing window).
if (typeof window !== 'undefined') {
const { track } = await import('./analytics');
try {
const endpoint = new URL(response.url).pathname;
track('api_error', { endpoint, status: response.status, route: window.location.pathname });
} catch { /* never */ }
}
throw new APIFetchError(
`API request failed: ${errorDetail}`,
response.status,
errorDetail
);
}
return response.json();
}
// ============================================================================
// Helper Functions
// ============================================================================
function buildQueryString(params: Record<string, any>): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
searchParams.append(key, String(value));
}
});
return searchParams.toString();
}
// ============================================================================
// School APIs
// ============================================================================
/**
* Fetch schools with optional search and filters
* Supports both server-side (SSR) and client-side fetching
*/
export async function fetchSchools(
params: SchoolSearchParams = {},
options: RequestInit = {}
): Promise<SchoolsResponse> {
const queryString = buildQueryString(params);
const url = `${API_BASE_URL}/schools${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
...options,
next: {
revalidate: CACHE_DURATION.SCHOOLS_LIST,
...options.next,
},
});
return handleResponse<SchoolsResponse>(response);
}
/**
* Fetch detailed information for a specific school by URN
*/
export async function fetchSchoolDetails(
urn: number,
options: RequestInit = {}
): Promise<SchoolDetailsResponse> {
const url = `${API_BASE_URL}/schools/${urn}`;
const response = await fetch(url, {
...options,
next: {
revalidate: CACHE_DURATION.SCHOOL_DETAILS,
...options.next,
},
});
return handleResponse<SchoolDetailsResponse>(response);
}
// ============================================================================
// Comparison APIs
// ============================================================================
/**
* Fetch comparison data for multiple schools
* @param urns - Comma-separated URNs or array of URNs
*/
export async function fetchComparison(
urns: string | number[],
options: RequestInit = {}
): Promise<ComparisonResponse> {
const urnsString = Array.isArray(urns) ? urns.join(',') : urns;
const url = `${API_BASE_URL}/compare?urns=${urnsString}`;
const response = await fetch(url, {
...options,
next: {
revalidate: CACHE_DURATION.COMPARISON,
...options.next,
},
});
return handleResponse<ComparisonResponse>(response);
}
// ============================================================================
// Rankings APIs
// ============================================================================
/**
* Fetch school rankings by metric
*/
export async function fetchRankings(
params: RankingsParams,
options: RequestInit = {}
): Promise<RankingsResponse> {
const queryString = buildQueryString(params);
const url = `${API_BASE_URL}/rankings?${queryString}`;
const response = await fetch(url, {
...options,
next: {
revalidate: CACHE_DURATION.RANKINGS,
...options.next,
},
});
return handleResponse<RankingsResponse>(response);
}
// ============================================================================
// Filter & Metadata APIs
// ============================================================================
/**
* Fetch available filter options (local authorities, school types, years)
*/
export async function fetchFilters(
options: RequestInit = {}
): Promise<FiltersResponse> {
const url = `${API_BASE_URL}/filters`;
const response = await fetch(url, {
...options,
next: {
revalidate: CACHE_DURATION.FILTERS,
...options.next,
},
});
return handleResponse<FiltersResponse>(response);
}
/**
* Fetch metric definitions (labels, descriptions, formats)
*/
export async function fetchMetrics(
options: RequestInit = {}
): Promise<MetricsResponse> {
const url = `${API_BASE_URL}/metrics`;
const response = await fetch(url, {
...options,
next: {
revalidate: CACHE_DURATION.METRICS,
...options.next,
},
});
const data = await handleResponse<any>(response);
// Transform backend response to match our TypeScript types
// Backend uses 'name' and 'type', we use 'label' and 'format'
return {
metrics: data.metrics.map((metric: any) => ({
key: metric.key,
label: metric.name, // Map 'name' to 'label'
description: metric.description,
category: metric.category,
format: metric.type, // Map 'type' to 'format'
hasNationalAverage: metric.hasNationalAverage,
})),
};
}
/**
* Fetch per-LA average Attainment 8 score for secondary schools
*/
export async function fetchLAaverages(
options: RequestInit = {}
): Promise<LAaveragesResponse> {
const url = `${API_BASE_URL}/la-averages`;
const response = await fetch(url, {
...options,
next: {
revalidate: 3600,
...options.next,
},
});
return handleResponse<LAaveragesResponse>(response);
}
/**
* Fetch official DfE KS2 national averages (primary) and computed KS4 secondary averages.
* Returns latest year snapshot plus per-year history for chart reference lines.
*/
export async function fetchNationalAverages(
options: RequestInit = {}
): Promise<NationalAverages> {
const url = `${API_BASE_URL}/national-averages`;
const response = await fetch(url, {
...options,
next: {
revalidate: 3600,
...options.next,
},
});
return handleResponse<NationalAverages>(response);
}
/**
* Fetch database statistics and info
*/
export async function fetchDataInfo(
options: RequestInit = {}
): Promise<DataInfoResponse> {
const url = `${API_BASE_URL}/data-info`;
const response = await fetch(url, {
...options,
next: {
revalidate: CACHE_DURATION.DATA_INFO,
...options.next,
},
});
return handleResponse<DataInfoResponse>(response);
}
// ============================================================================
// Geocoding API
// ============================================================================
/**
* Geocode a UK postcode using postcodes.io
*/
export async function geocodePostcode(postcode: string): Promise<{
latitude: number;
longitude: number;
} | null> {
try {
const cleanPostcode = postcode.trim().toUpperCase();
const response = await fetch(
`https://api.postcodes.io/postcodes/${encodeURIComponent(cleanPostcode)}`
);
if (!response.ok) {
return null;
}
const data = await response.json();
if (data.result) {
return {
latitude: data.result.latitude,
longitude: data.result.longitude,
};
}
return null;
} catch (error) {
console.error('Geocoding error:', error);
return null;
}
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Calculate distance between two coordinates using Haversine formula
* @returns Distance in kilometers
*/
export function calculateDistance(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
const R = 6371; // Earth's radius in kilometers
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}