1295 lines
35 KiB
Markdown
1295 lines
35 KiB
Markdown
# 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<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
|
|
|
|
```typescript
|
|
@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
|
|
|
|
```typescript
|
|
@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
|
|
|
|
```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<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
|
|
|
|
```typescript
|
|
@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
|
|
|
|
```typescript
|
|
@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
|
|
|
|
```typescript
|
|
@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
|
|
|
|
```typescript
|
|
@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
|
|
|
|
```typescript
|
|
@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
|
|
|
|
```typescript
|
|
@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
|
|
|
|
```typescript
|
|
@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
|
|
|
|
```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<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
|
|
|
|
```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<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
|
|
|
|
```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<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
|
|
|
|
```typescript
|
|
@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
|
|
|
|
```typescript
|
|
@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
|
|
|
|
- [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*
|