[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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 07:05:18 -06:00
parent 164186cec6
commit 99fadef0ba
5 changed files with 1919 additions and 1 deletions

View File

@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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;
}

View File

@ -1,8 +1,25 @@
/** /**
* Quality Controllers Index * Quality Controllers Index
* @module Quality *
* Barrel file exporting all quality module controllers.
*
* @module Quality (MAI-009)
*/ */
// Inspection management
export * from './inspection.controller'; export * from './inspection.controller';
// Post-sale tickets
export * from './ticket.controller'; export * from './ticket.controller';
// Checklist management (English API)
export { default as checklistController } from './checklist.controller'; 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';

View File

@ -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<void> => {
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<void> => {
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; return router;
} }

View File

@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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;
}

View File

@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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;
}