erp-construccion/docs/05-backend-specs/modules/SPEC-documents.md

35 KiB

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

@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<PaginatedResponse<DocumentDto>>;

  // GET /api/v1/documents/:id
  @Get(':id')
  async findOne(
    @Param('id') id: UUID,
    @CurrentTenant() tenantId: UUID
  ): Promise<DocumentDetailDto>;

  // 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<DocumentDto>;

  // PUT /api/v1/documents/:id
  @Put(':id')
  async update(
    @Param('id') id: UUID,
    @Body() dto: UpdateDocumentDto
  ): Promise<DocumentDto>;

  // DELETE /api/v1/documents/:id
  @Delete(':id')
  async remove(@Param('id') id: UUID): Promise<void>;

  // GET /api/v1/documents/:id/download
  @Get(':id/download')
  async download(@Param('id') id: UUID): Promise<StreamableFile>;

  // GET /api/v1/documents/:id/preview
  @Get(':id/preview')
  async preview(@Param('id') id: UUID): Promise<PreviewDto>;

  // 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<DocumentVersionDto>;

  // GET /api/v1/documents/:id/versions
  @Get(':id/versions')
  async getVersions(@Param('id') id: UUID): Promise<DocumentVersionDto[]>;

  // GET /api/v1/documents/:id/versions/:versionId/download
  @Get(':id/versions/:versionId/download')
  async downloadVersion(
    @Param('id') id: UUID,
    @Param('versionId') versionId: UUID
  ): Promise<StreamableFile>;

  // 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<DocumentDto>;

  // POST /api/v1/documents/:id/copy
  @Post(':id/copy')
  async copyDocument(
    @Param('id') id: UUID,
    @Body() dto: CopyDocumentDto,
    @CurrentUser() userId: UUID
  ): Promise<DocumentDto>;

  // POST /api/v1/documents/:id/move
  @Post(':id/move')
  async moveDocument(
    @Param('id') id: UUID,
    @Body() dto: MoveDocumentDto,
    @CurrentUser() userId: UUID
  ): Promise<DocumentDto>;

  // POST /api/v1/documents/:id/tags
  @Post(':id/tags')
  async addTags(
    @Param('id') id: UUID,
    @Body() dto: AddTagsDto
  ): Promise<DocumentDto>;

  // DELETE /api/v1/documents/:id/tags/:tagId
  @Delete(':id/tags/:tagId')
  async removeTag(
    @Param('id') id: UUID,
    @Param('tagId') tagId: UUID
  ): Promise<DocumentDto>;

  // POST /api/v1/documents/:id/link
  @Post(':id/link')
  async linkToEntity(
    @Param('id') id: UUID,
    @Body() dto: LinkDocumentDto
  ): Promise<DocumentLinkDto>;

  // DELETE /api/v1/documents/:id/link/:linkId
  @Delete(':id/link/:linkId')
  async unlinkFromEntity(
    @Param('id') id: UUID,
    @Param('linkId') linkId: UUID
  ): Promise<void>;

  // 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<BulkUploadResultDto>;
}

2. FolderController

@Controller('api/v1/documents/folders')
@ApiTags('document-folders')
export class FolderController {

  // GET /api/v1/documents/folders
  @Get()
  async findAll(@CurrentTenant() tenantId: UUID): Promise<FolderTreeDto[]>;

  // GET /api/v1/documents/folders/:id
  @Get(':id')
  async findOne(@Param('id') id: UUID): Promise<FolderDetailDto>;

  // POST /api/v1/documents/folders
  @Post()
  async create(
    @Body() dto: CreateFolderDto,
    @CurrentUser() userId: UUID
  ): Promise<FolderDto>;

  // PUT /api/v1/documents/folders/:id
  @Put(':id')
  async update(
    @Param('id') id: UUID,
    @Body() dto: UpdateFolderDto
  ): Promise<FolderDto>;

  // DELETE /api/v1/documents/folders/:id
  @Delete(':id')
  async remove(@Param('id') id: UUID): Promise<void>;

