From 4bf90ffe2551d66182ccaab28f07165d95bbfc13 Mon Sep 17 00:00:00 2001 From: glass beadian Date: Sun, 11 Oct 2026 11:08:08 +0000 Subject: [PATCH] Complete files UI rewiring to the deployed api surface; expose mimeClass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit page.tsx now calls the real backend: GET /files with groupId/name/mimeClass/ cursor/limit (origin filter dropped — the api has no origin param), shares tracked as ShareCreated (revoke by id), retention mapped to the orthogonal legalHold model, downloads by item.name, samples reshaped to the wire contract. GroupFile gains mimeClass (the indexed coarse class the row icon and the filter depend on): core contract, service row mapper, parseGroupFile guard, test fixture, UI types — one vocabulary end to end. parseGroupFile's roomId becomes null-tolerant to match direct-upload rows (mapping input stays strict). Verified: core build clean, api typecheck 0 errors + group-files 22/22, web typecheck 0 errors. --- .../src/group-files/group-files.service.ts | 1 + apps/api/src/group-files/group-files.test.ts | 1 + .../src/group-files/group-files.validation.ts | 3 +- apps/web/src/app/(main)/files/page.tsx | 92 +++++++++---------- apps/web/src/components/files/types.ts | 1 + packages/core/src/shared-files.ts | 4 + 6 files changed, 53 insertions(+), 49 deletions(-) diff --git a/apps/api/src/group-files/group-files.service.ts b/apps/api/src/group-files/group-files.service.ts index a4bc15c..d567edb 100644 --- a/apps/api/src/group-files/group-files.service.ts +++ b/apps/api/src/group-files/group-files.service.ts @@ -160,6 +160,7 @@ function rowToGroupFile(row: ArchiveRow, libraryUrl: (resourceId: string) => str origin, name: row.original_name, mimeType: row.mime, + mimeClass: row.mime_class as MimeClass, sizeBytes: safeNumber(row.byte_size), checksum: { algorithm: "sha256", value: row.sha256 }, createdAt: iso(row.created_at), diff --git a/apps/api/src/group-files/group-files.test.ts b/apps/api/src/group-files/group-files.test.ts index 31ca246..9db2ff0 100644 --- a/apps/api/src/group-files/group-files.test.ts +++ b/apps/api/src/group-files/group-files.test.ts @@ -135,6 +135,7 @@ describe("group-files runtime validation (shared contracts)", () => { origin: { kind: "matrix", mxcUri: "mxc://example.org/x" }, name: "clip.mp4", mimeType: "video/mp4", + mimeClass: "video", sizeBytes: 12, checksum: { algorithm: "sha256", value: hex64("z") }, createdAt: "2026-01-02T03:04:05Z", diff --git a/apps/api/src/group-files/group-files.validation.ts b/apps/api/src/group-files/group-files.validation.ts index f2e2ba1..1f0ffaf 100644 --- a/apps/api/src/group-files/group-files.validation.ts +++ b/apps/api/src/group-files/group-files.validation.ts @@ -216,12 +216,13 @@ export function parseGroupFile(value: unknown, field = "item"): GroupFile { id: stringField(value, "id", `${field}.id`), workspaceId: stringField(value, "workspaceId", `${field}.workspaceId`), groupId: stringField(value, "groupId", `${field}.groupId`), - roomId: requireRoomId(value.roomId, `${field}.roomId`), + roomId: value.roomId === undefined || value.roomId === null ? null : requireRoomId(value.roomId, `${field}.roomId`), senderId: requireMxid(value.senderId, `${field}.senderId`), eventId: eventId === undefined || eventId === null ? null : eventId, origin, name: stringField(value, "name", `${field}.name`), mimeType: stringField(value, "mimeType", `${field}.mimeType`), + mimeClass: enumField(value, "mimeClass", MIME_CLASSES, `${field}.mimeClass`), sizeBytes: integerField(value, "sizeBytes", `${field}.sizeBytes`), checksum: { algorithm: "sha256", value: requireSha256(checksum.value, `${field}.checksum.value`) }, createdAt: isoField(value, "createdAt", `${field}.createdAt`), diff --git a/apps/web/src/app/(main)/files/page.tsx b/apps/web/src/app/(main)/files/page.tsx index 5014f44..fb86ab0 100644 --- a/apps/web/src/app/(main)/files/page.tsx +++ b/apps/web/src/app/(main)/files/page.tsx @@ -14,44 +14,50 @@ import { ShareDialog } from "../../../components/files/share-dialog"; import { RetentionDialog } from "../../../components/files/retention-dialog"; import { downloadGroupFile } from "../../../components/files/files-client"; import { useFilesLang } from "../../../components/files/i18n"; -import { MIME_CLASS_LABEL, ORIGIN_LABEL } from "../../../components/files/labels"; +import { MIME_CLASS_LABEL } from "../../../components/files/labels"; import type { - FileOrigin, - GroupFileItem, + GroupFile, GroupFilePage, GroupSummary, MimeClass, RetentionInput, + ShareCreated, } from "../../../components/files/types"; /** * Group file library — the per-group "group cloud". Live mode pages through - * GET /files/groups/:groupId/items by cursor (video library's load-more - * pattern) with a debounced server-side `q` and mimeClass/origin filters. - * Offline the screen renders sample data read-only per useApiResource rules. + * GET /files by cursor (video library's load-more pattern) with a debounced + * server-side `name` search and a mimeClass filter. Offline the screen + * renders sample data read-only per useApiResource rules. */ const PAGE_LIMIT = 50; const SAMPLE_GROUPS: GroupSummary[] = [{ id: "sample-group", name: "Collective media" }]; -const SAMPLE_FILES: GroupFileItem[] = [ +const SAMPLE_FILES: GroupFile[] = [ { - id: "sample-1", groupId: "sample-group", origin: "matrix", senderMxid: "@ada:collective.example", - originalName: "press-kit.zip", safeName: "press-kit.zip", mime: "application/zip", mimeClass: "other", - byteSize: 4_812_441, sha256: "a".repeat(64), createdAt: "2026-10-08T09:12:00.000Z", available: true, - retentionMode: "keep", retentionUntil: null, + id: "sample-1", workspaceId: "sample-ws", groupId: "sample-group", roomId: null, senderId: "@ada:collective.example", + eventId: null, origin: { kind: "matrix" }, name: "press-kit.zip", mimeType: "application/zip", mimeClass: "other", + sizeBytes: 4_812_441, checksum: { value: "a".repeat(64) }, createdAt: "2026-10-08T09:12:00.000Z", + indexedAt: "2026-10-08T09:12:05.000Z", file: { resourceId: "res-sample-1" }, + shareLink: { url: "", access: "members", expiresAt: null }, + retention: { mode: "keep", legalHold: false, expiresAt: null }, status: "available", }, { - id: "sample-2", groupId: "sample-group", origin: "upload", senderMxid: "@you:collective.example", - originalName: "launch-banner.png", safeName: "launch-banner.png", mime: "image/png", mimeClass: "image", - byteSize: 348_112, sha256: "b".repeat(64), createdAt: "2026-10-08T11:40:00.000Z", available: true, - retentionMode: "until", retentionUntil: "2026-11-30T00:00:00.000Z", + id: "sample-2", workspaceId: "sample-ws", groupId: "sample-group", roomId: null, senderId: "@you:collective.example", + eventId: null, origin: { kind: "upload" }, name: "launch-banner.png", mimeType: "image/png", mimeClass: "image", + sizeBytes: 348_112, checksum: { value: "b".repeat(64) }, createdAt: "2026-10-08T11:40:00.000Z", + indexedAt: "2026-10-08T11:40:05.000Z", file: { resourceId: "res-sample-2" }, + shareLink: { url: "", access: "members", expiresAt: null }, + retention: { mode: "until", legalHold: false, expiresAt: "2026-11-30T00:00:00.000Z" }, status: "available", }, { - id: "sample-3", groupId: "sample-group", origin: "bridge", senderMxid: "@bridge:collective.example", - originalName: "statement-final.pdf", safeName: "statement-final.pdf", mime: "application/pdf", mimeClass: "document", - byteSize: 1_204_998, sha256: "c".repeat(64), createdAt: "2026-10-07T18:05:00.000Z", available: true, - retentionMode: "legalHold", retentionUntil: null, + id: "sample-3", workspaceId: "sample-ws", groupId: "sample-group", roomId: null, senderId: "@bridge:collective.example", + eventId: "evt-1", origin: { kind: "bridge" }, name: "statement-final.pdf", mimeType: "application/pdf", mimeClass: "document", + sizeBytes: 1_204_998, checksum: { value: "c".repeat(64) }, createdAt: "2026-10-07T18:05:00.000Z", + indexedAt: "2026-10-07T18:05:05.000Z", file: { resourceId: "res-sample-3" }, + shareLink: { url: "", access: "members", expiresAt: null }, + retention: { mode: "keep", legalHold: true, expiresAt: null }, status: "available", }, ]; @@ -63,7 +69,7 @@ export default function FilesPage(): React.ReactElement { const groups = useApiResource("/groups/mine", identityGroups, SAMPLE_GROUPS); const [pickedGroup, setPickedGroup] = useState(null); - const [items, setItems] = useState([]); + const [items, setItems] = useState([]); const [source, setSource] = useState<"loading" | "live" | "sample">("loading"); const [error, setError] = useState(null); const [nextCursor, setNextCursor] = useState(null); @@ -74,14 +80,13 @@ export default function FilesPage(): React.ReactElement { const [qInput, setQInput] = useState(""); const [q, setQ] = useState(""); const [mimeClass, setMimeClass] = useState<"" | MimeClass>(""); - const [origin, setOrigin] = useState<"" | FileOrigin>(""); const [actionError, setActionError] = useState(null); const [notice, setNotice] = useState(null); const [busyId, setBusyId] = useState(null); - const [shareFor, setShareFor] = useState(null); - const [retentionFor, setRetentionFor] = useState(null); - const [shares, setShares] = useState>({}); + const [shareFor, setShareFor] = useState(null); + const [retentionFor, setRetentionFor] = useState(null); + const [shares, setShares] = useState>({}); const groupId = pickedGroup ?? groups.data[0]?.id ?? ""; const live = source === "live"; @@ -111,7 +116,7 @@ export default function FilesPage(): React.ReactElement { const controller = new AbortController(); setLoading(true); api - .get(`/files/groups/${encodeURIComponent(groupId)}/items`, { q, mimeClass, origin, limit: PAGE_LIMIT }, controller.signal) + .get("/files", { groupId, name: q, mimeClass, limit: PAGE_LIMIT }, controller.signal) .then((page) => { setItems(page.items); setNextCursor(page.nextCursor); @@ -133,14 +138,14 @@ export default function FilesPage(): React.ReactElement { if (!controller.signal.aborted) setLoading(false); }); return () => controller.abort(); - }, [authStatus, groupId, q, mimeClass, origin, nonce]); + }, [authStatus, groupId, q, mimeClass, nonce]); async function loadMore(): Promise { if (nextCursor === null || groupId === "") return; setLoadingMore(true); try { - const page = await api.get(`/files/groups/${encodeURIComponent(groupId)}/items`, { - q, mimeClass, origin, limit: PAGE_LIMIT, cursor: nextCursor, + const page = await api.get("/files", { + groupId, name: q, mimeClass, limit: PAGE_LIMIT, cursor: nextCursor, }); setItems((current) => { const seen = new Set(current.map((item) => item.id)); @@ -154,24 +159,24 @@ export default function FilesPage(): React.ReactElement { } } - const handleUploaded = useCallback((item: GroupFileItem): void => { + const handleUploaded = useCallback((item: GroupFile): void => { setItems((current) => [item, ...current.filter((existing) => existing.id !== item.id)]); setNotice(t({ en: "File uploaded.", es: "Archivo subido." })); }, [t]); - async function handleDownload(item: GroupFileItem): Promise { + async function handleDownload(item: GroupFile): Promise { setActionError(null); try { - await downloadGroupFile(item.id, item.safeName); + await downloadGroupFile(item.id, item.name); } catch (err) { setActionError(errorMessage(err)); } } - async function revokeShare(item: GroupFileItem): Promise { + async function revokeShare(item: GroupFile): Promise { const entry = shares[item.id]; - if (entry === undefined || entry.shareId === null || entry.shareId === "") { - setActionError(t({ en: "No share id to revoke — reopen the share dialog.", es: "No hay id de enlace que revocar — vuelve a abrir el cuadro de compartir." })); + if (entry === undefined) { + setActionError(t({ en: "No share to revoke — open the share dialog first.", es: "No hay enlace que revocar — abre primero el cuadro de compartir." })); return; } const previous = shares; @@ -184,7 +189,7 @@ export default function FilesPage(): React.ReactElement { return next; }); try { - await api.del(`/files/shares/${encodeURIComponent(entry.shareId)}`); + await api.del(`/files/shares/${encodeURIComponent(entry.id)}`); setNotice(t({ en: "Share link revoked.", es: "Enlace para compartir revocado." })); } catch (err) { setShares(previous); @@ -197,7 +202,9 @@ export default function FilesPage(): React.ReactElement { function handleRetentionSaved(itemId: string, input: RetentionInput): void { setItems((current) => current.map((item) => - item.id === itemId ? { ...item, retentionMode: input.mode, retentionUntil: input.until ?? null } : item, + item.id === itemId + ? { ...item, retention: { mode: input.mode, legalHold: input.legalHold ?? false, expiresAt: input.until ?? null } } + : item, ), ); setNotice(t({ en: "Retention updated.", es: "Retención actualizada." })); @@ -277,17 +284,6 @@ export default function FilesPage(): React.ReactElement { ))} - {live && (