import type { CodeDescription, CourseResponse, DbMeetingTime, InstructorResponse, SearchResponse as SearchResponseGenerated, ServiceInfo, ServiceStatus, StatusResponse, User, } from "$lib/bindings"; const API_BASE_URL = "/api"; // Re-export generated types under their canonical names export type { CodeDescription, CourseResponse, DbMeetingTime, InstructorResponse, ServiceInfo, ServiceStatus, StatusResponse, }; // Semantic aliases — these all share the CodeDescription shape export type Term = CodeDescription; export type Subject = CodeDescription; export type ReferenceEntry = CodeDescription; // SearchResponse re-exported (aliased to strip the "Generated" suffix) export type SearchResponse = SearchResponseGenerated; // Client-side only — not generated from Rust export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats"; export type SortDirection = "asc" | "desc"; export interface AdminStatus { userCount: number; sessionCount: number; courseCount: number; scrapeJobCount: number; services: { name: string; status: string }[]; } export interface ScrapeJob { id: number; targetType: string; targetPayload: unknown; priority: string; executeAt: string; createdAt: string; lockedAt: string | null; retryCount: number; maxRetries: number; queuedAt: string; status: "processing" | "staleLock" | "exhausted" | "scheduled" | "pending"; } export interface ScrapeJobsResponse { jobs: ScrapeJob[]; } export interface AuditLogEntry { id: number; courseId: number; timestamp: string; fieldChanged: string; oldValue: string; newValue: string; subject: string | null; courseNumber: string | null; crn: string | null; courseTitle: string | null; } export interface AuditLogResponse { entries: AuditLogEntry[]; } export interface MetricEntry { id: number; courseId: number; timestamp: string; enrollment: number; waitCount: number; seatsAvailable: number; } export interface MetricsResponse { metrics: MetricEntry[]; count: number; timestamp: string; } export interface MetricsParams { course_id?: number; term?: string; crn?: string; range?: "1h" | "6h" | "24h" | "7d" | "30d"; limit?: number; } export interface SearchParams { term: string; subjects?: string[]; q?: string; open_only?: boolean; limit?: number; offset?: number; sort_by?: SortColumn; sort_dir?: SortDirection; } 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 async request(endpoint: string): Promise { const response = await this.fetchFn(`${this.baseUrl}${endpoint}`); if (!response.ok) { throw new Error(`API request failed: ${response.status} ${response.statusText}`); } return (await response.json()) as T; } async getStatus(): Promise { return this.request("/status"); } async searchCourses(params: SearchParams): Promise { const query = new URLSearchParams(); query.set("term", params.term); if (params.subjects) { for (const s of params.subjects) { query.append("subject", s); } } if (params.q) query.set("q", params.q); if (params.open_only) query.set("open_only", "true"); if (params.limit !== undefined) query.set("limit", String(params.limit)); if (params.offset !== undefined) query.set("offset", String(params.offset)); if (params.sort_by) query.set("sort_by", params.sort_by); if (params.sort_dir) query.set("sort_dir", params.sort_dir); return this.request(`/courses/search?${query.toString()}`); } async getTerms(): Promise { return this.request("/terms"); } async getSubjects(termCode: string): Promise { return this.request(`/subjects?term=${encodeURIComponent(termCode)}`); } async getReference(category: string): Promise { return this.request(`/reference/${encodeURIComponent(category)}`); } // Admin endpoints async getAdminStatus(): Promise { return this.request("/admin/status"); } async getAdminUsers(): Promise { return this.request("/admin/users"); } async setUserAdmin(discordId: string, isAdmin: boolean): Promise { const response = await this.fetchFn(`${this.baseUrl}/admin/users/${discordId}/admin`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ is_admin: isAdmin }), }); if (!response.ok) throw new Error(`API request failed: ${response.status}`); return (await response.json()) as User; } async getAdminScrapeJobs(): Promise { return this.request("/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 { const headers: Record = {}; 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 getMetrics(params?: MetricsParams): Promise { const query = new URLSearchParams(); if (params?.course_id !== undefined) query.set("course_id", String(params.course_id)); if (params?.term) query.set("term", params.term); if (params?.crn) query.set("crn", params.crn); if (params?.range) query.set("range", params.range); if (params?.limit !== undefined) query.set("limit", String(params.limit)); const qs = query.toString(); return this.request(`/metrics${qs ? `?${qs}` : ""}`); } } export const client = new BannerApiClient();