  // GET /api/v1/documents/folders/:id/contents
  @Get(':id/contents')
  async getContents(
    @Param('id') id: UUID,
    @Query() query: FolderContentsQueryDto
  ): Promise<FolderContentsDto>;

  // POST /api/v1/documents/folders/:id/permissions
  @Post(':id/permissions')
  async setPermissions(
    @Param('id') id: UUID,
    @Body() dto: SetPermissionsDto
  ): Promise<FolderPermissionDto[]>;

  // GET /api/v1/documents/folders/:id/permissions
  @Get(':id/permissions')
  async getPermissions(@Param('id') id: UUID): Promise<FolderPermissionDto[]>;

  // DELETE /api/v1/documents/folders/:id/permissions/:permissionId
  @Delete(':id/permissions/:permissionId')
  async removePermission(
    @Param('id') id: UUID,
    @Param('permissionId') permissionId: UUID
  ): Promise<void>;

  // GET /api/v1/documents/folders/:id/path
  @Get(':id/path')
  async getPath(@Param('id') id: UUID): Promise<FolderPathDto[]>;

  // POST /api/v1/documents/folders/:id/move
  @Post(':id/move')
  async moveFolder(
    @Param('id') id: UUID,
    @Body() dto: MoveFolderDto
  ): Promise<FolderDto>;
}

3. PlanController

@Controller('api/v1/documents/plans')
@ApiTags('construction-plans')
export class PlanController {

  // GET /api/v1/documents/plans
  @Get()
  async findAll(@Query() query: PlanQueryDto): Promise<PaginatedResponse<PlanDto>>;

  // GET /api/v1/documents/plans/:id
  @Get(':id')
  async findOne(@Param('id') id: UUID): Promise<PlanDetailDto>;

  // POST /api/v1/documents/plans
  @Post()
  @UseInterceptors(FileInterceptor('file'))
  async create(
    @UploadedFile() file: Express.Multer.File,
    @Body() dto: CreatePlanDto,
    @CurrentUser() userId: UUID
  ): Promise<PlanDto>;

  // PUT /api/v1/documents/plans/:id
  @Put(':id')
  async update(
    @Param('id') id: UUID,
    @Body() dto: UpdatePlanDto
  ): Promise<PlanDto>;

  // DELETE /api/v1/documents/plans/:id
  @Delete(':id')
  async remove(@Param('id') id: UUID): Promise<void>;

  // GET /api/v1/documents/plans/:id/download
  @Get(':id/download')
  async download(@Param('id') id: UUID): Promise<StreamableFile>;

  // GET /api/v1/documents/plans/:id/revisions
  @Get(':id/revisions')
  async getRevisions(@Param('id') id: UUID): Promise<PlanRevisionDto[]>;

  // 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<PlanRevisionDto>;

  // 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<StreamableFile>;

  // 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<PlanRevisionDto>;

  // 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<PlanRevisionDto>;

  // GET /api/v1/documents/plans/by-project/:projectId
  @Get('by-project/:projectId')
  async getByProject(
    @Param('projectId') projectId: UUID,
    @Query() query: PlanQueryDto
  ): Promise<PaginatedResponse<PlanDto>>;

  // POST /api/v1/documents/plans/:id/compare
  @Post(':id/compare')
  async compareRevisions(
    @Param('id') id: UUID,
    @Body() dto: CompareRevisionsDto
  ): Promise<ComparisonResultDto>;
}

4. ApprovalController

