2026-02-07 10:23:29 -04:00
|
|
|
import { Injectable, inject } from '@angular/core';
|
2026-02-08 02:10:09 -04:00
|
|
|
import { Observable, throwError, map, catchError } from 'rxjs';
|
|
|
|
|
import { ApiService, UploadProgress, validateId } from '../../../core/services/api.service';
|
2026-02-07 10:23:29 -04:00
|
|
|
import {
|
|
|
|
|
DocumentResponseDto,
|
|
|
|
|
DocumentVersionResponseDto,
|
|
|
|
|
DownloadUrlResponseDto,
|
|
|
|
|
DocumentType,
|
|
|
|
|
} from '../../../api/models';
|
|
|
|
|
|
2026-02-08 02:10:09 -04:00
|
|
|
// 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<T>(response: T[] | null | undefined): T[] {
|
|
|
|
|
return Array.isArray(response) ? response : [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 10:23:29 -04:00
|
|
|
@Injectable({
|
|
|
|
|
providedIn: 'root',
|
|
|
|
|
})
|
|
|
|
|
export class DocumentService {
|
|
|
|
|
private readonly api = inject(ApiService);
|
|
|
|
|
|
|
|
|
|
getDocuments(requestId: string): Observable<DocumentResponseDto[]> {
|
2026-02-08 02:10:09 -04:00
|
|
|
try {
|
|
|
|
|
const validRequestId = validateId(requestId, 'Request ID');
|
|
|
|
|
|
|
|
|
|
return this.api.get<DocumentResponseDto[]>(`/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);
|
|
|
|
|
}
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getDocument(requestId: string, documentId: string): Observable<DocumentResponseDto> {
|
2026-02-08 02:10:09 -04:00
|
|
|
try {
|
|
|
|
|
const validRequestId = validateId(requestId, 'Request ID');
|
|
|
|
|
const validDocumentId = validateId(documentId, 'Document ID');
|
|
|
|
|
|
|
|
|
|
return this.api
|
|
|
|
|
.get<DocumentResponseDto>(`/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);
|
|
|
|
|
}
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 02:10:09 -04:00
|
|
|
getDocumentVersions(requestId: string, documentId: string): Observable<DocumentVersionResponseDto[]> {
|
|
|
|
|
try {
|
|
|
|
|
const validRequestId = validateId(requestId, 'Request ID');
|
|
|
|
|
const validDocumentId = validateId(documentId, 'Document ID');
|
|
|
|
|
|
|
|
|
|
return this.api
|
|
|
|
|
.get<DocumentVersionResponseDto[]>(
|
|
|
|
|
`/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);
|
|
|
|
|
}
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uploadDocument(
|
|
|
|
|
requestId: string,
|
|
|
|
|
file: File,
|
|
|
|
|
docType: DocumentType,
|
|
|
|
|
description?: string
|
|
|
|
|
): Observable<DocumentResponseDto> {
|
2026-02-08 02:10:09 -04:00
|
|
|
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<DocumentResponseDto>(`/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);
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Upload document with progress tracking
|
|
|
|
|
*/
|
|
|
|
|
uploadDocumentWithProgress(
|
|
|
|
|
requestId: string,
|
|
|
|
|
file: File,
|
|
|
|
|
docType: DocumentType,
|
|
|
|
|
description?: string
|
|
|
|
|
): Observable<UploadProgress<DocumentResponseDto>> {
|
2026-02-08 02:10:09 -04:00
|
|
|
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<DocumentResponseDto>(`/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);
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 02:10:09 -04:00
|
|
|
updateDocument(requestId: string, documentId: string, file: File): Observable<DocumentResponseDto> {
|
|
|
|
|
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<DocumentResponseDto>(
|
|
|
|
|
`/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);
|
|
|
|
|
}
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deleteDocument(requestId: string, documentId: string): Observable<void> {
|
2026-02-08 02:10:09 -04:00
|
|
|
try {
|
|
|
|
|
const validRequestId = validateId(requestId, 'Request ID');
|
|
|
|
|
const validDocumentId = validateId(documentId, 'Document ID');
|
|
|
|
|
|
|
|
|
|
return this.api
|
|
|
|
|
.delete<void>(`/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);
|
|
|
|
|
}
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getDownloadUrl(requestId: string, documentId: string): Observable<DownloadUrlResponseDto> {
|
2026-02-08 02:10:09 -04:00
|
|
|
try {
|
|
|
|
|
const validRequestId = validateId(requestId, 'Request ID');
|
|
|
|
|
const validDocumentId = validateId(documentId, 'Document ID');
|
|
|
|
|
|
|
|
|
|
return this.api
|
|
|
|
|
.get<DownloadUrlResponseDto>(
|
|
|
|
|
`/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);
|
|
|
|
|
}
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
verifyDocument(requestId: string, documentId: string): Observable<{ verified: boolean }> {
|
2026-02-08 02:10:09 -04:00
|
|
|
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);
|
|
|
|
|
}
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
}
|