/** * The `/api/v1` client. Cookie auth (`credentials: "same-origin"`); every non-GET request sends * `X-Requested-With: sttracker` (the server's CSRF rule) and, unless it is multipart, a JSON content type. * Wire types mirror the server's responses; the web app cannot import `@sttracker/core`. */ import type { PartialDate, Status } from "./format.ts"; export class ApiError extends Error { constructor( readonly status: number, readonly code: string, message: string, ) { super(message); this.name = "ApiError"; } } type Method = "GET" | "POST" | "PATCH" | "DELETE"; type QueryValue = string | number | boolean | null | undefined; export interface RequestOpts { body?: unknown; form?: FormData; query?: Record; fetchImpl?: typeof fetch; } export async function request(method: Method, path: string, opts: RequestOpts = {}): Promise { const qs = new URLSearchParams(); for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== undefined && v !== null && v !== "") qs.set(k, String(v)); const url = `/api/v1/${path}${qs.size ? `?${qs}` : ""}`; const headers: Record = { accept: "application/json" }; let body: BodyInit | undefined; if (method !== "GET") { headers["x-requested-with"] = "sttracker"; if (opts.form) body = opts.form; else { headers["content-type"] = "application/json"; if (opts.body !== undefined) body = JSON.stringify(opts.body); } } let res: Response; try { res = await (opts.fetchImpl ?? fetch)(url, { method, headers, body, credentials: "same-origin" }); } catch { throw new ApiError(0, "network", "Can't reach the server. Check your connection and try again."); } if (res.status === 204) return undefined as T; const text = await res.text(); let data: unknown; try { data = text ? JSON.parse(text) : undefined; } catch { data = undefined; } if (!res.ok) { const err = (data as { error?: { code?: string; message?: string } } | undefined)?.error; if (err?.code) throw new ApiError(res.status, err.code, err.message ?? err.code); throw new ApiError(res.status, `http_${res.status}`, `The server answered ${res.status}. Try again in a moment.`); } return data as T; } // --------------------------------------------------------------------------------------------- // Wire types // --------------------------------------------------------------------------------------------- export interface User { id: string; username: string; displayName: string; role: "admin" | "member"; } export interface EntryView { id: string; workId: string; status: Status; rating: number | null; review: string | null; privateNotes: string | null; favorite: boolean; tags: string[]; customFields: Record; visibility: "private" | "friends" | "public"; overrides: { title?: string; coverUrl?: string; year?: number | null }; mediaTypeOverride: string | null; fieldSources: Record; createdAt: string; updatedAt: string; work: { id: string; mediaType: string; title: string; originalTitle: string; year: number | null; coverUrl: string; parentId: string | null }; draft: boolean; childSummary: { total: number; completed: number } | null; } export type EntryPatch = Partial< Pick >; export interface Page { items: T[]; nextCursor: string | null; } export interface Engagement { id: string; entryId: string; startedAt: PartialDate | null; finishedAt: PartialDate | null; progress: Record; platform: string | null; rating: number | null; notes: string | null; fieldSources: Record; createdAt: string; } export interface Copy { id: string; workId: string | null; titleHint: string | null; format: string | null; acquiredFrom: string | null; acquiredAt: PartialDate | null; priceCents: number | null; currency: string | null; notes: string | null; } export interface Attachment { id: string; mime: string; filename: string; caption: string | null; size: number; } export interface Connection { id: string; pluginId: string; name: string; status: string; lastRunAt: string | null; hasCredentials: boolean; } export interface ChangeEvent { id: string; type: string; status: string; connectionId: string | null; targetEntity: string; targetId: string | null; payload: Record; createdAt: string; } export interface HistoryRecord { id: string; entity: string; entityId: string; before: Record; after: Record; actor: string; createdAt: string; } export interface CustomFieldDef { id: string; name: string; type: "text" | "long_text" | "number" | "date" | "boolean" | "single_select" | "multi_select" | "url" | "rating"; options: string[]; } export interface Work { id: string; media_type: string; title: string; year: number | null; cover_url: string; creators: { name: string; role?: string }[]; } export interface Suggestion { id: string; workId: string; candidateWorkId: string; score: number; } // --------------------------------------------------------------------------------------------- // Endpoints // --------------------------------------------------------------------------------------------- export interface EntryQuery { q?: string; mediaType?: string; status?: Status; draft?: boolean; parentId?: string; cursor?: string; limit?: number; } export const api = { me: () => request("GET", "me"), login: (username: string, password: string) => request("POST", "auth/login", { body: { username, password } }), register: (body: { username: string; password: string; displayName?: string; inviteCode?: string }) => request("POST", "auth/register", { body }), logout: () => request("POST", "auth/logout"), entries: (query: EntryQuery) => request>("GET", "entries", { query: { ...query } }), entry: (id: string) => request("GET", `entries/${id}`), quickAdd: (title: string) => request("POST", "entries/quick", { body: { title } }), patchEntry: (id: string, patch: EntryPatch) => request("PATCH", `entries/${id}`, { body: patch }), moveEntry: (id: string, workId: string) => request("POST", `entries/${id}/move`, { body: { workId } }), history: (id: string) => request("GET", `entries/${id}/history`), restore: (historyId: string) => request("POST", `history/${historyId}/restore`), engagements: (entryId: string) => request("GET", `entries/${entryId}/engagements`), addEngagement: (entryId: string, body: { startedAt?: PartialDate | null; finishedAt?: PartialDate | null }) => request("POST", `entries/${entryId}/engagements`, { body }), deleteEngagement: (id: string) => request("DELETE", `engagements/${id}`), copies: (workId: string) => request("GET", "copies", { query: { workId } }), addCopy: (body: Partial>) => request("POST", "copies", { body }), upload: (file: File, entityId: string) => { const form = new FormData(); form.set("file", file); form.set("entity", "entry"); form.set("entityId", entityId); return request("POST", "attachments", { form }); }, sources: () => request("GET", "sources"), changes: () => request>("GET", "changes", { query: { limit: 200 } }), customFields: (mediaType: string) => request("GET", "custom-fields", { query: { mediaType, level: "entry" } }), work: (id: string) => request("GET", `works/${id}`), searchWorks: (q: string, mediaType?: string) => request("GET", "works/search", { query: { q, mediaType, limit: 8 } }), suggestions: (workId: string) => request("GET", `works/${workId}/suggestions`), acceptSuggestion: (id: string) => request("POST", `suggestions/${id}/accept`), rejectSuggestion: (id: string) => request("POST", `suggestions/${id}/reject`), };