@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<PendingApprovalDto[]>;

  // GET /api/v1/documents/approvals/:id
  @Get(':id')
  async findOne(@Param('id') id: UUID): Promise<ApprovalDetailDto>;

  // POST /api/v1/documents/approvals
  @Post()
  async createApprovalRequest(
    @Body() dto: CreateApprovalRequestDto,
    @CurrentUser() userId: UUID
  ): Promise<ApprovalDto>;

  // POST /api/v1/documents/approvals/:id/approve
  @Post(':id/approve')
  async approve(
    @Param('id') id: UUID,
    @Body() dto: ApproveDto,
    @CurrentUser() userId: UUID
  ): Promise<ApprovalDto>;

  // POST /api/v1/documents/approvals/:id/reject
  @Post(':id/reject')
  async reject(
    @Param('id') id: UUID,
    @Body() dto: RejectDto,
    @CurrentUser() userId: UUID
  ): Promise<ApprovalDto>;

  // 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<ApprovalDto>;

  // POST /api/v1/documents/approvals/:id/cancel
  @Post(':id/cancel')
  async cancel(
    @Param('id') id: UUID,
    @CurrentUser() userId: UUID
  ): Promise<ApprovalDto>;

  // GET /api/v1/documents/approvals/:id/history
  @Get(':id/history')
  async getHistory(@Param('id') id: UUID): Promise<ApprovalHistoryDto[]>;

  // GET /api/v1/documents/approvals/workflows
  @Get('workflows')
  async getWorkflows(@CurrentTenant() tenantId: UUID): Promise<ApprovalWorkflowDto[]>;

  // POST /api/v1/documents/approvals/workflows
  @Post('workflows')
  async createWorkflow(
    @Body() dto: CreateWorkflowDto,
    @CurrentUser() userId: UUID
  ): Promise<ApprovalWorkflowDto>;
}

5. SearchController

@Controller('api/v1/documents/search')
@ApiTags('document-search')
export class SearchController {

  // GET /api/v1/documents/search
  @Get()
  async search(@Query() query: SearchQueryDto): Promise<SearchResultDto>;

  // GET /api/v1/documents/search/advanced
  @Get('advanced')
  async advancedSearch(@Query() query: AdvancedSearchQueryDto): Promise<SearchResultDto>;

  // GET /api/v1/documents/search/suggestions
  @Get('suggestions')
  async getSuggestions(@Query('q') query: string): Promise<SuggestionDto[]>;

  // GET /api/v1/documents/search/recent
  @Get('recent')
  async getRecent(@CurrentUser() userId: UUID): Promise<DocumentDto[]>;

  // GET /api/v1/documents/search/by-entity
  @Get('by-entity')
  async searchByEntity(@Query() query: EntitySearchQueryDto): Promise<DocumentDto[]>;

  // GET /api/v1/documents/search/tags
  @Get('tags')
  async searchTags(@Query('q') query: string): Promise<TagDto[]>;

  // POST /api/v1/documents/search/index/:id
  @Post('index/:id')
  async reindexDocument(@Param('id') id: UUID): Promise<void>;
}

Services

DocumentService

@Injectable()
export class DocumentService {
  constructor(
    @InjectRepository(Document)
    private readonly documentRepo: Repository<Document>,
    @InjectRepository(DocumentVersion)
    private readonly versionRepo: Repository<DocumentVersion>,
    private readonly storageService: StorageService,
    private readonly eventEmitter: EventEmitter2
  ) {}

  async findAll(tenantId: UUID, query: DocumentQueryDto): Promise<PaginatedResponse<DocumentDto>>;
  async findOne(tenantId: UUID, id: UUID): Promise<DocumentDetailDto>;
  async create(tenantId: UUID, userId: UUID, file: Express.Multer.File, dto: CreateDocumentDto): Promise<DocumentDto>;
  async update(id: UUID, dto: UpdateDocumentDto): Promise<DocumentDto>;
  async remove(id: UUID): Promise<void>;
  async getDownloadStream(id: UUID): Promise<{ stream: Readable; filename: string; mimeType: string }>;
  async generatePreview(id: UUID): Promise<PreviewDto>;
  async copyDocument(id: UUID, userId: UUID, dto: CopyDocumentDto): Promise<DocumentDto>;
  async moveDocument(id: UUID, userId: UUID, dto: MoveDocumentDto): Promise<DocumentDto>;
  async addTags(id: UUID, tagIds: string[]): Promise<DocumentDto>;
  async removeTag(id: UUID, tagId: UUID): Promise<DocumentDto>;
  async linkToEntity(id: UUID, dto: LinkDocumentDto): Promise<DocumentLinkDto>;
  async unlinkFromEntity(id: UUID, linkId: UUID): Promise<void>;
  async bulkUpload(tenantId: UUID, userId: UUID, files: Express.Multer.File[], dto: BulkUploadDto): Promise<BulkUploadResultDto>;
}

