| 1 | |
| 2 | import type { |
| 3 | ApiCollection, |
| 4 | ApiResource, |
| 5 | Drop, |
| 6 | Source, |
| 7 | Stats, |
| 8 | TicketEvent, |
| 9 | WatchlistCreate, |
| 10 | WatchlistRule, |
| 11 | } from "./types"; |
| 12 | |
| 13 | const BASE = ""; |
| 14 | |
| 15 | export class ApiError extends Error { |
| 16 | readonly code: string; |
| 17 | readonly status: number; |
| 18 | readonly details?: unknown; |
| 19 | |
| 20 | constructor(status: number, code: string, message: string, details?: unknown) { |
| 21 | super(message); |
| 22 | this.name = "ApiError"; |
| 23 | this.status = status; |
| 24 | this.code = code; |
| 25 | this.details = details; |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | async function request<T>(path: string, init?: RequestInit): Promise<T> { |
| 30 | const res = await fetch(`${BASE}/api${path}`, { |
| 31 | ...init, |
| 32 | headers: { |
| 33 | "Content-Type": "application/json; charset=utf-8", |
| 34 | ...init?.headers, |
| 35 | }, |
| 36 | cache: "no-store", |
| 37 | }); |
| 38 | |
| 39 | if (res.status === 204) return undefined as T; |
| 40 | |
| 41 | const body = (await res.json().catch(() => null)) as unknown; |
| 42 | |
| 43 | if (!res.ok) { |
| 44 | const err = (body as { error?: { code?: string; message?: string; details?: unknown } } | null)?.error; |
| 45 | throw new ApiError( |
| 46 | res.status, |
| 47 | err?.code ?? "INTERNAL", |
| 48 | err?.message ?? `Request failed (${res.status})`, |
| 49 | err?.details, |
| 50 | ); |
| 51 | } |
| 52 | return body as T; |
| 53 | } |
| 54 | |
| 55 | function qs(params: Record<string, string | number | undefined | null>): string; |
| 56 | function qs(params: object): string; |
| 57 | function qs(params: object): string { |
| 58 | const sp = new URLSearchParams(); |
| 59 | for (const [k, v] of Object.entries(params)) { |
| 60 | if (v !== undefined && v !== null && v !== "") sp.set(k, String(v)); |
| 61 | } |
| 62 | const s = sp.toString(); |
| 63 | return s ? `?${s}` : ""; |
| 64 | } |
| 65 | |
| 66 | export interface EventQuery { |
| 67 | page?: number; |
| 68 | per_page?: number; |
| 69 | category?: string; |
| 70 | status?: string; |
| 71 | source_id?: string; |
| 72 | q?: string; |
| 73 | sort?: string; |
| 74 | } |
| 75 | |
| 76 | export const api = { |
| 77 | stats: () => request<ApiResource<Stats>>("/stats").then((r) => r.data), |
| 78 | |
| 79 | events: (params: EventQuery = {}) => |
| 80 | request<ApiCollection<TicketEvent>>(`/events${qs(params)}`), |
| 81 | |
| 82 | drops: (params: { page?: number; per_page?: number; drop_type?: string; source_id?: string } = {}) => |
| 83 | request<ApiCollection<Drop>>(`/drops${qs(params)}`), |
| 84 | |
| 85 | sources: () => request<ApiCollection<Source>>("/sources").then((r) => r.data), |
| 86 | |
| 87 | watchlist: () => request<ApiCollection<WatchlistRule>>("/watchlist").then((r) => r.data), |
| 88 | |
| 89 | createWatch: (body: WatchlistCreate) => |
| 90 | request<ApiResource<WatchlistRule>>("/watchlist", { |
| 91 | method: "POST", |
| 92 | body: JSON.stringify(body), |
| 93 | }).then((r) => r.data), |
| 94 | |
| 95 | patchWatch: (id: number, body: Partial<Pick<WatchlistRule, "active" | "priority" | "label">>) => |
| 96 | request<ApiResource<WatchlistRule>>(`/watchlist/${id}`, { |
| 97 | method: "PATCH", |
| 98 | body: JSON.stringify(body), |
| 99 | }).then((r) => r.data), |
| 100 | |
| 101 | deleteWatch: (id: number) => request<void>(`/watchlist/${id}`, { method: "DELETE" }), |
| 102 | |
| 103 | bulkWatch: (labels: string[]) => |
| 104 | request<ApiResource<{ created: number; skipped_duplicates: number }>>("/watchlist/bulk", { |
| 105 | method: "POST", |
| 106 | body: JSON.stringify({ rules: labels.map((label) => ({ kind: "keyword", label })) }), |
| 107 | }).then((r) => r.data), |
| 108 | |
| 109 | patchSource: (id: string, enabled: boolean) => |
| 110 | request<ApiResource<Source>>(`/sources/${id}`, { |
| 111 | method: "PATCH", |
| 112 | body: JSON.stringify({ enabled }), |
| 113 | }).then((r) => r.data), |
| 114 | |
| 115 | testNotification: () => |
| 116 | request<ApiResource<{ sent: boolean; topic: string }>>("/notifications/test", { |
| 117 | method: "POST", |
| 118 | }).then((r) => r.data), |
| 119 | }; |