From 99fadef0ba4db0c2476660d0bb5b089716b6b5a2 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 07:05:18 -0600 Subject: [PATCH] [SPRINT-2] feat: Complete Quality module controllers - Add non-conformity.controller.ts with CRUD endpoints - Add corrective-action.controller.ts with workflow management - Add protocolo-calidad.controller.ts for quality protocols - Update inspection.controller.ts and exports index Co-Authored-By: Claude Opus 4.5 --- .../corrective-action.controller.ts | 521 +++++++++++++ src/modules/quality/controllers/index.ts | 19 +- .../controllers/inspection.controller.ts | 76 ++ .../controllers/non-conformity.controller.ts | 687 ++++++++++++++++++ .../protocolo-calidad.controller.ts | 617 ++++++++++++++++ 5 files changed, 1919 insertions(+), 1 deletion(-) create mode 100644 src/modules/quality/controllers/corrective-action.controller.ts create mode 100644 src/modules/quality/controllers/non-conformity.controller.ts create mode 100644 src/modules/quality/controllers/protocolo-calidad.controller.ts diff --git a/src/modules/quality/controllers/corrective-action.controller.ts b/src/modules/quality/controllers/corrective-action.controller.ts new file mode 100644 index 0000000..3d648ca --- /dev/null +++ b/src/modules/quality/controllers/corrective-action.controller.ts @@ -0,0 +1,521 @@ +/** + * CorrectiveActionController - REST API for corrective actions (Acciones Correctivas CAPA) + * + * Endpoints para gestion de acciones correctivas y preventivas (CAPA). + * + * @module Quality (MAI-009) + * @routes /api/acciones-correctivas + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + CorrectiveActionService, + CorrectiveActionFilters, + CreateCorrectiveActionDto, + UpdateCorrectiveActionDto, + CompleteActionDto, + VerifyActionDto, +} from '../services/corrective-action.service'; +import { CorrectiveAction, ActionType, ActionStatus } from '../entities/corrective-action.entity'; +import { NonConformity } from '../entities/non-conformity.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; +} + +/** + * Creates the CorrectiveAction controller with all REST endpoints + * @param dataSource - TypeORM DataSource for database operations + * @returns Express Router with all endpoints configured + */ +export function createCorrectiveActionController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const actionRepo = dataSource.getRepository(CorrectiveAction); + const ncRepo = dataSource.getRepository(NonConformity); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Services + const service = new CorrectiveActionService(actionRepo, ncRepo); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + /** + * Helper to extract service context from request + */ + 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/acciones-correctivas + * List corrective actions with filters and pagination + * + * Query params: + * - nonConformityId: Filter by non-conformity + * - responsibleId: Filter by responsible user + * - actionType: Filter by type (corrective, preventive, improvement) + * - status: Filter by status (pending, in_progress, completed, verified) + * - effectivenessVerified: Filter by effectiveness verification + * - dueDateFrom/To: Due date range filters + * - search: Search in description + * - page: Page number (default: 1) + * - limit: Items per page (default: 20, max: 100) + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: CorrectiveActionFilters = {}; + if (req.query.nonConformityId) filters.nonConformityId = req.query.nonConformityId as string; + if (req.query.responsibleId) filters.responsibleId = req.query.responsibleId as string; + if (req.query.actionType) filters.actionType = req.query.actionType as ActionType; + if (req.query.status) filters.status = req.query.status as ActionStatus; + if (req.query.effectivenessVerified !== undefined) { + filters.effectivenessVerified = req.query.effectivenessVerified === 'true'; + } + if (req.query.dueDateFrom) filters.dueDateFrom = new Date(req.query.dueDateFrom as string); + if (req.query.dueDateTo) filters.dueDateTo = new Date(req.query.dueDateTo as string); + if (req.query.search) filters.search = req.query.search as string; + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await service.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: Math.ceil(result.total / result.limit), + }, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/acciones-correctivas/vencidas + * Get overdue corrective actions + */ + router.get('/vencidas', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const overdueActions = await service.getOverdueActions(getContext(req)); + + res.status(200).json({ + success: true, + data: overdueActions, + count: overdueActions.length, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/acciones-correctivas/por-responsable/:responsibleId + * Get corrective action statistics by responsible user + */ + router.get('/por-responsable/:responsibleId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const stats = await service.getStatsByResponsible(getContext(req), req.params.responsibleId); + + res.status(200).json({ + success: true, + data: stats, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/acciones-correctivas/por-no-conformidad/:nonConformityId + * Get corrective actions for a specific non-conformity + */ + router.get('/por-no-conformidad/:nonConformityId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const actions = await service.findByNonConformity(getContext(req), req.params.nonConformityId); + + res.status(200).json({ + success: true, + data: actions, + count: actions.length, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/acciones-correctivas/:id + * Get corrective action detail with relations + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const action = await service.findWithDetails(getContext(req), req.params.id); + if (!action) { + res.status(404).json({ error: 'Not Found', message: 'Accion correctiva no encontrada' }); + return; + } + + res.status(200).json({ + success: true, + data: action, + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/acciones-correctivas + * Create new corrective action + * + * Body: + * - nonConformityId: (required) UUID of the non-conformity + * - actionType: (required) 'corrective' | 'preventive' | 'improvement' + * - description: (required) Description text + * - responsibleId: (required) UUID of responsible user + * - dueDate: (required) Due date + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + // Validate required fields + const { nonConformityId, actionType, description, responsibleId, dueDate } = req.body; + if (!nonConformityId || !actionType || !description || !responsibleId || !dueDate) { + res.status(400).json({ + error: 'Bad Request', + message: 'nonConformityId, actionType, description, responsibleId y dueDate son requeridos', + }); + return; + } + + // Validate actionType enum + const validTypes: ActionType[] = ['corrective', 'preventive', 'improvement']; + if (!validTypes.includes(actionType)) { + res.status(400).json({ + error: 'Bad Request', + message: `Tipo de accion invalido. Valores validos: ${validTypes.join(', ')}`, + }); + return; + } + + const dto: CreateCorrectiveActionDto = { + nonConformityId, + actionType, + description, + responsibleId, + dueDate: new Date(dueDate), + }; + + const action = await service.create(getContext(req), dto); + + res.status(201).json({ + success: true, + data: action, + message: 'Accion correctiva creada exitosamente', + }); + } catch (error: any) { + if (error.message?.includes('not found')) { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message?.includes('Cannot add')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PUT /api/acciones-correctivas/:id + * Update corrective action + */ + router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateCorrectiveActionDto = {}; + if (req.body.actionType !== undefined) dto.actionType = req.body.actionType; + if (req.body.description !== undefined) dto.description = req.body.description; + if (req.body.responsibleId !== undefined) dto.responsibleId = req.body.responsibleId; + if (req.body.dueDate !== undefined) dto.dueDate = new Date(req.body.dueDate); + + const action = await service.update(getContext(req), req.params.id, dto); + if (!action) { + res.status(404).json({ error: 'Not Found', message: 'Accion correctiva no encontrada' }); + return; + } + + res.status(200).json({ + success: true, + data: action, + message: 'Accion correctiva actualizada exitosamente', + }); + } catch (error: any) { + if (error.message?.includes('Cannot update')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /api/acciones-correctivas/:id/iniciar + * Start work on corrective action + */ + router.post('/:id/iniciar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const action = await service.startWork(getContext(req), req.params.id); + if (!action) { + res.status(404).json({ error: 'Not Found', message: 'Accion correctiva no encontrada' }); + return; + } + + res.status(200).json({ + success: true, + data: action, + message: 'Trabajo iniciado en accion correctiva', + }); + } catch (error: any) { + if (error.message?.includes('Can only start')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /api/acciones-correctivas/:id/completar + * Mark corrective action as completed + * + * Body: + * - completionNotes: (required) Notes about the completion + */ + router.post('/:id/completar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { completionNotes } = req.body; + if (!completionNotes) { + res.status(400).json({ error: 'Bad Request', message: 'completionNotes es requerido' }); + return; + } + + const dto: CompleteActionDto = { completionNotes }; + + const action = await service.complete(getContext(req), req.params.id, dto); + if (!action) { + res.status(404).json({ error: 'Not Found', message: 'Accion correctiva no encontrada' }); + return; + } + + res.status(200).json({ + success: true, + data: action, + message: 'Accion correctiva marcada como completada', + }); + } catch (error: any) { + if (error.message?.includes('Can only complete')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /api/acciones-correctivas/:id/verificar + * Verify effectiveness of corrective action + * + * Body: + * - effectivenessVerified: (required) boolean indicating if action was effective + * - verificationNotes: (optional) Notes about verification + */ + router.post('/:id/verificar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { effectivenessVerified, verificationNotes } = req.body; + if (effectivenessVerified === undefined) { + res.status(400).json({ error: 'Bad Request', message: 'effectivenessVerified es requerido' }); + return; + } + + const dto: VerifyActionDto = { + effectivenessVerified: Boolean(effectivenessVerified), + verificationNotes, + }; + + const action = await service.verify(getContext(req), req.params.id, dto); + if (!action) { + res.status(404).json({ error: 'Not Found', message: 'Accion correctiva no encontrada' }); + return; + } + + res.status(200).json({ + success: true, + data: action, + message: action.effectivenessVerified + ? 'Accion correctiva verificada como efectiva' + : 'Accion correctiva verificada como no efectiva', + }); + } catch (error: any) { + if (error.message?.includes('Can only verify')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /api/acciones-correctivas/:id/reabrir + * Reopen completed or verified corrective action + * + * Body: + * - reason: (required) Reason for reopening + */ + router.post('/:id/reabrir', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { reason } = req.body; + if (!reason) { + res.status(400).json({ error: 'Bad Request', message: 'reason es requerido' }); + return; + } + + const action = await service.reopen(getContext(req), req.params.id, reason); + if (!action) { + res.status(404).json({ error: 'Not Found', message: 'Accion correctiva no encontrada' }); + return; + } + + res.status(200).json({ + success: true, + data: action, + message: 'Accion correctiva reabierta exitosamente', + }); + } catch (error: any) { + if (error.message?.includes('Can only reopen')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * DELETE /api/acciones-correctivas/:id + * Delete pending corrective action + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await service.delete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Accion correctiva no encontrada' }); + return; + } + + res.status(200).json({ + success: true, + message: 'Accion correctiva eliminada exitosamente', + }); + } catch (error: any) { + if (error.message?.includes('Can only delete')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + return router; +} diff --git a/src/modules/quality/controllers/index.ts b/src/modules/quality/controllers/index.ts index e1c7c8f..aeb1583 100644 --- a/src/modules/quality/controllers/index.ts +++ b/src/modules/quality/controllers/index.ts @@ -1,8 +1,25 @@ /** * Quality Controllers Index - * @module Quality + * + * Barrel file exporting all quality module controllers. + * + * @module Quality (MAI-009) */ +// Inspection management export * from './inspection.controller'; + +// Post-sale tickets export * from './ticket.controller'; + +// Checklist management (English API) export { default as checklistController } from './checklist.controller'; + +// Non-conformity management +export * from './non-conformity.controller'; + +// Corrective actions (CAPA) +export * from './corrective-action.controller'; + +// Quality protocols (Spanish API for checklists) +export * from './protocolo-calidad.controller'; diff --git a/src/modules/quality/controllers/inspection.controller.ts b/src/modules/quality/controllers/inspection.controller.ts index c246f17..ad2b22b 100644 --- a/src/modules/quality/controllers/inspection.controller.ts +++ b/src/modules/quality/controllers/inspection.controller.ts @@ -254,5 +254,81 @@ export function createInspectionController(dataSource: DataSource): Router { } }); + /** + * GET /api/inspections/:id/checklist + * Get checklist associated with inspection + */ + router.get('/:id/checklist', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const inspection = await service.findWithDetails(getContext(req), req.params.id); + if (!inspection) { + res.status(404).json({ error: 'Not Found', message: 'Inspection not found' }); + return; + } + + res.status(200).json({ + success: true, + data: { + checklist: inspection.checklist, + results: inspection.results, + totalItems: inspection.totalItems, + passedItems: inspection.passedItems, + failedItems: inspection.failedItems, + passRate: inspection.passRate, + }, + }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /api/inspections/:id + * Update inspection + */ + router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const inspection = await service.findById(getContext(req), req.params.id); + if (!inspection) { + res.status(404).json({ error: 'Not Found', message: 'Inspection not found' }); + return; + } + + if (inspection.status !== 'pending') { + res.status(400).json({ error: 'Bad Request', message: 'Can only update pending inspections' }); + return; + } + + // Update allowed fields + if (req.body.inspectionDate) { + inspection.inspectionDate = new Date(req.body.inspectionDate); + } + if (req.body.inspectorId) { + inspection.inspectorId = req.body.inspectorId; + } + if (req.body.notes !== undefined) { + inspection.notes = req.body.notes; + } + inspection.updatedById = req.user?.sub || ''; + + const updated = await inspectionRepo.save(inspection); + res.status(200).json({ success: true, data: updated }); + } catch (error) { + next(error); + } + }); + return router; } diff --git a/src/modules/quality/controllers/non-conformity.controller.ts b/src/modules/quality/controllers/non-conformity.controller.ts new file mode 100644 index 0000000..b560324 --- /dev/null +++ b/src/modules/quality/controllers/non-conformity.controller.ts @@ -0,0 +1,687 @@ +/** + * NonConformityController - REST API for non-conformities (No Conformidades) + * + * Endpoints para gestion de no conformidades detectadas en inspecciones de calidad. + * + * @module Quality (MAI-009) + * @routes /api/no-conformidades + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + NonConformityService, + NonConformityFilters, + CreateNonConformityDto, + UpdateNonConformityDto, + CloseNonConformityDto, +} from '../services/non-conformity.service'; +import { + CorrectiveActionService, + CreateCorrectiveActionDto, +} from '../services/corrective-action.service'; +import { NonConformity, NCSeverity, NCStatus } from '../entities/non-conformity.entity'; +import { CorrectiveAction } from '../entities/corrective-action.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; +} + +/** + * Creates the NonConformity controller with all REST endpoints + * @param dataSource - TypeORM DataSource for database operations + * @returns Express Router with all endpoints configured + */ +export function createNonConformityController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const ncRepo = dataSource.getRepository(NonConformity); + const actionRepo = dataSource.getRepository(CorrectiveAction); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Services + const service = new NonConformityService(ncRepo, actionRepo); + const actionService = new CorrectiveActionService(actionRepo, ncRepo); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + /** + * Helper to extract service context from request + */ + 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/no-conformidades + * List non-conformities with filters and pagination + * + * Query params: + * - inspectionId: Filter by inspection + * - loteId: Filter by lote + * - contractorId: Filter by contractor + * - severity: Filter by severity (minor, major, critical) + * - status: Filter by status (open, in_progress, closed, verified) + * - category: Filter by category (partial match) + * - detectionDateFrom/To: Date range filters + * - dueDateFrom/To: Due date range filters + * - search: Search in ncNumber, description, category + * - page: Page number (default: 1) + * - limit: Items per page (default: 20, max: 100) + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: NonConformityFilters = {}; + if (req.query.inspectionId) filters.inspectionId = req.query.inspectionId as string; + if (req.query.loteId) filters.loteId = req.query.loteId as string; + if (req.query.contractorId) filters.contractorId = req.query.contractorId as string; + if (req.query.severity) filters.severity = req.query.severity as NCSeverity; + if (req.query.status) filters.status = req.query.status as NCStatus; + if (req.query.category) filters.category = req.query.category as string; + if (req.query.detectionDateFrom) filters.detectionDateFrom = new Date(req.query.detectionDateFrom as string); + if (req.query.detectionDateTo) filters.detectionDateTo = new Date(req.query.detectionDateTo as string); + if (req.query.dueDateFrom) filters.dueDateFrom = new Date(req.query.dueDateFrom as string); + if (req.query.dueDateTo) filters.dueDateTo = new Date(req.query.dueDateTo as string); + if (req.query.search) filters.search = req.query.search as string; + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await service.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: Math.ceil(result.total / result.limit), + }, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/no-conformidades/estadisticas + * Get statistics for non-conformities + */ + router.get('/estadisticas', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const stats = await service.getStatistics(getContext(req)); + + res.status(200).json({ + success: true, + data: stats, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/no-conformidades/vencidas + * Get overdue non-conformities + */ + router.get('/vencidas', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const overdueNCs = await service.getOverdueNCs(getContext(req)); + + res.status(200).json({ + success: true, + data: overdueNCs, + count: overdueNCs.length, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/no-conformidades/por-severidad + * Get open non-conformities count by severity + */ + router.get('/por-severidad', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const bySeverity = await service.getOpenBySeverity(getContext(req)); + + res.status(200).json({ + success: true, + data: bySeverity, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/no-conformidades/por-contratista/:contractorId + * Get non-conformity statistics by contractor + */ + router.get('/por-contratista/:contractorId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const stats = await service.getStatsByContractor(getContext(req), req.params.contractorId); + + res.status(200).json({ + success: true, + data: stats, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/no-conformidades/por-lote/:loteId + * Get non-conformities for a specific lote + */ + router.get('/por-lote/:loteId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const ncs = await service.findByLote(getContext(req), req.params.loteId); + + res.status(200).json({ + success: true, + data: ncs, + count: ncs.length, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/no-conformidades/por-inspeccion/:inspectionId + * Get non-conformities for a specific inspection + */ + router.get('/por-inspeccion/:inspectionId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const ncs = await service.findByInspection(getContext(req), req.params.inspectionId); + + res.status(200).json({ + success: true, + data: ncs, + count: ncs.length, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/no-conformidades/por-numero/:ncNumber + * Get non-conformity by number + */ + router.get('/por-numero/:ncNumber', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const nc = await service.findByNumber(getContext(req), req.params.ncNumber); + if (!nc) { + res.status(404).json({ error: 'Not Found', message: 'No conformidad no encontrada' }); + return; + } + + res.status(200).json({ + success: true, + data: nc, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/no-conformidades/:id + * Get non-conformity detail with relations + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const nc = await service.findWithDetails(getContext(req), req.params.id); + if (!nc) { + res.status(404).json({ error: 'Not Found', message: 'No conformidad no encontrada' }); + return; + } + + res.status(200).json({ + success: true, + data: nc, + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/no-conformidades + * Create new non-conformity + * + * Body: + * - loteId: (required) UUID of the lote + * - detectionDate: (required) Date of detection + * - category: (required) Category string + * - severity: (required) 'minor' | 'major' | 'critical' + * - description: (required) Description text + * - inspectionId: (optional) UUID of related inspection + * - rootCause: (optional) Root cause analysis + * - photoUrl: (optional) Evidence photo URL + * - contractorId: (optional) UUID of responsible contractor + * - dueDate: (optional) Due date (auto-calculated based on severity if not provided) + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + // Validate required fields + const { loteId, detectionDate, category, severity, description } = req.body; + if (!loteId || !detectionDate || !category || !severity || !description) { + res.status(400).json({ + error: 'Bad Request', + message: 'loteId, detectionDate, category, severity y description son requeridos', + }); + return; + } + + // Validate severity enum + const validSeverities: NCSeverity[] = ['minor', 'major', 'critical']; + if (!validSeverities.includes(severity)) { + res.status(400).json({ + error: 'Bad Request', + message: `Severidad invalida. Valores validos: ${validSeverities.join(', ')}`, + }); + return; + } + + const dto: CreateNonConformityDto = { + inspectionId: req.body.inspectionId, + loteId, + detectionDate: new Date(detectionDate), + category, + severity, + description, + rootCause: req.body.rootCause, + photoUrl: req.body.photoUrl, + contractorId: req.body.contractorId, + dueDate: req.body.dueDate ? new Date(req.body.dueDate) : undefined, + }; + + const nc = await service.create(getContext(req), dto); + + res.status(201).json({ + success: true, + data: nc, + message: 'No conformidad registrada exitosamente', + }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /api/no-conformidades/:id + * Update non-conformity + */ + router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateNonConformityDto = {}; + if (req.body.loteId !== undefined) dto.loteId = req.body.loteId; + if (req.body.category !== undefined) dto.category = req.body.category; + if (req.body.severity !== undefined) dto.severity = req.body.severity; + if (req.body.description !== undefined) dto.description = req.body.description; + if (req.body.rootCause !== undefined) dto.rootCause = req.body.rootCause; + if (req.body.photoUrl !== undefined) dto.photoUrl = req.body.photoUrl; + if (req.body.contractorId !== undefined) dto.contractorId = req.body.contractorId; + if (req.body.dueDate !== undefined) dto.dueDate = new Date(req.body.dueDate); + + const nc = await service.update(getContext(req), req.params.id, dto); + if (!nc) { + res.status(404).json({ error: 'Not Found', message: 'No conformidad no encontrada' }); + return; + } + + res.status(200).json({ + success: true, + data: nc, + message: 'No conformidad actualizada exitosamente', + }); + } catch (error: any) { + if (error.message?.includes('Cannot update')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /api/no-conformidades/:id/accion-correctiva + * Add corrective action to non-conformity + * + * Body: + * - actionType: (required) 'corrective' | 'preventive' | 'improvement' + * - description: (required) Action description + * - responsibleId: (required) UUID of responsible user + * - dueDate: (required) Due date for the action + */ + router.post('/:id/accion-correctiva', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { actionType, description, responsibleId, dueDate } = req.body; + if (!actionType || !description || !responsibleId || !dueDate) { + res.status(400).json({ + error: 'Bad Request', + message: 'actionType, description, responsibleId y dueDate son requeridos', + }); + return; + } + + const dto: CreateCorrectiveActionDto = { + nonConformityId: req.params.id, + actionType, + description, + responsibleId, + dueDate: new Date(dueDate), + }; + + const action = await actionService.create(getContext(req), dto); + + res.status(201).json({ + success: true, + data: action, + message: 'Accion correctiva agregada exitosamente', + }); + } catch (error: any) { + if (error.message?.includes('not found')) { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message?.includes('Cannot add')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /api/no-conformidades/:id/asignar-contratista + * Assign contractor to non-conformity + */ + router.post('/:id/asignar-contratista', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { contractorId } = req.body; + if (!contractorId) { + res.status(400).json({ error: 'Bad Request', message: 'contractorId es requerido' }); + return; + } + + const nc = await service.assignContractor(getContext(req), req.params.id, contractorId); + if (!nc) { + res.status(404).json({ error: 'Not Found', message: 'No conformidad no encontrada' }); + return; + } + + res.status(200).json({ + success: true, + data: nc, + message: 'Contratista asignado exitosamente', + }); + } catch (error: any) { + if (error.message?.includes('Cannot assign')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /api/no-conformidades/:id/causa-raiz + * Set root cause analysis + */ + router.post('/:id/causa-raiz', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { rootCause } = req.body; + if (!rootCause) { + res.status(400).json({ error: 'Bad Request', message: 'rootCause es requerido' }); + return; + } + + const nc = await service.setRootCause(getContext(req), req.params.id, rootCause); + if (!nc) { + res.status(404).json({ error: 'Not Found', message: 'No conformidad no encontrada' }); + return; + } + + res.status(200).json({ + success: true, + data: nc, + message: 'Causa raiz registrada exitosamente', + }); + } catch (error: any) { + if (error.message?.includes('Cannot modify')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /api/no-conformidades/:id/cerrar + * Close non-conformity + * + * Body: + * - closurePhotoUrl: (optional) URL of closure evidence photo + * - closureNotes: (optional) Closure notes + */ + router.post('/:id/cerrar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CloseNonConformityDto = { + closurePhotoUrl: req.body.closurePhotoUrl, + closureNotes: req.body.closureNotes, + }; + + const nc = await service.close(getContext(req), req.params.id, dto); + if (!nc) { + res.status(404).json({ error: 'Not Found', message: 'No conformidad no encontrada' }); + return; + } + + res.status(200).json({ + success: true, + data: nc, + message: 'No conformidad cerrada exitosamente', + }); + } catch (error: any) { + if (error.message?.includes('Can only close') || error.message?.includes('Cannot close')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /api/no-conformidades/:id/verificar + * Verify closed non-conformity + * + * Body: + * - verificationNotes: (optional) Verification notes + */ + router.post('/:id/verificar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto = { + verificationNotes: req.body.verificationNotes, + }; + + const nc = await service.verify(getContext(req), req.params.id, dto); + if (!nc) { + res.status(404).json({ error: 'Not Found', message: 'No conformidad no encontrada' }); + return; + } + + res.status(200).json({ + success: true, + data: nc, + message: 'No conformidad verificada exitosamente', + }); + } catch (error: any) { + if (error.message?.includes('Can only verify') || error.message?.includes('Cannot verify')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /api/no-conformidades/:id/reabrir + * Reopen closed or verified non-conformity + * + * Body: + * - reason: (required) Reason for reopening + */ + router.post('/:id/reabrir', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { reason } = req.body; + if (!reason) { + res.status(400).json({ error: 'Bad Request', message: 'reason es requerido' }); + return; + } + + const nc = await service.reopen(getContext(req), req.params.id, reason); + if (!nc) { + res.status(404).json({ error: 'Not Found', message: 'No conformidad no encontrada' }); + return; + } + + res.status(200).json({ + success: true, + data: nc, + message: 'No conformidad reabierta exitosamente', + }); + } catch (error: any) { + if (error.message?.includes('Can only reopen')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + return router; +} diff --git a/src/modules/quality/controllers/protocolo-calidad.controller.ts b/src/modules/quality/controllers/protocolo-calidad.controller.ts new file mode 100644 index 0000000..938d753 --- /dev/null +++ b/src/modules/quality/controllers/protocolo-calidad.controller.ts @@ -0,0 +1,617 @@ +/** + * ProtocoloCalidadController - REST API for quality protocols (Protocolos de Calidad) + * + * Wrapper controller que expone checklists como protocolos de calidad con endpoints en espanol. + * Un protocolo de calidad es esencialmente un checklist con su configuracion de items. + * + * @module Quality (MAI-009) + * @routes /api/protocolos-calidad + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + ChecklistService, + CreateChecklistDto, + UpdateChecklistDto, + ChecklistFilters, +} from '../services/checklist.service'; +import { Checklist, ChecklistStage } from '../entities/checklist.entity'; +import { ChecklistItem } from '../entities/checklist-item.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; +} + +/** + * Creates the ProtocoloCalidad controller with all REST endpoints + * @param dataSource - TypeORM DataSource for database operations + * @returns Express Router with all endpoints configured + */ +export function createProtocoloCalidadController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const checklistRepo = dataSource.getRepository(Checklist); + const itemRepo = dataSource.getRepository(ChecklistItem); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Services + const service = new ChecklistService(checklistRepo, itemRepo); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + /** + * Helper to extract service context from request + */ + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * Valid stages for quality protocols + */ + const VALID_STAGES: ChecklistStage[] = ['foundation', 'structure', 'installations', 'finishes', 'delivery', 'custom']; + + /** + * GET /api/protocolos-calidad + * List quality protocols (checklists) with filters and pagination + * + * Query params: + * - stage: Filter by construction stage + * - prototypeId: Filter by prototype + * - isActive: Filter active/inactive protocols + * - search: Search in name, code, description + * - page: Page number (default: 1) + * - limit: Items per page (default: 20) + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: ChecklistFilters = {}; + if (req.query.stage) filters.stage = req.query.stage as ChecklistStage; + if (req.query.prototypeId) filters.prototypeId = req.query.prototypeId as string; + if (req.query.isActive !== undefined) filters.isActive = req.query.isActive === 'true'; + if (req.query.search) filters.search = req.query.search as string; + filters.page = parseInt(req.query.page as string) || 1; + filters.limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await service.findAll(getContext(req), filters); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: Math.ceil(result.total / result.limit), + }, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/protocolos-calidad/estadisticas + * Get statistics for quality protocols + */ + router.get('/estadisticas', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const stats = await service.getStatistics(getContext(req)); + + res.status(200).json({ + success: true, + data: stats, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/protocolos-calidad/por-etapa/:stage + * Get protocols by construction stage + */ + router.get('/por-etapa/:stage', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const stage = req.params.stage as ChecklistStage; + if (!VALID_STAGES.includes(stage)) { + res.status(400).json({ + error: 'Bad Request', + message: `Etapa invalida. Valores validos: ${VALID_STAGES.join(', ')}`, + }); + return; + } + + const protocols = await service.findByStage(getContext(req), stage); + + res.status(200).json({ + success: true, + data: protocols, + count: protocols.length, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/protocolos-calidad/por-codigo/:code + * Get protocol by code + */ + router.get('/por-codigo/:code', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const protocol = await service.findByCode(getContext(req), req.params.code); + if (!protocol) { + res.status(404).json({ error: 'Not Found', message: 'Protocolo no encontrado' }); + return; + } + + res.status(200).json({ + success: true, + data: protocol, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/protocolos-calidad/:id + * Get protocol detail with checklist items + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const protocol = await service.findWithItems(getContext(req), req.params.id); + if (!protocol) { + res.status(404).json({ error: 'Not Found', message: 'Protocolo no encontrado' }); + return; + } + + res.status(200).json({ + success: true, + data: protocol, + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/protocolos-calidad + * Create new quality protocol + * + * Body: + * - code: (required) Unique protocol code + * - name: (required) Protocol name + * - stage: (required) Construction stage + * - description: (optional) Protocol description + * - prototypeId: (optional) Associated prototype UUID + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { code, name, stage, description, prototypeId } = req.body; + if (!code || !name || !stage) { + res.status(400).json({ + error: 'Bad Request', + message: 'code, name y stage son requeridos', + }); + return; + } + + if (!VALID_STAGES.includes(stage)) { + res.status(400).json({ + error: 'Bad Request', + message: `Etapa invalida. Valores validos: ${VALID_STAGES.join(', ')}`, + }); + return; + } + + const dto: CreateChecklistDto = { + code, + name, + stage, + description, + prototypeId, + }; + + const protocol = await service.create(getContext(req), dto); + + res.status(201).json({ + success: true, + data: protocol, + message: 'Protocolo de calidad creado exitosamente', + }); + } catch (error: any) { + if (error.message?.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PUT /api/protocolos-calidad/:id + * Update quality protocol + */ + router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateChecklistDto = {}; + if (req.body.name !== undefined) dto.name = req.body.name; + if (req.body.description !== undefined) dto.description = req.body.description; + if (req.body.stage !== undefined) { + if (!VALID_STAGES.includes(req.body.stage)) { + res.status(400).json({ + error: 'Bad Request', + message: `Etapa invalida. Valores validos: ${VALID_STAGES.join(', ')}`, + }); + return; + } + dto.stage = req.body.stage; + } + if (req.body.prototypeId !== undefined) dto.prototypeId = req.body.prototypeId; + if (req.body.isActive !== undefined) dto.isActive = req.body.isActive; + + const protocol = await service.update(getContext(req), req.params.id, dto); + if (!protocol) { + res.status(404).json({ error: 'Not Found', message: 'Protocolo no encontrado' }); + return; + } + + res.status(200).json({ + success: true, + data: protocol, + message: 'Protocolo actualizado exitosamente', + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/protocolos-calidad/:id/activar + * Activate quality protocol + */ + router.post('/:id/activar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const protocol = await service.activate(getContext(req), req.params.id); + if (!protocol) { + res.status(404).json({ error: 'Not Found', message: 'Protocolo no encontrado' }); + return; + } + + res.status(200).json({ + success: true, + data: protocol, + message: 'Protocolo activado exitosamente', + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/protocolos-calidad/:id/desactivar + * Deactivate quality protocol + */ + router.post('/:id/desactivar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const protocol = await service.deactivate(getContext(req), req.params.id); + if (!protocol) { + res.status(404).json({ error: 'Not Found', message: 'Protocolo no encontrado' }); + return; + } + + res.status(200).json({ + success: true, + data: protocol, + message: 'Protocolo desactivado exitosamente', + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/protocolos-calidad/:id/duplicar + * Duplicate quality protocol + * + * Body: + * - newCode: (required) Code for the new protocol + * - newName: (required) Name for the new protocol + */ + router.post('/:id/duplicar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { newCode, newName } = req.body; + if (!newCode || !newName) { + res.status(400).json({ + error: 'Bad Request', + message: 'newCode y newName son requeridos', + }); + return; + } + + const protocol = await service.duplicate(getContext(req), req.params.id, newCode, newName); + if (!protocol) { + res.status(404).json({ error: 'Not Found', message: 'Protocolo no encontrado' }); + return; + } + + res.status(201).json({ + success: true, + data: protocol, + message: 'Protocolo duplicado exitosamente', + }); + } catch (error: any) { + if (error.message?.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }); + + /** + * DELETE /api/protocolos-calidad/:id + * Soft delete quality protocol + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await service.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Protocolo no encontrado' }); + return; + } + + res.status(200).json({ + success: true, + message: 'Protocolo eliminado exitosamente', + }); + } catch (error) { + next(error); + } + }); + + // ───────────────────────────────────────────────────────────────────────────── + // ITEMS ENDPOINTS (Checklist items as protocol criteria) + // ───────────────────────────────────────────────────────────────────────────── + + /** + * POST /api/protocolos-calidad/:id/criterios + * Add criterion (checklist item) to protocol + * + * Body: + * - sequenceNumber: (required) Order number + * - category: (required) Category/section name + * - description: (required) Criterion description + * - isCritical: (optional) Is this a critical criterion + * - requiresPhoto: (optional) Does this require photo evidence + * - acceptanceCriteria: (optional) Acceptance criteria text + */ + router.post('/:id/criterios', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { sequenceNumber, category, description, isCritical, requiresPhoto, acceptanceCriteria } = req.body; + if (sequenceNumber === undefined || !category || !description) { + res.status(400).json({ + error: 'Bad Request', + message: 'sequenceNumber, category y description son requeridos', + }); + return; + } + + const item = await service.addItem(getContext(req), req.params.id, { + sequenceNumber, + category, + description, + isCritical, + requiresPhoto, + acceptanceCriteria, + }); + + res.status(201).json({ + success: true, + data: item, + message: 'Criterio agregado exitosamente', + }); + } catch (error: any) { + if (error.message?.includes('not found')) { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PATCH /api/protocolos-calidad/:protocolId/criterios/:itemId + * Update criterion in protocol + */ + router.patch('/:protocolId/criterios/:itemId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: any = {}; + if (req.body.sequenceNumber !== undefined) dto.sequenceNumber = req.body.sequenceNumber; + if (req.body.category !== undefined) dto.category = req.body.category; + if (req.body.description !== undefined) dto.description = req.body.description; + if (req.body.isCritical !== undefined) dto.isCritical = req.body.isCritical; + if (req.body.requiresPhoto !== undefined) dto.requiresPhoto = req.body.requiresPhoto; + if (req.body.acceptanceCriteria !== undefined) dto.acceptanceCriteria = req.body.acceptanceCriteria; + if (req.body.isActive !== undefined) dto.isActive = req.body.isActive; + + const item = await service.updateItem(getContext(req), req.params.itemId, dto); + if (!item) { + res.status(404).json({ error: 'Not Found', message: 'Criterio no encontrado' }); + return; + } + + res.status(200).json({ + success: true, + data: item, + message: 'Criterio actualizado exitosamente', + }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /api/protocolos-calidad/:protocolId/criterios/:itemId + * Remove criterion from protocol + */ + router.delete('/:protocolId/criterios/:itemId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await service.removeItem(getContext(req), req.params.itemId); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Criterio no encontrado' }); + return; + } + + res.status(200).json({ + success: true, + message: 'Criterio eliminado exitosamente', + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/protocolos-calidad/:id/criterios/reordenar + * Reorder criteria in protocol + * + * Body: + * - itemOrders: Array of { itemId: string, sequenceNumber: number } + */ + router.post('/:id/criterios/reordenar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { itemOrders } = req.body; + if (!itemOrders || !Array.isArray(itemOrders)) { + res.status(400).json({ + error: 'Bad Request', + message: 'itemOrders es requerido y debe ser un array', + }); + return; + } + + const success = await service.reorderItems(getContext(req), req.params.id, itemOrders); + if (!success) { + res.status(404).json({ error: 'Not Found', message: 'Protocolo no encontrado' }); + return; + } + + res.status(200).json({ + success: true, + message: 'Criterios reordenados exitosamente', + }); + } catch (error) { + next(error); + } + }); + + return router; +}