diff --git a/src/modules/documents/controllers/document.controller.ts b/src/modules/documents/controllers/document.controller.ts new file mode 100644 index 0000000..7d20dfc --- /dev/null +++ b/src/modules/documents/controllers/document.controller.ts @@ -0,0 +1,399 @@ +/** + * DocumentController - REST API for document management + * + * Endpoints para gestión documental. + * + * @module Documents (MAE-016) + * @routes /api/documents + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { DocumentService, DocumentFilters } from '../services/document.service'; +import { DocumentVersionService } from '../services/document-version.service'; +import { Document } from '../entities/document.entity'; +import { DocumentCategory } from '../entities/document-category.entity'; +import { DocumentVersion } from '../entities/document-version.entity'; +import { AccessLog } from '../entities/access-log.entity'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +export function createDocumentController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const documentRepo = dataSource.getRepository(Document); + const categoryRepo = dataSource.getRepository(DocumentCategory); + const versionRepo = dataSource.getRepository(DocumentVersion); + const accessLogRepo = dataSource.getRepository(AccessLog); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Services + const documentService = new DocumentService(documentRepo, categoryRepo, accessLogRepo); + const versionService = new DocumentVersionService(versionRepo, documentRepo); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper for service context + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /api/documents + * List documents with filters + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + const filters: DocumentFilters = {}; + if (req.query.categoryId) filters.categoryId = req.query.categoryId as string; + if (req.query.documentType) filters.documentType = req.query.documentType as DocumentFilters['documentType']; + if (req.query.status) filters.status = req.query.status as DocumentFilters['status']; + if (req.query.projectId) filters.projectId = req.query.projectId as string; + if (req.query.search) filters.search = req.query.search as string; + if (req.query.tags) filters.tags = (req.query.tags as string).split(','); + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + filters.page = page; + filters.limit = limit; + + const result = await documentService.findAll(ctx.tenantId, filters); + res.status(200).json({ success: true, data: result.data, pagination: { total: result.total, page: result.page, limit: result.limit } }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/documents/statistics + * Get document statistics + */ + router.get('/statistics', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const projectId = req.query.projectId as string | undefined; + + const stats = await documentService.getStatistics(ctx.tenantId, projectId); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/documents/generate-code + * Generate unique document code + */ + router.get('/generate-code', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const prefix = req.query.prefix as string || 'DOC'; + + const code = await documentService.generateDocumentCode(ctx.tenantId, prefix); + res.status(200).json({ success: true, data: { code } }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/documents/project/:projectId + * Get documents by project + */ + router.get('/project/:projectId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const documentType = req.query.documentType as any; + + const documents = await documentService.findByProject(ctx.tenantId, req.params.projectId, documentType); + res.status(200).json({ success: true, data: documents }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/documents/:id + * Get document by ID + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + const document = await documentService.findById(ctx.tenantId, req.params.id); + if (!document) { + res.status(404).json({ error: 'Not Found', message: 'Document not found' }); + return; + } + + // Log access + await documentService.logAccess(ctx.tenantId, req.params.id, ctx.userId || '', 'view', { + userIp: req.ip, + userAgent: req.headers['user-agent'], + }); + + res.status(200).json({ success: true, data: document }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/documents/:id/access-history + * Get document access history + */ + router.get('/:id/access-history', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const limit = parseInt(req.query.limit as string) || 50; + + const history = await documentService.getAccessHistory(ctx.tenantId, req.params.id, limit); + res.status(200).json({ success: true, data: history }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/documents/:id/versions + * Get document versions + */ + router.get('/:id/versions', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + const versions = await versionService.findByDocument(ctx.tenantId, req.params.id); + res.status(200).json({ success: true, data: versions }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/documents/:id/versions/statistics + * Get version statistics + */ + router.get('/:id/versions/statistics', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + const stats = await versionService.getStatistics(ctx.tenantId, req.params.id); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/documents + * Create new document + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'documents', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + const document = await documentService.create(ctx.tenantId, req.body, ctx.userId); + res.status(201).json({ success: true, data: document }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/documents/:id/versions + * Create new document version + */ + router.post('/:id/versions', authMiddleware.authenticate, authMiddleware.authorize('admin', 'documents', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + const version = await versionService.create( + ctx.tenantId, + { ...req.body, documentId: req.params.id }, + ctx.userId, + ); + + // Log upload + await documentService.logAccess(ctx.tenantId, req.params.id, ctx.userId || '', 'upload', { + versionId: version.id, + userIp: req.ip, + userAgent: req.headers['user-agent'], + }); + + res.status(201).json({ success: true, data: version }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /api/documents/:id + * Update document + */ + router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'documents', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + const document = await documentService.update(ctx.tenantId, req.params.id, req.body, ctx.userId); + if (!document) { + res.status(404).json({ error: 'Not Found', message: 'Document not found' }); + return; + } + + res.status(200).json({ success: true, data: document }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/documents/:id/status + * Change document status + */ + router.post('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'documents', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + const document = await documentService.changeStatus(ctx.tenantId, req.params.id, req.body.status, ctx.userId); + if (!document) { + res.status(404).json({ error: 'Not Found', message: 'Document not found' }); + return; + } + + res.status(200).json({ success: true, data: document }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/documents/:id/lock + * Lock document for editing + */ + router.post('/:id/lock', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + if (!ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'User ID required' }); + return; + } + + const document = await documentService.lock(ctx.tenantId, req.params.id, ctx.userId); + if (!document) { + res.status(404).json({ error: 'Not Found', message: 'Document not found' }); + return; + } + + res.status(200).json({ success: true, data: document }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/documents/:id/unlock + * Unlock document + */ + router.post('/:id/unlock', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + if (!ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'User ID required' }); + return; + } + + const document = await documentService.unlock(ctx.tenantId, req.params.id, ctx.userId); + if (!document) { + res.status(404).json({ error: 'Not Found', message: 'Document not found' }); + return; + } + + res.status(200).json({ success: true, data: document }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/documents/versions/:versionId/set-current + * Set version as current + */ + router.post('/versions/:versionId/set-current', authMiddleware.authenticate, authMiddleware.authorize('admin', 'documents'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + const version = await versionService.setAsCurrent(ctx.tenantId, req.params.versionId); + if (!version) { + res.status(404).json({ error: 'Not Found', message: 'Version not found' }); + return; + } + + res.status(200).json({ success: true, data: version }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/documents/versions/:versionId/archive + * Archive a version + */ + router.post('/versions/:versionId/archive', authMiddleware.authenticate, authMiddleware.authorize('admin', 'documents'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + const version = await versionService.archive(ctx.tenantId, req.params.versionId); + if (!version) { + res.status(404).json({ error: 'Not Found', message: 'Version not found' }); + return; + } + + res.status(200).json({ success: true, data: version }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /api/documents/:id + * Soft delete document + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + const deleted = await documentService.delete(ctx.tenantId, req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Document not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/src/modules/documents/controllers/index.ts b/src/modules/documents/controllers/index.ts new file mode 100644 index 0000000..2f16959 --- /dev/null +++ b/src/modules/documents/controllers/index.ts @@ -0,0 +1,6 @@ +/** + * Documents Controllers Index + * @module Documents (MAE-016) + */ + +export * from './document.controller'; diff --git a/src/modules/documents/services/document-version.service.ts b/src/modules/documents/services/document-version.service.ts index 680d936..796c797 100644 --- a/src/modules/documents/services/document-version.service.ts +++ b/src/modules/documents/services/document-version.service.ts @@ -5,10 +5,8 @@ * @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 { DocumentVersion } from '../entities/document-version.entity'; import { Document } from '../entities/document.entity'; export interface CreateVersionDto { @@ -33,13 +31,10 @@ export interface CreateVersionDto { uploadSource?: string; } -@Injectable() export class DocumentVersionService { constructor( - @InjectRepository(DocumentVersion) - private versionRepository: Repository, - @InjectRepository(Document) - private documentRepository: Repository, + private readonly versionRepository: Repository, + private readonly documentRepository: Repository, ) {} /** diff --git a/src/modules/documents/services/document.service.ts b/src/modules/documents/services/document.service.ts index 661490d..5f9bada 100644 --- a/src/modules/documents/services/document.service.ts +++ b/src/modules/documents/services/document.service.ts @@ -5,16 +5,13 @@ * @module Documents (MAE-016) */ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, FindOptionsWhere, ILike, In } from 'typeorm'; +import { Repository, FindOptionsWhere } 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 { @@ -56,17 +53,11 @@ export interface DocumentFilters { limit?: number; } -@Injectable() export class DocumentService { constructor( - @InjectRepository(Document) - private documentRepository: Repository, - @InjectRepository(DocumentCategory) - private categoryRepository: Repository, - @InjectRepository(DocumentVersion) - private versionRepository: Repository, - @InjectRepository(AccessLog) - private accessLogRepository: Repository, + private readonly documentRepository: Repository, + private readonly categoryRepository: Repository, + private readonly accessLogRepository: Repository, ) {} /**