1"use client";
2
3
15import { authStore } from "./auth-store";
16import type { SavedSearch as SavedSearchRow } from "./search";
17import { API_BASE } from "./constants";
18import type { Canvas } from "@factory/whiteboard";
19
20
26function newRequestId(): string {
27 return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
28 const r = (Math.random() * 16) | 0;
29 const v = c === "x" ? r : (r & 0x3) | 0x8;
30 return v.toString(16);
31 });
32}
33import {
34 enqueueMutation,
35 registerOutboxTransport,
36 settleMutationFailure,
37 settleMutationSuccess
38} from "@/stores/offlineQueue";
39import type { OutboxItem, OutboxResult } from "@factory/collaboration/outbox";
40import { BLOCK_TYPES } from "./types";
41import type {
42 ActivityRow,
43 Block,
44 BlockPatch,
45 BlockType,
46 Comment,
47 Database,
48 DatabaseRecord,
49 DatabaseProperty,
50 DatabaseView,
51 FileMeta,
52 Notification,
53 Page,
54 PageShare,
55 PageTreeEntry,
56 PageVersion,
57 VersionDiff,
58 VersionPreview,
59 Position,
60 PropertyType,
61 SearchQueryInput,
62 SearchQueryResult,
63 SearchResult,
64 SearchSuggestion,
65 SearchType,
66 PageReview,
67 Task,
68 Team,
69 TemplateInfo,
70 TemplateVersionInfo,
71 User,
72 ViewType,
73 Workspace,
74 WorkspaceInvite,
75 WorkspaceMember,
76 WorkspaceRole,
77 WorkspaceStats
78} from "./types";
79
80
81
82export class ApiError extends Error {
83 readonly code: string;
84 readonly status: number;
85 readonly details?: unknown;
86 readonly requestId?: string;
87
88 constructor(code: string, status: number, message: string, details?: unknown, requestId?: string) {
89 super(message);
90 this.name = "ApiError";
91 this.code = code;
92 this.status = status;
93 this.details = details;
94 this.requestId = requestId;
95 }
96}
97
98
99export function isApiError(err: unknown): err is ApiError {
100 return err instanceof ApiError;
101}
102
103
113export function isStaleRefreshError(err: unknown): boolean {
114 if (!isApiError(err) || err.status !== 401) return false;
115 const details = err.details;
116 if (!Array.isArray(details)) return false;
117 return details.some(
118 (d) => typeof d === "object" && d !== null && (d as { reason?: string }).reason === "refresh_token_stale"
119 );
120}
121
122
123
124export function buildQuery(params: Record<string, string | number | boolean | null | undefined>): string {
125 const parts: string[] = [];
126 for (const [key, value] of Object.entries(params)) {
127 if (value === null || value === undefined) continue;
128 parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
129 }
130 return parts.length > 0 ? `?${parts.join("&")}` : "";
131}
132
133export interface ApiInit {
134 method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
135 body?: unknown;
136 signal?: AbortSignal;
137
138 noAuth?: boolean;
139
140 headers?: Record<string, string>;
141
142 resource?: { type: string; id: string };
143
148 idempotencyKey?: string;
149}
150
151
162let refreshInFlight: Promise<AuthTokens> | null = null;
163
164function refreshAccessToken(): Promise<AuthTokens> {
165 if (!refreshInFlight) {
166 refreshInFlight = (async () => {
167 try {
168
169
170
171
172 const res = await fetch(`${API_BASE}/auth/refresh`, {
173 method: "POST",
174 credentials: "include"
175 });
176 if (!res.ok) {
177
178
179
180 let code = "UNAUTHORIZED";
181 let details: unknown;
182 try {
183 const body = (await res.json()) as {
184 error?: { code?: string; details?: unknown };
185 };
186 code = body?.error?.code ?? code;
187 details = body?.error?.details;
188 } catch {
189
190 }
191 throw new ApiError(code, res.status, `Refresh failed (${res.status})`, details);
192 }
193
194
195
196
197 const json = (await res.json()) as AuthTokens;
198 if (!json.accessToken) throw new ApiError("UNAUTHORIZED", 200, "Refresh response missing access token");
199 authStore.setAccessToken(json.accessToken);
200 return json;
201 } finally {
202 refreshInFlight = null;
203 }
204 })();
205 }
206 return refreshInFlight;
207}
208
209async function doFetch(path: string, init: ApiInit, retried: boolean): Promise<Response> {
210 const headers: Record<string, string> = {
211 Accept: "application/json",
212 ...init.headers
213 };
214 if (!init.noAuth) {
215 const token = authStore.accessToken;
216 if (token) headers.Authorization = `Bearer ${token}`;
217 }
218
219
220 if (!("X-Request-Id" in headers)) headers["X-Request-Id"] = newRequestId();
221
222
223
224 if (init.idempotencyKey) headers["Idempotency-Key"] = init.idempotencyKey;
225
226
227
228
229
230 const rawBody = init.body;
231 const body =
232 rawBody === undefined || rawBody === null
233 ? undefined
234 : typeof rawBody === "string"
235 ? rawBody
236 : JSON.stringify(rawBody);
237 if (body !== undefined) headers["Content-Type"] = "application/json";
238
239 const res = await fetch(`${API_BASE}${path}`, {
240 method: init.method ?? "GET",
241 headers,
242 body,
243 credentials: "include",
244 signal: init.signal
245 });
246
247
248
249
250
251 if (res.status === 401 && !init.noAuth && !retried) {
252 try {
253 await refreshAccessToken();
254 return doFetch(path, init, true);
255 } catch (err) {
256
257
258
259
260
261 if (isStaleRefreshError(err)) throw err;
262 return res;
263 }
264 }
265 return res;
266}
267
268
272export async function apiFetch<T>(path: string, init: ApiInit = {}): Promise<T> {
273 const res = await doFetch(path, init, false);
274
275 if (res.status === 204) return undefined as T;
276
277 let json: unknown = null;
278 const text = await res.text();
279 if (text.length > 0) {
280 try {
281 json = JSON.parse(text);
282 } catch {
283 json = null;
284 }
285 }
286
287 if (!res.ok) {
288 const err = (json as { error?: { code?: string; message?: string; details?: unknown; requestId?: string } } | null)?.error;
289 throw new ApiError(
290 err?.code ?? "INTERNAL",
291 res.status,
292 err?.message ?? `Request failed with status ${res.status}`,
293 err?.details,
294 err?.requestId
295 );
296 }
297 return json as T;
298}
299
300
306export async function apiFetchRaw(path: string, init: ApiInit = {}): Promise<Response> {
307 return doFetch(path, init, false);
308}
309
310
311
312
318export function isOffline(): boolean {
319 return typeof navigator !== "undefined" && navigator.onLine === false;
320}
321
322
327export async function mutatingApiFetch<T>(
328 path: string,
329 init: ApiInit,
330 resource?: { type: string; id: string }
331): Promise<T> {
332 const method = init.method ?? "POST";
333 if (method === "GET" || !isOffline() || !authStore.accessToken) {
334
335 return apiFetch<T>(path, init);
336 }
337
338
339 return enqueueMutation<T>({
340 method,
341 path,
342 body: (init.body ?? undefined) as Record<string, unknown> | undefined,
343 ...(resource ? { resource } : {}),
344 ...(init.idempotencyKey ? { idempotencyKey: init.idempotencyKey } : {})
345 });
346}
347
348
355registerOutboxTransport(async (item: OutboxItem): Promise<OutboxResult> => {
356 try {
357 const body = await apiFetch<unknown>(item.path, {
358 method: item.method,
359 body: item.body,
360 idempotencyKey: item.key
361 });
362 settleMutationSuccess(item, body);
363 return { ok: true, status: 200 };
364 } catch (err) {
365 if (isApiError(err) && err.status >= 400 && err.status < 500) {
366
367
368
369 settleMutationFailure(item, err);
370 }
371 return { ok: false, status: isApiError(err) ? err.status : undefined, error: err instanceof Error ? err.message : String(err) };
372 }
373});
374
375
376
377
391export interface LoginInput {
392 email: string;
393 password: string;
394}
395export interface RegisterInput {
396 name: string;
397 email: string;
398 password: string;
399 workspaceName: string;
400}
401export interface AuthTokens {
402 accessToken: string;
403 refreshToken: string;
404 expiresIn: number;
405 session: { id: string; expiresAt?: string };
406}
407export interface LoginResult extends AuthTokens {
408 user: User;
409}
410export interface RegisterResult extends AuthTokens {
411 user: User;
412
413 workspace: { id: string; name: string };
414 personalWorkspace: { id: string; name: string };
415}
416export interface MeWorkspace {
417 id: string;
418 name: string;
419 slug: string;
420 isPersonal: boolean;
421 role: "OWNER" | "ADMIN" | "MEMBER" | "GUEST";
422}
423export interface MeResult {
424 user: User;
425 workspaces: MeWorkspace[];
426}
427
428export const authApi = {
429 login(input: LoginInput): Promise<LoginResult> {
430 return mutatingApiFetch<LoginResult>("/auth/login", { method: "POST", body: input, noAuth: true });
431 },
432
441 ssoOptions(email: string): Promise<{ orgs: Array<{ slug: string; ssoAvailable: boolean; startUrl: string }> }> {
442 return apiFetch<{ orgs: Array<{ slug: string; ssoAvailable: boolean; startUrl: string }> }>(
443 `/auth/sso/options?email=${encodeURIComponent(email)}`,
444 { noAuth: true }
445 );
446 },
447 register(input: RegisterInput): Promise<RegisterResult> {
448 return mutatingApiFetch<RegisterResult>("/auth/register", { method: "POST", body: input, noAuth: true });
449 },
450
456 logout(): Promise<{ loggedOut: true }> {
457 return mutatingApiFetch<{ loggedOut: true }>("/auth/logout", { method: "POST" });
458 },
459
460 me(): Promise<MeResult> {
461 return apiFetch<MeResult>("/auth/me");
462 },
463
475 refresh(): Promise<AuthTokens> {
476 return refreshAccessToken();
477 },
478
479
480
481 resetRequest(email: string): Promise<{ sent: true }> {
482 return mutatingApiFetch<{ sent: true }>("/auth/reset-password", { method: "POST", body: { email }, noAuth: true });
483 },
484 resetConfirm(email: string, code: string, newPassword: string): Promise<{ reset: true }> {
485 return mutatingApiFetch<{ reset: true }>("/auth/reset-password/confirm", {
486 method: "POST",
487 body: { email, code, newPassword },
488 noAuth: true
489 });
490 }
491};
492
493
494
495
497export interface SsoSamlConfig {
498 entityId: string;
499 ssoUrl: string;
500 acsUrl: string;
501 x509CertificateRef?: "current" | "previous";
502 nameIdAttribute?: string;
503 emailAttribute?: string;
504 emailIsVerified?: boolean;
505 metadataUrl?: string;
506 idpEntityId?: string;
507}
508
509export interface SsoOidcConfig {
510 issuer: string;
511 clientId: string;
512 authorizationEndpoint: string;
513 tokenEndpoint: string;
514 jwksUri?: string;
515 redirectUri: string;
516 scope?: string;
517}
518
519export interface SsoProviderView {
520 id: string;
521 name: string;
522 type: "SAML" | "OIDC";
523 enforce: boolean;
524 config: SsoSamlConfig | SsoOidcConfig;
525 certificateConfigured?: boolean;
526 clientSecretConfigured?: boolean;
527 createdAt: string;
528 updatedAt: string;
529}
530export interface SsoTestResult {
531 ok: boolean;
532 reason?: string;
533 details?: Record<string, unknown>;
534}
535
536
543export const ssoApi = {
544 get(orgId: string): Promise<{ provider: SsoProviderView | null }> {
545 return apiFetch<{ provider: SsoProviderView | null }>(`/orgs/${orgId}/sso`);
546 },
547 create(
548 orgId: string,
549 body:
550 | { type: "SAML"; name: string; config: SsoSamlConfig; certificate: string; previousCertificate?: string }
551 | { type: "OIDC"; name: string; config: SsoOidcConfig; clientSecret: string }
552 ): Promise<{ provider: SsoProviderView }> {
553 return mutatingApiFetch<{ provider: SsoProviderView }>(`/orgs/${orgId}/sso`, { method: "POST", body });
554 },
555 update(
556 orgId: string,
557 body: {
558 name?: string;
559 config?: Record<string, unknown>;
560 certificate?: string;
561 previousCertificate?: string;
562 clientSecret?: string;
563 }
564 ): Promise<{ provider: SsoProviderView }> {
565 return mutatingApiFetch<{ provider: SsoProviderView }>(`/orgs/${orgId}/sso`, { method: "PATCH", body });
566 },
567 remove(orgId: string): Promise<{ deleted: true }> {
568 return mutatingApiFetch<{ deleted: true }>(`/orgs/${orgId}/sso`, { method: "DELETE" });
569 },
570 setEnforce(orgId: string, enforce: boolean): Promise<{ enforce: boolean }> {
571 return mutatingApiFetch<{ enforce: boolean }>(`/orgs/${orgId}/sso/enforce`, {
572 method: "PATCH",
573 body: { enforce },
574 });
575 },
576 test(orgId: string): Promise<SsoTestResult> {
577 return mutatingApiFetch<SsoTestResult>(`/orgs/${orgId}/sso/test`, { method: "POST" });
578 },
579};
580
581
582
583
584export interface ScimConfigView {
585 enabled: boolean;
586 tokenHint?: string;
587 userEmailDomain?: string | null;
588 defaultRole?: "GUEST" | "MEMBER";
589 autoJoinOrgWorkspaces?: boolean;
590 enabledAt?: string;
591 endpointUrl?: string;
592 curlExample?: string;
593}
594export interface ScimTestResult {
595 ok: boolean;
596 detail: string;
597}
598
599
605export const scimApi = {
606 get(orgId: string): Promise<ScimConfigView> {
607 return apiFetch<ScimConfigView>(`/orgs/${orgId}/scim`);
608 },
609 enable(
610 orgId: string,
611 body: { userEmailDomain?: string; defaultRole?: "GUEST" | "MEMBER"; autoJoinOrgWorkspaces?: boolean } = {}
612 ): Promise<ScimConfigView & { token: string }> {
613 return mutatingApiFetch<ScimConfigView & { token: string }>(`/orgs/${orgId}/scim`, { method: "POST", body });
614 },
615 update(
616 orgId: string,
617 body: {
618 rotate?: boolean;
619 disable?: boolean;
620 userEmailDomain?: string | null;
621 defaultRole?: "GUEST" | "MEMBER";
622 autoJoinOrgWorkspaces?: boolean;
623 }
624 ): Promise<ScimConfigView & { token?: string }> {
625 return mutatingApiFetch<ScimConfigView & { token?: string }>(`/orgs/${orgId}/scim`, { method: "PATCH", body });
626 },
627 test(orgId: string, token: string): Promise<ScimTestResult> {
628 return mutatingApiFetch<ScimTestResult>(`/orgs/${orgId}/scim/test`, { method: "POST", body: { token } });
629 },
630};
631
632
633
634
635
640export interface WorkspaceMemberWire {
641 userId: string;
642 email: string;
643 name: string;
644 displayName?: string | null;
645 avatarUrl?: string | null;
646 role: string;
647 joinedAt: string | null;
648}
649
650
658export function normalizeWorkspaceMember(m: WorkspaceMemberWire): WorkspaceMember {
659 return {
660 userId: m.userId,
661 user: { id: m.userId, name: m.name, email: m.email, image: m.avatarUrl ?? null },
662 role: m.role.toLowerCase() as WorkspaceRole,
663 joinedAt: m.joinedAt ?? new Date(0).toISOString()
664 };
665}
666
667export const workspaceApi = {
668
669
670
671
672 list(params: { search?: string; type?: string; archived?: boolean; sort?: string } = {}): Promise<Workspace[]> {
673 return apiFetch<{ data: Workspace[] }>(`/workspaces${buildQuery({
674 search: params.search,
675 type: params.type,
676 archived: params.archived ? "true" : undefined, // only "true" shows archived
677 sort: params.sort
678 })}`).then((r) => r.data);
679 },
680 get(idOrSlug: string): Promise<Workspace> {
681 return apiFetch<Workspace>(`/workspaces/${encodeURIComponent(idOrSlug)}`);
682 },
683 create(name: string, emoji?: string): Promise<Workspace> {
684 return mutatingApiFetch<Workspace>("/workspaces", { method: "POST", body: { name, ...(emoji ? { emoji } : {}) } });
685 },
686 update(id: string, patch: Partial<Pick<Workspace, "name" | "emoji" | "type">>): Promise<Workspace> {
687 return mutatingApiFetch<Workspace>(`/workspaces/${id}`, { method: "PATCH", body: patch });
688 },
689
690 archive(id: string): Promise<Workspace> {
691 return mutatingApiFetch<Workspace>(`/workspaces/${id}/archive`, { method: "POST" });
692 },
693
694 restoreArchive(id: string): Promise<Workspace> {
695 return mutatingApiFetch<Workspace>(`/workspaces/${id}/archive/restore`, { method: "POST" });
696 },
697
698 stats(id: string): Promise<WorkspaceStats> {
699 return apiFetch<WorkspaceStats>(`/workspaces/${id}/stats`);
700 },
701 remove(id: string): Promise<void> {
702 return mutatingApiFetch<void>(`/workspaces/${id}`, { method: "DELETE" });
703 },
704 pages(workspaceId: string, params: { parent?: string | null; limit?: number } = {}): Promise<Page[]> {
705
706
707
708
709
710 return apiFetch<{ data: Page[] }>(`/workspaces/${workspaceId}/pages${buildQuery({ parent: params.parent ?? null, limit: params.limit })}`).then((r) => r.data);
711 },
712 pageTree(workspaceId: string, params: { parentId?: string | null } = {}): Promise<PageTreeEntry[]> {
713
714
715
716
717
718
719
720
721
722
723
724
725 return apiFetch<{ data: Array<Page & { childrenCount?: number }> }>(
726 `/workspaces/${workspaceId}/pages${buildQuery({ parentId: params.parentId ?? null })}`
727 ).then((r) =>
728 r.data.map((p) => ({
729 id: p.id,
730 parentId: p.parentId,
731 title: p.title,
732 icon: p.icon ?? null,
733 type: p.databaseId ? ("database" as const) : ("page" as const),
734 position: p.position,
735 hasMore: (p.childrenCount ?? 0) > 0,
736 updatedAt: p.updatedAt
737 }))
738 );
739 },
740 members(id: string): Promise<WorkspaceMember[]> {
741
742
743
744
745
746
747
748
749
750
751
752
753 return apiFetch<{ data: WorkspaceMemberWire[] }>(`/workspaces/${id}/members`).then(
754 (r) => (r.data ?? []).map(normalizeWorkspaceMember)
755 );
756 },
757
758 teams(id: string): Promise<Team[]> {
759 return apiFetch<{ data: Team[] }>(`/workspaces/${id}/teams`).then((r) => r.data);
760 },
761
762 databases(id: string, params: { limit?: number } = {}): Promise<Database[]> {
763 return apiFetch<Database[]>(`/workspaces/${id}/databases${buildQuery({ limit: params.limit })}`);
764 },
765 invite(id: string, email: string, role: WorkspaceRole): Promise<WorkspaceInvite> {
766 return mutatingApiFetch<WorkspaceInvite>(`/workspaces/${id}/invites`, { method: "POST", body: { email, role } });
767 },
768 invites(id: string): Promise<WorkspaceInvite[]> {
769 return apiFetch<WorkspaceInvite[]>(`/workspaces/${id}/invites`);
770 }
771};
772
773
774
775export interface CreatePageInput {
776 workspaceId: string;
777 parentId?: string | null;
778 title?: string;
779 position?: Position;
780 type?: "page" | "database" | "whiteboard";
781}
782
783export const pageApi = {
784 get(id: string): Promise<Page> {
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800 return apiFetch<{ page: Page & { coverUrl?: string | null }; database?: { id: string } | null }>(`/pages/${id}`).then(
801 (r) => ({
802 ...r.page,
803
804
805
806
807 cover: r.page.coverUrl ?? r.page.cover ?? null,
808 databaseId: r.database?.id ?? r.page.databaseId ?? null
809 }),
810 );
811 },
812
823 list(workspaceId: string, params: { parentId?: string | null; trashed?: boolean; favorites?: boolean; limit?: number } = {}): Promise<Page[]> {
824 return apiFetch<{ data: Page[] }>(
825 `/workspaces/${workspaceId}/pages${buildQuery({
826 parentId: params.parentId ?? null,
827 trashed: params.trashed,
828 favorites: params.favorites,
829 limit: params.limit
830 })}`
831 ).then((r) => r.data);
832 },
833
834
835
836 create(input: CreatePageInput): Promise<Page & { databaseId?: string; blockIds?: string[] }> {
837
838
839
840
841 const { workspaceId, ...body } = input;
842 return mutatingApiFetch<Page & { databaseId?: string; blockIds?: string[] }>(`/workspaces/${workspaceId}/pages`, { method: "POST", body });
843 },
844
845
846
847
848 update(id: string, patch: Partial<Pick<Page, "title" | "icon" | "cover" | "parentId" | "position" | "type">> & { archived?: boolean }): Promise<Page> {
849
850
851
852
853
854
855 const { cover, ...rest } = patch;
856 return mutatingApiFetch<Page & { coverUrl?: string | null }>(`/pages/${id}`, {
857 method: "PATCH",
858 body: { ...rest, ...(cover !== undefined ? { coverUrl: cover } : {}) }
859 }).then((p) => ({ ...p, cover: p.coverUrl ?? p.cover ?? null }));
860 },
861 remove(id: string): Promise<void> {
862 return mutatingApiFetch<void>(`/pages/${id}`, { method: "DELETE" });
863 },
864 restore(id: string): Promise<Page> {
865 return mutatingApiFetch<Page>(`/pages/${id}/restore`, { method: "POST" });
866 },
867 blocks(id: string, params: { parentId?: string | null; depth?: number; limit?: number } = {}): Promise<Block[]> {
868
869
870
871 return apiFetch<{ data: Block[] }>(`/pages/${id}/blocks${buildQuery({ parentId: params.parentId ?? null, depth: params.depth, limit: params.limit })}`).then((r) => mapBlocks(r.data));
872 },
873 versions(id: string): Promise<PageVersion[]> {
874
875 return apiFetch<{ data: PageVersion[] }>(`/pages/${id}/versions`).then((r) => r.data);
876 },
877
881 snapshotVersion(id: string, note?: string): Promise<PageVersion> {
882 return mutatingApiFetch<PageVersion>(`/pages/${id}/versions`, { method: "POST", body: note ? { note } : {} });
883 },
884 restoreVersion(id: string, versionId: string): Promise<Page> {
885 return mutatingApiFetch<Page>(`/pages/${id}/versions/${versionId}/restore`, { method: "POST" });
886 },
887
889 compareVersions(id: string, from: number, to: number): Promise<VersionDiff> {
890 return apiFetch<VersionDiff>(`/pages/${id}/versions/compare${buildQuery({ from, to })}`);
891 },
892
893 previewVersion(id: string, versionId: string): Promise<VersionPreview> {
894 return apiFetch<VersionPreview>(`/pages/${id}/versions/${versionId}`);
895 },
896 toggleFavorite(id: string): Promise<{ favorite: boolean }> {
897 return mutatingApiFetch<{ favorite: boolean }>(`/pages/${id}/favorite`, { method: "POST" });
898 },
899 removeFavorite(id: string): Promise<{ favorited: boolean }> {
900 return mutatingApiFetch<{ favorited: boolean }>(`/pages/${id}/favorite`, { method: "DELETE" });
901 },
902
903 isWatching(id: string): Promise<{ watching: boolean }> {
904 return apiFetch<{ watching: boolean }>(`/pages/${id}/watch`);
905 },
906 watch(id: string): Promise<{ watching: boolean }> {
907 return mutatingApiFetch<{ watching: boolean }>(`/pages/${id}/watch`, { method: "POST" });
908 },
909 unwatch(id: string): Promise<{ watching: boolean }> {
910 return mutatingApiFetch<{ watching: boolean }>(`/pages/${id}/watch`, { method: "DELETE" });
911 },
912 share(id: string, input: { level: "view" | "edit" | "public"; password?: string | null; expiresAt?: string | null }): Promise<PageShare> {
913 return mutatingApiFetch<PageShare>(`/pages/${id}/share`, { method: "PUT", body: input });
914 },
915 invite(id: string, input: { email: string; role: WorkspaceRole }): Promise<WorkspaceInvite> {
916 return mutatingApiFetch<WorkspaceInvite>(`/pages/${id}/invites`, { method: "POST", body: input });
917 },
918
921 duplicate(id: string, input: { parentId?: string | null; title?: string } = {}): Promise<{ id: string; sourcePageId: string }> {
922 return mutatingApiFetch<{ id: string; sourcePageId: string }>(`/pages/${id}/duplicate`, { method: "POST", body: input });
923 },
924
925 backlinks(id: string): Promise<Array<{ pageId: string; title: string; icon: string | null; kind: string }>> {
926 return apiFetch<{ data: Array<{ pageId: string; title: string; icon: string | null; kind: string }> }>(`/pages/${id}/backlinks`).then((r) => r.data);
927 },
928
935 async exportMarkdown(id: string, includeChildren = false): Promise<Response> {
936 const res = await apiFetchRaw(`/pages/${id}/export/markdown${includeChildren ? "?includeChildren=1" : ""}`, {
937 headers: { Accept: "text/markdown" }
938 });
939 if (!res.ok) {
940 const body = (await res.json().catch(() => null)) as { error?: { code?: string; message?: string } } | null;
941 throw new ApiError(
942 body?.error?.code ?? "INTERNAL",
943 res.status,
944 body?.error?.message ?? `Export failed with status ${res.status}`
945 );
946 }
947 return res;
948 },
949
950 move(id: string, input: { toWorkspaceId: string; toParentId?: string | null }): Promise<{ moved: true; pageId: string; pageIds: number }> {
951 return mutatingApiFetch(`/pages/${id}/move`, { method: "POST", body: input });
952 },
953
955 bulk(workspaceId: string, action: "trash" | "archive" | "unarchive" | "favorite" | "unfavorite", ids: string[]): Promise<{ succeeded: string[]; failed: Array<{ id: string; code: string }> }> {
956 return mutatingApiFetch(`/pages/bulk`, { method: "POST", body: { action, ids }, headers: { "x-factory-workspace": workspaceId } });
957 }
958};
959
960
961
962
963export interface WhiteboardCanvasResponse {
964 pageId: string;
965 schemaVersion: number;
966 canvas: Canvas;
967
968 valid: boolean;
969 updatedAt: string;
970 updatedBy: string | null;
971}
972
973
977export const whiteboardApi = {
978 get(pageId: string): Promise<WhiteboardCanvasResponse> {
979 return apiFetch<WhiteboardCanvasResponse>(`/pages/${pageId}/whiteboard`);
980 },
981 save(pageId: string, canvas: Canvas): Promise<{ pageId: string; schemaVersion: number; canvas: Canvas; updatedAt: string }> {
982 return mutatingApiFetch(`/pages/${pageId}/whiteboard`, { method: "PUT", body: canvas });
983 },
984
986 saveVersion(pageId: string, note?: string): Promise<{ id: string; version: number }> {
987 return mutatingApiFetch(`/pages/${pageId}/versions`, { method: "POST", body: note ? { note } : undefined });
988 },
989 restoreVersion(pageId: string, versionId: string): Promise<{ restored: boolean; version: number }> {
990 return mutatingApiFetch(`/pages/${pageId}/versions/${versionId}/restore`, { method: "POST" });
991 },
992
995 convertSelection(
996 pageId: string,
997 body: { elementIds: string[]; target: "page" | "task"; title?: string; parentId?: string | null }
998 ): Promise<{ page: { id: string; kind: string; title: string }; task: { id: string; status: string; pageId: string } | null; blockCount: number }> {
999 return mutatingApiFetch(`/pages/${pageId}/whiteboard/convert`, { method: "POST", body });
1000 }
1001};
1002
1003
1004
1005
1021function dbToBlockType(raw: string): BlockType {
1022 const lowered = raw.toLowerCase();
1023 return (BLOCK_TYPES as readonly string[]).includes(lowered) ? (lowered as BlockType) : (raw as BlockType);
1024}
1025
1026function withBlockType(b: Block): Block {
1027 return b.type ? { ...b, type: dbToBlockType(b.type) } : b;
1028}
1029
1030function mapBlocks(blocks: Block[]): Block[] {
1031 return blocks.map(withBlockType);
1032}
1033
1034export interface InsertBlockInput {
1035 pageId: string;
1036 parentId: string | null;
1037 afterBlockId?: string | null;
1038 beforeBlockId?: string | null;
1039 type: BlockType;
1040 content?: unknown;
1041 properties?: Record<string, unknown>;
1042 position?: Position;
1043
1045 id?: string;
1046}
1047
1048export interface BatchBlockOp {
1049 op: "upsert" | "insert" | "update" | "delete" | "move";
1050 blockId?: string;
1051 block?: Omit<InsertBlockInput, "pageId" | "parentId"> & { parentId: string | null };
1052 patch?: BlockPatch;
1053 parentId?: string | null;
1054 position?: Position;
1055}
1056
1057export const blockApi = {
1058 list(pageId: string, params: { parentId?: string | null; depth?: number | "all" } = {}): Promise<Block[]> {
1059
1060 return apiFetch<{ data: Block[] }>(`/pages/${pageId}/blocks${buildQuery({ parentId: params.parentId ?? null, depth: params.depth })}`).then((r) => mapBlocks(r.data));
1061 },
1062 insert(input: InsertBlockInput): Promise<Block> {
1063
1064
1065
1066 return mutatingApiFetch<Block>(`/pages/${input.pageId}/blocks`, { method: "POST", body: input });
1067 },
1068 update(id: string, patch: BlockPatch): Promise<Block> {
1069 return mutatingApiFetch<Block>(`/blocks/${id}`, { method: "PATCH", body: patch });
1070 },
1071
1072
1073
1074
1075
1076
1077 move(
1078 id: string,
1079 input: { parentId: string | null; position?: Position; beforeBlockId?: string | null; afterBlockId?: string | null },
1080 ): Promise<Block> {
1081 return mutatingApiFetch<Block>(`/blocks/${id}/move`, { method: "POST", body: input });
1082 },
1083 batch(pageId: string, ops: BatchBlockOp[]): Promise<{ applied: number }> {
1084 return mutatingApiFetch<{ applied: number }>("/blocks/batch", { method: "POST", body: { pageId, ops } });
1085 },
1086 remove(id: string): Promise<void> {
1087 return mutatingApiFetch<void>(`/blocks/${id}`, { method: "DELETE" });
1088 },
1089
1094 duplicate(id: string, input: { parentId?: string | null; afterBlockId?: string } = {}): Promise<{ id: string }> {
1095 return mutatingApiFetch<{ newRootId: string }>(`/blocks/${id}/duplicate`, {
1096 method: "POST",
1097 body: Object.keys(input).length > 0 ? input : undefined
1098 }).then((r) => ({ id: r.newRootId }));
1099 }
1100};
1101
1102
1103
1104export const databaseApi = {
1105 get(id: string): Promise<Database> {
1106 return apiFetch<Database>(`/databases/${id}`);
1107 },
1108 properties: {
1109 list(id: string): Promise<DatabaseProperty[]> {
1110 return apiFetch<DatabaseProperty[]>(`/databases/${id}/properties`);
1111 },
1112 create(id: string, input: { name: string; type: PropertyType; config?: Record<string, unknown> }): Promise<DatabaseProperty> {
1113 return mutatingApiFetch<DatabaseProperty>(`/databases/${id}/properties`, { method: "POST", body: input });
1114 }
1115 },
1116 views: {
1117 list(id: string): Promise<DatabaseView[]> {
1118 return apiFetch<DatabaseView[]>(`/databases/${id}/views`);
1119 },
1120 create(id: string, input: { name: string; type: ViewType }): Promise<DatabaseView> {
1121 return mutatingApiFetch<DatabaseView>(`/databases/${id}/views`, { method: "POST", body: input });
1122 }
1123 },
1124 records: {
1125 list(id: string, params: { viewId?: string | null; limit?: number; cursor?: string | null } = {}): Promise<DatabaseRecord[]> {
1126 return apiFetch<DatabaseRecord[]>(`/databases/${id}/records${buildQuery({ viewId: params.viewId ?? null, limit: params.limit, cursor: params.cursor ?? null })}`);
1127 },
1128 get(databaseId: string, recordId: string): Promise<DatabaseRecord> {
1129 return apiFetch<DatabaseRecord>(`/databases/${databaseId}/records/${recordId}`);
1130 },
1131 bulk(databaseId: string, ops: { op: "create" | "update" | "delete"; recordId?: string; values?: Record<string, unknown> }[]): Promise<{ applied: number }> {
1132 return mutatingApiFetch<{ applied: number }>(`/databases/${databaseId}/records/bulk`, { method: "POST", body: { ops } });
1133 },
1134 export(id: string, format: "csv" | "json"): Promise<{ url: string }> {
1135 return mutatingApiFetch<{ url: string }>(`/databases/${id}/records/export`, { method: "POST", body: { format } });
1136 }
1137 }
1138};
1139
1140
1141
1142export const commentApi = {
1143 list(pageId: string): Promise<Comment[]> {
1144 return apiFetch<Comment[]>(`/pages/${pageId}/comments`);
1145 },
1146 create(input: { blockId: string | null; pageId: string; parentId?: string | null; body: string }): Promise<Comment> {
1147 return mutatingApiFetch<Comment>("/comments", { method: "POST", body: input });
1148 },
1149 update(id: string, patch: { body?: string; resolved?: boolean }): Promise<Comment> {
1150 return mutatingApiFetch<Comment>(`/comments/${id}`, { method: "PATCH", body: patch });
1151 },
1152 remove(id: string): Promise<void> {
1153 return mutatingApiFetch<void>(`/comments/${id}`, { method: "DELETE" });
1154 }
1155};
1156
1157
1158
1159
1173export async function uploadFile(
1174 file: File,
1175 onProgress?: (fraction: number) => void,
1176 opts: { workspaceId?: string; pageId?: string; blockId?: string } = {}
1177): Promise<FileMeta> {
1178
1179
1180
1181 const { uploadFile: uploadFileImpl } = await import("@/lib/files/upload");
1182 const { MAX_FILE_SIZE_HARD } = await import("@/lib/files/api");
1183
1184
1185 const handle = uploadFileImpl(file, {
1186 workspaceId: opts.workspaceId,
1187 pageId: opts.pageId,
1188 blockId: opts.blockId,
1189
1190
1191
1192
1193
1194 maxFileSize: MAX_FILE_SIZE_HARD,
1195 onProgress: onProgress ? (p) => onProgress(p.fraction) : undefined,
1196 });
1197 const result = await handle.promise;
1198 const d = result.file;
1199 const status: FileMeta["status"] =
1200 d.status === "READY" ? "ready" : d.status === "FAILED" || d.status === "VIRUS_DETECTED" ? "error" : "processing";
1201 return {
1202 id: d.id,
1203 workspaceId: d.workspaceId,
1204 name: d.name,
1205 mime: d.mimeType,
1206 size: d.size,
1207
1208 url: d.previewUrl,
1209 status,
1210 createdAt: d.createdAt,
1211 };
1212}
1213
1214
1215
1216
1232export const searchApi = {
1233
1234 search(input: SearchQueryInput, signal?: AbortSignal): Promise<SearchQueryResult> {
1235 return apiFetch<SearchQueryResult>(
1236 "/search",
1237 { method: "POST", body: input, signal }
1238 );
1239 },
1240
1241 query(
1242 q: string,
1243 params: { types?: SearchType[]; workspaceId?: string; limit?: number; cursor?: string | null } = {},
1244 signal?: AbortSignal
1245 ): Promise<SearchResult[]> {
1246 return apiFetch<SearchResult[]>(
1247 `/search/query${buildQuery({
1248 q,
1249 workspaceId: params.workspaceId,
1250 limit: params.limit,
1251 cursor: params.cursor ?? null,
1252 types: params.types?.join(",") ?? null
1253 })}`,
1254 { signal }
1255 );
1256 },
1257
1258 suggest(q: string, limit = 8, signal?: AbortSignal): Promise<SearchSuggestion[]> {
1259 return apiFetch<SearchSuggestion[]>(`/search/suggest${buildQuery({ q, limit })}`, { signal });
1260 },
1261
1262 recent(): Promise<string[]> {
1263 return apiFetch<{ data: string[] }>("/search/recent").then((r) => r.data);
1264 },
1265
1270 recordRecent(q: string): Promise<string[]> {
1271 return mutatingApiFetch<{ data: string[] }>("/search/recent", { method: "PUT", body: { q } }).then((r) => r.data);
1272 },
1273
1274 clearRecent(): Promise<void> {
1275 return mutatingApiFetch<void>("/search/recent", { method: "DELETE" });
1276 }
1277};
1278
1279
1280
1281export interface RecentViewItem {
1282 id: string;
1283 itemType: "page" | "database" | "whiteboard" | "task";
1284 refId: string;
1285 at: string;
1286}
1287
1288
1289
1290
1291export type SavedSearch = SavedSearchRow;
1292
1293
1296export const meApi = {
1297
1298 recentViewed(): Promise<RecentViewItem[]> {
1299 return apiFetch<{ data: RecentViewItem[] }>("/users/me/recent").then((r) => r.data);
1300 },
1301
1306 recordView(itemType: "page" | "database" | "whiteboard" | "task", refId: string): Promise<void> {
1307 return mutatingApiFetch<void>("/users/me/recent", { method: "PUT", body: { itemType, refId } });
1308 },
1309 listSavedSearches(): Promise<SavedSearch[]> {
1310 return apiFetch<{ data: SavedSearch[] }>("/users/me/saved-searches").then((r) => r.data);
1311 },
1312 createSavedSearch(input: { name: string; workspaceId?: string | null; query: Record<string, unknown> }): Promise<SavedSearch> {
1313 return apiFetch<SavedSearch>("/users/me/saved-searches", { method: "POST", body: input });
1314 },
1315 deleteSavedSearch(id: string): Promise<void> {
1316 return mutatingApiFetch<void>(`/users/me/saved-searches/${id}`, { method: "DELETE" });
1317 },
1318
1320 myActivity(params: { cursor?: string; limit?: number } = {}): Promise<{ data: ActivityRow[]; nextCursor: string | null }> {
1321 return apiFetch<{ data: ActivityRow[]; nextCursor: string | null }>(
1322 `/users/me/activity${buildQuery({ cursor: params.cursor, limit: params.limit })}`
1323 );
1324 }
1325};
1326
1327
1329export function workspaceActivity(
1330 workspaceId: string,
1331 params: { cursor?: string; limit?: number } = {}
1332): Promise<{ data: ActivityRow[]; nextCursor: string | null }> {
1333 return apiFetch<{ data: ActivityRow[]; nextCursor: string | null }>(
1334 `/workspaces/${workspaceId}/activity${buildQuery({ cursor: params.cursor, limit: params.limit })}`
1335 );
1336}
1337
1338
1339
1340export interface NotificationPage {
1341 data: Notification[];
1342 nextCursor: string | null;
1343}
1344
1345export const notificationApi = {
1346
1347
1348 list(params: { limit?: number; unreadOnly?: boolean; cursor?: string | null } = {}): Promise<NotificationPage> {
1349 return apiFetch<NotificationPage>(
1350 `/notifications${buildQuery({
1351 limit: params.limit,
1352 read: params.unreadOnly ? "false" : params.unreadOnly === false ? "true" : undefined,
1353 cursor: params.cursor ?? undefined
1354 })}`
1355 );
1356 },
1357
1358 unreadCount(): Promise<{ count: number }> {
1359 return apiFetch<{ count: number }>("/notifications/unread-count");
1360 },
1361 markRead(id: string): Promise<{ read: boolean; id: string }> {
1362 return mutatingApiFetch<{ read: boolean; id: string }>(`/notifications/${id}/read`, { method: "POST" });
1363 },
1364 markAllRead(): Promise<{ marked: number }> {
1365 return mutatingApiFetch<{ marked: number }>("/notifications/read-all", { method: "POST" });
1366 }
1367};
1368
1369
1370
1371export const templateApi = {
1372
1377 list(workspaceId: string, params: { category?: string; search?: string; type?: string } = {}): Promise<TemplateInfo[]> {
1378 return apiFetch<{ data: TemplateInfo[] }>(
1379 `/workspaces/${workspaceId}/templates${buildQuery({ category: params.category, search: params.search, type: params.type })}`
1380 ).then((r) => r.data);
1381 },
1382
1385 saveFromPage(pageId: string, name: string, icon?: string): Promise<TemplateInfo> {
1386 return mutatingApiFetch<TemplateInfo>(`/pages/${pageId}/templates`, { method: "POST", body: { name, ...(icon ? { icon } : {}) } });
1387 },
1388
1392
1393 apply(templateId: string, input: { workspaceId: string; parentId?: string | null; title?: string; values?: Record<string, unknown> }): Promise<Page> {
1394 return mutatingApiFetch<Page>(`/templates/${templateId}/apply`, { method: "POST", body: input });
1395 },
1396
1401 preview(templateId: string): Promise<{ snapshot: unknown }> {
1402 return apiFetch<{ data: { snapshot: unknown } }>(`/templates/${templateId}/preview`).then((r) => r.data);
1403 },
1404
1405 versions(templateId: string, workspaceId: string): Promise<TemplateVersionInfo[]> {
1406 return apiFetch<{ data: TemplateVersionInfo[] }>(`/templates/${templateId}/versions`, { headers: { "x-factory-workspace": workspaceId } }).then((r) => r.data);
1407 },
1408 createVersion(templateId: string, workspaceId: string, note?: string): Promise<TemplateVersionInfo> {
1409 return mutatingApiFetch<TemplateVersionInfo>(`/templates/${templateId}/versions`, { method: "POST", body: note ? { note } : {}, headers: { "x-factory-workspace": workspaceId } });
1410 },
1411 restoreVersion(templateId: string, versionId: string, workspaceId: string): Promise<TemplateVersionInfo> {
1412 return mutatingApiFetch<TemplateVersionInfo>(`/templates/${templateId}/versions/${versionId}/restore`, { method: "POST", headers: { "x-factory-workspace": workspaceId } });
1413 }
1414};
1415
1416
1417
1418
1421export interface ShareRow {
1422 id: string;
1423 token: string;
1424 scope: string;
1425 pageId: string | null;
1426 recordId: string | null;
1427 allowComments: boolean;
1428 expiresAt: string | null;
1429 createdAt: string;
1430}
1431
1432
1438export const reviewApi = {
1439 get(pageId: string): Promise<PageReview> {
1440 return apiFetch<PageReview>(`/pages/${pageId}/review`);
1441 },
1442 act(
1443 pageId: string,
1444 input: { action: "submit" | "approve" | "request_changes" | "cancel"; reviewerIds?: string[]; comment?: string; deadline?: string | null }
1445 ): Promise<{ state: string }> {
1446 return mutatingApiFetch<{ state: string }>(`/pages/${pageId}/review`, { method: "POST", body: input });
1447 }
1448};
1449
1450
1456export const taskApi = {
1457 list(
1458 workspaceId: string,
1459 params: {
1460 scope?: "inbox" | "all";
1461 status?: string;
1462 dueBefore?: string;
1463 dueAfter?: string;
1464 month?: string;
1465 overdue?: boolean;
1466 cursor?: string;
1467 limit?: number
1468 } = {}
1469 ): Promise<{ data: Task[]; nextCursor: string | null }> {
1470 return apiFetch<{ data: Task[]; nextCursor: string | null }>(
1471 `/workspaces/${workspaceId}/tasks${buildQuery({
1472 scope: params.scope,
1473 status: params.status,
1474 dueBefore: params.dueBefore,
1475 dueAfter: params.dueAfter,
1476 month: params.month,
1477 overdue: params.overdue,
1478 cursor: params.cursor,
1479 limit: params.limit
1480 })}`
1481 );
1482 },
1483 get(workspaceId: string, id: string): Promise<Task> {
1484 return apiFetch<Task>(`/workspaces/${workspaceId}/tasks/${id}`);
1485 },
1486 create(
1487 workspaceId: string,
1488 input: { title: string; parentId?: string | null; status?: string; dueAt?: string | null; assigneeIds?: string[] }
1489 ): Promise<Task> {
1490 return mutatingApiFetch<Task>(`/workspaces/${workspaceId}/tasks`, { method: "POST", body: input });
1491 },
1492 update(
1493 workspaceId: string,
1494 id: string,
1495 patch: { title?: string; status?: string; dueAt?: string | null; assigneeIds?: string[] | null }
1496 ): Promise<Task> {
1497 return mutatingApiFetch<Task>(`/workspaces/${workspaceId}/tasks/${id}`, { method: "PATCH", body: patch });
1498 },
1499 remove(workspaceId: string, id: string): Promise<{ trashed: boolean }> {
1500 return mutatingApiFetch<{ trashed: boolean }>(`/workspaces/${workspaceId}/tasks/${id}`, { method: "DELETE" });
1501 }
1502};
1503
1504export const sharesApi = {
1505
1506 list(workspaceId: string): Promise<ShareRow[]> {
1507 return apiFetch<{ data: ShareRow[] }>(`/shares/${workspaceId}`).then((r) => r.data);
1508 },
1509
1510 remove(id: string): Promise<void> {
1511 return mutatingApiFetch<void>(`/shares/${id}`, { method: "DELETE" });
1512 }
1513};
1514