249 lines
8.1 KiB
TypeScript
249 lines
8.1 KiB
TypeScript
/**
|
|
* 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<string, QueryValue>;
|
|
fetchImpl?: typeof fetch;
|
|
}
|
|
|
|
export async function request<T = unknown>(method: Method, path: string, opts: RequestOpts = {}): Promise<T> {
|
|
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<string, string> = { 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<string, unknown>;
|
|
visibility: "private" | "friends" | "public";
|
|
overrides: { title?: string; coverUrl?: string; year?: number | null };
|
|
mediaTypeOverride: string | null;
|
|
fieldSources: Record<string, string>;
|
|
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<EntryView, "status" | "rating" | "review" | "privateNotes" | "favorite" | "tags" | "customFields" | "visibility" | "overrides">
|
|
>;
|
|
|
|
export interface Page<T> {
|
|
items: T[];
|
|
nextCursor: string | null;
|
|
}
|
|
|
|
export interface Engagement {
|
|
id: string;
|
|
entryId: string;
|
|
startedAt: PartialDate | null;
|
|
finishedAt: PartialDate | null;
|
|
progress: Record<string, number | string>;
|
|
platform: string | null;
|
|
rating: number | null;
|
|
notes: string | null;
|
|
fieldSources: Record<string, string>;
|
|
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<string, unknown>;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface HistoryRecord {
|
|
id: string;
|
|
entity: string;
|
|
entityId: string;
|
|
before: Record<string, unknown>;
|
|
after: Record<string, unknown>;
|
|
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<User>("GET", "me"),
|
|
login: (username: string, password: string) => request<User>("POST", "auth/login", { body: { username, password } }),
|
|
register: (body: { username: string; password: string; displayName?: string; inviteCode?: string }) =>
|
|
request<User>("POST", "auth/register", { body }),
|
|
logout: () => request<void>("POST", "auth/logout"),
|
|
|
|
entries: (query: EntryQuery) => request<Page<EntryView>>("GET", "entries", { query: { ...query } }),
|
|
entry: (id: string) => request<EntryView>("GET", `entries/${id}`),
|
|
quickAdd: (title: string) => request<EntryView>("POST", "entries/quick", { body: { title } }),
|
|
patchEntry: (id: string, patch: EntryPatch) => request<EntryView>("PATCH", `entries/${id}`, { body: patch }),
|
|
moveEntry: (id: string, workId: string) => request<EntryView>("POST", `entries/${id}/move`, { body: { workId } }),
|
|
history: (id: string) => request<HistoryRecord[]>("GET", `entries/${id}/history`),
|
|
restore: (historyId: string) => request<void>("POST", `history/${historyId}/restore`),
|
|
|
|
engagements: (entryId: string) => request<Engagement[]>("GET", `entries/${entryId}/engagements`),
|
|
addEngagement: (entryId: string, body: { startedAt?: PartialDate | null; finishedAt?: PartialDate | null }) =>
|
|
request<Engagement>("POST", `entries/${entryId}/engagements`, { body }),
|
|
deleteEngagement: (id: string) => request<void>("DELETE", `engagements/${id}`),
|
|
|
|
copies: (workId: string) => request<Copy[]>("GET", "copies", { query: { workId } }),
|
|
addCopy: (body: Partial<Omit<Copy, "id">>) => request<Copy>("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<Attachment>("POST", "attachments", { form });
|
|
},
|
|
|
|
sources: () => request<Connection[]>("GET", "sources"),
|
|
changes: () => request<Page<ChangeEvent>>("GET", "changes", { query: { limit: 200 } }),
|
|
customFields: (mediaType: string) => request<CustomFieldDef[]>("GET", "custom-fields", { query: { mediaType, level: "entry" } }),
|
|
|
|
work: (id: string) => request<Work>("GET", `works/${id}`),
|
|
searchWorks: (q: string, mediaType?: string) => request<Work[]>("GET", "works/search", { query: { q, mediaType, limit: 8 } }),
|
|
suggestions: (workId: string) => request<Suggestion[]>("GET", `works/${workId}/suggestions`),
|
|
acceptSuggestion: (id: string) => request<void>("POST", `suggestions/${id}/accept`),
|
|
rejectSuggestion: (id: string) => request<void>("POST", `suggestions/${id}/reject`),
|
|
};
|