Files
Goa-gel-fullstack/frontend/src/app/features/documents/services/document.service.ts
Mahi d9de183e51 feat: Runtime configuration and Docker deployment improvements
Frontend:
- Add runtime configuration service for deployment-time API URL injection
- Create docker-entrypoint.sh to generate config.json from environment variables
- Update ApiService, ApprovalService, and DocumentViewer to use RuntimeConfigService
- Add APP_INITIALIZER to load runtime config before app starts

Backend:
- Fix init-blockchain.js to properly quote mnemonic phrases in .env file
- Improve docker-entrypoint.sh with health checks and better error handling

Docker:
- Add API_BASE_URL environment variable to frontend container
- Update docker-compose.yml with clear documentation for remote deployment
- Reorganize .env.example with clear categories (REQUIRED FOR REMOTE, PRODUCTION, AUTO-GENERATED)

Workflow fixes:
- Fix DepartmentApproval interface to match backend schema
- Fix stage transformation for 0-indexed stageOrder
- Fix workflow list to show correct stage count from definition.stages

Cleanup:
- Move development artifacts to .trash directory
- Remove root-level package.json (was only for utility scripts)
- Add .trash/ to .gitignore
2026-02-08 18:45:01 -04:00

392 lines
11 KiB
TypeScript

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[] = [
'FLOOR_PLAN',
'PHOTOGRAPH',
'ID_PROOF',
'ADDRESS_PROOF',
'NOC',
'LICENSE_COPY',
'OTHER',
'FIRE_SAFETY',
'HEALTH_CERT',
'TAX_CLEARANCE',
'SITE_PLAN',
'BUILDING_PERMIT',
'BUSINESS_LICENSE',
];
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 : [];
}
@Injectable({
providedIn: 'root',
})
export class DocumentService {
private readonly api = inject(ApiService);
getDocuments(requestId: string): Observable<DocumentResponseDto[]> {
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);
}
}
getDocument(requestId: string, documentId: string): Observable<DocumentResponseDto> {
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);
}
}
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);
}
}
uploadDocument(
requestId: string,
file: File,
docType: DocumentType,
description?: string
): Observable<DocumentResponseDto> {
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);
}
}
/**
* Upload document with progress tracking
*/
uploadDocumentWithProgress(
requestId: string,
file: File,
docType: DocumentType,
description?: string
): Observable<UploadProgress<DocumentResponseDto>> {
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);
}
}
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);
}
}
deleteDocument(requestId: string, documentId: string): Observable<void> {
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);
}
}
getDownloadUrl(requestId: string, documentId: string): Observable<DownloadUrlResponseDto> {
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);
}
}
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);
}
}
}