"use client"; /** * TASK-0103 — `useFileUploads`: the editor-side upload orchestrator. * * Every upload path (drag-drop on the canvas, paste, the file block's * "Add file" button) calls `start(file, anchor)`: * * 1. fetch the upload cap ONCE per page session (memoized): the plan * limit ∩ the ORG policy cap (TASK-0133 — the member-facing * `GET /workspaces/:id/policy/effective`; on failure the plan limit * alone is used — the presign stays authoritative either way) * 2. `uploadFile` — pre-check → session → XHR PUT (progress + cancel) * → complete → poll (lib/files/upload.ts) * 3. on first session: insert the `file` block at `anchor` (properties: * fileId, name) and focus it — the block shows the progress bar * (lib/files/upload-store.ts) before the bytes are done * 4. on error: a toast with the server message; the block (if inserted) * shows its error state; cancel mid-transfer removes the block * * The cap is a UI pre-check only — the server re-checks on session * creation (413 FILE_TOO_LARGE is authoritative). Fetching it once per page * session is the spec's requirement (Requirements §1a). */ import { useCallback, useRef } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "@/stores/ui-store"; import { apiFetch, blockApi, workspaceApi } from "@/lib/api"; import { effectiveMaxFileSize } from "./api"; import { uploadFile, UploadError } from "./upload"; import { useUploadStore } from "./upload-store"; export interface UploadAnchor { parentId: string | null; /** Fractional position to insert AT (null = server appends after the last sibling). */ position?: string | null; /** When set, the block is UPDATED (replace-in-place) instead of inserted. */ updateBlockId?: string; /** * TASK-0104: when set, the upload is a NEW VERSION of that existing file * (sent as `replaceFileId` in the session body). The server re-anchors to * the target file's own page/block and keeps the same fileId — the caller * should still pass the block that references it (updateBlockId) so the * progress bar renders there. */ replaceFileId?: string; } interface RawWorkspace { billingPlan?: string; usageLimits?: { maxFileSize?: number } | null; } export function useFileUploads(workspaceId: string | null | undefined, pageId: string | null) { const qc = useQueryClient(); const lastLimit = useRef<{ ws: string; at: number; max: number } | null>(null); const maxFileSize = useQuery({ queryKey: ["files", "plan-limit", workspaceId], queryFn: async () => { // a per-page-session fetch: valid for 5 min after the first read const cached = lastLimit.current; if (cached && cached.ws === workspaceId && Date.now() - cached.at < 5 * 60_000) return cached.max; if (!workspaceId) return effectiveMaxFileSize("free", null); const ws = (await workspaceApi.get(workspaceId)) as unknown as RawWorkspace; // The plan cap (always computed — it is the fallback below). let max = effectiveMaxFileSize(ws.billingPlan ?? "free", ws.usageLimits ?? null); try { // TASK-0133 — the ORG-aware cap: the member-facing effective endpoint // returns org ∩ plan (the SAME computation the presign enforces), so // an org with a lower `maxFileSizeMB` warns here instead of on the // 413. A non-org workspace returns the plan cap unchanged. const eff = await apiFetch<{ effective: { maxFileSizeBytes?: number } }>( `/workspaces/${workspaceId}/policy/effective` ); const orgCap = eff.effective?.maxFileSizeBytes; if (typeof orgCap === "number" && orgCap > 0) max = Math.min(max, orgCap); } catch { // fall back to the plan cap — the presign's 413 stays authoritative. } lastLimit.current = { ws: workspaceId, at: Date.now(), max }; return max; }, enabled: Boolean(workspaceId), staleTime: Infinity, retry: false, }); const start = useCallback( async (file: File, anchor: UploadAnchor) => { const max = maxFileSize.data ?? effectiveMaxFileSize("free", null); const ws = workspaceId ?? undefined; const handle = uploadFile( file, { pageId: pageId ?? null, workspaceId: ws, maxFileSize: max, replaceFileId: anchor.replaceFileId, onSession: (fileId) => { // The block appears as soon as the session exists — the progress // bar is visible BEFORE the transfer completes (spec §1). The // cancel handle is registered here too (fileId is known now). useUploadStore.getState().registerCancel(fileId, handle.cancel); const insert = async () => { if (anchor.updateBlockId) { await blockApi.update(anchor.updateBlockId, { type: "file", properties: { fileId, name: file.name }, }); } else { await blockApi.insert({ pageId: pageId as string, parentId: anchor.parentId, type: "file", properties: { fileId, name: file.name }, // an explicit fractional position wins; otherwise the API // appends after the last sibling of this parent. ...(anchor.position ? { position: anchor.position } : {}), }); } // These calls bypass the invalidating block hooks (this file is // imported by the editor, so it cannot use them without a cycle) — // refresh the page's block query here, or the rendered block // stays "No file yet" until a manual reload (the uploaded file // is already READY server-side). Same key as // useInvalidatePageBlocks (hooks/use-blocks.ts). if (pageId) qc.invalidateQueries({ queryKey: ["pages", pageId, "blocks"] }); }; void insert().catch((e) => toast("error", "Could not add the file block", e instanceof Error ? e.message : undefined), ); }, onProgress: (p) => { const id = handle.fileId; if (id) useUploadStore.getState().set(id, p); }, }, ); try { await handle.promise; // settled: the block now polls GET /files/:id on its own if (handle.fileId) useUploadStore.getState().clear(handle.fileId); } catch (err) { if (handle.fileId) useUploadStore.getState().clear(handle.fileId); if (err instanceof UploadError && err.code === "CANCELLED") { // default: remove the block (spec §2) if (anchor.updateBlockId) { void blockApi.remove(anchor.updateBlockId).catch(() => undefined); } toast("info", "Upload cancelled"); return; } if (err instanceof UploadError && err.code === "FILE_TOO_LARGE") { toast("error", "File too large", err.message); return; } toast("error", "Upload failed", err instanceof Error ? err.message : "Unknown error"); } }, [qc, maxFileSize.data, pageId, workspaceId], ); return { start, maxFileSize: maxFileSize.data ?? null, limitLoading: maxFileSize.isLoading }; }