From 22b9692e3a3ecb6fe874b8b6d19a1375d54a03c8 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 08:22:49 -0600 Subject: [PATCH] [MAE-016] feat: Add Documents module (Gestion Documental) - Create 11 entities matching DDL: - DocumentCategory: Hierarchical categories - Document: Main document registry - DocumentVersion: Version control with files - DocumentPermission: Granular permissions - ApprovalWorkflow: Workflow definitions - ApprovalInstance: Active workflow instances - ApprovalStep: Steps per instance - ApprovalActionEntity: Actions taken - Annotation: Comments and markups - AccessLog: Access history - DocumentShare: External sharing links - Create DocumentService with full CRUD - Create DocumentVersionService for version management - All entities follow ERP-Construction patterns Co-Authored-By: Claude Opus 4.5 --- .../documents/entities/access-log.entity.ts | 76 +++ .../documents/entities/annotation.entity.ts | 133 ++++++ .../entities/approval-action.entity.ts | 76 +++ .../entities/approval-instance.entity.ts | 132 ++++++ .../entities/approval-step.entity.ts | 113 +++++ .../entities/approval-workflow.entity.ts | 102 ++++ .../entities/document-category.entity.ts | 112 +++++ .../entities/document-permission.entity.ts | 100 ++++ .../entities/document-share.entity.ts | 110 +++++ .../entities/document-version.entity.ts | 136 ++++++ .../documents/entities/document.entity.ts | 234 +++++++++ src/modules/documents/entities/index.ts | 16 + src/modules/documents/index.ts | 9 + .../services/document-version.service.ts | 304 ++++++++++++ .../documents/services/document.service.ts | 447 ++++++++++++++++++ src/modules/documents/services/index.ts | 7 + 16 files changed, 2107 insertions(+) create mode 100644 src/modules/documents/entities/access-log.entity.ts create mode 100644 src/modules/documents/entities/annotation.entity.ts create mode 100644 src/modules/documents/entities/approval-action.entity.ts create mode 100644 src/modules/documents/entities/approval-instance.entity.ts create mode 100644 src/modules/documents/entities/approval-step.entity.ts create mode 100644 src/modules/documents/entities/approval-workflow.entity.ts create mode 100644 src/modules/documents/entities/document-category.entity.ts create mode 100644 src/modules/documents/entities/document-permission.entity.ts create mode 100644 src/modules/documents/entities/document-share.entity.ts create mode 100644 src/modules/documents/entities/document-version.entity.ts create mode 100644 src/modules/documents/entities/document.entity.ts create mode 100644 src/modules/documents/entities/index.ts create mode 100644 src/modules/documents/index.ts create mode 100644 src/modules/documents/services/document-version.service.ts create mode 100644 src/modules/documents/services/document.service.ts create mode 100644 src/modules/documents/services/index.ts diff --git a/src/modules/documents/entities/access-log.entity.ts b/src/modules/documents/entities/access-log.entity.ts new file mode 100644 index 0000000..50e5a84 --- /dev/null +++ b/src/modules/documents/entities/access-log.entity.ts @@ -0,0 +1,76 @@ +/** + * Access Log Entity + * Historial de acceso a documentos. + * + * @module Documents (MAE-016) + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Document } from './document.entity'; +import { DocumentVersion } from './document-version.entity'; + +@Entity('access_logs', { schema: 'documents' }) +@Index(['tenantId']) +@Index(['documentId']) +@Index(['userId']) +@Index(['accessedAt']) +export class AccessLog { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string; + + // Documento + @Column({ name: 'document_id', type: 'uuid' }) + documentId!: string; + + @ManyToOne(() => Document) + @JoinColumn({ name: 'document_id' }) + document!: Document; + + @Column({ name: 'version_id', type: 'uuid', nullable: true }) + versionId?: string; + + @ManyToOne(() => DocumentVersion, { nullable: true }) + @JoinColumn({ name: 'version_id' }) + version?: DocumentVersion; + + // Accion + @Column({ length: 50 }) + action!: string; // view, download, print, share, edit + + @Column({ name: 'action_details', type: 'jsonb', nullable: true }) + actionDetails?: Record; + + // Usuario + @Column({ name: 'user_id', type: 'uuid' }) + userId!: string; + + @Column({ name: 'user_name', length: 255, nullable: true }) + userName?: string; + + @Column({ name: 'user_ip', length: 45, nullable: true }) + userIp?: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent?: string; + + // Contexto + @Column({ name: 'session_id', length: 100, nullable: true }) + sessionId?: string; + + @Column({ name: 'request_source', length: 50, nullable: true }) + requestSource?: string; // web, mobile, api + + // Timestamp + @Column({ name: 'accessed_at', type: 'timestamptz', default: () => 'NOW()' }) + accessedAt!: Date; +} diff --git a/src/modules/documents/entities/annotation.entity.ts b/src/modules/documents/entities/annotation.entity.ts new file mode 100644 index 0000000..3671786 --- /dev/null +++ b/src/modules/documents/entities/annotation.entity.ts @@ -0,0 +1,133 @@ +/** + * Annotation Entity + * Anotaciones y comentarios sobre documentos. + * + * @module Documents (MAE-016) + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Document } from './document.entity'; +import { DocumentVersion } from './document-version.entity'; + +export type AnnotationType = + | 'comment' + | 'markup' + | 'highlight' + | 'arrow' + | 'dimension' + | 'stamp' + | 'signature'; + +@Entity('annotations', { schema: 'documents' }) +@Index(['tenantId']) +@Index(['documentId']) +@Index(['versionId']) +@Index(['authorId']) +export class Annotation { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string; + + // Documento y version + @Column({ name: 'document_id', type: 'uuid' }) + documentId!: string; + + @ManyToOne(() => Document, (doc) => doc.annotations, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'document_id' }) + document!: Document; + + @Column({ name: 'version_id', type: 'uuid', nullable: true }) + versionId?: string; + + @ManyToOne(() => DocumentVersion, { nullable: true }) + @JoinColumn({ name: 'version_id' }) + version?: DocumentVersion; + + // Tipo + @Column({ + name: 'annotation_type', + type: 'enum', + enum: ['comment', 'markup', 'highlight', 'arrow', 'dimension', 'stamp', 'signature'], + enumName: 'annotation_type', + }) + annotationType!: AnnotationType; + + // Ubicacion en el documento + @Column({ name: 'page_number', type: 'int', nullable: true }) + pageNumber?: number; + + @Column({ name: 'x_position', type: 'decimal', precision: 10, scale: 4, nullable: true }) + xPosition?: number; + + @Column({ name: 'y_position', type: 'decimal', precision: 10, scale: 4, nullable: true }) + yPosition?: number; + + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + width?: number; + + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + height?: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true }) + rotation?: number; + + // Contenido + @Column({ type: 'text', nullable: true }) + content?: string; + + @Column({ type: 'jsonb', nullable: true }) + style?: Record; + + // Respuesta a otra anotacion + @Column({ name: 'parent_annotation_id', type: 'uuid', nullable: true }) + parentAnnotationId?: string; + + @ManyToOne(() => Annotation, { nullable: true }) + @JoinColumn({ name: 'parent_annotation_id' }) + parentAnnotation?: Annotation; + + // Estado + @Column({ name: 'is_resolved', type: 'boolean', default: false }) + isResolved!: boolean; + + @Column({ name: 'resolved_by_id', type: 'uuid', nullable: true }) + resolvedById?: string; + + @Column({ name: 'resolved_at', type: 'timestamptz', nullable: true }) + resolvedAt?: Date; + + // Visibilidad + @Column({ name: 'is_private', type: 'boolean', default: false }) + isPrivate!: boolean; + + @Column({ name: 'visible_to', type: 'uuid', array: true, nullable: true }) + visibleTo?: string[]; + + // Autor + @Column({ name: 'author_id', type: 'uuid' }) + authorId!: string; + + @Column({ name: 'author_name', length: 255, nullable: true }) + authorName?: string; + + // Auditoria + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/documents/entities/approval-action.entity.ts b/src/modules/documents/entities/approval-action.entity.ts new file mode 100644 index 0000000..9395d7e --- /dev/null +++ b/src/modules/documents/entities/approval-action.entity.ts @@ -0,0 +1,76 @@ +/** + * Approval Action Entity + * Acciones tomadas en pasos de aprobacion. + * + * @module Documents (MAE-016) + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { ApprovalStep } from './approval-step.entity'; +import { ApprovalInstance, ApprovalAction } from './approval-instance.entity'; + +@Entity('approval_actions', { schema: 'documents' }) +@Index(['stepId']) +@Index(['userId']) +export class ApprovalActionEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string; + + // Paso + @Column({ name: 'step_id', type: 'uuid' }) + stepId!: string; + + @ManyToOne(() => ApprovalStep, (step) => step.actions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'step_id' }) + step!: ApprovalStep; + + @Column({ name: 'instance_id', type: 'uuid' }) + instanceId!: string; + + @ManyToOne(() => ApprovalInstance) + @JoinColumn({ name: 'instance_id' }) + instance!: ApprovalInstance; + + // Accion + @Column({ + type: 'enum', + enum: ['approve', 'reject', 'request_changes'], + enumName: 'approval_action', + }) + action!: ApprovalAction; + + @Column({ type: 'text', nullable: true }) + comments?: string; + + // Usuario que toma la accion + @Column({ name: 'user_id', type: 'uuid' }) + userId!: string; + + @Column({ name: 'user_name', length: 255, nullable: true }) + userName?: string; + + // Firma digital (si aplica) + @Column({ name: 'signature_data', type: 'text', nullable: true }) + signatureData?: string; + + @Column({ name: 'signature_timestamp', type: 'timestamptz', nullable: true }) + signatureTimestamp?: Date; + + @Column({ name: 'signature_ip', length: 45, nullable: true }) + signatureIp?: string; + + // Auditoria + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/src/modules/documents/entities/approval-instance.entity.ts b/src/modules/documents/entities/approval-instance.entity.ts new file mode 100644 index 0000000..937f670 --- /dev/null +++ b/src/modules/documents/entities/approval-instance.entity.ts @@ -0,0 +1,132 @@ +/** + * Approval Instance Entity + * Instancias de flujos de aprobacion activos. + * + * @module Documents (MAE-016) + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { ApprovalWorkflow } from './approval-workflow.entity'; +import { Document } from './document.entity'; +import { DocumentVersion } from './document-version.entity'; +import { ApprovalStep } from './approval-step.entity'; + +export type WorkflowStatus = + | 'draft' + | 'pending' + | 'in_progress' + | 'approved' + | 'rejected' + | 'cancelled'; + +export type ApprovalAction = 'approve' | 'reject' | 'request_changes'; + +@Entity('approval_instances', { schema: 'documents' }) +@Index(['tenantId']) +@Index(['documentId']) +@Index(['status']) +export class ApprovalInstance { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string; + + // Flujo y documento + @Column({ name: 'workflow_id', type: 'uuid' }) + workflowId!: string; + + @ManyToOne(() => ApprovalWorkflow) + @JoinColumn({ name: 'workflow_id' }) + workflow!: ApprovalWorkflow; + + @Column({ name: 'document_id', type: 'uuid' }) + documentId!: string; + + @ManyToOne(() => Document) + @JoinColumn({ name: 'document_id' }) + document!: Document; + + @Column({ name: 'version_id', type: 'uuid', nullable: true }) + versionId?: string; + + @ManyToOne(() => DocumentVersion, { nullable: true }) + @JoinColumn({ name: 'version_id' }) + version?: DocumentVersion; + + // Estado + @Column({ + type: 'enum', + enum: ['draft', 'pending', 'in_progress', 'approved', 'rejected', 'cancelled'], + enumName: 'workflow_status', + default: 'draft', + }) + status!: WorkflowStatus; + + @Column({ name: 'current_step', type: 'int', default: 1 }) + currentStep!: number; + + @Column({ name: 'total_steps', type: 'int' }) + totalSteps!: number; + + // Fechas + @Column({ name: 'started_at', type: 'timestamptz', nullable: true }) + startedAt?: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date; + + @Column({ name: 'due_date', type: 'timestamptz', nullable: true }) + dueDate?: Date; + + // Iniciador + @Column({ name: 'initiated_by_id', type: 'uuid', nullable: true }) + initiatedById?: string; + + @Column({ name: 'initiated_by_name', length: 255, nullable: true }) + initiatedByName?: string; + + // Resultado final + @Column({ + name: 'final_action', + type: 'enum', + enum: ['approve', 'reject', 'request_changes'], + enumName: 'approval_action', + nullable: true, + }) + finalAction?: ApprovalAction; + + @Column({ name: 'final_comments', type: 'text', nullable: true }) + finalComments?: string; + + @Column({ name: 'final_approver_id', type: 'uuid', nullable: true }) + finalApproverId?: string; + + // Notas + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Relaciones + @OneToMany(() => ApprovalStep, (step) => step.instance) + steps?: ApprovalStep[]; + + // Auditoria + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/src/modules/documents/entities/approval-step.entity.ts b/src/modules/documents/entities/approval-step.entity.ts new file mode 100644 index 0000000..d7c4c19 --- /dev/null +++ b/src/modules/documents/entities/approval-step.entity.ts @@ -0,0 +1,113 @@ +/** + * Approval Step Entity + * Pasos de aprobacion por instancia. + * + * @module Documents (MAE-016) + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { ApprovalInstance, WorkflowStatus, ApprovalAction } from './approval-instance.entity'; +import { ApprovalActionEntity } from './approval-action.entity'; + +export type ApprovalStepType = 'review' | 'approval' | 'signature' | 'comment'; + +@Entity('approval_steps', { schema: 'documents' }) +@Index(['instanceId']) +@Index(['status']) +export class ApprovalStep { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string; + + // Instancia + @Column({ name: 'instance_id', type: 'uuid' }) + instanceId!: string; + + @ManyToOne(() => ApprovalInstance, (instance) => instance.steps, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'instance_id' }) + instance!: ApprovalInstance; + + // Paso + @Column({ name: 'step_number', type: 'int' }) + stepNumber!: number; + + @Column({ name: 'step_name', length: 255 }) + stepName!: string; + + @Column({ + name: 'step_type', + type: 'enum', + enum: ['review', 'approval', 'signature', 'comment'], + enumName: 'approval_step_type', + }) + stepType!: ApprovalStepType; + + // Aprobadores requeridos + @Column({ name: 'required_approvers', type: 'uuid', array: true, nullable: true }) + requiredApprovers?: string[]; + + @Column({ name: 'required_count', type: 'int', default: 1 }) + requiredCount!: number; + + // Estado + @Column({ + type: 'enum', + enum: ['draft', 'pending', 'in_progress', 'approved', 'rejected', 'cancelled'], + enumName: 'workflow_status', + default: 'pending', + }) + status!: WorkflowStatus; + + // Fechas + @Column({ name: 'started_at', type: 'timestamptz', nullable: true }) + startedAt?: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date; + + @Column({ name: 'due_date', type: 'timestamptz', nullable: true }) + dueDate?: Date; + + // Resultado + @Column({ + name: 'action_taken', + type: 'enum', + enum: ['approve', 'reject', 'request_changes'], + enumName: 'approval_action', + nullable: true, + }) + actionTaken?: ApprovalAction; + + @Column({ name: 'approved_by', type: 'uuid', array: true, nullable: true }) + approvedBy?: string[]; + + @Column({ name: 'rejected_by', type: 'uuid', array: true, nullable: true }) + rejectedBy?: string[]; + + // Notas + @Column({ type: 'text', nullable: true }) + notes?: string; + + // Relaciones + @OneToMany(() => ApprovalActionEntity, (action) => action.step) + actions?: ApprovalActionEntity[]; + + // Auditoria + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/src/modules/documents/entities/approval-workflow.entity.ts b/src/modules/documents/entities/approval-workflow.entity.ts new file mode 100644 index 0000000..6c29f50 --- /dev/null +++ b/src/modules/documents/entities/approval-workflow.entity.ts @@ -0,0 +1,102 @@ +/** + * Approval Workflow Entity + * Definicion de flujos de aprobacion para documentos. + * + * @module Documents (MAE-016) + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { DocumentCategory } from './document-category.entity'; +import { DocumentType } from './document.entity'; + +@Entity('approval_workflows', { schema: 'documents' }) +@Index(['tenantId', 'workflowCode'], { unique: true }) +@Index(['tenantId', 'categoryId']) +export class ApprovalWorkflow { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Identificacion + @Column({ name: 'workflow_code', length: 50 }) + workflowCode!: string; + + @Column({ length: 255 }) + name!: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + // Aplica a + @Column({ name: 'category_id', type: 'uuid', nullable: true }) + categoryId?: string; + + @ManyToOne(() => DocumentCategory, { nullable: true }) + @JoinColumn({ name: 'category_id' }) + category?: DocumentCategory; + + @Column({ + name: 'document_type', + type: 'enum', + enum: ['plan', 'specification', 'contract', 'permit', 'report', 'photograph', 'drawing', 'manual', 'procedure', 'form', 'correspondence', 'invoice', 'estimate', 'other'], + enumName: 'document_type', + nullable: true, + }) + documentType?: DocumentType; + + // Pasos (JSON array) + @Column({ type: 'jsonb' }) + steps!: Array<{ + stepNumber: number; + name: string; + type: 'review' | 'approval' | 'signature' | 'comment'; + approvers: string[]; + requiredCount: number; + }>; + + // Configuracion + @Column({ name: 'allow_parallel', type: 'boolean', default: false }) + allowParallel!: boolean; + + @Column({ name: 'allow_skip', type: 'boolean', default: false }) + allowSkip!: boolean; + + @Column({ name: 'auto_archive_on_approval', type: 'boolean', default: false }) + autoArchiveOnApproval!: boolean; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + // Metadatos + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoria + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/documents/entities/document-category.entity.ts b/src/modules/documents/entities/document-category.entity.ts new file mode 100644 index 0000000..672a3e9 --- /dev/null +++ b/src/modules/documents/entities/document-category.entity.ts @@ -0,0 +1,112 @@ +/** + * Document Category Entity + * Sistema de categorias jerarquicas para documentos. + * + * @module Documents (MAE-016) + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; + +export type AccessLevel = 'public' | 'internal' | 'confidential' | 'restricted'; + +@Entity('document_categories', { schema: 'documents' }) +@Index(['tenantId', 'code'], { unique: true }) +@Index(['tenantId', 'parentId']) +export class DocumentCategory { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Informacion basica + @Column({ length: 50 }) + code!: string; + + @Column({ length: 100 }) + name!: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + // Jerarquia + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId?: string; + + @ManyToOne(() => DocumentCategory, { nullable: true }) + @JoinColumn({ name: 'parent_id' }) + parent?: DocumentCategory; + + @OneToMany(() => DocumentCategory, (cat) => cat.parent) + children?: DocumentCategory[]; + + @Column({ type: 'int', default: 1 }) + level!: number; + + @Column({ length: 500, nullable: true }) + path?: string; + + // Configuracion + @Column({ + name: 'default_access_level', + type: 'enum', + enum: ['public', 'internal', 'confidential', 'restricted'], + enumName: 'access_level', + default: 'internal', + }) + defaultAccessLevel!: AccessLevel; + + @Column({ name: 'requires_approval', type: 'boolean', default: false }) + requiresApproval!: boolean; + + @Column({ name: 'retention_days', type: 'int', nullable: true }) + retentionDays?: number; + + @Column({ name: 'allowed_extensions', type: 'varchar', array: true, nullable: true }) + allowedExtensions?: string[]; + + // Icono y color + @Column({ length: 50, nullable: true }) + icon?: string; + + @Column({ length: 20, nullable: true }) + color?: string; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder!: number; + + // Metadatos + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoria + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/documents/entities/document-permission.entity.ts b/src/modules/documents/entities/document-permission.entity.ts new file mode 100644 index 0000000..7e0f596 --- /dev/null +++ b/src/modules/documents/entities/document-permission.entity.ts @@ -0,0 +1,100 @@ +/** + * Document Permission Entity + * Permisos granulares por documento o categoria. + * + * @module Documents (MAE-016) + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Document } from './document.entity'; +import { DocumentCategory } from './document-category.entity'; + +@Entity('document_permissions', { schema: 'documents' }) +@Index(['documentId']) +@Index(['categoryId']) +@Index(['userId']) +@Index(['roleId']) +export class DocumentPermission { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string; + + // Objeto (documento o categoria) + @Column({ name: 'document_id', type: 'uuid', nullable: true }) + documentId?: string; + + @ManyToOne(() => Document, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'document_id' }) + document?: Document; + + @Column({ name: 'category_id', type: 'uuid', nullable: true }) + categoryId?: string; + + @ManyToOne(() => DocumentCategory, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'category_id' }) + category?: DocumentCategory; + + // Sujeto (quien tiene el permiso) + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId?: string; + + @Column({ name: 'role_id', type: 'uuid', nullable: true }) + roleId?: string; + + @Column({ name: 'team_id', type: 'uuid', nullable: true }) + teamId?: string; + + // Permisos + @Column({ name: 'can_view', type: 'boolean', default: false }) + canView!: boolean; + + @Column({ name: 'can_download', type: 'boolean', default: false }) + canDownload!: boolean; + + @Column({ name: 'can_edit', type: 'boolean', default: false }) + canEdit!: boolean; + + @Column({ name: 'can_delete', type: 'boolean', default: false }) + canDelete!: boolean; + + @Column({ name: 'can_share', type: 'boolean', default: false }) + canShare!: boolean; + + @Column({ name: 'can_approve', type: 'boolean', default: false }) + canApprove!: boolean; + + @Column({ name: 'can_annotate', type: 'boolean', default: false }) + canAnnotate!: boolean; + + // Vigencia + @Column({ name: 'valid_from', type: 'timestamptz', nullable: true }) + validFrom?: Date; + + @Column({ name: 'valid_until', type: 'timestamptz', nullable: true }) + validUntil?: Date; + + // Otorgado por + @Column({ name: 'granted_by_id', type: 'uuid', nullable: true }) + grantedById?: string; + + @Column({ name: 'granted_at', type: 'timestamptz', default: () => 'NOW()' }) + grantedAt!: Date; + + // Auditoria + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/src/modules/documents/entities/document-share.entity.ts b/src/modules/documents/entities/document-share.entity.ts new file mode 100644 index 0000000..7770541 --- /dev/null +++ b/src/modules/documents/entities/document-share.entity.ts @@ -0,0 +1,110 @@ +/** + * Document Share Entity + * Links de compartido externo para documentos. + * + * @module Documents (MAE-016) + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Document } from './document.entity'; +import { DocumentVersion } from './document-version.entity'; + +@Entity('document_shares', { schema: 'documents' }) +@Index(['tenantId']) +@Index(['documentId']) +@Index(['shareToken']) +export class DocumentShare { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string; + + // Documento + @Column({ name: 'document_id', type: 'uuid' }) + documentId!: string; + + @ManyToOne(() => Document, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'document_id' }) + document!: Document; + + @Column({ name: 'version_id', type: 'uuid', nullable: true }) + versionId?: string; + + @ManyToOne(() => DocumentVersion, { nullable: true }) + @JoinColumn({ name: 'version_id' }) + version?: DocumentVersion; + + // Link compartido + @Column({ name: 'share_token', length: 100, unique: true }) + shareToken!: string; + + @Column({ name: 'share_url', length: 2000, nullable: true }) + shareUrl?: string; + + // Destinatario + @Column({ name: 'shared_with_email', length: 255, nullable: true }) + sharedWithEmail?: string; + + @Column({ name: 'shared_with_name', length: 255, nullable: true }) + sharedWithName?: string; + + // Permisos + @Column({ name: 'can_download', type: 'boolean', default: false }) + canDownload!: boolean; + + @Column({ name: 'can_comment', type: 'boolean', default: false }) + canComment!: boolean; + + // Seguridad + @Column({ name: 'password_hash', length: 255, nullable: true }) + passwordHash?: string; + + @Column({ name: 'max_downloads', type: 'int', nullable: true }) + maxDownloads?: number; + + @Column({ name: 'download_count', type: 'int', default: 0 }) + downloadCount!: number; + + // Vigencia + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt?: Date; + + @Column({ name: 'is_revoked', type: 'boolean', default: false }) + isRevoked!: boolean; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt?: Date; + + @Column({ name: 'revoked_by_id', type: 'uuid', nullable: true }) + revokedById?: string; + + // Compartido por + @Column({ name: 'shared_by_id', type: 'uuid' }) + sharedById!: string; + + @Column({ name: 'shared_by_name', length: 255, nullable: true }) + sharedByName?: string; + + // Notificacion + @Column({ name: 'notification_sent', type: 'boolean', default: false }) + notificationSent!: boolean; + + @Column({ name: 'notification_sent_at', type: 'timestamptz', nullable: true }) + notificationSentAt?: Date; + + // Auditoria + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @Column({ name: 'last_accessed_at', type: 'timestamptz', nullable: true }) + lastAccessedAt?: Date; +} diff --git a/src/modules/documents/entities/document-version.entity.ts b/src/modules/documents/entities/document-version.entity.ts new file mode 100644 index 0000000..442a851 --- /dev/null +++ b/src/modules/documents/entities/document-version.entity.ts @@ -0,0 +1,136 @@ +/** + * Document Version Entity + * Control de versiones de documentos con archivos. + * + * @module Documents (MAE-016) + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Document } from './document.entity'; + +export type VersionStatus = 'current' | 'superseded' | 'archived'; + +@Entity('document_versions', { schema: 'documents' }) +@Index(['tenantId']) +@Index(['documentId']) +@Index(['status']) +export class DocumentVersion { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string; + + // Documento padre + @Column({ name: 'document_id', type: 'uuid' }) + documentId!: string; + + @ManyToOne(() => Document, (doc) => doc.versions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'document_id' }) + document!: Document; + + // Version + @Column({ name: 'version_number', length: 20 }) + versionNumber!: string; + + @Column({ name: 'version_label', length: 100, nullable: true }) + versionLabel?: string; + + @Column({ + type: 'enum', + enum: ['current', 'superseded', 'archived'], + enumName: 'version_status', + default: 'current', + }) + status!: VersionStatus; + + // Archivo + @Column({ name: 'file_name', length: 500 }) + fileName!: string; + + @Column({ name: 'file_extension', length: 20 }) + fileExtension!: string; + + @Column({ name: 'file_size_bytes', type: 'bigint' }) + fileSizeBytes!: number; + + @Column({ name: 'mime_type', length: 100, nullable: true }) + mimeType?: string; + + @Column({ name: 'checksum_md5', length: 32, nullable: true }) + checksumMd5?: string; + + @Column({ name: 'checksum_sha256', length: 64, nullable: true }) + checksumSha256?: string; + + // Almacenamiento + @Column({ name: 'storage_provider', length: 50, default: 's3' }) + storageProvider!: string; + + @Column({ name: 'storage_bucket', length: 255, nullable: true }) + storageBucket?: string; + + @Column({ name: 'storage_key', length: 1000 }) + storageKey!: string; + + @Column({ name: 'storage_url', length: 2000, nullable: true }) + storageUrl?: string; + + @Column({ name: 'thumbnail_url', length: 2000, nullable: true }) + thumbnailUrl?: string; + + @Column({ name: 'preview_url', length: 2000, nullable: true }) + previewUrl?: string; + + // Procesamiento + @Column({ name: 'is_processed', type: 'boolean', default: false }) + isProcessed!: boolean; + + @Column({ name: 'ocr_text', type: 'text', nullable: true }) + ocrText?: string; + + @Column({ name: 'extracted_metadata', type: 'jsonb', nullable: true }) + extractedMetadata?: Record; + + // Paginas + @Column({ name: 'page_count', type: 'int', nullable: true }) + pageCount?: number; + + @Column({ name: 'page_dimensions', type: 'jsonb', nullable: true }) + pageDimensions?: Record; + + // Cambios + @Column({ name: 'change_summary', type: 'text', nullable: true }) + changeSummary?: string; + + @Column({ name: 'change_type', length: 50, nullable: true }) + changeType?: string; + + // Subido por + @Column({ name: 'uploaded_by_id', type: 'uuid', nullable: true }) + uploadedById?: string; + + @Column({ name: 'uploaded_by_name', length: 255, nullable: true }) + uploadedByName?: string; + + @Column({ name: 'upload_source', length: 50, nullable: true }) + uploadSource?: string; + + // Auditoria + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @Column({ name: 'superseded_at', type: 'timestamptz', nullable: true }) + supersededAt?: Date; + + @Column({ name: 'superseded_by_version_id', type: 'uuid', nullable: true }) + supersededByVersionId?: string; +} diff --git a/src/modules/documents/entities/document.entity.ts b/src/modules/documents/entities/document.entity.ts new file mode 100644 index 0000000..9ab7be2 --- /dev/null +++ b/src/modules/documents/entities/document.entity.ts @@ -0,0 +1,234 @@ +/** + * Document Entity - Registro Principal de Documentos + * Sistema de gestion documental. + * + * @module Documents (MAE-016) + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { DocumentCategory, AccessLevel } from './document-category.entity'; +import { DocumentVersion } from './document-version.entity'; +import { Annotation } from './annotation.entity'; + +export type DocumentType = + | 'plan' + | 'specification' + | 'contract' + | 'permit' + | 'report' + | 'photograph' + | 'drawing' + | 'manual' + | 'procedure' + | 'form' + | 'correspondence' + | 'invoice' + | 'estimate' + | 'other'; + +export type DocumentStatus = + | 'draft' + | 'pending_review' + | 'in_review' + | 'approved' + | 'rejected' + | 'obsolete' + | 'archived'; + +@Entity('documents', { schema: 'documents' }) +@Index(['tenantId', 'documentCode'], { unique: true }) +@Index(['tenantId', 'categoryId']) +@Index(['tenantId', 'documentType']) +@Index(['tenantId', 'status']) +@Index(['tenantId', 'projectId']) +export class Document { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Identificacion + @Column({ name: 'document_code', length: 100 }) + documentCode!: string; + + @Column({ length: 500 }) + title!: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + // Clasificacion + @Column({ name: 'category_id', type: 'uuid', nullable: true }) + categoryId?: string; + + @ManyToOne(() => DocumentCategory, { nullable: true }) + @JoinColumn({ name: 'category_id' }) + category?: DocumentCategory; + + @Column({ + name: 'document_type', + type: 'enum', + enum: ['plan', 'specification', 'contract', 'permit', 'report', 'photograph', 'drawing', 'manual', 'procedure', 'form', 'correspondence', 'invoice', 'estimate', 'other'], + enumName: 'document_type', + }) + documentType!: DocumentType; + + @Column({ + type: 'enum', + enum: ['draft', 'pending_review', 'in_review', 'approved', 'rejected', 'obsolete', 'archived'], + enumName: 'document_status', + default: 'draft', + }) + status!: DocumentStatus; + + @Column({ + name: 'access_level', + type: 'enum', + enum: ['public', 'internal', 'confidential', 'restricted'], + enumName: 'access_level', + default: 'internal', + }) + accessLevel!: AccessLevel; + + // Version actual + @Column({ name: 'current_version_id', type: 'uuid', nullable: true }) + currentVersionId?: string; + + @Column({ name: 'current_version_number', length: 20, default: '1.0' }) + currentVersionNumber!: string; + + // Proyecto/Contexto + @Column({ name: 'project_id', type: 'uuid', nullable: true }) + projectId?: string; + + @Column({ name: 'project_code', length: 50, nullable: true }) + projectCode?: string; + + @Column({ name: 'project_name', length: 255, nullable: true }) + projectName?: string; + + // Metadatos del documento + @Column({ length: 255, nullable: true }) + author?: string; + + @Column({ type: 'varchar', array: true, nullable: true }) + keywords?: string[]; + + @Column({ type: 'varchar', array: true, nullable: true }) + tags?: string[]; + + // Fechas importantes + @Column({ name: 'document_date', type: 'date', nullable: true }) + documentDate?: Date; + + @Column({ name: 'effective_date', type: 'date', nullable: true }) + effectiveDate?: Date; + + @Column({ name: 'expiry_date', type: 'date', nullable: true }) + expiryDate?: Date; + + @Column({ name: 'review_date', type: 'date', nullable: true }) + reviewDate?: Date; + + // Origen + @Column({ length: 100, nullable: true }) + source?: string; + + @Column({ name: 'external_reference', length: 255, nullable: true }) + externalReference?: string; + + @Column({ name: 'original_filename', length: 500, nullable: true }) + originalFilename?: string; + + // Relaciones de documentos + @Column({ name: 'parent_document_id', type: 'uuid', nullable: true }) + parentDocumentId?: string; + + @ManyToOne(() => Document, { nullable: true }) + @JoinColumn({ name: 'parent_document_id' }) + parentDocument?: Document; + + @Column({ name: 'related_documents', type: 'uuid', array: true, nullable: true }) + relatedDocuments?: string[]; + + // Flujo de aprobacion + @Column({ name: 'requires_approval', type: 'boolean', default: false }) + requiresApproval!: boolean; + + @Column({ name: 'current_workflow_id', type: 'uuid', nullable: true }) + currentWorkflowId?: string; + + @Column({ name: 'approved_by_id', type: 'uuid', nullable: true }) + approvedById?: string; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt?: Date; + + // Estadisticas + @Column({ name: 'view_count', type: 'int', default: 0 }) + viewCount!: number; + + @Column({ name: 'download_count', type: 'int', default: 0 }) + downloadCount!: number; + + @Column({ name: 'last_accessed_at', type: 'timestamptz', nullable: true }) + lastAccessedAt?: Date; + + // Flags + @Column({ name: 'is_template', type: 'boolean', default: false }) + isTemplate!: boolean; + + @Column({ name: 'is_locked', type: 'boolean', default: false }) + isLocked!: boolean; + + @Column({ name: 'locked_by_id', type: 'uuid', nullable: true }) + lockedById?: string; + + @Column({ name: 'locked_at', type: 'timestamptz', nullable: true }) + lockedAt?: Date; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ name: 'custom_fields', type: 'jsonb', nullable: true }) + customFields?: Record; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Relaciones + @OneToMany(() => DocumentVersion, (version) => version.document) + versions?: DocumentVersion[]; + + @OneToMany(() => Annotation, (annotation) => annotation.document) + annotations?: Annotation[]; + + // Auditoria + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/documents/entities/index.ts b/src/modules/documents/entities/index.ts new file mode 100644 index 0000000..622807f --- /dev/null +++ b/src/modules/documents/entities/index.ts @@ -0,0 +1,16 @@ +/** + * Documents Entities Index + * @module Documents (MAE-016) + */ + +export * from './document-category.entity'; +export * from './document.entity'; +export * from './document-version.entity'; +export * from './document-permission.entity'; +export * from './approval-workflow.entity'; +export * from './approval-instance.entity'; +export * from './approval-step.entity'; +export * from './approval-action.entity'; +export * from './annotation.entity'; +export * from './access-log.entity'; +export * from './document-share.entity'; diff --git a/src/modules/documents/index.ts b/src/modules/documents/index.ts new file mode 100644 index 0000000..99d2628 --- /dev/null +++ b/src/modules/documents/index.ts @@ -0,0 +1,9 @@ +/** + * Documents Module + * Sistema de Gestion Documental (MAE-016) + * + * ERP Construccion + */ + +export * from './entities'; +export * from './services'; diff --git a/src/modules/documents/services/document-version.service.ts b/src/modules/documents/services/document-version.service.ts new file mode 100644 index 0000000..680d936 --- /dev/null +++ b/src/modules/documents/services/document-version.service.ts @@ -0,0 +1,304 @@ +/** + * Document Version Service + * Logica de negocio para control de versiones. + * + * @module Documents (MAE-016) + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DocumentVersion, VersionStatus } from '../entities/document-version.entity'; +import { Document } from '../entities/document.entity'; + +export interface CreateVersionDto { + documentId: string; + versionNumber: string; + versionLabel?: string; + fileName: string; + fileExtension: string; + fileSizeBytes: number; + mimeType?: string; + checksumMd5?: string; + checksumSha256?: string; + storageProvider?: string; + storageBucket?: string; + storageKey: string; + storageUrl?: string; + thumbnailUrl?: string; + previewUrl?: string; + pageCount?: number; + changeSummary?: string; + changeType?: string; + uploadSource?: string; +} + +@Injectable() +export class DocumentVersionService { + constructor( + @InjectRepository(DocumentVersion) + private versionRepository: Repository, + @InjectRepository(Document) + private documentRepository: Repository, + ) {} + + /** + * Create a new document version + */ + async create( + tenantId: string, + dto: CreateVersionDto, + userId?: string, + userName?: string, + ): Promise { + // Get document to validate + const document = await this.documentRepository.findOne({ + where: { id: dto.documentId, tenantId }, + }); + + if (!document) { + throw new Error('Documento no encontrado'); + } + + // Mark previous current version as superseded + await this.versionRepository.update( + { documentId: dto.documentId, status: 'current' }, + { status: 'superseded', supersededAt: new Date() }, + ); + + // Create new version + const version = this.versionRepository.create({ + tenantId, + ...dto, + status: 'current', + uploadedById: userId, + uploadedByName: userName, + }); + + const savedVersion = await this.versionRepository.save(version); + + // Update document with current version + await this.documentRepository.update( + { id: dto.documentId }, + { + currentVersionId: savedVersion.id, + currentVersionNumber: dto.versionNumber, + }, + ); + + return savedVersion; + } + + /** + * Get all versions of a document + */ + async findByDocument( + tenantId: string, + documentId: string, + ): Promise { + return this.versionRepository.find({ + where: { tenantId, documentId }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Get current version of a document + */ + async findCurrentVersion( + tenantId: string, + documentId: string, + ): Promise { + return this.versionRepository.findOne({ + where: { tenantId, documentId, status: 'current' }, + }); + } + + /** + * Get version by ID + */ + async findById( + tenantId: string, + id: string, + ): Promise { + return this.versionRepository.findOne({ + where: { tenantId, id }, + relations: ['document'], + }); + } + + /** + * Set version as current + */ + async setAsCurrent( + tenantId: string, + versionId: string, + ): Promise { + const version = await this.findById(tenantId, versionId); + if (!version) { + return null; + } + + // Mark all other versions as superseded + await this.versionRepository.update( + { documentId: version.documentId, status: 'current' }, + { status: 'superseded', supersededAt: new Date() }, + ); + + // Set this version as current + version.status = 'current'; + version.supersededAt = undefined; + version.supersededByVersionId = undefined; + + const savedVersion = await this.versionRepository.save(version); + + // Update document + await this.documentRepository.update( + { id: version.documentId }, + { + currentVersionId: versionId, + currentVersionNumber: version.versionNumber, + }, + ); + + return savedVersion; + } + + /** + * Archive a version + */ + async archive( + tenantId: string, + versionId: string, + ): Promise { + const version = await this.findById(tenantId, versionId); + if (!version) { + return null; + } + + if (version.status === 'current') { + throw new Error('No se puede archivar la version actual'); + } + + version.status = 'archived'; + return this.versionRepository.save(version); + } + + /** + * Update version processing status + */ + async updateProcessingStatus( + tenantId: string, + versionId: string, + options: { + isProcessed?: boolean; + ocrText?: string; + extractedMetadata?: Record; + pageCount?: number; + pageDimensions?: Record; + }, + ): Promise { + const version = await this.findById(tenantId, versionId); + if (!version) { + return null; + } + + Object.assign(version, options); + return this.versionRepository.save(version); + } + + /** + * Generate next version number + */ + async generateNextVersionNumber( + tenantId: string, + documentId: string, + changeType: 'major' | 'minor' | 'patch' = 'minor', + ): Promise { + const currentVersion = await this.findCurrentVersion(tenantId, documentId); + + if (!currentVersion) { + return '1.0'; + } + + const parts = currentVersion.versionNumber.split('.').map(Number); + const major = parts[0] || 1; + const minor = parts[1] || 0; + const patch = parts[2] || 0; + + switch (changeType) { + case 'major': + return `${major + 1}.0`; + case 'minor': + return `${major}.${minor + 1}`; + case 'patch': + return `${major}.${minor}.${patch + 1}`; + default: + return `${major}.${minor + 1}`; + } + } + + /** + * Get version statistics + */ + async getStatistics( + tenantId: string, + documentId: string, + ): Promise<{ + totalVersions: number; + totalSizeBytes: number; + firstVersion: Date | null; + lastVersion: Date | null; + }> { + const stats = await this.versionRepository + .createQueryBuilder('v') + .select('COUNT(*)', 'totalVersions') + .addSelect('COALESCE(SUM(v.file_size_bytes), 0)', 'totalSizeBytes') + .addSelect('MIN(v.created_at)', 'firstVersion') + .addSelect('MAX(v.created_at)', 'lastVersion') + .where('v.tenant_id = :tenantId', { tenantId }) + .andWhere('v.document_id = :documentId', { documentId }) + .getRawOne(); + + return { + totalVersions: parseInt(stats?.totalVersions || '0', 10), + totalSizeBytes: parseInt(stats?.totalSizeBytes || '0', 10), + firstVersion: stats?.firstVersion || null, + lastVersion: stats?.lastVersion || null, + }; + } + + /** + * Compare two versions (metadata only) + */ + async compareVersions( + tenantId: string, + versionId1: string, + versionId2: string, + ): Promise<{ + version1: DocumentVersion | null; + version2: DocumentVersion | null; + differences: string[]; + }> { + const [v1, v2] = await Promise.all([ + this.findById(tenantId, versionId1), + this.findById(tenantId, versionId2), + ]); + + const differences: string[] = []; + + if (v1 && v2) { + if (v1.fileSizeBytes !== v2.fileSizeBytes) { + differences.push('Tamaño de archivo diferente'); + } + if (v1.pageCount !== v2.pageCount) { + differences.push('Número de páginas diferente'); + } + if (v1.checksumMd5 !== v2.checksumMd5) { + differences.push('Contenido modificado'); + } + } + + return { version1: v1, version2: v2, differences }; + } +} diff --git a/src/modules/documents/services/document.service.ts b/src/modules/documents/services/document.service.ts new file mode 100644 index 0000000..661490d --- /dev/null +++ b/src/modules/documents/services/document.service.ts @@ -0,0 +1,447 @@ +/** + * Document Service + * Logica de negocio para gestion de documentos. + * + * @module Documents (MAE-016) + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere, ILike, In } from 'typeorm'; +import { + Document, + DocumentType, + DocumentStatus, +} from '../entities/document.entity'; +import { DocumentCategory, AccessLevel } from '../entities/document-category.entity'; +import { DocumentVersion } from '../entities/document-version.entity'; +import { AccessLog } from '../entities/access-log.entity'; + +export interface CreateDocumentDto { + documentCode: string; + title: string; + description?: string; + categoryId?: string; + documentType: DocumentType; + accessLevel?: AccessLevel; + projectId?: string; + projectCode?: string; + projectName?: string; + author?: string; + keywords?: string[]; + tags?: string[]; + documentDate?: Date; + effectiveDate?: Date; + expiryDate?: Date; + source?: string; + externalReference?: string; + requiresApproval?: boolean; + customFields?: Record; + metadata?: Record; +} + +export interface UpdateDocumentDto extends Partial { + status?: DocumentStatus; + notes?: string; +} + +export interface DocumentFilters { + categoryId?: string; + documentType?: DocumentType; + status?: DocumentStatus; + projectId?: string; + search?: string; + tags?: string[]; + page?: number; + limit?: number; +} + +@Injectable() +export class DocumentService { + constructor( + @InjectRepository(Document) + private documentRepository: Repository, + @InjectRepository(DocumentCategory) + private categoryRepository: Repository, + @InjectRepository(DocumentVersion) + private versionRepository: Repository, + @InjectRepository(AccessLog) + private accessLogRepository: Repository, + ) {} + + /** + * Create a new document + */ + async create( + tenantId: string, + dto: CreateDocumentDto, + userId?: string, + ): Promise { + // Get category for default settings + let accessLevel = dto.accessLevel || 'internal'; + let requiresApproval = dto.requiresApproval || false; + + if (dto.categoryId) { + const category = await this.categoryRepository.findOne({ + where: { id: dto.categoryId, tenantId }, + }); + if (category) { + accessLevel = dto.accessLevel || category.defaultAccessLevel; + requiresApproval = dto.requiresApproval ?? category.requiresApproval; + } + } + + const document = this.documentRepository.create({ + tenantId, + ...dto, + accessLevel, + requiresApproval, + createdBy: userId, + }); + + return this.documentRepository.save(document); + } + + /** + * Find document by ID + */ + async findById(tenantId: string, id: string): Promise { + return this.documentRepository.findOne({ + where: { id, tenantId }, + relations: ['category'], + }); + } + + /** + * Find document by code + */ + async findByCode(tenantId: string, code: string): Promise { + return this.documentRepository.findOne({ + where: { documentCode: code, tenantId }, + relations: ['category'], + }); + } + + /** + * Find all documents with filters + */ + async findAll( + tenantId: string, + filters: DocumentFilters, + ): Promise<{ data: Document[]; total: number; page: number; limit: number }> { + const page = filters.page || 1; + const limit = filters.limit || 20; + const skip = (page - 1) * limit; + + const queryBuilder = this.documentRepository + .createQueryBuilder('doc') + .leftJoinAndSelect('doc.category', 'category') + .where('doc.tenant_id = :tenantId', { tenantId }) + .andWhere('doc.deleted_at IS NULL'); + + if (filters.categoryId) { + queryBuilder.andWhere('doc.category_id = :categoryId', { + categoryId: filters.categoryId, + }); + } + + if (filters.documentType) { + queryBuilder.andWhere('doc.document_type = :documentType', { + documentType: filters.documentType, + }); + } + + if (filters.status) { + queryBuilder.andWhere('doc.status = :status', { status: filters.status }); + } + + if (filters.projectId) { + queryBuilder.andWhere('doc.project_id = :projectId', { + projectId: filters.projectId, + }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(doc.title ILIKE :search OR doc.document_code ILIKE :search OR doc.description ILIKE :search)', + { search: `%${filters.search}%` }, + ); + } + + if (filters.tags && filters.tags.length > 0) { + queryBuilder.andWhere('doc.tags && :tags', { tags: filters.tags }); + } + + const [data, total] = await queryBuilder + .orderBy('doc.created_at', 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + /** + * Update a document + */ + async update( + tenantId: string, + id: string, + dto: UpdateDocumentDto, + userId?: string, + ): Promise { + const document = await this.findById(tenantId, id); + if (!document) { + return null; + } + + Object.assign(document, dto, { updatedBy: userId }); + return this.documentRepository.save(document); + } + + /** + * Soft delete a document + */ + async delete(tenantId: string, id: string): Promise { + const result = await this.documentRepository.update( + { id, tenantId }, + { deletedAt: new Date() }, + ); + return (result.affected ?? 0) > 0; + } + + /** + * Change document status + */ + async changeStatus( + tenantId: string, + id: string, + status: DocumentStatus, + userId?: string, + ): Promise { + const document = await this.findById(tenantId, id); + if (!document) { + return null; + } + + document.status = status; + document.updatedBy = userId; + + if (status === 'approved') { + document.approvedById = userId; + document.approvedAt = new Date(); + } + + return this.documentRepository.save(document); + } + + /** + * Lock a document for editing + */ + async lock( + tenantId: string, + id: string, + userId: string, + ): Promise { + const document = await this.findById(tenantId, id); + if (!document) { + return null; + } + + if (document.isLocked && document.lockedById !== userId) { + throw new Error('Documento bloqueado por otro usuario'); + } + + document.isLocked = true; + document.lockedById = userId; + document.lockedAt = new Date(); + + return this.documentRepository.save(document); + } + + /** + * Unlock a document + */ + async unlock( + tenantId: string, + id: string, + userId: string, + ): Promise { + const document = await this.findById(tenantId, id); + if (!document) { + return null; + } + + if (document.lockedById !== userId) { + throw new Error('Solo el usuario que bloqueó puede desbloquear'); + } + + document.isLocked = false; + document.lockedById = undefined; + document.lockedAt = undefined; + + return this.documentRepository.save(document); + } + + /** + * Log document access + */ + async logAccess( + tenantId: string, + documentId: string, + userId: string, + action: string, + options?: { + versionId?: string; + userName?: string; + userIp?: string; + userAgent?: string; + sessionId?: string; + requestSource?: string; + actionDetails?: Record; + }, + ): Promise { + const log = this.accessLogRepository.create({ + tenantId, + documentId, + userId, + action, + versionId: options?.versionId, + userName: options?.userName, + userIp: options?.userIp, + userAgent: options?.userAgent, + sessionId: options?.sessionId, + requestSource: options?.requestSource, + actionDetails: options?.actionDetails, + }); + + return this.accessLogRepository.save(log); + } + + /** + * Get document access history + */ + async getAccessHistory( + tenantId: string, + documentId: string, + limit: number = 50, + ): Promise { + return this.accessLogRepository.find({ + where: { tenantId, documentId }, + order: { accessedAt: 'DESC' }, + take: limit, + }); + } + + /** + * Get documents by project + */ + async findByProject( + tenantId: string, + projectId: string, + documentType?: DocumentType, + ): Promise { + const where: FindOptionsWhere = { tenantId, projectId }; + if (documentType) { + where.documentType = documentType; + } + + return this.documentRepository.find({ + where, + relations: ['category'], + order: { createdAt: 'DESC' }, + }); + } + + /** + * Generate unique document code + */ + async generateDocumentCode( + tenantId: string, + prefix: string = 'DOC', + ): Promise { + const year = new Date().getFullYear(); + const codePrefix = `${prefix}-${year}-`; + + const lastDoc = await this.documentRepository + .createQueryBuilder('doc') + .where('doc.tenant_id = :tenantId', { tenantId }) + .andWhere('doc.document_code LIKE :prefix', { prefix: `${codePrefix}%` }) + .orderBy('doc.document_code', 'DESC') + .getOne(); + + let nextNumber = 1; + if (lastDoc) { + const match = lastDoc.documentCode.match(/\d{5}$/); + if (match) { + nextNumber = parseInt(match[0], 10) + 1; + } + } + + return `${codePrefix}${nextNumber.toString().padStart(5, '0')}`; + } + + /** + * Get document statistics + */ + async getStatistics( + tenantId: string, + projectId?: string, + ): Promise<{ + total: number; + byStatus: Record; + byType: Record; + recentUploads: number; + }> { + const baseWhere: FindOptionsWhere = { tenantId }; + if (projectId) { + baseWhere.projectId = projectId; + } + + const [total, byStatusRaw, byTypeRaw] = await Promise.all([ + this.documentRepository.count({ where: baseWhere }), + this.documentRepository + .createQueryBuilder('doc') + .select('doc.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('doc.tenant_id = :tenantId', { tenantId }) + .andWhere(projectId ? 'doc.project_id = :projectId' : '1=1', { projectId }) + .andWhere('doc.deleted_at IS NULL') + .groupBy('doc.status') + .getRawMany(), + this.documentRepository + .createQueryBuilder('doc') + .select('doc.document_type', 'type') + .addSelect('COUNT(*)', 'count') + .where('doc.tenant_id = :tenantId', { tenantId }) + .andWhere(projectId ? 'doc.project_id = :projectId' : '1=1', { projectId }) + .andWhere('doc.deleted_at IS NULL') + .groupBy('doc.document_type') + .getRawMany(), + ]); + + // Count documents uploaded in last 7 days + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + + const recentUploads = await this.documentRepository + .createQueryBuilder('doc') + .where('doc.tenant_id = :tenantId', { tenantId }) + .andWhere(projectId ? 'doc.project_id = :projectId' : '1=1', { projectId }) + .andWhere('doc.created_at >= :date', { date: sevenDaysAgo }) + .andWhere('doc.deleted_at IS NULL') + .getCount(); + + const byStatus: Record = {}; + byStatusRaw.forEach((row) => { + byStatus[row.status] = parseInt(row.count, 10); + }); + + const byType: Record = {}; + byTypeRaw.forEach((row) => { + byType[row.type] = parseInt(row.count, 10); + }); + + return { total, byStatus, byType, recentUploads }; + } +} diff --git a/src/modules/documents/services/index.ts b/src/modules/documents/services/index.ts new file mode 100644 index 0000000..80ff157 --- /dev/null +++ b/src/modules/documents/services/index.ts @@ -0,0 +1,7 @@ +/** + * Documents Services Index + * @module Documents (MAE-016) + */ + +export * from './document.service'; +export * from './document-version.service';