/** * File Attachment Service * Typed API client matching the backend file-attachment.routes.js exactly */ import apiClient from './api-client'; // ───────────────────────────────────────── // Types // ───────────────────────────────────────── export interface FileAttachment { id: string; tenant_id: string; original_name: string; stored_name: string; file_path: string; mime_type: string; file_size: number; file_size_formatted: string; checksum: string; storage_provider: string; storage_bucket: string | null; storage_region: string | null; entity_type: string; entity_id: string; category: string; category_id: string | null; description: string | null; tags: string[]; version: number; is_current_version: boolean; previous_version_id: string | null; is_public: boolean; access_level: string; download_count: number; has_thumbnail: boolean; thumbnail_path: string | null; metadata: Record; scan_status: string; scanned_at: string | null; source_module: string; source_module_id: string | null; uploaded_by: string; uploaded_by_email: string | null; created_at: string; updated_at: string; deleted_at: string | null; } export interface FileAttachmentPagination { total: number; limit: number; offset: number; } export interface FileListResponse { success: boolean; data: FileAttachment[]; pagination: FileAttachmentPagination; } export interface FileDetailResponse { success: boolean; data: FileAttachment; } export interface VersionHistoryResponse { success: boolean; data: FileAttachment[]; } export interface StorageStats { quota: { max_storage: number; used_storage: number; available: number; usage_percent: number; }; files: { total: number; images: number; pdfs: number; documents: number; }; by_entity: Record; by_module: Record; } export interface StorageQuota { id: string; tenant_id: string; max_storage_bytes: number; max_file_size_bytes: number; used_storage_bytes: number; file_count: number; allowed_mime_types: string[] | null; blocked_extensions: string[]; max_storage_formatted: string; used_storage_formatted: string; max_file_size_formatted: string; updated_at: string; } export interface FilterOptions { tags: string[]; metadata: Record; } export interface CategoriesFilterOptions { categories: Array<{ category: string; category_id: string | null }>; } export interface ShareResponse { id: string; tenant_id: string; file_id: string; share_token: string; share_type: 'link' | 'user'; shared_with_user_id: string | null; shared_with_email: string | null; permissions: 'view' | 'download'; expires_at: string | null; max_downloads: number | null; current_downloads: number; created_by: string; created_at: string; is_active: boolean; share_url: string; } // ───────────────────────────────────────── // Upload Params (matches UploadFileSchema) // ───────────────────────────────────────── export interface UploadFilesParams { files: File[]; entity_type: string; // required entity_id: string; // required — must be valid UUID category?: string; // optional string label category_id?: string; // optional description?: string; // max 500 chars tags?: string[]; // array of tag strings metadata?: Record; } // ───────────────────────────────────────── // List Params // ───────────────────────────────────────── export interface FileListParams { entity_type?: string; entity_id?: string; mime_type?: string; category?: string; category_id?: string; source_module?: string; source_module_id?: string; search?: string; tags?: string; uploaded_by?: string; sort_by?: 'created_at' | 'original_name' | 'file_size' | 'version' | 'category' | 'source_module'; sort_order?: 'ASC' | 'DESC'; limit?: number; offset?: number; } // ───────────────────────────────────────── // Update Params (matches UpdateFileMetadataSchema) // ───────────────────────────────────────── export interface UpdateFileMetadataParams { category?: string; category_id?: string | null; description?: string; tags?: string[]; metadata?: Record; } // ───────────────────────────────────────── // Share Params (matches CreateShareSchema) // ───────────────────────────────────────── export interface CreateShareParams { share_type?: 'link' | 'user'; shared_with_user_id?: string | null; shared_with_email?: string | null; permissions?: 'view' | 'download'; expires_at?: Date | null; expires_in_hours?: number; max_downloads?: number | null; } // ───────────────────────────────────────── // Service // ───────────────────────────────────────── export const fileAttachmentService = { /** GET /files — list with filters */ list: async (params: FileListParams = {}): Promise => { const response = await apiClient.get('/files', { params }); return response.data; }, /** GET /files/:id — single file detail */ getById: async (id: string): Promise => { const response = await apiClient.get(`/files/${id}`); return response.data; }, /** GET /files/entity/:entity_type/:entity_id */ getByEntity: async ( entityType: string, entityId: string, params: { category?: string; limit?: number; offset?: number; current_only?: boolean } = {} ): Promise => { const response = await apiClient.get( `/files/entity/${entityType}/${entityId}`, { params } ); return response.data; }, /** POST /files/upload — single file */ upload: async (params: UploadFilesParams): Promise => { const formData = new FormData(); formData.append('file', params.files[0]); formData.append('entity_type', params.entity_type); formData.append('entity_id', params.entity_id); if (params.category) formData.append('category', params.category); if (params.category_id) formData.append('category_id', params.category_id); if (params.description) formData.append('description', params.description); if (params.tags?.length) formData.append('tags', JSON.stringify(params.tags)); if (params.metadata) formData.append('metadata', JSON.stringify(params.metadata)); const response = await apiClient.post('/files/upload', formData); return response.data; }, /** POST /files/upload/multiple — up to 10 files */ uploadMultiple: async ( params: UploadFilesParams, onProgress?: (fileIndex: number, percent: number) => void ): Promise<{ success: boolean; data: { uploaded: FileAttachment[]; errors: Array<{ file: string; error: string }>; total: number; success_count: number; error_count: number } }> => { const formData = new FormData(); params.files.forEach((file) => formData.append('files', file)); formData.append('entity_type', params.entity_type); formData.append('entity_id', params.entity_id); if (params.category) formData.append('category', params.category); if (params.category_id) formData.append('category_id', params.category_id); if (params.description) formData.append('description', params.description); if (params.tags?.length) formData.append('tags', JSON.stringify(params.tags)); if (params.metadata) formData.append('metadata', JSON.stringify(params.metadata)); const response = await apiClient.post('/files/upload/multiple', formData, { onUploadProgress: (evt) => { if (onProgress && evt.total) { onProgress(0, Math.round((evt.loaded / evt.total) * 100)); } }, }); return response.data; }, /** PUT /files/:id — update metadata */ updateMetadata: async (id: string, data: UpdateFileMetadataParams): Promise => { const response = await apiClient.put(`/files/${id}`, data); return response.data; }, /** DELETE /files/:id */ delete: async (id: string, hard = false): Promise<{ success: boolean }> => { const response = await apiClient.delete(`/files/${id}`, { params: { hard } }); return response.data; }, /** GET /files/:id/download — returns blob URL */ getDownloadUrl: (id: string): string => { const baseUrl = (import.meta as any).env?.VITE_API_BASE_URL || 'http://localhost:3000/api/v1'; return `${baseUrl}/files/${id}/download`; }, /** GET /files/:id/download (blob) */ download: async (id: string, filename?: string): Promise => { const response = await apiClient.get(`/files/${id}/download`, { responseType: 'blob' }); const url = URL.createObjectURL(response.data); const a = document.createElement('a'); a.href = url; a.download = filename || 'download'; a.click(); URL.revokeObjectURL(url); }, /** GET /files/:id/preview — returns blob URL for inline preview */ getPreviewUrl: async (id: string): Promise => { const response = await apiClient.get(`/files/${id}/preview`, { responseType: 'blob' }); return URL.createObjectURL(response.data); }, /** GET /files/:id/versions — version history */ getVersionHistory: async (id: string): Promise => { const response = await apiClient.get(`/files/${id}/versions`); return response.data; }, /** POST /files/:id/versions — upload new version */ uploadVersion: async ( id: string, file: File, options: { entity_id?: string; category?: string; description?: string; tags?: string[]; metadata?: Record } = {} ): Promise => { const formData = new FormData(); formData.append('file', file); if (options.entity_id) formData.append('entity_id', options.entity_id); if (options.category) formData.append('category', options.category); if (options.description) formData.append('description', options.description); if (options.tags?.length) formData.append('tags', JSON.stringify(options.tags)); if (options.metadata) formData.append('metadata', JSON.stringify(options.metadata)); const response = await apiClient.post(`/files/${id}/versions`, formData); return response.data; }, /** POST /files/:id/share — create share link */ createShare: async (id: string, params: CreateShareParams): Promise<{ success: boolean; data: ShareResponse }> => { const response = await apiClient.post(`/files/${id}/share`, params); return response.data; }, /** DELETE /files/shares/:shareId — revoke a share */ revokeShare: async (shareId: string): Promise<{ success: boolean }> => { const response = await apiClient.delete(`/files/shares/${shareId}`); return response.data; }, /** GET /files/stats */ getStorageStats: async (): Promise<{ success: boolean; data: StorageStats }> => { const response = await apiClient.get('/files/stats'); return response.data; }, /** GET /files/quota */ getQuota: async (): Promise<{ success: boolean; data: StorageQuota }> => { const response = await apiClient.get('/files/quota'); return response.data; }, /** PUT /files/quota — admin only */ updateQuota: async (data: Partial>): Promise<{ success: boolean; data: StorageQuota }> => { const response = await apiClient.put('/files/quota', data); return response.data; }, /** GET /files/filter-options */ getFilterOptions: async (params?: { source_module?: string; uploaded_by?: string }): Promise<{ success: boolean; data: FilterOptions }> => { const response = await apiClient.get('/files/filter-options', { params }); return response.data; }, /** GET /files/categories-filter-options */ getCategoriesFilterOptions: async (params?: { source_module?: string }): Promise<{ success: boolean; data: CategoriesFilterOptions }> => { const response = await apiClient.get('/files/categories-filter-options', { params }); return response.data; }, /** GET /files/:id/content — extract text/html */ extractContent: async (id: string): Promise<{ success: boolean; data: { html: string; text: string; original_name: string; file_size: number; mime_type: string; checksum: string } }> => { const response = await apiClient.get(`/files/${id}/content`); return response.data; }, }; export default fileAttachmentService;