Files
FabledGrimoire/frontend/src/api/client.ts
T
bvandeusenandClaude Opus 5.5 bdd803537b
CI & Build / Python lint (push) Successful in 9s
CI & Build / Plugin hooks (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m55s
CI & Build / Python tests (push) Successful in 2m20s
CI & Build / Build & push image (push) Successful in 1m7s
docs(6208): the remaining frontend comments drop milestone, step and rule citations
Components, stores, api clients, router, types and shared CSS now state what
each piece is and why, without the milestone that introduced it. diff.ts
names its consumers instead of the copy history; stores/notes and tasks state
their current scope instead of the cleanup that set it; theme.css states the
token practice instead of the sweep that established it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:14:03 -04:00

538 lines
19 KiB
TypeScript

export class ApiError extends Error {
status: number;
body: Record<string, unknown>;
constructor(status: number, body: Record<string, unknown>) {
const msg = (body.error as string) || `API error: ${status}`;
super(msg);
this.name = "ApiError";
this.status = status;
this.body = body;
}
}
async function handleResponse<T>(res: Response, path: string): Promise<T> {
if (!res.ok) {
let body: Record<string, unknown> = {};
try {
body = await res.json();
} catch {
body = { error: `API error: ${res.status}` };
}
// Redirect to login on 401 (except for auth endpoints)
if (res.status === 401 && !path.startsWith("/api/auth/")) {
const { default: router } = await import("@/router/index");
const currentPath = window.location.pathname;
if (currentPath !== "/login" && currentPath !== "/register") {
router.push({ name: "login", query: { redirect: currentPath } });
}
}
throw new ApiError(res.status, body);
}
// Handle 204 No Content
if (res.status === 204) {
return undefined as T;
}
return res.json() as Promise<T>;
}
/**
* The server's `{"error": "..."}` message from a failed call, or `fallback`
* when the failure carried none (network error, non-JSON body). The one place
* the error envelope is unpacked on the client — views used to restate this
* as a six-line `"body" in e` branch at every catch site.
*/
export function apiErrorMessage(e: unknown, fallback: string): string {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: unknown } }).body;
if (body && typeof body.error === "string" && body.error) return body.error;
}
return fallback;
}
/**
* How long an ordinary JSON call may wait before it is declared failed.
*
* A wait with no deadline is a bug. `fetch`'s own default is to wait
* as long as the browser will, which is not a deadline — it is the absence of
* one, and it renders as a spinner that never resolves. There is no state a
* surface can show for "pending forever" that is not a lie.
*
* 30s is chosen to be longer than anything healthy: it has to clear a cold
* embedding call and a list view under connection-pool contention (#2384 had
* /api/projects fanning 25 concurrent sessions at a 15-connection pool), so
* tripping it means something is genuinely wrong rather than merely busy. Slow
* BY DESIGN is a different case and passes its own value — see the callers in
* SettingsView that do.
*/
const DEFAULT_TIMEOUT_MS = 30_000;
/** HTTP 408. Not a status any Scribe route returns, so it unambiguously means
* "the client gave up" rather than anything the server said. */
const CLIENT_TIMEOUT_STATUS = 408;
/**
* How long a STREAM may take to answer with its headers.
*
* Streams are the one case a wall-clock deadline would break: a long-lived SSE
* connection is *supposed* to stay open, and `AbortSignal.timeout` would kill
* it mid-flight along with the body. But that does not exempt them from rule
* 156 — it relocates the deadline. Two different waits are involved:
*
* connect — the server answering with headers. CAN fail to answer, so it
* carries this deadline, cleared the moment headers arrive.
* stream — the body, open indefinitely on purpose. Its failure mode is
* going quiet, which a timeout cannot tell from being idle; that
* is what reconnection and Last-Event-ID are for, not this.
*
* Reading the connect as exempt because "the stream is long-lived" is the easy
* mistake here, and it leaves an unreachable server looking like a quiet one.
*/
const STREAM_CONNECT_TIMEOUT_MS = 15_000;
/**
* A signal that aborts if headers do not arrive in time, plus the `settle` to
* call once they do. After `settle()` the returned signal never fires, so the
* stream body runs unbounded — which is the intent.
*/
function connectDeadline(base: AbortSignal): { signal: AbortSignal; settle: () => void } {
const gate = new AbortController();
const timer = setTimeout(
() => gate.abort(new DOMException("stream did not connect in time", "TimeoutError")),
STREAM_CONNECT_TIMEOUT_MS,
);
return {
signal: AbortSignal.any([base, gate.signal]),
settle: () => clearTimeout(timer),
};
}
export interface RequestOpts {
/** Override the deadline. Pass one when the call is slow BY DESIGN. */
timeoutMs?: number;
}
/**
* The one place a request is actually made — every verb below goes through
* here, so the deadline cannot be forgotten by adding a sixth.
*
* EXPIRY SURFACES AS AN `ApiError`, the deadline's second half: the
* failure has to arrive in the shape the caller already handles. A bare
* `DOMException: TimeoutError` would reach `apiErrorMessage(e, fallback)` as
* an object with no `body`, so every catch site in the app would report its
* generic fallback and the timeout would be invisible in the very situation it
* exists to expose. Rethrowing as `ApiError` means ~330 existing call sites
* report it correctly without being touched.
*
* Only a TIMEOUT is converted. A deliberate cancellation aborts with
* `AbortError` and is left alone — a caller that cancelled its own request
* does not want it reported as a server failure.
*/
async function request<T>(path: string, init: RequestInit, opts?: RequestOpts): Promise<T> {
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
let res: Response;
try {
res = await fetch(path, { ...init, signal: AbortSignal.timeout(timeoutMs) });
} catch (e) {
if (e instanceof DOMException && e.name === "TimeoutError") {
throw new ApiError(CLIENT_TIMEOUT_STATUS, {
error: `The server did not answer within ${Math.round(timeoutMs / 1000)}s.`,
});
}
throw e;
}
return handleResponse<T>(res, path);
}
/** JSON body headers — the three write verbs sent an identical literal each. */
const JSON_HEADERS = { "Content-Type": "application/json" };
export function apiGet<T>(path: string, opts?: RequestOpts): Promise<T> {
return request<T>(path, {}, opts);
}
export function apiPost<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
return request<T>(path, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
}
export function apiPut<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
return request<T>(path, { method: "PUT", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
}
export function apiPatch<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
return request<T>(path, { method: "PATCH", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
}
export function apiDelete(path: string, opts?: RequestOpts): Promise<void> {
return request<void>(path, { method: "DELETE" }, opts);
}
// ---------------------------------------------------------------------------
// Sharing, Groups, Notifications, User search
// ---------------------------------------------------------------------------
export interface ShareEntry {
id: number
permission: string
shared_with_user_id: number | null
shared_with_group_id: number | null
username?: string
group_name?: string
invited_by: number
created_at: string
}
export interface GroupEntry {
id: number
name: string
description: string | null
created_by: number | null
member_count: number
is_member: boolean
created_at: string
updated_at: string
}
export interface GroupMember {
id: number
group_id: number
user_id: number
role: string
username: string
email: string | null
created_at: string
}
export interface NotificationEntry {
id: number
user_id: number
type: string
payload: Record<string, unknown>
read_at: string | null
created_at: string
}
export interface UserSearchResult {
id: number
username: string
}
// --- User search ---
export const searchUsers = (q: string) =>
apiGet<{ users: UserSearchResult[] }>(`/api/users/search?q=${encodeURIComponent(q)}`).then(r => r.users)
// --- Groups ---
export const listGroups = () => apiGet<{ groups: GroupEntry[] }>('/api/groups').then(r => r.groups)
export const createGroup = (name: string, description?: string) =>
apiPost<GroupEntry>('/api/groups', { name, description })
export const updateGroup = (id: number, data: { name?: string; description?: string }) =>
apiPatch<GroupEntry>(`/api/groups/${id}`, data)
export const deleteGroup = (id: number) => apiDelete(`/api/groups/${id}`)
export const getGroupDetail = (id: number) =>
apiGet<GroupEntry & { members: GroupMember[] }>(`/api/groups/${id}`)
export const listGroupMembers = (id: number) =>
apiGet<{ members: GroupMember[] }>(`/api/groups/${id}/members`).then(r => r.members)
export const addGroupMember = (groupId: number, userId: number, role: string) =>
apiPost<GroupMember>(`/api/groups/${groupId}/members`, { user_id: userId, role })
export const updateGroupMember = (groupId: number, userId: number, role: string) =>
apiPatch<GroupMember>(`/api/groups/${groupId}/members/${userId}`, { role })
export const removeGroupMember = (groupId: number, userId: number) =>
apiDelete(`/api/groups/${groupId}/members/${userId}`)
// --- Project shares ---
export const listProjectShares = (projectId: number) =>
apiGet<{ shares: ShareEntry[] }>(`/api/projects/${projectId}/shares`).then(r => r.shares)
export const createProjectShare = (projectId: number, body: { user_id?: number; group_id?: number; permission: string }) =>
apiPost<ShareEntry>(`/api/projects/${projectId}/shares`, body)
export const updateProjectShare = (projectId: number, shareId: number, permission: string) =>
apiPatch<ShareEntry>(`/api/projects/${projectId}/shares/${shareId}`, { permission })
export const deleteProjectShare = (projectId: number, shareId: number) =>
apiDelete(`/api/projects/${projectId}/shares/${shareId}`)
// --- Note / task shares ---
export const listNoteShares = (noteId: number) =>
apiGet<{ shares: ShareEntry[] }>(`/api/notes/${noteId}/shares`).then(r => r.shares)
export const createNoteShare = (noteId: number, body: { user_id?: number; group_id?: number; permission: string }) =>
apiPost<ShareEntry>(`/api/notes/${noteId}/shares`, body)
export const updateNoteShare = (noteId: number, shareId: number, permission: string) =>
apiPatch<ShareEntry>(`/api/notes/${noteId}/shares/${shareId}`, { permission })
export const deleteNoteShare = (noteId: number, shareId: number) =>
apiDelete(`/api/notes/${noteId}/shares/${shareId}`)
// --- Shared-with-me ---
export const getSharedWithMe = () =>
apiGet<{ projects: Record<string, unknown>[]; notes: Record<string, unknown>[] }>('/api/shared-with-me')
// --- In-app notifications ---
export const getNotifications = (all = false) =>
apiGet<{ notifications: NotificationEntry[] }>(`/api/notifications${all ? '?all=true' : ''}`).then(r => r.notifications)
export const getNotificationCount = () =>
apiGet<{ count: number }>('/api/notifications/count').then(r => r.count)
export const markNotificationRead = (id: number) => apiPost<void>(`/api/notifications/${id}/read`, {})
export const markAllNotificationsRead = () => apiPost<{ marked: number }>('/api/notifications/read-all', {})
export interface SSEStreamHandle {
close(): void;
/** Resolves when the stream closes (normally or via error/abort). */
done: Promise<void>;
}
export interface SSENamedEvent {
id: number;
event: string;
data: Record<string, unknown>;
}
export function apiSSEStream(
path: string,
onEvent: (event: SSENamedEvent) => void,
options?: { lastEventId?: number; signal?: AbortSignal },
): SSEStreamHandle {
const controller = new AbortController();
const combinedSignal = options?.signal
? AbortSignal.any([controller.signal, options.signal])
: controller.signal;
const headers: Record<string, string> = {};
if (options?.lastEventId !== undefined) {
headers["Last-Event-ID"] = String(options.lastEventId);
}
const done = (async () => {
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
const connect = connectDeadline(combinedSignal);
let res: Response;
try {
res = await fetch(path, { headers, signal: connect.signal });
} finally {
connect.settle();
}
if (!res.ok) {
let body: Record<string, unknown> = {};
try {
body = await res.json();
} catch {
body = { error: `API error: ${res.status}` };
}
if (res.status === 401 && !path.startsWith("/api/auth/")) {
const { default: router } = await import("@/router/index");
router.push({ name: "login" });
}
throw new ApiError(res.status, body);
}
const reader = res.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
// SSE field parsing state
let currentId = -1;
let currentEvent = "message";
let currentData = "";
function dispatch() {
if (!currentData) return;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(currentData);
} catch {
return;
}
onEvent({ id: currentId, event: currentEvent, data: parsed });
// Reset for next event
currentEvent = "message";
currentData = "";
}
function processLine(line: string) {
if (line === "") {
// Empty line = end of event
dispatch();
return;
}
if (line.startsWith(":")) return; // comment / keepalive
const colonIdx = line.indexOf(":");
let field: string;
let value: string;
if (colonIdx === -1) {
field = line;
value = "";
} else {
field = line.slice(0, colonIdx);
value = line.slice(colonIdx + 1);
if (value.startsWith(" ")) value = value.slice(1);
}
switch (field) {
case "id":
currentId = parseInt(value, 10);
break;
case "event":
currentEvent = value;
break;
case "data":
currentData += value;
break;
}
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
processLine(line);
}
}
// Process remaining buffer
if (buffer) {
processLine(buffer);
}
dispatch();
})().catch(() => {
// Stream closed or aborted — handled by caller via reconnection
});
return { close: () => controller.abort(), done };
}
export async function apiStreamPost(
path: string,
body: unknown,
onChunk: (data: Record<string, unknown>) => void
): Promise<void> {
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
const connect = connectDeadline(new AbortController().signal);
let res: Response;
try {
res = await fetch(path, {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify(body),
signal: connect.signal,
});
} finally {
connect.settle();
}
if (!res.ok) {
let errBody: Record<string, unknown> = {};
try {
errBody = await res.json();
} catch {
errBody = { error: `API error: ${res.status}` };
}
if (res.status === 401 && !path.startsWith("/api/auth/")) {
const { default: router } = await import("@/router/index");
router.push({ name: "login" });
}
throw new ApiError(res.status, errBody);
}
const reader = res.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
function processLine(line: string) {
const trimmed = line.trim();
if (trimmed.startsWith("data: ")) {
let data;
try {
data = JSON.parse(trimmed.slice(6));
} catch {
return; // Skip malformed JSON lines
}
onChunk(data);
}
}
function processBuffer() {
const lines = buffer.split("\n");
// Keep the last (possibly incomplete) line in the buffer
buffer = lines.pop() || "";
for (const line of lines) {
processLine(line);
}
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
processBuffer();
}
} catch {
// Stream may close with a network error after all data was sent.
}
// Flush any remaining complete lines in the buffer
if (buffer.trim()) {
// The buffer may contain one or more unprocessed lines
const remaining = buffer;
buffer = "";
for (const line of remaining.split("\n")) {
processLine(line);
}
}
}
// ─── API Keys ─────────────────────────────────────────────────────────────────
export interface ApiKeyEntry {
id: number
name: string
scope: string
key_prefix: string
last_used_at: string | null
}
export const listApiKeys = () =>
apiGet<{ api_keys: ApiKeyEntry[] }>('/api/api-keys').then(r => r.api_keys)
export const createApiKey = (name: string, scope: 'read' | 'write') =>
apiPost<{ key: string; api_key: ApiKeyEntry }>('/api/api-keys', { name, scope })
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
// ── User Profile ─────────────────────────────────────────────────────────────
export interface UserProfile {
display_name: string
job_title: string
industry: string
expertise_level: 'novice' | 'intermediate' | 'expert'
response_style: 'concise' | 'balanced' | 'detailed'
tone: 'casual' | 'professional' | 'technical'
interests: string[]
work_schedule: { days?: string[]; start?: string; end?: string }
}
export const getProfile = () => apiGet<UserProfile>('/api/profile')
export const updateProfile = (data: Partial<UserProfile>) =>
apiPut<UserProfile>('/api/profile', data)
// ── Note Versions (pinning) ──────────────────────────────────────────────────
import type { NoteVersion } from '../types/task'
/** Mark a note version as manually pinned, optionally with a commit-note
* label. Re-calling with a different label updates the label. */
export const pinNoteVersion = (noteId: number, versionId: number, label?: string | null) =>
apiPost<NoteVersion>(
`/api/notes/${noteId}/versions/${versionId}/pin`,
{ label: label ?? null },
)
/** Downgrade a manually-pinned version back to rolling. Does NOT delete
* the row — older rows may be FIFO-pruned by the next autosave. */
export const unpinNoteVersion = (noteId: number, versionId: number) =>
apiDelete(`/api/notes/${noteId}/versions/${versionId}/pin`)