1
23
24import { apiFetch, mutatingApiFetch, buildQuery } from "@/lib/api";
25
26
27
28
29export type FileStatus =
30 | "UPLOADING"
31 | "SCANNING"
32 | "READY"
33 | "VIRUS_DETECTED"
34 | "FAILED";
35
36
37export type FileKind = "image" | "video" | "audio" | "pdf" | "doc" | "other";
38
39
40export interface FileRecord {
41 id: string;
42 workspaceId: string;
43 blockId: string | null;
44 pageId: string | null;
45 name: string;
46 size: number;
47 mimeType: string;
48 status: FileStatus;
49 metadata: Record<string, unknown>;
50 thumbnailKey: string | null;
51
52 thumbnailUrl: string | null;
53 scanReport: Record<string, unknown> | null;
54 uploadedBy: string;
55 version: number;
56 createdAt: string;
57 updatedAt: string;
58}
59
60
61export interface FileListItem extends FileRecord {
62 uploadedByName: string | null;
63 pageName: string | null;
64 versionCount: number;
65}
66
67
68export interface FileDetail extends FileRecord {
69
76 downloadUrl?: string;
77
78 previewUrl?: string;
79
80 expiresAt?: string;
81 versions: { id: string; version: number; size: number; createdAt: string }[];
82}
83
84
85export interface UploadSession {
86 file: FileRecord;
87
88 uploadUrl: string;
89 method: "PUT";
90
91 headers: Record<string, string>;
92 expiresAt: string;
93}
94
95export interface ListFilesParams {
96 scope?: "page" | "workspace";
97 pageId?: string;
98 kind?: FileKind;
99 status?: FileStatus;
100 search?: string;
101 sort?: "name" | "size" | "date" | "updated";
102 cursor?: string;
103 limit?: number;
104}
105
106export interface ListFilesResult {
107 data: FileListItem[];
108 nextCursor: string | null;
109}
110
111
112
113
114export interface FileGrant {
115 id: string;
116 granteeType: "user" | "team" | "role";
117
118 granteeId: string;
119
120 level: "NONE" | "VIEW" | "COMMENT" | "EDIT" | "FULL_ACCESS";
121
122 granteeName: string | null;
123 granteeEmail?: string | null;
124}
125
126
127export interface FilePermissionGrantInput {
128 granteeType: "user" | "team" | "role";
129 granteeId: string;
130 level: "NONE" | "VIEW" | "COMMENT" | "EDIT" | "FULL_ACCESS";
131}
132
133
135export interface FileVersion {
136 id: string;
137 version: number;
138 size: number;
139 current: boolean;
140 createdBy: string | null;
141 createdByName: string | null;
142 createdAt: string;
143}
144
145
146export interface TrashedFile extends FileRecord {
147 deletedAt: string | null;
148 uploadedByName: string | null;
149}
150
151
153export interface WorkspaceUsage {
154 workspaceId: string;
155 usage: {
156 pages: number;
157 databases: number;
158 records: number;
159 storageBytes: string;
160 storageBytesComputed: number;
161 apiKeys: number;
162 webhooks: number;
163 };
164 limits: {
165 maxFileSize: number;
166 maxPages: number | null;
167 maxDatabases: number | null;
168 maxRecords: number | null;
169
170 maxStorageBytes: number;
171 maxApiKeys: number | null;
172 maxWebhooks: number | null;
173 };
174}
175
176
177
178
179export interface WsOpt {
180 workspaceId?: string;
181}
182
183function wsHeaders(workspaceId?: string): Record<string, string> {
184 return workspaceId ? { "x-factory-workspace": workspaceId } : {};
185}
186
187export const filesApi = {
188
189 list(params: ListFilesParams & WsOpt = {}): Promise<ListFilesResult> {
190 const { workspaceId, scope, pageId, kind, status, search, sort, cursor, limit } = params;
191 return apiFetch<ListFilesResult>(
192 `/files${buildQuery({ scope, pageId, kind, status, search, sort, cursor, limit })}`,
193 { headers: wsHeaders(workspaceId) },
194 );
195 },
196
197
198 get(fileId: string, opts: WsOpt = {}): Promise<FileDetail> {
199 return apiFetch<FileDetail>(`/files/${fileId}`, { headers: wsHeaders(opts.workspaceId) });
200 },
201
202
203 openSession(input: {
204 name: string;
205 mimeType: string;
206 size: number;
207 pageId?: string | null;
208 blockId?: string | null;
209
210 replaceFileId?: string | null;
211 } & WsOpt): Promise<UploadSession> {
212 const { workspaceId, ...body } = input;
213 return mutatingApiFetch<{ upload: UploadSession }>("/files/uploads", {
214 method: "POST",
215 body,
216 headers: wsHeaders(workspaceId),
217 }).then((r) => r.upload);
218 },
219
220
221 complete(fileId: string, opts: WsOpt = {}): Promise<{ id: string; status: FileStatus; verifiedBy: string }> {
222 return mutatingApiFetch(`/files/uploads/${fileId}/complete`, {
223 method: "PUT",
224 body: {},
225 headers: wsHeaders(opts.workspaceId),
226 });
227 },
228
229
230 cancel(fileId: string, opts: WsOpt = {}): Promise<{ deleted: boolean }> {
231 return mutatingApiFetch(`/files/uploads/${fileId}/cancel`, {
232 method: "POST",
233 body: {},
234 headers: wsHeaders(opts.workspaceId),
235 });
236 },
237
238
239
246
247
248 listTrash(opts: WsOpt = {}): Promise<TrashedFile[]> {
249 return apiFetch<TrashedFile[]>("/files/trash", { headers: wsHeaders(opts.workspaceId) });
250 },
251
252
253 listVersions(fileId: string, opts: WsOpt = {}): Promise<FileVersion[]> {
254 return apiFetch<FileVersion[]>(`/files/${fileId}/versions`, { headers: wsHeaders(opts.workspaceId) });
255 },
256
257
258 rollback(fileId: string, version: number, opts: WsOpt = {}): Promise<FileRecord> {
259 return mutatingApiFetch<FileRecord>(`/files/${fileId}/rollback`, {
260 method: "POST",
261 body: { version },
262 headers: wsHeaders(opts.workspaceId),
263 });
264 },
265
266
267 pruneVersions(fileId: string, opts: WsOpt = {}): Promise<{ pruned: number }> {
268 return mutatingApiFetch<{ pruned: number }>(`/files/${fileId}/versions/prune`, {
269 method: "POST",
270 body: {},
271 headers: wsHeaders(opts.workspaceId),
272 });
273 },
274
275
276 trash(fileId: string, opts: WsOpt = {}): Promise<{ trashed: true; deletedAt: string }> {
277 return mutatingApiFetch(`/files/${fileId}`, {
278 method: "DELETE",
279 headers: wsHeaders(opts.workspaceId),
280 });
281 },
282
283
284 purge(fileId: string, opts: WsOpt = {}): Promise<{ purged: true }> {
285 return mutatingApiFetch(`/files/${fileId}${buildQuery({ purge: "true" })}`, {
286 method: "DELETE",
287 headers: wsHeaders(opts.workspaceId),
288 });
289 },
290
291
292 restore(fileId: string, opts: WsOpt = {}): Promise<FileRecord> {
293 return mutatingApiFetch<FileRecord>(`/files/${fileId}/restore`, {
294 method: "POST",
295 body: {},
296 headers: wsHeaders(opts.workspaceId),
297 });
298 },
299
300
301 listPermissions(fileId: string, opts: WsOpt = {}): Promise<FileGrant[]> {
302 return apiFetch<FileGrant[]>(`/files/${fileId}/permissions`, { headers: wsHeaders(opts.workspaceId) });
303 },
304
305
307 setPermissions(fileId: string, grants: FilePermissionGrantInput[], opts: WsOpt = {}): Promise<FileGrant[]> {
308 return mutatingApiFetch<FileGrant[]>(`/files/${fileId}/permissions`, {
309 method: "PUT",
310 body: { grants },
311 headers: wsHeaders(opts.workspaceId),
312 });
313 },
314
315
316 rename(fileId: string, name: string, opts: WsOpt = {}): Promise<FileRecord> {
317 return mutatingApiFetch<FileRecord>(`/files/${fileId}`, {
318 method: "PATCH",
319 body: { name },
320 headers: wsHeaders(opts.workspaceId),
321 });
322 },
323};
324
325
328export function getWorkspaceUsage(workspaceId: string): Promise<WorkspaceUsage> {
329 return apiFetch<WorkspaceUsage>(`/workspaces/${workspaceId}/usage`);
330}
331
332
333
334
339const KIND_MIMES: Record<FileKind, string[]> = {
340 image: ["image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml"],
341 video: ["video/mp4", "video/webm", "video/quicktime"],
342 audio: ["audio/mpeg", "audio/wav", "audio/mp4", "audio/x-wav"],
343 pdf: ["application/pdf"],
344 doc: [
345 "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
346 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
347 "application/vnd.openxmlformats-officedocument.presentationml.presentation",
348 "text/markdown",
349 "text/plain",
350 "text/csv",
351 ],
352 other: [],
353};
354
355export function kindForMime(mimeType: string | null | undefined): FileKind {
356 if (!mimeType) return "other";
357 const m = (mimeType.toLowerCase().split(";")[0] ?? "").trim();
358 if (!m) return "other";
359 for (const kind of ["image", "video", "audio", "pdf", "doc"] as FileKind[]) {
360 if (KIND_MIMES[kind].includes(m)) return kind;
361 }
362 return "other";
363}
364
365
366export function humanSize(bytes: number): string {
367 if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
368 if (bytes < 1024) return `${bytes} B`;
369 const units = ["KB", "MB", "GB", "TB"];
370 let value = bytes;
371 let i = -1;
372 do {
373 value /= 1024;
374 i += 1;
375 } while (value >= 1024 && i < units.length - 1);
376 return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[i]}`;
377}
378
379
380
381
382const PLAN_MAX_FILE: Record<string, number> = {
383 free: 50 * 1024 * 1024,
384 paid: 500 * 1024 * 1024,
385};
386
387
388export const MAX_FILE_SIZE_HARD = 500 * 1024 * 1024;
389
390
396export function effectiveMaxFileSize(
397 billingPlan: string,
398 usageLimits: { maxFileSize?: number } | null | undefined,
399): number {
400 const plan = PLAN_MAX_FILE[billingPlan] ?? PLAN_MAX_FILE.free!;
401 const override = usageLimits?.maxFileSize;
402 return Math.min(
403 plan,
404 typeof override === "number" && override > 0 ? override : Infinity,
405 MAX_FILE_SIZE_HARD,
406 );
407}
408