import { Injectable, inject } from '@angular/core'; import { Observable, throwError, map, catchError } from 'rxjs'; import { ApiService, UploadProgress, validateId } from '../../../core/services/api.service'; import { DocumentResponseDto, DocumentVersionResponseDto, DownloadUrlResponseDto, DocumentType, } from '../../../api/models'; // File validation constants const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50MB const ALLOWED_MIME_TYPES = [ 'application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', ]; const ALLOWED_EXTENSIONS = [ '.pdf', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.txt', ]; /** * Validates a file before upload */ function validateFile(file: File | null | undefined): File { if (!file) { throw new Error('File is required'); } if (!(file instanceof File)) { throw new Error('Invalid file object'); } // Check file size if (file.size === 0) { throw new Error('File is empty'); } if (file.size > MAX_FILE_SIZE_BYTES) { const maxSizeMB = MAX_FILE_SIZE_BYTES / (1024 * 1024); throw new Error(`File size exceeds maximum allowed size of ${maxSizeMB}MB`); } // Check MIME type if (file.type && !ALLOWED_MIME_TYPES.includes(file.type)) { throw new Error(`File type '${file.type}' is not allowed`); } // Check file extension const fileName = file.name || ''; const extension = fileName.toLowerCase().substring(fileName.lastIndexOf('.')); if (!ALLOWED_EXTENSIONS.includes(extension)) { throw new Error(`File extension '${extension}' is not allowed`); } // Sanitize filename - prevent path traversal if (fileName.includes('..') || fileName.includes('/') || fileName.includes('\\')) { throw new Error('Invalid filename'); } return file; } /** * Validates document type */ function validateDocType(docType: DocumentType | string | undefined | null): DocumentType { if (!docType) { throw new Error('Document type is required'); } const validDocTypes: DocumentType[] = [ 'FIRE_SAFETY_CERTIFICATE', 'BUILDING_PLAN', 'PROPERTY_OWNERSHIP', 'INSPECTION_REPORT', 'POLLUTION_CERTIFICATE', 'ELECTRICAL_SAFETY_CERTIFICATE', 'STRUCTURAL_STABILITY_CERTIFICATE', 'IDENTITY_PROOF', 'ADDRESS_PROOF', 'OTHER', ]; if (!validDocTypes.includes(docType as DocumentType)) { throw new Error(`Invalid document type: ${docType}`); } return docType as DocumentType; } /** * Sanitizes description text */ function sanitizeDescription(description: string | undefined | null): string | undefined { if (!description) { return undefined; } if (typeof description !== 'string') { return undefined; } const trimmed = description.trim(); if (trimmed.length === 0) { return undefined; } // Limit length if (trimmed.length > 1000) { throw new Error('Description cannot exceed 1000 characters'); } return trimmed; } /** * Ensures array response is valid */ function ensureValidArray(response: T[] | null | undefined): T[] { return Array.isArray(response) ? response : []; } @Injectable({ providedIn: 'root', }) export class DocumentService { private readonly api = inject(ApiService); getDocuments(requestId: string): Observable { try { const validRequestId = validateId(requestId, 'Request ID'); return this.api.get(`/requests/${validRequestId}/documents`).pipe( map((response) => ensureValidArray(response)), catchError((error: unknown) => { const message = error instanceof Error ? error.message : `Failed to fetch documents for request: ${requestId}`; return throwError(() => new Error(message)); }) ); } catch (error) { return throwError(() => error); } } getDocument(requestId: string, documentId: string): Observable { try { const validRequestId = validateId(requestId, 'Request ID'); const validDocumentId = validateId(documentId, 'Document ID'); return this.api .get(`/requests/${validRequestId}/documents/${validDocumentId}`) .pipe( map((response) => { if (!response) { throw new Error('Document not found'); } return response; }), catchError((error: unknown) => { const message = error instanceof Error ? error.message : `Failed to fetch document: ${documentId}`; return throwError(() => new Error(message)); }) ); } catch (error) { return throwError(() => error); } } getDocumentVersions(requestId: string, documentId: string): Observable { try { const validRequestId = validateId(requestId, 'Request ID'); const validDocumentId = validateId(documentId, 'Document ID'); return this.api .get( `/requests/${validRequestId}/documents/${validDocumentId}/versions` ) .pipe( map((response) => ensureValidArray(response)), catchError((error: unknown) => { const message = error instanceof Error ? error.message : `Failed to fetch versions for document: ${documentId}`; return throwError(() => new Error(message)); }) ); } catch (error) { return throwError(() => error); } } uploadDocument( requestId: string, file: File, docType: DocumentType, description?: string ): Observable { try { const validRequestId = validateId(requestId, 'Request ID'); const validFile = validateFile(file); const validDocType = validateDocType(docType); const sanitizedDescription = sanitizeDescription(description); const formData = new FormData(); formData.append('file', validFile); formData.append('docType', validDocType); if (sanitizedDescription) { formData.append('description', sanitizedDescription); } return this.api .upload(`/requests/${validRequestId}/documents`, formData) .pipe( catchError((error: unknown) => { const message = error instanceof Error ? error.message : 'Failed to upload document'; return throwError(() => new Error(message)); }) ); } catch (error) { return throwError(() => error); } } /** * Upload document with progress tracking */ uploadDocumentWithProgress( requestId: string, file: File, docType: DocumentType, description?: string ): Observable> { try { const validRequestId = validateId(requestId, 'Request ID'); const validFile = validateFile(file); const validDocType = validateDocType(docType); const sanitizedDescription = sanitizeDescription(description); const formData = new FormData(); formData.append('file', validFile); formData.append('docType', validDocType); if (sanitizedDescription) { formData.append('description', sanitizedDescription); } return this.api .uploadWithProgress(`/requests/${validRequestId}/documents`, formData) .pipe( catchError((error: unknown) => { const message = error instanceof Error ? error.message : 'Failed to upload document with progress'; return throwError(() => new Error(message)); }) ); } catch (error) { return throwError(() => error); } } updateDocument(requestId: string, documentId: string, file: File): Observable { try { const validRequestId = validateId(requestId, 'Request ID'); const validDocumentId = validateId(documentId, 'Document ID'); const validFile = validateFile(file); const formData = new FormData(); formData.append('file', validFile); return this.api .upload( `/requests/${validRequestId}/documents/${validDocumentId}`, formData ) .pipe( catchError((error: unknown) => { const message = error instanceof Error ? error.message : `Failed to update document: ${documentId}`; return throwError(() => new Error(message)); }) ); } catch (error) { return throwError(() => error); } } deleteDocument(requestId: string, documentId: string): Observable { try { const validRequestId = validateId(requestId, 'Request ID'); const validDocumentId = validateId(documentId, 'Document ID'); return this.api .delete(`/requests/${validRequestId}/documents/${validDocumentId}`) .pipe( catchError((error: unknown) => { const message = error instanceof Error ? error.message : `Failed to delete document: ${documentId}`; return throwError(() => new Error(message)); }) ); } catch (error) { return throwError(() => error); } } getDownloadUrl(requestId: string, documentId: string): Observable { try { const validRequestId = validateId(requestId, 'Request ID'); const validDocumentId = validateId(documentId, 'Document ID'); return this.api .get( `/requests/${validRequestId}/documents/${validDocumentId}/download` ) .pipe( map((response) => { if (!response || !response.url) { throw new Error('Invalid download URL response'); } return response; }), catchError((error: unknown) => { const message = error instanceof Error ? error.message : `Failed to get download URL for document: ${documentId}`; return throwError(() => new Error(message)); }) ); } catch (error) { return throwError(() => error); } } verifyDocument(requestId: string, documentId: string): Observable<{ verified: boolean }> { try { const validRequestId = validateId(requestId, 'Request ID'); const validDocumentId = validateId(documentId, 'Document ID'); return this.api .get<{ verified: boolean }>( `/requests/${validRequestId}/documents/${validDocumentId}/verify` ) .pipe( map((response) => { if (!response) { return { verified: false }; } return { verified: typeof response.verified === 'boolean' ? response.verified : false, }; }), catchError((error: unknown) => { const message = error instanceof Error ? error.message : `Failed to verify document: ${documentId}`; return throwError(() => new Error(message)); }) ); } catch (error) { return throwError(() => error); } } }