mirror of
https://github.com/Xevion/banner.git
synced 2026-02-01 08:23:40 -06:00
Extract DateRange, MeetingLocation, CreditHours, CrossList, Enrollment, and RmpRating into dedicated types. Replace primitive fields across backend models and frontend bindings with type-safe alternatives.
449 lines
13 KiB
TypeScript
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();
|