Files
banner/web/src/lib/api.ts
Xevion 215703593b refactor: consolidate course data models into structured types
Extract DateRange, MeetingLocation, CreditHours, CrossList, Enrollment,
and RmpRating into dedicated types. Replace primitive fields across
backend models and frontend bindings with type-safe alternatives.
2026-02-01 04:07:06 -06:00

449 lines
13 KiB
TypeScript

import { authStore } from "$lib/auth.svelte";
import type {
AdminStatusResponse,
ApiError,
ApiErrorCode,
AuditLogEntry,
AuditLogResponse,
CandidateResponse,
CodeDescription,
CourseResponse,
DayOfWeek,
DbMeetingTime,
DeliveryMode,
FilterRanges,
InstructorDetail,
InstructorDetailResponse,
InstructorListItem,
InstructorResponse,
InstructorStats,
LinkedRmpProfile,
ListInstructorsParams as ListInstructorsParamsGenerated,
ListInstructorsResponse,
MatchBody,
MetricEntry,
MetricsParams as MetricsParamsGenerated,
MetricsResponse,
RejectCandidateBody,
RescoreResponse,
ScrapeJobDto,
ScrapeJobEvent,
ScrapeJobsResponse,
ScraperStatsResponse,
SearchOptionsReference,
SearchOptionsResponse,
SearchParams as SearchParamsGenerated,
SearchResponse as SearchResponseGenerated,
ServiceInfo,
ServiceStatus,
SortColumn,
SortDirection,
StatusResponse,
SubjectDetailResponse,
SubjectResultEntry,
SubjectSummary,
SubjectsResponse,
TermResponse,
TimeRange,
TimelineRequest,
TimelineResponse,
TimelineSlot,
TimeseriesPoint,
TimeseriesResponse,
TopCandidateResponse,
User,
} from "$lib/bindings";
const API_BASE_URL = "/api";
// Re-export generated types under their canonical names
export type {
AdminStatusResponse,
ApiError,
ApiErrorCode,
AuditLogEntry,
AuditLogResponse,
CandidateResponse,
CodeDescription,
CourseResponse,
DayOfWeek,
DbMeetingTime,
DeliveryMode,
FilterRanges,
InstructorDetail,
InstructorDetailResponse,
InstructorListItem,
InstructorResponse,
InstructorStats,
LinkedRmpProfile,
ListInstructorsResponse,
MetricEntry,
MetricsResponse,
RescoreResponse,
ScrapeJobDto,
ScrapeJobEvent,
ScrapeJobsResponse,
ScraperStatsResponse,
SearchOptionsReference,
SearchOptionsResponse,
ServiceInfo,
ServiceStatus,
SortColumn,
SortDirection,
StatusResponse,
SubjectDetailResponse,
SubjectResultEntry,
SubjectSummary,
SubjectsResponse,
TermResponse,
TimelineRequest,
TimelineResponse,
TimelineSlot,
TimeRange,
TimeseriesPoint,
TimeseriesResponse,
TopCandidateResponse,
};
// Semantic aliases
export type Term = TermResponse;
export type Subject = CodeDescription;
export type ReferenceEntry = CodeDescription;
// Re-export with simplified names
export type SearchResponse = SearchResponseGenerated;
export type SearchParams = SearchParamsGenerated;
export type MetricsParams = MetricsParamsGenerated;
export type ListInstructorsParams = ListInstructorsParamsGenerated;
export type ScraperPeriod = "1h" | "6h" | "24h" | "7d" | "30d";
/**
* Converts a typed object to URLSearchParams, preserving camelCase keys.
* Handles arrays, optional values, and primitives.
*/
function toURLSearchParams(obj: Record<string, unknown>): URLSearchParams {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(obj)) {
if (value === undefined || value === null) {
continue; // Skip undefined/null values
}
if (Array.isArray(value)) {
// Append each array element
for (const item of value) {
if (item !== undefined && item !== null) {
params.append(key, String(item));
}
}
} else {
// Convert primitives to string
params.set(key, String(value));
}
}
return params;
}
/**
* API error class that wraps the structured ApiError response from the backend.
*/
export class ApiErrorClass extends Error {
public readonly code: ApiErrorCode;
public readonly details: unknown | null;
constructor(apiError: ApiError) {
super(apiError.message);
this.name = "ApiError";
this.code = apiError.code;
this.details = apiError.details;
}
isNotFound(): boolean {
return this.code === "NOT_FOUND";
}
isBadRequest(): boolean {
return (
this.code === "BAD_REQUEST" || this.code === "INVALID_TERM" || this.code === "INVALID_RANGE"
);
}
isInternalError(): boolean {
return this.code === "INTERNAL_ERROR";
}
}
export class BannerApiClient {
private baseUrl: string;
private fetchFn: typeof fetch;
constructor(baseUrl: string = API_BASE_URL, fetchFn: typeof fetch = fetch) {
this.baseUrl = baseUrl;
this.fetchFn = fetchFn;
}
private buildInit(options?: { method?: string; body?: unknown }): RequestInit | undefined {
if (!options) return undefined;
const init: RequestInit = {};
if (options.method) {
init.method = options.method;
}
if (options.body !== undefined) {
init.headers = { "Content-Type": "application/json" };
init.body = JSON.stringify(options.body);
} else if (options.method) {
init.headers = { "Content-Type": "application/json" };
}
return Object.keys(init).length > 0 ? init : undefined;
}
private async request<T>(
endpoint: string,
options?: { method?: string; body?: unknown }
): Promise<T> {
const init = this.buildInit(options);
const args: [string, RequestInit?] = [`${this.baseUrl}${endpoint}`];
if (init) args.push(init);
const response = await this.fetchFn(...args);
if (response.status === 401) {
authStore.handleUnauthorized();
}
if (!response.ok) {
let apiError: ApiError;
try {
apiError = (await response.json()) as ApiError;
} catch {
apiError = {
code: "INTERNAL_ERROR",
message: `API request failed: ${response.status} ${response.statusText}`,
details: null,
};
}
throw new ApiErrorClass(apiError);
}
return (await response.json()) as T;
}
private async requestVoid(
endpoint: string,
options?: { method?: string; body?: unknown }
): Promise<void> {
const init = this.buildInit(options);
const args: [string, RequestInit?] = [`${this.baseUrl}${endpoint}`];
if (init) args.push(init);
const response = await this.fetchFn(...args);
if (response.status === 401) {
authStore.handleUnauthorized();
}
if (!response.ok) {
let apiError: ApiError;
try {
apiError = (await response.json()) as ApiError;
} catch {
apiError = {
code: "INTERNAL_ERROR",
message: `API request failed: ${response.status} ${response.statusText}`,
details: null,
};
}
throw new ApiErrorClass(apiError);
}
}
async getStatus(): Promise<StatusResponse> {
return this.request<StatusResponse>("/status");
}
async searchCourses(params: Partial<SearchParams> & { term: string }): Promise<SearchResponse> {
const query = toURLSearchParams(params as Record<string, unknown>);
return this.request<SearchResponse>(`/courses/search?${query.toString()}`);
}
async getTerms(): Promise<Term[]> {
return this.request<Term[]>("/terms");
}
async getSubjects(term: string): Promise<Subject[]> {
return this.request<Subject[]>(`/subjects?term=${encodeURIComponent(term)}`);
}
async getReference(category: string): Promise<ReferenceEntry[]> {
return this.request<ReferenceEntry[]>(`/reference/${encodeURIComponent(category)}`);
}
// In-memory cache for search options per term
private searchOptionsCache = new Map<
string,
{ data: SearchOptionsResponse; fetchedAt: number }
>();
private static SEARCH_OPTIONS_TTL = 10 * 60 * 1000; // 10 minutes
async getSearchOptions(term?: string): Promise<SearchOptionsResponse> {
const cacheKey = term || "__default__";
const cached = this.searchOptionsCache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < BannerApiClient.SEARCH_OPTIONS_TTL) {
return cached.data;
}
const url = term ? `/search-options?term=${encodeURIComponent(term)}` : "/search-options";
const data = await this.request<SearchOptionsResponse>(url);
this.searchOptionsCache.set(cacheKey, { data, fetchedAt: Date.now() });
return data;
}
// Admin endpoints
async getAdminStatus(): Promise<AdminStatusResponse> {
return this.request<AdminStatusResponse>("/admin/status");
}
async getAdminUsers(): Promise<User[]> {
return this.request<User[]>("/admin/users");
}
async setUserAdmin(discordId: string, isAdmin: boolean): Promise<User> {
return this.request<User>(`/admin/users/${discordId}/admin`, {
method: "PUT",
body: { is_admin: isAdmin },
});
}
async getAdminScrapeJobs(): Promise<ScrapeJobsResponse> {
return this.request<ScrapeJobsResponse>("/admin/scrape-jobs");
}
/**
* Fetch the audit log with conditional request support.
*
* Returns `null` when the server responds 304 (data unchanged).
* Stores and sends `Last-Modified` / `If-Modified-Since` automatically.
*/
async getAdminAuditLog(): Promise<AuditLogResponse | null> {
const headers: Record<string, string> = {};
if (this._auditLastModified) {
headers["If-Modified-Since"] = this._auditLastModified;
}
const response = await this.fetchFn(`${this.baseUrl}/admin/audit-log`, { headers });
if (response.status === 304) {
return null;
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const lastMod = response.headers.get("Last-Modified");
if (lastMod) {
this._auditLastModified = lastMod;
}
return (await response.json()) as AuditLogResponse;
}
/** Stored `Last-Modified` value for audit log conditional requests. */
private _auditLastModified: string | null = null;
async getTimeline(ranges: TimeRange[]): Promise<TimelineResponse> {
return this.request<TimelineResponse>("/timeline", {
method: "POST",
body: { ranges } satisfies TimelineRequest,
});
}
async getMetrics(params?: Partial<MetricsParams>): Promise<MetricsResponse> {
if (!params) {
return this.request<MetricsResponse>("/metrics");
}
const query = toURLSearchParams(params as Record<string, unknown>);
const qs = query.toString();
return this.request<MetricsResponse>(`/metrics${qs ? `?${qs}` : ""}`);
}
// Admin instructor endpoints
async getAdminInstructors(
params?: Partial<ListInstructorsParams>
): Promise<ListInstructorsResponse> {
if (!params) {
return this.request<ListInstructorsResponse>("/admin/instructors");
}
const query = toURLSearchParams(params as Record<string, unknown>);
const qs = query.toString();
return this.request<ListInstructorsResponse>(`/admin/instructors${qs ? `?${qs}` : ""}`);
}
async getAdminInstructor(id: number): Promise<InstructorDetailResponse> {
return this.request<InstructorDetailResponse>(`/admin/instructors/${id}`);
}
async matchInstructor(id: number, rmpLegacyId: number): Promise<InstructorDetailResponse> {
return this.request<InstructorDetailResponse>(`/admin/instructors/${id}/match`, {
method: "POST",
body: { rmpLegacyId } satisfies MatchBody,
});
}
async rejectCandidate(id: number, rmpLegacyId: number): Promise<void> {
return this.requestVoid(`/admin/instructors/${id}/reject-candidate`, {
method: "POST",
body: { rmpLegacyId } satisfies RejectCandidateBody,
});
}
async rejectAllCandidates(id: number): Promise<void> {
return this.requestVoid(`/admin/instructors/${id}/reject-all`, {
method: "POST",
});
}
async unmatchInstructor(id: number, rmpLegacyId?: number): Promise<void> {
return this.requestVoid(`/admin/instructors/${id}/unmatch`, {
method: "POST",
...(rmpLegacyId !== undefined ? { body: { rmpLegacyId } satisfies MatchBody } : {}),
});
}
async rescoreInstructors(): Promise<RescoreResponse> {
return this.request<RescoreResponse>("/admin/rmp/rescore", {
method: "POST",
});
}
// Scraper analytics endpoints
async getScraperStats(period?: ScraperPeriod): Promise<ScraperStatsResponse> {
const qs = period ? `?period=${period}` : "";
return this.request<ScraperStatsResponse>(`/admin/scraper/stats${qs}`);
}
async getScraperTimeseries(period?: ScraperPeriod, bucket?: string): Promise<TimeseriesResponse> {
const query = new URLSearchParams();
if (period) query.set("period", period);
if (bucket) query.set("bucket", bucket);
const qs = query.toString();
return this.request<TimeseriesResponse>(`/admin/scraper/timeseries${qs ? `?${qs}` : ""}`);
}
async getScraperSubjects(): Promise<SubjectsResponse> {
return this.request<SubjectsResponse>("/admin/scraper/subjects");
}
async getScraperSubjectDetail(subject: string, limit?: number): Promise<SubjectDetailResponse> {
const qs = limit !== undefined ? `?limit=${limit}` : "";
return this.request<SubjectDetailResponse>(
`/admin/scraper/subjects/${encodeURIComponent(subject)}${qs}`
);
}
}
export const client = new BannerApiClient();