VersionService

@Injectable()
export class VersionService {
  constructor(
    @InjectRepository(DocumentVersion)
    private readonly versionRepo: Repository<DocumentVersion>,
    private readonly storageService: StorageService,
    private readonly eventEmitter: EventEmitter2
  ) {}

  async createVersion(documentId: UUID, userId: UUID, file: Express.Multer.File, dto: UploadVersionDto): Promise<DocumentVersionDto>;
  async getVersions(documentId: UUID): Promise<DocumentVersionDto[]>;
  async getVersion(versionId: UUID): Promise<DocumentVersionDto>;
  async downloadVersion(versionId: UUID): Promise<{ stream: Readable; filename: string }>;
  async restoreVersion(documentId: UUID, versionId: UUID, userId: UUID): Promise<DocumentDto>;
  async deleteOldVersions(documentId: UUID, keepCount: number): Promise<number>;
  async calculateVersionNumber(documentId: UUID): Promise<number>;
}

FolderService

@Injectable()
export class FolderService {
  constructor(
    @InjectRepository(Folder)
    private readonly folderRepo: Repository<Folder>,
    @InjectRepository(FolderPermission)
    private readonly permissionRepo: Repository<FolderPermission>
  ) {}

  async findAll(tenantId: UUID): Promise<FolderTreeDto[]>;
  async findOne(id: UUID): Promise<FolderDetailDto>;
  async create(tenantId: UUID, userId: UUID, dto: CreateFolderDto): Promise<FolderDto>;
  async update(id: UUID, dto: UpdateFolderDto): Promise<FolderDto>;
  async remove(id: UUID): Promise<void>;
  async getContents(id: UUID, query: FolderContentsQueryDto): Promise<FolderContentsDto>;
  async setPermissions(id: UUID, dto: SetPermissionsDto): Promise<FolderPermissionDto[]>;
  async getPermissions(id: UUID): Promise<FolderPermissionDto[]>;
  async removePermission(id: UUID, permissionId: UUID): Promise<void>;
  async getPath(id: UUID): Promise<FolderPathDto[]>;
  async moveFolder(id: UUID, dto: MoveFolderDto): Promise<FolderDto>;
  async checkPermission(folderId: UUID, userId: UUID, permission: PermissionType): Promise<boolean>;
  async getEffectivePermissions(folderId: UUID, userId: UUID): Promise<PermissionType[]>;
}

PlanService

@Injectable()
export class PlanService {
  constructor(
    @InjectRepository(ConstructionPlan)
    private readonly planRepo: Repository<ConstructionPlan>,
    @InjectRepository(PlanRevision)
    private readonly revisionRepo: Repository<PlanRevision>,
    private readonly storageService: StorageService,
    private readonly eventEmitter: EventEmitter2
  ) {}

  async findAll(tenantId: UUID, query: PlanQueryDto): Promise<PaginatedResponse<PlanDto>>;
  async findOne(id: UUID): Promise<PlanDetailDto>;
  async create(tenantId: UUID, userId: UUID, file: Express.Multer.File, dto: CreatePlanDto): Promise<PlanDto>;
  async update(id: UUID, dto: UpdatePlanDto): Promise<PlanDto>;
  async remove(id: UUID): Promise<void>;
  async getDownloadStream(id: UUID): Promise<{ stream: Readable; filename: string }>;
  async getRevisions(planId: UUID): Promise<PlanRevisionDto[]>;
  async createRevision(planId: UUID, userId: UUID, file: Express.Multer.File, dto: CreateRevisionDto): Promise<PlanRevisionDto>;
  async downloadRevision(revisionId: UUID): Promise<{ stream: Readable; filename: string }>;
  async approveRevision(planId: UUID, revisionId: UUID, userId: UUID, dto: ApproveRevisionDto): Promise<PlanRevisionDto>;
  async rejectRevision(planId: UUID, revisionId: UUID, userId: UUID, dto: RejectRevisionDto): Promise<PlanRevisionDto>;
  async getByProject(projectId: UUID, query: PlanQueryDto): Promise<PaginatedResponse<PlanDto>>;
  async compareRevisions(planId: UUID, revisionId1: UUID, revisionId2: UUID): Promise<ComparisonResultDto>;
  async calculateNextRevisionCode(planId: UUID): Promise<string>;
}

