From 5498cf4342f10ff6965f0bc05e25525f99825b30 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sat, 22 Nov 2025 04:22:18 +0100 Subject: [PATCH] apiclient --- frontend/src/app/SettingsRoute.tsx | 2 +- frontend/src/app/useDocumentPreview.ts | 21 ++-- frontend/src/app/useDocumentsSearch.ts | 3 +- frontend/src/desktop/DesktopWorkspace.tsx | 5 +- .../documents/SelectionFloatingActions.tsx | 5 +- .../src/documents/panel/DocumentsPanel.tsx | 2 +- .../src/hooks/documents/useDocumentUploads.ts | 4 +- .../hooks/documents/useDocumentsWorkspace.ts | 4 +- frontend/src/lib/apiClient.ts | 88 +++++++++++++++ frontend/src/lib/apiTypes.ts | 105 ++++++++++++++++++ frontend/src/preview/DocumentViewerPanel.tsx | 8 +- frontend/src/settings/useCapabilities.ts | 18 +-- frontend/src/settings/useCapabilitySets.ts | 5 +- 13 files changed, 224 insertions(+), 46 deletions(-) create mode 100644 frontend/src/lib/apiClient.ts create mode 100644 frontend/src/lib/apiTypes.ts diff --git a/frontend/src/app/SettingsRoute.tsx b/frontend/src/app/SettingsRoute.tsx index 00a16ee..fb86cb3 100644 --- a/frontend/src/app/SettingsRoute.tsx +++ b/frontend/src/app/SettingsRoute.tsx @@ -57,7 +57,7 @@ const SettingsRoute: React.FC = ({ open = true, onClose }) = capabilities, capabilitiesLoading, refreshCapabilities, - } = useCapabilities({ api, notifyApiError, token }); + } = useCapabilities({ notifyApiError, token }); useEffect(() => { refreshTokens(); diff --git a/frontend/src/app/useDocumentPreview.ts b/frontend/src/app/useDocumentPreview.ts index 848b64d..5b93709 100644 --- a/frontend/src/app/useDocumentPreview.ts +++ b/frontend/src/app/useDocumentPreview.ts @@ -4,6 +4,7 @@ import type { MutableRefObject, SetStateAction, } from 'react'; +import { fetchDocument } from '../lib/apiClient'; type DocumentId = string | number; type FolderId = DocumentId | 'root'; @@ -23,10 +24,6 @@ type DocumentLink = { expiresAt?: number; }; -interface ApiClient { - get: (path: string) => Promise<{ data: T }>; -} - type NavigateHandler = (path: string, options?: { replace?: boolean }) => void; interface UseDocumentPreviewArgs { @@ -39,7 +36,6 @@ interface UseDocumentPreviewArgs { ingest: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean }; }; selectedFolder?: FolderId | null; - api: ApiClient; notifyApiError: (error: unknown, message: string) => void; navigate: NavigateHandler; locationPathname: string; @@ -65,7 +61,6 @@ const useDocumentPreview = ({ routeDocumentId, documentsManager, selectedFolder, - api, notifyApiError, navigate, locationPathname, @@ -120,8 +115,8 @@ const useDocumentPreview = ({ const request: Promise = (async () => { try { - const docResponse = await api.get<{ document?: Record }>(`/documents/${documentId}`); - const download = docResponse.data?.document?.current_version?.download || null; + const docResponse = await fetchDocument(documentId); + const download = docResponse?.current_version?.download || null; const downloadUrl = download?.url; if (!downloadUrl) { throw new Error('Document missing download url'); @@ -129,8 +124,8 @@ const useDocumentPreview = ({ const entry: DocumentLink = { url: downloadUrl, - contentType: docResponse.data?.document?.current_version?.version?.content_type || null, - filename: docResponse.data?.document?.filename, + contentType: docResponse?.content_type || null, + filename: docResponse?.filename, expiresAt: download?.expires_at, }; setDocumentLinks((prev) => { @@ -150,7 +145,7 @@ const useDocumentPreview = ({ previewInflightRef.current.set(documentId, request); return request; }, - [documentLinks, api, notifyApiError], + [documentLinks, notifyApiError], ); const ensurePreviewData = useCallback( @@ -166,8 +161,7 @@ const useDocumentPreview = ({ } if (!doc) { - const { data } = await api.get(`/documents/${documentId}`); - const fetched = (data as { document?: DocumentLike })?.document || data; + const fetched = await fetchDocument(documentId); const { canonical } = documentsManager.ingest([fetched as unknown]); doc = (canonical[0] as DocumentLike | undefined) || null; if (!doc) { @@ -189,7 +183,6 @@ const useDocumentPreview = ({ documentsManager, ensureDownloadUrl, setActivePreviewId, - api, ], ); diff --git a/frontend/src/app/useDocumentsSearch.ts b/frontend/src/app/useDocumentsSearch.ts index 9329b99..f0feb3d 100644 --- a/frontend/src/app/useDocumentsSearch.ts +++ b/frontend/src/app/useDocumentsSearch.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; import { TAG_FILTER_UNTAGGED } from './appLayoutUtils'; +import { listDocuments } from '../lib/apiClient'; type Identifier = string | number; @@ -227,7 +228,7 @@ const useDocumentsSearch = ({ if (documentsSortDirection) { params.dir = documentsSortDirection; } - const { data } = await api.get('/documents', { params }); + const data = await listDocuments(params); if (cancelled) return; const results = Array.isArray(data) ? data : []; diff --git a/frontend/src/desktop/DesktopWorkspace.tsx b/frontend/src/desktop/DesktopWorkspace.tsx index 1a308f1..3f95414 100644 --- a/frontend/src/desktop/DesktopWorkspace.tsx +++ b/frontend/src/desktop/DesktopWorkspace.tsx @@ -566,7 +566,6 @@ const DesktopWorkspace: React.FC = ({ } const docContentType = doc?.content_type ?? null; - const versionContentType = (doc?.current_version as { version?: { content_type?: string | null } } | null)?.version?.content_type ?? null; const applyEntry = (entry?: DocumentLinkLike | null) => { if (!entry?.url) { @@ -575,8 +574,8 @@ const DesktopWorkspace: React.FC = ({ } setOverlaySource({ url: entry.url, - alt: doc.title as string | undefined, - contentType: entry.contentType || docContentType || versionContentType || undefined, + alt: doc.title, + contentType: docContentType || undefined, }); }; diff --git a/frontend/src/documents/SelectionFloatingActions.tsx b/frontend/src/documents/SelectionFloatingActions.tsx index c0b9ea6..49e0248 100644 --- a/frontend/src/documents/SelectionFloatingActions.tsx +++ b/frontend/src/documents/SelectionFloatingActions.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { getFolderTree } from '../lib/apiClient'; import { TrashIcon, AnalyzeIcon, @@ -10,7 +11,7 @@ import { } from '../ui/icons'; import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu'; import SelectionSummary from './SelectionSummary'; -import { api, useAppState } from '../app/appState'; +import { useAppState } from '../app/appState'; import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext'; const ROOT_FOLDER_LABEL = 'Documents'; @@ -312,7 +313,7 @@ const SelectionFloatingActions: React.FC = ({ const fetchPromise = (async () => { setLoadingFolders(true); try { - const { data } = await api.get('/folders/tree'); + const data = await getFolderTree(); const options = buildFolderTreeOptions(data); setRemoteFolderOptions(options); return options; diff --git a/frontend/src/documents/panel/DocumentsPanel.tsx b/frontend/src/documents/panel/DocumentsPanel.tsx index ff57976..33a3b08 100644 --- a/frontend/src/documents/panel/DocumentsPanel.tsx +++ b/frontend/src/documents/panel/DocumentsPanel.tsx @@ -291,7 +291,7 @@ const DocumentsPanelInner: React.FC = ({ }; } const docContentType = previewDoc.content_type; - const versionContentType = previewDoc.current_version?.version?.content_type; + const versionContentType = previewDoc.current_version?.content_type; const contentFallback = docContentType || versionContentType || null; const applyEntry = (entry?: DocumentLinkLike | null) => { diff --git a/frontend/src/hooks/documents/useDocumentUploads.ts b/frontend/src/hooks/documents/useDocumentUploads.ts index 9d5a41a..ed31fc7 100644 --- a/frontend/src/hooks/documents/useDocumentUploads.ts +++ b/frontend/src/hooks/documents/useDocumentUploads.ts @@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import useFileDrop from './useFileDrop'; import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils'; +import { fetchDocument } from '../../lib/apiClient'; type Identifier = string | number; type FolderId = Identifier | 'root' | null; @@ -174,8 +175,7 @@ const useDocumentUploads = ({ let conflictDocument = null; if (conflictId) { try { - const { data } = await apiClient.get(`/documents/${conflictId}`); - conflictDocument = (data as any)?.document ?? data ?? null; + conflictDocument = await fetchDocument(conflictId); } catch (fetchError) { console.warn('[Uploads] failed to fetch conflict document', fetchError); } diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.ts b/frontend/src/hooks/documents/useDocumentsWorkspace.ts index 1d2f1ac..66eebed 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.ts +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.ts @@ -48,6 +48,7 @@ import useTags from './useTags'; import useCorrespondents from './useCorrespondents'; import useTenantManager from './useTenantManager'; import useDocuments from './useDocuments'; +import { fetchDocument } from '../../lib/apiClient'; import useFolderTree from './useFolderTree'; import useFolderTreeActions from './useFolderTreeActions'; import useDocumentTagging from './useDocumentTagging'; @@ -238,7 +239,7 @@ const useDocumentsWorkspace = ({ if (!documentId) { return null; } - const { data } = await api.get(`/documents/${documentId}`); + const data = await fetchDocument(documentId); return extractDocumentFromResponse(data); }, [extractDocumentFromResponse], @@ -449,7 +450,6 @@ const useDocumentsWorkspace = ({ routeDocumentId: previewDocumentId, documentsManager, selectedFolder, - api, notifyApiError, navigate, locationPathname: location.pathname, diff --git a/frontend/src/lib/apiClient.ts b/frontend/src/lib/apiClient.ts new file mode 100644 index 0000000..0ffbac9 --- /dev/null +++ b/frontend/src/lib/apiClient.ts @@ -0,0 +1,88 @@ +import api from './api'; +import type { + ApiTokenRecord, + AssetResponse, + CapabilityResponse, + CapabilitySetResponse, + DownloadLink, + DocumentResponse, + FolderTreeNode, + Identifier, + PasskeySummary, +} from './apiTypes'; + +const normalizeNumber = (value: unknown): number | undefined => { + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +}; + +const normalizeDownload = (input?: DownloadLink | null): DownloadLink | null => { + if (!input?.url) { + return null; + } + const expires_at = normalizeNumber(input.expires_at); + if (!expires_at) { + return null; + } + return { url: input.url, expires_at }; +}; + +export const fetchDocument = async (id: Identifier): Promise => { + const { data } = await api.get<{ document?: DocumentResponse }>(`/documents/${id}`); + const doc = data?.document || (data as unknown as DocumentResponse); + if (doc?.current_version?.download) { + doc.current_version.download = normalizeDownload(doc.current_version.download); + } + return doc; +}; + +export const fetchAsset = async (id: Identifier): Promise => { + const { data } = await api.get(`/assets/${id}`); + const download = normalizeDownload(data.download); + return { + ...data, + download, + }; +}; + +export const listDocuments = async (params: Record = {}): Promise => { + const { data } = await api.get('/documents', { params }); + return Array.isArray(data) ? data : []; +}; + +export const getFolderTree = async (): Promise => { + const { data } = await api.get('/folders/tree'); + return Array.isArray(data) ? data : []; +}; + +export const listCapabilitySets = async (): Promise => { + const { data } = await api.get('/capability-sets'); + return Array.isArray(data) ? data : []; +}; + +export const listCapabilities = async (): Promise => { + const { data } = await api.get('/capabilities'); + return Array.isArray(data) ? data : []; +}; + +export const listApiTokens = async (): Promise => { + const { data } = await api.get('/profile/api-tokens'); + return Array.isArray(data) ? data : []; +}; + +export const listPasskeys = async (): Promise => { + const { data } = await api.get('/profile/passkeys'); + return Array.isArray(data) ? data : []; +}; + +export type { + DownloadLink, + DocumentResponse, + AssetResponse, + FolderTreeNode, + CapabilitySetResponse, + CapabilityResponse, + ApiTokenRecord, + PasskeySummary, + Identifier, +} from './apiTypes'; diff --git a/frontend/src/lib/apiTypes.ts b/frontend/src/lib/apiTypes.ts new file mode 100644 index 0000000..fb2b089 --- /dev/null +++ b/frontend/src/lib/apiTypes.ts @@ -0,0 +1,105 @@ +// Types aligned with OpenAPI schemas for common endpoints. + +export type Identifier = string | number; + +export interface DownloadLink { + url: string; + expires_at: number; +} + +export interface TagResponse { + id: string; + label: string; + color?: string | null; +} + +export interface CorrespondentResponse { + id: string; + name: string; + metadata: Record; +} + +export interface AssetResponse { + id: string; + asset_type: string; + mime_type: string; + metadata: Record; + download?: DownloadLink | null; +} + +export interface DocumentVersionResponse { + id: string; + version_number: number; + size_bytes: number; + checksum: string; + created_at: string; + content_type: string | null; + metadata: Record; + download: DownloadLink; + assets?: AssetResponse[] | null; +} + +export interface DocumentResponse { + id: string; + filename: string; + title: string; + original_name: string; + content_type?: string | null; + folder_id?: string | null; + created_at: string; + updated_at: string; + issued_at?: string | null; + metadata: Record; + tags: TagResponse[]; + correspondents?: CorrespondentResponse[]; + current_version?: DocumentVersionResponse | null; +} + +export interface FolderInfo { + id: string; + name: string; + parent_id?: string | null; + created_at?: string; + updated_at?: string; +} + +export interface FolderTreeNode extends FolderInfo { + children?: FolderTreeNode[]; +} + +export interface CapabilitySetResponse { + id: string; + slug: string; + is_system: boolean; + cap_version: number; + capabilities: string[]; +} + +export interface CapabilityResponse { + id?: string; + name: string; +} + +export interface TenantSnippet { + id: string; + name: string; +} + +export interface ApiTokenRecord { + id: string; + label?: string | null; + capability_set_id: string; + created_at: string; + last_used_at?: string | null; + expires_at?: string | null; +} + +export interface PasskeySummary { + id: string; + nickname?: string | null; + createdAt: string; + lastUsedAt?: string | null; + transports?: string[]; + revokedAt?: string | null; + revokedReason?: string | null; +} diff --git a/frontend/src/preview/DocumentViewerPanel.tsx b/frontend/src/preview/DocumentViewerPanel.tsx index 31e7b97..92ffe01 100644 --- a/frontend/src/preview/DocumentViewerPanel.tsx +++ b/frontend/src/preview/DocumentViewerPanel.tsx @@ -268,7 +268,7 @@ const DocumentViewerPanel: React.FC = ({ if (!href) { return null; } - const contentType = document.current_version?.version?.content_type || document.content_type || null; + const contentType = document.content_type || null; const filename = document.current_version?.filename || document.filename || document.title || null; return { url: href, @@ -363,15 +363,13 @@ const DocumentViewerPanel: React.FC = ({ if (!resolvedDocumentLink?.url || !document) { return null; } - const docContentType = document.content_type; - const versionContentType = document.current_version?.version?.content_type; - const normalizedContentType = resolvedDocumentLink.contentType || docContentType || versionContentType || null; + const normalizedContentType = document.content_type || null; return { url: resolvedDocumentLink.url, alt: document.title, contentType: normalizedContentType || undefined, }; - }, [resolvedDocumentLink?.url, resolvedDocumentLink?.contentType, document]); + }, [document, resolvedDocumentLink?.url]); const headerActions = createDocumentViewerHeaderActions({ document, diff --git a/frontend/src/settings/useCapabilities.ts b/frontend/src/settings/useCapabilities.ts index 0ceb0bc..96f61b4 100644 --- a/frontend/src/settings/useCapabilities.ts +++ b/frontend/src/settings/useCapabilities.ts @@ -1,16 +1,12 @@ import { useCallback, useEffect, useState } from 'react'; - -interface CapabilitiesApi { - get: (path: string) => Promise<{ data: unknown }>; -} +import { listCapabilities } from '../lib/apiClient'; interface UseCapabilitiesOptions { - api: CapabilitiesApi; notifyApiError?: (error: unknown, fallbackMessage: string) => void; token?: string | null; } -const useCapabilities = ({ api, notifyApiError, token }: UseCapabilitiesOptions) => { +const useCapabilities = ({ notifyApiError, token }: UseCapabilitiesOptions) => { const [capabilities, setCapabilities] = useState([]); const [capabilitiesLoading, setCapabilitiesLoading] = useState(false); @@ -21,19 +17,15 @@ const useCapabilities = ({ api, notifyApiError, token }: UseCapabilitiesOptions) } setCapabilitiesLoading(true); try { - const { data } = await api.get('/capabilities'); - if (Array.isArray(data)) { - setCapabilities(data as string[]); - } else { - setCapabilities([]); - } + const data = await listCapabilities(); + setCapabilities(Array.isArray(data) ? data.map((item) => item.name) : []); } catch (error) { notifyApiError?.(error, 'Failed to load capabilities.'); setCapabilities([]); } finally { setCapabilitiesLoading(false); } - }, [api, notifyApiError, token]); + }, [notifyApiError, token]); useEffect(() => { if (token) { diff --git a/frontend/src/settings/useCapabilitySets.ts b/frontend/src/settings/useCapabilitySets.ts index 2d9e62f..cb13509 100644 --- a/frontend/src/settings/useCapabilitySets.ts +++ b/frontend/src/settings/useCapabilitySets.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import { listCapabilitySets } from '../lib/apiClient'; type Identifier = string | number; @@ -51,14 +52,14 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: Use } setCapabilitySetsLoading(true); try { - const { data } = await api.get('/capability-sets'); + const data = await listCapabilitySets(); applyCapabilitySets(Array.isArray(data) ? data : []); } catch (error) { notifyApiError?.(error, 'Failed to load capability sets.'); } finally { setCapabilitySetsLoading(false); } - }, [api, applyCapabilitySets, notifyApiError, token]); + }, [applyCapabilitySets, notifyApiError, token]); useEffect(() => { if (token) {