/** * TASK-0103 — the file API client (web). * * The single source of truth for talking to the real TASK-0101/0103 file * endpoints. It replaces the stale `fileApi`/`uploadFile` in `@/lib/api` * (which targeted the pre-Phase-9 draft endpoints `POST /files/upload`, * `POST /files/:id/complete`, `GET /files/:id/url` and a `mime`/`url` shape * the live API never emitted — every one of those 404/405s against the * current backend). * * Security contract (spec §Security): the ONLY content path is a signed URL * minted by the API. This client never assembles a bucket URL from a raw key * (the raw `key` is not even part of `FileRecord`), and the file KIND is * always derived from the server-verified `mimeType` (post-scan), never the * extension. * * Workspace scoping: `GET /files` and `POST /files/uploads` resolve the * workspace from `req.auth.workspaceId` — the `x-factory-workspace` header * when present, else `claims.workspaces[0]`. We pass the header explicitly * whenever we know the active workspace (multi-workspace users), so a call is * never silently routed to the user's first workspace. */ import { apiFetch, mutatingApiFetch, buildQuery } from "@/lib/api"; /* ------------------------------------------------------------ types */ /** The server-side file status machine (TASK-0102). */ export type FileStatus = | "UPLOADING" | "SCANNING" | "READY" | "VIRUS_DETECTED" | "FAILED"; /** A render/display kind, derived from the verified `mimeType`. */ export type FileKind = "image" | "video" | "audio" | "pdf" | "doc" | "other"; /** `FileRecord` — the wire shape (docs/api/files.md §File object). */ export interface FileRecord { id: string; workspaceId: string; blockId: string | null; pageId: string | null; name: string; size: number; mimeType: string; status: FileStatus; metadata: Record; thumbnailKey: string | null; /** Worker-minted 15-min signed GET of the 480 artifact (may be expired). */ thumbnailUrl: string | null; scanReport: Record | null; uploadedBy: string; version: number; createdAt: string; updatedAt: string; } /** A list row: `FileRecord` + batch-resolved display fields. */ export interface FileListItem extends FileRecord { uploadedByName: string | null; pageName: string | null; versionCount: number; } /** `GET /files/:id` — metadata + FRESH signed URLs + version history. */ export interface FileDetail extends FileRecord { /** * Fresh signed GET, `attachment` disposition (900 s). OMITTED (absent) for * a `VIRUS_DETECTED` file: the detail endpoint still returns the record * (status/scanReport, so the block renders the "Blocked by virus scan" * card) but a quarantined file is never signed (docs/api/files.md §8.5; * the byte-serving endpoints 403, §8.6). */ downloadUrl?: string; /** Fresh signed GET, `inline` disposition (900 s) — absent when quarantined. */ previewUrl?: string; /** ISO timestamp the signed URLs above expire at — absent when quarantined. */ expiresAt?: string; versions: { id: string; version: number; size: number; createdAt: string }[]; } /** `POST /files/uploads` — a presigned upload session. */ export interface UploadSession { file: FileRecord; /** Query-string signed PUT URL (bound to the key + declared content-type). */ uploadUrl: string; method: "PUT"; /** The EXACT signed headers to send on the PUT (content-type is signed). */ headers: Record; expiresAt: string; } export interface ListFilesParams { scope?: "page" | "workspace"; pageId?: string; kind?: FileKind; status?: FileStatus; search?: string; sort?: "name" | "size" | "date" | "updated"; cursor?: string; limit?: number; } export interface ListFilesResult { data: FileListItem[]; nextCursor: string | null; } /* ----------------------------------------------------- TASK-0104 types */ /** A file-level explicit grant (`Permission` row, resourceType "file"). */ export interface FileGrant { id: string; granteeType: "user" | "team" | "role"; /** userId | teamId | role name ("OWNER" | "ADMIN" | "MEMBER" | "GUEST"). */ granteeId: string; /** NONE is an EXPLICIT DENY (overrides inheritance) — not "remove". */ level: "NONE" | "VIEW" | "COMMENT" | "EDIT" | "FULL_ACCESS"; /** Set by the server for display; a role grant resolves to the role name. */ granteeName: string | null; granteeEmail?: string | null; } /** One grant to (re)send with `setPermissions` (REPLACE — full desired set). */ export interface FilePermissionGrantInput { granteeType: "user" | "team" | "role"; granteeId: string; level: "NONE" | "VIEW" | "COMMENT" | "EDIT" | "FULL_ACCESS"; } /** `GET /files/:id/versions` — one version row (ascending, `current` flags * the active version). The raw storage `key` is never exposed. */ export interface FileVersion { id: string; version: number; size: number; current: boolean; createdBy: string | null; createdByName: string | null; createdAt: string; } /** `GET /files/trash` — a trashed file record (+ deletedAt for display). */ export interface TrashedFile extends FileRecord { deletedAt: string | null; uploadedByName: string | null; } /** `GET /workspaces/:id/usage`. NOTE: `storageBytes` arrives as a decimal * STRING (BigInt serialization) — `Number()` it before arithmetic. */ export interface WorkspaceUsage { workspaceId: string; usage: { pages: number; databases: number; records: number; storageBytes: string; storageBytesComputed: number; apiKeys: number; webhooks: number; }; limits: { maxFileSize: number; maxPages: number | null; maxDatabases: number | null; maxRecords: number | null; /** The effective plan quota (number) — always set since TASK-0104. */ maxStorageBytes: number; maxApiKeys: number | null; maxWebhooks: number | null; }; } /* ------------------------------------------------------------ client */ /** Every method takes the active workspace id (sent as a header). */ export interface WsOpt { workspaceId?: string; } function wsHeaders(workspaceId?: string): Record { return workspaceId ? { "x-factory-workspace": workspaceId } : {}; } export const filesApi = { /** `GET /files` — server-side permission-filtered, cursor-paginated list. */ list(params: ListFilesParams & WsOpt = {}): Promise { const { workspaceId, scope, pageId, kind, status, search, sort, cursor, limit } = params; return apiFetch( `/files${buildQuery({ scope, pageId, kind, status, search, sort, cursor, limit })}`, { headers: wsHeaders(workspaceId) }, ); }, /** `GET /files/:id` — metadata + fresh signed URLs + versions. */ get(fileId: string, opts: WsOpt = {}): Promise { return apiFetch(`/files/${fileId}`, { headers: wsHeaders(opts.workspaceId) }); }, /** `POST /files/uploads` — open a presigned session (plan limit enforced). */ openSession(input: { name: string; mimeType: string; size: number; pageId?: string | null; blockId?: string | null; /** TASK-0104: re-upload that replaces an existing file (same block). */ replaceFileId?: string | null; } & WsOpt): Promise { const { workspaceId, ...body } = input; return mutatingApiFetch<{ upload: UploadSession }>("/files/uploads", { method: "POST", body, headers: wsHeaders(workspaceId), }).then((r) => r.upload); }, /** `PUT /files/uploads/:id/complete` — verify bytes + SCANNING + enqueue. */ complete(fileId: string, opts: WsOpt = {}): Promise<{ id: string; status: FileStatus; verifiedBy: string }> { return mutatingApiFetch(`/files/uploads/${fileId}/complete`, { method: "PUT", body: {}, headers: wsHeaders(opts.workspaceId), }); }, /** `POST /files/uploads/:id/cancel` — delete the partial object + row. */ cancel(fileId: string, opts: WsOpt = {}): Promise<{ deleted: boolean }> { return mutatingApiFetch(`/files/uploads/${fileId}/cancel`, { method: "POST", body: {}, headers: wsHeaders(opts.workspaceId), }); }, /* ------------------------------------------------- TASK-0104 endpoints */ /* * Wire-shape note (verified against the live API): the list-ish endpoints * below return a BARE JSON array at the top level — there is no `{data}` / * `{versions}` / `{grants}` envelope and no global wrapper. Only the * object-returning endpoints (get/rename/rollback/restore) return a file * record. `GET /files` (list) keeps its `{data, nextCursor}` envelope. */ /** `GET /files/trash` — trashed files (deletedAt set), bare array. */ listTrash(opts: WsOpt = {}): Promise { return apiFetch("/files/trash", { headers: wsHeaders(opts.workspaceId) }); }, /** `GET /files/:id/versions` — version history, bare array (ascending). */ listVersions(fileId: string, opts: WsOpt = {}): Promise { return apiFetch(`/files/${fileId}/versions`, { headers: wsHeaders(opts.workspaceId) }); }, /** `POST /files/:id/rollback` — make version n current (key swap). */ rollback(fileId: string, version: number, opts: WsOpt = {}): Promise { return mutatingApiFetch(`/files/${fileId}/rollback`, { method: "POST", body: { version }, headers: wsHeaders(opts.workspaceId), }); }, /** `POST /files/:id/versions/prune` — delete non-current versions. */ pruneVersions(fileId: string, opts: WsOpt = {}): Promise<{ pruned: number }> { return mutatingApiFetch<{ pruned: number }>(`/files/${fileId}/versions/prune`, { method: "POST", body: {}, headers: wsHeaders(opts.workspaceId), }); }, /** `DELETE /files/:id` — soft delete to the workspace trash. */ trash(fileId: string, opts: WsOpt = {}): Promise<{ trashed: true; deletedAt: string }> { return mutatingApiFetch(`/files/${fileId}`, { method: "DELETE", headers: wsHeaders(opts.workspaceId), }); }, /** `DELETE /files/:id?purge=true` — delete objects + thumbs + rows. */ purge(fileId: string, opts: WsOpt = {}): Promise<{ purged: true }> { return mutatingApiFetch(`/files/${fileId}${buildQuery({ purge: "true" })}`, { method: "DELETE", headers: wsHeaders(opts.workspaceId), }); }, /** `POST /files/:id/restore` — restore a trashed file. */ restore(fileId: string, opts: WsOpt = {}): Promise { return mutatingApiFetch(`/files/${fileId}/restore`, { method: "POST", body: {}, headers: wsHeaders(opts.workspaceId), }); }, /** `GET /files/:id/permissions` — explicit file grants, bare array. */ listPermissions(fileId: string, opts: WsOpt = {}): Promise { return apiFetch(`/files/${fileId}/permissions`, { headers: wsHeaders(opts.workspaceId) }); }, /** `PUT /files/:id/permissions` — REPLACE the grant set; returns the new * grants (bare array). The API is the sole authority on effective levels. */ setPermissions(fileId: string, grants: FilePermissionGrantInput[], opts: WsOpt = {}): Promise { return mutatingApiFetch(`/files/${fileId}/permissions`, { method: "PUT", body: { grants }, headers: wsHeaders(opts.workspaceId), }); }, /** `PATCH /files/:id` — rename only (the API rejects any other field). */ rename(fileId: string, name: string, opts: WsOpt = {}): Promise { return mutatingApiFetch(`/files/${fileId}`, { method: "PATCH", body: { name }, headers: wsHeaders(opts.workspaceId), }); }, }; /** `GET /workspaces/:id/usage` — usage counters + effective limits. * Lives next to the file client (storage usage is the files concern) but * targets the workspace surface (no `x-factory-workspace` header needed). */ export function getWorkspaceUsage(workspaceId: string): Promise { return apiFetch(`/workspaces/${workspaceId}/usage`); } /* ------------------------------------------------- kind + size helpers */ /** * mime → render kind. Mirrors the server's `KIND_MIMES` (files.service.ts) so * the block renderer and the list filter agree. Derived from the * server-verified `mimeType` — never the file extension. */ const KIND_MIMES: Record = { image: ["image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml"], video: ["video/mp4", "video/webm", "video/quicktime"], audio: ["audio/mpeg", "audio/wav", "audio/mp4", "audio/x-wav"], pdf: ["application/pdf"], doc: [ "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "text/markdown", "text/plain", "text/csv", ], other: [], // the fallback bucket }; export function kindForMime(mimeType: string | null | undefined): FileKind { if (!mimeType) return "other"; const m = (mimeType.toLowerCase().split(";")[0] ?? "").trim(); if (!m) return "other"; for (const kind of ["image", "video", "audio", "pdf", "doc"] as FileKind[]) { if (KIND_MIMES[kind].includes(m)) return kind; } return "other"; } /** Bytes → human string ("50 MB", "1.2 GB"). The download card + list use it. */ export function humanSize(bytes: number): string { if (!Number.isFinite(bytes) || bytes < 0) return "0 B"; if (bytes < 1024) return `${bytes} B`; const units = ["KB", "MB", "GB", "TB"]; let value = bytes; let i = -1; do { value /= 1024; i += 1; } while (value >= 1024 && i < units.length - 1); return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[i]}`; } /* ------------------------------------------- plan size-limit (pre-check) */ /** Per-plan defaults — mirrors the server's `PLAN_USAGE_LIMITS`. */ const PLAN_MAX_FILE: Record = { free: 50 * 1024 * 1024, paid: 500 * 1024 * 1024, }; /** The hard ceiling (server `LIMITS.MAX_FILE_SIZE` / @factory/shared). */ export const MAX_FILE_SIZE_HARD = 500 * 1024 * 1024; /** * Effective per-file limit = min(plan default, admin override, hard ceiling). * Mirrors the server's `effectiveMaxFileSize` (uploads.service.ts) so the * client pre-check ("50 MB per file on your plan") rejects the exact set the * server would 413 — no wasted session, and the error names the real limit. */ export function effectiveMaxFileSize( billingPlan: string, usageLimits: { maxFileSize?: number } | null | undefined, ): number { const plan = PLAN_MAX_FILE[billingPlan] ?? PLAN_MAX_FILE.free!; const override = usageLimits?.maxFileSize; return Math.min( plan, typeof override === "number" && override > 0 ? override : Infinity, MAX_FILE_SIZE_HARD, ); }