# Backend Specification: Documents Module (DMS) **Version:** 1.0.0 **Fecha:** 2025-12-05 **Modulos:** MAE-016 (Gestion Documental) --- ## Resumen | Metrica | Valor | |---------|-------| | Controllers | 5 | | Services | 6 | | Entities | 10 | | Endpoints | 40+ | | Tests Requeridos | 45+ | --- ## Estructura del Modulo ``` modules/documents/ ├── documents.module.ts ├── controllers/ │ ├── document.controller.ts │ ├── folder.controller.ts │ ├── plan.controller.ts │ ├── approval.controller.ts │ └── search.controller.ts ├── services/ │ ├── document.service.ts │ ├── folder.service.ts │ ├── plan.service.ts │ ├── approval.service.ts │ ├── version.service.ts │ └── storage.service.ts ├── entities/ │ ├── document.entity.ts │ ├── document-version.entity.ts │ ├── folder.entity.ts │ ├── folder-permission.entity.ts │ ├── construction-plan.entity.ts │ ├── plan-revision.entity.ts │ ├── document-approval.entity.ts │ ├── approval-step.entity.ts │ ├── document-tag.entity.ts │ └── document-link.entity.ts ├── dto/ ├── events/ └── jobs/ └── cleanup.job.ts ``` --- ## Controllers ### 1. DocumentController ```typescript @Controller('api/v1/documents') @UseGuards(AuthGuard, TenantGuard) @ApiTags('documents') export class DocumentController { // GET /api/v1/documents @Get() @ApiOperation({ summary: 'List documents' }) async findAll( @Query() query: DocumentQueryDto, @CurrentTenant() tenantId: UUID ): Promise>; // GET /api/v1/documents/:id @Get(':id') async findOne( @Param('id') id: UUID, @CurrentTenant() tenantId: UUID ): Promise; // POST /api/v1/documents @Post() @UseInterceptors(FileInterceptor('file')) async create( @UploadedFile() file: Express.Multer.File, @Body() dto: CreateDocumentDto, @CurrentTenant() tenantId: UUID, @CurrentUser() userId: UUID ): Promise; // PUT /api/v1/documents/:id @Put(':id') async update( @Param('id') id: UUID, @Body() dto: UpdateDocumentDto ): Promise; // DELETE /api/v1/documents/:id @Delete(':id') async remove(@Param('id') id: UUID): Promise; // GET /api/v1/documents/:id/download @Get(':id/download') async download(@Param('id') id: UUID): Promise; // GET /api/v1/documents/:id/preview @Get(':id/preview') async preview(@Param('id') id: UUID): Promise; // POST /api/v1/documents/:id/versions @Post(':id/versions') @UseInterceptors(FileInterceptor('file')) async uploadNewVersion( @Param('id') id: UUID, @UploadedFile() file: Express.Multer.File, @Body() dto: UploadVersionDto, @CurrentUser() userId: UUID ): Promise; // GET /api/v1/documents/:id/versions @Get(':id/versions') async getVersions(@Param('id') id: UUID): Promise; // GET /api/v1/documents/:id/versions/:versionId/download @Get(':id/versions/:versionId/download') async downloadVersion( @Param('id') id: UUID, @Param('versionId') versionId: UUID ): Promise; // POST /api/v1/documents/:id/versions/:versionId/restore @Post(':id/versions/:versionId/restore') async restoreVersion( @Param('id') id: UUID, @Param('versionId') versionId: UUID, @CurrentUser() userId: UUID ): Promise; // POST /api/v1/documents/:id/copy @Post(':id/copy') async copyDocument( @Param('id') id: UUID, @Body() dto: CopyDocumentDto, @CurrentUser() userId: UUID ): Promise; // POST /api/v1/documents/:id/move @Post(':id/move') async moveDocument( @Param('id') id: UUID, @Body() dto: MoveDocumentDto, @CurrentUser() userId: UUID ): Promise; // POST /api/v1/documents/:id/tags @Post(':id/tags') async addTags( @Param('id') id: UUID, @Body() dto: AddTagsDto ): Promise; // DELETE /api/v1/documents/:id/tags/:tagId @Delete(':id/tags/:tagId') async removeTag( @Param('id') id: UUID, @Param('tagId') tagId: UUID ): Promise; // POST /api/v1/documents/:id/link @Post(':id/link') async linkToEntity( @Param('id') id: UUID, @Body() dto: LinkDocumentDto ): Promise; // DELETE /api/v1/documents/:id/link/:linkId @Delete(':id/link/:linkId') async unlinkFromEntity( @Param('id') id: UUID, @Param('linkId') linkId: UUID ): Promise; // POST /api/v1/documents/bulk-upload @Post('bulk-upload') @UseInterceptors(FilesInterceptor('files', 20)) async bulkUpload( @UploadedFiles() files: Express.Multer.File[], @Body() dto: BulkUploadDto, @CurrentTenant() tenantId: UUID, @CurrentUser() userId: UUID ): Promise; } ``` ### 2. FolderController ```typescript @Controller('api/v1/documents/folders') @ApiTags('document-folders') export class FolderController { // GET /api/v1/documents/folders @Get() async findAll(@CurrentTenant() tenantId: UUID): Promise; // GET /api/v1/documents/folders/:id @Get(':id') async findOne(@Param('id') id: UUID): Promise; // POST /api/v1/documents/folders @Post() async create( @Body() dto: CreateFolderDto, @CurrentUser() userId: UUID ): Promise; // PUT /api/v1/documents/folders/:id @Put(':id') async update( @Param('id') id: UUID, @Body() dto: UpdateFolderDto ): Promise; // DELETE /api/v1/documents/folders/:id @Delete(':id') async remove(@Param('id') id: UUID): Promise; // GET /api/v1/documents/folders/:id/contents @Get(':id/contents') async getContents( @Param('id') id: UUID, @Query() query: FolderContentsQueryDto ): Promise; // POST /api/v1/documents/folders/:id/permissions @Post(':id/permissions') async setPermissions( @Param('id') id: UUID, @Body() dto: SetPermissionsDto ): Promise; // GET /api/v1/documents/folders/:id/permissions @Get(':id/permissions') async getPermissions(@Param('id') id: UUID): Promise; // DELETE /api/v1/documents/folders/:id/permissions/:permissionId @Delete(':id/permissions/:permissionId') async removePermission( @Param('id') id: UUID, @Param('permissionId') permissionId: UUID ): Promise; // GET /api/v1/documents/folders/:id/path @Get(':id/path') async getPath(@Param('id') id: UUID): Promise; // POST /api/v1/documents/folders/:id/move @Post(':id/move') async moveFolder( @Param('id') id: UUID, @Body() dto: MoveFolderDto ): Promise; } ``` ### 3. PlanController ```typescript @Controller('api/v1/documents/plans') @ApiTags('construction-plans') export class PlanController { // GET /api/v1/documents/plans @Get() async findAll(@Query() query: PlanQueryDto): Promise>; // GET /api/v1/documents/plans/:id @Get(':id') async findOne(@Param('id') id: UUID): Promise; // POST /api/v1/documents/plans @Post() @UseInterceptors(FileInterceptor('file')) async create( @UploadedFile() file: Express.Multer.File, @Body() dto: CreatePlanDto, @CurrentUser() userId: UUID ): Promise; // PUT /api/v1/documents/plans/:id @Put(':id') async update( @Param('id') id: UUID, @Body() dto: UpdatePlanDto ): Promise; // DELETE /api/v1/documents/plans/:id @Delete(':id') async remove(@Param('id') id: UUID): Promise; // GET /api/v1/documents/plans/:id/download @Get(':id/download') async download(@Param('id') id: UUID): Promise; // GET /api/v1/documents/plans/:id/revisions @Get(':id/revisions') async getRevisions(@Param('id') id: UUID): Promise; // POST /api/v1/documents/plans/:id/revisions @Post(':id/revisions') @UseInterceptors(FileInterceptor('file')) async createRevision( @Param('id') id: UUID, @UploadedFile() file: Express.Multer.File, @Body() dto: CreateRevisionDto, @CurrentUser() userId: UUID ): Promise; // GET /api/v1/documents/plans/:id/revisions/:revisionId/download @Get(':id/revisions/:revisionId/download') async downloadRevision( @Param('id') id: UUID, @Param('revisionId') revisionId: UUID ): Promise; // POST /api/v1/documents/plans/:id/revisions/:revisionId/approve @Post(':id/revisions/:revisionId/approve') async approveRevision( @Param('id') id: UUID, @Param('revisionId') revisionId: UUID, @Body() dto: ApproveRevisionDto, @CurrentUser() userId: UUID ): Promise; // POST /api/v1/documents/plans/:id/revisions/:revisionId/reject @Post(':id/revisions/:revisionId/reject') async rejectRevision( @Param('id') id: UUID, @Param('revisionId') revisionId: UUID, @Body() dto: RejectRevisionDto, @CurrentUser() userId: UUID ): Promise; // GET /api/v1/documents/plans/by-project/:projectId @Get('by-project/:projectId') async getByProject( @Param('projectId') projectId: UUID, @Query() query: PlanQueryDto ): Promise>; // POST /api/v1/documents/plans/:id/compare @Post(':id/compare') async compareRevisions( @Param('id') id: UUID, @Body() dto: CompareRevisionsDto ): Promise; } ``` ### 4. ApprovalController ```typescript @Controller('api/v1/documents/approvals') @ApiTags('document-approvals') export class ApprovalController { // GET /api/v1/documents/approvals/pending @Get('pending') async getPendingApprovals( @CurrentUser() userId: UUID ): Promise; // GET /api/v1/documents/approvals/:id @Get(':id') async findOne(@Param('id') id: UUID): Promise; // POST /api/v1/documents/approvals @Post() async createApprovalRequest( @Body() dto: CreateApprovalRequestDto, @CurrentUser() userId: UUID ): Promise; // POST /api/v1/documents/approvals/:id/approve @Post(':id/approve') async approve( @Param('id') id: UUID, @Body() dto: ApproveDto, @CurrentUser() userId: UUID ): Promise; // POST /api/v1/documents/approvals/:id/reject @Post(':id/reject') async reject( @Param('id') id: UUID, @Body() dto: RejectDto, @CurrentUser() userId: UUID ): Promise; // POST /api/v1/documents/approvals/:id/request-changes @Post(':id/request-changes') async requestChanges( @Param('id') id: UUID, @Body() dto: RequestChangesDto, @CurrentUser() userId: UUID ): Promise; // POST /api/v1/documents/approvals/:id/cancel @Post(':id/cancel') async cancel( @Param('id') id: UUID, @CurrentUser() userId: UUID ): Promise; // GET /api/v1/documents/approvals/:id/history @Get(':id/history') async getHistory(@Param('id') id: UUID): Promise; // GET /api/v1/documents/approvals/workflows @Get('workflows') async getWorkflows(@CurrentTenant() tenantId: UUID): Promise; // POST /api/v1/documents/approvals/workflows @Post('workflows') async createWorkflow( @Body() dto: CreateWorkflowDto, @CurrentUser() userId: UUID ): Promise; } ``` ### 5. SearchController ```typescript @Controller('api/v1/documents/search') @ApiTags('document-search') export class SearchController { // GET /api/v1/documents/search @Get() async search(@Query() query: SearchQueryDto): Promise; // GET /api/v1/documents/search/advanced @Get('advanced') async advancedSearch(@Query() query: AdvancedSearchQueryDto): Promise; // GET /api/v1/documents/search/suggestions @Get('suggestions') async getSuggestions(@Query('q') query: string): Promise; // GET /api/v1/documents/search/recent @Get('recent') async getRecent(@CurrentUser() userId: UUID): Promise; // GET /api/v1/documents/search/by-entity @Get('by-entity') async searchByEntity(@Query() query: EntitySearchQueryDto): Promise; // GET /api/v1/documents/search/tags @Get('tags') async searchTags(@Query('q') query: string): Promise; // POST /api/v1/documents/search/index/:id @Post('index/:id') async reindexDocument(@Param('id') id: UUID): Promise; } ``` --- ## Services ### DocumentService ```typescript @Injectable() export class DocumentService { constructor( @InjectRepository(Document) private readonly documentRepo: Repository, @InjectRepository(DocumentVersion) private readonly versionRepo: Repository, private readonly storageService: StorageService, private readonly eventEmitter: EventEmitter2 ) {} async findAll(tenantId: UUID, query: DocumentQueryDto): Promise>; async findOne(tenantId: UUID, id: UUID): Promise; async create(tenantId: UUID, userId: UUID, file: Express.Multer.File, dto: CreateDocumentDto): Promise; async update(id: UUID, dto: UpdateDocumentDto): Promise; async remove(id: UUID): Promise; async getDownloadStream(id: UUID): Promise<{ stream: Readable; filename: string; mimeType: string }>; async generatePreview(id: UUID): Promise; async copyDocument(id: UUID, userId: UUID, dto: CopyDocumentDto): Promise; async moveDocument(id: UUID, userId: UUID, dto: MoveDocumentDto): Promise; async addTags(id: UUID, tagIds: string[]): Promise; async removeTag(id: UUID, tagId: UUID): Promise; async linkToEntity(id: UUID, dto: LinkDocumentDto): Promise; async unlinkFromEntity(id: UUID, linkId: UUID): Promise; async bulkUpload(tenantId: UUID, userId: UUID, files: Express.Multer.File[], dto: BulkUploadDto): Promise; } ``` ### VersionService ```typescript @Injectable() export class VersionService { constructor( @InjectRepository(DocumentVersion) private readonly versionRepo: Repository, private readonly storageService: StorageService, private readonly eventEmitter: EventEmitter2 ) {} async createVersion(documentId: UUID, userId: UUID, file: Express.Multer.File, dto: UploadVersionDto): Promise; async getVersions(documentId: UUID): Promise; async getVersion(versionId: UUID): Promise; async downloadVersion(versionId: UUID): Promise<{ stream: Readable; filename: string }>; async restoreVersion(documentId: UUID, versionId: UUID, userId: UUID): Promise; async deleteOldVersions(documentId: UUID, keepCount: number): Promise; async calculateVersionNumber(documentId: UUID): Promise; } ``` ### FolderService ```typescript @Injectable() export class FolderService { constructor( @InjectRepository(Folder) private readonly folderRepo: Repository, @InjectRepository(FolderPermission) private readonly permissionRepo: Repository ) {} async findAll(tenantId: UUID): Promise; async findOne(id: UUID): Promise; async create(tenantId: UUID, userId: UUID, dto: CreateFolderDto): Promise; async update(id: UUID, dto: UpdateFolderDto): Promise; async remove(id: UUID): Promise; async getContents(id: UUID, query: FolderContentsQueryDto): Promise; async setPermissions(id: UUID, dto: SetPermissionsDto): Promise; async getPermissions(id: UUID): Promise; async removePermission(id: UUID, permissionId: UUID): Promise; async getPath(id: UUID): Promise; async moveFolder(id: UUID, dto: MoveFolderDto): Promise; async checkPermission(folderId: UUID, userId: UUID, permission: PermissionType): Promise; async getEffectivePermissions(folderId: UUID, userId: UUID): Promise; } ``` ### PlanService ```typescript @Injectable() export class PlanService { constructor( @InjectRepository(ConstructionPlan) private readonly planRepo: Repository, @InjectRepository(PlanRevision) private readonly revisionRepo: Repository, private readonly storageService: StorageService, private readonly eventEmitter: EventEmitter2 ) {} async findAll(tenantId: UUID, query: PlanQueryDto): Promise>; async findOne(id: UUID): Promise; async create(tenantId: UUID, userId: UUID, file: Express.Multer.File, dto: CreatePlanDto): Promise; async update(id: UUID, dto: UpdatePlanDto): Promise; async remove(id: UUID): Promise; async getDownloadStream(id: UUID): Promise<{ stream: Readable; filename: string }>; async getRevisions(planId: UUID): Promise; async createRevision(planId: UUID, userId: UUID, file: Express.Multer.File, dto: CreateRevisionDto): Promise; async downloadRevision(revisionId: UUID): Promise<{ stream: Readable; filename: string }>; async approveRevision(planId: UUID, revisionId: UUID, userId: UUID, dto: ApproveRevisionDto): Promise; async rejectRevision(planId: UUID, revisionId: UUID, userId: UUID, dto: RejectRevisionDto): Promise; async getByProject(projectId: UUID, query: PlanQueryDto): Promise>; async compareRevisions(planId: UUID, revisionId1: UUID, revisionId2: UUID): Promise; async calculateNextRevisionCode(planId: UUID): Promise; } ``` ### ApprovalService ```typescript @Injectable() export class ApprovalService { constructor( @InjectRepository(DocumentApproval) private readonly approvalRepo: Repository, @InjectRepository(ApprovalStep) private readonly stepRepo: Repository, private readonly notificationService: NotificationService, private readonly eventEmitter: EventEmitter2 ) {} async getPendingApprovals(userId: UUID): Promise; async findOne(id: UUID): Promise; async createApprovalRequest(userId: UUID, dto: CreateApprovalRequestDto): Promise; async approve(id: UUID, userId: UUID, dto: ApproveDto): Promise; async reject(id: UUID, userId: UUID, dto: RejectDto): Promise; async requestChanges(id: UUID, userId: UUID, dto: RequestChangesDto): Promise; async cancel(id: UUID, userId: UUID): Promise; async getHistory(id: UUID): Promise; async getWorkflows(tenantId: UUID): Promise; async createWorkflow(tenantId: UUID, userId: UUID, dto: CreateWorkflowDto): Promise; async advanceWorkflow(approvalId: UUID): Promise; async notifyNextApprover(approvalId: UUID): Promise; } ``` ### StorageService ```typescript @Injectable() export class StorageService { constructor( private readonly configService: ConfigService ) {} async upload(file: Express.Multer.File, path: string): Promise; async download(storagePath: string): Promise; async delete(storagePath: string): Promise; async copy(sourcePath: string, destinationPath: string): Promise; async move(sourcePath: string, destinationPath: string): Promise; async getMetadata(storagePath: string): Promise; async generatePresignedUrl(storagePath: string, expiresIn: number): Promise; async getStorageUsage(tenantId: UUID): Promise; // Storage backend abstraction private getStorageBackend(): StorageBackend { const backend = this.configService.get('STORAGE_BACKEND'); switch (backend) { case 's3': return new S3StorageBackend(this.configService); case 'azure': return new AzureBlobStorageBackend(this.configService); case 'local': return new LocalStorageBackend(this.configService); default: return new LocalStorageBackend(this.configService); } } } ``` --- ## DTOs ### Document DTOs ```typescript export class CreateDocumentDto { @IsString() @MaxLength(300) name: string; @IsOptional() @IsString() description?: string; @IsOptional() @IsUUID() folderId?: string; @IsEnum(DocumentType) documentType: DocumentType; @IsOptional() @IsArray() @IsUUID('4', { each: true }) tagIds?: string[]; @IsOptional() @IsObject() metadata?: Record; @IsOptional() @IsUUID() projectId?: string; } export class DocumentQueryDto extends PaginationDto { @IsOptional() @IsUUID() folderId?: string; @IsOptional() @IsEnum(DocumentType) documentType?: DocumentType; @IsOptional() @IsString() search?: string; @IsOptional() @IsArray() @IsUUID('4', { each: true }) tagIds?: string[]; @IsOptional() @IsUUID() projectId?: string; @IsOptional() @IsDateString() fromDate?: string; @IsOptional() @IsDateString() toDate?: string; @IsOptional() @IsString() mimeType?: string; } export class UploadVersionDto { @IsOptional() @IsString() changeNotes?: string; @IsOptional() @IsBoolean() isMajorVersion?: boolean; } export class CopyDocumentDto { @IsUUID() targetFolderId: string; @IsOptional() @IsString() newName?: string; } export class MoveDocumentDto { @IsUUID() targetFolderId: string; } export class LinkDocumentDto { @IsEnum(EntityType) entityType: EntityType; @IsUUID() entityId: string; @IsOptional() @IsString() linkType?: string; } ``` ### Folder DTOs ```typescript export class CreateFolderDto { @IsString() @MaxLength(200) name: string; @IsOptional() @IsString() description?: string; @IsOptional() @IsUUID() parentId?: string; @IsOptional() @IsUUID() projectId?: string; } export class FolderContentsQueryDto extends PaginationDto { @IsOptional() @IsBoolean() includeFolders?: boolean; @IsOptional() @IsBoolean() includeDocuments?: boolean; @IsOptional() @IsString() sortBy?: 'name' | 'createdAt' | 'updatedAt' | 'size'; @IsOptional() @IsString() sortOrder?: 'asc' | 'desc'; } export class SetPermissionsDto { @IsArray() @ValidateNested({ each: true }) permissions: { principalType: 'user' | 'role'; principalId: string; permissionLevel: PermissionLevel; }[]; @IsOptional() @IsBoolean() applyToChildren?: boolean; } ``` ### Plan DTOs ```typescript export class CreatePlanDto { @IsString() @MaxLength(30) planNumber: string; @IsString() @MaxLength(300) name: string; @IsOptional() @IsString() description?: string; @IsEnum(PlanType) planType: PlanType; @IsOptional() @IsEnum(Discipline) discipline?: Discipline; @IsUUID() projectId: string; @IsOptional() @IsString() scale?: string; @IsOptional() @IsDateString() planDate?: string; } export class CreateRevisionDto { @IsString() @MaxLength(10) revisionCode: string; @IsOptional() @IsString() changeDescription?: string; @IsOptional() @IsArray() @IsString({ each: true }) changesFromPrevious?: string[]; } export class ApproveRevisionDto { @IsOptional() @IsString() comments?: string; @IsOptional() @IsString() digitalSignature?: string; } export class RejectRevisionDto { @IsString() reason: string; @IsOptional() @IsArray() @IsString({ each: true }) requiredChanges?: string[]; } export class CompareRevisionsDto { @IsUUID() revisionId1: string; @IsUUID() revisionId2: string; } ``` ### Approval DTOs ```typescript export class CreateApprovalRequestDto { @IsUUID() documentId: string; @IsOptional() @IsUUID() workflowId?: string; @IsOptional() @IsArray() @IsUUID('4', { each: true }) approverIds?: string[]; @IsOptional() @IsString() notes?: string; @IsOptional() @IsDateString() dueDate?: string; } export class ApproveDto { @IsOptional() @IsString() comments?: string; @IsOptional() @IsString() digitalSignature?: string; } export class RejectDto { @IsString() reason: string; } export class RequestChangesDto { @IsString() requestedChanges: string; @IsOptional() @IsDateString() reviseDueDate?: string; } export class CreateWorkflowDto { @IsString() @MaxLength(100) name: string; @IsOptional() @IsString() description?: string; @IsArray() @ValidateNested({ each: true }) steps: { stepOrder: number; approverType: 'user' | 'role'; approverId: string; isOptional?: boolean; }[]; @IsOptional() @IsBoolean() requireAllApprovers?: boolean; } ``` ### Search DTOs ```typescript export class SearchQueryDto { @IsString() @MinLength(2) q: string; @IsOptional() @IsNumber() limit?: number; @IsOptional() @IsNumber() offset?: number; @IsOptional() @IsEnum(DocumentType) documentType?: DocumentType; } export class AdvancedSearchQueryDto extends PaginationDto { @IsOptional() @IsString() name?: string; @IsOptional() @IsString() content?: string; @IsOptional() @IsArray() @IsUUID('4', { each: true }) folderIds?: string[]; @IsOptional() @IsArray() @IsUUID('4', { each: true }) tagIds?: string[]; @IsOptional() @IsEnum(DocumentType) documentType?: DocumentType; @IsOptional() @IsDateString() createdAfter?: string; @IsOptional() @IsDateString() createdBefore?: string; @IsOptional() @IsDateString() modifiedAfter?: string; @IsOptional() @IsDateString() modifiedBefore?: string; @IsOptional() @IsNumber() minSize?: number; @IsOptional() @IsNumber() maxSize?: number; @IsOptional() @IsString() mimeType?: string; @IsOptional() @IsUUID() createdBy?: string; } export class EntitySearchQueryDto { @IsEnum(EntityType) entityType: EntityType; @IsUUID() entityId: string; @IsOptional() @IsString() linkType?: string; } ``` --- ## Scheduled Jobs ### CleanupJob ```typescript @Injectable() export class CleanupJob { constructor( private readonly documentService: DocumentService, private readonly versionService: VersionService, private readonly storageService: StorageService, @InjectRepository(Document) private readonly documentRepo: Repository ) {} @Cron('0 2 * * 0') // Weekly on Sunday at 2 AM async cleanupDeletedDocuments() { // Find documents deleted more than 30 days ago const deletedDocs = await this.documentRepo.find({ where: { deletedAt: LessThan(subDays(new Date(), 30)) } }); for (const doc of deletedDocs) { // Delete all versions from storage const versions = await this.versionService.getVersions(doc.id); for (const version of versions) { await this.storageService.delete(version.storagePath); } // Hard delete document await this.documentRepo.delete(doc.id); } } @Cron('0 3 * * *') // Daily at 3 AM async cleanupOrphanedFiles() { // Find orphaned files in storage const orphanedFiles = await this.storageService.findOrphanedFiles(); for (const file of orphanedFiles) { await this.storageService.delete(file.path); } } @Cron('0 4 1 * *') // Monthly on 1st at 4 AM async archiveOldVersions() { // Archive versions older than 1 year (keep only latest 5) const documents = await this.documentRepo.find({ where: { createdAt: LessThan(subYears(new Date(), 1)) } }); for (const doc of documents) { await this.versionService.deleteOldVersions(doc.id, 5); } } } ``` --- ## Events ```typescript export class DocumentCreatedEvent { constructor( public readonly documentId: string, public readonly tenantId: string, public readonly name: string, public readonly folderId: string | null, public readonly uploadedBy: string, public readonly fileSize: number, public readonly mimeType: string, public readonly timestamp: Date = new Date() ) {} } export class DocumentVersionCreatedEvent { constructor( public readonly documentId: string, public readonly versionId: string, public readonly versionNumber: number, public readonly uploadedBy: string, public readonly changeNotes: string | null, public readonly timestamp: Date = new Date() ) {} } export class DocumentApprovedEvent { constructor( public readonly documentId: string, public readonly approvalId: string, public readonly approvedBy: string, public readonly allApproversCompleted: boolean, public readonly timestamp: Date = new Date() ) {} } export class DocumentRejectedEvent { constructor( public readonly documentId: string, public readonly approvalId: string, public readonly rejectedBy: string, public readonly reason: string, public readonly timestamp: Date = new Date() ) {} } export class PlanRevisionApprovedEvent { constructor( public readonly planId: string, public readonly revisionId: string, public readonly revisionCode: string, public readonly approvedBy: string, public readonly timestamp: Date = new Date() ) {} } export class FolderPermissionsChangedEvent { constructor( public readonly folderId: string, public readonly changedBy: string, public readonly permissionsAdded: number, public readonly permissionsRemoved: number, public readonly timestamp: Date = new Date() ) {} } ``` --- ## Integraciones ### Full-Text Search Integration ```typescript @Injectable() export class SearchIndexService { constructor( private readonly configService: ConfigService, private readonly httpService: HttpService ) {} private readonly elasticsearchUrl = this.configService.get('ELASTICSEARCH_URL'); async indexDocument(document: Document, content: string): Promise { await this.httpService.put( `${this.elasticsearchUrl}/documents/_doc/${document.id}`, { tenantId: document.tenantId, name: document.name, description: document.description, content: content, documentType: document.documentType, folderId: document.folderId, tags: document.tags.map(t => t.name), mimeType: document.mimeType, fileSize: document.fileSize, createdAt: document.createdAt, updatedAt: document.updatedAt, createdBy: document.createdBy } ).toPromise(); } async removeFromIndex(documentId: string): Promise { await this.httpService.delete( `${this.elasticsearchUrl}/documents/_doc/${documentId}` ).toPromise(); } async search(tenantId: UUID, query: SearchQueryDto): Promise { const response = await this.httpService.post( `${this.elasticsearchUrl}/documents/_search`, { query: { bool: { must: [ { term: { tenantId } }, { multi_match: { query: query.q, fields: ['name^3', 'description^2', 'content', 'tags'] } } ] } }, from: query.offset || 0, size: query.limit || 20, highlight: { fields: { content: {}, name: {}, description: {} } } } ).toPromise(); return { hits: response.data.hits.hits.map(hit => ({ id: hit._id, score: hit._score, ...hit._source, highlights: hit.highlight })), total: response.data.hits.total.value }; } } ``` ### PDF/Image Processing Integration ```typescript @Injectable() export class DocumentProcessingService { constructor( private readonly storageService: StorageService ) {} async extractText(storagePath: string, mimeType: string): Promise { const stream = await this.storageService.download(storagePath); switch (mimeType) { case 'application/pdf': return this.extractPdfText(stream); case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': return this.extractDocxText(stream); case 'image/png': case 'image/jpeg': return this.extractImageText(stream); // OCR default: return ''; } } async generateThumbnail(storagePath: string, mimeType: string): Promise { const stream = await this.storageService.download(storagePath); switch (mimeType) { case 'application/pdf': return this.generatePdfThumbnail(stream); case 'image/png': case 'image/jpeg': return this.generateImageThumbnail(stream); default: return this.getDefaultThumbnail(mimeType); } } async generatePreview(storagePath: string, mimeType: string): Promise { // Generate preview based on file type // Returns HTML or image URL for preview } } ``` ### Compliance Integration ```typescript @Injectable() export class DocumentComplianceIntegration { @OnEvent('document.approved') async handleDocumentApproved(event: DocumentApprovedEvent): Promise { if (event.allApproversCompleted) { // Check if document is linked to compliance item const links = await this.documentLinkRepo.find({ where: { documentId: event.documentId, entityType: 'compliance_item' } }); for (const link of links) { // Update compliance item status await this.complianceService.updateItemStatus(link.entityId, 'compliant', { evidenceDocumentId: event.documentId, approvalDate: event.timestamp }); } } } @OnEvent('plan_revision.approved') async handlePlanRevisionApproved(event: PlanRevisionApprovedEvent): Promise { // Update project with latest approved plan const plan = await this.planRepo.findOne(event.planId); if (plan.projectId) { await this.projectService.updatePlanStatus(plan.projectId, { planId: event.planId, revisionCode: event.revisionCode, approvedAt: event.timestamp }); } } } ``` --- ## Referencias - [DDL-SPEC-documents.md](../../04-modelado/database-design/schemas/DDL-SPEC-documents.md) - [DOCUMENTS-CONTEXT.md](../../04-modelado/domain-models/DOCUMENTS-CONTEXT.md) - [EPIC-MAE-016](../../08-epicas/EPIC-MAE-016-documents.md) --- *Ultima actualizacion: 2025-12-05*