ApprovalService

@Injectable()
export class ApprovalService {
  constructor(
    @InjectRepository(DocumentApproval)
    private readonly approvalRepo: Repository<DocumentApproval>,
    @InjectRepository(ApprovalStep)
    private readonly stepRepo: Repository<ApprovalStep>,
    private readonly notificationService: NotificationService,
    private readonly eventEmitter: EventEmitter2
  ) {}

  async getPendingApprovals(userId: UUID): Promise<PendingApprovalDto[]>;
  async findOne(id: UUID): Promise<ApprovalDetailDto>;
  async createApprovalRequest(userId: UUID, dto: CreateApprovalRequestDto): Promise<ApprovalDto>;
  async approve(id: UUID, userId: UUID, dto: ApproveDto): Promise<ApprovalDto>;
  async reject(id: UUID, userId: UUID, dto: RejectDto): Promise<ApprovalDto>;
  async requestChanges(id: UUID, userId: UUID, dto: RequestChangesDto): Promise<ApprovalDto>;
  async cancel(id: UUID, userId: UUID): Promise<ApprovalDto>;
  async getHistory(id: UUID): Promise<ApprovalHistoryDto[]>;
  async getWorkflows(tenantId: UUID): Promise<ApprovalWorkflowDto[]>;
  async createWorkflow(tenantId: UUID, userId: UUID, dto: CreateWorkflowDto): Promise<ApprovalWorkflowDto>;
  async advanceWorkflow(approvalId: UUID): Promise<void>;
  async notifyNextApprover(approvalId: UUID): Promise<void>;
}

StorageService

@Injectable()
export class StorageService {
  constructor(
    private readonly configService: ConfigService
  ) {}

  async upload(file: Express.Multer.File, path: string): Promise<StorageResult>;
  async download(storagePath: string): Promise<Readable>;
  async delete(storagePath: string): Promise<void>;
  async copy(sourcePath: string, destinationPath: string): Promise<StorageResult>;
  async move(sourcePath: string, destinationPath: string): Promise<StorageResult>;
  async getMetadata(storagePath: string): Promise<StorageMetadata>;
  async generatePresignedUrl(storagePath: string, expiresIn: number): Promise<string>;
  async getStorageUsage(tenantId: UUID): Promise<StorageUsageDto>;

  // 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

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<string, any>;

  @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

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

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

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

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

@Injectable()
export class CleanupJob {
  constructor(
    private readonly documentService: DocumentService,
    private readonly versionService: VersionService,
    private readonly storageService: StorageService,
    @InjectRepository(Document)
    private readonly documentRepo: Repository<Document>
  ) {}

  @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

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

@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<void> {
    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<void> {
    await this.httpService.delete(
      `${this.elasticsearchUrl}/documents/_doc/${documentId}`
    ).toPromise();
  }

  async search(tenantId: UUID, query: SearchQueryDto): Promise<SearchResultDto> {
    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

@Injectable()
export class DocumentProcessingService {
  constructor(
    private readonly storageService: StorageService
  ) {}

  async extractText(storagePath: string, mimeType: string): Promise<string> {
    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<Buffer> {
    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<PreviewDto> {
    // Generate preview based on file type
    // Returns HTML or image URL for preview
  }
}

Compliance Integration

@Injectable()
export class DocumentComplianceIntegration {
  @OnEvent('document.approved')
  async handleDocumentApproved(event: DocumentApprovedEvent): Promise<void> {
    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<void> {
    // 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


Ultima actualizacion: 2025-12-05