Qassure-frontend/src/services/file-attachment-service.ts

373 lines
13 KiB
TypeScript

/**
* 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<string, any>;
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<string, { count: number; size: number }>;
by_module: Record<string, { count: number; size: number }>;
}
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<string, string[]>;
}
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<string, any>;
}
// ─────────────────────────────────────────
// 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<string, any>;
}
// ─────────────────────────────────────────
// 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<FileListResponse> => {
const response = await apiClient.get<FileListResponse>('/files', { params });
return response.data;
},
/** GET /files/:id — single file detail */
getById: async (id: string): Promise<FileDetailResponse> => {
const response = await apiClient.get<FileDetailResponse>(`/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<FileListResponse> => {
const response = await apiClient.get<FileListResponse>(
`/files/entity/${entityType}/${entityId}`,
{ params }
);
return response.data;
},
/** POST /files/upload — single file */
upload: async (params: UploadFilesParams): Promise<FileDetailResponse> => {
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<FileDetailResponse>('/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<FileDetailResponse> => {
const response = await apiClient.put<FileDetailResponse>(`/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<void> => {
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<string> => {
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<VersionHistoryResponse> => {
const response = await apiClient.get<VersionHistoryResponse>(`/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<string, any> } = {}
): Promise<FileDetailResponse> => {
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<FileDetailResponse>(`/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<Pick<StorageQuota, 'max_storage_bytes' | 'max_file_size_bytes' | 'allowed_mime_types' | 'blocked_extensions'>>): 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;