import type { AgentKey, AuditEntry, BillingSummary, CellValue, ColumnFormat, ColumnMeta, ColumnType, Filter, ImportColumnOverride, ImportGroupOverride, ImportPreview, ImportResult, InviteInfo, Locale, Member, MetaResponse, NormalizeMerge, NormalizePlan, PendingInvite, PlanId, PublicConfig, Role, Row, RowsResponse, Rule, SessionResponse, Sort, Summaries, SummaryResponse, TableMeta, Tightening, TightenPlan, TightenWalkPlan, ViewDef, VoiceAssignment, Workspace, } from '@wellformed/shared'; export class ApiError extends Error { constructor( readonly status: number, readonly code: string, message: string, readonly details?: unknown, ) { super(message); this.name = 'ApiError'; } get isAuth(): boolean { return this.status === 401; } } async function request(path: string, init: RequestInit = {}): Promise { const method = (init.method ?? 'GET').toUpperCase(); const headers = new Headers(init.headers); if (init.body !== undefined) headers.set('content-type', 'application/json'); let response: Response; try { response = await fetch(path, { ...init, headers, // The session lives in an httpOnly cookie; it must ride along. credentials: 'same-origin', }); } catch (cause) { throw new ApiError(0, 'network', 'Cannot reach the Wellformed server.', cause); } if (response.status === 204) return undefined as T; const text = await response.text(); const body: unknown = text ? safeJson(text) : null; if (!response.ok) { const shaped = body as { error?: string; message?: string; details?: unknown } | null; throw new ApiError( response.status, shaped?.error ?? 'error', shaped?.message ?? `${method} ${path} failed with ${response.status}.`, shaped?.details, ); } return body as T; } function safeJson(text: string): unknown { try { return JSON.parse(text); } catch { return { message: text }; } } const json = (body: unknown): RequestInit => ({ body: JSON.stringify(body) }); /** * The workspace every data call is addressed to. * * One document shows one workspace — switching is a full navigation, see * `state/route.ts` — so this is a property of the page, not something worth * threading through every call site. `main.tsx` sets it once from the URL * before anything renders. */ let activeSlug: string | null = null; export function setActiveWorkspace(slug: string | null): void { activeSlug = slug; } export function activeWorkspace(): string | null { return activeSlug; } /** Build a workspace-scoped API path. Throws rather than silently calling nowhere. */ function w(path: string): string { if (!activeSlug) { throw new ApiError(0, 'no_workspace', 'No workspace is open.'); } return `/api/w/${encodeURIComponent(activeSlug)}${path}`; } export interface RowsQuery { table: string; viewId?: string | null; filters?: Filter[]; /** `undefined` keeps the view's sort; `null` explicitly clears it. */ sort?: Sort | null; /** `undefined` keeps the view's hidden columns; a list replaces them whole. */ hidden?: string[]; search?: string; limit?: number; offset?: number; } function rowsUrl(query: RowsQuery): string { const params = new URLSearchParams(); if (query.viewId) params.set('view', query.viewId); if (query.filters?.length) params.set('filters', JSON.stringify(query.filters)); if (query.sort !== undefined) params.set('sort', JSON.stringify(query.sort)); if (query.hidden !== undefined) params.set('hidden', JSON.stringify(query.hidden)); if (query.search) params.set('q', query.search); params.set('limit', String(query.limit ?? 200)); params.set('offset', String(query.offset ?? 0)); return w(`/tables/${encodeURIComponent(query.table)}/rows?${params}`); } /** The aggregate row over the same rows, so it takes the same scope minus the page. */ export interface SummaryQuery { table: string; viewId?: string | null; filters?: Filter[]; /** The grid's hidden columns, so a search scopes to the same rows it shows. */ hidden?: string[]; search?: string; summaries: Summaries; } function summaryUrl(query: SummaryQuery): string { const params = new URLSearchParams(); if (query.viewId) params.set('view', query.viewId); if (query.filters?.length) params.set('filters', JSON.stringify(query.filters)); if (query.hidden !== undefined) params.set('hidden', JSON.stringify(query.hidden)); if (query.search) params.set('q', query.search); params.set('summaries', JSON.stringify(query.summaries)); return w(`/tables/${encodeURIComponent(query.table)}/summary?${params}`); } export interface WriteResult { row: Row; workflows: string[]; } export interface DeleteResult { restore: Record; label: string; } /** * A signup makes an account and nothing else; the workspace comes after, * from the picker. */ export interface SignupInput { email: string; name: string; password: string; /** The landing page impression this signup came from, when there was one. */ voice?: string; } /** What to build into a new workspace: nothing, the demo's tables, or the demo whole. */ export type DemoChoice = 'none' | 'structure' | 'full'; /** * What to read: pasted text, or an uploaded file's bytes as base64. * * Exactly one of the two, and the server refuses both together rather than * picking. `headerRow`, `groupRow`, `sheet` and `columns` are the guesses the * preview makes and the operator can overrule; sending none means "guess * again". `groupRow: null` turns the group fold off explicitly. */ export interface ImportRequest { csv?: string; file?: { name: string; data: string }; headerRow?: number; groupRow?: number | null; /** Trailing totals rows to leave out; 0 keeps every row. */ footerRows?: number; sheet?: string; columns?: ImportColumnOverride[]; /** Corrections to the group headings; one renames a whole group's columns. */ groups?: ImportGroupOverride[]; } export const api = { health: () => request<{ ok: boolean }>('/api/health'), /** What this install offers, readable with no session at all. */ publicConfig: () => request('/api/public/config'), /** Which lines of the landing page this visit gets. See `state/voice.ts`. */ voice: () => request('/api/public/voice'), /** A person, not a crawler, looked at it. Fire and forget. */ voiceSeen: (id: string) => request(`/api/public/voice/${encodeURIComponent(id)}/seen`, { method: 'POST' }), // ---- the account, installation-wide ------------------------------------- me: () => request('/api/auth/me'), login: (email: string, password: string) => request('/api/auth/login', { method: 'POST', ...json({ email, password }) }), signup: (input: SignupInput) => request('/api/auth/signup', { method: 'POST', ...json(input) }), logout: () => request<{ ok: true }>('/api/auth/logout', { method: 'POST' }), renameAccount: (name: string) => request('/api/auth/me', { method: 'PATCH', ...json({ name }) }), changePassword: (current: string, next: string) => request<{ ok: true }>('/api/auth/password', { method: 'POST', ...json({ current, next }) }), // ---- locked out, and confirming the address ----------------------------- forgotPassword: (email: string) => request<{ ok: true; delivery: 'email' | 'none' }>('/api/auth/forgot', { method: 'POST', ...json({ email }), }), readReset: (token: string) => request<{ email: string; name: string; expiresAt: string }>( `/api/auth/reset/${encodeURIComponent(token)}`, ), resetPassword: (token: string, password: string) => request(`/api/auth/reset/${encodeURIComponent(token)}`, { method: 'POST', ...json({ password }), }), sendVerification: () => request<{ ok: true; delivery: 'email' | 'none' | 'already' }>('/api/auth/verify', { method: 'POST', }), confirmEmail: (token: string) => request<{ ok: true; email: string }>(`/api/auth/verify/${encodeURIComponent(token)}`, { method: 'POST', }), // ---- workspaces --------------------------------------------------------- workspaces: () => request<{ workspaces: Workspace[] }>('/api/workspaces'), createWorkspace: (input: { name: string; slug?: string; demo?: DemoChoice; locale?: Locale }) => request<{ workspace: Workspace }>('/api/workspaces', { method: 'POST', ...json(input) }), updateWorkspace: (input: { name?: string; slug?: string; currency?: string; locale?: Locale }) => request<{ workspace: Workspace }>(w('/settings'), { method: 'PATCH', ...json(input) }), deleteWorkspace: (slug: string) => request<{ ok: true }>(w(`/settings?confirm=${encodeURIComponent(slug)}`), { method: 'DELETE' }), leaveWorkspace: () => request<{ ok: true }>(w('/leave'), { method: 'POST' }), exportPath: () => w('/export'), // ---- people in this workspace ------------------------------------------- members: () => request<{ members: Member[] }>(w('/members')), updateMember: (id: string, input: { role: Role }) => request<{ member: Member }>(w(`/members/${encodeURIComponent(id)}`), { method: 'PATCH', ...json(input), }), removeMember: (id: string) => request<{ ok: true }>(w(`/members/${encodeURIComponent(id)}`), { method: 'DELETE' }), invites: () => request<{ invites: PendingInvite[] }>(w('/invites')), createInvite: (input: { email: string; role: Role }) => request<{ invite: PendingInvite }>(w('/invites'), { method: 'POST', ...json(input) }), revokeInvite: (id: string) => request<{ ok: true }>(w(`/invites/${encodeURIComponent(id)}`), { method: 'DELETE' }), readInvite: (token: string) => request<{ invite: InviteInfo }>(`/api/invites/${encodeURIComponent(token)}`), acceptInvite: (token: string, input: { name?: string; password?: string } = {}) => request<{ workspace: { slug: string; name: string; role: Role } }>( `/api/invites/${encodeURIComponent(token)}/accept`, { method: 'POST', ...json(input) }, ), // ---- keys an AI assistant works through --------------------------------- agentKeys: () => request<{ keys: AgentKey[] }>(w('/keys')), /** The response carries `secret`, and it is the only time it ever will. */ createAgentKey: (input: { label: string; ceiling: Role; expiresInDays?: number | null }) => request<{ key: AgentKey }>(w('/keys'), { method: 'POST', ...json(input) }), revokeAgentKey: (id: string) => request<{ ok: true }>(w(`/keys/${encodeURIComponent(id)}`), { method: 'DELETE' }), // ---- what this workspace pays for --------------------------------------- billing: () => request(w('/billing')), checkout: (plan: PlanId, billingEmail?: string) => request<{ url: string }>(w('/billing/checkout'), { method: 'POST', ...json({ plan, billingEmail }), }), setBillingContact: (billingEmail: string) => request<{ billingEmail: string | null }>(w('/billing/contact'), { method: 'PATCH', ...json({ billingEmail }), }), billingPortal: () => request<{ url: string }>(w('/billing/portal')), // ---- getting something into an empty workspace -------------------------- previewImport: (input: ImportRequest & { table?: string }) => request(w('/import/preview'), { method: 'POST', ...json(input) }), importSheet: ( input: ImportRequest & { /** Append into this table, or omit to create one with these names. */ table?: string; name?: string; label?: string; }, ) => request(w('/import'), { method: 'POST', ...json(input) }), // ---- inside the open workspace ------------------------------------------ meta: () => request(w('/meta')), rows: (query: RowsQuery) => request(rowsUrl(query)), summary: (query: SummaryQuery) => request(summaryUrl(query)), createRow: (table: string, values: Record, id?: string) => request(w(`/tables/${encodeURIComponent(table)}/rows`), { method: 'POST', ...json(id ? { values, id } : { values }), }), updateRow: (table: string, id: string, values: Record) => request(w(`/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(id)}`), { method: 'PATCH', ...json({ values }), }), deleteRow: (table: string, id: string) => request(w(`/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(id)}`), { method: 'DELETE', }), /** Labels only — the server derives every identifier. */ createTable: (input: { label: string; displayLabel?: string; group?: string }) => request<{ table: TableMeta }>(w('/tables'), { method: 'POST', ...json(input) }), /** One flat order for all tables; the sidebar's groups are runs within it. */ reorderTables: (order: string[]) => request<{ ok: true }>(w('/tables/reorder'), { method: 'POST', ...json({ order }) }), /** The label, row-label column and sidebar group; the SQL name is not up for changing. */ updateTable: (table: string, input: { label?: string; display?: string; group?: string | null }) => request<{ table: TableMeta }>(w(`/tables/${encodeURIComponent(table)}`), { method: 'PATCH', ...json(input), }), dropTable: (table: string) => request<{ ok: true }>(w(`/tables/${encodeURIComponent(table)}`), { method: 'DELETE' }), addColumn: ( table: string, input: { /** What the column is called; the identifier is derived from it. */ label: string; type: ColumnType; options?: string[]; target?: string; expr?: string; format?: ColumnFormat | null; required?: boolean; unique?: boolean; sequence?: string | null; }, ) => request<{ column: ColumnMeta }>(w(`/tables/${encodeURIComponent(table)}/columns`), { method: 'POST', ...json(input), }), /** * What tightening a column would cost, before it costs it. Read-only, and * asked again on every keystroke that changes the proposal. */ planTighten: (table: string, column: string, input: Tightening) => request<{ plan: TightenPlan }>( w(`/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}/tighten/plan`), { method: 'POST', ...json(input) }, ), tighten: (table: string, column: string, input: Tightening) => request<{ column: ColumnMeta; plan: TightenPlan; cleared: number }>( w(`/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}/tighten`), { method: 'POST', ...json(input) }, ), /** * The tighten walk: every stored column looked at once, each with what its * values say it really is. Read-only; the steps are applied through * `normalize` and `tighten`, one column at a time. */ walkTighten: (table: string) => request<{ plan: TightenWalkPlan }>(w(`/tables/${encodeURIComponent(table)}/tighten/walk`), { method: 'POST', ...json({}), }), /** * The whole table read for spellings that are the same answer written * differently. Read-only; the walk's merge step. */ planNormalize: (table: string) => request<{ plan: NormalizePlan }>(w(`/tables/${encodeURIComponent(table)}/normalize/plan`), { method: 'POST', ...json({}), }), normalize: (table: string, column: string, merges: NormalizeMerge[]) => request<{ column: ColumnMeta; changed: number }>( w(`/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}/normalize`), { method: 'POST', ...json({ merges }) }, ), updateColumn: ( table: string, column: string, input: { /** What the column is called from now on; the identifier stays. */ label?: string; options?: string[]; expr?: string; format?: ColumnFormat | null; required?: boolean; sequence?: string | null; }, ) => request<{ column: ColumnMeta }>( w(`/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}`), { method: 'PATCH', ...json(input) }, ), dropColumn: (table: string, column: string) => request<{ ok: true }>( w(`/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}`), { method: 'DELETE' }, ), reorderColumns: (table: string, order: string[]) => request<{ table: unknown }>(w(`/tables/${encodeURIComponent(table)}/columns/reorder`), { method: 'POST', ...json({ order }), }), setDisplayColumn: (table: string, display: string) => request<{ table: unknown }>(w(`/tables/${encodeURIComponent(table)}`), { method: 'PATCH', ...json({ display }), }), validateFormula: (table: string, expr: string) => request<{ ok: boolean; error: string | null }>(w('/formula/validate'), { method: 'POST', ...json({ table, expr }), }), createView: ( table: string, input: { name: string; filters: Filter[]; sort: Sort | null; summaries?: Summaries; hidden?: string[]; }, ) => request<{ view: ViewDef }>(w(`/tables/${encodeURIComponent(table)}/views`), { method: 'POST', ...json(input), }), updateView: ( id: string, input: { name?: string; filters?: Filter[]; sort?: Sort | null; summaries?: Summaries; hidden?: string[]; }, ) => request<{ view: ViewDef }>(w(`/views/${encodeURIComponent(id)}`), { method: 'PATCH', ...json(input), }), deleteView: (id: string) => request<{ ok: true }>(w(`/views/${encodeURIComponent(id)}`), { method: 'DELETE' }), setRuleEnabled: (id: string, enabled: boolean) => request<{ rule: Rule }>(w(`/rules/${encodeURIComponent(id)}`), { method: 'PATCH', ...json({ enabled }), }), audit: (query: { table?: string; rowId?: string; before?: number; limit?: number }) => { const params = new URLSearchParams(); if (query.table) params.set('table', query.table); if (query.rowId) params.set('rowId', query.rowId); if (query.before) params.set('before', String(query.before)); params.set('limit', String(query.limit ?? 60)); return request<{ entries: AuditEntry[]; hasMore: boolean }>(w(`/audit?${params}`)); }, }; export type Api = typeof api;