[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 <noreply@anthropic.com>
This commit is contained in:
parent
881e7e40f2
commit
22b9692e3a
76
src/modules/documents/entities/access-log.entity.ts
Normal file
76
src/modules/documents/entities/access-log.entity.ts
Normal file
@ -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<string, any>;
|
||||
|
||||
// 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;
|
||||
}
|
||||
133
src/modules/documents/entities/annotation.entity.ts
Normal file
133
src/modules/documents/entities/annotation.entity.ts
Normal file
@ -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<string, any>;
|
||||
|
||||
// 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;
|
||||
}
|
||||
76
src/modules/documents/entities/approval-action.entity.ts
Normal file
76
src/modules/documents/entities/approval-action.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
132
src/modules/documents/entities/approval-instance.entity.ts
Normal file
132
src/modules/documents/entities/approval-instance.entity.ts
Normal file
@ -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<string, any>;
|
||||
|
||||
// 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;
|
||||
}
|
||||
113
src/modules/documents/entities/approval-step.entity.ts
Normal file
113
src/modules/documents/entities/approval-step.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
102
src/modules/documents/entities/approval-workflow.entity.ts
Normal file
102
src/modules/documents/entities/approval-workflow.entity.ts
Normal file
@ -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<string, any>;
|
||||
|
||||
// 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;
|
||||
}
|
||||
112
src/modules/documents/entities/document-category.entity.ts
Normal file
112
src/modules/documents/entities/document-category.entity.ts
Normal file
@ -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<string, any>;
|
||||
|
||||
// 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;
|
||||
}
|
||||
100
src/modules/documents/entities/document-permission.entity.ts
Normal file
100
src/modules/documents/entities/document-permission.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
110
src/modules/documents/entities/document-share.entity.ts
Normal file
110
src/modules/documents/entities/document-share.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
136
src/modules/documents/entities/document-version.entity.ts
Normal file
136
src/modules/documents/entities/document-version.entity.ts
Normal file
@ -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<string, any>;
|
||||
|
||||
// Paginas
|
||||
@Column({ name: 'page_count', type: 'int', nullable: true })
|
||||
pageCount?: number;
|
||||
|
||||
@Column({ name: 'page_dimensions', type: 'jsonb', nullable: true })
|
||||
pageDimensions?: Record<string, any>;
|
||||
|
||||
// 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;
|
||||
}
|
||||
234
src/modules/documents/entities/document.entity.ts
Normal file
234
src/modules/documents/entities/document.entity.ts
Normal file
@ -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<string, any>;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
// 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;
|
||||
}
|
||||
16
src/modules/documents/entities/index.ts
Normal file
16
src/modules/documents/entities/index.ts
Normal file
@ -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';
|
||||
9
src/modules/documents/index.ts
Normal file
9
src/modules/documents/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Documents Module
|
||||
* Sistema de Gestion Documental (MAE-016)
|
||||
*
|
||||
* ERP Construccion
|
||||
*/
|
||||
|
||||
export * from './entities';
|
||||
export * from './services';
|
||||
304
src/modules/documents/services/document-version.service.ts
Normal file
304
src/modules/documents/services/document-version.service.ts
Normal file
@ -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<DocumentVersion>,
|
||||
@InjectRepository(Document)
|
||||
private documentRepository: Repository<Document>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new document version
|
||||
*/
|
||||
async create(
|
||||
tenantId: string,
|
||||
dto: CreateVersionDto,
|
||||
userId?: string,
|
||||
userName?: string,
|
||||
): Promise<DocumentVersion> {
|
||||
// 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<DocumentVersion[]> {
|
||||
return this.versionRepository.find({
|
||||
where: { tenantId, documentId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current version of a document
|
||||
*/
|
||||
async findCurrentVersion(
|
||||
tenantId: string,
|
||||
documentId: string,
|
||||
): Promise<DocumentVersion | null> {
|
||||
return this.versionRepository.findOne({
|
||||
where: { tenantId, documentId, status: 'current' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version by ID
|
||||
*/
|
||||
async findById(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
): Promise<DocumentVersion | null> {
|
||||
return this.versionRepository.findOne({
|
||||
where: { tenantId, id },
|
||||
relations: ['document'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set version as current
|
||||
*/
|
||||
async setAsCurrent(
|
||||
tenantId: string,
|
||||
versionId: string,
|
||||
): Promise<DocumentVersion | null> {
|
||||
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<DocumentVersion | null> {
|
||||
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<string, any>;
|
||||
pageCount?: number;
|
||||
pageDimensions?: Record<string, any>;
|
||||
},
|
||||
): Promise<DocumentVersion | null> {
|
||||
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<string> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
447
src/modules/documents/services/document.service.ts
Normal file
447
src/modules/documents/services/document.service.ts
Normal file
@ -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<string, any>;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UpdateDocumentDto extends Partial<CreateDocumentDto> {
|
||||
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<Document>,
|
||||
@InjectRepository(DocumentCategory)
|
||||
private categoryRepository: Repository<DocumentCategory>,
|
||||
@InjectRepository(DocumentVersion)
|
||||
private versionRepository: Repository<DocumentVersion>,
|
||||
@InjectRepository(AccessLog)
|
||||
private accessLogRepository: Repository<AccessLog>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new document
|
||||
*/
|
||||
async create(
|
||||
tenantId: string,
|
||||
dto: CreateDocumentDto,
|
||||
userId?: string,
|
||||
): Promise<Document> {
|
||||
// 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<Document | null> {
|
||||
return this.documentRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['category'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find document by code
|
||||
*/
|
||||
async findByCode(tenantId: string, code: string): Promise<Document | null> {
|
||||
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<Document | null> {
|
||||
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<boolean> {
|
||||
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<Document | null> {
|
||||
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<Document | null> {
|
||||
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<Document | null> {
|
||||
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<string, any>;
|
||||
},
|
||||
): Promise<AccessLog> {
|
||||
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<AccessLog[]> {
|
||||
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<Document[]> {
|
||||
const where: FindOptionsWhere<Document> = { 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<string> {
|
||||
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<string, number>;
|
||||
byType: Record<string, number>;
|
||||
recentUploads: number;
|
||||
}> {
|
||||
const baseWhere: FindOptionsWhere<Document> = { 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<string, number> = {};
|
||||
byStatusRaw.forEach((row) => {
|
||||
byStatus[row.status] = parseInt(row.count, 10);
|
||||
});
|
||||
|
||||
const byType: Record<string, number> = {};
|
||||
byTypeRaw.forEach((row) => {
|
||||
byType[row.type] = parseInt(row.count, 10);
|
||||
});
|
||||
|
||||
return { total, byStatus, byType, recentUploads };
|
||||
}
|
||||
}
|
||||
7
src/modules/documents/services/index.ts
Normal file
7
src/modules/documents/services/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Documents Services Index
|
||||
* @module Documents (MAE-016)
|
||||
*/
|
||||
|
||||
export * from './document.service';
|
||||
export * from './document-version.service';
|
||||
Loading…
Reference in New Issue
Block a user