"use client"; /** * Typed fetch client for the Factory REST API. * * - Base URL: NEXT_PUBLIC_API_URL (see .env.example). * - Auth: Bearer access token from lib/auth-store (memory only). On 401 the * client performs a **single-flight** POST /auth/refresh (the httpOnly * `factory_refresh` cookie travels automatically with credentials: * "include"; the API rotates the cookie and returns the new access token) * and retries the original request once. * - Errors: JSON `{ error: { code, message, status, details?, requestId? } }` * (the `@factory/shared` error model) → thrown as `ApiError`. */ import { authStore } from "./auth-store"; import type { SavedSearch as SavedSearchRow } from "./search"; import { API_BASE } from "./constants"; import type { Canvas } from "@factory/whiteboard"; /** * Client-safe request id (TASK-0135 propagation: web → api). A v4 UUID built * from Math.random (no Node `crypto` in a "use client" module) — only * uniqueness matters; the API's middleware adopts it into the span/`requestId` * log binding and echoes it back in the error envelope. */ function newRequestId(): string { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === "x" ? r : (r & 0x3) | 0x8; return v.toString(16); }); } import { enqueueMutation, registerOutboxTransport, settleMutationFailure, settleMutationSuccess } from "@/stores/offlineQueue"; import type { OutboxItem, OutboxResult } from "@factory/collaboration/outbox"; import { BLOCK_TYPES } from "./types"; import type { ActivityRow, Block, BlockPatch, BlockType, Comment, Database, DatabaseRecord, DatabaseProperty, DatabaseView, FileMeta, Notification, Page, PageShare, PageTreeEntry, PageVersion, VersionDiff, VersionPreview, Position, PropertyType, SearchQueryInput, SearchQueryResult, SearchResult, SearchSuggestion, SearchType, PageReview, Task, Team, TemplateInfo, TemplateVersionInfo, User, ViewType, Workspace, WorkspaceInvite, WorkspaceMember, WorkspaceRole, WorkspaceStats } from "./types"; /* ---------------------------------------------------------------- errors */ export class ApiError extends Error { readonly code: string; readonly status: number; readonly details?: unknown; readonly requestId?: string; constructor(code: string, status: number, message: string, details?: unknown, requestId?: string) { super(message); this.name = "ApiError"; this.code = code; this.status = status; this.details = details; this.requestId = requestId; } } /** True for "safe" errors worth retrying after a transient failure. */ export function isApiError(err: unknown): err is ApiError { return err instanceof ApiError; } /** * True when the server answered a refresh with 401 + reason * `refresh_token_stale` — the presented refresh token was already rotated by * a CONCURRENT refresh (security-architecture.md §1.2 "stale retry"). The * rotation chain moved on without the session being killed: the httpOnly * cookie in this tab's jar may already hold the live successor (when the * racing rotation's response was observed by the jar), so the tab is usually * still fully authenticated. Callers may retry ONCE; a second stale answer * (or any other failure) is terminal. */ export function isStaleRefreshError(err: unknown): boolean { if (!isApiError(err) || err.status !== 401) return false; const details = err.details; if (!Array.isArray(details)) return false; return details.some( (d) => typeof d === "object" && d !== null && (d as { reason?: string }).reason === "refresh_token_stale" ); } /* -------------------------------------------------------------- helpers */ export function buildQuery(params: Record): string { const parts: string[] = []; for (const [key, value] of Object.entries(params)) { if (value === null || value === undefined) continue; parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); } return parts.length > 0 ? `?${parts.join("&")}` : ""; } export interface ApiInit { method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; body?: unknown; signal?: AbortSignal; /** Set true to skip the Authorization header (auth endpoints). */ noAuth?: boolean; /** Extra headers (e.g. Content-Type overrides). */ headers?: Record; /** The per-resource ordering key (the outbox's FIFO chain) for mutations. */ resource?: { type: string; id: string }; /** * The idempotency token (INTERNAL — only the outbox transport sets this, on * a replay). A fresh online write never sets it (the API middleware is a * no-op without the header). Do not pass this from feature code. */ idempotencyKey?: string; } /** * Single-flight silent refresh (cookie → new access token), shared by BOTH * the 401-retry path in `doFetch` AND the startup bootstrap (authApi.refresh * / use-auth.ts). One tab must never rotate its refresh cookie twice in * parallel: the server rotates the cookie on every refresh, so two in-flight * rotations race — the loser's response carries a rotated-out token that the * httpOnly jar then plays back on the next load, tripping reuse detection * (or, under the §1.2 stale-retry branch, a benign 401). Dedupe here so a * burst of 401s AND a double-mounted (StrictMode) bootstrap each trigger * exactly one POST /auth/refresh. */ let refreshInFlight: Promise | null = null; function refreshAccessToken(): Promise { if (!refreshInFlight) { refreshInFlight = (async () => { try { // No Authorization header on purpose: the endpoint is on the auth // whitelist and authenticates via the httpOnly refresh cookie. // (The Bearer token that 401'd is expired — sending it would only // mask the failure mode.) const res = await fetch(`${API_BASE}/auth/refresh`, { method: "POST", credentials: "include" }); if (!res.ok) { // Parse the error body for the reason code (stale vs reused vs // expired) — doFetch's retry and the bootstrap's single retry both // branch on `refresh_token_stale`. let code = "UNAUTHORIZED"; let details: unknown; try { const body = (await res.json()) as { error?: { code?: string; details?: unknown }; }; code = body?.error?.code ?? code; details = body?.error?.details; } catch { /* non-JSON error body — keep the defaults */ } throw new ApiError(code, res.status, `Refresh failed (${res.status})`, details); } // The API contract (auth.service.ts `refresh` → `issueAccess` + // rotation): { accessToken, refreshToken, expiresIn, session? } — // NO user object. Keep the current user; refresh rotates the cookie // and re-issues the access token. const json = (await res.json()) as AuthTokens; if (!json.accessToken) throw new ApiError("UNAUTHORIZED", 200, "Refresh response missing access token"); authStore.setAccessToken(json.accessToken); return json; } finally { refreshInFlight = null; } })(); } return refreshInFlight; } async function doFetch(path: string, init: ApiInit, retried: boolean): Promise { const headers: Record = { Accept: "application/json", ...init.headers }; if (!init.noAuth) { const token = authStore.accessToken; if (token) headers.Authorization = `Bearer ${token}`; } // TASK-0135: web → api correlation. The API middleware adopts the inbound // id (span attr + requestId log binding) and echoes it in error envelopes. if (!("X-Request-Id" in headers)) headers["X-Request-Id"] = newRequestId(); // The outbox's idempotency token (R1): sent ONLY on a replay (an offline // mutation) — a fresh online write has no key (the API middleware is a // no-op without the header, so existing endpoints are unaffected). if (init.idempotencyKey) headers["Idempotency-Key"] = init.idempotencyKey; // Callers pass `body` either as an object (stringify it) or as an ALREADY- // STRINGIFIED string (the use-database hooks pass `body: JSON.stringify(x)`). // Without this guard the string body is JSON.stringify'd a SECOND time, // producing a double-encoded body the server rejects with 400 // (VALIDATION_ERROR: `Unexpected token '"'`). Send string bodies verbatim. const rawBody = init.body; const body = rawBody === undefined || rawBody === null ? undefined : typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody); if (body !== undefined) headers["Content-Type"] = "application/json"; const res = await fetch(`${API_BASE}${path}`, { method: init.method ?? "GET", headers, body, credentials: "include", signal: init.signal }); // 401 → single-flight refresh, then retry exactly once. A stale-retry // 401 from the refresh itself is NOT an infinite loop: `refreshAccessToken` // re-throws the server's reason, and `doFetch` is only re-entered on a // SUCCESSFUL rotation (fresh access token in the store). if (res.status === 401 && !init.noAuth && !retried) { try { await refreshAccessToken(); return doFetch(path, init, true); } catch (err) { // Refresh failed (stale, reused, expired, network). Surface the // original 401 so the caller's ApiError carries the right reason — // but only for errors that are NOT the original (non-refresh) 401. // A reused-token kill means the whole session is gone: the caller // gets a 401 and use-auth.ts drives the re-login. if (isStaleRefreshError(err)) throw err; // caller can retry once return res; } } return res; } /** * Core typed request. `path` may include a query string (use buildQuery). * 204 → resolves undefined. JSON `{error}` or non-2xx → throws ApiError. */ export async function apiFetch(path: string, init: ApiInit = {}): Promise { const res = await doFetch(path, init, false); if (res.status === 204) return undefined as T; let json: unknown = null; const text = await res.text(); if (text.length > 0) { try { json = JSON.parse(text); } catch { json = null; } } if (!res.ok) { const err = (json as { error?: { code?: string; message?: string; details?: unknown; requestId?: string } } | null)?.error; throw new ApiError( err?.code ?? "INTERNAL", res.status, err?.message ?? `Request failed with status ${res.status}`, err?.details, err?.requestId ); } return json as T; } /** * A RAW response (the body is not JSON — the CSV export returns `text/csv`). * Reuses `doFetch` so the Bearer token + 401-refresh behavior is identical to * `apiFetch`; the caller owns the body (`.text()` / `.json()`) and any error * envelope. */ export async function apiFetchRaw(path: string, init: ApiInit = {}): Promise { return doFetch(path, init, false); } /* --------------------------------------------------- offline outbox (R1) */ /** * The offline gate (R1): a MUTATING call checks connectivity — online → * `apiFetch` (send now, no key — a fresh online write needs no idempotency * token); OFFLINE → `enqueueMutation` (the mutation suspends in the outbox, * replayed on reconnect with its `Idempotency-Key`). GETs are never tagged. */ export function isOffline(): boolean { return typeof navigator !== "undefined" && navigator.onLine === false; } /** * A REST mutation (the wrapped `apiFetch` for every POST/PATCH/PUT/DELETE). * `resource` scopes the per-resource FIFO ordering; when omitted the outbox * store derives it from the path (see `deriveOutboxResource`). */ export async function mutatingApiFetch( path: string, init: ApiInit, resource?: { type: string; id: string } ): Promise { const method = init.method ?? "POST"; if (method === "GET" || !isOffline() || !authStore.accessToken) { // Online (or an auth-flow mutation with no session) → send now. return apiFetch(path, init); } // Offline + a mutation + a session → the outbox (replayed on reconnect with // the Idempotency-Key; resolves with the real 2xx body, rejects on a 4xx). return enqueueMutation({ method, path, body: (init.body ?? undefined) as Record | undefined, ...(resource ? { resource } : {}), ...(init.idempotencyKey ? { idempotencyKey: init.idempotencyKey } : {}) }); } /** * The outbox TRANSPORT (registered once) — sends a persisted item with ITS * OWN `Idempotency-Key` (the item's key; a 5xx retry reuses it, a user Retry * gets a fresh one from `OutboxQueue.retry`). The response settles the * caller promise (2xx → resolve; a 4xx → reject + the failed list; a 5xx / * network → keep pending for the backoff retry). */ registerOutboxTransport(async (item: OutboxItem): Promise => { try { const body = await apiFetch(item.path, { method: item.method, body: item.body, idempotencyKey: item.key }); settleMutationSuccess(item, body); return { ok: true, status: 200 }; } catch (err) { if (isApiError(err) && err.status >= 400 && err.status < 500) { // A 4xx (e.g. a permission revoked while offline) → the failed list. // (The title-consent 409 path routes here; the UI surfaces the LWW // outcome — see offlineQueue's failed dialog + `lww`.) settleMutationFailure(item, err); } return { ok: false, status: isApiError(err) ? err.status : undefined, error: err instanceof Error ? err.message : String(err) }; } }); /* ---------------------------------------------------------------- auth */ /** * Auth API shapes — aligned to the real API (auth.service.ts, S10): * - login → 200 { user, accessToken, refreshToken, expiresIn, session } * - register → 201 { user, workspace, personalWorkspace, accessToken, … } * (workspace = the team workspace when one was created at registration, * else the personal workspace; the personal one is ALSO always returned) * - me → 200 { user, workspaces } (Bearer-gated — NO token in the body; * the access token is re-issued via /auth/refresh, silent refresh) * - logout → 201 { loggedOut } (Bearer-gated: the API resolves the user * from the token to scope the session revocation; the httpOnly refresh * cookie rides along and is cleared) * The stale `{ token, workspaceId }` shapes (W4) were the Phase-1 draft * contract that never matched the implemented API. */ export interface LoginInput { email: string; password: string; } export interface RegisterInput { name: string; email: string; password: string; workspaceName: string; } export interface AuthTokens { accessToken: string; refreshToken: string; expiresIn: number; session: { id: string; expiresAt?: string }; } export interface LoginResult extends AuthTokens { user: User; } export interface RegisterResult extends AuthTokens { user: User; /** The workspace to land in (team workspace when created, else personal). */ workspace: { id: string; name: string }; personalWorkspace: { id: string; name: string }; } export interface MeWorkspace { id: string; name: string; slug: string; isPersonal: boolean; role: "OWNER" | "ADMIN" | "MEMBER" | "GUEST"; } export interface MeResult { user: User; workspaces: MeWorkspace[]; } export const authApi = { login(input: LoginInput): Promise { return mutatingApiFetch("/auth/login", { method: "POST", body: input, noAuth: true }); }, /** * TASK-0131 — ANONYMOUS SSO discovery for the login page. Minimal-info by * design: the response reveals ONLY the org slugs that have SSO enabled + * a protocol-agnostic start URL (the server-side `/sso/start/:slug` * dispatcher does the protocol 302). It never returns the provider type, * the org id, or any URL a caller could use to fingerprint the IdP. * Unknown/empty email → `{ orgs: [] }` (no enumeration). `noAuth: true` * because the login page is anonymous. */ ssoOptions(email: string): Promise<{ orgs: Array<{ slug: string; ssoAvailable: boolean; startUrl: string }> }> { return apiFetch<{ orgs: Array<{ slug: string; ssoAvailable: boolean; startUrl: string }> }>( `/auth/sso/options?email=${encodeURIComponent(email)}`, { noAuth: true } ); }, register(input: RegisterInput): Promise { return mutatingApiFetch("/auth/register", { method: "POST", body: input, noAuth: true }); }, /** * Bearer-gated (NOT on the API whitelist) — the API needs the token to * resolve which user's session to revoke. The httpOnly refresh cookie is * cleared by the API on the response. `noAuth: true` here was the W-class * bug: it always 401'd, and the caller swallowed it. */ logout(): Promise<{ loggedOut: true }> { return mutatingApiFetch<{ loggedOut: true }>("/auth/logout", { method: "POST" }); }, /** Bearer-gated (W3 — was `noAuth: true`, which always 401'd). No token in the body. */ me(): Promise { return apiFetch("/auth/me"); }, /** * The single-flight silent refresh (cookie → new access token). Exposed for * the startup bootstrap in use-auth.ts. BOTH this and the 401-retry path in * doFetch now funnel through the SAME `refreshAccessToken` primitive, so a * StrictMode double-mounted bootstrap AND a burst of 401s each trigger * exactly one POST /auth/refresh per tab. (Previously this bypassed the * single-flight via mutatingApiFetch — the root of the double-rotation race * that orphaned live refresh tokens.) Resolves the full AuthTokens on * success; REJECTS with an ApiError (reason `refresh_token_stale` / * `refresh_token_reused` / …) on failure so the caller can branch — callers * that want "stay signed out on failure" must catch it. */ refresh(): Promise { return refreshAccessToken(); }, // Real API routes: /auth/reset-password + /auth/reset-password/confirm // (auth.controller.ts). Both always 200 (no user enumeration); confirm // returns { reset: true } — the old /auth/reset/* paths 404'd. resetRequest(email: string): Promise<{ sent: true }> { return mutatingApiFetch<{ sent: true }>("/auth/reset-password", { method: "POST", body: { email }, noAuth: true }); }, resetConfirm(email: string, code: string, newPassword: string): Promise<{ reset: true }> { return mutatingApiFetch<{ reset: true }>("/auth/reset-password/confirm", { method: "POST", body: { email, code, newPassword }, noAuth: true }); } }; /* ---------------------------------------------------------------- SSO admin */ /** SAML SP config (mirrors `SamlConfigSchema` in the api — config only, the * encrypted cert/secret are separate reveal-once fields). */ export interface SsoSamlConfig { entityId: string; ssoUrl: string; acsUrl: string; x509CertificateRef?: "current" | "previous"; nameIdAttribute?: string; emailAttribute?: string; emailIsVerified?: boolean; metadataUrl?: string; idpEntityId?: string; } /** OIDC client config (mirrors `OidcConfigSchema`). */ export interface SsoOidcConfig { issuer: string; clientId: string; authorizationEndpoint: string; tokenEndpoint: string; jwksUri?: string; redirectUri: string; scope?: string; } /** The masked provider read (secrets NEVER returned — booleans only). */ export interface SsoProviderView { id: string; name: string; type: "SAML" | "OIDC"; enforce: boolean; config: SsoSamlConfig | SsoOidcConfig; certificateConfigured?: boolean; clientSecretConfigured?: boolean; createdAt: string; updatedAt: string; } export interface SsoTestResult { ok: boolean; reason?: string; details?: Record; } /** * TASK-0131 — org SSO provider admin (org-owner gated for now; the role gate * lands in TASK-0133). The secrets (SAML cert PEM / OIDC client secret) are * REVEAL-ONCE: they are POSTed on create/rotate and the server encrypts them * (AES-256-GCM) — the read returns only `certificateConfigured` / * `clientSecretConfigured` booleans, never the values. */ export const ssoApi = { get(orgId: string): Promise<{ provider: SsoProviderView | null }> { return apiFetch<{ provider: SsoProviderView | null }>(`/orgs/${orgId}/sso`); }, create( orgId: string, body: | { type: "SAML"; name: string; config: SsoSamlConfig; certificate: string; previousCertificate?: string } | { type: "OIDC"; name: string; config: SsoOidcConfig; clientSecret: string } ): Promise<{ provider: SsoProviderView }> { return mutatingApiFetch<{ provider: SsoProviderView }>(`/orgs/${orgId}/sso`, { method: "POST", body }); }, update( orgId: string, body: { name?: string; config?: Record; certificate?: string; previousCertificate?: string; clientSecret?: string; } ): Promise<{ provider: SsoProviderView }> { return mutatingApiFetch<{ provider: SsoProviderView }>(`/orgs/${orgId}/sso`, { method: "PATCH", body }); }, remove(orgId: string): Promise<{ deleted: true }> { return mutatingApiFetch<{ deleted: true }>(`/orgs/${orgId}/sso`, { method: "DELETE" }); }, setEnforce(orgId: string, enforce: boolean): Promise<{ enforce: boolean }> { return mutatingApiFetch<{ enforce: boolean }>(`/orgs/${orgId}/sso/enforce`, { method: "PATCH", body: { enforce }, }); }, test(orgId: string): Promise { return mutatingApiFetch(`/orgs/${orgId}/sso/test`, { method: "POST" }); }, }; /* --------------------------------------------------------------- SCIM */ /** The masked SCIM read (the raw token is NEVER returned after enable/rotate). */ export interface ScimConfigView { enabled: boolean; tokenHint?: string; userEmailDomain?: string | null; defaultRole?: "GUEST" | "MEMBER"; autoJoinOrgWorkspaces?: boolean; enabledAt?: string; endpointUrl?: string; curlExample?: string; } export interface ScimTestResult { ok: boolean; detail: string; } /** * TASK-0132 — org SCIM 2.0 admin (org-owner gated now; the `requireOrgAdmin` * role gate lands in TASK-0133). The bearer token is REVEAL-ONCE: it is * generated server-side on `enable`/`rotate` and returned exactly once in the * mutation response — the read (`get`) returns only `tokenHint` (last 4 chars). */ export const scimApi = { get(orgId: string): Promise { return apiFetch(`/orgs/${orgId}/scim`); }, enable( orgId: string, body: { userEmailDomain?: string; defaultRole?: "GUEST" | "MEMBER"; autoJoinOrgWorkspaces?: boolean } = {} ): Promise { return mutatingApiFetch(`/orgs/${orgId}/scim`, { method: "POST", body }); }, update( orgId: string, body: { rotate?: boolean; disable?: boolean; userEmailDomain?: string | null; defaultRole?: "GUEST" | "MEMBER"; autoJoinOrgWorkspaces?: boolean; } ): Promise { return mutatingApiFetch(`/orgs/${orgId}/scim`, { method: "PATCH", body }); }, test(orgId: string, token: string): Promise { return mutatingApiFetch(`/orgs/${orgId}/scim/test`, { method: "POST", body: { token } }); }, }; /* ------------------------------------------------------------ workspaces */ /** * Wire shape of `GET /workspaces/:id/members` (workspaces.service.ts * `listMembers`) — a FLAT row. The web model is nested (`WorkspaceMember`); * `normalizeWorkspaceMember` bridges the two. Exported for the unit test. */ export interface WorkspaceMemberWire { userId: string; email: string; name: string; displayName?: string | null; avatarUrl?: string | null; role: string; joinedAt: string | null; } /** * Bridge the flat members wire row into the nested web `WorkspaceMember`. * `role` is lowercased (the API emits the raw UPPERCASE enum; the core type is * lowercase — the same casing seam noted in use-members.ts). `joinedAt` * defaults to epoch (never undefined: consumers call `.toLocaleDateString` * on it). `avatarUrl` maps to the core `image` field (there is no other * avatar field on the web `User` type). */ export function normalizeWorkspaceMember(m: WorkspaceMemberWire): WorkspaceMember { return { userId: m.userId, user: { id: m.userId, name: m.name, email: m.email, image: m.avatarUrl ?? null }, role: m.role.toLowerCase() as WorkspaceRole, joinedAt: m.joinedAt ?? new Date(0).toISOString() }; } export const workspaceApi = { // The API returns `{ data: Workspace[] }` (workspaces.service.ts // `listMine` → cursor-shaped envelope) — unwrap to the bare array the // switcher/hooks consume (S10 contract alignment). /** T-136: directory with optional filters. No params → today's behavior. */ list(params: { search?: string; type?: string; archived?: boolean; sort?: string } = {}): Promise { return apiFetch<{ data: Workspace[] }>(`/workspaces${buildQuery({ search: params.search, type: params.type, archived: params.archived ? "true" : undefined, // only "true" shows archived sort: params.sort })}`).then((r) => r.data); }, get(idOrSlug: string): Promise { return apiFetch(`/workspaces/${encodeURIComponent(idOrSlug)}`); }, create(name: string, emoji?: string): Promise { return mutatingApiFetch("/workspaces", { method: "POST", body: { name, ...(emoji ? { emoji } : {}) } }); }, update(id: string, patch: Partial>): Promise { return mutatingApiFetch(`/workspaces/${id}`, { method: "PATCH", body: patch }); }, /** T-136: archive a space (owner/admin only). */ archive(id: string): Promise { return mutatingApiFetch(`/workspaces/${id}/archive`, { method: "POST" }); }, /** T-136: restore an archived space (owner/admin only). */ restoreArchive(id: string): Promise { return mutatingApiFetch(`/workspaces/${id}/archive/restore`, { method: "POST" }); }, /** T-137: space usage stats. */ stats(id: string): Promise { return apiFetch(`/workspaces/${id}/stats`); }, remove(id: string): Promise { return mutatingApiFetch(`/workspaces/${id}`, { method: "DELETE" }); }, pages(workspaceId: string, params: { parent?: string | null; limit?: number } = {}): Promise { // TASK-0100 S5: the API returns the `{ data, nextCursor }` envelope // (cursor-paginated — pages.service.ts `list`); the caller expects a bare // Page[] (sortByPosition does [...items] — the envelope is not iterable, // which crashed the workspace index into the 404 boundary). Unwrap like // list()/teams() below. return apiFetch<{ data: Page[] }>(`/workspaces/${workspaceId}/pages${buildQuery({ parent: params.parent ?? null, limit: params.limit })}`).then((r) => r.data); }, pageTree(workspaceId: string, params: { parentId?: string | null } = {}): Promise { // The tree IS the documented sibling-list endpoint (docs/api/pages.md §3 // "List & Tree": `GET /workspaces/:id/pages?parentId=`, roots when the // param is omitted — `buildQuery` drops nulls). The former `/page-tree` // route was never built on the API (recorded in // docs/tasks/phase-15-production.md "Still-open pre-existing notes") and // 404'd, so the sidebar tree rendered empty and newly created/renamed // pages never appeared — even though every write persisted fine. // // Map the list row → PageTreeEntry: the Page model has no `type` field // (a database is a page with a seeded Database row — see the `Page.type` // conflict note in types.ts), so `databaseId` decides; `hasMore` comes // from `childrenCount` so the expand caret + lazy child load work. return apiFetch<{ data: Array }>( `/workspaces/${workspaceId}/pages${buildQuery({ parentId: params.parentId ?? null })}` ).then((r) => r.data.map((p) => ({ id: p.id, parentId: p.parentId, title: p.title, icon: p.icon ?? null, type: p.databaseId ? ("database" as const) : ("page" as const), position: p.position, hasMore: (p.childrenCount ?? 0) > 0, updatedAt: p.updatedAt })) ); }, members(id: string): Promise { // TASK-0100 S5: the API wraps the member list in `{ data }`. // // The API returns a FLAT row (`{ userId, email, name, displayName, // avatarUrl, role, joinedAt }`), but the web `WorkspaceMember` model — and // every consumer of it (settings page, members page, invite dialog, // file-permissions, comments, mention sources, teams) — expects a NESTED // `user` object. Reading `m.user.name` on the flat row threw // "Cannot read properties of undefined (reading 'name')" and crashed the // whole page under the Next.js dev error overlay (the "grey screen + // popup"). Normalize the flat wire shape into the nested model here, at // the single seam, so all consumers are fixed at once without touching the // API (whose flat shape its own e2e contract + integration tests pin). return apiFetch<{ data: WorkspaceMemberWire[] }>(`/workspaces/${id}/members`).then( (r) => (r.data ?? []).map(normalizeWorkspaceMember) ); }, /** The workspace's teamspace list (the mention popup's Teams group — R3). */ teams(id: string): Promise { return apiFetch<{ data: Team[] }>(`/workspaces/${id}/teams`).then((r) => r.data); }, /** The workspace's databases (the mention popup's Databases group — R3). */ databases(id: string, params: { limit?: number } = {}): Promise { return apiFetch(`/workspaces/${id}/databases${buildQuery({ limit: params.limit })}`); }, invite(id: string, email: string, role: WorkspaceRole): Promise { return mutatingApiFetch(`/workspaces/${id}/invites`, { method: "POST", body: { email, role } }); }, invites(id: string): Promise { return apiFetch(`/workspaces/${id}/invites`); } }; /* ---------------------------------------------------------------- pages */ export interface CreatePageInput { workspaceId: string; parentId?: string | null; title?: string; position?: Position; type?: "page" | "database" | "whiteboard"; } export const pageApi = { get(id: string): Promise { // TASK-0100 S5: GET /pages/:id returns the envelope // { page, workspace, topLevelBlocks, database, childrenCount } — unwrap // `.page` so callers (page-view title input, topbar breadcrumbs, the // page layout) see a real Page. Previously the whole envelope was treated // as the Page, so `page.id`/`page.title` were `undefined`: the title // input's `if (page)` guard passed (the envelope is truthy) but the // mutation fired `PATCH /pages/undefined` and the controlled value was // always `""` — the title never persisted (the doc indexed "(untitled // page)"). // // The inner `page` object does NOT carry `databaseId` (the Page model has // no such column) — only the list endpoint and the envelope's `database` // sibling do. Surface it here so `page.databaseId` matches the `Page` // interface ("non-null ⇔ this page is a database") for the route canvas // (page-canvas.tsx branches on it) and any page-level DB badge. return apiFetch<{ page: Page & { coverUrl?: string | null }; database?: { id: string } | null }>(`/pages/${id}`).then( (r) => ({ ...r.page, // The server stores the cover as `coverUrl` (UpdatePageDto) but the // web `Page` type spells it `cover` — normalize here so `page.cover` // is populated for the page-cover band + pickers (recorded conflict, // T-192: the API exposes only `coverUrl`, never `cover`). cover: r.page.coverUrl ?? r.page.cover ?? null, databaseId: r.database?.id ?? r.page.databaseId ?? null }), ); }, /** List a level of the workspace (`GET /workspaces/:id/pages` — the * `{ data, nextCursor }` envelope, unwrapped to a bare Page[]). * * `trashed: true` lists the trash (the trashed ROOTS — a trashed subtree * appears once, at its root) and is server-gated to Admin/Owner; * `favorites: true` lists the caller's favorited pages. The two are * mutually exclusive server-side (docs/api/pages.md §3). Rows carry * `databaseId` (null for plain pages) so the UI can badge database pages — * the Page model's `type` field does not exist in the API (a database is a * page with a Database row, not a distinct type — recorded conflict). */ list(workspaceId: string, params: { parentId?: string | null; trashed?: boolean; favorites?: boolean; limit?: number } = {}): Promise { return apiFetch<{ data: Page[] }>( `/workspaces/${workspaceId}/pages${buildQuery({ parentId: params.parentId ?? null, trashed: params.trashed, favorites: params.favorites, limit: params.limit })}` ).then((r) => r.data); }, // `type: "database"` pages carry the seeded Database row's id in the // response (`page.service.create` returns `{ ...page, databaseId }`) — // the Database row has its OWN cuid, it is NOT id-parity with the page. create(input: CreatePageInput): Promise { // TASK-0100 S5: the API route is the nested POST /workspaces/:workspaceId/pages // (workspace from the path; CreatePageDto has no workspaceId field — the old // global POST /pages 404'd). `position` is kept for client ergonomics but the // DTO strips it; the server assigns the position from the last sibling. const { workspaceId, ...body } = input; return mutatingApiFetch(`/workspaces/${workspaceId}/pages`, { method: "POST", body }); }, // `archived` (TASK-0035 archive endpoint, wired by the TASK-0045 context // menu) + `coverUrl` (server field name for `Page.cover`) ride along — // the PATCH DTO accepts both even though the web `Page` type spells them // differently. update(id: string, patch: Partial> & { archived?: boolean }): Promise { // `cover` (web field) → `coverUrl` (server field) is mapped HERE, at the // boundary, so callers (useUpdatePage, pickers) pass `cover` uniformly // (recorded conflict, T-192: the API exposes only `coverUrl`). // The response is normalized the same way `get` is (coverUrl → cover) so // the `["pages", id]` cache the mutation's onSuccess writes keeps `cover` // populated (otherwise the page-cover band disappears right after a pick). const { cover, ...rest } = patch; return mutatingApiFetch(`/pages/${id}`, { method: "PATCH", body: { ...rest, ...(cover !== undefined ? { coverUrl: cover } : {}) } }).then((p) => ({ ...p, cover: p.coverUrl ?? p.cover ?? null })); }, remove(id: string): Promise { return mutatingApiFetch(`/pages/${id}`, { method: "DELETE" }); }, restore(id: string): Promise { return mutatingApiFetch(`/pages/${id}/restore`, { method: "POST" }); }, blocks(id: string, params: { parentId?: string | null; depth?: number; limit?: number } = {}): Promise { // TASK-0100 S5: the API wraps list endpoints in `{ data, tree, total }` // (block.service `list`); the editor does sorted.map over a bare Block[] — // the envelope crashed the page view ("Cannot read ... of undefined"). return apiFetch<{ data: Block[] }>(`/pages/${id}/blocks${buildQuery({ parentId: params.parentId ?? null, depth: params.depth, limit: params.limit })}`).then((r) => mapBlocks(r.data)); }, versions(id: string): Promise { // TASK-0100 S5: same `{ data }` envelope (page.service `listVersions`). return apiFetch<{ data: PageVersion[] }>(`/pages/${id}/versions`).then((r) => r.data); }, /** T-192: manual snapshot (TASK-0043 / Phase 3). Phase 3 ships MANUAL * versions only — auto-snapshot on block edits is deferred to Phase 4 * (the 100-op counter). This is the only way to create a version in * Phase 3, so the E2E "version snapshot visible" test drives it here. */ snapshotVersion(id: string, note?: string): Promise { return mutatingApiFetch(`/pages/${id}/versions`, { method: "POST", body: note ? { note } : {} }); }, restoreVersion(id: string, versionId: string): Promise { return mutatingApiFetch(`/pages/${id}/versions/${versionId}/restore`, { method: "POST" }); }, /** T-150: block-level diff between two versions. `from`/`to` are the * version NUMBERS (the list rows' `version` field), not ids. */ compareVersions(id: string, from: number, to: number): Promise { return apiFetch(`/pages/${id}/versions/compare${buildQuery({ from, to })}`); }, /** T-151: read-only snapshot at a version id. */ previewVersion(id: string, versionId: string): Promise { return apiFetch(`/pages/${id}/versions/${versionId}`); }, toggleFavorite(id: string): Promise<{ favorite: boolean }> { return mutatingApiFetch<{ favorite: boolean }>(`/pages/${id}/favorite`, { method: "POST" }); }, removeFavorite(id: string): Promise<{ favorited: boolean }> { return mutatingApiFetch<{ favorited: boolean }>(`/pages/${id}/favorite`, { method: "DELETE" }); }, /** T-157: watchlist — is the caller watching this page? (VIEW-level.) */ isWatching(id: string): Promise<{ watching: boolean }> { return apiFetch<{ watching: boolean }>(`/pages/${id}/watch`); }, watch(id: string): Promise<{ watching: boolean }> { return mutatingApiFetch<{ watching: boolean }>(`/pages/${id}/watch`, { method: "POST" }); }, unwatch(id: string): Promise<{ watching: boolean }> { return mutatingApiFetch<{ watching: boolean }>(`/pages/${id}/watch`, { method: "DELETE" }); }, share(id: string, input: { level: "view" | "edit" | "public"; password?: string | null; expiresAt?: string | null }): Promise { return mutatingApiFetch(`/pages/${id}/share`, { method: "PUT", body: input }); }, invite(id: string, input: { email: string; role: WorkspaceRole }): Promise { return mutatingApiFetch(`/pages/${id}/invites`, { method: "POST", body: input }); }, /** T-138: recursive duplicate (blocks, child pages, database snapshot). * The endpoint returns the BARE `{ id, sourcePageId }` (the tested T-138 * contract) — fetch the new page by id for the full row. */ duplicate(id: string, input: { parentId?: string | null; title?: string } = {}): Promise<{ id: string; sourcePageId: string }> { return mutatingApiFetch<{ id: string; sourcePageId: string }>(`/pages/${id}/duplicate`, { method: "POST", body: input }); }, /** T-140: permission-filtered backlinks (pages that reference this one). */ backlinks(id: string): Promise> { return apiFetch<{ data: Array<{ pageId: string; title: string; icon: string | null; kind: string }> }>(`/pages/${id}/backlinks`).then((r) => r.data); }, /** T-180 (Phase 8): export the page as Markdown. Returns the RAW response * (body = `text/markdown`, the `Content-Disposition` header carries the * filename) — the caller triggers the browser download (keeps this layer * a thin HTTP wrapper; the topbar owns the blob/anchor). Re-uses * `apiFetchRaw` so the Bearer + 401-refresh behavior is identical; a * non-2xx (e.g. 403 for a restricted page) throws an ApiError so the UI * can toast it. */ async exportMarkdown(id: string, includeChildren = false): Promise { const res = await apiFetchRaw(`/pages/${id}/export/markdown${includeChildren ? "?includeChildren=1" : ""}`, { headers: { Accept: "text/markdown" } }); if (!res.ok) { const body = (await res.json().catch(() => null)) as { error?: { code?: string; message?: string } } | null; throw new ApiError( body?.error?.code ?? "INTERNAL", res.status, body?.error?.message ?? `Export failed with status ${res.status}` ); } return res; }, /** T-149: cross-workspace move (EDIT required in BOTH workspaces). */ move(id: string, input: { toWorkspaceId: string; toParentId?: string | null }): Promise<{ moved: true; pageId: string; pageIds: number }> { return mutatingApiFetch(`/pages/${id}/move`, { method: "POST", body: input }); }, /** T-139: bulk action. `workspaceId` goes in the X-Factory-Workspace header * (the API requires it). */ bulk(workspaceId: string, action: "trash" | "archive" | "unarchive" | "favorite" | "unfavorite", ids: string[]): Promise<{ succeeded: string[]; failed: Array<{ id: string; code: string }> }> { return mutatingApiFetch(`/pages/bulk`, { method: "POST", body: { action, ids }, headers: { "x-factory-workspace": workspaceId } }); } }; /* ------------------------------------------------------------------ whiteboard */ /** T-167 — `GET /pages/:id/whiteboard` response (the canvas + validity flag). */ export interface WhiteboardCanvasResponse { pageId: string; schemaVersion: number; canvas: Canvas; /** false → the stored document failed validation: render read-only (banner). */ valid: boolean; updatedAt: string; updatedBy: string | null; } /** T-168 — whiteboard canvas client. The canvas document is the full v1 * `@factory/whiteboard` shape; the server validates it (`validateCanvas`) and * 400s with field-level issues otherwise. Saves are debounced by the * renderer; the route-scoped 2 MB body cap (413) guards oversized boards. */ export const whiteboardApi = { get(pageId: string): Promise { return apiFetch(`/pages/${pageId}/whiteboard`); }, save(pageId: string, canvas: Canvas): Promise<{ pageId: string; schemaVersion: number; canvas: Canvas; updatedAt: string }> { return mutatingApiFetch(`/pages/${pageId}/whiteboard`, { method: "PUT", body: canvas }); }, /** T-170 save & restore: snapshot the current state (the server snapshots * the canvas sidecar for whiteboard pages). */ saveVersion(pageId: string, note?: string): Promise<{ id: string; version: number }> { return mutatingApiFetch(`/pages/${pageId}/versions`, { method: "POST", body: note ? { note } : undefined }); }, restoreVersion(pageId: string, versionId: string): Promise<{ restored: boolean; version: number }> { return mutatingApiFetch(`/pages/${pageId}/versions/${versionId}/restore`, { method: "POST" }); }, /** T-171: convert a selection of canvas elements into a real page (or * task page) — the server re-reads the canvas and maps text elements to * blocks; returns the created page (and task sidecar row, if any). */ convertSelection( pageId: string, body: { elementIds: string[]; target: "page" | "task"; title?: string; parentId?: string | null } ): Promise<{ page: { id: string; kind: string; title: string }; task: { id: string; status: string; pageId: string } | null; blockCount: number }> { return mutatingApiFetch(`/pages/${pageId}/whiteboard/convert`, { method: "POST", body }); } }; /* ---------------------------------------------------------------- blocks */ /** * Block type case reconciliation at the REST read boundary. * * The DB/REST wire enum is UPPERCASE (API `constants.BLOCK_TYPES`), but the * web editor codec is lowercase (`types.BLOCK_TYPES`), and the editor's * `BlockRenderer` switches on the lowercase codec case. The realtime gateway * already reconciles the two at its boundary (`prisma-store.ts` * `dbToCodecType`); the REST client now does the same on READ so a block * fetched from `GET /pages/:id/blocks` renders identically to one delivered * by a realtime frame. The write side needs no transform: the API's block * DTO is case-tolerant (see `apps/api` `blocks/dto.ts`, mirroring the * gateway's `codecToDbType`), so whatever case the editor sends is stored as * the DB case. Lowercase input is a no-op here (backward compatible); unknown * values pass through untouched so a genuine server-side change surfaces * rather than being silently mangled. */ function dbToBlockType(raw: string): BlockType { const lowered = raw.toLowerCase(); return (BLOCK_TYPES as readonly string[]).includes(lowered) ? (lowered as BlockType) : (raw as BlockType); } function withBlockType(b: Block): Block { return b.type ? { ...b, type: dbToBlockType(b.type) } : b; } function mapBlocks(blocks: Block[]): Block[] { return blocks.map(withBlockType); } export interface InsertBlockInput { pageId: string; parentId: string | null; afterBlockId?: string | null; beforeBlockId?: string | null; type: BlockType; content?: unknown; properties?: Record; position?: Position; /** Optional client-chosen id (paste imports reference nested parents by * id within one batch); omitted → server assigns. */ id?: string; } export interface BatchBlockOp { op: "upsert" | "insert" | "update" | "delete" | "move"; blockId?: string; block?: Omit & { parentId: string | null }; patch?: BlockPatch; parentId?: string | null; position?: Position; } export const blockApi = { list(pageId: string, params: { parentId?: string | null; depth?: number | "all" } = {}): Promise { // TASK-0100 S5: same `{ data, tree, total }` envelope as pageApi.blocks. return apiFetch<{ data: Block[] }>(`/pages/${pageId}/blocks${buildQuery({ parentId: params.parentId ?? null, depth: params.depth })}`).then((r) => mapBlocks(r.data)); }, insert(input: InsertBlockInput): Promise { // The API route is `POST /pages/:pageId/blocks` (BlocksController.create) // — there is no `POST /api/v1/blocks`. The body carries the full // CreateBlockInput (the server reads `pageId` from the path). return mutatingApiFetch(`/pages/${input.pageId}/blocks`, { method: "POST", body: input }); }, update(id: string, patch: BlockPatch): Promise { return mutatingApiFetch(`/blocks/${id}`, { method: "PATCH", body: patch }); }, // The API resolves the new position SERVER-side: pass neighbor block ids // (`beforeBlockId`/`afterBlockId`) and let the server's canonical base-62 // engine compute the position, OR pass an explicit `position`. Position // computation must NOT happen client-side — the web's base-36 shared engine // rejects the server's base-62 positions (uppercase) and threw on every // reorder (TASK-0135). move( id: string, input: { parentId: string | null; position?: Position; beforeBlockId?: string | null; afterBlockId?: string | null }, ): Promise { return mutatingApiFetch(`/blocks/${id}/move`, { method: "POST", body: input }); }, batch(pageId: string, ops: BatchBlockOp[]): Promise<{ applied: number }> { return mutatingApiFetch<{ applied: number }>("/blocks/batch", { method: "POST", body: { pageId, ops } }); }, remove(id: string): Promise { return mutatingApiFetch(`/blocks/${id}`, { method: "DELETE" }); }, /** Duplicate a block's subtree. `afterBlockId` anchors the copy (server * computes the fractional position — client position math threw on the * API's base-62 positions). Omitted → directly below the source. The API * returns `{ duplicated, newRootId, blockIds }` — map to a Block-ish * shape with the new root id (callers only need the id). */ duplicate(id: string, input: { parentId?: string | null; afterBlockId?: string } = {}): Promise<{ id: string }> { return mutatingApiFetch<{ newRootId: string }>(`/blocks/${id}/duplicate`, { method: "POST", body: Object.keys(input).length > 0 ? input : undefined }).then((r) => ({ id: r.newRootId })); } }; /* ------------------------------------------------------------- databases */ export const databaseApi = { get(id: string): Promise { return apiFetch(`/databases/${id}`); }, properties: { list(id: string): Promise { return apiFetch(`/databases/${id}/properties`); }, create(id: string, input: { name: string; type: PropertyType; config?: Record }): Promise { return mutatingApiFetch(`/databases/${id}/properties`, { method: "POST", body: input }); } }, views: { list(id: string): Promise { return apiFetch(`/databases/${id}/views`); }, create(id: string, input: { name: string; type: ViewType }): Promise { return mutatingApiFetch(`/databases/${id}/views`, { method: "POST", body: input }); } }, records: { list(id: string, params: { viewId?: string | null; limit?: number; cursor?: string | null } = {}): Promise { return apiFetch(`/databases/${id}/records${buildQuery({ viewId: params.viewId ?? null, limit: params.limit, cursor: params.cursor ?? null })}`); }, get(databaseId: string, recordId: string): Promise { return apiFetch(`/databases/${databaseId}/records/${recordId}`); }, bulk(databaseId: string, ops: { op: "create" | "update" | "delete"; recordId?: string; values?: Record }[]): Promise<{ applied: number }> { return mutatingApiFetch<{ applied: number }>(`/databases/${databaseId}/records/bulk`, { method: "POST", body: { ops } }); }, export(id: string, format: "csv" | "json"): Promise<{ url: string }> { return mutatingApiFetch<{ url: string }>(`/databases/${id}/records/export`, { method: "POST", body: { format } }); } } }; /* --------------------------------------------------------------- comments */ export const commentApi = { list(pageId: string): Promise { return apiFetch(`/pages/${pageId}/comments`); }, create(input: { blockId: string | null; pageId: string; parentId?: string | null; body: string }): Promise { return mutatingApiFetch("/comments", { method: "POST", body: input }); }, update(id: string, patch: { body?: string; resolved?: boolean }): Promise { return mutatingApiFetch(`/comments/${id}`, { method: "PATCH", body: patch }); }, remove(id: string): Promise { return mutatingApiFetch(`/comments/${id}`, { method: "DELETE" }); } }; /* ----------------------------------------------------------------- files */ /** * TASK-0103 migration: the old `fileApi` object targeted the pre-Phase-9 * draft endpoints (`POST /files/upload`, `POST /files/:id/complete`, * `GET /files/:id/url`, `GET /workspaces/:id/files`) which 404/405 against the * live API and had zero external consumers. It is removed. `uploadFile` is * now a thin ADAPTER over the real upload machine * (`@/lib/files/upload.ts`: plan pre-check → presigned session → XHR PUT with * progress → complete → poll) and returns the legacy `FileMeta` shape so the * pre-Phase-9 consumers (insert-dialog image tab, form FILE field, database * cell file upload) keep working against the real backend with no churn. * * The full client (list / get / upload session / cancel + the FileRecord * wire types) lives in `@/lib/files/api` — use that for anything new. */ export async function uploadFile( file: File, onProgress?: (fraction: number) => void, opts: { workspaceId?: string; pageId?: string; blockId?: string } = {} ): Promise { // Imported lazily: `@/lib/files/upload` → `@/lib/files/api` → `@/lib/api`, // so a top-level import here would be a module cycle. The cycle is // runtime-only (used inside the function body) and safe. const { uploadFile: uploadFileImpl } = await import("@/lib/files/upload"); const { MAX_FILE_SIZE_HARD } = await import("@/lib/files/api"); // uploadFile returns an UploadHandle {fileId, promise, cancel}; the result // (the FileDetail) resolves from `promise`. const handle = uploadFileImpl(file, { workspaceId: opts.workspaceId, pageId: opts.pageId, blockId: opts.blockId, // The legacy contract had NO client pre-check (the server enforced the // plan limit). Pass the hard ceiling so only > 500 MB is rejected // client-side; plan-specific over-limit files still 413 server-side with // a clear message (the new block uploader fetches the plan for a friendlier // toast — see use-file-uploads). maxFileSize: MAX_FILE_SIZE_HARD, onProgress: onProgress ? (p) => onProgress(p.fraction) : undefined, }); const result = await handle.promise; const d = result.file; const status: FileMeta["status"] = d.status === "READY" ? "ready" : d.status === "FAILED" || d.status === "VIRUS_DETECTED" ? "error" : "processing"; return { id: d.id, workspaceId: d.workspaceId, name: d.name, mime: d.mimeType, size: d.size, // Signed inline (preview) URL — the only content path (spec §Security). url: d.previewUrl, status, createdAt: d.createdAt, }; } /* ---------------------------------------------------------------- search */ /** * Search API — Phase-8 contract (TASK-0098; the API agent implements the * backend against these exact shapes): * - `search` → POST /search { q, types?, workspaceId?, … } → * { data: SearchResult[], nextCursor, tookMs } (one ResultItem shape). * - `query` → GET /search/query?q=&workspaceId=&limit=&cursor= → BARE * array of SearchResult (the command palette consumes this). * - `suggest` → GET /search/suggest?q=&limit= → BARE array of * { id, type, title } (the suggestion row shape — a bare `title`, not a * full ResultItem). * - recent → GET/PUT/DELETE /search/recent, the server (Redis) is the * ONLY source of truth for recents (no localStorage duplicate). * * Errors: 503 `{ error: { code: "SEARCH_UNAVAILABLE" } }` when OpenSearch is * down — callers show "Search is temporarily unavailable", they never crash. */ export const searchApi = { /** Paged full-text search (the /search page's primary endpoint). */ search(input: SearchQueryInput, signal?: AbortSignal): Promise { return apiFetch( "/search", { method: "POST", body: input, signal } ); }, /** Quick query (⌘K dialog results + the command palette). Bare array. */ query( q: string, params: { types?: SearchType[]; workspaceId?: string; limit?: number; cursor?: string | null } = {}, signal?: AbortSignal ): Promise { return apiFetch( `/search/query${buildQuery({ q, workspaceId: params.workspaceId, limit: params.limit, cursor: params.cursor ?? null, types: params.types?.join(",") ?? null })}`, { signal } ); }, /** Live title-prefix suggestions (bare array of { id, type, title }). */ suggest(q: string, limit = 8, signal?: AbortSignal): Promise { return apiFetch(`/search/suggest${buildQuery({ q, limit })}`, { signal }); }, /** The user's recent searches (newest first, server cap 20). */ recent(): Promise { return apiFetch<{ data: string[] }>("/search/recent").then((r) => r.data); }, /** * Record a search (server dedupes, cap 20, newest first). Returns the * updated list. Fire-and-forget friendly (the dialog's recency writes do * NOT await this — the e2e closes the dialog the moment a result appears). */ recordRecent(q: string): Promise { return mutatingApiFetch<{ data: string[] }>("/search/recent", { method: "PUT", body: { q } }).then((r) => r.data); }, /** Clear all recently searched queries. */ clearRecent(): Promise { return mutatingApiFetch("/search/recent", { method: "DELETE" }); } }; /* --------------------------------------------------------- me (T-153/T-154) */ export interface RecentViewItem { id: string; itemType: "page" | "database" | "whiteboard" | "task"; refId: string; at: string; } // The canonical saved-search row lives in lib/search (its `queryJson` mirrors // the POST /search body). Reuse it so the client and the ⌘K home agree on the // shape (a local re-definition here drifted and broke the dialog's state). export type SavedSearch = SavedSearchRow; /** T-153/T-154 — the caller's recently-VIEWED content (a separate store from * the recent-SEARCHES above) and saved searches. All strictly per-user * (the server keys everything by the JWT subject; a foreign id is 404). */ export const meApi = { /** The caller's recently-viewed items, newest first (server cap 50). */ recentViewed(): Promise { return apiFetch<{ data: RecentViewItem[] }>("/users/me/recent").then((r) => r.data); }, /** * Record a view (upsert + bump, cap 50). Fire-and-forget: page opens never * await it (a failed record must not block the editor). A non-viewable * "page" ref is a 404 the caller swallows (no-leak — never surfaces). */ recordView(itemType: "page" | "database" | "whiteboard" | "task", refId: string): Promise { return mutatingApiFetch("/users/me/recent", { method: "PUT", body: { itemType, refId } }); }, listSavedSearches(): Promise { return apiFetch<{ data: SavedSearch[] }>("/users/me/saved-searches").then((r) => r.data); }, createSavedSearch(input: { name: string; workspaceId?: string | null; query: Record }): Promise { return apiFetch("/users/me/saved-searches", { method: "POST", body: input }); }, deleteSavedSearch(id: string): Promise { return mutatingApiFetch(`/users/me/saved-searches/${id}`, { method: "DELETE" }); }, /** T-158: the caller's activity feed (their actions + their workspaces' * rows; other workspaces never leak). Keyset-paginated. */ myActivity(params: { cursor?: string; limit?: number } = {}): Promise<{ data: ActivityRow[]; nextCursor: string | null }> { return apiFetch<{ data: ActivityRow[]; nextCursor: string | null }>( `/users/me/activity${buildQuery({ cursor: params.cursor, limit: params.limit })}` ); } }; /** T-158: a workspace's (space) activity feed — membership-gated (403 for * non-members, the same no-leak contract as stats/archive). */ export function workspaceActivity( workspaceId: string, params: { cursor?: string; limit?: number } = {} ): Promise<{ data: ActivityRow[]; nextCursor: string | null }> { return apiFetch<{ data: ActivityRow[]; nextCursor: string | null }>( `/workspaces/${workspaceId}/activity${buildQuery({ cursor: params.cursor, limit: params.limit })}` ); } /* ---------------------------------------------------------- notifications */ export interface NotificationPage { data: Notification[]; nextCursor: string | null; } export const notificationApi = { // The API's `GET /notifications` filters on `read` (not `unreadOnly`) and // returns `{ data, nextCursor }` (cursor-paginated — TASK-0094 R5). list(params: { limit?: number; unreadOnly?: boolean; cursor?: string | null } = {}): Promise { return apiFetch( `/notifications${buildQuery({ limit: params.limit, read: params.unreadOnly ? "false" : params.unreadOnly === false ? "true" : undefined, cursor: params.cursor ?? undefined })}` ); }, /** The bell's count (R5: cheaper than a list fetch). */ unreadCount(): Promise<{ count: number }> { return apiFetch<{ count: number }>("/notifications/unread-count"); }, markRead(id: string): Promise<{ read: boolean; id: string }> { return mutatingApiFetch<{ read: boolean; id: string }>(`/notifications/${id}/read`, { method: "POST" }); }, markAllRead(): Promise<{ marked: number }> { return mutatingApiFetch<{ marked: number }>("/notifications/read-all", { method: "POST" }); } }; /* --------------------------------------------------------------- templates */ export const templateApi = { /** Wire shape of the Template rows `GET /workspaces/:id/templates` returns * (`page.service.listTemplates`) — a template is NOT a Page (it has `name`, * no `title`). The editor's insert dialog renders `t.title`/`t.icon`; the * templates page renders the real fields, so this returns the wire shape. * T-141: optional category/search/type filters. */ list(workspaceId: string, params: { category?: string; search?: string; type?: string } = {}): Promise { return apiFetch<{ data: TemplateInfo[] }>( `/workspaces/${workspaceId}/templates${buildQuery({ category: params.category, search: params.search, type: params.type })}` ).then((r) => r.data); }, /** Snapshot a page subtree into a template (`POST /pages/:id/templates`). * NOTE: the earlier `POST /templates` route does not exist (404) — the * controller mounts create under the page. */ saveFromPage(pageId: string, name: string, icon?: string): Promise { return mutatingApiFetch(`/pages/${pageId}/templates`, { method: "POST", body: { name, ...(icon ? { icon } : {}) } }); }, /** Instantiate a template (`POST /templates/:id/apply`). `workspaceId` is * REQUIRED (the controller rejects an empty body/header — `doFetch` never * sets `X-Factory-Workspace`, so it must come from the body). The server * returns the BARE created page row. */ /** T-142: `values` supplies typed variables for variable templates. */ apply(templateId: string, input: { workspaceId: string; parentId?: string | null; title?: string; values?: Record }): Promise { return mutatingApiFetch(`/templates/${templateId}/apply`, { method: "POST", body: input }); }, /** T-141: read-only preview of the CURRENT snapshot. ID-scoped (no * workspace header) — the directory is a cross-workspace union whose rows * carry no workspaceId; the server authorizes against the template's OWN * workspace membership. `snapshot` is the frozen `{ page, blocks, * children }` (loose — rendered read-only by the preview dialog). */ preview(templateId: string): Promise<{ snapshot: unknown }> { return apiFetch<{ data: { snapshot: unknown } }>(`/templates/${templateId}/preview`).then((r) => r.data); }, /** T-145: append-only version history (workspace header required). */ versions(templateId: string, workspaceId: string): Promise { return apiFetch<{ data: TemplateVersionInfo[] }>(`/templates/${templateId}/versions`, { headers: { "x-factory-workspace": workspaceId } }).then((r) => r.data); }, createVersion(templateId: string, workspaceId: string, note?: string): Promise { return mutatingApiFetch(`/templates/${templateId}/versions`, { method: "POST", body: note ? { note } : {}, headers: { "x-factory-workspace": workspaceId } }); }, restoreVersion(templateId: string, versionId: string, workspaceId: string): Promise { return mutatingApiFetch(`/templates/${templateId}/versions/${versionId}/restore`, { method: "POST", headers: { "x-factory-workspace": workspaceId } }); } }; /* ------------------------------------------------------------------ shares */ /** Wire shape of `GET /shares/:workspaceId` (share.service `list`) — the * shares the CALLER created in the workspace (the Phase 4+ "shared with me" * reverse lookup is not built yet). */ export interface ShareRow { id: string; token: string; scope: string; pageId: string | null; recordId: string | null; allowComments: boolean; expiresAt: string | null; createdAt: string; } /** * T-163 — the page review workflow (A-003). `get` returns the banner state * (`{ state, open, lastReview }`); `act` performs a review action * (`submit`/`approve`/`request_changes`/`cancel` — the server enforces the * transition matrix + the reviewer-of-record gate). */ export const reviewApi = { get(pageId: string): Promise { return apiFetch(`/pages/${pageId}/review`); }, act( pageId: string, input: { action: "submit" | "approve" | "request_changes" | "cancel"; reviewerIds?: string[]; comment?: string; deadline?: string | null } ): Promise<{ state: string }> { return mutatingApiFetch<{ state: string }>(`/pages/${pageId}/review`, { method: "POST", body: input }); } }; /** * T-164 — first-class tasks (A-006). A task is a `kind="task"` page + a 1:1 * sidecar; the routes address the sidecar id (`task.id`), the page id lives at * `task.pageId`. `list` returns the `{ data, nextCursor }` envelope with the * task page attached (`t.page` / `t.pageTitle`). */ export const taskApi = { list( workspaceId: string, params: { scope?: "inbox" | "all"; status?: string; dueBefore?: string; dueAfter?: string; month?: string; overdue?: boolean; cursor?: string; limit?: number } = {} ): Promise<{ data: Task[]; nextCursor: string | null }> { return apiFetch<{ data: Task[]; nextCursor: string | null }>( `/workspaces/${workspaceId}/tasks${buildQuery({ scope: params.scope, status: params.status, dueBefore: params.dueBefore, dueAfter: params.dueAfter, month: params.month, overdue: params.overdue, cursor: params.cursor, limit: params.limit })}` ); }, get(workspaceId: string, id: string): Promise { return apiFetch(`/workspaces/${workspaceId}/tasks/${id}`); }, create( workspaceId: string, input: { title: string; parentId?: string | null; status?: string; dueAt?: string | null; assigneeIds?: string[] } ): Promise { return mutatingApiFetch(`/workspaces/${workspaceId}/tasks`, { method: "POST", body: input }); }, update( workspaceId: string, id: string, patch: { title?: string; status?: string; dueAt?: string | null; assigneeIds?: string[] | null } ): Promise { return mutatingApiFetch(`/workspaces/${workspaceId}/tasks/${id}`, { method: "PATCH", body: patch }); }, remove(workspaceId: string, id: string): Promise<{ trashed: boolean }> { return mutatingApiFetch<{ trashed: boolean }>(`/workspaces/${workspaceId}/tasks/${id}`, { method: "DELETE" }); } }; export const sharesApi = { /** Shares I created in this workspace (`GET /shares/:workspaceId`). */ list(workspaceId: string): Promise { return apiFetch<{ data: ShareRow[] }>(`/shares/${workspaceId}`).then((r) => r.data); }, /** Revoke a share (`DELETE /shares/:id`). */ remove(id: string): Promise { return mutatingApiFetch(`/shares/${id}`, { method: "DELETE" }); } };