[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:
Adrian Flores Cortes 2026-01-25 08:22:49 -06:00
parent 881e7e40f2
commit 22b9692e3a
16 changed files with 2107 additions and 0 deletions

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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';

View File

@ -0,0 +1,9 @@
/**
* Documents Module
* Sistema de Gestion Documental (MAE-016)
*
* ERP Construccion
*/
export * from './entities';
export * from './services';

View 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 };
}
}

View 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 };
}
}

View File

@ -0,0 +1,7 @@
/**
* Documents Services Index
* @module Documents (MAE-016)
*/
export * from './document.service';
export * from './document-version.service';