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 && (