diff --git a/src/modules/branches/controllers/branch-inventory-settings.controller.ts b/src/modules/branches/controllers/branch-inventory-settings.controller.ts new file mode 100644 index 0000000..e5dc493 --- /dev/null +++ b/src/modules/branches/controllers/branch-inventory-settings.controller.ts @@ -0,0 +1,195 @@ +/** + * BranchInventorySettings Controller + * API endpoints para gestión de configuración de inventario por sucursal + * + * @module Branches + * @prefix /api/v1/branches/:branchId/inventory-settings + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + BranchInventorySettingsService, + CreateBranchInventorySettingsDto, + UpdateBranchInventorySettingsDto, +} from '../services/branch-inventory-settings.service'; +import { ServiceContext } from '../services/branch.service'; + +const router = Router({ mergeParams: true }); +const inventorySettingsService = new BranchInventorySettingsService(); + +/** + * Helper to extract ServiceContext from request + */ +function getContext(req: Request): ServiceContext { + return { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; +} + +/** + * GET /api/v1/branches/:branchId/inventory-settings + * Obtiene la configuración de inventario de una sucursal + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const settings = await inventorySettingsService.findByBranch(getContext(req), branchId); + + if (!settings) { + // Return effective settings with defaults if none configured + const effective = await inventorySettingsService.getEffectiveSettings(getContext(req), branchId); + return res.json({ + success: true, + data: null, + defaults: effective.defaults, + message: 'No hay configuración específica, se usan valores por defecto', + }); + } + + return res.json({ success: true, data: settings }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/:branchId/inventory-settings/effective + * Obtiene la configuración efectiva (configurada o defaults) + */ +router.get('/effective', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const effective = await inventorySettingsService.getEffectiveSettings(getContext(req), branchId); + + // Merge settings with defaults + const mergedSettings = { + ...effective.defaults, + ...(effective.settings || {}), + }; + + return res.json({ + success: true, + data: mergedSettings, + hasCustomSettings: !!effective.settings, + }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/branches/:branchId/inventory-settings + * Crea la configuración de inventario para una sucursal + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const data: CreateBranchInventorySettingsDto = { + ...req.body, + branchId, + }; + + const settings = await inventorySettingsService.create(getContext(req), data); + return res.status(201).json({ success: true, data: settings }); + } catch (error: any) { + if (error.message?.includes('already exist')) { + return res.status(409).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * PUT /api/v1/branches/:branchId/inventory-settings + * Crea o actualiza la configuración de inventario (upsert) + */ +router.put('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const data: UpdateBranchInventorySettingsDto = req.body; + + const settings = await inventorySettingsService.upsertByBranch(getContext(req), branchId, data); + return res.json({ success: true, data: settings }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/branches/:branchId/inventory-settings + * Actualiza parcialmente la configuración de inventario + */ +router.patch('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const existing = await inventorySettingsService.findByBranch(getContext(req), branchId); + + if (!existing) { + return res.status(404).json({ + error: 'No hay configuración de inventario para esta sucursal. Use POST para crear.', + }); + } + + const data: UpdateBranchInventorySettingsDto = req.body; + const settings = await inventorySettingsService.update(getContext(req), existing.id, data); + + return res.json({ success: true, data: settings }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/branches/:branchId/inventory-settings + * Elimina la configuración de inventario (vuelve a defaults) + */ +router.delete('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const deleted = await inventorySettingsService.deleteByBranch(getContext(req), branchId); + + if (!deleted) { + return res.status(404).json({ error: 'No hay configuración de inventario para esta sucursal' }); + } + + return res.json({ + success: true, + message: 'Configuración de inventario eliminada, se usarán valores por defecto', + }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/src/modules/branches/controllers/branch-payment-terminal.controller.ts b/src/modules/branches/controllers/branch-payment-terminal.controller.ts new file mode 100644 index 0000000..b4389fc --- /dev/null +++ b/src/modules/branches/controllers/branch-payment-terminal.controller.ts @@ -0,0 +1,312 @@ +/** + * BranchPaymentTerminal Controller + * API endpoints para gestión de terminales de pago por sucursal + * + * @module Branches + * @prefix /api/v1/branches/:branchId/payment-terminals + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + BranchPaymentTerminalService, + CreateBranchPaymentTerminalDto, + UpdateBranchPaymentTerminalDto, +} from '../services/branch-payment-terminal.service'; +import { ServiceContext } from '../services/branch.service'; +import { HealthStatus } from '../entities/branch-payment-terminal.entity'; + +const router = Router({ mergeParams: true }); +const terminalService = new BranchPaymentTerminalService(); + +/** + * Helper to extract ServiceContext from request + */ +function getContext(req: Request): ServiceContext { + return { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; +} + +/** + * GET /api/v1/branches/:branchId/payment-terminals + * Lista todas las terminales de pago de una sucursal + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const { terminalProvider, isActive, healthStatus } = req.query; + + const terminals = await terminalService.findAll(getContext(req), { + branchId, + terminalProvider: terminalProvider as any, + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined, + healthStatus: healthStatus as HealthStatus, + }); + + return res.json({ + success: true, + data: terminals, + count: terminals.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/:branchId/payment-terminals/primary + * Obtiene la terminal primaria de una sucursal + */ +router.get('/primary', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const terminal = await terminalService.findPrimaryTerminal(getContext(req), branchId); + + if (!terminal) { + return res.status(404).json({ error: 'No hay terminal primaria configurada' }); + } + + return res.json({ success: true, data: terminal }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/:branchId/payment-terminals/health + * Obtiene el resumen de salud de las terminales + */ +router.get('/health', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const summary = await terminalService.getHealthSummary(getContext(req)); + return res.json({ success: true, data: summary }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/:branchId/payment-terminals/needs-check + * Obtiene las terminales que necesitan health check + */ +router.get('/needs-check', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { maxAgeMinutes } = req.query; + const terminals = await terminalService.getTerminalsNeedingHealthCheck( + getContext(req), + maxAgeMinutes ? parseInt(maxAgeMinutes as string) : undefined + ); + + return res.json({ success: true, data: terminals, count: terminals.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/:branchId/payment-terminals/:id + * Obtiene una terminal específica + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const terminal = await terminalService.findById(getContext(req), req.params.id); + if (!terminal) { + return res.status(404).json({ error: 'Terminal no encontrada' }); + } + + return res.json({ success: true, data: terminal }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/branches/:branchId/payment-terminals + * Crea una nueva terminal de pago + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const data: CreateBranchPaymentTerminalDto = { + ...req.body, + branchId, + }; + + // Validate required fields + if (!data.terminalId || !data.terminalProvider || !data.provider) { + return res.status(400).json({ + error: 'terminalId, terminalProvider y provider son requeridos', + }); + } + + // Check if terminal ID already exists + const existing = await terminalService.findByTerminalId(getContext(req), data.terminalId); + if (existing) { + return res.status(409).json({ error: 'Ya existe una terminal con ese ID' }); + } + + const terminal = await terminalService.create(getContext(req), data); + return res.status(201).json({ success: true, data: terminal }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/branches/:branchId/payment-terminals/:id + * Actualiza una terminal + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: UpdateBranchPaymentTerminalDto = req.body; + const terminal = await terminalService.update(getContext(req), req.params.id, data); + + if (!terminal) { + return res.status(404).json({ error: 'Terminal no encontrada' }); + } + + return res.json({ success: true, data: terminal }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/branches/:branchId/payment-terminals/:id/set-primary + * Establece una terminal como primaria + */ +router.post('/:id/set-primary', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const terminal = await terminalService.setAsPrimary(getContext(req), req.params.id); + if (!terminal) { + return res.status(404).json({ error: 'Terminal no encontrada' }); + } + + return res.json({ + success: true, + data: terminal, + message: 'Terminal establecida como primaria', + }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/branches/:branchId/payment-terminals/:id/health-check + * Actualiza el estado de salud de una terminal + */ +router.post('/:id/health-check', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { healthStatus } = req.body; + if (!healthStatus) { + return res.status(400).json({ error: 'healthStatus es requerido' }); + } + + const terminal = await terminalService.updateHealthStatus( + getContext(req), + req.params.id, + healthStatus as HealthStatus + ); + + if (!terminal) { + return res.status(404).json({ error: 'Terminal no encontrada' }); + } + + return res.json({ success: true, data: terminal }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/branches/:branchId/payment-terminals/:id/record-transaction + * Registra una transacción en la terminal + */ +router.post('/:id/record-transaction', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const terminal = await terminalService.recordTransaction(getContext(req), req.params.id); + if (!terminal) { + return res.status(404).json({ error: 'Terminal no encontrada' }); + } + + return res.json({ success: true, data: terminal }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/branches/:branchId/payment-terminals/:id + * Elimina una terminal + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const deleted = await terminalService.delete(getContext(req), req.params.id); + if (!deleted) { + return res.status(404).json({ error: 'Terminal no encontrada' }); + } + + return res.json({ success: true, message: 'Terminal eliminada' }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/src/modules/branches/controllers/branch-schedule.controller.ts b/src/modules/branches/controllers/branch-schedule.controller.ts new file mode 100644 index 0000000..16964d3 --- /dev/null +++ b/src/modules/branches/controllers/branch-schedule.controller.ts @@ -0,0 +1,298 @@ +/** + * BranchSchedule Controller + * API endpoints para gestión de horarios de sucursales + * + * @module Branches + * @prefix /api/v1/branches/:branchId/schedules + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + BranchScheduleService, + CreateBranchScheduleDto, + UpdateBranchScheduleDto, +} from '../services/branch-schedule.service'; +import { ServiceContext } from '../services/branch.service'; + +const router = Router({ mergeParams: true }); +const branchScheduleService = new BranchScheduleService(); + +/** + * Helper to extract ServiceContext from request + */ +function getContext(req: Request): ServiceContext { + return { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; +} + +/** + * GET /api/v1/branches/:branchId/schedules + * Lista todos los horarios de una sucursal + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const { scheduleType, dayOfWeek, isActive } = req.query; + + const schedules = await branchScheduleService.findAll(getContext(req), { + branchId, + scheduleType: scheduleType as any, + dayOfWeek: dayOfWeek ? parseInt(dayOfWeek as string) : undefined, + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined, + }); + + return res.json({ + success: true, + data: schedules, + count: schedules.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/:branchId/schedules/regular + * Obtiene los horarios regulares de una sucursal + */ +router.get('/regular', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const schedules = await branchScheduleService.findRegularSchedule(getContext(req), branchId); + + return res.json({ success: true, data: schedules, count: schedules.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/:branchId/schedules/holidays + * Obtiene los horarios de días festivos de una sucursal + */ +router.get('/holidays', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const schedules = await branchScheduleService.findHolidaySchedule(getContext(req), branchId); + + return res.json({ success: true, data: schedules, count: schedules.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/:branchId/schedules/for-date + * Obtiene el horario efectivo para una fecha específica + */ +router.get('/for-date', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const { date } = req.query; + + if (!date) { + return res.status(400).json({ error: 'date es requerido (formato: YYYY-MM-DD)' }); + } + + const schedule = await branchScheduleService.findScheduleForDate( + getContext(req), + branchId, + new Date(date as string) + ); + + if (!schedule) { + return res.status(404).json({ error: 'No hay horario configurado para esta fecha' }); + } + + return res.json({ success: true, data: schedule }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/:branchId/schedules/is-open + * Verifica si la sucursal está abierta en un momento dado + */ +router.get('/is-open', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const { datetime } = req.query; + + const checkDate = datetime ? new Date(datetime as string) : new Date(); + const isOpen = await branchScheduleService.isBranchOpenAt(getContext(req), branchId, checkDate); + + return res.json({ + success: true, + data: { + isOpen, + checkedAt: checkDate.toISOString(), + }, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/:branchId/schedules/:id + * Obtiene un horario específico + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const schedule = await branchScheduleService.findById(getContext(req), req.params.id); + if (!schedule) { + return res.status(404).json({ error: 'Horario no encontrado' }); + } + + return res.json({ success: true, data: schedule }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/branches/:branchId/schedules + * Crea un nuevo horario + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const data: CreateBranchScheduleDto = { + ...req.body, + branchId, + }; + + // Validate required fields + if (!data.name || !data.openTime || !data.closeTime) { + return res.status(400).json({ error: 'name, openTime y closeTime son requeridos' }); + } + + const schedule = await branchScheduleService.create(getContext(req), data); + return res.status(201).json({ success: true, data: schedule }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/branches/:branchId/schedules/weekly + * Crea o reemplaza el horario semanal completo + */ +router.post('/weekly', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const { weeklyHours } = req.body; + + if (!weeklyHours || !Array.isArray(weeklyHours)) { + return res.status(400).json({ + error: 'weeklyHours es requerido como array de { dayOfWeek, openTime, closeTime, shifts? }', + }); + } + + const schedules = await branchScheduleService.createWeeklySchedule( + getContext(req), + branchId, + weeklyHours + ); + + return res.status(201).json({ + success: true, + data: schedules, + count: schedules.length, + message: 'Horario semanal configurado', + }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/branches/:branchId/schedules/:id + * Actualiza un horario + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: UpdateBranchScheduleDto = req.body; + const schedule = await branchScheduleService.update(getContext(req), req.params.id, data); + + if (!schedule) { + return res.status(404).json({ error: 'Horario no encontrado' }); + } + + return res.json({ success: true, data: schedule }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/branches/:branchId/schedules/:id + * Elimina un horario + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const deleted = await branchScheduleService.delete(getContext(req), req.params.id); + if (!deleted) { + return res.status(404).json({ error: 'Horario no encontrado' }); + } + + return res.json({ success: true, message: 'Horario eliminado' }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/src/modules/branches/controllers/branch.controller.ts b/src/modules/branches/controllers/branch.controller.ts new file mode 100644 index 0000000..0758f4a --- /dev/null +++ b/src/modules/branches/controllers/branch.controller.ts @@ -0,0 +1,315 @@ +/** + * Branch Controller + * API endpoints para gestión de sucursales + * + * @module Branches + * @prefix /api/v1/branches + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + BranchService, + CreateBranchDto, + UpdateBranchDto, + ServiceContext, +} from '../services/branch.service'; + +const router = Router(); +const branchService = new BranchService(); + +/** + * Helper to extract ServiceContext from request + */ +function getContext(req: Request): ServiceContext { + return { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; +} + +/** + * GET /api/v1/branches + * Lista todas las sucursales del tenant + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchType, isActive, city, state, parentId, page, limit } = req.query; + + const result = await branchService.findAll( + getContext(req), + { + branchType: branchType as any, + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined, + city: city as string, + state: state as string, + parentId: parentId as string, + }, + { + page: page ? parseInt(page as string) : undefined, + limit: limit ? parseInt(limit as string) : undefined, + } + ); + + return res.json({ + success: true, + ...result, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/statistics + * Estadísticas de sucursales + */ +router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const stats = await branchService.getStatistics(getContext(req)); + return res.json({ success: true, data: stats }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/hierarchy + * Obtiene la jerarquía completa de sucursales + */ +router.get('/hierarchy', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const hierarchy = await branchService.getHierarchy(getContext(req)); + return res.json({ success: true, data: hierarchy }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/roots + * Obtiene las sucursales raíz (sin padre) + */ +router.get('/roots', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const roots = await branchService.findRootBranches(getContext(req)); + return res.json({ success: true, data: roots, count: roots.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/main + * Obtiene la sucursal principal/matriz + */ +router.get('/main', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const main = await branchService.findMainBranch(getContext(req)); + if (!main) { + return res.status(404).json({ error: 'No hay sucursal principal configurada' }); + } + + return res.json({ success: true, data: main }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/nearby + * Busca sucursales cercanas a una ubicación + */ +router.get('/nearby', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { lat, lng, radius } = req.query; + + if (!lat || !lng) { + return res.status(400).json({ error: 'lat y lng son requeridos' }); + } + + const branches = await branchService.findByLocation( + getContext(req), + parseFloat(lat as string), + parseFloat(lng as string), + radius ? parseFloat(radius as string) : undefined + ); + + return res.json({ success: true, data: branches, count: branches.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/:id + * Obtiene una sucursal por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const branch = await branchService.findById(getContext(req), req.params.id); + if (!branch) { + return res.status(404).json({ error: 'Sucursal no encontrada' }); + } + + return res.json({ success: true, data: branch }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/:id/children + * Obtiene las sucursales hijas de una sucursal + */ +router.get('/:id/children', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const children = await branchService.findChildren(getContext(req), req.params.id); + return res.json({ success: true, data: children, count: children.length }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/branches + * Crea una nueva sucursal + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: CreateBranchDto = req.body; + + // Validate required fields + if (!data.code || !data.name) { + return res.status(400).json({ error: 'code y name son requeridos' }); + } + + // Check if code already exists + const existing = await branchService.findByCode(getContext(req), data.code); + if (existing) { + return res.status(409).json({ error: 'Ya existe una sucursal con ese código' }); + } + + const branch = await branchService.create(getContext(req), data); + return res.status(201).json({ success: true, data: branch }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/branches/:id + * Actualiza una sucursal + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: UpdateBranchDto = req.body; + const branch = await branchService.update(getContext(req), req.params.id, data); + + if (!branch) { + return res.status(404).json({ error: 'Sucursal no encontrada' }); + } + + return res.json({ success: true, data: branch }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/branches/:id/set-main + * Establece una sucursal como principal/matriz + */ +router.post('/:id/set-main', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const branch = await branchService.setAsMain(getContext(req), req.params.id); + if (!branch) { + return res.status(404).json({ error: 'Sucursal no encontrada' }); + } + + return res.json({ success: true, data: branch, message: 'Sucursal establecida como principal' }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/branches/:id + * Elimina una sucursal (soft delete) + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const deleted = await branchService.delete(getContext(req), req.params.id); + if (!deleted) { + return res.status(404).json({ error: 'Sucursal no encontrada' }); + } + + return res.json({ success: true, message: 'Sucursal eliminada' }); + } catch (error: any) { + if (error.message?.includes('children')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +export default router; diff --git a/src/modules/branches/controllers/index.ts b/src/modules/branches/controllers/index.ts new file mode 100644 index 0000000..0c1c33b --- /dev/null +++ b/src/modules/branches/controllers/index.ts @@ -0,0 +1,13 @@ +/** + * Branches Controllers Index + * @module Branches + */ + +export { default as branchController } from './branch.controller'; +export { default as branchScheduleController } from './branch-schedule.controller'; +export { default as branchInventorySettingsController } from './branch-inventory-settings.controller'; +export { default as branchPaymentTerminalController } from './branch-payment-terminal.controller'; +export { + default as userBranchAssignmentController, + userBranchAssignmentRouter, +} from './user-branch-assignment.controller'; diff --git a/src/modules/branches/controllers/user-branch-assignment.controller.ts b/src/modules/branches/controllers/user-branch-assignment.controller.ts new file mode 100644 index 0000000..b9ed703 --- /dev/null +++ b/src/modules/branches/controllers/user-branch-assignment.controller.ts @@ -0,0 +1,428 @@ +/** + * UserBranchAssignment Controller + * API endpoints para gestión de asignaciones de usuarios a sucursales + * + * @module Branches + * @prefix /api/v1/branches/:branchId/assignments + * @prefix /api/v1/user-branch-assignments (for user-centric operations) + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + UserBranchAssignmentService, + CreateUserBranchAssignmentDto, + UpdateUserBranchAssignmentDto, +} from '../services/user-branch-assignment.service'; +import { ServiceContext } from '../services/branch.service'; + +const router = Router({ mergeParams: true }); +const assignmentService = new UserBranchAssignmentService(); + +/** + * Helper to extract ServiceContext from request + */ +function getContext(req: Request): ServiceContext { + return { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; +} + +/** + * GET /api/v1/branches/:branchId/assignments + * Lista todas las asignaciones de usuarios a una sucursal + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const { assignmentType, branchRole, isActive } = req.query; + + const assignments = await assignmentService.findAll(getContext(req), { + branchId, + assignmentType: assignmentType as any, + branchRole: branchRole as any, + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined, + }); + + return res.json({ + success: true, + data: assignments, + count: assignments.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/:branchId/assignments/managers + * Obtiene los gerentes de una sucursal + */ +router.get('/managers', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const managers = await assignmentService.getBranchManagers(getContext(req), branchId); + + return res.json({ success: true, data: managers, count: managers.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/:branchId/assignments/statistics + * Obtiene estadísticas de asignaciones + */ +router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const stats = await assignmentService.getAssignmentStatistics(getContext(req)); + return res.json({ success: true, data: stats }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/:branchId/assignments/check-user/:userId + * Verifica si un usuario está asignado a una sucursal + */ +router.get('/check-user/:userId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId, userId } = req.params; + const isAssigned = await assignmentService.isUserAssignedToBranch( + getContext(req), + userId, + branchId + ); + + return res.json({ + success: true, + data: { isAssigned, userId, branchId }, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/branches/:branchId/assignments/:id + * Obtiene una asignación específica + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const assignment = await assignmentService.findById(getContext(req), req.params.id); + if (!assignment) { + return res.status(404).json({ error: 'Asignación no encontrada' }); + } + + return res.json({ success: true, data: assignment }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/branches/:branchId/assignments + * Crea una nueva asignación de usuario a sucursal + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { branchId } = req.params; + const data: CreateUserBranchAssignmentDto = { + ...req.body, + branchId, + }; + + // Validate required fields + if (!data.userId) { + return res.status(400).json({ error: 'userId es requerido' }); + } + + const assignment = await assignmentService.create(getContext(req), data); + return res.status(201).json({ success: true, data: assignment }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/branches/:branchId/assignments/:id + * Actualiza una asignación + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: UpdateUserBranchAssignmentDto = req.body; + const assignment = await assignmentService.update(getContext(req), req.params.id, data); + + if (!assignment) { + return res.status(404).json({ error: 'Asignación no encontrada' }); + } + + return res.json({ success: true, data: assignment }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/branches/:branchId/assignments/:id/set-primary + * Establece una asignación como primaria + */ +router.post('/:id/set-primary', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const assignment = await assignmentService.setAsPrimary(getContext(req), req.params.id); + if (!assignment) { + return res.status(404).json({ error: 'Asignación no encontrada' }); + } + + return res.json({ + success: true, + data: assignment, + message: 'Asignación establecida como primaria', + }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/branches/:branchId/assignments/:id/deactivate + * Desactiva una asignación + */ +router.post('/:id/deactivate', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const assignment = await assignmentService.deactivate(getContext(req), req.params.id); + if (!assignment) { + return res.status(404).json({ error: 'Asignación no encontrada' }); + } + + return res.json({ success: true, data: assignment, message: 'Asignación desactivada' }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/branches/:branchId/assignments/:id + * Elimina una asignación + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const deleted = await assignmentService.delete(getContext(req), req.params.id); + if (!deleted) { + return res.status(404).json({ error: 'Asignación no encontrada' }); + } + + return res.json({ success: true, message: 'Asignación eliminada' }); + } catch (error) { + return next(error); + } +}); + +export default router; + +/** + * User-centric router for branch assignments + * Mounted at /api/v1/user-branch-assignments + */ +export const userBranchAssignmentRouter = Router(); + +/** + * GET /api/v1/user-branch-assignments/user/:userId + * Obtiene todas las asignaciones de un usuario + */ +userBranchAssignmentRouter.get( + '/user/:userId', + async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { userId } = req.params; + const assignments = await assignmentService.findByUser(getContext(req), userId); + + return res.json({ success: true, data: assignments, count: assignments.length }); + } catch (error) { + return next(error); + } + } +); + +/** + * GET /api/v1/user-branch-assignments/user/:userId/primary + * Obtiene la asignación primaria de un usuario + */ +userBranchAssignmentRouter.get( + '/user/:userId/primary', + async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { userId } = req.params; + const assignment = await assignmentService.findPrimaryAssignment(getContext(req), userId); + + if (!assignment) { + return res.status(404).json({ error: 'No hay asignación primaria para este usuario' }); + } + + return res.json({ success: true, data: assignment }); + } catch (error) { + return next(error); + } + } +); + +/** + * GET /api/v1/user-branch-assignments/user/:userId/active + * Obtiene las asignaciones activas de un usuario + */ +userBranchAssignmentRouter.get( + '/user/:userId/active', + async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { userId } = req.params; + const { asOfDate } = req.query; + + const assignments = await assignmentService.findActiveAssignments( + getContext(req), + userId, + asOfDate ? new Date(asOfDate as string) : undefined + ); + + return res.json({ success: true, data: assignments, count: assignments.length }); + } catch (error) { + return next(error); + } + } +); + +/** + * GET /api/v1/user-branch-assignments/user/:userId/permissions/:branchId + * Obtiene los permisos de un usuario en una sucursal + */ +userBranchAssignmentRouter.get( + '/user/:userId/permissions/:branchId', + async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { userId, branchId } = req.params; + const permissions = await assignmentService.getUserPermissionsForBranch( + getContext(req), + userId, + branchId + ); + + return res.json({ success: true, data: permissions }); + } catch (error) { + return next(error); + } + } +); + +/** + * POST /api/v1/user-branch-assignments/transfer + * Transfiere un usuario de una sucursal a otra + */ +userBranchAssignmentRouter.post( + '/transfer', + async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { userId, fromBranchId, toBranchId, transferPrimary } = req.body; + + if (!userId || !fromBranchId || !toBranchId) { + return res.status(400).json({ + error: 'userId, fromBranchId y toBranchId son requeridos', + }); + } + + const assignment = await assignmentService.transferUserToBranch( + getContext(req), + userId, + fromBranchId, + toBranchId, + transferPrimary !== false + ); + + if (!assignment) { + return res.status(400).json({ error: 'No se pudo completar la transferencia' }); + } + + return res.json({ + success: true, + data: assignment, + message: 'Usuario transferido exitosamente', + }); + } catch (error) { + return next(error); + } + } +); diff --git a/src/modules/branches/services/branch-inventory-settings.service.ts b/src/modules/branches/services/branch-inventory-settings.service.ts new file mode 100644 index 0000000..db07a74 --- /dev/null +++ b/src/modules/branches/services/branch-inventory-settings.service.ts @@ -0,0 +1,141 @@ +/** + * BranchInventorySettings Service + * Servicio para gestión de configuración de inventario por sucursal + * + * @module Branches + */ + +import { Repository } from 'typeorm'; +import { AppDataSource } from '../../../shared/database/typeorm.config'; +import { BranchInventorySettings } from '../entities/branch-inventory-settings.entity'; +import { ServiceContext } from './branch.service'; + +export interface CreateBranchInventorySettingsDto { + branchId: string; + warehouseId?: string; + defaultStockMin?: number; + defaultStockMax?: number; + autoReorderEnabled?: boolean; + priceListId?: string; + allowPriceOverride?: boolean; + maxDiscountPercent?: number; + taxConfig?: Record; +} + +export interface UpdateBranchInventorySettingsDto { + warehouseId?: string; + defaultStockMin?: number; + defaultStockMax?: number; + autoReorderEnabled?: boolean; + priceListId?: string; + allowPriceOverride?: boolean; + maxDiscountPercent?: number; + taxConfig?: Record; +} + +export class BranchInventorySettingsService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(BranchInventorySettings); + } + + async findByBranch(_ctx: ServiceContext, branchId: string): Promise { + return this.repository.findOne({ + where: { branchId }, + relations: ['branch'], + }); + } + + async findById(_ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { id }, + relations: ['branch'], + }); + } + + async findAll(_ctx: ServiceContext): Promise { + return this.repository.find({ + relations: ['branch'], + }); + } + + async create( + ctx: ServiceContext, + data: CreateBranchInventorySettingsDto + ): Promise { + // Check if settings already exist for this branch + const existing = await this.findByBranch(ctx, data.branchId); + if (existing) { + throw new Error('Inventory settings already exist for this branch'); + } + + const settings = this.repository.create(data); + return this.repository.save(settings); + } + + async update( + ctx: ServiceContext, + id: string, + data: UpdateBranchInventorySettingsDto + ): Promise { + const settings = await this.findById(ctx, id); + if (!settings) { + return null; + } + + Object.assign(settings, data); + return this.repository.save(settings); + } + + async upsertByBranch( + ctx: ServiceContext, + branchId: string, + data: UpdateBranchInventorySettingsDto + ): Promise { + let settings = await this.findByBranch(ctx, branchId); + + if (settings) { + Object.assign(settings, data); + } else { + settings = this.repository.create({ + branchId, + ...data, + }); + } + + return this.repository.save(settings); + } + + async delete(_ctx: ServiceContext, id: string): Promise { + const result = await this.repository.delete({ id }); + return result.affected ? result.affected > 0 : false; + } + + async deleteByBranch(_ctx: ServiceContext, branchId: string): Promise { + const result = await this.repository.delete({ branchId }); + return result.affected ? result.affected > 0 : false; + } + + async getEffectiveSettings( + ctx: ServiceContext, + branchId: string + ): Promise<{ + settings: BranchInventorySettings | null; + defaults: UpdateBranchInventorySettingsDto; + }> { + const settings = await this.findByBranch(ctx, branchId); + + // Default values if no settings configured + const defaults: UpdateBranchInventorySettingsDto = { + defaultStockMin: 0, + defaultStockMax: 1000, + autoReorderEnabled: false, + allowPriceOverride: false, + maxDiscountPercent: 0, + taxConfig: {}, + }; + + return { settings, defaults }; + } +} diff --git a/src/modules/branches/services/branch-payment-terminal.service.ts b/src/modules/branches/services/branch-payment-terminal.service.ts new file mode 100644 index 0000000..938b7a7 --- /dev/null +++ b/src/modules/branches/services/branch-payment-terminal.service.ts @@ -0,0 +1,240 @@ +/** + * BranchPaymentTerminal Service + * Servicio para gestión de terminales de pago por sucursal + * + * @module Branches + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { AppDataSource } from '../../../shared/database/typeorm.config'; +import { + BranchPaymentTerminal, + TerminalProvider, + HealthStatus, +} from '../entities/branch-payment-terminal.entity'; +import { ServiceContext } from './branch.service'; + +export interface CreateBranchPaymentTerminalDto { + branchId: string; + terminalId: string; + terminalName?: string; + terminalProvider: TerminalProvider; + provider: string; + credentials?: Record; + isActive?: boolean; + isPrimary?: boolean; + dailyLimit?: number; + transactionLimit?: number; + config?: Record; +} + +export interface UpdateBranchPaymentTerminalDto { + terminalName?: string; + terminalProvider?: TerminalProvider; + provider?: string; + credentials?: Record; + isActive?: boolean; + isPrimary?: boolean; + healthStatus?: HealthStatus; + dailyLimit?: number; + transactionLimit?: number; + config?: Record; +} + +export interface BranchPaymentTerminalFilters { + branchId?: string; + terminalProvider?: TerminalProvider; + isActive?: boolean; + healthStatus?: HealthStatus; +} + +export class BranchPaymentTerminalService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(BranchPaymentTerminal); + } + + async findAll( + ctx: ServiceContext, + filters?: BranchPaymentTerminalFilters + ): Promise { + const where: FindOptionsWhere = { + tenantId: ctx.tenantId, + }; + + if (filters?.branchId) { + where.branchId = filters.branchId; + } + + if (filters?.terminalProvider) { + where.terminalProvider = filters.terminalProvider; + } + + if (filters?.isActive !== undefined) { + where.isActive = filters.isActive; + } + + if (filters?.healthStatus) { + where.healthStatus = filters.healthStatus; + } + + return this.repository.find({ + where, + order: { isPrimary: 'DESC', terminalName: 'ASC' }, + }); + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId: ctx.tenantId }, + }); + } + + async findByBranch(ctx: ServiceContext, branchId: string): Promise { + return this.repository.find({ + where: { branchId, tenantId: ctx.tenantId, isActive: true }, + order: { isPrimary: 'DESC', terminalName: 'ASC' }, + }); + } + + async findPrimaryTerminal( + ctx: ServiceContext, + branchId: string + ): Promise { + return this.repository.findOne({ + where: { branchId, tenantId: ctx.tenantId, isPrimary: true, isActive: true }, + }); + } + + async findByTerminalId( + ctx: ServiceContext, + terminalId: string + ): Promise { + return this.repository.findOne({ + where: { terminalId, tenantId: ctx.tenantId }, + }); + } + + async create( + ctx: ServiceContext, + data: CreateBranchPaymentTerminalDto + ): Promise { + // If this is the first terminal or marked as primary, ensure only one primary + if (data.isPrimary) { + await this.repository.update( + { branchId: data.branchId, tenantId: ctx.tenantId, isPrimary: true }, + { isPrimary: false } + ); + } + + const terminal = this.repository.create({ + ...data, + tenantId: ctx.tenantId, + healthStatus: 'unknown', + }); + + return this.repository.save(terminal); + } + + async update( + ctx: ServiceContext, + id: string, + data: UpdateBranchPaymentTerminalDto + ): Promise { + const terminal = await this.findById(ctx, id); + if (!terminal) { + return null; + } + + // If setting as primary, unset other primaries for this branch + if (data.isPrimary && !terminal.isPrimary) { + await this.repository.update( + { branchId: terminal.branchId, tenantId: ctx.tenantId, isPrimary: true }, + { isPrimary: false } + ); + } + + Object.assign(terminal, data); + return this.repository.save(terminal); + } + + async delete(ctx: ServiceContext, id: string): Promise { + const result = await this.repository.delete({ id, tenantId: ctx.tenantId }); + return result.affected ? result.affected > 0 : false; + } + + async setAsPrimary(ctx: ServiceContext, id: string): Promise { + const terminal = await this.findById(ctx, id); + if (!terminal) { + return null; + } + + // Unset other primaries + await this.repository.update( + { branchId: terminal.branchId, tenantId: ctx.tenantId, isPrimary: true }, + { isPrimary: false } + ); + + terminal.isPrimary = true; + return this.repository.save(terminal); + } + + async updateHealthStatus( + ctx: ServiceContext, + id: string, + healthStatus: HealthStatus + ): Promise { + const terminal = await this.findById(ctx, id); + if (!terminal) { + return null; + } + + terminal.healthStatus = healthStatus; + terminal.lastHealthCheckAt = new Date(); + return this.repository.save(terminal); + } + + async recordTransaction(ctx: ServiceContext, id: string): Promise { + const terminal = await this.findById(ctx, id); + if (!terminal) { + return null; + } + + terminal.lastTransactionAt = new Date(); + return this.repository.save(terminal); + } + + async getHealthSummary(ctx: ServiceContext): Promise<{ + total: number; + healthy: number; + degraded: number; + offline: number; + unknown: number; + }> { + const terminals = await this.repository.find({ + where: { tenantId: ctx.tenantId, isActive: true }, + }); + + return { + total: terminals.length, + healthy: terminals.filter(t => t.healthStatus === 'healthy').length, + degraded: terminals.filter(t => t.healthStatus === 'degraded').length, + offline: terminals.filter(t => t.healthStatus === 'offline').length, + unknown: terminals.filter(t => t.healthStatus === 'unknown').length, + }; + } + + async getTerminalsNeedingHealthCheck( + ctx: ServiceContext, + maxAgeMinutes: number = 30 + ): Promise { + const cutoff = new Date(Date.now() - maxAgeMinutes * 60 * 1000); + + const terminals = await this.repository.find({ + where: { tenantId: ctx.tenantId, isActive: true }, + }); + + return terminals.filter(t => !t.lastHealthCheckAt || t.lastHealthCheckAt < cutoff); + } +} diff --git a/src/modules/branches/services/branch-schedule.service.ts b/src/modules/branches/services/branch-schedule.service.ts new file mode 100644 index 0000000..f875a4d --- /dev/null +++ b/src/modules/branches/services/branch-schedule.service.ts @@ -0,0 +1,206 @@ +/** + * BranchSchedule Service + * Servicio para gestión de horarios de sucursales + * + * @module Branches + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { AppDataSource } from '../../../shared/database/typeorm.config'; +import { BranchSchedule, ScheduleType } from '../entities/branch-schedule.entity'; +import { ServiceContext } from './branch.service'; + +export interface CreateBranchScheduleDto { + branchId: string; + name: string; + description?: string; + scheduleType?: ScheduleType; + dayOfWeek?: number; + specificDate?: Date; + openTime: string; + closeTime: string; + shifts?: Array<{ name: string; start: string; end: string }>; + isActive?: boolean; +} + +export interface UpdateBranchScheduleDto { + name?: string; + description?: string; + scheduleType?: ScheduleType; + dayOfWeek?: number; + specificDate?: Date; + openTime?: string; + closeTime?: string; + shifts?: Array<{ name: string; start: string; end: string }>; + isActive?: boolean; +} + +export interface BranchScheduleFilters { + branchId?: string; + scheduleType?: ScheduleType; + dayOfWeek?: number; + isActive?: boolean; +} + +export class BranchScheduleService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(BranchSchedule); + } + + async findAll(_ctx: ServiceContext, filters?: BranchScheduleFilters): Promise { + const where: FindOptionsWhere = {}; + + if (filters?.branchId) { + where.branchId = filters.branchId; + } + + if (filters?.scheduleType) { + where.scheduleType = filters.scheduleType; + } + + if (filters?.dayOfWeek !== undefined) { + where.dayOfWeek = filters.dayOfWeek; + } + + if (filters?.isActive !== undefined) { + where.isActive = filters.isActive; + } + + return this.repository.find({ + where, + relations: ['branch'], + order: { dayOfWeek: 'ASC', openTime: 'ASC' }, + }); + } + + async findById(_ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { id }, + relations: ['branch'], + }); + } + + async findByBranch(_ctx: ServiceContext, branchId: string): Promise { + return this.repository.find({ + where: { branchId, isActive: true }, + order: { dayOfWeek: 'ASC', openTime: 'ASC' }, + }); + } + + async findRegularSchedule(_ctx: ServiceContext, branchId: string): Promise { + return this.repository.find({ + where: { branchId, scheduleType: 'regular', isActive: true }, + order: { dayOfWeek: 'ASC' }, + }); + } + + async findHolidaySchedule(_ctx: ServiceContext, branchId: string): Promise { + return this.repository.find({ + where: { branchId, scheduleType: 'holiday', isActive: true }, + order: { specificDate: 'ASC' }, + }); + } + + async findScheduleForDate( + _ctx: ServiceContext, + branchId: string, + date: Date + ): Promise { + // First check for specific date (holiday or special) + const specificSchedule = await this.repository.findOne({ + where: { + branchId, + specificDate: date, + isActive: true, + }, + }); + + if (specificSchedule) { + return specificSchedule; + } + + // Fall back to regular schedule for day of week + const dayOfWeek = date.getDay(); + return this.repository.findOne({ + where: { + branchId, + scheduleType: 'regular', + dayOfWeek, + isActive: true, + }, + }); + } + + async create(_ctx: ServiceContext, data: CreateBranchScheduleDto): Promise { + const schedule = this.repository.create(data); + return this.repository.save(schedule); + } + + async update( + ctx: ServiceContext, + id: string, + data: UpdateBranchScheduleDto + ): Promise { + const schedule = await this.findById(ctx, id); + if (!schedule) { + return null; + } + + Object.assign(schedule, data); + return this.repository.save(schedule); + } + + async delete(_ctx: ServiceContext, id: string): Promise { + const result = await this.repository.delete({ id }); + return result.affected ? result.affected > 0 : false; + } + + async createWeeklySchedule( + _ctx: ServiceContext, + branchId: string, + weeklyHours: Array<{ + dayOfWeek: number; + openTime: string; + closeTime: string; + shifts?: Array<{ name: string; start: string; end: string }>; + }> + ): Promise { + // Delete existing regular schedules for this branch + await this.repository.delete({ + branchId, + scheduleType: 'regular', + }); + + // Create new schedules + const schedules = weeklyHours.map(hours => + this.repository.create({ + branchId, + name: `Regular - Day ${hours.dayOfWeek}`, + scheduleType: 'regular', + dayOfWeek: hours.dayOfWeek, + openTime: hours.openTime, + closeTime: hours.closeTime, + shifts: hours.shifts || [], + isActive: true, + }) + ); + + return this.repository.save(schedules); + } + + async isBranchOpenAt( + ctx: ServiceContext, + branchId: string, + dateTime: Date + ): Promise { + const schedule = await this.findScheduleForDate(ctx, branchId, dateTime); + if (!schedule) { + return false; + } + + const timeStr = dateTime.toTimeString().slice(0, 5); // HH:MM format + return timeStr >= schedule.openTime && timeStr <= schedule.closeTime; + } +} diff --git a/src/modules/branches/services/branch.service.ts b/src/modules/branches/services/branch.service.ts new file mode 100644 index 0000000..49fbfbf --- /dev/null +++ b/src/modules/branches/services/branch.service.ts @@ -0,0 +1,317 @@ +/** + * Branch Service + * Servicio para gestión de sucursales + * + * @module Branches + */ + +import { Repository, FindOptionsWhere, IsNull, Not } from 'typeorm'; +import { AppDataSource } from '../../../shared/database/typeorm.config'; +import { Branch, BranchType } from '../entities/branch.entity'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateBranchDto { + code: string; + name: string; + shortName?: string; + branchType?: BranchType; + parentId?: string; + phone?: string; + email?: string; + managerId?: string; + addressLine1?: string; + addressLine2?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + latitude?: number; + longitude?: number; + geofenceRadius?: number; + geofenceEnabled?: boolean; + timezone?: string; + currency?: string; + isActive?: boolean; + isMain?: boolean; + operatingHours?: Record; + settings?: Record; +} + +export interface UpdateBranchDto { + name?: string; + shortName?: string; + branchType?: BranchType; + parentId?: string; + phone?: string; + email?: string; + managerId?: string; + addressLine1?: string; + addressLine2?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + latitude?: number; + longitude?: number; + geofenceRadius?: number; + geofenceEnabled?: boolean; + timezone?: string; + currency?: string; + isActive?: boolean; + isMain?: boolean; + operatingHours?: Record; + settings?: Record; +} + +export interface BranchFilters { + branchType?: BranchType; + isActive?: boolean; + city?: string; + state?: string; + parentId?: string; + search?: string; +} + +export interface PaginationOptions { + page?: number; + limit?: number; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class BranchService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(Branch); + } + + async findAll( + ctx: ServiceContext, + filters?: BranchFilters, + pagination?: PaginationOptions + ): Promise> { + const where: FindOptionsWhere = { + tenantId: ctx.tenantId, + }; + + if (filters?.branchType) { + where.branchType = filters.branchType; + } + + if (filters?.isActive !== undefined) { + where.isActive = filters.isActive; + } + + if (filters?.city) { + where.city = filters.city; + } + + if (filters?.state) { + where.state = filters.state; + } + + if (filters?.parentId) { + where.parentId = filters.parentId; + } + + const page = pagination?.page || 1; + const limit = pagination?.limit || 50; + const skip = (page - 1) * limit; + + const [data, total] = await this.repository.findAndCount({ + where, + relations: ['parent', 'children'], + order: { code: 'ASC' }, + skip, + take: limit, + }); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId: ctx.tenantId }, + relations: ['parent', 'children', 'userAssignments', 'schedules', 'paymentTerminals'], + }); + } + + async findByCode(ctx: ServiceContext, code: string): Promise { + return this.repository.findOne({ + where: { code, tenantId: ctx.tenantId }, + }); + } + + async findMainBranch(ctx: ServiceContext): Promise { + return this.repository.findOne({ + where: { tenantId: ctx.tenantId, isMain: true }, + }); + } + + async findRootBranches(ctx: ServiceContext): Promise { + return this.repository.find({ + where: { tenantId: ctx.tenantId, parentId: IsNull() }, + relations: ['children'], + order: { code: 'ASC' }, + }); + } + + async findChildren(ctx: ServiceContext, parentId: string): Promise { + return this.repository.find({ + where: { tenantId: ctx.tenantId, parentId }, + order: { code: 'ASC' }, + }); + } + + async create(ctx: ServiceContext, data: CreateBranchDto): Promise { + const branch = this.repository.create({ + ...data, + tenantId: ctx.tenantId, + createdBy: ctx.userId, + }); + + // Build hierarchy path if parent exists + if (data.parentId) { + const parent = await this.findById(ctx, data.parentId); + if (parent) { + branch.hierarchyPath = parent.hierarchyPath + ? `${parent.hierarchyPath}/${parent.id}` + : parent.id; + branch.hierarchyLevel = parent.hierarchyLevel + 1; + } + } + + return this.repository.save(branch); + } + + async update(ctx: ServiceContext, id: string, data: UpdateBranchDto): Promise { + const branch = await this.findById(ctx, id); + if (!branch) { + return null; + } + + // If parent changed, update hierarchy + if (data.parentId !== undefined && data.parentId !== branch.parentId) { + if (data.parentId) { + const newParent = await this.findById(ctx, data.parentId); + if (newParent) { + branch.hierarchyPath = newParent.hierarchyPath + ? `${newParent.hierarchyPath}/${newParent.id}` + : newParent.id; + branch.hierarchyLevel = newParent.hierarchyLevel + 1; + } + } else { + branch.hierarchyPath = ''; + branch.hierarchyLevel = 0; + } + } + + Object.assign(branch, data); + if (ctx.userId) { + branch.updatedBy = ctx.userId; + } + + return this.repository.save(branch); + } + + async delete(ctx: ServiceContext, id: string): Promise { + // Check if branch has children + const children = await this.findChildren(ctx, id); + if (children.length > 0) { + throw new Error('Cannot delete branch with children. Delete children first.'); + } + + const result = await this.repository.softDelete({ id, tenantId: ctx.tenantId }); + return result.affected ? result.affected > 0 : false; + } + + async setAsMain(ctx: ServiceContext, id: string): Promise { + // First, unset any current main branch + await this.repository.update( + { tenantId: ctx.tenantId, isMain: true }, + { isMain: false } + ); + + // Set new main branch + return this.update(ctx, id, { isMain: true }); + } + + async getStatistics(ctx: ServiceContext): Promise<{ + total: number; + active: number; + inactive: number; + byType: Record; + }> { + const branches = await this.repository.find({ + where: { tenantId: ctx.tenantId }, + }); + + const byType: Record = {}; + branches.forEach(branch => { + byType[branch.branchType] = (byType[branch.branchType] || 0) + 1; + }); + + return { + total: branches.length, + active: branches.filter(b => b.isActive).length, + inactive: branches.filter(b => !b.isActive).length, + byType, + }; + } + + async getHierarchy(ctx: ServiceContext): Promise { + const roots = await this.findRootBranches(ctx); + + const loadChildren = async (branch: Branch): Promise => { + const children = await this.findChildren(ctx, branch.id); + branch.children = await Promise.all(children.map(loadChildren)); + return branch; + }; + + return Promise.all(roots.map(loadChildren)); + } + + async findByLocation( + ctx: ServiceContext, + latitude: number, + longitude: number, + radiusKm: number = 10 + ): Promise { + // Simple distance calculation using Haversine formula approximation + const latDelta = radiusKm / 111; // 1 degree latitude ~ 111 km + const lonDelta = radiusKm / (111 * Math.cos(latitude * Math.PI / 180)); + + const branches = await this.repository.find({ + where: { + tenantId: ctx.tenantId, + isActive: true, + latitude: Not(IsNull()), + longitude: Not(IsNull()), + }, + }); + + return branches.filter(branch => { + if (!branch.latitude || !branch.longitude) return false; + const latDiff = Math.abs(Number(branch.latitude) - latitude); + const lonDiff = Math.abs(Number(branch.longitude) - longitude); + return latDiff <= latDelta && lonDiff <= lonDelta; + }); + } +} diff --git a/src/modules/branches/services/index.ts b/src/modules/branches/services/index.ts new file mode 100644 index 0000000..a10b881 --- /dev/null +++ b/src/modules/branches/services/index.ts @@ -0,0 +1,10 @@ +/** + * Branches Services Index + * @module Branches + */ + +export * from './branch.service'; +export * from './branch-schedule.service'; +export * from './branch-inventory-settings.service'; +export * from './branch-payment-terminal.service'; +export * from './user-branch-assignment.service'; diff --git a/src/modules/branches/services/user-branch-assignment.service.ts b/src/modules/branches/services/user-branch-assignment.service.ts new file mode 100644 index 0000000..7c455f6 --- /dev/null +++ b/src/modules/branches/services/user-branch-assignment.service.ts @@ -0,0 +1,344 @@ +/** + * UserBranchAssignment Service + * Servicio para gestión de asignaciones de usuarios a sucursales + * + * @module Branches + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { AppDataSource } from '../../../shared/database/typeorm.config'; +import { + UserBranchAssignment, + AssignmentType, + BranchRole, +} from '../entities/user-branch-assignment.entity'; +import { ServiceContext } from './branch.service'; + +export interface CreateUserBranchAssignmentDto { + userId: string; + branchId: string; + assignmentType?: AssignmentType; + branchRole?: BranchRole; + permissions?: string[]; + validFrom?: Date; + validUntil?: Date; + isActive?: boolean; +} + +export interface UpdateUserBranchAssignmentDto { + assignmentType?: AssignmentType; + branchRole?: BranchRole; + permissions?: string[]; + validFrom?: Date; + validUntil?: Date; + isActive?: boolean; +} + +export interface UserBranchAssignmentFilters { + userId?: string; + branchId?: string; + assignmentType?: AssignmentType; + branchRole?: BranchRole; + isActive?: boolean; + includeExpired?: boolean; +} + +export class UserBranchAssignmentService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(UserBranchAssignment); + } + + async findAll( + ctx: ServiceContext, + filters?: UserBranchAssignmentFilters + ): Promise { + const where: FindOptionsWhere = { + tenantId: ctx.tenantId, + }; + + if (filters?.userId) { + where.userId = filters.userId; + } + + if (filters?.branchId) { + where.branchId = filters.branchId; + } + + if (filters?.assignmentType) { + where.assignmentType = filters.assignmentType; + } + + if (filters?.branchRole) { + where.branchRole = filters.branchRole; + } + + if (filters?.isActive !== undefined) { + where.isActive = filters.isActive; + } + + return this.repository.find({ + where, + relations: ['branch'], + order: { assignmentType: 'ASC', createdAt: 'DESC' }, + }); + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId: ctx.tenantId }, + relations: ['branch'], + }); + } + + async findByUser(ctx: ServiceContext, userId: string): Promise { + return this.repository.find({ + where: { userId, tenantId: ctx.tenantId, isActive: true }, + relations: ['branch'], + order: { assignmentType: 'ASC' }, + }); + } + + async findByBranch(ctx: ServiceContext, branchId: string): Promise { + return this.repository.find({ + where: { branchId, tenantId: ctx.tenantId, isActive: true }, + relations: ['branch'], + order: { branchRole: 'ASC' }, + }); + } + + async findPrimaryAssignment( + ctx: ServiceContext, + userId: string + ): Promise { + return this.repository.findOne({ + where: { + userId, + tenantId: ctx.tenantId, + assignmentType: 'primary', + isActive: true, + }, + relations: ['branch'], + }); + } + + async findActiveAssignments( + ctx: ServiceContext, + userId: string, + asOfDate?: Date + ): Promise { + const now = asOfDate || new Date(); + + const assignments = await this.repository.find({ + where: { + userId, + tenantId: ctx.tenantId, + isActive: true, + }, + relations: ['branch'], + }); + + // Filter by validity period + return assignments.filter(a => { + const validFrom = a.validFrom || new Date(0); + const validUntil = a.validUntil || new Date('9999-12-31'); + return now >= validFrom && now <= validUntil; + }); + } + + async isUserAssignedToBranch( + ctx: ServiceContext, + userId: string, + branchId: string + ): Promise { + const assignment = await this.repository.findOne({ + where: { + userId, + branchId, + tenantId: ctx.tenantId, + isActive: true, + }, + }); + + if (!assignment) { + return false; + } + + // Check validity + const now = new Date(); + const validFrom = assignment.validFrom || new Date(0); + const validUntil = assignment.validUntil || new Date('9999-12-31'); + + return now >= validFrom && now <= validUntil; + } + + async create( + ctx: ServiceContext, + data: CreateUserBranchAssignmentDto + ): Promise { + // If setting as primary, unset other primary assignments for this user + if (data.assignmentType === 'primary') { + await this.repository.update( + { + userId: data.userId, + tenantId: ctx.tenantId, + assignmentType: 'primary', + }, + { assignmentType: 'secondary' } + ); + } + + const assignment = this.repository.create({ + ...data, + tenantId: ctx.tenantId, + createdBy: ctx.userId, + }); + + return this.repository.save(assignment); + } + + async update( + ctx: ServiceContext, + id: string, + data: UpdateUserBranchAssignmentDto + ): Promise { + const assignment = await this.findById(ctx, id); + if (!assignment) { + return null; + } + + // If setting as primary, unset other primary assignments for this user + if (data.assignmentType === 'primary' && assignment.assignmentType !== 'primary') { + await this.repository.update( + { + userId: assignment.userId, + tenantId: ctx.tenantId, + assignmentType: 'primary', + }, + { assignmentType: 'secondary' } + ); + } + + Object.assign(assignment, data); + return this.repository.save(assignment); + } + + async delete(ctx: ServiceContext, id: string): Promise { + const result = await this.repository.delete({ id, tenantId: ctx.tenantId }); + return result.affected ? result.affected > 0 : false; + } + + async deactivate(ctx: ServiceContext, id: string): Promise { + return this.update(ctx, id, { isActive: false }); + } + + async setAsPrimary(ctx: ServiceContext, id: string): Promise { + const assignment = await this.findById(ctx, id); + if (!assignment) { + return null; + } + + // Unset other primary assignments for this user + await this.repository.update( + { + userId: assignment.userId, + tenantId: ctx.tenantId, + assignmentType: 'primary', + }, + { assignmentType: 'secondary' } + ); + + assignment.assignmentType = 'primary'; + return this.repository.save(assignment); + } + + async getBranchManagers(ctx: ServiceContext, branchId: string): Promise { + return this.repository.find({ + where: { + branchId, + tenantId: ctx.tenantId, + branchRole: 'manager', + isActive: true, + }, + relations: ['branch'], + }); + } + + async getUserPermissionsForBranch( + ctx: ServiceContext, + userId: string, + branchId: string + ): Promise { + const assignments = await this.repository.find({ + where: { + userId, + branchId, + tenantId: ctx.tenantId, + isActive: true, + }, + }); + + // Merge all permissions from all assignments + const permissions = new Set(); + assignments.forEach(a => { + (a.permissions || []).forEach(p => permissions.add(p)); + }); + + return Array.from(permissions); + } + + async getAssignmentStatistics(ctx: ServiceContext): Promise<{ + totalAssignments: number; + activeAssignments: number; + byType: Record; + byRole: Record; + }> { + const assignments = await this.repository.find({ + where: { tenantId: ctx.tenantId }, + }); + + const byType: Record = {}; + const byRole: Record = {}; + + assignments.forEach(a => { + byType[a.assignmentType] = (byType[a.assignmentType] || 0) + 1; + if (a.branchRole) { + byRole[a.branchRole] = (byRole[a.branchRole] || 0) + 1; + } + }); + + return { + totalAssignments: assignments.length, + activeAssignments: assignments.filter(a => a.isActive).length, + byType, + byRole, + }; + } + + async transferUserToBranch( + ctx: ServiceContext, + userId: string, + fromBranchId: string, + toBranchId: string, + transferPrimary: boolean = true + ): Promise { + // Deactivate assignments at old branch + await this.repository.update( + { + userId, + branchId: fromBranchId, + tenantId: ctx.tenantId, + }, + { isActive: false } + ); + + // Create new assignment at new branch + return this.create(ctx, { + userId, + branchId: toBranchId, + assignmentType: transferPrimary ? 'primary' : 'secondary', + isActive: true, + }); + } +} diff --git a/src/modules/invoices/controllers/index.ts b/src/modules/invoices/controllers/index.ts new file mode 100644 index 0000000..5e82256 --- /dev/null +++ b/src/modules/invoices/controllers/index.ts @@ -0,0 +1,7 @@ +/** + * Invoices Controllers Index + * @module Invoices + */ + +export { createInvoiceController } from './invoice.controller'; +export { createPaymentController } from './payment.controller'; diff --git a/src/modules/invoices/controllers/invoice.controller.ts b/src/modules/invoices/controllers/invoice.controller.ts new file mode 100644 index 0000000..0c381e2 --- /dev/null +++ b/src/modules/invoices/controllers/invoice.controller.ts @@ -0,0 +1,498 @@ +/** + * InvoiceController - REST Controller for Invoices + * + * Endpoints for invoice management including CRUD and workflow operations. + * + * @module Invoices + */ + +import { Router, Request, Response } from 'express'; +import { DataSource } from 'typeorm'; +import { InvoiceService, InvoiceFilters } from '../services'; + +export function createInvoiceController(dataSource: DataSource): Router { + const router = Router(); + const service = new InvoiceService(dataSource); + + // ==================== CRUD ENDPOINTS ==================== + + /** + * GET / + * List invoices with filters and pagination + */ + router.get('/', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const filters: InvoiceFilters = { + invoiceType: req.query.invoiceType as any, + invoiceContext: req.query.invoiceContext as any, + status: req.query.status as any, + partnerId: req.query.partnerId as string, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + overdue: req.query.overdue === 'true', + search: req.query.search as string, + }; + + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + + const result = await service.findAll(ctx, filters, page, limit); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /dashboard + * Get dashboard statistics + */ + router.get('/dashboard', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const stats = await service.getDashboardStats(ctx); + res.json(stats); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /summary + * Get invoice summary + */ + router.get('/summary', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const filters: InvoiceFilters = { + invoiceContext: req.query.invoiceContext as any, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + }; + + const summary = await service.getSummary(ctx, filters); + res.json(summary); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /by-number/:invoiceNumber + * Get invoice by number + */ + router.get('/by-number/:invoiceNumber', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const invoice = await service.findByNumber(ctx, req.params.invoiceNumber); + if (!invoice) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + res.json(invoice); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /:id + * Get invoice by ID + */ + router.get('/:id', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const invoice = await service.findWithItems(ctx, req.params.id); + if (!invoice) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + res.json(invoice); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * POST / + * Create new invoice + */ + router.post('/', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const invoice = await service.create(ctx, req.body); + res.status(201).json(invoice); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * PUT /:id + * Update invoice + */ + router.put('/:id', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const invoice = await service.update(ctx, req.params.id, req.body); + if (!invoice) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + res.json(invoice); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * DELETE /:id + * Delete invoice (soft delete) + */ + router.delete('/:id', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const deleted = await service.softDelete(ctx, req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + res.status(204).send(); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ==================== ITEM ENDPOINTS ==================== + + /** + * POST /:id/items + * Add items to invoice + */ + router.post('/:id/items', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const items = await service.addItems(ctx, req.params.id, req.body.items || [req.body]); + res.status(201).json(items); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * PUT /items/:itemId + * Update invoice item + */ + router.put('/items/:itemId', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const item = await service.updateItem(ctx, req.params.itemId, req.body); + if (!item) { + res.status(404).json({ error: 'Item no encontrado' }); + return; + } + + res.json(item); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * DELETE /items/:itemId + * Remove invoice item + */ + router.delete('/items/:itemId', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const deleted = await service.removeItem(ctx, req.params.itemId); + if (!deleted) { + res.status(404).json({ error: 'Item no encontrado' }); + return; + } + + res.status(204).send(); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ==================== WORKFLOW ENDPOINTS ==================== + + /** + * POST /:id/validate + * Validate invoice (draft -> validated) + */ + router.post('/:id/validate', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const invoice = await service.validate(ctx, req.params.id); + if (!invoice) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + res.json(invoice); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/send + * Send invoice (validated -> sent) + */ + router.post('/:id/send', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const invoice = await service.send(ctx, req.params.id); + if (!invoice) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + res.json(invoice); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/record-payment + * Record partial payment + */ + router.post('/:id/record-payment', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const { amount } = req.body; + if (!amount || amount <= 0) { + res.status(400).json({ error: 'Monto de pago invalido' }); + return; + } + + const invoice = await service.recordPartialPayment(ctx, req.params.id, amount); + if (!invoice) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + res.json(invoice); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/mark-paid + * Mark invoice as paid + */ + router.post('/:id/mark-paid', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const invoice = await service.markAsPaid(ctx, req.params.id, req.body.paymentReference); + if (!invoice) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + res.json(invoice); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/void + * Void invoice + */ + router.post('/:id/void', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const invoice = await service.void(ctx, req.params.id, req.body.reason); + if (!invoice) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + res.json(invoice); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/cancel + * Cancel invoice + */ + router.post('/:id/cancel', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const invoice = await service.cancel(ctx, req.params.id, req.body.reason); + if (!invoice) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + res.json(invoice); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/refund + * Create refund (credit note) + */ + router.post('/:id/refund', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const creditNote = await service.createRefund(ctx, req.params.id, req.body.reason); + if (!creditNote) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + res.status(201).json(creditNote); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ==================== GENERATION ENDPOINTS ==================== + + /** + * POST /generate/from-sales-order + * Generate invoice from sales order + */ + router.post('/generate/from-sales-order', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const { salesOrderId, ...salesOrderData } = req.body; + const invoice = await service.generateFromSalesOrder(ctx, salesOrderId, salesOrderData); + res.status(201).json(invoice); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /generate/for-subscription + * Generate invoice for subscription period + */ + router.post('/generate/for-subscription', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const invoice = await service.generateForSubscription(ctx, req.body); + res.status(201).json(invoice); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ==================== MAINTENANCE ENDPOINTS ==================== + + /** + * POST /update-overdue + * Update overdue status for all invoices + */ + router.post('/update-overdue', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const count = await service.updateOverdueStatus(ctx); + res.json({ updated: count }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/invoices/controllers/payment.controller.ts b/src/modules/invoices/controllers/payment.controller.ts new file mode 100644 index 0000000..ee0e21b --- /dev/null +++ b/src/modules/invoices/controllers/payment.controller.ts @@ -0,0 +1,357 @@ +/** + * PaymentController - REST Controller for Payments + * + * Endpoints for payment management including CRUD, allocation, and workflow operations. + * + * @module Invoices + */ + +import { Router, Request, Response } from 'express'; +import { DataSource } from 'typeorm'; +import { PaymentService, PaymentFilters } from '../services'; + +export function createPaymentController(dataSource: DataSource): Router { + const router = Router(); + const service = new PaymentService(dataSource); + + // ==================== CRUD ENDPOINTS ==================== + + /** + * GET / + * List payments with filters and pagination + */ + router.get('/', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const filters: PaymentFilters = { + paymentType: req.query.paymentType as any, + partnerId: req.query.partnerId as string, + status: req.query.status as any, + paymentMethod: req.query.paymentMethod as string, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + search: req.query.search as string, + }; + + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + + const result = await service.findAll(ctx, filters, page, limit); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /summary + * Get payment summary + */ + router.get('/summary', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const filters: PaymentFilters = { + paymentType: req.query.paymentType as any, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + }; + + const summary = await service.getSummary(ctx, filters); + res.json(summary); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /by-partner/:partnerId + * Get payments by partner + */ + router.get('/by-partner/:partnerId', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + + const result = await service.findByPartner(ctx, req.params.partnerId, page, limit); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /invoices-for-allocation/:partnerId + * Get invoices available for allocation + */ + router.get('/invoices-for-allocation/:partnerId', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const invoices = await service.getInvoicesForAllocation(ctx, req.params.partnerId); + res.json(invoices); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /:id + * Get payment by ID + */ + router.get('/:id', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const payment = await service.findWithAllocations(ctx, req.params.id); + if (!payment) { + res.status(404).json({ error: 'Pago no encontrado' }); + return; + } + + res.json(payment); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /:id/unallocated + * Get unallocated amount for a payment + */ + router.get('/:id/unallocated', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const unallocatedAmount = await service.getUnallocatedAmount(ctx, req.params.id); + res.json({ unallocatedAmount }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /:id/allocations + * Get allocations for a payment + */ + router.get('/:id/allocations', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const allocations = await service.getAllocations(ctx, req.params.id); + res.json(allocations); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * POST / + * Create new payment + */ + router.post('/', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const payment = await service.create(ctx, req.body); + res.status(201).json(payment); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * PUT /:id + * Update payment + */ + router.put('/:id', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const payment = await service.update(ctx, req.params.id, req.body); + if (!payment) { + res.status(404).json({ error: 'Pago no encontrado' }); + return; + } + + res.json(payment); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * DELETE /:id + * Delete payment (soft delete) + */ + router.delete('/:id', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const deleted = await service.softDelete(ctx, req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Pago no encontrado' }); + return; + } + + res.status(204).send(); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ==================== ALLOCATION ENDPOINTS ==================== + + /** + * POST /:id/allocate + * Allocate payment to invoices + */ + router.post('/:id/allocate', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const { allocations } = req.body; + if (!allocations || !Array.isArray(allocations) || allocations.length === 0) { + res.status(400).json({ error: 'Se requieren asignaciones' }); + return; + } + + const result = await service.allocateToInvoices(ctx, req.params.id, allocations); + res.status(201).json(result); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * DELETE /allocations/:allocationId + * Remove allocation + */ + router.delete('/allocations/:allocationId', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const deleted = await service.removeAllocation(ctx, req.params.allocationId); + if (!deleted) { + res.status(404).json({ error: 'Asignacion no encontrada' }); + return; + } + + res.status(204).send(); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ==================== WORKFLOW ENDPOINTS ==================== + + /** + * POST /:id/confirm + * Confirm payment (draft -> confirmed) + */ + router.post('/:id/confirm', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const payment = await service.confirm(ctx, req.params.id); + if (!payment) { + res.status(404).json({ error: 'Pago no encontrado' }); + return; + } + + res.json(payment); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/reconcile + * Reconcile payment (confirmed -> reconciled) + */ + router.post('/:id/reconcile', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const payment = await service.reconcile(ctx, req.params.id); + if (!payment) { + res.status(404).json({ error: 'Pago no encontrado' }); + return; + } + + res.json(payment); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/cancel + * Cancel payment + */ + router.post('/:id/cancel', async (req: Request, res: Response): Promise => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const payment = await service.cancel(ctx, req.params.id, req.body.reason); + if (!payment) { + res.status(404).json({ error: 'Pago no encontrado' }); + return; + } + + res.json(payment); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/invoices/index.ts b/src/modules/invoices/index.ts new file mode 100644 index 0000000..2961c2a --- /dev/null +++ b/src/modules/invoices/index.ts @@ -0,0 +1,13 @@ +/** + * Invoices Module Index + * @module Invoices + */ + +// Entities +export * from './entities'; + +// Services +export * from './services'; + +// Controllers +export * from './controllers'; diff --git a/src/modules/invoices/services/index.ts b/src/modules/invoices/services/index.ts new file mode 100644 index 0000000..7bb0e3d --- /dev/null +++ b/src/modules/invoices/services/index.ts @@ -0,0 +1,19 @@ +/** + * Invoices Services Index + * @module Invoices + */ + +export { InvoiceService } from './invoice.service'; +export type { + CreateInvoiceDto, + CreateInvoiceItemDto, + InvoiceFilters, +} from './invoice.service'; + +export { PaymentService } from './payment.service'; +export type { + CreatePaymentDto, + PaymentFilters, + PaymentType, + PaymentStatus, +} from './payment.service'; diff --git a/src/modules/invoices/services/invoice.service.ts b/src/modules/invoices/services/invoice.service.ts new file mode 100644 index 0000000..f516477 --- /dev/null +++ b/src/modules/invoices/services/invoice.service.ts @@ -0,0 +1,983 @@ +/** + * InvoiceService - Invoice Management Service + * + * Handles unified invoice management for both commercial and SaaS contexts. + * Includes CRUD operations, invoice generation, and status workflow. + * + * @module Invoices + */ + +import { DataSource, Repository, IsNull } from 'typeorm'; +import { + Invoice, + InvoiceType, + InvoiceStatus, + InvoiceContext, + InvoiceItem, +} from '../entities'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface CreateInvoiceDto { + invoiceType?: InvoiceType; + invoiceContext?: InvoiceContext; + // Commercial fields + salesOrderId?: string; + purchaseOrderId?: string; + partnerId?: string; + partnerName?: string; + partnerTaxId?: string; + // SaaS billing fields + subscriptionId?: string; + periodStart?: Date; + periodEnd?: Date; + // Billing information + billingName?: string; + billingEmail?: string; + billingAddress?: Record; + taxId?: string; + // Dates + invoiceDate?: Date; + dueDate?: Date; + // Payment + currency?: string; + exchangeRate?: number; + paymentTermDays?: number; + paymentMethod?: string; + // Notes + notes?: string; + internalNotes?: string; + // Items + items?: CreateInvoiceItemDto[]; +} + +export interface CreateInvoiceItemDto { + productId?: string; + lineNumber?: number; + productSku?: string; + productName: string; + description?: string; + satProductCode?: string; + satUnitCode?: string; + quantity: number; + uom?: string; + unitPrice: number; + discountPercent?: number; + taxRate?: number; + withholdingRate?: number; +} + +export interface InvoiceFilters { + invoiceType?: InvoiceType; + invoiceContext?: InvoiceContext; + status?: InvoiceStatus; + partnerId?: string; + startDate?: Date; + endDate?: Date; + overdue?: boolean; + search?: string; +} + +interface InvoiceSummary { + totalInvoices: number; + totalAmount: number; + totalPaid: number; + totalDue: number; + byStatus: Partial>; +} + +interface DashboardStats { + totalPending: number; + totalOverdue: number; + dueThisWeek: number; + dueThisMonth: number; + countPending: number; + countOverdue: number; + recentInvoices: Invoice[]; +} + +export class InvoiceService { + private repository: Repository; + private itemRepository: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repository = dataSource.getRepository(Invoice); + this.itemRepository = dataSource.getRepository(InvoiceItem); + } + + // ==================== CRUD OPERATIONS ==================== + + /** + * Find all invoices with pagination and filters + */ + async findAll( + ctx: ServiceContext, + filters: InvoiceFilters = {}, + page = 1, + limit = 20 + ): Promise> { + const queryBuilder = this.repository + .createQueryBuilder('invoice') + .where('invoice.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('invoice.deletedAt IS NULL'); + + if (filters.invoiceType) { + queryBuilder.andWhere('invoice.invoiceType = :invoiceType', { + invoiceType: filters.invoiceType, + }); + } + + if (filters.invoiceContext) { + queryBuilder.andWhere('invoice.invoiceContext = :invoiceContext', { + invoiceContext: filters.invoiceContext, + }); + } + + if (filters.status) { + queryBuilder.andWhere('invoice.status = :status', { status: filters.status }); + } + + if (filters.partnerId) { + queryBuilder.andWhere('invoice.partnerId = :partnerId', { + partnerId: filters.partnerId, + }); + } + + if (filters.startDate) { + queryBuilder.andWhere('invoice.invoiceDate >= :startDate', { + startDate: filters.startDate, + }); + } + + if (filters.endDate) { + queryBuilder.andWhere('invoice.invoiceDate <= :endDate', { + endDate: filters.endDate, + }); + } + + if (filters.overdue) { + queryBuilder.andWhere('invoice.dueDate < :today', { today: new Date() }); + queryBuilder.andWhere('invoice.status IN (:...pendingStatuses)', { + pendingStatuses: ['draft', 'validated', 'sent', 'partial'], + }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(invoice.invoiceNumber ILIKE :search OR invoice.partnerName ILIKE :search OR invoice.billingName ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + queryBuilder.orderBy('invoice.invoiceDate', 'DESC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder + .skip((page - 1) * limit) + .take(limit) + .getMany(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Find invoice by ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + } + + /** + * Find invoice with items + */ + async findWithItems(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + relations: ['items'], + }); + } + + /** + * Find invoice by number + */ + async findByNumber(ctx: ServiceContext, invoiceNumber: string): Promise { + return this.repository.findOne({ + where: { + invoiceNumber, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + relations: ['items'], + }); + } + + /** + * Create new invoice + */ + async create(ctx: ServiceContext, data: CreateInvoiceDto): Promise { + const invoiceNumber = await this.generateInvoiceNumber(ctx, data.invoiceType || 'sale'); + + const invoice = this.repository.create({ + tenantId: ctx.tenantId, + invoiceNumber, + invoiceType: data.invoiceType || 'sale', + invoiceContext: data.invoiceContext || 'commercial', + salesOrderId: data.salesOrderId, + purchaseOrderId: data.purchaseOrderId, + partnerId: data.partnerId, + partnerName: data.partnerName, + partnerTaxId: data.partnerTaxId, + subscriptionId: data.subscriptionId, + periodStart: data.periodStart, + periodEnd: data.periodEnd, + billingName: data.billingName, + billingEmail: data.billingEmail, + billingAddress: data.billingAddress, + taxId: data.taxId, + invoiceDate: data.invoiceDate || new Date(), + dueDate: data.dueDate || this.calculateDueDate(data.invoiceDate, data.paymentTermDays), + currency: data.currency || 'MXN', + exchangeRate: data.exchangeRate || 1, + paymentTermDays: data.paymentTermDays || 30, + paymentMethod: data.paymentMethod, + notes: data.notes, + internalNotes: data.internalNotes, + status: 'draft', + createdBy: ctx.userId, + }); + + const savedInvoice = await this.repository.save(invoice); + + // Add items if provided + if (data.items && data.items.length > 0) { + await this.addItems(ctx, savedInvoice.id, data.items); + await this.recalculateTotals(ctx, savedInvoice.id); + } + + return this.findWithItems(ctx, savedInvoice.id) as Promise; + } + + /** + * Update invoice + */ + async update( + ctx: ServiceContext, + id: string, + data: Partial + ): Promise { + const invoice = await this.findById(ctx, id); + if (!invoice) { + return null; + } + + if (invoice.status !== 'draft') { + throw new Error('Solo se pueden modificar facturas en borrador'); + } + + const updateData: Partial = {}; + + if (data.partnerId !== undefined) updateData.partnerId = data.partnerId; + if (data.partnerName !== undefined) updateData.partnerName = data.partnerName; + if (data.partnerTaxId !== undefined) updateData.partnerTaxId = data.partnerTaxId; + if (data.billingName !== undefined) updateData.billingName = data.billingName; + if (data.billingEmail !== undefined) updateData.billingEmail = data.billingEmail; + if (data.billingAddress !== undefined) updateData.billingAddress = data.billingAddress; + if (data.taxId !== undefined) updateData.taxId = data.taxId; + if (data.invoiceDate !== undefined) updateData.invoiceDate = data.invoiceDate; + if (data.dueDate !== undefined) updateData.dueDate = data.dueDate; + if (data.currency !== undefined) updateData.currency = data.currency; + if (data.exchangeRate !== undefined) updateData.exchangeRate = data.exchangeRate; + if (data.paymentTermDays !== undefined) updateData.paymentTermDays = data.paymentTermDays; + if (data.paymentMethod !== undefined) updateData.paymentMethod = data.paymentMethod; + if (data.notes !== undefined) updateData.notes = data.notes; + if (data.internalNotes !== undefined) updateData.internalNotes = data.internalNotes; + + updateData.updatedBy = ctx.userId; + + Object.assign(invoice, updateData); + await this.repository.save(invoice); + + return this.findWithItems(ctx, id); + } + + /** + * Soft delete invoice + */ + async softDelete(ctx: ServiceContext, id: string): Promise { + const invoice = await this.findById(ctx, id); + if (!invoice) { + return false; + } + + if (invoice.status !== 'draft') { + throw new Error('Solo se pueden eliminar facturas en borrador'); + } + + invoice.deletedAt = new Date(); + await this.repository.save(invoice); + return true; + } + + // ==================== INVOICE ITEMS ==================== + + /** + * Add items to invoice + */ + async addItems( + ctx: ServiceContext, + invoiceId: string, + items: CreateInvoiceItemDto[] + ): Promise { + const invoice = await this.findById(ctx, invoiceId); + if (!invoice) { + throw new Error('Factura no encontrada'); + } + + if (invoice.status !== 'draft') { + throw new Error('Solo se pueden agregar items a facturas en borrador'); + } + + const savedItems: InvoiceItem[] = []; + let lineNumber = 1; + + // Get existing max line number + const existingItems = await this.itemRepository.find({ + where: { invoiceId }, + order: { lineNumber: 'DESC' }, + take: 1, + }); + + if (existingItems.length > 0) { + lineNumber = existingItems[0].lineNumber + 1; + } + + for (const itemData of items) { + const item = this.itemRepository.create({ + invoiceId, + productId: itemData.productId, + lineNumber: itemData.lineNumber || lineNumber++, + productSku: itemData.productSku, + productName: itemData.productName, + description: itemData.description, + satProductCode: itemData.satProductCode, + satUnitCode: itemData.satUnitCode, + quantity: itemData.quantity, + uom: itemData.uom || 'PZA', + unitPrice: itemData.unitPrice, + discountPercent: itemData.discountPercent || 0, + taxRate: itemData.taxRate ?? 16.0, + withholdingRate: itemData.withholdingRate || 0, + }); + + // Calculate amounts + const subtotal = item.quantity * item.unitPrice; + const discountAmount = subtotal * (item.discountPercent / 100); + const afterDiscount = subtotal - discountAmount; + const taxAmount = afterDiscount * (item.taxRate / 100); + const withholdingAmount = afterDiscount * (item.withholdingRate / 100); + const total = afterDiscount + taxAmount - withholdingAmount; + + item.subtotal = afterDiscount; + item.discountAmount = discountAmount; + item.taxAmount = taxAmount; + item.withholdingAmount = withholdingAmount; + item.total = total; + + savedItems.push(await this.itemRepository.save(item)); + } + + await this.recalculateTotals(ctx, invoiceId); + return savedItems; + } + + /** + * Update invoice item + */ + async updateItem( + ctx: ServiceContext, + itemId: string, + data: Partial + ): Promise { + const item = await this.itemRepository.findOne({ + where: { id: itemId }, + relations: ['invoice'], + }); + + if (!item || item.invoice.tenantId !== ctx.tenantId) { + return null; + } + + if (item.invoice.status !== 'draft') { + throw new Error('Solo se pueden modificar items de facturas en borrador'); + } + + if (data.productName !== undefined) item.productName = data.productName; + if (data.description !== undefined) item.description = data.description; + if (data.quantity !== undefined) item.quantity = data.quantity; + if (data.unitPrice !== undefined) item.unitPrice = data.unitPrice; + if (data.discountPercent !== undefined) item.discountPercent = data.discountPercent; + if (data.taxRate !== undefined) item.taxRate = data.taxRate; + if (data.withholdingRate !== undefined) item.withholdingRate = data.withholdingRate; + + // Recalculate amounts + const subtotal = item.quantity * item.unitPrice; + const discountAmount = subtotal * (item.discountPercent / 100); + const afterDiscount = subtotal - discountAmount; + const taxAmount = afterDiscount * (item.taxRate / 100); + const withholdingAmount = afterDiscount * (item.withholdingRate / 100); + const total = afterDiscount + taxAmount - withholdingAmount; + + item.subtotal = afterDiscount; + item.discountAmount = discountAmount; + item.taxAmount = taxAmount; + item.withholdingAmount = withholdingAmount; + item.total = total; + + const savedItem = await this.itemRepository.save(item); + await this.recalculateTotals(ctx, item.invoiceId); + + return savedItem; + } + + /** + * Remove invoice item + */ + async removeItem(ctx: ServiceContext, itemId: string): Promise { + const item = await this.itemRepository.findOne({ + where: { id: itemId }, + relations: ['invoice'], + }); + + if (!item || item.invoice.tenantId !== ctx.tenantId) { + return false; + } + + if (item.invoice.status !== 'draft') { + throw new Error('Solo se pueden eliminar items de facturas en borrador'); + } + + const invoiceId = item.invoiceId; + await this.itemRepository.remove(item); + await this.recalculateTotals(ctx, invoiceId); + + return true; + } + + // ==================== STATUS WORKFLOW ==================== + + /** + * Validate invoice (draft -> validated) + */ + async validate(ctx: ServiceContext, id: string): Promise { + return this.changeStatus(ctx, id, 'validated', ['draft']); + } + + /** + * Send invoice (validated -> sent) + */ + async send(ctx: ServiceContext, id: string): Promise { + return this.changeStatus(ctx, id, 'sent', ['validated']); + } + + /** + * Record partial payment + */ + async recordPartialPayment( + ctx: ServiceContext, + id: string, + amount: number + ): Promise { + const invoice = await this.findById(ctx, id); + if (!invoice) { + return null; + } + + if (!['sent', 'partial', 'overdue'].includes(invoice.status)) { + throw new Error('Estado invalido para registrar pago'); + } + + const newAmountPaid = Number(invoice.amountPaid) + amount; + const total = Number(invoice.total); + + invoice.amountPaid = newAmountPaid; + invoice.paidAmount = newAmountPaid; + invoice.updatedBy = ctx.userId; + + if (newAmountPaid >= total) { + invoice.status = 'paid'; + invoice.paidAt = new Date(); + invoice.paymentDate = new Date(); + } else { + invoice.status = 'partial'; + } + + return this.repository.save(invoice); + } + + /** + * Mark as paid (sent/partial -> paid) + */ + async markAsPaid( + ctx: ServiceContext, + id: string, + paymentReference?: string + ): Promise { + const invoice = await this.findById(ctx, id); + if (!invoice) { + return null; + } + + if (!['sent', 'partial', 'overdue'].includes(invoice.status)) { + throw new Error('Estado invalido para marcar como pagada'); + } + + invoice.status = 'paid'; + invoice.amountPaid = invoice.total; + invoice.paidAmount = invoice.total; + invoice.paidAt = new Date(); + invoice.paymentDate = new Date(); + invoice.paymentReference = paymentReference || invoice.paymentReference; + invoice.updatedBy = ctx.userId; + + return this.repository.save(invoice); + } + + /** + * Void invoice + */ + async void(ctx: ServiceContext, id: string, reason?: string): Promise { + const invoice = await this.findById(ctx, id); + if (!invoice) { + return null; + } + + if (invoice.status === 'paid') { + throw new Error('No se puede anular una factura pagada'); + } + + invoice.status = 'voided'; + invoice.internalNotes = `${invoice.internalNotes || ''}\n[ANULADA]: ${reason || 'Sin motivo especificado'}`; + invoice.updatedBy = ctx.userId; + + return this.repository.save(invoice); + } + + /** + * Cancel invoice + */ + async cancel(ctx: ServiceContext, id: string, reason?: string): Promise { + const invoice = await this.findById(ctx, id); + if (!invoice) { + return null; + } + + if (invoice.status === 'paid') { + throw new Error('No se puede cancelar una factura pagada'); + } + + invoice.status = 'cancelled'; + invoice.internalNotes = `${invoice.internalNotes || ''}\n[CANCELADA]: ${reason || 'Sin motivo especificado'}`; + invoice.updatedBy = ctx.userId; + + return this.repository.save(invoice); + } + + /** + * Create refund (credit note) + */ + async createRefund( + ctx: ServiceContext, + id: string, + reason?: string + ): Promise { + const originalInvoice = await this.findWithItems(ctx, id); + if (!originalInvoice) { + return null; + } + + if (originalInvoice.status !== 'paid') { + throw new Error('Solo se pueden reembolsar facturas pagadas'); + } + + // Create credit note + const creditNoteData: CreateInvoiceDto = { + invoiceType: 'credit_note', + invoiceContext: originalInvoice.invoiceContext as InvoiceContext, + partnerId: originalInvoice.partnerId ?? undefined, + partnerName: originalInvoice.partnerName ?? undefined, + partnerTaxId: originalInvoice.partnerTaxId ?? undefined, + billingName: originalInvoice.billingName ?? undefined, + billingEmail: originalInvoice.billingEmail ?? undefined, + billingAddress: originalInvoice.billingAddress ?? undefined, + currency: originalInvoice.currency, + exchangeRate: originalInvoice.exchangeRate, + notes: `Nota de credito por factura ${originalInvoice.invoiceNumber}. ${reason || ''}`, + items: originalInvoice.items?.map((item) => ({ + productId: item.productId, + productSku: item.productSku, + productName: item.productName, + description: item.description, + quantity: item.quantity, + uom: item.uom, + unitPrice: item.unitPrice, + discountPercent: item.discountPercent, + taxRate: item.taxRate, + withholdingRate: item.withholdingRate, + })), + }; + + const creditNote = await this.create(ctx, creditNoteData); + + // Mark original as refunded + originalInvoice.status = 'refunded'; + originalInvoice.updatedBy = ctx.userId; + await this.repository.save(originalInvoice); + + return creditNote; + } + + /** + * Check and update overdue invoices + */ + async updateOverdueStatus(ctx: ServiceContext): Promise { + const today = new Date(); + + const result = await this.repository + .createQueryBuilder() + .update(Invoice) + .set({ status: 'overdue' }) + .where('tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('status IN (:...statuses)', { statuses: ['sent', 'partial'] }) + .andWhere('dueDate < :today', { today }) + .andWhere('deletedAt IS NULL') + .execute(); + + return result.affected || 0; + } + + // ==================== GENERATION ==================== + + /** + * Generate invoice from sales order + */ + async generateFromSalesOrder( + ctx: ServiceContext, + salesOrderId: string, + salesOrderData: { + partnerId: string; + partnerName: string; + partnerTaxId?: string; + items: CreateInvoiceItemDto[]; + currency?: string; + paymentTermDays?: number; + } + ): Promise { + const invoiceData: CreateInvoiceDto = { + invoiceType: 'sale', + invoiceContext: 'commercial', + salesOrderId, + partnerId: salesOrderData.partnerId, + partnerName: salesOrderData.partnerName, + partnerTaxId: salesOrderData.partnerTaxId, + currency: salesOrderData.currency, + paymentTermDays: salesOrderData.paymentTermDays, + items: salesOrderData.items, + }; + + return this.create(ctx, invoiceData); + } + + /** + * Generate invoice for subscription period + */ + async generateForSubscription( + ctx: ServiceContext, + subscriptionData: { + subscriptionId: string; + billingName: string; + billingEmail: string; + taxId?: string; + periodStart: Date; + periodEnd: Date; + items: CreateInvoiceItemDto[]; + currency?: string; + } + ): Promise { + const invoiceData: CreateInvoiceDto = { + invoiceType: 'sale', + invoiceContext: 'saas', + subscriptionId: subscriptionData.subscriptionId, + billingName: subscriptionData.billingName, + billingEmail: subscriptionData.billingEmail, + taxId: subscriptionData.taxId, + periodStart: subscriptionData.periodStart, + periodEnd: subscriptionData.periodEnd, + currency: subscriptionData.currency, + items: subscriptionData.items, + }; + + return this.create(ctx, invoiceData); + } + + // ==================== REPORTS ==================== + + /** + * Get invoice summary + */ + async getSummary( + ctx: ServiceContext, + filters: InvoiceFilters = {} + ): Promise { + const queryBuilder = this.repository + .createQueryBuilder('invoice') + .where('invoice.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('invoice.deletedAt IS NULL'); + + if (filters.invoiceContext) { + queryBuilder.andWhere('invoice.invoiceContext = :context', { + context: filters.invoiceContext, + }); + } + + if (filters.startDate) { + queryBuilder.andWhere('invoice.invoiceDate >= :startDate', { + startDate: filters.startDate, + }); + } + + if (filters.endDate) { + queryBuilder.andWhere('invoice.invoiceDate <= :endDate', { + endDate: filters.endDate, + }); + } + + const invoices = await queryBuilder.getMany(); + + const summary: InvoiceSummary = { + totalInvoices: invoices.length, + totalAmount: 0, + totalPaid: 0, + totalDue: 0, + byStatus: {}, + }; + + for (const invoice of invoices) { + const total = Number(invoice.total) || 0; + const amountPaid = Number(invoice.amountPaid) || 0; + + summary.totalAmount += total; + summary.totalPaid += amountPaid; + summary.totalDue += total - amountPaid; + + if (!summary.byStatus[invoice.status]) { + summary.byStatus[invoice.status] = { count: 0, amount: 0 }; + } + summary.byStatus[invoice.status]!.count++; + summary.byStatus[invoice.status]!.amount += total; + } + + return summary; + } + + /** + * Get dashboard statistics + */ + async getDashboardStats(ctx: ServiceContext): Promise { + const today = new Date(); + const endOfWeek = new Date(today); + endOfWeek.setDate(today.getDate() + (7 - today.getDay())); + const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0); + + const baseQuery = this.repository + .createQueryBuilder('invoice') + .where('invoice.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('invoice.status IN (:...statuses)', { + statuses: ['draft', 'validated', 'sent', 'partial'], + }) + .andWhere('invoice.deletedAt IS NULL'); + + // Total pending + const totalPendingResult = await baseQuery + .clone() + .select('SUM(invoice.total - invoice.amountPaid)', 'total') + .getRawOne(); + + // Total overdue + const totalOverdueResult = await baseQuery + .clone() + .andWhere('invoice.dueDate < :today', { today }) + .select('SUM(invoice.total - invoice.amountPaid)', 'total') + .getRawOne(); + + // Due this week + const dueThisWeekResult = await baseQuery + .clone() + .andWhere('invoice.dueDate BETWEEN :today AND :endOfWeek', { today, endOfWeek }) + .select('SUM(invoice.total - invoice.amountPaid)', 'total') + .getRawOne(); + + // Due this month + const dueThisMonthResult = await baseQuery + .clone() + .andWhere('invoice.dueDate BETWEEN :today AND :endOfMonth', { today, endOfMonth }) + .select('SUM(invoice.total - invoice.amountPaid)', 'total') + .getRawOne(); + + // Counts + const countPending = await baseQuery.clone().getCount(); + const countOverdue = await baseQuery + .clone() + .andWhere('invoice.dueDate < :today', { today }) + .getCount(); + + // Recent invoices + const recentInvoices = await this.repository.find({ + where: { + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + order: { createdAt: 'DESC' }, + take: 5, + }); + + return { + totalPending: parseFloat(totalPendingResult?.total) || 0, + totalOverdue: parseFloat(totalOverdueResult?.total) || 0, + dueThisWeek: parseFloat(dueThisWeekResult?.total) || 0, + dueThisMonth: parseFloat(dueThisMonthResult?.total) || 0, + countPending, + countOverdue, + recentInvoices, + }; + } + + // ==================== PRIVATE HELPERS ==================== + + private async changeStatus( + ctx: ServiceContext, + id: string, + newStatus: InvoiceStatus, + allowedFromStatuses: InvoiceStatus[] + ): Promise { + const invoice = await this.findById(ctx, id); + if (!invoice) { + return null; + } + + if (!allowedFromStatuses.includes(invoice.status)) { + throw new Error( + `Transicion de estado invalida: de ${invoice.status} a ${newStatus}` + ); + } + + invoice.status = newStatus; + invoice.updatedBy = ctx.userId; + + return this.repository.save(invoice); + } + + private async generateInvoiceNumber( + ctx: ServiceContext, + invoiceType: InvoiceType + ): Promise { + const year = new Date().getFullYear(); + const prefix = this.getInvoicePrefix(invoiceType); + + const lastInvoice = await this.repository + .createQueryBuilder('invoice') + .where('invoice.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('invoice.invoiceNumber LIKE :pattern', { pattern: `${prefix}-${year}%` }) + .orderBy('invoice.invoiceNumber', 'DESC') + .getOne(); + + let sequence = 1; + if (lastInvoice) { + const match = lastInvoice.invoiceNumber.match(/(\d+)$/); + if (match) { + sequence = parseInt(match[1], 10) + 1; + } + } + + return `${prefix}-${year}-${String(sequence).padStart(6, '0')}`; + } + + private getInvoicePrefix(invoiceType: InvoiceType): string { + switch (invoiceType) { + case 'sale': + return 'FAC'; + case 'purchase': + return 'FP'; + case 'credit_note': + return 'NC'; + case 'debit_note': + return 'ND'; + default: + return 'INV'; + } + } + + private calculateDueDate(invoiceDate?: Date, paymentTermDays?: number): Date { + const baseDate = invoiceDate || new Date(); + const days = paymentTermDays || 30; + const dueDate = new Date(baseDate); + dueDate.setDate(dueDate.getDate() + days); + return dueDate; + } + + private async recalculateTotals(ctx: ServiceContext, invoiceId: string): Promise { + const items = await this.itemRepository.find({ where: { invoiceId } }); + + let subtotal = 0; + let discountAmount = 0; + let taxAmount = 0; + let withholdingTax = 0; + + for (const item of items) { + subtotal += Number(item.subtotal) || 0; + discountAmount += Number(item.discountAmount) || 0; + taxAmount += Number(item.taxAmount) || 0; + withholdingTax += Number(item.withholdingAmount) || 0; + } + + const total = subtotal + taxAmount - withholdingTax; + + await this.repository.update( + { id: invoiceId, tenantId: ctx.tenantId }, + { + subtotal, + discountAmount, + taxAmount, + withholdingTax, + total, + updatedBy: ctx.userId, + } + ); + } +} diff --git a/src/modules/invoices/services/payment.service.ts b/src/modules/invoices/services/payment.service.ts new file mode 100644 index 0000000..c33f99f --- /dev/null +++ b/src/modules/invoices/services/payment.service.ts @@ -0,0 +1,617 @@ +/** + * PaymentService - Payment Management Service + * + * Handles payment processing and allocation to invoices. + * + * @module Invoices + */ + +import { DataSource, Repository, IsNull } from 'typeorm'; +import { Payment, PaymentAllocation, Invoice } from '../entities'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export type PaymentType = 'received' | 'made'; +export type PaymentStatus = 'draft' | 'confirmed' | 'reconciled' | 'cancelled'; + +export interface CreatePaymentDto { + paymentType?: PaymentType; + partnerId: string; + partnerName?: string; + currency?: string; + amount: number; + exchangeRate?: number; + paymentDate?: Date; + paymentMethod: string; + reference?: string; + bankAccountId?: string; + notes?: string; + invoiceAllocations?: { + invoiceId: string; + amount: number; + }[]; +} + +export interface PaymentFilters { + paymentType?: PaymentType; + partnerId?: string; + status?: PaymentStatus; + paymentMethod?: string; + startDate?: Date; + endDate?: Date; + search?: string; +} + +interface PaymentSummary { + totalPayments: number; + totalAmount: number; + byMethod: Record; + byStatus: Partial>; +} + +export class PaymentService { + private repository: Repository; + private allocationRepository: Repository; + private invoiceRepository: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repository = dataSource.getRepository(Payment); + this.allocationRepository = dataSource.getRepository(PaymentAllocation); + this.invoiceRepository = dataSource.getRepository(Invoice); + } + + // ==================== CRUD OPERATIONS ==================== + + /** + * Find all payments with pagination and filters + */ + async findAll( + ctx: ServiceContext, + filters: PaymentFilters = {}, + page = 1, + limit = 20 + ): Promise> { + const queryBuilder = this.repository + .createQueryBuilder('payment') + .where('payment.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('payment.deletedAt IS NULL'); + + if (filters.paymentType) { + queryBuilder.andWhere('payment.paymentType = :paymentType', { + paymentType: filters.paymentType, + }); + } + + if (filters.partnerId) { + queryBuilder.andWhere('payment.partnerId = :partnerId', { + partnerId: filters.partnerId, + }); + } + + if (filters.status) { + queryBuilder.andWhere('payment.status = :status', { status: filters.status }); + } + + if (filters.paymentMethod) { + queryBuilder.andWhere('payment.paymentMethod = :paymentMethod', { + paymentMethod: filters.paymentMethod, + }); + } + + if (filters.startDate) { + queryBuilder.andWhere('payment.paymentDate >= :startDate', { + startDate: filters.startDate, + }); + } + + if (filters.endDate) { + queryBuilder.andWhere('payment.paymentDate <= :endDate', { + endDate: filters.endDate, + }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(payment.paymentNumber ILIKE :search OR payment.partnerName ILIKE :search OR payment.reference ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + queryBuilder.orderBy('payment.paymentDate', 'DESC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder + .skip((page - 1) * limit) + .take(limit) + .getMany(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Find payment by ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + } + + /** + * Find payment with allocations + */ + async findWithAllocations(ctx: ServiceContext, id: string): Promise { + const payment = await this.findById(ctx, id); + if (!payment) { + return null; + } + + const allocations = await this.allocationRepository.find({ + where: { paymentId: id }, + relations: ['invoice'], + }); + + (payment as any).allocations = allocations; + return payment; + } + + /** + * Create new payment + */ + async create(ctx: ServiceContext, data: CreatePaymentDto): Promise { + const paymentNumber = await this.generatePaymentNumber(ctx, data.paymentType || 'received'); + + const payment = this.repository.create({ + tenantId: ctx.tenantId, + paymentNumber, + paymentType: data.paymentType || 'received', + partnerId: data.partnerId, + partnerName: data.partnerName || '', + currency: data.currency || 'MXN', + amount: data.amount, + exchangeRate: data.exchangeRate || 1, + paymentDate: data.paymentDate || new Date(), + paymentMethod: data.paymentMethod, + reference: data.reference, + bankAccountId: data.bankAccountId, + notes: data.notes, + status: 'draft', + createdBy: ctx.userId, + }); + + const savedPayment = await this.repository.save(payment); + + // Process allocations if provided + if (data.invoiceAllocations && data.invoiceAllocations.length > 0) { + await this.allocateToInvoices(ctx, savedPayment.id, data.invoiceAllocations); + } + + return this.findById(ctx, savedPayment.id) as Promise; + } + + /** + * Update payment + */ + async update( + ctx: ServiceContext, + id: string, + data: Partial + ): Promise { + const payment = await this.findById(ctx, id); + if (!payment) { + return null; + } + + if (payment.status !== 'draft') { + throw new Error('Solo se pueden modificar pagos en borrador'); + } + + if (data.partnerId !== undefined) payment.partnerId = data.partnerId; + if (data.partnerName !== undefined) payment.partnerName = data.partnerName || ''; + if (data.amount !== undefined) payment.amount = data.amount; + if (data.paymentDate !== undefined) payment.paymentDate = data.paymentDate; + if (data.paymentMethod !== undefined) payment.paymentMethod = data.paymentMethod; + if (data.reference !== undefined) payment.reference = data.reference || ''; + if (data.bankAccountId !== undefined) payment.bankAccountId = data.bankAccountId || ''; + if (data.notes !== undefined) payment.notes = data.notes || ''; + + return this.repository.save(payment); + } + + /** + * Soft delete payment + */ + async softDelete(ctx: ServiceContext, id: string): Promise { + const payment = await this.findById(ctx, id); + if (!payment) { + return false; + } + + if (payment.status !== 'draft') { + throw new Error('Solo se pueden eliminar pagos en borrador'); + } + + payment.deletedAt = new Date(); + await this.repository.save(payment); + return true; + } + + // ==================== ALLOCATION ==================== + + /** + * Allocate payment to invoices + */ + async allocateToInvoices( + ctx: ServiceContext, + paymentId: string, + allocations: { invoiceId: string; amount: number }[] + ): Promise { + const payment = await this.findById(ctx, paymentId); + if (!payment) { + throw new Error('Pago no encontrado'); + } + + if (payment.status === 'cancelled') { + throw new Error('No se puede asignar a un pago cancelado'); + } + + // Validate total allocation + const totalAllocation = allocations.reduce((sum, a) => sum + a.amount, 0); + const existingAllocations = await this.allocationRepository.find({ + where: { paymentId }, + }); + const existingTotal = existingAllocations.reduce( + (sum, a) => sum + Number(a.amount), + 0 + ); + + if (existingTotal + totalAllocation > Number(payment.amount)) { + throw new Error('El total de asignaciones excede el monto del pago'); + } + + const savedAllocations: PaymentAllocation[] = []; + + for (const allocation of allocations) { + const invoice = await this.invoiceRepository.findOne({ + where: { + id: allocation.invoiceId, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + + if (!invoice) { + throw new Error(`Factura ${allocation.invoiceId} no encontrada`); + } + + // Validate invoice status + if (!['sent', 'partial', 'overdue'].includes(invoice.status)) { + throw new Error( + `Factura ${invoice.invoiceNumber} no puede recibir pagos en estado ${invoice.status}` + ); + } + + // Validate amount + const amountDue = Number(invoice.total) - Number(invoice.amountPaid); + if (allocation.amount > amountDue) { + throw new Error( + `El monto de asignacion excede el saldo pendiente de la factura ${invoice.invoiceNumber}` + ); + } + + // Create allocation + const paymentAllocation = this.allocationRepository.create({ + paymentId, + invoiceId: allocation.invoiceId, + amount: allocation.amount, + allocationDate: new Date(), + createdBy: ctx.userId, + }); + + savedAllocations.push(await this.allocationRepository.save(paymentAllocation)); + + // Update invoice + const newAmountPaid = Number(invoice.amountPaid) + allocation.amount; + invoice.amountPaid = newAmountPaid; + invoice.paidAmount = newAmountPaid; + + if (newAmountPaid >= Number(invoice.total)) { + invoice.status = 'paid'; + invoice.paidAt = new Date(); + invoice.paymentDate = new Date(); + } else { + invoice.status = 'partial'; + } + + invoice.updatedBy = ctx.userId; + await this.invoiceRepository.save(invoice); + } + + return savedAllocations; + } + + /** + * Remove allocation + */ + async removeAllocation(ctx: ServiceContext, allocationId: string): Promise { + const allocation = await this.allocationRepository.findOne({ + where: { id: allocationId }, + relations: ['payment', 'invoice'], + }); + + if (!allocation || allocation.payment.tenantId !== ctx.tenantId) { + return false; + } + + if (allocation.payment.status === 'reconciled') { + throw new Error('No se pueden eliminar asignaciones de pagos conciliados'); + } + + // Revert invoice amounts + const invoice = allocation.invoice; + const newAmountPaid = Number(invoice.amountPaid) - Number(allocation.amount); + invoice.amountPaid = Math.max(0, newAmountPaid); + invoice.paidAmount = invoice.amountPaid; + + if (invoice.status === 'paid' || invoice.status === 'partial') { + if (invoice.amountPaid <= 0) { + invoice.status = 'sent'; + invoice.paidAt = null; + invoice.paymentDate = null; + } else { + invoice.status = 'partial'; + } + } + + invoice.updatedBy = ctx.userId; + await this.invoiceRepository.save(invoice); + + await this.allocationRepository.remove(allocation); + return true; + } + + /** + * Get allocations for a payment + */ + async getAllocations( + ctx: ServiceContext, + paymentId: string + ): Promise { + const payment = await this.findById(ctx, paymentId); + if (!payment) { + return []; + } + + return this.allocationRepository.find({ + where: { paymentId }, + relations: ['invoice'], + }); + } + + // ==================== STATUS WORKFLOW ==================== + + /** + * Confirm payment (draft -> confirmed) + */ + async confirm(ctx: ServiceContext, id: string): Promise { + const payment = await this.findById(ctx, id); + if (!payment) { + return null; + } + + if (payment.status !== 'draft') { + throw new Error('Solo se pueden confirmar pagos en borrador'); + } + + payment.status = 'confirmed'; + return this.repository.save(payment); + } + + /** + * Reconcile payment (confirmed -> reconciled) + */ + async reconcile(ctx: ServiceContext, id: string): Promise { + const payment = await this.findById(ctx, id); + if (!payment) { + return null; + } + + if (payment.status !== 'confirmed') { + throw new Error('Solo se pueden conciliar pagos confirmados'); + } + + payment.status = 'reconciled'; + return this.repository.save(payment); + } + + /** + * Cancel payment + */ + async cancel(ctx: ServiceContext, id: string, reason?: string): Promise { + const payment = await this.findById(ctx, id); + if (!payment) { + return null; + } + + if (payment.status === 'reconciled') { + throw new Error('No se pueden cancelar pagos conciliados'); + } + + // Remove all allocations and revert invoice amounts + const allocations = await this.getAllocations(ctx, id); + for (const allocation of allocations) { + await this.removeAllocation(ctx, allocation.id); + } + + payment.status = 'cancelled'; + payment.notes = `${payment.notes || ''}\n[CANCELADO]: ${reason || 'Sin motivo especificado'}`; + + return this.repository.save(payment); + } + + // ==================== REPORTS ==================== + + /** + * Get payment summary + */ + async getSummary( + ctx: ServiceContext, + filters: PaymentFilters = {} + ): Promise { + const queryBuilder = this.repository + .createQueryBuilder('payment') + .where('payment.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('payment.deletedAt IS NULL') + .andWhere('payment.status != :cancelled', { cancelled: 'cancelled' }); + + if (filters.paymentType) { + queryBuilder.andWhere('payment.paymentType = :paymentType', { + paymentType: filters.paymentType, + }); + } + + if (filters.startDate) { + queryBuilder.andWhere('payment.paymentDate >= :startDate', { + startDate: filters.startDate, + }); + } + + if (filters.endDate) { + queryBuilder.andWhere('payment.paymentDate <= :endDate', { + endDate: filters.endDate, + }); + } + + const payments = await queryBuilder.getMany(); + + const summary: PaymentSummary = { + totalPayments: payments.length, + totalAmount: 0, + byMethod: {}, + byStatus: {}, + }; + + for (const payment of payments) { + const amount = Number(payment.amount) || 0; + + summary.totalAmount += amount; + + // By method + if (!summary.byMethod[payment.paymentMethod]) { + summary.byMethod[payment.paymentMethod] = { count: 0, amount: 0 }; + } + summary.byMethod[payment.paymentMethod].count++; + summary.byMethod[payment.paymentMethod].amount += amount; + + // By status + if (!summary.byStatus[payment.status]) { + summary.byStatus[payment.status] = { count: 0, amount: 0 }; + } + summary.byStatus[payment.status]!.count++; + summary.byStatus[payment.status]!.amount += amount; + } + + return summary; + } + + /** + * Get payments by partner + */ + async findByPartner( + ctx: ServiceContext, + partnerId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { partnerId }, page, limit); + } + + /** + * Get unallocated amount for a payment + */ + async getUnallocatedAmount(ctx: ServiceContext, paymentId: string): Promise { + const payment = await this.findById(ctx, paymentId); + if (!payment) { + return 0; + } + + const allocations = await this.allocationRepository.find({ + where: { paymentId }, + }); + + const allocatedAmount = allocations.reduce( + (sum, a) => sum + Number(a.amount), + 0 + ); + + return Number(payment.amount) - allocatedAmount; + } + + /** + * Get invoices available for allocation + */ + async getInvoicesForAllocation( + ctx: ServiceContext, + partnerId: string + ): Promise { + return this.invoiceRepository.find({ + where: { + tenantId: ctx.tenantId, + partnerId, + deletedAt: IsNull(), + }, + }).then((invoices) => + invoices.filter((inv) => ['sent', 'partial', 'overdue'].includes(inv.status)) + ); + } + + // ==================== PRIVATE HELPERS ==================== + + private async generatePaymentNumber( + ctx: ServiceContext, + paymentType: PaymentType + ): Promise { + const year = new Date().getFullYear(); + const prefix = paymentType === 'received' ? 'REC' : 'PAG'; + + const lastPayment = await this.repository + .createQueryBuilder('payment') + .where('payment.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('payment.paymentNumber LIKE :pattern', { + pattern: `${prefix}-${year}%`, + }) + .orderBy('payment.paymentNumber', 'DESC') + .getOne(); + + let sequence = 1; + if (lastPayment) { + const match = lastPayment.paymentNumber.match(/(\d+)$/); + if (match) { + sequence = parseInt(match[1], 10) + 1; + } + } + + return `${prefix}-${year}-${String(sequence).padStart(6, '0')}`; + } +} diff --git a/src/modules/notifications/controllers/in-app-notification.controller.ts b/src/modules/notifications/controllers/in-app-notification.controller.ts new file mode 100644 index 0000000..ce259b8 --- /dev/null +++ b/src/modules/notifications/controllers/in-app-notification.controller.ts @@ -0,0 +1,627 @@ +/** + * In-App Notification Controller + * REST endpoints for in-app notification management. + * + * @module Notifications + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + InAppNotificationService, + CreateInAppNotificationDto, + InAppNotificationFilters, +} from '../services/in-app-notification.service'; +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'; +import { InAppNotification, InAppCategory, InAppPriority } from '../entities/in-app-notification.entity'; + +/** + * Create in-app notification controller router + */ +export function createInAppNotificationController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + const inAppNotificationRepository = dataSource.getRepository(InAppNotification); + + // Services + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + const inAppNotificationService = new InAppNotificationService(inAppNotificationRepository); + + // Helper to get service context + const getContext = (req: Request) => ({ + tenantId: req.tenantId!, + userId: req.user?.sub, + }); + + /** + * GET /notifications/in-app + * Get all in-app notifications for current user + */ + router.get( + '/', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const includeRead = req.query.includeRead === 'true'; + const includeArchived = req.query.includeArchived === 'true'; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); + + const notifications = await inAppNotificationService.findByUser(ctx, ctx.userId, { + includeRead, + includeArchived, + limit, + }); + + res.status(200).json({ success: true, data: notifications }); + } catch (error) { + next(error); + } + }, + ); + + /** + * GET /notifications/in-app/counts + * Get notification counts for current user + */ + router.get( + '/counts', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const counts = await inAppNotificationService.getCounts(ctx, ctx.userId); + + res.status(200).json({ success: true, data: counts }); + } catch (error) { + next(error); + } + }, + ); + + /** + * GET /notifications/in-app/recent + * Get recent notifications since a given date + */ + router.get( + '/recent', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const since = req.query.since + ? new Date(req.query.since as string) + : new Date(Date.now() - 24 * 60 * 60 * 1000); // Default: last 24 hours + + const notifications = await inAppNotificationService.getRecent(ctx, ctx.userId, since); + + res.status(200).json({ success: true, data: notifications }); + } catch (error) { + next(error); + } + }, + ); + + /** + * GET /notifications/in-app/:id + * Get a specific in-app notification + */ + router.get( + '/:id', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const notification = await inAppNotificationService.findById(ctx, req.params.id); + if (!notification) { + res.status(404).json({ error: 'Not Found', message: 'Notification not found' }); + return; + } + + // Ensure user can only access their own notifications + if (notification.userId !== ctx.userId) { + res.status(403).json({ error: 'Forbidden', message: 'Access denied' }); + return; + } + + res.status(200).json({ success: true, data: notification }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/in-app/:id/read + * Mark notification as read + */ + router.post( + '/:id/read', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const notification = await inAppNotificationService.markAsRead(ctx, req.params.id); + if (!notification) { + res.status(404).json({ error: 'Not Found', message: 'Notification not found' }); + return; + } + + res.status(200).json({ success: true, data: notification }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/in-app/:id/unread + * Mark notification as unread + */ + router.post( + '/:id/unread', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const notification = await inAppNotificationService.markAsUnread(ctx, req.params.id); + if (!notification) { + res.status(404).json({ error: 'Not Found', message: 'Notification not found' }); + return; + } + + res.status(200).json({ success: true, data: notification }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/in-app/read-all + * Mark all notifications as read for current user + */ + router.post( + '/read-all', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const count = await inAppNotificationService.markAllAsRead(ctx, ctx.userId); + + res.status(200).json({ success: true, data: { updated: count } }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/in-app/read-bulk + * Mark multiple notifications as read + */ + router.post( + '/read-bulk', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { ids } = req.body; + if (!Array.isArray(ids) || ids.length === 0) { + res.status(400).json({ error: 'Bad Request', message: 'ids array is required' }); + return; + } + + const count = await inAppNotificationService.markMultipleAsRead(ctx, ids); + + res.status(200).json({ success: true, data: { updated: count } }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/in-app/:id/archive + * Archive a notification + */ + router.post( + '/:id/archive', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const notification = await inAppNotificationService.archive(ctx, req.params.id); + if (!notification) { + res.status(404).json({ error: 'Not Found', message: 'Notification not found' }); + return; + } + + res.status(200).json({ success: true, data: notification }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/in-app/:id/unarchive + * Unarchive a notification + */ + router.post( + '/:id/unarchive', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const notification = await inAppNotificationService.unarchive(ctx, req.params.id); + if (!notification) { + res.status(404).json({ error: 'Not Found', message: 'Notification not found' }); + return; + } + + res.status(200).json({ success: true, data: notification }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/in-app/archive-all + * Archive all notifications for current user + */ + router.post( + '/archive-all', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const count = await inAppNotificationService.archiveAll(ctx, ctx.userId); + + res.status(200).json({ success: true, data: { archived: count } }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/in-app/archive-bulk + * Archive multiple notifications + */ + router.post( + '/archive-bulk', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { ids } = req.body; + if (!Array.isArray(ids) || ids.length === 0) { + res.status(400).json({ error: 'Bad Request', message: 'ids array is required' }); + return; + } + + const count = await inAppNotificationService.archiveMultiple(ctx, ids); + + res.status(200).json({ success: true, data: { archived: count } }); + } catch (error) { + next(error); + } + }, + ); + + /** + * DELETE /notifications/in-app/:id + * Delete a notification + */ + router.delete( + '/:id', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await inAppNotificationService.delete(ctx, req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Notification not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Notification deleted' }); + } catch (error) { + next(error); + } + }, + ); + + /** + * DELETE /notifications/in-app/archived + * Delete all archived notifications for current user + */ + router.delete( + '/archived', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const count = await inAppNotificationService.deleteArchived(ctx, ctx.userId); + + res.status(200).json({ success: true, data: { deleted: count } }); + } catch (error) { + next(error); + } + }, + ); + + // ============= Admin Routes ============= + + /** + * GET /notifications/in-app/admin/all + * Get all in-app notifications (admin) + */ + router.get( + '/admin/all', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'super_admin'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: InAppNotificationFilters = { + userId: req.query.userId as string, + category: req.query.category as InAppCategory, + priority: req.query.priority as InAppPriority, + isRead: req.query.isRead === 'true' ? true : req.query.isRead === 'false' ? false : undefined, + isArchived: req.query.isArchived === 'true' ? true : req.query.isArchived === 'false' ? false : undefined, + contextType: req.query.contextType as string, + contextId: req.query.contextId as string, + fromDate: req.query.fromDate ? new Date(req.query.fromDate as string) : undefined, + toDate: req.query.toDate ? new Date(req.query.toDate as string) : undefined, + page: parseInt(req.query.page as string) || 1, + limit: Math.min(parseInt(req.query.limit as string) || 20, 100), + }; + + const result = await inAppNotificationService.findAll(ctx, filters); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + page: result.page, + limit: result.limit, + total: result.total, + totalPages: Math.ceil(result.total / result.limit), + }, + }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/in-app/admin/create + * Create an in-app notification (admin) + */ + router.post( + '/admin/create', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'super_admin'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateInAppNotificationDto = req.body; + + if (!dto.userId || !dto.title || !dto.message) { + res.status(400).json({ + error: 'Bad Request', + message: 'userId, title, and message are required', + }); + return; + } + + const notification = await inAppNotificationService.create(ctx, dto); + + res.status(201).json({ success: true, data: notification }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/in-app/admin/broadcast + * Broadcast notification to multiple users (admin) + */ + router.post( + '/admin/broadcast', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'super_admin'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { userIds, title, message, category, priority, actionUrl } = req.body; + + if (!Array.isArray(userIds) || userIds.length === 0) { + res.status(400).json({ error: 'Bad Request', message: 'userIds array is required' }); + return; + } + + if (!title || !message) { + res.status(400).json({ + error: 'Bad Request', + message: 'title and message are required', + }); + return; + } + + const count = await inAppNotificationService.broadcastToTenant(ctx, userIds, { + title, + message, + category, + priority, + actionUrl, + }); + + res.status(201).json({ success: true, data: { created: count } }); + } catch (error) { + next(error); + } + }, + ); + + /** + * GET /notifications/in-app/admin/by-context/:contextType/:contextId + * Get notifications by context (admin) + */ + router.get( + '/admin/by-context/:contextType/:contextId', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'super_admin'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const notifications = await inAppNotificationService.findByContext( + ctx, + req.params.contextType, + req.params.contextId, + ); + + res.status(200).json({ success: true, data: notifications }); + } catch (error) { + next(error); + } + }, + ); + + /** + * DELETE /notifications/in-app/admin/by-context/:contextType/:contextId + * Delete notifications by context (admin) + */ + router.delete( + '/admin/by-context/:contextType/:contextId', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'super_admin'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const count = await inAppNotificationService.deleteByContext( + ctx, + req.params.contextType, + req.params.contextId, + ); + + res.status(200).json({ success: true, data: { deleted: count } }); + } catch (error) { + next(error); + } + }, + ); + + return router; +} + +export default createInAppNotificationController; diff --git a/src/modules/notifications/controllers/index.ts b/src/modules/notifications/controllers/index.ts new file mode 100644 index 0000000..64a4b89 --- /dev/null +++ b/src/modules/notifications/controllers/index.ts @@ -0,0 +1,8 @@ +/** + * Notifications Controllers Index + * @module Notifications + */ + +export * from './notification.controller'; +export * from './preference.controller'; +export * from './in-app-notification.controller'; diff --git a/src/modules/notifications/controllers/notification.controller.ts b/src/modules/notifications/controllers/notification.controller.ts new file mode 100644 index 0000000..a680446 --- /dev/null +++ b/src/modules/notifications/controllers/notification.controller.ts @@ -0,0 +1,752 @@ +/** + * Notification Controller + * REST endpoints for notification management. + * + * @module Notifications + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + NotificationService, + SendNotificationDto, + NotificationFilters, +} from '../services/notification.service'; +import { TemplateService } from '../services/template.service'; +import { ChannelService } from '../services/channel.service'; +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'; +import { + Notification, + NotificationStatus, + NotificationPriority, +} from '../entities/notification.entity'; +import { NotificationTemplate, TemplateTranslation } from '../entities/template.entity'; +import { NotificationPreference } from '../entities/preference.entity'; +import { Channel, ChannelType } from '../entities/channel.entity'; +import { InAppNotification } from '../entities/in-app-notification.entity'; + +/** + * Create notification controller router + */ +export function createNotificationController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + const notificationRepository = dataSource.getRepository(Notification); + const templateRepository = dataSource.getRepository(NotificationTemplate); + const translationRepository = dataSource.getRepository(TemplateTranslation); + const preferenceRepository = dataSource.getRepository(NotificationPreference); + const channelRepository = dataSource.getRepository(Channel); + const inAppNotificationRepository = dataSource.getRepository(InAppNotification); + + // Services + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + const notificationService = new NotificationService( + notificationRepository, + templateRepository, + preferenceRepository, + channelRepository, + inAppNotificationRepository, + ); + const templateService = new TemplateService(templateRepository, translationRepository); + const channelService = new ChannelService(channelRepository); + + // Helper to get service context + const getContext = (req: Request) => ({ + tenantId: req.tenantId!, + userId: req.user?.sub, + }); + + /** + * GET /notifications + * List notifications with filters + */ + router.get( + '/', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: NotificationFilters = { + userId: req.query.userId as string, + channelType: req.query.channelType as ChannelType, + status: req.query.status as NotificationStatus, + priority: req.query.priority as NotificationPriority, + contextType: req.query.contextType as string, + contextId: req.query.contextId as string, + fromDate: req.query.fromDate ? new Date(req.query.fromDate as string) : undefined, + toDate: req.query.toDate ? new Date(req.query.toDate as string) : undefined, + page: parseInt(req.query.page as string) || 1, + limit: Math.min(parseInt(req.query.limit as string) || 20, 100), + }; + + const result = await notificationService.findAll(ctx, filters); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + page: result.page, + limit: result.limit, + total: result.total, + totalPages: Math.ceil(result.total / result.limit), + }, + }); + } catch (error) { + next(error); + } + }, + ); + + /** + * GET /notifications/stats + * Get notification statistics + */ + router.get( + '/stats', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const options = { + fromDate: req.query.fromDate ? new Date(req.query.fromDate as string) : undefined, + toDate: req.query.toDate ? new Date(req.query.toDate as string) : undefined, + userId: req.query.userId as string, + }; + + const stats = await notificationService.getStatistics(ctx, options); + + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }, + ); + + /** + * GET /notifications/:id + * Get notification by ID + */ + router.get( + '/:id', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const notification = await notificationService.findById(ctx, req.params.id); + if (!notification) { + res.status(404).json({ error: 'Not Found', message: 'Notification not found' }); + return; + } + + res.status(200).json({ success: true, data: notification }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/send + * Send a notification using a template + */ + router.post( + '/send', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: SendNotificationDto = req.body; + + if (!dto.templateCode || !dto.channelType) { + res.status(400).json({ + error: 'Bad Request', + message: 'templateCode and channelType are required', + }); + return; + } + + const notification = await notificationService.send(ctx, dto); + + res.status(201).json({ success: true, data: notification }); + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('Template not found')) { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message.includes('disabled')) { + res.status(422).json({ error: 'Unprocessable Entity', message: error.message }); + return; + } + } + next(error); + } + }, + ); + + /** + * POST /notifications/:id/mark-read + * Mark notification as read + */ + router.post( + '/:id/mark-read', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const notification = await notificationService.markAsRead(ctx, req.params.id); + if (!notification) { + res.status(404).json({ error: 'Not Found', message: 'Notification not found' }); + return; + } + + res.status(200).json({ success: true, data: notification }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/:id/mark-unread + * Mark notification as unread + */ + router.post( + '/:id/mark-unread', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const notification = await notificationService.markAsUnread(ctx, req.params.id); + if (!notification) { + res.status(404).json({ error: 'Not Found', message: 'Notification not found' }); + return; + } + + res.status(200).json({ success: true, data: notification }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/mark-read-bulk + * Mark multiple notifications as read + */ + router.post( + '/mark-read-bulk', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { ids } = req.body; + if (!Array.isArray(ids) || ids.length === 0) { + res.status(400).json({ error: 'Bad Request', message: 'ids array is required' }); + return; + } + + const count = await notificationService.markMultipleAsRead(ctx, ids); + + res.status(200).json({ success: true, data: { updated: count } }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/:id/cancel + * Cancel a pending notification + */ + router.post( + '/:id/cancel', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const notification = await notificationService.cancel(ctx, req.params.id); + if (!notification) { + res.status(404).json({ error: 'Not Found', message: 'Notification not found' }); + return; + } + + res.status(200).json({ success: true, data: notification }); + } catch (error) { + if (error instanceof Error && error.message.includes('Only pending')) { + res.status(422).json({ error: 'Unprocessable Entity', message: error.message }); + return; + } + next(error); + } + }, + ); + + /** + * POST /notifications/:id/retry + * Retry a failed notification + */ + router.post( + '/:id/retry', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const notification = await notificationService.retry(ctx, req.params.id); + if (!notification) { + res.status(404).json({ error: 'Not Found', message: 'Notification not found' }); + return; + } + + res.status(200).json({ success: true, data: notification }); + } catch (error) { + if (error instanceof Error && error.message.includes('Only failed')) { + res.status(422).json({ error: 'Unprocessable Entity', message: error.message }); + return; + } + next(error); + } + }, + ); + + // ============= Template Routes ============= + + /** + * GET /notifications/templates + * List notification templates + */ + router.get( + '/templates', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters = { + category: req.query.category as any, + channelType: req.query.channelType as ChannelType, + isActive: req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : undefined, + isSystem: req.query.isSystem === 'true' ? true : req.query.isSystem === 'false' ? false : undefined, + search: req.query.search as string, + page: parseInt(req.query.page as string) || 1, + limit: Math.min(parseInt(req.query.limit as string) || 20, 100), + }; + + const result = await templateService.findAll(ctx, filters); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + page: result.page, + limit: result.limit, + total: result.total, + totalPages: Math.ceil(result.total / result.limit), + }, + }); + } catch (error) { + next(error); + } + }, + ); + + /** + * GET /notifications/templates/:id + * Get template by ID + */ + router.get( + '/templates/:id', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const template = await templateService.findById(ctx, req.params.id); + if (!template) { + res.status(404).json({ error: 'Not Found', message: 'Template not found' }); + return; + } + + res.status(200).json({ success: true, data: template }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/templates + * Create a new template + */ + router.post( + '/templates', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'super_admin'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { code, name, channelType } = req.body; + if (!code || !name || !channelType) { + res.status(400).json({ + error: 'Bad Request', + message: 'code, name, and channelType are required', + }); + return; + } + + const template = await templateService.create(ctx, req.body); + + res.status(201).json({ success: true, data: template }); + } catch (error) { + if (error instanceof Error && error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }, + ); + + /** + * PATCH /notifications/templates/:id + * Update a template + */ + router.patch( + '/templates/:id', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'super_admin'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const template = await templateService.update(ctx, req.params.id, req.body); + if (!template) { + res.status(404).json({ error: 'Not Found', message: 'Template not found' }); + return; + } + + res.status(200).json({ success: true, data: template }); + } catch (error) { + if (error instanceof Error && error.message.includes('Cannot modify system')) { + res.status(403).json({ error: 'Forbidden', message: error.message }); + return; + } + next(error); + } + }, + ); + + /** + * DELETE /notifications/templates/:id + * Delete a template + */ + router.delete( + '/templates/:id', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'super_admin'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await templateService.delete(ctx, req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Template not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Template deleted' }); + } catch (error) { + if (error instanceof Error && error.message.includes('Cannot delete system')) { + res.status(403).json({ error: 'Forbidden', message: error.message }); + return; + } + next(error); + } + }, + ); + + /** + * POST /notifications/templates/:id/preview + * Preview a template with sample variables + */ + router.post( + '/templates/:id/preview', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { variables, locale } = req.body; + const rendered = await templateService.preview(ctx, req.params.id, variables, locale); + + res.status(200).json({ success: true, data: rendered }); + } catch (error) { + if (error instanceof Error && error.message.includes('not found')) { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }, + ); + + // ============= Channel Routes ============= + + /** + * GET /notifications/channels + * List notification channels + */ + router.get( + '/channels', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const filters = { + channelType: req.query.channelType as ChannelType, + isActive: req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : undefined, + provider: req.query.provider as string, + page: parseInt(req.query.page as string) || 1, + limit: Math.min(parseInt(req.query.limit as string) || 20, 100), + }; + + const result = await channelService.findAll(filters); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + page: result.page, + limit: result.limit, + total: result.total, + totalPages: Math.ceil(result.total / result.limit), + }, + }); + } catch (error) { + next(error); + } + }, + ); + + /** + * GET /notifications/channels/types + * Get available channel types + */ + router.get( + '/channels/types', + authMiddleware.authenticate, + async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + const types = channelService.getChannelTypes(); + res.status(200).json({ success: true, data: types }); + } catch (error) { + next(error); + } + }, + ); + + /** + * GET /notifications/channels/:id + * Get channel by ID + */ + router.get( + '/channels/:id', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const channel = await channelService.findById(req.params.id); + if (!channel) { + res.status(404).json({ error: 'Not Found', message: 'Channel not found' }); + return; + } + + res.status(200).json({ success: true, data: channel }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/channels + * Create a new channel (admin only) + */ + router.post( + '/channels', + authMiddleware.authenticate, + authMiddleware.authorize('super_admin'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { code, name, channelType } = req.body; + if (!code || !name || !channelType) { + res.status(400).json({ + error: 'Bad Request', + message: 'code, name, and channelType are required', + }); + return; + } + + const channel = await channelService.create(req.body); + + res.status(201).json({ success: true, data: channel }); + } catch (error) { + if (error instanceof Error && error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }, + ); + + /** + * PATCH /notifications/channels/:id + * Update a channel (admin only) + */ + router.patch( + '/channels/:id', + authMiddleware.authenticate, + authMiddleware.authorize('super_admin'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const channel = await channelService.update(req.params.id, req.body); + if (!channel) { + res.status(404).json({ error: 'Not Found', message: 'Channel not found' }); + return; + } + + res.status(200).json({ success: true, data: channel }); + } catch (error) { + next(error); + } + }, + ); + + /** + * DELETE /notifications/channels/:id + * Delete a channel (admin only) + */ + router.delete( + '/channels/:id', + authMiddleware.authenticate, + authMiddleware.authorize('super_admin'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const deleted = await channelService.delete(req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Channel not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Channel deleted' }); + } catch (error) { + if (error instanceof Error && error.message.includes('Cannot delete')) { + res.status(422).json({ error: 'Unprocessable Entity', message: error.message }); + return; + } + next(error); + } + }, + ); + + /** + * POST /notifications/channels/:id/test + * Test channel connectivity + */ + router.post( + '/channels/:id/test', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'super_admin'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const result = await channelService.testChannel(req.params.id); + + res.status(200).json({ success: result.success, data: result }); + } catch (error) { + next(error); + } + }, + ); + + return router; +} + +export default createNotificationController; diff --git a/src/modules/notifications/controllers/preference.controller.ts b/src/modules/notifications/controllers/preference.controller.ts new file mode 100644 index 0000000..a8d4f8b --- /dev/null +++ b/src/modules/notifications/controllers/preference.controller.ts @@ -0,0 +1,469 @@ +/** + * Preference Controller + * REST endpoints for user notification preferences. + * + * @module Notifications + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + PreferenceService, + UpdatePreferenceDto, + ChannelPreferences, +} from '../services/preference.service'; +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'; +import { NotificationPreference, DigestFrequency } from '../entities/preference.entity'; + +/** + * Create preference controller router + */ +export function createPreferenceController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + const preferenceRepository = dataSource.getRepository(NotificationPreference); + + // Services + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + const preferenceService = new PreferenceService(preferenceRepository); + + // Helper to get service context + const getContext = (req: Request) => ({ + tenantId: req.tenantId!, + userId: req.user?.sub, + }); + + /** + * GET /notifications/preferences/me + * Get current user's notification preferences + */ + router.get( + '/me', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const preferences = await preferenceService.getOrCreate(ctx, ctx.userId); + + res.status(200).json({ success: true, data: preferences }); + } catch (error) { + next(error); + } + }, + ); + + /** + * PATCH /notifications/preferences/me + * Update current user's notification preferences + */ + router.patch( + '/me', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const dto: UpdatePreferenceDto = req.body; + const preferences = await preferenceService.upsert(ctx, ctx.userId, dto); + + res.status(200).json({ success: true, data: preferences }); + } catch (error) { + next(error); + } + }, + ); + + /** + * GET /notifications/preferences/me/channels + * Get current user's channel preferences + */ + router.get( + '/me/channels', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const channels = await preferenceService.getChannelPreferences(ctx, ctx.userId); + + res.status(200).json({ success: true, data: channels }); + } catch (error) { + next(error); + } + }, + ); + + /** + * PATCH /notifications/preferences/me/channels + * Update current user's channel preferences + */ + router.patch( + '/me/channels', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const channels: Partial = req.body; + const preferences = await preferenceService.updateChannelPreferences(ctx, ctx.userId, channels); + + res.status(200).json({ success: true, data: preferences }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/preferences/me/enable-all + * Enable all notifications for current user + */ + router.post( + '/me/enable-all', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const preferences = await preferenceService.enableAll(ctx, ctx.userId); + + res.status(200).json({ success: true, data: preferences }); + } catch (error) { + next(error); + } + }, + ); + + /** + * POST /notifications/preferences/me/disable-all + * Disable all notifications for current user + */ + router.post( + '/me/disable-all', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const preferences = await preferenceService.disableAll(ctx, ctx.userId); + + res.status(200).json({ success: true, data: preferences }); + } catch (error) { + next(error); + } + }, + ); + + /** + * PATCH /notifications/preferences/me/quiet-hours + * Set quiet hours for current user + */ + router.patch( + '/me/quiet-hours', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const { start, end } = req.body; + const preferences = await preferenceService.setQuietHours(ctx, ctx.userId, start, end); + + res.status(200).json({ success: true, data: preferences }); + } catch (error) { + next(error); + } + }, + ); + + /** + * DELETE /notifications/preferences/me/quiet-hours + * Clear quiet hours for current user + */ + router.delete( + '/me/quiet-hours', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const preferences = await preferenceService.clearQuietHours(ctx, ctx.userId); + + res.status(200).json({ success: true, data: preferences }); + } catch (error) { + next(error); + } + }, + ); + + /** + * PATCH /notifications/preferences/me/digest + * Set digest frequency for current user + */ + router.patch( + '/me/digest', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const { frequency, day, hour } = req.body; + if (!frequency) { + res.status(400).json({ error: 'Bad Request', message: 'frequency is required' }); + return; + } + + const validFrequencies: DigestFrequency[] = ['instant', 'hourly', 'daily', 'weekly']; + if (!validFrequencies.includes(frequency)) { + res.status(400).json({ + error: 'Bad Request', + message: `Invalid frequency. Must be one of: ${validFrequencies.join(', ')}`, + }); + return; + } + + const preferences = await preferenceService.setDigestFrequency( + ctx, + ctx.userId, + frequency, + { day, hour }, + ); + + res.status(200).json({ success: true, data: preferences }); + } catch (error) { + next(error); + } + }, + ); + + /** + * PATCH /notifications/preferences/me/timezone + * Set timezone for current user + */ + router.patch( + '/me/timezone', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const { timezone } = req.body; + if (!timezone) { + res.status(400).json({ error: 'Bad Request', message: 'timezone is required' }); + return; + } + + const preferences = await preferenceService.setTimezone(ctx, ctx.userId, timezone); + + res.status(200).json({ success: true, data: preferences }); + } catch (error) { + next(error); + } + }, + ); + + /** + * PATCH /notifications/preferences/me/categories + * Update category preferences for current user + */ + router.patch( + '/me/categories', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId || !ctx.userId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID and User ID required' }); + return; + } + + const categoryPreferences = req.body; + const preferences = await preferenceService.updateCategoryPreferences( + ctx, + ctx.userId, + categoryPreferences, + ); + + res.status(200).json({ success: true, data: preferences }); + } catch (error) { + next(error); + } + }, + ); + + // ============= Admin Routes ============= + + /** + * GET /notifications/preferences/users/:userId + * Get preferences for a specific user (admin only) + */ + router.get( + '/users/:userId', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'super_admin'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const preferences = await preferenceService.findByUserId(ctx, req.params.userId); + if (!preferences) { + res.status(404).json({ error: 'Not Found', message: 'Preferences not found' }); + return; + } + + res.status(200).json({ success: true, data: preferences }); + } catch (error) { + next(error); + } + }, + ); + + /** + * PATCH /notifications/preferences/users/:userId + * Update preferences for a specific user (admin only) + */ + router.patch( + '/users/:userId', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'super_admin'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdatePreferenceDto = req.body; + const preferences = await preferenceService.upsert(ctx, req.params.userId, dto); + + res.status(200).json({ success: true, data: preferences }); + } catch (error) { + next(error); + } + }, + ); + + /** + * DELETE /notifications/preferences/users/:userId + * Delete preferences for a specific user (admin only) + */ + router.delete( + '/users/:userId', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'super_admin'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await preferenceService.delete(ctx, req.params.userId); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Preferences not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Preferences deleted' }); + } catch (error) { + next(error); + } + }, + ); + + /** + * GET /notifications/preferences + * List all preferences for the tenant (admin only) + */ + router.get( + '/', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'super_admin'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + if (!ctx.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); + + const result = await preferenceService.findAllByTenant(ctx, { page, limit }); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + page, + limit, + total: result.total, + totalPages: Math.ceil(result.total / limit), + }, + }); + } catch (error) { + next(error); + } + }, + ); + + return router; +} + +export default createPreferenceController; diff --git a/src/modules/notifications/index.ts b/src/modules/notifications/index.ts new file mode 100644 index 0000000..524e572 --- /dev/null +++ b/src/modules/notifications/index.ts @@ -0,0 +1,10 @@ +/** + * Notifications Module + * System notifications, alerts, and messaging + * + * ERP Construccion + */ + +export * from './entities'; +export * from './services'; +export * from './controllers'; diff --git a/src/modules/notifications/services/channel.service.ts b/src/modules/notifications/services/channel.service.ts new file mode 100644 index 0000000..13ac0a8 --- /dev/null +++ b/src/modules/notifications/services/channel.service.ts @@ -0,0 +1,433 @@ +/** + * Channel Service + * Notification channel management. + * + * @module Notifications + */ + +import { Repository } from 'typeorm'; +import { Channel, ChannelType } from '../entities/channel.entity'; + +export interface CreateChannelDto { + code: string; + name: string; + description?: string; + channelType: ChannelType; + provider?: string; + providerConfig?: Record; + rateLimitPerMinute?: number; + rateLimitPerHour?: number; + rateLimitPerDay?: number; + isActive?: boolean; + isDefault?: boolean; + metadata?: Record; +} + +export interface UpdateChannelDto { + name?: string; + description?: string; + provider?: string; + providerConfig?: Record; + rateLimitPerMinute?: number | null; + rateLimitPerHour?: number | null; + rateLimitPerDay?: number | null; + isActive?: boolean; + isDefault?: boolean; + metadata?: Record; +} + +export interface ChannelFilters { + channelType?: ChannelType; + isActive?: boolean; + provider?: string; + page?: number; + limit?: number; +} + +export interface ChannelStats { + totalMessages: number; + successRate: number; + averageDeliveryTime: number; +} + +export class ChannelService { + constructor( + private readonly channelRepository: Repository, + ) {} + + /** + * Create a new channel + */ + async create(dto: CreateChannelDto): Promise { + // Check for duplicate code + const existing = await this.findByCode(dto.code); + if (existing) { + throw new Error(`Channel with code '${dto.code}' already exists`); + } + + // If setting as default, clear other defaults for this type + if (dto.isDefault) { + await this.clearDefaultForType(dto.channelType); + } + + const channel = this.channelRepository.create(dto); + return this.channelRepository.save(channel); + } + + /** + * Find channel by ID + */ + async findById(id: string): Promise { + return this.channelRepository.findOne({ + where: { id }, + }); + } + + /** + * Find channel by code + */ + async findByCode(code: string): Promise { + return this.channelRepository.findOne({ + where: { code }, + }); + } + + /** + * Find all channels with filters + */ + async findAll( + filters: ChannelFilters, + ): Promise<{ data: Channel[]; total: number; page: number; limit: number }> { + const page = filters.page || 1; + const limit = filters.limit || 20; + const skip = (page - 1) * limit; + + const queryBuilder = this.channelRepository.createQueryBuilder('c'); + + if (filters.channelType) { + queryBuilder.andWhere('c.channel_type = :channelType', { channelType: filters.channelType }); + } + + if (filters.isActive !== undefined) { + queryBuilder.andWhere('c.is_active = :isActive', { isActive: filters.isActive }); + } + + if (filters.provider) { + queryBuilder.andWhere('c.provider = :provider', { provider: filters.provider }); + } + + const [data, total] = await queryBuilder + .orderBy('c.channel_type', 'ASC') + .addOrderBy('c.is_default', 'DESC') + .addOrderBy('c.name', 'ASC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + /** + * Update a channel + */ + async update(id: string, dto: UpdateChannelDto): Promise { + const channel = await this.findById(id); + if (!channel) { + return null; + } + + // If setting as default, clear other defaults for this type + if (dto.isDefault && !channel.isDefault) { + await this.clearDefaultForType(channel.channelType); + } + + Object.assign(channel, dto); + return this.channelRepository.save(channel); + } + + /** + * Delete a channel + */ + async delete(id: string): Promise { + const channel = await this.findById(id); + if (!channel) { + return false; + } + + // Cannot delete default channel + if (channel.isDefault) { + throw new Error('Cannot delete the default channel. Set another channel as default first.'); + } + + const result = await this.channelRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + /** + * Activate/deactivate a channel + */ + async setActive(id: string, isActive: boolean): Promise { + const channel = await this.findById(id); + if (!channel) { + return null; + } + + // Cannot deactivate default channel + if (!isActive && channel.isDefault) { + throw new Error('Cannot deactivate the default channel. Set another channel as default first.'); + } + + channel.isActive = isActive; + return this.channelRepository.save(channel); + } + + /** + * Set a channel as default for its type + */ + async setDefault(id: string): Promise { + const channel = await this.findById(id); + if (!channel) { + return null; + } + + if (!channel.isActive) { + throw new Error('Cannot set an inactive channel as default'); + } + + // Clear other defaults for this type + await this.clearDefaultForType(channel.channelType); + + channel.isDefault = true; + return this.channelRepository.save(channel); + } + + /** + * Clear default flag for all channels of a type + */ + private async clearDefaultForType(channelType: ChannelType): Promise { + await this.channelRepository.update( + { channelType, isDefault: true }, + { isDefault: false }, + ); + } + + /** + * Get the default channel for a type + */ + async getDefault(channelType: ChannelType): Promise { + return this.channelRepository.findOne({ + where: { channelType, isDefault: true, isActive: true }, + }); + } + + /** + * Get all active channels for a type + */ + async getActiveByType(channelType: ChannelType): Promise { + return this.channelRepository.find({ + where: { channelType, isActive: true }, + order: { isDefault: 'DESC', name: 'ASC' }, + }); + } + + /** + * Update provider configuration + */ + async updateProviderConfig( + id: string, + providerConfig: Record, + ): Promise { + const channel = await this.findById(id); + if (!channel) { + return null; + } + + channel.providerConfig = { + ...channel.providerConfig, + ...providerConfig, + }; + + return this.channelRepository.save(channel); + } + + /** + * Update rate limits + */ + async updateRateLimits( + id: string, + limits: { + perMinute?: number | null; + perHour?: number | null; + perDay?: number | null; + }, + ): Promise { + const channel = await this.findById(id); + if (!channel) { + return null; + } + + if (limits.perMinute !== undefined) { + channel.rateLimitPerMinute = limits.perMinute as number; + } + if (limits.perHour !== undefined) { + channel.rateLimitPerHour = limits.perHour as number; + } + if (limits.perDay !== undefined) { + channel.rateLimitPerDay = limits.perDay as number; + } + + return this.channelRepository.save(channel); + } + + /** + * Check if rate limit is exceeded for a channel + */ + async checkRateLimit( + id: string, + sentCounts: { perMinute: number; perHour: number; perDay: number }, + ): Promise<{ allowed: boolean; reason?: string }> { + const channel = await this.findById(id); + if (!channel) { + return { allowed: false, reason: 'Channel not found' }; + } + + if (!channel.isActive) { + return { allowed: false, reason: 'Channel is inactive' }; + } + + if (channel.rateLimitPerMinute && sentCounts.perMinute >= channel.rateLimitPerMinute) { + return { allowed: false, reason: 'Per-minute rate limit exceeded' }; + } + + if (channel.rateLimitPerHour && sentCounts.perHour >= channel.rateLimitPerHour) { + return { allowed: false, reason: 'Per-hour rate limit exceeded' }; + } + + if (channel.rateLimitPerDay && sentCounts.perDay >= channel.rateLimitPerDay) { + return { allowed: false, reason: 'Per-day rate limit exceeded' }; + } + + return { allowed: true }; + } + + /** + * Get all channel types + */ + getChannelTypes(): ChannelType[] { + return ['email', 'sms', 'push', 'whatsapp', 'in_app', 'webhook']; + } + + /** + * Get available providers for a channel type + */ + getProvidersForType(channelType: ChannelType): string[] { + switch (channelType) { + case 'email': + return ['sendgrid', 'ses', 'mailgun', 'smtp']; + case 'sms': + return ['twilio', 'nexmo', 'aws_sns']; + case 'push': + return ['fcm', 'apns', 'onesignal']; + case 'whatsapp': + return ['twilio', 'meta_whatsapp_business']; + case 'in_app': + return ['internal']; + case 'webhook': + return ['http']; + default: + return []; + } + } + + /** + * Validate provider configuration + */ + validateProviderConfig( + channelType: ChannelType, + provider: string, + config: Record, + ): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + const requiredFields = this.getRequiredConfigFields(channelType, provider); + for (const field of requiredFields) { + if (!config[field]) { + errors.push(`Missing required field: ${field}`); + } + } + + return { + valid: errors.length === 0, + errors, + }; + } + + /** + * Get required configuration fields for a provider + */ + private getRequiredConfigFields(channelType: ChannelType, provider: string): string[] { + const configMap: Record> = { + email: { + sendgrid: ['apiKey'], + ses: ['accessKeyId', 'secretAccessKey', 'region'], + mailgun: ['apiKey', 'domain'], + smtp: ['host', 'port', 'username', 'password'], + }, + sms: { + twilio: ['accountSid', 'authToken', 'fromNumber'], + nexmo: ['apiKey', 'apiSecret', 'fromNumber'], + aws_sns: ['accessKeyId', 'secretAccessKey', 'region'], + }, + push: { + fcm: ['serviceAccountKey'], + apns: ['keyId', 'teamId', 'bundleId', 'privateKey'], + onesignal: ['appId', 'apiKey'], + }, + whatsapp: { + twilio: ['accountSid', 'authToken', 'fromNumber'], + meta_whatsapp_business: ['accessToken', 'phoneNumberId'], + }, + in_app: { + internal: [], + }, + webhook: { + http: ['url'], + }, + }; + + return configMap[channelType]?.[provider] || []; + } + + /** + * Test channel connectivity + */ + async testChannel(id: string): Promise<{ success: boolean; message: string }> { + const channel = await this.findById(id); + if (!channel) { + return { success: false, message: 'Channel not found' }; + } + + if (!channel.isActive) { + return { success: false, message: 'Channel is inactive' }; + } + + // Validate configuration + if (channel.provider) { + const validation = this.validateProviderConfig( + channel.channelType, + channel.provider, + channel.providerConfig, + ); + + if (!validation.valid) { + return { + success: false, + message: `Invalid configuration: ${validation.errors.join(', ')}`, + }; + } + } + + // In production, this would actually test the provider connectivity + return { success: true, message: 'Channel configuration is valid' }; + } +} diff --git a/src/modules/notifications/services/in-app-notification.service.ts b/src/modules/notifications/services/in-app-notification.service.ts new file mode 100644 index 0000000..23d9071 --- /dev/null +++ b/src/modules/notifications/services/in-app-notification.service.ts @@ -0,0 +1,471 @@ +/** + * In-App Notification Service + * In-app notification management with read/archive tracking. + * + * @module Notifications + */ + +import { Repository, In, LessThan, MoreThan } from 'typeorm'; +import { + InAppNotification, + InAppCategory, + InAppPriority, + InAppActionType, +} from '../entities/in-app-notification.entity'; +import { ServiceContext } from './notification.service'; + +export interface CreateInAppNotificationDto { + userId: string; + title: string; + message: string; + icon?: string; + color?: string; + actionType?: InAppActionType; + actionUrl?: string; + actionData?: Record; + category?: InAppCategory; + contextType?: string; + contextId?: string; + priority?: InAppPriority; + expiresAt?: Date; +} + +export interface InAppNotificationFilters { + userId?: string; + category?: InAppCategory; + priority?: InAppPriority; + isRead?: boolean; + isArchived?: boolean; + contextType?: string; + contextId?: string; + fromDate?: Date; + toDate?: Date; + page?: number; + limit?: number; +} + +export interface InAppNotificationCounts { + total: number; + unread: number; + byCategory: Record; +} + +export class InAppNotificationService { + constructor( + private readonly inAppNotificationRepository: Repository, + ) {} + + /** + * Create a new in-app notification + */ + async create( + ctx: ServiceContext, + dto: CreateInAppNotificationDto, + ): Promise { + const notification = this.inAppNotificationRepository.create({ + tenantId: ctx.tenantId, + ...dto, + }); + + return this.inAppNotificationRepository.save(notification); + } + + /** + * Create multiple in-app notifications (broadcast) + */ + async createMany( + ctx: ServiceContext, + userIds: string[], + data: Omit, + ): Promise { + const notifications = userIds.map((userId) => + this.inAppNotificationRepository.create({ + tenantId: ctx.tenantId, + userId, + ...data, + }), + ); + + return this.inAppNotificationRepository.save(notifications); + } + + /** + * Find notification by ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.inAppNotificationRepository.findOne({ + where: { id, tenantId: ctx.tenantId }, + }); + } + + /** + * Find all notifications for a user + */ + async findByUser( + ctx: ServiceContext, + userId: string, + options?: { + includeRead?: boolean; + includeArchived?: boolean; + limit?: number; + }, + ): Promise { + const queryBuilder = this.inAppNotificationRepository + .createQueryBuilder('n') + .where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('n.user_id = :userId', { userId }); + + if (!options?.includeRead) { + queryBuilder.andWhere('n.is_read = false'); + } + + if (!options?.includeArchived) { + queryBuilder.andWhere('n.is_archived = false'); + } + + // Exclude expired notifications + queryBuilder.andWhere('(n.expires_at IS NULL OR n.expires_at > :now)', { now: new Date() }); + + return queryBuilder + .orderBy('n.priority', 'DESC') + .addOrderBy('n.created_at', 'DESC') + .take(options?.limit || 50) + .getMany(); + } + + /** + * Find all notifications with filters + */ + async findAll( + ctx: ServiceContext, + filters: InAppNotificationFilters, + ): Promise<{ data: InAppNotification[]; total: number; page: number; limit: number }> { + const page = filters.page || 1; + const limit = filters.limit || 20; + const skip = (page - 1) * limit; + + const queryBuilder = this.inAppNotificationRepository + .createQueryBuilder('n') + .where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.userId) { + queryBuilder.andWhere('n.user_id = :userId', { userId: filters.userId }); + } + + if (filters.category) { + queryBuilder.andWhere('n.category = :category', { category: filters.category }); + } + + if (filters.priority) { + queryBuilder.andWhere('n.priority = :priority', { priority: filters.priority }); + } + + if (filters.isRead !== undefined) { + queryBuilder.andWhere('n.is_read = :isRead', { isRead: filters.isRead }); + } + + if (filters.isArchived !== undefined) { + queryBuilder.andWhere('n.is_archived = :isArchived', { isArchived: filters.isArchived }); + } + + if (filters.contextType) { + queryBuilder.andWhere('n.context_type = :contextType', { contextType: filters.contextType }); + } + + if (filters.contextId) { + queryBuilder.andWhere('n.context_id = :contextId', { contextId: filters.contextId }); + } + + if (filters.fromDate) { + queryBuilder.andWhere('n.created_at >= :fromDate', { fromDate: filters.fromDate }); + } + + if (filters.toDate) { + queryBuilder.andWhere('n.created_at <= :toDate', { toDate: filters.toDate }); + } + + const [data, total] = await queryBuilder + .orderBy('n.created_at', 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + /** + * Mark notification as read + */ + async markAsRead(ctx: ServiceContext, id: string): Promise { + const notification = await this.findById(ctx, id); + if (!notification) { + return null; + } + + if (!notification.isRead) { + notification.isRead = true; + notification.readAt = new Date(); + return this.inAppNotificationRepository.save(notification); + } + + return notification; + } + + /** + * Mark notification as unread + */ + async markAsUnread(ctx: ServiceContext, id: string): Promise { + const notification = await this.findById(ctx, id); + if (!notification) { + return null; + } + + if (notification.isRead) { + notification.isRead = false; + notification.readAt = null as unknown as Date; + return this.inAppNotificationRepository.save(notification); + } + + return notification; + } + + /** + * Mark all notifications as read for a user + */ + async markAllAsRead(ctx: ServiceContext, userId: string): Promise { + const result = await this.inAppNotificationRepository.update( + { tenantId: ctx.tenantId, userId, isRead: false }, + { isRead: true, readAt: new Date() }, + ); + return result.affected || 0; + } + + /** + * Mark multiple notifications as read + */ + async markMultipleAsRead(ctx: ServiceContext, ids: string[]): Promise { + const result = await this.inAppNotificationRepository.update( + { id: In(ids), tenantId: ctx.tenantId, isRead: false }, + { isRead: true, readAt: new Date() }, + ); + return result.affected || 0; + } + + /** + * Archive a notification + */ + async archive(ctx: ServiceContext, id: string): Promise { + const notification = await this.findById(ctx, id); + if (!notification) { + return null; + } + + if (!notification.isArchived) { + notification.isArchived = true; + notification.archivedAt = new Date(); + return this.inAppNotificationRepository.save(notification); + } + + return notification; + } + + /** + * Unarchive a notification + */ + async unarchive(ctx: ServiceContext, id: string): Promise { + const notification = await this.findById(ctx, id); + if (!notification) { + return null; + } + + if (notification.isArchived) { + notification.isArchived = false; + notification.archivedAt = null as unknown as Date; + return this.inAppNotificationRepository.save(notification); + } + + return notification; + } + + /** + * Archive all notifications for a user + */ + async archiveAll(ctx: ServiceContext, userId: string): Promise { + const result = await this.inAppNotificationRepository.update( + { tenantId: ctx.tenantId, userId, isArchived: false }, + { isArchived: true, archivedAt: new Date() }, + ); + return result.affected || 0; + } + + /** + * Archive multiple notifications + */ + async archiveMultiple(ctx: ServiceContext, ids: string[]): Promise { + const result = await this.inAppNotificationRepository.update( + { id: In(ids), tenantId: ctx.tenantId, isArchived: false }, + { isArchived: true, archivedAt: new Date() }, + ); + return result.affected || 0; + } + + /** + * Delete a notification + */ + async delete(ctx: ServiceContext, id: string): Promise { + const result = await this.inAppNotificationRepository.delete({ + id, + tenantId: ctx.tenantId, + }); + return (result.affected ?? 0) > 0; + } + + /** + * Delete all archived notifications for a user + */ + async deleteArchived(ctx: ServiceContext, userId: string): Promise { + const result = await this.inAppNotificationRepository.delete({ + tenantId: ctx.tenantId, + userId, + isArchived: true, + }); + return result.affected || 0; + } + + /** + * Get notification counts for a user + */ + async getCounts(ctx: ServiceContext, userId: string): Promise { + const [total, unread, byCategoryRaw] = await Promise.all([ + this.inAppNotificationRepository.count({ + where: { tenantId: ctx.tenantId, userId, isArchived: false }, + }), + this.inAppNotificationRepository.count({ + where: { tenantId: ctx.tenantId, userId, isRead: false, isArchived: false }, + }), + this.inAppNotificationRepository + .createQueryBuilder('n') + .select('n.category', 'category') + .addSelect('COUNT(*)', 'count') + .where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('n.user_id = :userId', { userId }) + .andWhere('n.is_read = false') + .andWhere('n.is_archived = false') + .groupBy('n.category') + .getRawMany(), + ]); + + const byCategory: Record = {}; + byCategoryRaw.forEach((row: { category: string; count: string }) => { + byCategory[row.category] = parseInt(row.count, 10); + }); + + return { + total, + unread, + byCategory: byCategory as Record, + }; + } + + /** + * Get recent notifications for a user (for real-time updates) + */ + async getRecent( + ctx: ServiceContext, + userId: string, + sinceDate: Date, + ): Promise { + return this.inAppNotificationRepository.find({ + where: { + tenantId: ctx.tenantId, + userId, + createdAt: MoreThan(sinceDate), + }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Clean up expired notifications + */ + async cleanupExpired(): Promise { + const result = await this.inAppNotificationRepository.delete({ + expiresAt: LessThan(new Date()), + }); + return result.affected || 0; + } + + /** + * Clean up old archived notifications + */ + async cleanupOldArchived(daysOld: number = 30): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysOld); + + const result = await this.inAppNotificationRepository.delete({ + isArchived: true, + archivedAt: LessThan(cutoffDate), + }); + + return result.affected || 0; + } + + /** + * Get notifications by context (e.g., for a specific project or task) + */ + async findByContext( + ctx: ServiceContext, + contextType: string, + contextId: string, + ): Promise { + return this.inAppNotificationRepository.find({ + where: { + tenantId: ctx.tenantId, + contextType, + contextId, + }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Delete notifications by context (e.g., when entity is deleted) + */ + async deleteByContext( + ctx: ServiceContext, + contextType: string, + contextId: string, + ): Promise { + const result = await this.inAppNotificationRepository.delete({ + tenantId: ctx.tenantId, + contextType, + contextId, + }); + return result.affected || 0; + } + + /** + * Send a system notification to all users in a tenant + */ + async broadcastToTenant( + ctx: ServiceContext, + userIds: string[], + data: { + title: string; + message: string; + category?: InAppCategory; + priority?: InAppPriority; + actionUrl?: string; + }, + ): Promise { + const notifications = await this.createMany(ctx, userIds, { + title: data.title, + message: data.message, + category: data.category || 'info', + priority: data.priority || 'normal', + actionUrl: data.actionUrl, + }); + + return notifications.length; + } +} diff --git a/src/modules/notifications/services/index.ts b/src/modules/notifications/services/index.ts new file mode 100644 index 0000000..3a423e3 --- /dev/null +++ b/src/modules/notifications/services/index.ts @@ -0,0 +1,10 @@ +/** + * Notifications Services Index + * @module Notifications + */ + +export * from './notification.service'; +export * from './preference.service'; +export * from './template.service'; +export * from './channel.service'; +export * from './in-app-notification.service'; diff --git a/src/modules/notifications/services/notification.service.ts b/src/modules/notifications/services/notification.service.ts new file mode 100644 index 0000000..69bd83e --- /dev/null +++ b/src/modules/notifications/services/notification.service.ts @@ -0,0 +1,609 @@ +/** + * Notification Service + * Core notification logic: CRUD, sending, and delivery tracking. + * + * @module Notifications + */ + +import { Repository, In, LessThan } from 'typeorm'; +import { + Notification, + NotificationStatus, + NotificationPriority, +} from '../entities/notification.entity'; +import { NotificationTemplate } from '../entities/template.entity'; +import { NotificationPreference } from '../entities/preference.entity'; +import { Channel, ChannelType } from '../entities/channel.entity'; +import { InAppNotification, InAppCategory } from '../entities/in-app-notification.entity'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateNotificationDto { + userId?: string; + recipientEmail?: string; + recipientPhone?: string; + recipientDeviceId?: string; + templateId?: string; + templateCode?: string; + channelType: ChannelType; + channelId?: string; + subject?: string; + body?: string; + bodyHtml?: string; + variables?: Record; + contextType?: string; + contextId?: string; + priority?: NotificationPriority; + expiresAt?: Date; + metadata?: Record; +} + +export interface SendNotificationDto { + userId?: string; + recipientEmail?: string; + recipientPhone?: string; + recipientDeviceId?: string; + templateCode: string; + channelType: ChannelType; + variables?: Record; + contextType?: string; + contextId?: string; + priority?: NotificationPriority; + metadata?: Record; +} + +export interface NotificationFilters { + userId?: string; + channelType?: ChannelType; + status?: NotificationStatus; + priority?: NotificationPriority; + contextType?: string; + contextId?: string; + fromDate?: Date; + toDate?: Date; + page?: number; + limit?: number; +} + +export interface NotificationStats { + total: number; + byStatus: Record; + byChannel: Record; + deliveryRate: number; + readRate: number; +} + +export class NotificationService { + constructor( + private readonly notificationRepository: Repository, + private readonly templateRepository: Repository, + private readonly preferenceRepository: Repository, + private readonly channelRepository: Repository, + private readonly inAppNotificationRepository: Repository, + ) {} + + /** + * Create a new notification (queued for delivery) + */ + async create( + ctx: ServiceContext, + dto: CreateNotificationDto, + ): Promise { + const notification = this.notificationRepository.create({ + tenantId: ctx.tenantId, + ...dto, + status: 'pending', + queuedAt: new Date(), + }); + + return this.notificationRepository.save(notification); + } + + /** + * Send a notification using a template + */ + async send( + ctx: ServiceContext, + dto: SendNotificationDto, + ): Promise { + // Get the template + const template = await this.templateRepository.findOne({ + where: [ + { code: dto.templateCode, tenantId: ctx.tenantId, channelType: dto.channelType, isActive: true }, + { code: dto.templateCode, tenantId: null as any, channelType: dto.channelType, isActive: true }, + ], + }); + + if (!template) { + throw new Error(`Template not found: ${dto.templateCode}`); + } + + // Check user preferences if userId is provided + if (dto.userId) { + const preferences = await this.preferenceRepository.findOne({ + where: { userId: dto.userId, tenantId: ctx.tenantId }, + }); + + if (preferences) { + // Check if notifications are globally disabled + if (!preferences.globalEnabled) { + throw new Error('User has disabled all notifications'); + } + + // Check channel-specific preferences + const channelEnabled = this.isChannelEnabled(preferences, dto.channelType); + if (!channelEnabled) { + throw new Error(`User has disabled ${dto.channelType} notifications`); + } + + // Check quiet hours + if (this.isInQuietHours(preferences)) { + // Queue for later instead of immediate send + const notification = await this.create(ctx, { + ...dto, + templateId: template.id, + templateCode: template.code, + subject: this.renderTemplate(template.subject || '', dto.variables || {}), + body: this.renderTemplate(template.bodyTemplate || '', dto.variables || {}), + bodyHtml: template.bodyHtml ? this.renderTemplate(template.bodyHtml, dto.variables || {}) : undefined, + }); + return notification; + } + } + } + + // Get the default channel for this type + const channel = await this.channelRepository.findOne({ + where: { channelType: dto.channelType, isActive: true, isDefault: true }, + }); + + // Render the template with variables + const subject = this.renderTemplate(template.subject || '', dto.variables || {}); + const body = this.renderTemplate(template.bodyTemplate || '', dto.variables || {}); + const bodyHtml = template.bodyHtml ? this.renderTemplate(template.bodyHtml, dto.variables || {}) : undefined; + + // Create the notification + const notification = await this.create(ctx, { + userId: dto.userId, + recipientEmail: dto.recipientEmail, + recipientPhone: dto.recipientPhone, + recipientDeviceId: dto.recipientDeviceId, + templateId: template.id, + templateCode: template.code, + channelType: dto.channelType, + channelId: channel?.id, + subject, + body, + bodyHtml, + variables: dto.variables, + contextType: dto.contextType, + contextId: dto.contextId, + priority: dto.priority || 'normal', + metadata: dto.metadata, + }); + + // For in-app notifications, also create an InAppNotification record + if (dto.channelType === 'in_app' && dto.userId) { + await this.createInAppNotification(ctx, { + userId: dto.userId, + title: subject, + message: body, + category: 'info', + contextType: dto.contextType, + contextId: dto.contextId, + priority: dto.priority || 'normal', + }); + } + + // Simulate sending (in production, this would be queued to a message broker) + await this.processNotification(notification); + + return notification; + } + + /** + * Process a notification (send via the appropriate channel) + */ + private async processNotification(notification: Notification): Promise { + try { + notification.status = 'sending'; + await this.notificationRepository.save(notification); + + // Simulate channel-specific sending + switch (notification.channelType) { + case 'email': + // In production: send via email provider (SendGrid, SES, etc.) + break; + case 'sms': + // In production: send via SMS provider (Twilio, etc.) + break; + case 'push': + // In production: send via push notification service (FCM, APNS, etc.) + break; + case 'whatsapp': + // In production: send via WhatsApp Business API + break; + case 'in_app': + // Already handled via InAppNotification + break; + case 'webhook': + // In production: call webhook URL + break; + } + + notification.status = 'sent'; + notification.sentAt = new Date(); + await this.notificationRepository.save(notification); + } catch (error) { + notification.status = 'failed'; + notification.failedAt = new Date(); + notification.errorMessage = error instanceof Error ? error.message : 'Unknown error'; + notification.retryCount += 1; + + if (notification.retryCount < notification.maxRetries) { + notification.status = 'pending'; + notification.nextRetryAt = new Date(Date.now() + this.getRetryDelay(notification.retryCount)); + } + + await this.notificationRepository.save(notification); + } + } + + /** + * Get retry delay based on retry count (exponential backoff) + */ + private getRetryDelay(retryCount: number): number { + return Math.min(1000 * Math.pow(2, retryCount), 3600000); // Max 1 hour + } + + /** + * Render a template with variables + */ + private renderTemplate(template: string, variables: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (match, key) => { + return variables[key] !== undefined ? String(variables[key]) : match; + }); + } + + /** + * Check if a channel is enabled for a user + */ + private isChannelEnabled(preferences: NotificationPreference, channelType: ChannelType): boolean { + switch (channelType) { + case 'email': + return preferences.emailEnabled; + case 'sms': + return preferences.smsEnabled; + case 'push': + return preferences.pushEnabled; + case 'whatsapp': + return preferences.whatsappEnabled; + case 'in_app': + return preferences.inAppEnabled; + default: + return true; + } + } + + /** + * Check if current time is within quiet hours + */ + private isInQuietHours(preferences: NotificationPreference): boolean { + if (!preferences.quietHoursStart || !preferences.quietHoursEnd) { + return false; + } + + const now = new Date(); + const currentTime = now.toTimeString().slice(0, 5); + const start = preferences.quietHoursStart; + const end = preferences.quietHoursEnd; + + if (start <= end) { + return currentTime >= start && currentTime <= end; + } else { + // Quiet hours span midnight + return currentTime >= start || currentTime <= end; + } + } + + /** + * Create an in-app notification + */ + private async createInAppNotification( + ctx: ServiceContext, + data: { + userId: string; + title: string; + message: string; + category?: InAppCategory; + contextType?: string; + contextId?: string; + priority?: NotificationPriority; + actionUrl?: string; + actionType?: string; + }, + ): Promise { + const inAppNotification = this.inAppNotificationRepository.create({ + tenantId: ctx.tenantId, + userId: data.userId, + title: data.title, + message: data.message, + category: data.category || 'info', + contextType: data.contextType, + contextId: data.contextId, + priority: data.priority || 'normal', + actionUrl: data.actionUrl, + actionType: data.actionType as any, + }); + + return this.inAppNotificationRepository.save(inAppNotification); + } + + /** + * Find notification by ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.notificationRepository.findOne({ + where: { id, tenantId: ctx.tenantId }, + relations: ['template', 'channel'], + }); + } + + /** + * Find all notifications with filters + */ + async findAll( + ctx: ServiceContext, + filters: NotificationFilters, + ): Promise<{ data: Notification[]; total: number; page: number; limit: number }> { + const page = filters.page || 1; + const limit = filters.limit || 20; + const skip = (page - 1) * limit; + + const queryBuilder = this.notificationRepository + .createQueryBuilder('n') + .leftJoinAndSelect('n.template', 'template') + .leftJoinAndSelect('n.channel', 'channel') + .where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.userId) { + queryBuilder.andWhere('n.user_id = :userId', { userId: filters.userId }); + } + + if (filters.channelType) { + queryBuilder.andWhere('n.channel_type = :channelType', { channelType: filters.channelType }); + } + + if (filters.status) { + queryBuilder.andWhere('n.status = :status', { status: filters.status }); + } + + if (filters.priority) { + queryBuilder.andWhere('n.priority = :priority', { priority: filters.priority }); + } + + if (filters.contextType) { + queryBuilder.andWhere('n.context_type = :contextType', { contextType: filters.contextType }); + } + + if (filters.contextId) { + queryBuilder.andWhere('n.context_id = :contextId', { contextId: filters.contextId }); + } + + if (filters.fromDate) { + queryBuilder.andWhere('n.created_at >= :fromDate', { fromDate: filters.fromDate }); + } + + if (filters.toDate) { + queryBuilder.andWhere('n.created_at <= :toDate', { toDate: filters.toDate }); + } + + const [data, total] = await queryBuilder + .orderBy('n.created_at', 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + /** + * Mark notification as delivered + */ + async markAsDelivered(ctx: ServiceContext, id: string): Promise { + const notification = await this.findById(ctx, id); + if (!notification) { + return null; + } + + notification.status = 'delivered'; + notification.deliveredAt = new Date(); + return this.notificationRepository.save(notification); + } + + /** + * Mark notification as read + */ + async markAsRead(ctx: ServiceContext, id: string): Promise { + const notification = await this.findById(ctx, id); + if (!notification) { + return null; + } + + notification.status = 'read'; + notification.readAt = new Date(); + return this.notificationRepository.save(notification); + } + + /** + * Mark notification as unread + */ + async markAsUnread(ctx: ServiceContext, id: string): Promise { + const notification = await this.findById(ctx, id); + if (!notification) { + return null; + } + + // Can only mark as unread if previously read + if (notification.status === 'read') { + notification.status = 'delivered'; + notification.readAt = null as unknown as Date; + return this.notificationRepository.save(notification); + } + + return notification; + } + + /** + * Mark multiple notifications as read + */ + async markMultipleAsRead(ctx: ServiceContext, ids: string[]): Promise { + const result = await this.notificationRepository.update( + { id: In(ids), tenantId: ctx.tenantId }, + { status: 'read', readAt: new Date() }, + ); + return result.affected || 0; + } + + /** + * Cancel a pending notification + */ + async cancel(ctx: ServiceContext, id: string): Promise { + const notification = await this.findById(ctx, id); + if (!notification) { + return null; + } + + if (notification.status !== 'pending' && notification.status !== 'queued') { + throw new Error('Only pending or queued notifications can be cancelled'); + } + + notification.status = 'cancelled'; + return this.notificationRepository.save(notification); + } + + /** + * Retry a failed notification + */ + async retry(ctx: ServiceContext, id: string): Promise { + const notification = await this.findById(ctx, id); + if (!notification) { + return null; + } + + if (notification.status !== 'failed') { + throw new Error('Only failed notifications can be retried'); + } + + notification.status = 'pending'; + notification.retryCount = 0; + notification.errorMessage = null as unknown as string; + notification.failedAt = null as unknown as Date; + notification.nextRetryAt = null as unknown as Date; + + await this.notificationRepository.save(notification); + await this.processNotification(notification); + + return notification; + } + + /** + * Get notification statistics + */ + async getStatistics( + ctx: ServiceContext, + options?: { fromDate?: Date; toDate?: Date; userId?: string }, + ): Promise { + const queryBuilder = this.notificationRepository + .createQueryBuilder('n') + .where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (options?.fromDate) { + queryBuilder.andWhere('n.created_at >= :fromDate', { fromDate: options.fromDate }); + } + + if (options?.toDate) { + queryBuilder.andWhere('n.created_at <= :toDate', { toDate: options.toDate }); + } + + if (options?.userId) { + queryBuilder.andWhere('n.user_id = :userId', { userId: options.userId }); + } + + const [total, byStatusRaw, byChannelRaw, deliveredCount, readCount] = await Promise.all([ + queryBuilder.getCount(), + this.notificationRepository + .createQueryBuilder('n') + .select('n.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .groupBy('n.status') + .getRawMany(), + this.notificationRepository + .createQueryBuilder('n') + .select('n.channel_type', 'channelType') + .addSelect('COUNT(*)', 'count') + .where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .groupBy('n.channel_type') + .getRawMany(), + this.notificationRepository.count({ + where: { tenantId: ctx.tenantId, status: In(['delivered', 'read']) }, + }), + this.notificationRepository.count({ + where: { tenantId: ctx.tenantId, status: 'read' }, + }), + ]); + + const byStatus: Record = {}; + byStatusRaw.forEach((row: { status: string; count: string }) => { + byStatus[row.status] = parseInt(row.count, 10); + }); + + const byChannel: Record = {}; + byChannelRaw.forEach((row: { channelType: string; count: string }) => { + byChannel[row.channelType] = parseInt(row.count, 10); + }); + + const sentCount = total - (byStatus['pending'] || 0) - (byStatus['queued'] || 0) - (byStatus['cancelled'] || 0); + + return { + total, + byStatus: byStatus as Record, + byChannel: byChannel as Record, + deliveryRate: sentCount > 0 ? deliveredCount / sentCount : 0, + readRate: deliveredCount > 0 ? readCount / deliveredCount : 0, + }; + } + + /** + * Get pending notifications for processing + */ + async getPendingNotifications(limit: number = 100): Promise { + return this.notificationRepository.find({ + where: [ + { status: 'pending' }, + { status: 'queued', nextRetryAt: LessThan(new Date()) }, + ], + order: { priority: 'DESC', createdAt: 'ASC' }, + take: limit, + }); + } + + /** + * Clean up old notifications + */ + async cleanupOldNotifications(daysOld: number = 90): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysOld); + + const result = await this.notificationRepository.delete({ + createdAt: LessThan(cutoffDate), + status: In(['sent', 'delivered', 'read', 'cancelled', 'failed']), + }); + + return result.affected || 0; + } +} diff --git a/src/modules/notifications/services/preference.service.ts b/src/modules/notifications/services/preference.service.ts new file mode 100644 index 0000000..f25eb9d --- /dev/null +++ b/src/modules/notifications/services/preference.service.ts @@ -0,0 +1,396 @@ +/** + * Preference Service + * User notification preferences management. + * + * @module Notifications + */ + +import { Repository } from 'typeorm'; +import { NotificationPreference, DigestFrequency } from '../entities/preference.entity'; +import { ServiceContext } from './notification.service'; + +export interface CreatePreferenceDto { + userId: string; + globalEnabled?: boolean; + quietHoursStart?: string; + quietHoursEnd?: string; + timezone?: string; + emailEnabled?: boolean; + smsEnabled?: boolean; + pushEnabled?: boolean; + whatsappEnabled?: boolean; + inAppEnabled?: boolean; + categoryPreferences?: Record; + digestFrequency?: DigestFrequency; + digestDay?: number; + digestHour?: number; + metadata?: Record; +} + +export interface UpdatePreferenceDto { + globalEnabled?: boolean; + quietHoursStart?: string; + quietHoursEnd?: string; + timezone?: string; + emailEnabled?: boolean; + smsEnabled?: boolean; + pushEnabled?: boolean; + whatsappEnabled?: boolean; + inAppEnabled?: boolean; + categoryPreferences?: Record; + digestFrequency?: DigestFrequency; + digestDay?: number; + digestHour?: number; + metadata?: Record; +} + +export interface ChannelPreferences { + email: boolean; + sms: boolean; + push: boolean; + whatsapp: boolean; + inApp: boolean; +} + +export class PreferenceService { + constructor( + private readonly preferenceRepository: Repository, + ) {} + + /** + * Create user preferences + */ + async create( + ctx: ServiceContext, + dto: CreatePreferenceDto, + ): Promise { + // Check if preferences already exist + const existing = await this.findByUserId(ctx, dto.userId); + if (existing) { + throw new Error('Preferences already exist for this user'); + } + + const preference = this.preferenceRepository.create({ + tenantId: ctx.tenantId, + ...dto, + }); + + return this.preferenceRepository.save(preference); + } + + /** + * Get preferences by user ID + */ + async findByUserId( + ctx: ServiceContext, + userId: string, + ): Promise { + return this.preferenceRepository.findOne({ + where: { userId, tenantId: ctx.tenantId }, + }); + } + + /** + * Get or create preferences for a user + */ + async getOrCreate( + ctx: ServiceContext, + userId: string, + ): Promise { + let preference = await this.findByUserId(ctx, userId); + + if (!preference) { + preference = await this.create(ctx, { userId }); + } + + return preference; + } + + /** + * Update user preferences + */ + async update( + ctx: ServiceContext, + userId: string, + dto: UpdatePreferenceDto, + ): Promise { + const preference = await this.findByUserId(ctx, userId); + if (!preference) { + return null; + } + + Object.assign(preference, dto); + return this.preferenceRepository.save(preference); + } + + /** + * Update or create preferences + */ + async upsert( + ctx: ServiceContext, + userId: string, + dto: UpdatePreferenceDto, + ): Promise { + let preference = await this.findByUserId(ctx, userId); + + if (preference) { + Object.assign(preference, dto); + } else { + preference = this.preferenceRepository.create({ + tenantId: ctx.tenantId, + userId, + ...dto, + }); + } + + return this.preferenceRepository.save(preference); + } + + /** + * Delete user preferences + */ + async delete(ctx: ServiceContext, userId: string): Promise { + const result = await this.preferenceRepository.delete({ + userId, + tenantId: ctx.tenantId, + }); + return (result.affected ?? 0) > 0; + } + + /** + * Enable all notifications for a user + */ + async enableAll(ctx: ServiceContext, userId: string): Promise { + return this.upsert(ctx, userId, { + globalEnabled: true, + emailEnabled: true, + smsEnabled: true, + pushEnabled: true, + whatsappEnabled: true, + inAppEnabled: true, + }); + } + + /** + * Disable all notifications for a user + */ + async disableAll(ctx: ServiceContext, userId: string): Promise { + return this.upsert(ctx, userId, { + globalEnabled: false, + }); + } + + /** + * Update channel preferences + */ + async updateChannelPreferences( + ctx: ServiceContext, + userId: string, + channels: Partial, + ): Promise { + const dto: UpdatePreferenceDto = {}; + + if (channels.email !== undefined) dto.emailEnabled = channels.email; + if (channels.sms !== undefined) dto.smsEnabled = channels.sms; + if (channels.push !== undefined) dto.pushEnabled = channels.push; + if (channels.whatsapp !== undefined) dto.whatsappEnabled = channels.whatsapp; + if (channels.inApp !== undefined) dto.inAppEnabled = channels.inApp; + + return this.upsert(ctx, userId, dto); + } + + /** + * Get channel preferences + */ + async getChannelPreferences( + ctx: ServiceContext, + userId: string, + ): Promise { + const preference = await this.getOrCreate(ctx, userId); + + return { + email: preference.emailEnabled, + sms: preference.smsEnabled, + push: preference.pushEnabled, + whatsapp: preference.whatsappEnabled, + inApp: preference.inAppEnabled, + }; + } + + /** + * Set quiet hours + */ + async setQuietHours( + ctx: ServiceContext, + userId: string, + start: string | null, + end: string | null, + ): Promise { + return this.upsert(ctx, userId, { + quietHoursStart: start || undefined, + quietHoursEnd: end || undefined, + }); + } + + /** + * Clear quiet hours + */ + async clearQuietHours(ctx: ServiceContext, userId: string): Promise { + return this.setQuietHours(ctx, userId, null, null); + } + + /** + * Set digest frequency + */ + async setDigestFrequency( + ctx: ServiceContext, + userId: string, + frequency: DigestFrequency, + options?: { day?: number; hour?: number }, + ): Promise { + return this.upsert(ctx, userId, { + digestFrequency: frequency, + digestDay: options?.day, + digestHour: options?.hour, + }); + } + + /** + * Update category preferences + */ + async updateCategoryPreferences( + ctx: ServiceContext, + userId: string, + categoryPreferences: Record, + ): Promise { + const preference = await this.getOrCreate(ctx, userId); + + preference.categoryPreferences = { + ...preference.categoryPreferences, + ...categoryPreferences, + }; + + return this.preferenceRepository.save(preference); + } + + /** + * Get category preference + */ + async getCategoryPreference( + ctx: ServiceContext, + userId: string, + category: string, + ): Promise | null> { + const preference = await this.findByUserId(ctx, userId); + if (!preference) { + return null; + } + + return preference.categoryPreferences[category] || null; + } + + /** + * Check if a specific notification type is enabled for a user + */ + async isNotificationEnabled( + ctx: ServiceContext, + userId: string, + channelType: string, + category?: string, + ): Promise { + const preference = await this.findByUserId(ctx, userId); + + // Default to enabled if no preferences exist + if (!preference) { + return true; + } + + // Check global enabled flag + if (!preference.globalEnabled) { + return false; + } + + // Check channel-specific flag + switch (channelType) { + case 'email': + if (!preference.emailEnabled) return false; + break; + case 'sms': + if (!preference.smsEnabled) return false; + break; + case 'push': + if (!preference.pushEnabled) return false; + break; + case 'whatsapp': + if (!preference.whatsappEnabled) return false; + break; + case 'in_app': + if (!preference.inAppEnabled) return false; + break; + } + + // Check category-specific preference if provided + if (category && preference.categoryPreferences[category] !== undefined) { + return preference.categoryPreferences[category].enabled !== false; + } + + return true; + } + + /** + * Get users who prefer a specific digest frequency + */ + async getUsersByDigestFrequency( + ctx: ServiceContext, + frequency: DigestFrequency, + options?: { day?: number; hour?: number }, + ): Promise { + const queryBuilder = this.preferenceRepository + .createQueryBuilder('p') + .where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('p.digest_frequency = :frequency', { frequency }) + .andWhere('p.global_enabled = true'); + + if (options?.day !== undefined) { + queryBuilder.andWhere('p.digest_day = :day', { day: options.day }); + } + + if (options?.hour !== undefined) { + queryBuilder.andWhere('p.digest_hour = :hour', { hour: options.hour }); + } + + return queryBuilder.getMany(); + } + + /** + * Set timezone for a user + */ + async setTimezone( + ctx: ServiceContext, + userId: string, + timezone: string, + ): Promise { + return this.upsert(ctx, userId, { timezone }); + } + + /** + * Get all preferences for a tenant (admin use) + */ + async findAllByTenant( + ctx: ServiceContext, + options?: { page?: number; limit?: number }, + ): Promise<{ data: NotificationPreference[]; total: number }> { + const page = options?.page || 1; + const limit = options?.limit || 50; + const skip = (page - 1) * limit; + + const [data, total] = await this.preferenceRepository.findAndCount({ + where: { tenantId: ctx.tenantId }, + skip, + take: limit, + order: { createdAt: 'DESC' }, + }); + + return { data, total }; + } +} diff --git a/src/modules/notifications/services/template.service.ts b/src/modules/notifications/services/template.service.ts new file mode 100644 index 0000000..3caef36 --- /dev/null +++ b/src/modules/notifications/services/template.service.ts @@ -0,0 +1,509 @@ +/** + * Template Service + * Notification template management with i18n support. + * + * @module Notifications + */ + +import { Repository } from 'typeorm'; +import { + NotificationTemplate, + TemplateTranslation, + TemplateCategory, +} from '../entities/template.entity'; +import { ChannelType } from '../entities/channel.entity'; +import { ServiceContext } from './notification.service'; + +export interface CreateTemplateDto { + code: string; + name: string; + description?: string; + category?: TemplateCategory; + channelType: ChannelType; + subject?: string; + bodyTemplate?: string; + bodyHtml?: string; + availableVariables?: string[]; + defaultLocale?: string; + isActive?: boolean; + isSystem?: boolean; +} + +export interface UpdateTemplateDto { + name?: string; + description?: string; + category?: TemplateCategory; + subject?: string; + bodyTemplate?: string; + bodyHtml?: string; + availableVariables?: string[]; + defaultLocale?: string; + isActive?: boolean; +} + +export interface CreateTranslationDto { + locale: string; + subject?: string; + bodyTemplate?: string; + bodyHtml?: string; + isActive?: boolean; +} + +export interface TemplateFilters { + category?: TemplateCategory; + channelType?: ChannelType; + isActive?: boolean; + isSystem?: boolean; + search?: string; + page?: number; + limit?: number; +} + +export interface RenderedTemplate { + subject: string; + body: string; + bodyHtml?: string; +} + +export class TemplateService { + constructor( + private readonly templateRepository: Repository, + private readonly translationRepository: Repository, + ) {} + + /** + * Create a new template + */ + async create( + ctx: ServiceContext, + dto: CreateTemplateDto, + ): Promise { + // Check for duplicate code + const existing = await this.findByCode(ctx, dto.code, dto.channelType); + if (existing) { + throw new Error(`Template with code '${dto.code}' already exists for channel '${dto.channelType}'`); + } + + const template = this.templateRepository.create({ + tenantId: ctx.tenantId, + ...dto, + createdBy: ctx.userId, + }); + + return this.templateRepository.save(template); + } + + /** + * Find template by ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.templateRepository.findOne({ + where: [ + { id, tenantId: ctx.tenantId }, + { id, tenantId: null as any }, // System templates + ], + relations: ['translations'], + }); + } + + /** + * Find template by code and channel type + */ + async findByCode( + ctx: ServiceContext, + code: string, + channelType: ChannelType, + ): Promise { + return this.templateRepository.findOne({ + where: [ + { code, channelType, tenantId: ctx.tenantId }, + { code, channelType, tenantId: null as any }, // System templates + ], + relations: ['translations'], + }); + } + + /** + * Find all templates with filters + */ + async findAll( + ctx: ServiceContext, + filters: TemplateFilters, + ): Promise<{ data: NotificationTemplate[]; total: number; page: number; limit: number }> { + const page = filters.page || 1; + const limit = filters.limit || 20; + const skip = (page - 1) * limit; + + const queryBuilder = this.templateRepository + .createQueryBuilder('t') + .leftJoinAndSelect('t.translations', 'translations') + .where('(t.tenant_id = :tenantId OR t.tenant_id IS NULL)', { tenantId: ctx.tenantId }); + + if (filters.category) { + queryBuilder.andWhere('t.category = :category', { category: filters.category }); + } + + if (filters.channelType) { + queryBuilder.andWhere('t.channel_type = :channelType', { channelType: filters.channelType }); + } + + if (filters.isActive !== undefined) { + queryBuilder.andWhere('t.is_active = :isActive', { isActive: filters.isActive }); + } + + if (filters.isSystem !== undefined) { + queryBuilder.andWhere('t.is_system = :isSystem', { isSystem: filters.isSystem }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(t.code ILIKE :search OR t.name ILIKE :search OR t.description ILIKE :search)', + { search: `%${filters.search}%` }, + ); + } + + const [data, total] = await queryBuilder + .orderBy('t.is_system', 'DESC') + .addOrderBy('t.code', 'ASC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + /** + * Update a template + */ + async update( + ctx: ServiceContext, + id: string, + dto: UpdateTemplateDto, + ): Promise { + const template = await this.findById(ctx, id); + if (!template) { + return null; + } + + // Cannot modify system templates from tenant context + if (template.isSystem && template.tenantId === null) { + throw new Error('Cannot modify system templates'); + } + + Object.assign(template, dto, { updatedBy: ctx.userId }); + template.version += 1; + + return this.templateRepository.save(template); + } + + /** + * Delete a template + */ + async delete(ctx: ServiceContext, id: string): Promise { + const template = await this.findById(ctx, id); + if (!template) { + return false; + } + + // Cannot delete system templates + if (template.isSystem && template.tenantId === null) { + throw new Error('Cannot delete system templates'); + } + + const result = await this.templateRepository.delete({ + id, + tenantId: ctx.tenantId, + }); + + return (result.affected ?? 0) > 0; + } + + /** + * Activate/deactivate a template + */ + async setActive( + ctx: ServiceContext, + id: string, + isActive: boolean, + ): Promise { + return this.update(ctx, id, { isActive }); + } + + /** + * Add a translation to a template + */ + async addTranslation( + ctx: ServiceContext, + templateId: string, + dto: CreateTranslationDto, + ): Promise { + const template = await this.findById(ctx, templateId); + if (!template) { + throw new Error('Template not found'); + } + + // Check for existing translation + const existing = await this.translationRepository.findOne({ + where: { templateId, locale: dto.locale }, + }); + + if (existing) { + throw new Error(`Translation for locale '${dto.locale}' already exists`); + } + + const translation = this.translationRepository.create({ + templateId, + ...dto, + }); + + return this.translationRepository.save(translation); + } + + /** + * Update a translation + */ + async updateTranslation( + ctx: ServiceContext, + templateId: string, + locale: string, + dto: Partial, + ): Promise { + const template = await this.findById(ctx, templateId); + if (!template) { + throw new Error('Template not found'); + } + + const translation = await this.translationRepository.findOne({ + where: { templateId, locale }, + }); + + if (!translation) { + return null; + } + + Object.assign(translation, dto); + return this.translationRepository.save(translation); + } + + /** + * Delete a translation + */ + async deleteTranslation( + ctx: ServiceContext, + templateId: string, + locale: string, + ): Promise { + const template = await this.findById(ctx, templateId); + if (!template) { + throw new Error('Template not found'); + } + + const result = await this.translationRepository.delete({ + templateId, + locale, + }); + + return (result.affected ?? 0) > 0; + } + + /** + * Get translation for a specific locale + */ + async getTranslation( + templateId: string, + locale: string, + ): Promise { + return this.translationRepository.findOne({ + where: { templateId, locale, isActive: true }, + }); + } + + /** + * Render a template with variables + */ + async render( + ctx: ServiceContext, + code: string, + channelType: ChannelType, + variables: Record, + locale?: string, + ): Promise { + const template = await this.findByCode(ctx, code, channelType); + if (!template) { + throw new Error(`Template not found: ${code} (${channelType})`); + } + + if (!template.isActive) { + throw new Error(`Template is inactive: ${code}`); + } + + let subject = template.subject || ''; + let body = template.bodyTemplate || ''; + let bodyHtml = template.bodyHtml; + + // Try to get translation if locale is specified + if (locale && locale !== template.defaultLocale) { + const translation = await this.getTranslation(template.id, locale); + if (translation) { + subject = translation.subject || subject; + body = translation.bodyTemplate || body; + bodyHtml = translation.bodyHtml || bodyHtml; + } + } + + // Render variables + subject = this.renderVariables(subject, variables); + body = this.renderVariables(body, variables); + if (bodyHtml) { + bodyHtml = this.renderVariables(bodyHtml, variables); + } + + return { subject, body, bodyHtml }; + } + + /** + * Replace variables in a template string + */ + private renderVariables(template: string, variables: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (match, key) => { + return variables[key] !== undefined ? String(variables[key]) : match; + }); + } + + /** + * Validate template variables + */ + async validateVariables( + ctx: ServiceContext, + code: string, + channelType: ChannelType, + variables: Record, + ): Promise<{ valid: boolean; missing: string[]; extra: string[] }> { + const template = await this.findByCode(ctx, code, channelType); + if (!template) { + throw new Error(`Template not found: ${code} (${channelType})`); + } + + const requiredVariables = template.availableVariables || []; + const providedVariables = Object.keys(variables); + + const missing = requiredVariables.filter((v) => !providedVariables.includes(v)); + const extra = providedVariables.filter((v) => !requiredVariables.includes(v)); + + return { + valid: missing.length === 0, + missing, + extra, + }; + } + + /** + * Clone a template + */ + async clone( + ctx: ServiceContext, + id: string, + newCode: string, + ): Promise { + const template = await this.findById(ctx, id); + if (!template) { + throw new Error('Template not found'); + } + + // Create new template + const cloned = await this.create(ctx, { + code: newCode, + name: `${template.name} (Copy)`, + description: template.description, + category: template.category as TemplateCategory, + channelType: template.channelType, + subject: template.subject, + bodyTemplate: template.bodyTemplate, + bodyHtml: template.bodyHtml, + availableVariables: [...(template.availableVariables || [])], + defaultLocale: template.defaultLocale, + isActive: false, // Start as inactive + isSystem: false, + }); + + // Clone translations + if (template.translations && template.translations.length > 0) { + for (const translation of template.translations) { + await this.addTranslation(ctx, cloned.id, { + locale: translation.locale, + subject: translation.subject, + bodyTemplate: translation.bodyTemplate, + bodyHtml: translation.bodyHtml, + isActive: translation.isActive, + }); + } + } + + return this.findById(ctx, cloned.id) as Promise; + } + + /** + * Get templates by channel type + */ + async findByChannelType( + ctx: ServiceContext, + channelType: ChannelType, + ): Promise { + return this.templateRepository.find({ + where: [ + { channelType, tenantId: ctx.tenantId, isActive: true }, + { channelType, tenantId: null as any, isActive: true }, + ], + relations: ['translations'], + order: { code: 'ASC' }, + }); + } + + /** + * Get templates by category + */ + async findByCategory( + ctx: ServiceContext, + category: TemplateCategory, + ): Promise { + return this.templateRepository.find({ + where: [ + { category, tenantId: ctx.tenantId, isActive: true }, + { category, tenantId: null as any, isActive: true }, + ], + relations: ['translations'], + order: { code: 'ASC' }, + }); + } + + /** + * Preview a template with sample variables + */ + async preview( + ctx: ServiceContext, + id: string, + sampleVariables?: Record, + locale?: string, + ): Promise { + const template = await this.findById(ctx, id); + if (!template) { + throw new Error('Template not found'); + } + + // Generate sample variables if not provided + const variables = sampleVariables || this.generateSampleVariables(template.availableVariables || []); + + return this.render(ctx, template.code, template.channelType, variables, locale); + } + + /** + * Generate sample variables for preview + */ + private generateSampleVariables(variableNames: string[]): Record { + const samples: Record = {}; + for (const name of variableNames) { + samples[name] = `[${name}]`; + } + return samples; + } +} diff --git a/src/modules/products/controllers/index.ts b/src/modules/products/controllers/index.ts new file mode 100644 index 0000000..f47b9e0 --- /dev/null +++ b/src/modules/products/controllers/index.ts @@ -0,0 +1,11 @@ +/** + * Products Controllers Index + * @module Products + */ + +export { default as productController } from './product.controller'; +export { default as productCategoryController } from './product-category.controller'; +export { default as productPriceController } from './product-price.controller'; +export { default as productSupplierController } from './product-supplier.controller'; +export { default as productAttributeController } from './product-attribute.controller'; +export { default as productVariantController } from './product-variant.controller'; diff --git a/src/modules/products/controllers/product-attribute.controller.ts b/src/modules/products/controllers/product-attribute.controller.ts new file mode 100644 index 0000000..97cff05 --- /dev/null +++ b/src/modules/products/controllers/product-attribute.controller.ts @@ -0,0 +1,306 @@ +/** + * Product Attribute Controller + * API endpoints para gestion de atributos de productos + * + * @module Products + * @prefix /api/v1/products/attributes + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + ProductAttributeService, + CreateProductAttributeDto, + UpdateProductAttributeDto, + UpdateAttributeValueDto +} from '../services/product-attribute.service'; + +const router = Router(); +const attributeService = new ProductAttributeService(); + +/** + * GET /api/v1/products/attributes + * Lista todos los atributos del tenant + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { isActive, search } = req.query; + + const attributes = await attributeService.findAll({ + tenantId, + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined, + search: search as string, + }); + + return res.json({ + success: true, + data: attributes, + count: attributes.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/attributes/:id + * Obtiene un atributo por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const attribute = await attributeService.findById(req.params.id, tenantId); + if (!attribute) { + return res.status(404).json({ error: 'Atributo no encontrado' }); + } + + return res.json({ success: true, data: attribute }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/attributes/:id/values + * Obtiene los valores de un atributo + */ +router.get('/:id/values', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const attribute = await attributeService.findById(req.params.id, tenantId); + if (!attribute) { + return res.status(404).json({ error: 'Atributo no encontrado' }); + } + + const values = await attributeService.findValuesByAttribute(req.params.id); + + return res.json({ + success: true, + data: values, + count: values.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/products/attributes + * Crea un nuevo atributo + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { values, ...attributeData } = req.body; + + const data: CreateProductAttributeDto = { + ...attributeData, + tenantId, + createdBy: (req as any).user?.id, + }; + + if (!data.code || !data.name) { + return res.status(400).json({ + error: 'code y name son requeridos' + }); + } + + const existing = await attributeService.findByCode(data.code, tenantId); + if (existing) { + return res.status(409).json({ error: 'Ya existe un atributo con ese codigo' }); + } + + let attribute; + if (values && Array.isArray(values) && values.length > 0) { + attribute = await attributeService.createWithValues(data, values); + } else { + attribute = await attributeService.create(data); + } + + return res.status(201).json({ success: true, data: attribute }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/products/attributes/:id/values + * Agrega valores a un atributo + */ +router.post('/:id/values', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const attribute = await attributeService.findById(req.params.id, tenantId); + if (!attribute) { + return res.status(404).json({ error: 'Atributo no encontrado' }); + } + + const { values } = req.body; + if (!values || !Array.isArray(values) || values.length === 0) { + return res.status(400).json({ error: 'values array is required' }); + } + + const createdValues = await attributeService.addValues(req.params.id, values); + + return res.status(201).json({ + success: true, + data: createdValues, + count: createdValues.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/products/attributes/:id/values/reorder + * Reordena los valores de un atributo + */ +router.post('/:id/values/reorder', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const attribute = await attributeService.findById(req.params.id, tenantId); + if (!attribute) { + return res.status(404).json({ error: 'Atributo no encontrado' }); + } + + const { valueIds } = req.body; + if (!valueIds || !Array.isArray(valueIds)) { + return res.status(400).json({ error: 'valueIds array is required' }); + } + + await attributeService.reorderValues(req.params.id, valueIds); + + return res.json({ success: true, message: 'Valores reordenados' }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/products/attributes/:id + * Actualiza un atributo + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: UpdateProductAttributeDto = { + ...req.body, + updatedBy: (req as any).user?.id, + }; + + if (data.code) { + const existing = await attributeService.findByCode(data.code, tenantId); + if (existing && existing.id !== req.params.id) { + return res.status(409).json({ error: 'Ya existe un atributo con ese codigo' }); + } + } + + const attribute = await attributeService.update(req.params.id, tenantId, data); + if (!attribute) { + return res.status(404).json({ error: 'Atributo no encontrado' }); + } + + return res.json({ success: true, data: attribute }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/products/attributes/values/:valueId + * Actualiza un valor de atributo + */ +router.patch('/values/:valueId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: UpdateAttributeValueDto = req.body; + const value = await attributeService.updateValue(req.params.valueId, data); + + if (!value) { + return res.status(404).json({ error: 'Valor no encontrado' }); + } + + return res.json({ success: true, data: value }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/products/attributes/:id + * Elimina un atributo y todos sus valores + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const deleted = await attributeService.delete(req.params.id, tenantId); + if (!deleted) { + return res.status(404).json({ error: 'Atributo no encontrado' }); + } + + return res.json({ success: true, message: 'Atributo eliminado' }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/products/attributes/values/:valueId + * Elimina un valor de atributo + */ +router.delete('/values/:valueId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const deleted = await attributeService.deleteValue(req.params.valueId); + if (!deleted) { + return res.status(404).json({ error: 'Valor no encontrado' }); + } + + return res.json({ success: true, message: 'Valor eliminado' }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/src/modules/products/controllers/product-category.controller.ts b/src/modules/products/controllers/product-category.controller.ts new file mode 100644 index 0000000..f9864a2 --- /dev/null +++ b/src/modules/products/controllers/product-category.controller.ts @@ -0,0 +1,255 @@ +/** + * Product Category Controller + * API endpoints para gestion de categorias de productos + * + * @module Products + * @prefix /api/v1/products/categories + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + ProductCategoryService, + CreateProductCategoryDto, + UpdateProductCategoryDto +} from '../services/product-category.service'; + +const router = Router(); +const categoryService = new ProductCategoryService(); + +/** + * GET /api/v1/products/categories + * Lista todas las categorias del tenant + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { parentId, isActive, search } = req.query; + + const categories = await categoryService.findAll({ + tenantId, + parentId: parentId === 'null' ? null : (parentId as string), + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined, + search: search as string, + }); + + return res.json({ + success: true, + data: categories, + count: categories.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/categories/tree + * Obtiene el arbol jerarquico de categorias + */ +router.get('/tree', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const tree = await categoryService.getHierarchyTree(tenantId); + + return res.json({ + success: true, + data: tree, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/categories/roots + * Obtiene las categorias raiz (sin padre) + */ +router.get('/roots', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const categories = await categoryService.findRootCategories(tenantId); + + return res.json({ + success: true, + data: categories, + count: categories.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/categories/:id + * Obtiene una categoria por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const category = await categoryService.findById(req.params.id, tenantId); + if (!category) { + return res.status(404).json({ error: 'Categoria no encontrada' }); + } + + return res.json({ success: true, data: category }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/categories/:id/children + * Obtiene las subcategorias de una categoria + */ +router.get('/:id/children', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const children = await categoryService.findChildren(req.params.id, tenantId); + + return res.json({ + success: true, + data: children, + count: children.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/categories/:id/products/count + * Cuenta los productos en una categoria + */ +router.get('/:id/products/count', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const count = await categoryService.countProducts(req.params.id, tenantId); + + return res.json({ + success: true, + data: { count }, + }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/products/categories + * Crea una nueva categoria + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: CreateProductCategoryDto = { + ...req.body, + tenantId, + }; + + if (!data.code || !data.name) { + return res.status(400).json({ + error: 'code y name son requeridos' + }); + } + + const existing = await categoryService.findByCode(data.code, tenantId); + if (existing) { + return res.status(409).json({ error: 'Ya existe una categoria con ese codigo' }); + } + + const category = await categoryService.create(data); + return res.status(201).json({ success: true, data: category }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/products/categories/:id + * Actualiza una categoria + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: UpdateProductCategoryDto = req.body; + + if (data.code) { + const existing = await categoryService.findByCode(data.code, tenantId); + if (existing && existing.id !== req.params.id) { + return res.status(409).json({ error: 'Ya existe una categoria con ese codigo' }); + } + } + + const category = await categoryService.update(req.params.id, tenantId, data); + if (!category) { + return res.status(404).json({ error: 'Categoria no encontrada' }); + } + + return res.json({ success: true, data: category }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/products/categories/:id + * Elimina una categoria (soft delete) + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const productCount = await categoryService.countProducts(req.params.id, tenantId); + if (productCount > 0) { + return res.status(409).json({ + error: `No se puede eliminar la categoria porque tiene ${productCount} productos asociados` + }); + } + + const deleted = await categoryService.delete(req.params.id, tenantId); + if (!deleted) { + return res.status(404).json({ error: 'Categoria no encontrada' }); + } + + return res.json({ success: true, message: 'Categoria eliminada' }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/src/modules/products/controllers/product-price.controller.ts b/src/modules/products/controllers/product-price.controller.ts new file mode 100644 index 0000000..952d8b3 --- /dev/null +++ b/src/modules/products/controllers/product-price.controller.ts @@ -0,0 +1,331 @@ +/** + * Product Price Controller + * API endpoints para gestion de precios de productos + * + * @module Products + * @prefix /api/v1/products/:productId/prices + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + ProductPriceService, + CreateProductPriceDto, + UpdateProductPriceDto +} from '../services/product-price.service'; +import { ProductService } from '../services/product.service'; + +const router = Router({ mergeParams: true }); +const priceService = new ProductPriceService(); +const productService = new ProductService(); + +/** + * GET /api/v1/products/:productId/prices + * Lista todos los precios de un producto + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { productId } = req.params; + const { priceType, isActive, activeOnly } = req.query; + + const product = await productService.findById(productId, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + let prices; + if (activeOnly === 'true') { + prices = await priceService.findActivePricesByProduct(productId); + } else { + prices = await priceService.findAll({ + productId, + priceType: priceType as 'standard' | 'wholesale' | 'retail' | 'promo', + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined, + }); + } + + return res.json({ + success: true, + data: prices, + count: prices.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/:productId/prices/best + * Obtiene el mejor precio para una cantidad dada + */ +router.get('/best', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { productId } = req.params; + const { quantity } = req.query; + + const product = await productService.findById(productId, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + const price = await priceService.findBestPrice( + productId, + quantity ? parseInt(quantity as string, 10) : 1 + ); + + return res.json({ + success: true, + data: price, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/:productId/prices/active + * Obtiene el precio activo por tipo + */ +router.get('/active', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { productId } = req.params; + const { priceType, quantity } = req.query; + + const product = await productService.findById(productId, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + const price = await priceService.findActivePrice( + productId, + (priceType as 'standard' | 'wholesale' | 'retail' | 'promo') || 'standard', + quantity ? parseInt(quantity as string, 10) : 1 + ); + + return res.json({ + success: true, + data: price, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/:productId/prices/:id + * Obtiene un precio por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const price = await priceService.findById(req.params.id); + if (!price || price.productId !== req.params.productId) { + return res.status(404).json({ error: 'Precio no encontrado' }); + } + + return res.json({ success: true, data: price }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/products/:productId/prices + * Crea un nuevo precio para un producto + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { productId } = req.params; + + const product = await productService.findById(productId, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + const data: CreateProductPriceDto = { + ...req.body, + productId, + }; + + if (data.price === undefined || data.price === null) { + return res.status(400).json({ + error: 'price es requerido' + }); + } + + const price = await priceService.create(data); + return res.status(201).json({ success: true, data: price }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/products/:productId/prices/standard + * Establece el precio estandar de un producto + */ +router.post('/standard', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { productId } = req.params; + const { price, currency } = req.body; + + const product = await productService.findById(productId, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + if (price === undefined || price === null) { + return res.status(400).json({ + error: 'price es requerido' + }); + } + + const productPrice = await priceService.setStandardPrice(productId, price, currency); + return res.json({ success: true, data: productPrice }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/products/:productId/prices/promo + * Crea un precio promocional para un producto + */ +router.post('/promo', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { productId } = req.params; + const { price, validFrom, validTo, priceListName } = req.body; + + const product = await productService.findById(productId, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + if (price === undefined || !validFrom || !validTo) { + return res.status(400).json({ + error: 'price, validFrom y validTo son requeridos' + }); + } + + const productPrice = await priceService.createPromoPrice( + productId, + price, + new Date(validFrom), + new Date(validTo), + priceListName + ); + + return res.status(201).json({ success: true, data: productPrice }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/products/:productId/prices/:id + * Actualiza un precio + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: UpdateProductPriceDto = req.body; + const price = await priceService.update(req.params.id, data); + + if (!price || price.productId !== req.params.productId) { + return res.status(404).json({ error: 'Precio no encontrado' }); + } + + return res.json({ success: true, data: price }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/products/:productId/prices/:id/deactivate + * Desactiva un precio + */ +router.patch('/:id/deactivate', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const price = await priceService.deactivate(req.params.id); + + if (!price || price.productId !== req.params.productId) { + return res.status(404).json({ error: 'Precio no encontrado' }); + } + + return res.json({ success: true, data: price }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/products/:productId/prices/:id + * Elimina un precio + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const price = await priceService.findById(req.params.id); + if (!price || price.productId !== req.params.productId) { + return res.status(404).json({ error: 'Precio no encontrado' }); + } + + const deleted = await priceService.delete(req.params.id); + if (!deleted) { + return res.status(404).json({ error: 'Precio no encontrado' }); + } + + return res.json({ success: true, message: 'Precio eliminado' }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/src/modules/products/controllers/product-supplier.controller.ts b/src/modules/products/controllers/product-supplier.controller.ts new file mode 100644 index 0000000..c46d838 --- /dev/null +++ b/src/modules/products/controllers/product-supplier.controller.ts @@ -0,0 +1,331 @@ +/** + * Product Supplier Controller + * API endpoints para gestion de proveedores de productos + * + * @module Products + * @prefix /api/v1/products/:productId/suppliers + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + ProductSupplierService, + CreateProductSupplierDto, + UpdateProductSupplierDto +} from '../services/product-supplier.service'; +import { ProductService } from '../services/product.service'; + +const router = Router({ mergeParams: true }); +const supplierService = new ProductSupplierService(); +const productService = new ProductService(); + +/** + * GET /api/v1/products/:productId/suppliers + * Lista todos los proveedores de un producto + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { productId } = req.params; + const { isPreferred, isActive } = req.query; + + const product = await productService.findById(productId, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + const suppliers = await supplierService.findAll({ + productId, + isPreferred: isPreferred === 'true' ? true : isPreferred === 'false' ? false : undefined, + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined, + }); + + return res.json({ + success: true, + data: suppliers, + count: suppliers.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/:productId/suppliers/preferred + * Obtiene el proveedor preferido de un producto + */ +router.get('/preferred', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { productId } = req.params; + + const product = await productService.findById(productId, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + const supplier = await supplierService.findPreferred(productId); + + return res.json({ + success: true, + data: supplier, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/:productId/suppliers/cheapest + * Obtiene el proveedor mas barato de un producto + */ +router.get('/cheapest', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { productId } = req.params; + + const product = await productService.findById(productId, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + const supplier = await supplierService.findCheapest(productId); + + return res.json({ + success: true, + data: supplier, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/:productId/suppliers/stats + * Obtiene estadisticas de precios de proveedores + */ +router.get('/stats', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { productId } = req.params; + + const product = await productService.findById(productId, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + const [count, avgPrice, minPrice] = await Promise.all([ + supplierService.countByProduct(productId), + supplierService.getAveragePurchasePrice(productId), + supplierService.getLowestPurchasePrice(productId), + ]); + + return res.json({ + success: true, + data: { + suppliersCount: count, + averagePurchasePrice: avgPrice, + lowestPurchasePrice: minPrice, + }, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/:productId/suppliers/:id + * Obtiene un proveedor por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const supplier = await supplierService.findById(req.params.id); + if (!supplier || supplier.productId !== req.params.productId) { + return res.status(404).json({ error: 'Proveedor no encontrado' }); + } + + return res.json({ success: true, data: supplier }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/products/:productId/suppliers + * Asocia un proveedor a un producto + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { productId } = req.params; + + const product = await productService.findById(productId, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + const data: CreateProductSupplierDto = { + ...req.body, + productId, + }; + + if (!data.supplierId) { + return res.status(400).json({ + error: 'supplierId es requerido' + }); + } + + const existing = await supplierService.findByProductAndSupplier(productId, data.supplierId); + if (existing) { + return res.status(409).json({ + error: 'Este proveedor ya esta asociado a este producto' + }); + } + + const supplier = await supplierService.create(data); + return res.status(201).json({ success: true, data: supplier }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/products/:productId/suppliers/:id + * Actualiza la relacion proveedor-producto + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: UpdateProductSupplierDto = req.body; + const supplier = await supplierService.update(req.params.id, data); + + if (!supplier || supplier.productId !== req.params.productId) { + return res.status(404).json({ error: 'Proveedor no encontrado' }); + } + + return res.json({ success: true, data: supplier }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/products/:productId/suppliers/:id/set-preferred + * Establece un proveedor como preferido + */ +router.patch('/:id/set-preferred', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const existing = await supplierService.findById(req.params.id); + if (!existing || existing.productId !== req.params.productId) { + return res.status(404).json({ error: 'Proveedor no encontrado' }); + } + + const supplier = await supplierService.setPreferred(req.params.id); + if (!supplier) { + return res.status(404).json({ error: 'Proveedor no encontrado' }); + } + + return res.json({ success: true, data: supplier }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/products/:productId/suppliers/:id/update-price + * Actualiza el precio de compra + */ +router.patch('/:id/update-price', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { purchasePrice, currency } = req.body; + if (purchasePrice === undefined) { + return res.status(400).json({ error: 'purchasePrice es requerido' }); + } + + const existing = await supplierService.findById(req.params.id); + if (!existing || existing.productId !== req.params.productId) { + return res.status(404).json({ error: 'Proveedor no encontrado' }); + } + + const supplier = await supplierService.updatePurchasePrice( + req.params.id, + purchasePrice, + currency + ); + + if (!supplier) { + return res.status(404).json({ error: 'Proveedor no encontrado' }); + } + + return res.json({ success: true, data: supplier }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/products/:productId/suppliers/:id + * Elimina la relacion proveedor-producto + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const existing = await supplierService.findById(req.params.id); + if (!existing || existing.productId !== req.params.productId) { + return res.status(404).json({ error: 'Proveedor no encontrado' }); + } + + const deleted = await supplierService.delete(req.params.id); + if (!deleted) { + return res.status(404).json({ error: 'Proveedor no encontrado' }); + } + + return res.json({ success: true, message: 'Proveedor desvinculado del producto' }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/src/modules/products/controllers/product-variant.controller.ts b/src/modules/products/controllers/product-variant.controller.ts new file mode 100644 index 0000000..a7044f5 --- /dev/null +++ b/src/modules/products/controllers/product-variant.controller.ts @@ -0,0 +1,367 @@ +/** + * Product Variant Controller + * API endpoints para gestion de variantes de productos + * + * @module Products + * @prefix /api/v1/products/:productId/variants + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + ProductVariantService, + CreateProductVariantDto, + UpdateProductVariantDto +} from '../services/product-variant.service'; +import { ProductService } from '../services/product.service'; + +const router = Router({ mergeParams: true }); +const variantService = new ProductVariantService(); +const productService = new ProductService(); + +/** + * GET /api/v1/products/:productId/variants + * Lista todas las variantes de un producto + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { productId } = req.params; + const { isActive, search } = req.query; + + const product = await productService.findById(productId, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + const variants = await variantService.findAll({ + tenantId, + productId, + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined, + search: search as string, + }); + + return res.json({ + success: true, + data: variants, + count: variants.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/:productId/variants/total-stock + * Obtiene el stock total de todas las variantes + */ +router.get('/total-stock', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { productId } = req.params; + + const product = await productService.findById(productId, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + const totalStock = await variantService.getTotalStock(productId, tenantId); + + return res.json({ + success: true, + data: { totalStock }, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/:productId/variants/:id + * Obtiene una variante por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const variant = await variantService.findById(req.params.id, tenantId); + if (!variant || variant.productId !== req.params.productId) { + return res.status(404).json({ error: 'Variante no encontrada' }); + } + + return res.json({ success: true, data: variant }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/products/:productId/variants + * Crea una nueva variante + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { productId } = req.params; + + const product = await productService.findById(productId, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + const data: CreateProductVariantDto = { + ...req.body, + productId, + tenantId, + createdBy: (req as any).user?.id, + }; + + if (!data.sku || !data.name) { + return res.status(400).json({ + error: 'sku y name son requeridos' + }); + } + + const existingBySku = await variantService.findBySku(data.sku, tenantId); + if (existingBySku) { + return res.status(409).json({ error: 'Ya existe una variante con ese SKU' }); + } + + if (data.barcode) { + const existingByBarcode = await variantService.findByBarcode(data.barcode, tenantId); + if (existingByBarcode) { + return res.status(409).json({ error: 'Ya existe una variante con ese codigo de barras' }); + } + } + + const variant = await variantService.create(data); + return res.status(201).json({ success: true, data: variant }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/products/:productId/variants/bulk + * Crea multiples variantes en lote + */ +router.post('/bulk', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { productId } = req.params; + const { variants } = req.body; + + const product = await productService.findById(productId, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + if (!variants || !Array.isArray(variants) || variants.length === 0) { + return res.status(400).json({ error: 'variants array is required' }); + } + + const variantsData: CreateProductVariantDto[] = variants.map((v: any) => ({ + ...v, + productId, + tenantId, + createdBy: (req as any).user?.id, + })); + + const createdVariants = await variantService.bulkCreate(variantsData); + + return res.status(201).json({ + success: true, + data: createdVariants, + count: createdVariants.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/products/:productId/variants/:id + * Actualiza una variante + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: UpdateProductVariantDto = { + ...req.body, + updatedBy: (req as any).user?.id, + }; + + if (data.sku) { + const existing = await variantService.findBySku(data.sku, tenantId); + if (existing && existing.id !== req.params.id) { + return res.status(409).json({ error: 'Ya existe una variante con ese SKU' }); + } + } + + if (data.barcode) { + const existing = await variantService.findByBarcode(data.barcode, tenantId); + if (existing && existing.id !== req.params.id) { + return res.status(409).json({ error: 'Ya existe una variante con ese codigo de barras' }); + } + } + + const variant = await variantService.update(req.params.id, tenantId, data); + if (!variant || variant.productId !== req.params.productId) { + return res.status(404).json({ error: 'Variante no encontrada' }); + } + + return res.json({ success: true, data: variant }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/products/:productId/variants/:id/stock + * Actualiza el stock de una variante + */ +router.patch('/:id/stock', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { quantity, operation } = req.body; + + if (quantity === undefined) { + return res.status(400).json({ error: 'quantity es requerido' }); + } + + const existing = await variantService.findById(req.params.id, tenantId); + if (!existing || existing.productId !== req.params.productId) { + return res.status(404).json({ error: 'Variante no encontrada' }); + } + + const variant = await variantService.updateStock( + req.params.id, + tenantId, + quantity, + operation || 'set' + ); + + if (!variant) { + return res.status(404).json({ error: 'Variante no encontrada' }); + } + + return res.json({ success: true, data: variant }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/products/:productId/variants/:id/price-extra + * Actualiza el precio extra de una variante + */ +router.patch('/:id/price-extra', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { priceExtra } = req.body; + + if (priceExtra === undefined) { + return res.status(400).json({ error: 'priceExtra es requerido' }); + } + + const existing = await variantService.findById(req.params.id, tenantId); + if (!existing || existing.productId !== req.params.productId) { + return res.status(404).json({ error: 'Variante no encontrada' }); + } + + const variant = await variantService.updatePriceExtra(req.params.id, tenantId, priceExtra); + if (!variant) { + return res.status(404).json({ error: 'Variante no encontrada' }); + } + + return res.json({ success: true, data: variant }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/products/:productId/variants/:id/toggle-active + * Activa/desactiva una variante + */ +router.patch('/:id/toggle-active', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const existing = await variantService.findById(req.params.id, tenantId); + if (!existing || existing.productId !== req.params.productId) { + return res.status(404).json({ error: 'Variante no encontrada' }); + } + + const variant = await variantService.toggleActive(req.params.id, tenantId); + if (!variant) { + return res.status(404).json({ error: 'Variante no encontrada' }); + } + + return res.json({ success: true, data: variant }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/products/:productId/variants/:id + * Elimina una variante + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const existing = await variantService.findById(req.params.id, tenantId); + if (!existing || existing.productId !== req.params.productId) { + return res.status(404).json({ error: 'Variante no encontrada' }); + } + + const deleted = await variantService.delete(req.params.id, tenantId); + if (!deleted) { + return res.status(404).json({ error: 'Variante no encontrada' }); + } + + return res.json({ success: true, message: 'Variante eliminada' }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/src/modules/products/controllers/product.controller.ts b/src/modules/products/controllers/product.controller.ts new file mode 100644 index 0000000..adbb83f --- /dev/null +++ b/src/modules/products/controllers/product.controller.ts @@ -0,0 +1,391 @@ +/** + * Product Controller + * API endpoints para gestion de productos comerciales + * + * @module Products + * @prefix /api/v1/products + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + ProductService, + CreateProductDto, + UpdateProductDto +} from '../services/product.service'; + +const router = Router(); +const productService = new ProductService(); + +/** + * GET /api/v1/products + * Lista todos los productos del tenant (paginado) + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { categoryId, productType, isActive, isSellable, isPurchasable, search, tags, limit, offset } = req.query; + + const result = await productService.findAll({ + tenantId, + categoryId: categoryId as string, + productType: productType as 'product' | 'service' | 'consumable' | 'kit', + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined, + isSellable: isSellable === 'true' ? true : isSellable === 'false' ? false : undefined, + isPurchasable: isPurchasable === 'true' ? true : isPurchasable === 'false' ? false : undefined, + search: search as string, + tags: tags ? (tags as string).split(',') : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + return res.json({ + success: true, + data: result.data, + total: result.total, + limit: result.limit, + offset: result.offset, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/search + * Busqueda rapida de productos + */ +router.get('/search', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { q, limit } = req.query; + if (!q) { + return res.status(400).json({ error: 'Query parameter q is required' }); + } + + const products = await productService.searchProducts( + tenantId, + q as string, + limit ? parseInt(limit as string, 10) : 20 + ); + + return res.json({ + success: true, + data: products, + count: products.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/low-stock + * Obtiene productos con stock bajo + */ +router.get('/low-stock', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const products = await productService.getLowStockProducts(tenantId); + + return res.json({ + success: true, + data: products, + count: products.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/:id + * Obtiene un producto por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const product = await productService.findById(req.params.id, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + return res.json({ success: true, data: product }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/sku/:sku + * Obtiene un producto por SKU + */ +router.get('/sku/:sku', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const product = await productService.findBySku(req.params.sku, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + return res.json({ success: true, data: product }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/products/barcode/:barcode + * Obtiene un producto por codigo de barras + */ +router.get('/barcode/:barcode', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const product = await productService.findByBarcode(req.params.barcode, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + return res.json({ success: true, data: product }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/products + * Crea un nuevo producto + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: CreateProductDto = { + ...req.body, + tenantId, + createdBy: (req as any).user?.id, + }; + + if (!data.sku || !data.name) { + return res.status(400).json({ + error: 'sku y name son requeridos' + }); + } + + const existingBySku = await productService.findBySku(data.sku, tenantId); + if (existingBySku) { + return res.status(409).json({ error: 'Ya existe un producto con ese SKU' }); + } + + if (data.barcode) { + const existingByBarcode = await productService.findByBarcode(data.barcode, tenantId); + if (existingByBarcode) { + return res.status(409).json({ error: 'Ya existe un producto con ese codigo de barras' }); + } + } + + const product = await productService.create(data); + return res.status(201).json({ success: true, data: product }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/products/:id + * Actualiza un producto + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: UpdateProductDto = { + ...req.body, + updatedBy: (req as any).user?.id, + }; + + if (data.sku) { + const existing = await productService.findBySku(data.sku, tenantId); + if (existing && existing.id !== req.params.id) { + return res.status(409).json({ error: 'Ya existe un producto con ese SKU' }); + } + } + + if (data.barcode) { + const existing = await productService.findByBarcode(data.barcode, tenantId); + if (existing && existing.id !== req.params.id) { + return res.status(409).json({ error: 'Ya existe un producto con ese codigo de barras' }); + } + } + + const product = await productService.update(req.params.id, tenantId, data); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + return res.json({ success: true, data: product }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/products/:id/prices + * Actualiza los precios de un producto + */ +router.patch('/:id/prices', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { salePrice, costPrice, minSalePrice } = req.body; + + const product = await productService.updatePrices(req.params.id, tenantId, { + salePrice, + costPrice, + minSalePrice, + }); + + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + return res.json({ success: true, data: product }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/products/:id/stock-settings + * Actualiza la configuracion de stock de un producto + */ +router.patch('/:id/stock-settings', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { minStock, maxStock, reorderPoint, reorderQuantity } = req.body; + + const product = await productService.updateStock(req.params.id, tenantId, { + minStock, + maxStock, + reorderPoint, + reorderQuantity, + }); + + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + return res.json({ success: true, data: product }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/products/:id/toggle-active + * Activa/desactiva un producto + */ +router.patch('/:id/toggle-active', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const product = await productService.toggleActive(req.params.id, tenantId); + if (!product) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + return res.json({ success: true, data: product }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/products/bulk-update-prices + * Actualiza precios en lote + */ +router.post('/bulk-update-prices', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { updates } = req.body; + if (!updates || !Array.isArray(updates)) { + return res.status(400).json({ error: 'updates array is required' }); + } + + const updated = await productService.bulkUpdatePrices(tenantId, updates); + + return res.json({ + success: true, + data: { updated }, + }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/products/:id + * Elimina un producto (soft delete) + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const deleted = await productService.delete(req.params.id, tenantId); + if (!deleted) { + return res.status(404).json({ error: 'Producto no encontrado' }); + } + + return res.json({ success: true, message: 'Producto eliminado' }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/src/modules/products/index.ts b/src/modules/products/index.ts new file mode 100644 index 0000000..2af51bb --- /dev/null +++ b/src/modules/products/index.ts @@ -0,0 +1,10 @@ +/** + * Products Module + * Modulo de productos comerciales para ERP Construccion + * + * @module Products + */ + +export * from './entities'; +export * from './services'; +export * from './controllers'; diff --git a/src/modules/products/services/index.ts b/src/modules/products/services/index.ts new file mode 100644 index 0000000..b9b89d7 --- /dev/null +++ b/src/modules/products/services/index.ts @@ -0,0 +1,53 @@ +/** + * Products Services Index + * @module Products + */ + +// Export ServiceContext only once from the first service +export { ServiceContext } from './product-category.service'; + +// Export services and DTOs (excluding duplicate ServiceContext) +export { + ProductCategoryService, + CreateProductCategoryDto, + UpdateProductCategoryDto, + ProductCategoryFilters, +} from './product-category.service'; + +export { + ProductService, + CreateProductDto, + UpdateProductDto, + ProductFilters, + PaginatedResult, +} from './product.service'; + +export { + ProductPriceService, + CreateProductPriceDto, + UpdateProductPriceDto, + ProductPriceFilters, +} from './product-price.service'; + +export { + ProductSupplierService, + CreateProductSupplierDto, + UpdateProductSupplierDto, + ProductSupplierFilters, +} from './product-supplier.service'; + +export { + ProductAttributeService, + CreateProductAttributeDto, + UpdateProductAttributeDto, + CreateAttributeValueDto, + UpdateAttributeValueDto, + ProductAttributeFilters, +} from './product-attribute.service'; + +export { + ProductVariantService, + CreateProductVariantDto, + UpdateProductVariantDto, + ProductVariantFilters, +} from './product-variant.service'; diff --git a/src/modules/products/services/product-attribute.service.ts b/src/modules/products/services/product-attribute.service.ts new file mode 100644 index 0000000..2eea95e --- /dev/null +++ b/src/modules/products/services/product-attribute.service.ts @@ -0,0 +1,235 @@ +/** + * Product Attribute Service + * Servicio para gestion de atributos de productos (color, talla, etc.) + * + * @module Products + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { AppDataSource } from '../../../shared/database/typeorm.config'; +import { ProductAttribute } from '../entities/product-attribute.entity'; +import { ProductAttributeValue } from '../entities/product-attribute-value.entity'; + +export interface CreateProductAttributeDto { + tenantId: string; + code: string; + name: string; + description?: string; + displayType?: 'radio' | 'select' | 'color' | 'pills'; + sortOrder?: number; + createdBy?: string; +} + +export interface UpdateProductAttributeDto { + code?: string; + name?: string; + description?: string; + displayType?: 'radio' | 'select' | 'color' | 'pills'; + sortOrder?: number; + isActive?: boolean; + updatedBy?: string; +} + +export interface CreateAttributeValueDto { + attributeId: string; + code?: string; + name: string; + htmlColor?: string; + imageUrl?: string; + sortOrder?: number; +} + +export interface UpdateAttributeValueDto { + code?: string; + name?: string; + htmlColor?: string; + imageUrl?: string; + sortOrder?: number; + isActive?: boolean; +} + +export interface ProductAttributeFilters { + tenantId: string; + isActive?: boolean; + search?: string; +} + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export class ProductAttributeService { + private attributeRepository: Repository; + private valueRepository: Repository; + + constructor() { + this.attributeRepository = AppDataSource.getRepository(ProductAttribute); + this.valueRepository = AppDataSource.getRepository(ProductAttributeValue); + } + + async findAll(filters: ProductAttributeFilters): Promise { + const where: FindOptionsWhere = { + tenantId: filters.tenantId, + }; + + if (filters.isActive !== undefined) { + where.isActive = filters.isActive; + } + + const queryBuilder = this.attributeRepository.createQueryBuilder('attr') + .where(where) + .leftJoinAndSelect('attr.values', 'values', 'values.isActive = true') + .orderBy('attr.sortOrder', 'ASC') + .addOrderBy('attr.name', 'ASC') + .addOrderBy('values.sortOrder', 'ASC'); + + if (filters.search) { + queryBuilder.andWhere( + '(attr.name ILIKE :search OR attr.code ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + return queryBuilder.getMany(); + } + + async findById(id: string, tenantId: string): Promise { + return this.attributeRepository.findOne({ + where: { id, tenantId }, + relations: ['values'], + }); + } + + async findByCode(code: string, tenantId: string): Promise { + return this.attributeRepository.findOne({ + where: { code, tenantId }, + relations: ['values'], + }); + } + + async create(data: CreateProductAttributeDto): Promise { + const attribute = this.attributeRepository.create(data); + return this.attributeRepository.save(attribute); + } + + async update( + id: string, + tenantId: string, + data: UpdateProductAttributeDto + ): Promise { + const attribute = await this.findById(id, tenantId); + if (!attribute) { + return null; + } + + Object.assign(attribute, data); + return this.attributeRepository.save(attribute); + } + + async delete(id: string, tenantId: string): Promise { + const attribute = await this.findById(id, tenantId); + if (!attribute) { + return false; + } + + await this.valueRepository.delete({ attributeId: id }); + + const result = await this.attributeRepository.delete({ id, tenantId }); + return result.affected ? result.affected > 0 : false; + } + + async findValueById(id: string): Promise { + return this.valueRepository.findOne({ + where: { id }, + relations: ['attribute'], + }); + } + + async findValuesByAttribute(attributeId: string): Promise { + return this.valueRepository.find({ + where: { attributeId, isActive: true }, + order: { sortOrder: 'ASC', name: 'ASC' }, + }); + } + + async createValue(data: CreateAttributeValueDto): Promise { + const value = this.valueRepository.create(data); + return this.valueRepository.save(value); + } + + async updateValue( + id: string, + data: UpdateAttributeValueDto + ): Promise { + const value = await this.findValueById(id); + if (!value) { + return null; + } + + Object.assign(value, data); + return this.valueRepository.save(value); + } + + async deleteValue(id: string): Promise { + const result = await this.valueRepository.delete({ id }); + return result.affected ? result.affected > 0 : false; + } + + async addValues( + attributeId: string, + values: Array<{ code?: string; name: string; htmlColor?: string; sortOrder?: number }> + ): Promise { + const created: ProductAttributeValue[] = []; + + for (let i = 0; i < values.length; i++) { + const val = values[i]; + const value = this.valueRepository.create({ + attributeId, + code: val.code, + name: val.name, + htmlColor: val.htmlColor, + sortOrder: val.sortOrder ?? i, + }); + const saved = await this.valueRepository.save(value); + created.push(saved); + } + + return created; + } + + async reorderValues( + attributeId: string, + valueIds: string[] + ): Promise { + for (let i = 0; i < valueIds.length; i++) { + await this.valueRepository.update( + { id: valueIds[i], attributeId }, + { sortOrder: i } + ); + } + } + + async countValues(attributeId: string): Promise { + return this.valueRepository.count({ + where: { attributeId, isActive: true }, + }); + } + + async createWithValues( + data: CreateProductAttributeDto, + values: Array<{ code?: string; name: string; htmlColor?: string }> + ): Promise { + const attribute = await this.create(data); + + if (values.length > 0) { + await this.addValues(attribute.id, values); + const refreshed = await this.findById(attribute.id, data.tenantId); + if (refreshed) { + return refreshed; + } + } + + return attribute; + } +} diff --git a/src/modules/products/services/product-category.service.ts b/src/modules/products/services/product-category.service.ts new file mode 100644 index 0000000..52bdf42 --- /dev/null +++ b/src/modules/products/services/product-category.service.ts @@ -0,0 +1,196 @@ +/** + * Product Category Service + * Servicio para gestion de categorias de productos + * + * @module Products + */ + +import { Repository, FindOptionsWhere, IsNull } from 'typeorm'; +import { AppDataSource } from '../../../shared/database/typeorm.config'; +import { ProductCategory } from '../entities/product-category.entity'; + +export interface CreateProductCategoryDto { + tenantId: string; + parentId?: string; + code: string; + name: string; + description?: string; + imageUrl?: string; + sortOrder?: number; +} + +export interface UpdateProductCategoryDto { + parentId?: string | null; + code?: string; + name?: string; + description?: string; + imageUrl?: string; + sortOrder?: number; + isActive?: boolean; +} + +export interface ProductCategoryFilters { + tenantId: string; + parentId?: string | null; + isActive?: boolean; + search?: string; +} + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export class ProductCategoryService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(ProductCategory); + } + + async findAll(filters: ProductCategoryFilters): Promise { + const where: FindOptionsWhere = { + tenantId: filters.tenantId, + }; + + if (filters.parentId !== undefined) { + where.parentId = filters.parentId === null ? IsNull() : filters.parentId; + } + + if (filters.isActive !== undefined) { + where.isActive = filters.isActive; + } + + const queryBuilder = this.repository.createQueryBuilder('category') + .where(where) + .leftJoinAndSelect('category.parent', 'parent') + .orderBy('category.sortOrder', 'ASC') + .addOrderBy('category.name', 'ASC'); + + if (filters.search) { + queryBuilder.andWhere( + '(category.name ILIKE :search OR category.code ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + return queryBuilder.getMany(); + } + + async findById(id: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['parent'], + }); + } + + async findByCode(code: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { code, tenantId }, + }); + } + + async findRootCategories(tenantId: string): Promise { + return this.repository.find({ + where: { tenantId, parentId: IsNull() }, + order: { sortOrder: 'ASC', name: 'ASC' }, + }); + } + + async findChildren(parentId: string, tenantId: string): Promise { + return this.repository.find({ + where: { parentId, tenantId }, + order: { sortOrder: 'ASC', name: 'ASC' }, + }); + } + + async create(data: CreateProductCategoryDto): Promise { + let hierarchyPath = ''; + let hierarchyLevel = 0; + + if (data.parentId) { + const parent = await this.findById(data.parentId, data.tenantId); + if (parent) { + hierarchyPath = parent.hierarchyPath + ? `${parent.hierarchyPath}/${parent.id}` + : parent.id; + hierarchyLevel = parent.hierarchyLevel + 1; + } + } + + const category = this.repository.create({ + ...data, + hierarchyPath, + hierarchyLevel, + }); + + return this.repository.save(category); + } + + async update( + id: string, + tenantId: string, + data: UpdateProductCategoryDto + ): Promise { + const category = await this.findById(id, tenantId); + if (!category) { + return null; + } + + if (data.parentId !== undefined && data.parentId !== category.parentId) { + if (data.parentId === null) { + data = { ...data, parentId: undefined } as UpdateProductCategoryDto; + (category as any).hierarchyPath = ''; + (category as any).hierarchyLevel = 0; + } else { + const parent = await this.findById(data.parentId, tenantId); + if (parent) { + (category as any).hierarchyPath = parent.hierarchyPath + ? `${parent.hierarchyPath}/${parent.id}` + : parent.id; + (category as any).hierarchyLevel = parent.hierarchyLevel + 1; + } + } + } + + Object.assign(category, data); + return this.repository.save(category); + } + + async delete(id: string, tenantId: string): Promise { + const result = await this.repository.softDelete({ id, tenantId }); + return result.affected ? result.affected > 0 : false; + } + + async hardDelete(id: string, tenantId: string): Promise { + const result = await this.repository.delete({ id, tenantId }); + return result.affected ? result.affected > 0 : false; + } + + async countProducts(categoryId: string, tenantId: string): Promise { + const result = await this.repository.manager.query( + `SELECT COUNT(*) as count FROM products.products + WHERE category_id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, + [categoryId, tenantId] + ); + return parseInt(result[0].count, 10); + } + + async getHierarchyTree(tenantId: string): Promise { + const categories = await this.repository.find({ + where: { tenantId, isActive: true }, + order: { hierarchyLevel: 'ASC', sortOrder: 'ASC', name: 'ASC' }, + }); + + return this.buildTree(categories); + } + + private buildTree(categories: ProductCategory[], parentId: string | null = null): ProductCategory[] { + return categories + .filter(cat => cat.parentId === parentId) + .map(cat => ({ + ...cat, + children: this.buildTree(categories, cat.id), + })) as ProductCategory[]; + } +} diff --git a/src/modules/products/services/product-price.service.ts b/src/modules/products/services/product-price.service.ts new file mode 100644 index 0000000..36fd68f --- /dev/null +++ b/src/modules/products/services/product-price.service.ts @@ -0,0 +1,234 @@ +/** + * Product Price Service + * Servicio para gestion de precios de productos (listas de precios, promociones) + * + * @module Products + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { AppDataSource } from '../../../shared/database/typeorm.config'; +import { ProductPrice } from '../entities/product-price.entity'; + +export interface CreateProductPriceDto { + productId: string; + priceType?: 'standard' | 'wholesale' | 'retail' | 'promo'; + priceListName?: string; + price: number; + currency?: string; + minQuantity?: number; + validFrom?: Date; + validTo?: Date; +} + +export interface UpdateProductPriceDto { + priceType?: 'standard' | 'wholesale' | 'retail' | 'promo'; + priceListName?: string; + price?: number; + currency?: string; + minQuantity?: number; + validFrom?: Date; + validTo?: Date; + isActive?: boolean; +} + +export interface ProductPriceFilters { + productId: string; + priceType?: 'standard' | 'wholesale' | 'retail' | 'promo'; + isActive?: boolean; + validAt?: Date; +} + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export class ProductPriceService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(ProductPrice); + } + + async findAll(filters: ProductPriceFilters): Promise { + const where: FindOptionsWhere = { + productId: filters.productId, + }; + + if (filters.priceType) { + where.priceType = filters.priceType; + } + + if (filters.isActive !== undefined) { + where.isActive = filters.isActive; + } + + return this.repository.find({ + where, + relations: ['product'], + order: { priceType: 'ASC', minQuantity: 'ASC' }, + }); + } + + async findById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + relations: ['product'], + }); + } + + async findActivePrice( + productId: string, + priceType: 'standard' | 'wholesale' | 'retail' | 'promo' = 'standard', + quantity: number = 1, + date: Date = new Date() + ): Promise { + return this.repository.createQueryBuilder('price') + .where('price.productId = :productId', { productId }) + .andWhere('price.priceType = :priceType', { priceType }) + .andWhere('price.isActive = true') + .andWhere('price.minQuantity <= :quantity', { quantity }) + .andWhere('price.validFrom <= :date', { date }) + .andWhere('(price.validTo IS NULL OR price.validTo >= :date)', { date }) + .orderBy('price.minQuantity', 'DESC') + .getOne(); + } + + async findBestPrice( + productId: string, + quantity: number = 1, + date: Date = new Date() + ): Promise { + return this.repository.createQueryBuilder('price') + .where('price.productId = :productId', { productId }) + .andWhere('price.isActive = true') + .andWhere('price.minQuantity <= :quantity', { quantity }) + .andWhere('price.validFrom <= :date', { date }) + .andWhere('(price.validTo IS NULL OR price.validTo >= :date)', { date }) + .orderBy('price.price', 'ASC') + .getOne(); + } + + async findByProduct(productId: string): Promise { + return this.repository.find({ + where: { productId }, + order: { priceType: 'ASC', minQuantity: 'ASC' }, + }); + } + + async findActivePricesByProduct(productId: string, date: Date = new Date()): Promise { + return this.repository.createQueryBuilder('price') + .where('price.productId = :productId', { productId }) + .andWhere('price.isActive = true') + .andWhere('price.validFrom <= :date', { date }) + .andWhere('(price.validTo IS NULL OR price.validTo >= :date)', { date }) + .orderBy('price.priceType', 'ASC') + .addOrderBy('price.minQuantity', 'ASC') + .getMany(); + } + + async create(data: CreateProductPriceDto): Promise { + const price = this.repository.create({ + ...data, + validFrom: data.validFrom || new Date(), + }); + return this.repository.save(price); + } + + async update( + id: string, + data: UpdateProductPriceDto + ): Promise { + const price = await this.findById(id); + if (!price) { + return null; + } + + Object.assign(price, data); + return this.repository.save(price); + } + + async delete(id: string): Promise { + const result = await this.repository.delete({ id }); + return result.affected ? result.affected > 0 : false; + } + + async deactivate(id: string): Promise { + const price = await this.findById(id); + if (!price) { + return null; + } + + price.isActive = false; + return this.repository.save(price); + } + + async deactivateByProduct(productId: string, priceType?: 'standard' | 'wholesale' | 'retail' | 'promo'): Promise { + const where: FindOptionsWhere = { productId }; + if (priceType) { + where.priceType = priceType; + } + + const result = await this.repository.update(where, { isActive: false }); + return result.affected || 0; + } + + async setStandardPrice(productId: string, price: number, currency: string = 'MXN'): Promise { + const existing = await this.repository.findOne({ + where: { productId, priceType: 'standard', minQuantity: 1 }, + }); + + if (existing) { + existing.price = price; + existing.currency = currency; + existing.isActive = true; + return this.repository.save(existing); + } + + return this.create({ + productId, + priceType: 'standard', + price, + currency, + minQuantity: 1, + }); + } + + async createPromoPrice( + productId: string, + price: number, + validFrom: Date, + validTo: Date, + priceListName?: string + ): Promise { + return this.create({ + productId, + priceType: 'promo', + price, + priceListName, + validFrom, + validTo, + }); + } + + async getExpiredPromotions(date: Date = new Date()): Promise { + return this.repository.createQueryBuilder('price') + .where('price.priceType = :priceType', { priceType: 'promo' }) + .andWhere('price.isActive = true') + .andWhere('price.validTo < :date', { date }) + .leftJoinAndSelect('price.product', 'product') + .getMany(); + } + + async deactivateExpiredPromotions(date: Date = new Date()): Promise { + const result = await this.repository.createQueryBuilder() + .update(ProductPrice) + .set({ isActive: false }) + .where('priceType = :priceType', { priceType: 'promo' }) + .andWhere('isActive = true') + .andWhere('validTo < :date', { date }) + .execute(); + + return result.affected || 0; + } +} diff --git a/src/modules/products/services/product-supplier.service.ts b/src/modules/products/services/product-supplier.service.ts new file mode 100644 index 0000000..de9a4ab --- /dev/null +++ b/src/modules/products/services/product-supplier.service.ts @@ -0,0 +1,224 @@ +/** + * Product Supplier Service + * Servicio para gestion de proveedores de productos + * + * @module Products + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { AppDataSource } from '../../../shared/database/typeorm.config'; +import { ProductSupplier } from '../entities/product-supplier.entity'; + +export interface CreateProductSupplierDto { + productId: string; + supplierId: string; + supplierSku?: string; + supplierName?: string; + purchasePrice?: number; + currency?: string; + minOrderQty?: number; + leadTimeDays?: number; + isPreferred?: boolean; +} + +export interface UpdateProductSupplierDto { + supplierSku?: string; + supplierName?: string; + purchasePrice?: number; + currency?: string; + minOrderQty?: number; + leadTimeDays?: number; + isPreferred?: boolean; + isActive?: boolean; +} + +export interface ProductSupplierFilters { + productId?: string; + supplierId?: string; + isPreferred?: boolean; + isActive?: boolean; +} + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export class ProductSupplierService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(ProductSupplier); + } + + async findAll(filters: ProductSupplierFilters): Promise { + const where: FindOptionsWhere = {}; + + if (filters.productId) { + where.productId = filters.productId; + } + + if (filters.supplierId) { + where.supplierId = filters.supplierId; + } + + if (filters.isPreferred !== undefined) { + where.isPreferred = filters.isPreferred; + } + + if (filters.isActive !== undefined) { + where.isActive = filters.isActive; + } + + return this.repository.find({ + where, + relations: ['product'], + order: { isPreferred: 'DESC', purchasePrice: 'ASC' }, + }); + } + + async findById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + relations: ['product'], + }); + } + + async findByProduct(productId: string): Promise { + return this.repository.find({ + where: { productId, isActive: true }, + order: { isPreferred: 'DESC', purchasePrice: 'ASC' }, + }); + } + + async findBySupplier(supplierId: string): Promise { + return this.repository.find({ + where: { supplierId, isActive: true }, + relations: ['product'], + order: { createdAt: 'DESC' }, + }); + } + + async findPreferred(productId: string): Promise { + return this.repository.findOne({ + where: { productId, isPreferred: true, isActive: true }, + }); + } + + async findCheapest(productId: string): Promise { + return this.repository.createQueryBuilder('ps') + .where('ps.productId = :productId', { productId }) + .andWhere('ps.isActive = true') + .andWhere('ps.purchasePrice IS NOT NULL') + .orderBy('ps.purchasePrice', 'ASC') + .getOne(); + } + + async findByProductAndSupplier(productId: string, supplierId: string): Promise { + return this.repository.findOne({ + where: { productId, supplierId }, + }); + } + + async create(data: CreateProductSupplierDto): Promise { + if (data.isPreferred) { + await this.clearPreferred(data.productId); + } + + const supplier = this.repository.create(data); + return this.repository.save(supplier); + } + + async update( + id: string, + data: UpdateProductSupplierDto + ): Promise { + const supplier = await this.findById(id); + if (!supplier) { + return null; + } + + if (data.isPreferred && !supplier.isPreferred) { + await this.clearPreferred(supplier.productId); + } + + Object.assign(supplier, data); + return this.repository.save(supplier); + } + + async delete(id: string): Promise { + const result = await this.repository.delete({ id }); + return result.affected ? result.affected > 0 : false; + } + + async setPreferred(id: string): Promise { + const supplier = await this.findById(id); + if (!supplier) { + return null; + } + + await this.clearPreferred(supplier.productId); + + supplier.isPreferred = true; + return this.repository.save(supplier); + } + + async clearPreferred(productId: string): Promise { + await this.repository.update( + { productId, isPreferred: true }, + { isPreferred: false } + ); + } + + async updatePurchasePrice( + id: string, + purchasePrice: number, + currency?: string + ): Promise { + const supplier = await this.findById(id); + if (!supplier) { + return null; + } + + supplier.purchasePrice = purchasePrice; + if (currency) { + supplier.currency = currency; + } + + return this.repository.save(supplier); + } + + async countByProduct(productId: string): Promise { + return this.repository.count({ + where: { productId, isActive: true }, + }); + } + + async countBySupplier(supplierId: string): Promise { + return this.repository.count({ + where: { supplierId, isActive: true }, + }); + } + + async getAveragePurchasePrice(productId: string): Promise { + const result = await this.repository.createQueryBuilder('ps') + .select('AVG(ps.purchasePrice)', 'avgPrice') + .where('ps.productId = :productId', { productId }) + .andWhere('ps.isActive = true') + .andWhere('ps.purchasePrice IS NOT NULL') + .getRawOne(); + + return result?.avgPrice ? parseFloat(result.avgPrice) : null; + } + + async getLowestPurchasePrice(productId: string): Promise { + const result = await this.repository.createQueryBuilder('ps') + .select('MIN(ps.purchasePrice)', 'minPrice') + .where('ps.productId = :productId', { productId }) + .andWhere('ps.isActive = true') + .andWhere('ps.purchasePrice IS NOT NULL') + .getRawOne(); + + return result?.minPrice ? parseFloat(result.minPrice) : null; + } +} diff --git a/src/modules/products/services/product-variant.service.ts b/src/modules/products/services/product-variant.service.ts new file mode 100644 index 0000000..867f6b2 --- /dev/null +++ b/src/modules/products/services/product-variant.service.ts @@ -0,0 +1,238 @@ +/** + * Product Variant Service + * Servicio para gestion de variantes de productos + * + * @module Products + */ + +import { Repository, FindOptionsWhere, In } from 'typeorm'; +import { AppDataSource } from '../../../shared/database/typeorm.config'; +import { ProductVariant } from '../entities/product-variant.entity'; + +export interface CreateProductVariantDto { + productId: string; + tenantId: string; + sku: string; + barcode?: string; + name: string; + priceExtra?: number; + costExtra?: number; + stockQty?: number; + imageUrl?: string; + createdBy?: string; +} + +export interface UpdateProductVariantDto { + sku?: string; + barcode?: string; + name?: string; + priceExtra?: number; + costExtra?: number; + stockQty?: number; + imageUrl?: string; + isActive?: boolean; + updatedBy?: string; +} + +export interface ProductVariantFilters { + tenantId: string; + productId?: string; + isActive?: boolean; + search?: string; +} + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export class ProductVariantService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(ProductVariant); + } + + async findAll(filters: ProductVariantFilters): Promise { + const where: FindOptionsWhere = { + tenantId: filters.tenantId, + }; + + if (filters.productId) { + where.productId = filters.productId; + } + + if (filters.isActive !== undefined) { + where.isActive = filters.isActive; + } + + const queryBuilder = this.repository.createQueryBuilder('variant') + .where(where) + .leftJoinAndSelect('variant.product', 'product') + .orderBy('variant.name', 'ASC'); + + if (filters.search) { + queryBuilder.andWhere( + '(variant.name ILIKE :search OR variant.sku ILIKE :search OR variant.barcode ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + return queryBuilder.getMany(); + } + + async findById(id: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['product'], + }); + } + + async findBySku(sku: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { sku, tenantId }, + relations: ['product'], + }); + } + + async findByBarcode(barcode: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { barcode, tenantId }, + relations: ['product'], + }); + } + + async findByProduct(productId: string, tenantId: string): Promise { + return this.repository.find({ + where: { productId, tenantId, isActive: true }, + order: { name: 'ASC' }, + }); + } + + async findByIds(ids: string[], tenantId: string): Promise { + return this.repository.find({ + where: { id: In(ids), tenantId }, + relations: ['product'], + }); + } + + async create(data: CreateProductVariantDto): Promise { + const variant = this.repository.create(data); + return this.repository.save(variant); + } + + async update( + id: string, + tenantId: string, + data: UpdateProductVariantDto + ): Promise { + const variant = await this.findById(id, tenantId); + if (!variant) { + return null; + } + + Object.assign(variant, data); + return this.repository.save(variant); + } + + async delete(id: string, tenantId: string): Promise { + const result = await this.repository.delete({ id, tenantId }); + return result.affected ? result.affected > 0 : false; + } + + async updateStock( + id: string, + tenantId: string, + quantity: number, + operation: 'set' | 'add' | 'subtract' = 'set' + ): Promise { + const variant = await this.findById(id, tenantId); + if (!variant) { + return null; + } + + switch (operation) { + case 'add': + variant.stockQty = Number(variant.stockQty) + quantity; + break; + case 'subtract': + variant.stockQty = Math.max(0, Number(variant.stockQty) - quantity); + break; + case 'set': + default: + variant.stockQty = quantity; + break; + } + + return this.repository.save(variant); + } + + async updatePriceExtra( + id: string, + tenantId: string, + priceExtra: number + ): Promise { + const variant = await this.findById(id, tenantId); + if (!variant) { + return null; + } + + variant.priceExtra = priceExtra; + return this.repository.save(variant); + } + + async toggleActive(id: string, tenantId: string): Promise { + const variant = await this.findById(id, tenantId); + if (!variant) { + return null; + } + + variant.isActive = !variant.isActive; + return this.repository.save(variant); + } + + async countByProduct(productId: string, tenantId: string): Promise { + return this.repository.count({ + where: { productId, tenantId, isActive: true }, + }); + } + + async getTotalStock(productId: string, tenantId: string): Promise { + const result = await this.repository.createQueryBuilder('variant') + .select('SUM(variant.stockQty)', 'totalStock') + .where('variant.productId = :productId', { productId }) + .andWhere('variant.tenantId = :tenantId', { tenantId }) + .andWhere('variant.isActive = true') + .getRawOne(); + + return result?.totalStock ? parseFloat(result.totalStock) : 0; + } + + async searchVariants( + tenantId: string, + query: string, + limit: number = 20 + ): Promise { + return this.repository.createQueryBuilder('variant') + .where('variant.tenantId = :tenantId', { tenantId }) + .andWhere('variant.isActive = true') + .andWhere( + '(variant.name ILIKE :query OR variant.sku ILIKE :query OR variant.barcode ILIKE :query)', + { query: `%${query}%` } + ) + .leftJoinAndSelect('variant.product', 'product') + .orderBy('variant.name', 'ASC') + .limit(limit) + .getMany(); + } + + async bulkCreate(variants: CreateProductVariantDto[]): Promise { + const entities = this.repository.create(variants); + return this.repository.save(entities); + } + + async deleteByProduct(productId: string, tenantId: string): Promise { + const result = await this.repository.delete({ productId, tenantId }); + return result.affected || 0; + } +} diff --git a/src/modules/products/services/product.service.ts b/src/modules/products/services/product.service.ts new file mode 100644 index 0000000..eae93b7 --- /dev/null +++ b/src/modules/products/services/product.service.ts @@ -0,0 +1,348 @@ +/** + * Product Service + * Servicio para gestion de productos comerciales + * + * @module Products + */ + +import { Repository, FindOptionsWhere, In } from 'typeorm'; +import { AppDataSource } from '../../../shared/database/typeorm.config'; +import { Product } from '../entities/product.entity'; + +export interface CreateProductDto { + tenantId: string; + categoryId?: string; + inventoryProductId?: string; + sku: string; + barcode?: string; + name: string; + shortName?: string; + description?: string; + productType?: 'product' | 'service' | 'consumable' | 'kit'; + salePrice?: number; + costPrice?: number; + minSalePrice?: number; + currency?: string; + taxRate?: number; + taxIncluded?: boolean; + satProductCode?: string; + satUnitCode?: string; + uom?: string; + uomPurchase?: string; + conversionFactor?: number; + trackInventory?: boolean; + minStock?: number; + maxStock?: number; + reorderPoint?: number; + reorderQuantity?: number; + trackLots?: boolean; + trackSerials?: boolean; + trackExpiry?: boolean; + weight?: number; + weightUnit?: string; + length?: number; + width?: number; + height?: number; + dimensionUnit?: string; + imageUrl?: string; + images?: string[]; + tags?: string[]; + notes?: string; + createdBy?: string; +} + +export interface UpdateProductDto { + categoryId?: string | null; + inventoryProductId?: string | null; + sku?: string; + barcode?: string; + name?: string; + shortName?: string; + description?: string; + productType?: 'product' | 'service' | 'consumable' | 'kit'; + salePrice?: number; + costPrice?: number; + minSalePrice?: number; + currency?: string; + taxRate?: number; + taxIncluded?: boolean; + satProductCode?: string; + satUnitCode?: string; + uom?: string; + uomPurchase?: string; + conversionFactor?: number; + trackInventory?: boolean; + minStock?: number; + maxStock?: number; + reorderPoint?: number; + reorderQuantity?: number; + trackLots?: boolean; + trackSerials?: boolean; + trackExpiry?: boolean; + weight?: number; + weightUnit?: string; + length?: number; + width?: number; + height?: number; + dimensionUnit?: string; + imageUrl?: string; + images?: string[]; + tags?: string[]; + notes?: string; + isActive?: boolean; + isSellable?: boolean; + isPurchasable?: boolean; + updatedBy?: string; +} + +export interface ProductFilters { + tenantId: string; + categoryId?: string; + productType?: 'product' | 'service' | 'consumable' | 'kit'; + isActive?: boolean; + isSellable?: boolean; + isPurchasable?: boolean; + search?: string; + tags?: string[]; + limit?: number; + offset?: number; +} + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface PaginatedResult { + data: T[]; + total: number; + limit: number; + offset: number; +} + +export class ProductService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(Product); + } + + async findAll(filters: ProductFilters): Promise> { + const where: FindOptionsWhere = { + tenantId: filters.tenantId, + }; + + if (filters.categoryId) { + where.categoryId = filters.categoryId; + } + + if (filters.productType) { + where.productType = filters.productType; + } + + if (filters.isActive !== undefined) { + where.isActive = filters.isActive; + } + + if (filters.isSellable !== undefined) { + where.isSellable = filters.isSellable; + } + + if (filters.isPurchasable !== undefined) { + where.isPurchasable = filters.isPurchasable; + } + + const queryBuilder = this.repository.createQueryBuilder('product') + .where(where) + .leftJoinAndSelect('product.category', 'category'); + + if (filters.search) { + queryBuilder.andWhere( + '(product.name ILIKE :search OR product.sku ILIKE :search OR product.barcode ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + if (filters.tags && filters.tags.length > 0) { + queryBuilder.andWhere('product.tags && :tags', { tags: filters.tags }); + } + + const total = await queryBuilder.getCount(); + + queryBuilder + .orderBy('product.name', 'ASC') + .limit(filters.limit || 50) + .offset(filters.offset || 0); + + const data = await queryBuilder.getMany(); + + return { + data, + total, + limit: filters.limit || 50, + offset: filters.offset || 0, + }; + } + + async findById(id: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['category'], + }); + } + + async findBySku(sku: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { sku, tenantId }, + relations: ['category'], + }); + } + + async findByBarcode(barcode: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { barcode, tenantId }, + relations: ['category'], + }); + } + + async findByCategory(categoryId: string, tenantId: string): Promise { + return this.repository.find({ + where: { categoryId, tenantId, isActive: true }, + order: { name: 'ASC' }, + }); + } + + async findByIds(ids: string[], tenantId: string): Promise { + return this.repository.find({ + where: { id: In(ids), tenantId }, + relations: ['category'], + }); + } + + async create(data: CreateProductDto): Promise { + const product = this.repository.create(data); + return this.repository.save(product); + } + + async update( + id: string, + tenantId: string, + data: UpdateProductDto + ): Promise { + const product = await this.findById(id, tenantId); + if (!product) { + return null; + } + + Object.assign(product, data); + return this.repository.save(product); + } + + async delete(id: string, tenantId: string): Promise { + const result = await this.repository.softDelete({ id, tenantId }); + return result.affected ? result.affected > 0 : false; + } + + async hardDelete(id: string, tenantId: string): Promise { + const result = await this.repository.delete({ id, tenantId }); + return result.affected ? result.affected > 0 : false; + } + + async updatePrices( + id: string, + tenantId: string, + prices: { salePrice?: number; costPrice?: number; minSalePrice?: number } + ): Promise { + const product = await this.findById(id, tenantId); + if (!product) { + return null; + } + + if (prices.salePrice !== undefined) product.salePrice = prices.salePrice; + if (prices.costPrice !== undefined) product.costPrice = prices.costPrice; + if (prices.minSalePrice !== undefined) product.minSalePrice = prices.minSalePrice; + + return this.repository.save(product); + } + + async updateStock( + id: string, + tenantId: string, + stockData: { minStock?: number; maxStock?: number; reorderPoint?: number; reorderQuantity?: number } + ): Promise { + const product = await this.findById(id, tenantId); + if (!product) { + return null; + } + + Object.assign(product, stockData); + return this.repository.save(product); + } + + async toggleActive(id: string, tenantId: string): Promise { + const product = await this.findById(id, tenantId); + if (!product) { + return null; + } + + product.isActive = !product.isActive; + return this.repository.save(product); + } + + async countByCategory(categoryId: string, tenantId: string): Promise { + return this.repository.count({ + where: { categoryId, tenantId }, + }); + } + + async searchProducts( + tenantId: string, + query: string, + limit: number = 20 + ): Promise { + return this.repository.createQueryBuilder('product') + .where('product.tenantId = :tenantId', { tenantId }) + .andWhere('product.isActive = true') + .andWhere( + '(product.name ILIKE :query OR product.sku ILIKE :query OR product.barcode ILIKE :query OR product.shortName ILIKE :query)', + { query: `%${query}%` } + ) + .leftJoinAndSelect('product.category', 'category') + .orderBy('product.name', 'ASC') + .limit(limit) + .getMany(); + } + + async getLowStockProducts(tenantId: string): Promise { + return this.repository.createQueryBuilder('product') + .where('product.tenantId = :tenantId', { tenantId }) + .andWhere('product.isActive = true') + .andWhere('product.trackInventory = true') + .andWhere('product.reorderPoint IS NOT NULL') + .leftJoinAndSelect('product.category', 'category') + .orderBy('product.name', 'ASC') + .getMany(); + } + + async bulkUpdatePrices( + tenantId: string, + updates: Array<{ id: string; salePrice?: number; costPrice?: number }> + ): Promise { + let updated = 0; + + for (const update of updates) { + const result = await this.repository.update( + { id: update.id, tenantId }, + { + ...(update.salePrice !== undefined && { salePrice: update.salePrice }), + ...(update.costPrice !== undefined && { costPrice: update.costPrice }), + } + ); + if (result.affected) { + updated += result.affected; + } + } + + return updated; + } +} diff --git a/src/modules/projects/controllers/index.ts b/src/modules/projects/controllers/index.ts new file mode 100644 index 0000000..285b916 --- /dev/null +++ b/src/modules/projects/controllers/index.ts @@ -0,0 +1 @@ +export * from './projects.controller'; diff --git a/src/modules/projects/controllers/projects.controller.ts b/src/modules/projects/controllers/projects.controller.ts new file mode 100644 index 0000000..ff0ad8f --- /dev/null +++ b/src/modules/projects/controllers/projects.controller.ts @@ -0,0 +1,1130 @@ +/** + * ProjectsController - REST Controller for Projects Module + * + * Provides endpoints for managing projects, tasks, timesheets, + * milestones, and team members. + * + * @module Projects + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +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'; + +import { + ProjectService, + TaskService, + TimesheetService, + MilestoneService, + ProjectMemberService, + ProjectStageService, + ServiceContext, +} from '../services'; + +import { ProjectStatus, ProjectPrivacy } from '../entities/project.entity'; +import { TaskStatus, TaskPriority } from '../entities/task.entity'; +import { TimesheetStatus } from '../entities/timesheet.entity'; +import { MilestoneStatus } from '../entities/milestone.entity'; + +/** + * Helper to get 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, + }; +}; + +/** + * Create projects router + */ +export function createProjectsController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Services + const projectService = new ProjectService(dataSource); + const taskService = new TaskService(dataSource); + const timesheetService = new TimesheetService(dataSource); + const milestoneService = new MilestoneService(dataSource); + const memberService = new ProjectMemberService(dataSource); + const stageService = new ProjectStageService(dataSource); + + const authService = new AuthService( + userRepository, + tenantRepository, + refreshTokenRepository as any + ); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // ======================================== + // PROJECT ROUTES + // ======================================== + + /** + * GET /projects + * List all projects + */ + router.get( + '/', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters = { + companyId: req.query.companyId as string, + managerId: req.query.managerId as string, + partnerId: req.query.partnerId as string, + status: req.query.status as ProjectStatus, + search: req.query.search as string, + }; + + const result = await projectService.findAll(ctx, filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, + }); + } catch (error) { + next(error); + } + } + ); + + /** + * GET /projects/:id + * Get project by ID + */ + router.get( + '/:id', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const project = await projectService.findById(ctx, req.params.id); + + if (!project) { + res.status(404).json({ error: 'Not Found', message: 'Project not found' }); + return; + } + + res.status(200).json({ success: true, data: project }); + } catch (error) { + next(error); + } + } + ); + + /** + * GET /projects/:id/stats + * Get project statistics + */ + router.get( + '/:id/stats', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const stats = await projectService.getStats(ctx, req.params.id); + + if (!stats) { + res.status(404).json({ error: 'Not Found', message: 'Project not found' }); + return; + } + + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + } + ); + + /** + * POST /projects + * Create a new project + */ + router.post( + '/', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'manager', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + if (!req.body.companyId || !req.body.name) { + res.status(400).json({ + error: 'Bad Request', + message: 'companyId and name are required', + }); + return; + } + + const project = await projectService.create(ctx, req.body); + res.status(201).json({ success: true, data: project }); + } catch (error) { + next(error); + } + } + ); + + /** + * PATCH /projects/:id + * Update a project + */ + router.patch( + '/:id', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'manager', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const project = await projectService.update(ctx, req.params.id, req.body); + + if (!project) { + res.status(404).json({ error: 'Not Found', message: 'Project not found' }); + return; + } + + res.status(200).json({ success: true, data: project }); + } catch (error) { + next(error); + } + } + ); + + /** + * DELETE /projects/:id + * Soft delete a project + */ + router.delete( + '/:id', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const deleted = await projectService.softDelete(ctx, req.params.id); + + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Project not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Project deleted' }); + } catch (error) { + next(error); + } + } + ); + + /** + * POST /projects/:id/activate + * Activate a project + */ + router.post( + '/:id/activate', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'manager', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const project = await projectService.activate(ctx, req.params.id); + + if (!project) { + res.status(404).json({ error: 'Not Found', message: 'Project not found' }); + return; + } + + res.status(200).json({ success: true, data: project, message: 'Project activated' }); + } catch (error) { + next(error); + } + } + ); + + /** + * POST /projects/:id/complete + * Complete a project + */ + router.post( + '/:id/complete', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'manager', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const project = await projectService.complete(ctx, req.params.id); + + if (!project) { + res.status(404).json({ error: 'Not Found', message: 'Project not found' }); + return; + } + + res.status(200).json({ success: true, data: project, message: 'Project completed' }); + } catch (error) { + next(error); + } + } + ); + + // ======================================== + // TASK ROUTES + // ======================================== + + /** + * GET /projects/:projectId/tasks + * List tasks for a project + */ + router.get( + '/:projectId/tasks', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters = { + projectId: req.params.projectId, + stageId: req.query.stageId as string, + assignedTo: req.query.assignedTo as string, + status: req.query.status as TaskStatus, + priority: req.query.priority as TaskPriority, + search: req.query.search as string, + }; + + const result = await taskService.findAll(ctx, filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, + }); + } catch (error) { + next(error); + } + } + ); + + /** + * GET /tasks/:id + * Get task by ID + */ + router.get( + '/tasks/:id', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const task = await taskService.findByIdWithHours(ctx, req.params.id); + + if (!task) { + res.status(404).json({ error: 'Not Found', message: 'Task not found' }); + return; + } + + res.status(200).json({ success: true, data: task }); + } catch (error) { + next(error); + } + } + ); + + /** + * POST /tasks + * Create a new task + */ + router.post( + '/tasks', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + if (!req.body.projectId || !req.body.name) { + res.status(400).json({ + error: 'Bad Request', + message: 'projectId and name are required', + }); + return; + } + + const task = await taskService.create(ctx, req.body); + res.status(201).json({ success: true, data: task }); + } catch (error) { + next(error); + } + } + ); + + /** + * PATCH /tasks/:id + * Update a task + */ + router.patch( + '/tasks/:id', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const task = await taskService.update(ctx, req.params.id, req.body); + + if (!task) { + res.status(404).json({ error: 'Not Found', message: 'Task not found' }); + return; + } + + res.status(200).json({ success: true, data: task }); + } catch (error) { + next(error); + } + } + ); + + /** + * DELETE /tasks/:id + * Delete a task + */ + router.delete( + '/tasks/:id', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const deleted = await taskService.softDelete(ctx, req.params.id); + + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Task not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Task deleted' }); + } catch (error) { + next(error); + } + } + ); + + /** + * POST /tasks/:id/move + * Move task to a different stage + */ + router.post( + '/tasks/:id/move', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { stageId, sequence } = req.body; + + const task = await taskService.move(ctx, req.params.id, stageId, sequence || 0); + + if (!task) { + res.status(404).json({ error: 'Not Found', message: 'Task not found' }); + return; + } + + res.status(200).json({ success: true, data: task, message: 'Task moved' }); + } catch (error) { + next(error); + } + } + ); + + /** + * POST /tasks/:id/assign + * Assign task to a user + */ + router.post( + '/tasks/:id/assign', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { userId } = req.body; + + const task = await taskService.assign(ctx, req.params.id, userId); + + if (!task) { + res.status(404).json({ error: 'Not Found', message: 'Task not found' }); + return; + } + + res.status(200).json({ success: true, data: task, message: 'Task assigned' }); + } catch (error) { + next(error); + } + } + ); + + // ======================================== + // TIMESHEET ROUTES + // ======================================== + + /** + * GET /timesheets + * List all timesheets + */ + router.get( + '/timesheets', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters = { + companyId: req.query.companyId as string, + projectId: req.query.projectId as string, + taskId: req.query.taskId as string, + userId: req.query.userId as string, + status: req.query.status as TimesheetStatus, + dateFrom: req.query.dateFrom as string, + dateTo: req.query.dateTo as string, + }; + + const result = await timesheetService.findAll(ctx, filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, + }); + } catch (error) { + next(error); + } + } + ); + + /** + * GET /timesheets/my + * Get my timesheets + */ + router.get( + '/timesheets/my', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters = { + projectId: req.query.projectId as string, + dateFrom: req.query.dateFrom as string, + dateTo: req.query.dateTo as string, + }; + + const result = await timesheetService.findMyTimesheets(ctx, filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, + }); + } catch (error) { + next(error); + } + } + ); + + /** + * GET /timesheets/pending + * Get pending approvals + */ + router.get( + '/timesheets/pending', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'manager', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await timesheetService.findPendingApprovals(ctx, {}, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, + }); + } catch (error) { + next(error); + } + } + ); + + /** + * POST /timesheets + * Create a timesheet entry + */ + router.post( + '/timesheets', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + if (!req.body.companyId || !req.body.projectId || !req.body.date || !req.body.hours) { + res.status(400).json({ + error: 'Bad Request', + message: 'companyId, projectId, date and hours are required', + }); + return; + } + + const timesheet = await timesheetService.create(ctx, req.body); + res.status(201).json({ success: true, data: timesheet }); + } catch (error) { + next(error); + } + } + ); + + /** + * PATCH /timesheets/:id + * Update a timesheet + */ + router.patch( + '/timesheets/:id', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const timesheet = await timesheetService.update(ctx, req.params.id, req.body); + + if (!timesheet) { + res.status(404).json({ error: 'Not Found', message: 'Timesheet not found' }); + return; + } + + res.status(200).json({ success: true, data: timesheet }); + } catch (error) { + if (error instanceof Error) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + } + ); + + /** + * DELETE /timesheets/:id + * Delete a timesheet + */ + router.delete( + '/timesheets/:id', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const deleted = await timesheetService.delete(ctx, req.params.id); + + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Timesheet not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Timesheet deleted' }); + } catch (error) { + if (error instanceof Error) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + } + ); + + /** + * POST /timesheets/:id/submit + * Submit timesheet for approval + */ + router.post( + '/timesheets/:id/submit', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const timesheet = await timesheetService.submit(ctx, req.params.id); + + if (!timesheet) { + res.status(404).json({ error: 'Not Found', message: 'Timesheet not found' }); + return; + } + + res.status(200).json({ success: true, data: timesheet, message: 'Timesheet submitted' }); + } catch (error) { + if (error instanceof Error) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + } + ); + + /** + * POST /timesheets/:id/approve + * Approve a timesheet + */ + router.post( + '/timesheets/:id/approve', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'manager', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const timesheet = await timesheetService.approve(ctx, req.params.id); + + if (!timesheet) { + res.status(404).json({ error: 'Not Found', message: 'Timesheet not found' }); + return; + } + + res.status(200).json({ success: true, data: timesheet, message: 'Timesheet approved' }); + } catch (error) { + if (error instanceof Error) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + } + ); + + /** + * POST /timesheets/:id/reject + * Reject a timesheet + */ + router.post( + '/timesheets/:id/reject', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'manager', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const timesheet = await timesheetService.reject(ctx, req.params.id); + + if (!timesheet) { + res.status(404).json({ error: 'Not Found', message: 'Timesheet not found' }); + return; + } + + res.status(200).json({ success: true, data: timesheet, message: 'Timesheet rejected' }); + } catch (error) { + if (error instanceof Error) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + } + ); + + // ======================================== + // MILESTONE ROUTES + // ======================================== + + /** + * GET /projects/:projectId/milestones + * List milestones for a project + */ + router.get( + '/:projectId/milestones', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await milestoneService.findByProject( + ctx, + req.params.projectId, + page, + limit + ); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, + }); + } catch (error) { + next(error); + } + } + ); + + /** + * POST /milestones + * Create a milestone + */ + router.post( + '/milestones', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'manager', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + if (!req.body.projectId || !req.body.name) { + res.status(400).json({ + error: 'Bad Request', + message: 'projectId and name are required', + }); + return; + } + + const milestone = await milestoneService.create(ctx, req.body); + res.status(201).json({ success: true, data: milestone }); + } catch (error) { + next(error); + } + } + ); + + /** + * PATCH /milestones/:id + * Update a milestone + */ + router.patch( + '/milestones/:id', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'manager', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const milestone = await milestoneService.update(ctx, req.params.id, req.body); + + if (!milestone) { + res.status(404).json({ error: 'Not Found', message: 'Milestone not found' }); + return; + } + + res.status(200).json({ success: true, data: milestone }); + } catch (error) { + next(error); + } + } + ); + + /** + * DELETE /milestones/:id + * Delete a milestone + */ + router.delete( + '/milestones/:id', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const deleted = await milestoneService.delete(ctx, req.params.id); + + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Milestone not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Milestone deleted' }); + } catch (error) { + next(error); + } + } + ); + + /** + * POST /milestones/:id/complete + * Complete a milestone + */ + router.post( + '/milestones/:id/complete', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'manager', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const milestone = await milestoneService.complete(ctx, req.params.id); + + if (!milestone) { + res.status(404).json({ error: 'Not Found', message: 'Milestone not found' }); + return; + } + + res.status(200).json({ success: true, data: milestone, message: 'Milestone completed' }); + } catch (error) { + next(error); + } + } + ); + + // ======================================== + // MEMBER ROUTES + // ======================================== + + /** + * GET /projects/:projectId/members + * List members of a project + */ + router.get( + '/:projectId/members', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await memberService.findByProject(ctx, req.params.projectId, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, + }); + } catch (error) { + next(error); + } + } + ); + + /** + * POST /projects/:projectId/members + * Add member to a project + */ + router.post( + '/:projectId/members', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'manager', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + if (!req.body.userId) { + res.status(400).json({ + error: 'Bad Request', + message: 'userId is required', + }); + return; + } + + const member = await memberService.addMember(ctx, { + projectId: req.params.projectId, + userId: req.body.userId, + role: req.body.role, + }); + + res.status(201).json({ success: true, data: member }); + } catch (error) { + if (error instanceof Error) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + } + ); + + /** + * DELETE /projects/:projectId/members/:userId + * Remove member from a project + */ + router.delete( + '/:projectId/members/:userId', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'manager', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const deleted = await memberService.removeMemberByProjectAndUser( + ctx, + req.params.projectId, + req.params.userId + ); + + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Member not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Member removed' }); + } catch (error) { + next(error); + } + } + ); + + // ======================================== + // STAGE ROUTES + // ======================================== + + /** + * GET /stages + * List global stages + */ + router.get( + '/stages', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const projectId = req.query.projectId as string | undefined; + + const result = projectId + ? await stageService.findByProject(ctx, projectId) + : await stageService.findGlobalStages(ctx); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, + }); + } catch (error) { + next(error); + } + } + ); + + /** + * POST /stages + * Create a stage + */ + router.post( + '/stages', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'manager', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + + if (!req.body.name) { + res.status(400).json({ + error: 'Bad Request', + message: 'name is required', + }); + return; + } + + const stage = await stageService.create(ctx, req.body); + res.status(201).json({ success: true, data: stage }); + } catch (error) { + next(error); + } + } + ); + + /** + * PATCH /stages/:id + * Update a stage + */ + router.patch( + '/stages/:id', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'manager', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const stage = await stageService.update(ctx, req.params.id, req.body); + + if (!stage) { + res.status(404).json({ error: 'Not Found', message: 'Stage not found' }); + return; + } + + res.status(200).json({ success: true, data: stage }); + } catch (error) { + next(error); + } + } + ); + + /** + * DELETE /stages/:id + * Delete a stage + */ + router.delete( + '/stages/:id', + authMiddleware.authenticate, + authMiddleware.authorize('admin', 'director'), + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const deleted = await stageService.delete(ctx, req.params.id); + + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Stage not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Stage deleted' }); + } catch (error) { + next(error); + } + } + ); + + return router; +} + +export default createProjectsController; diff --git a/src/modules/projects/entities/index.ts b/src/modules/projects/entities/index.ts index dbc3634..b20b014 100644 --- a/src/modules/projects/entities/index.ts +++ b/src/modules/projects/entities/index.ts @@ -1 +1,6 @@ +export * from './project.entity'; +export * from './task.entity'; +export * from './milestone.entity'; +export * from './project-member.entity'; +export * from './project-stage.entity'; export * from './timesheet.entity'; diff --git a/src/modules/projects/entities/milestone.entity.ts b/src/modules/projects/entities/milestone.entity.ts new file mode 100644 index 0000000..92cc3cb --- /dev/null +++ b/src/modules/projects/entities/milestone.entity.ts @@ -0,0 +1,68 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ProjectEntity } from './project.entity'; + +export enum MilestoneStatus { + PENDING = 'pending', + COMPLETED = 'completed', + CANCELLED = 'cancelled', +} + +@Entity({ schema: 'projects', name: 'milestones' }) +@Index('idx_milestones_tenant', ['tenantId']) +@Index('idx_milestones_project', ['projectId']) +@Index('idx_milestones_status', ['status']) +@Index('idx_milestones_deadline', ['dateDeadline']) +export class MilestoneEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'project_id' }) + projectId: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'date', nullable: true, name: 'date_deadline' }) + dateDeadline: Date | null; + + @Column({ + type: 'enum', + enum: MilestoneStatus, + default: MilestoneStatus.PENDING, + nullable: false, + }) + status: MilestoneStatus; + + // Relations + @ManyToOne(() => ProjectEntity, (project) => project.milestones, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'project_id' }) + project: ProjectEntity; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; +} diff --git a/src/modules/projects/entities/project-member.entity.ts b/src/modules/projects/entities/project-member.entity.ts new file mode 100644 index 0000000..2bfb568 --- /dev/null +++ b/src/modules/projects/entities/project-member.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { ProjectEntity } from './project.entity'; + +export enum ProjectMemberRole { + MEMBER = 'member', + CONTRIBUTOR = 'contributor', + VIEWER = 'viewer', +} + +@Entity({ schema: 'projects', name: 'project_members' }) +@Index('idx_project_members_tenant', ['tenantId']) +@Index('idx_project_members_project', ['projectId']) +@Index('idx_project_members_user', ['userId']) +@Unique(['projectId', 'userId']) +export class ProjectMemberEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'project_id' }) + projectId: string; + + @Column({ type: 'uuid', nullable: false, name: 'user_id' }) + userId: string; + + @Column({ type: 'varchar', length: 50, default: 'member', nullable: false }) + role: string; + + // Relations + @ManyToOne(() => ProjectEntity, (project) => project.members, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'project_id' }) + project: ProjectEntity; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} diff --git a/src/modules/projects/entities/project-stage.entity.ts b/src/modules/projects/entities/project-stage.entity.ts new file mode 100644 index 0000000..ff6d316 --- /dev/null +++ b/src/modules/projects/entities/project-stage.entity.ts @@ -0,0 +1,50 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ProjectEntity } from './project.entity'; + +@Entity({ schema: 'projects', name: 'project_stages' }) +@Index('idx_project_stages_tenant', ['tenantId']) +@Index('idx_project_stages_project', ['projectId']) +@Index('idx_project_stages_sequence', ['sequence']) +export class ProjectStageEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: true, name: 'project_id' }) + projectId: string | null; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ type: 'int', default: 0, nullable: false }) + sequence: number; + + @Column({ type: 'boolean', default: false, nullable: false }) + fold: boolean; + + @Column({ type: 'boolean', default: false, nullable: false, name: 'is_closed' }) + isClosed: boolean; + + // Relations + @ManyToOne(() => ProjectEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'project_id' }) + project: ProjectEntity | null; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true }) + updatedAt: Date | null; +} diff --git a/src/modules/projects/entities/project.entity.ts b/src/modules/projects/entities/project.entity.ts new file mode 100644 index 0000000..64841f4 --- /dev/null +++ b/src/modules/projects/entities/project.entity.ts @@ -0,0 +1,123 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { TaskEntity } from './task.entity'; +import { MilestoneEntity } from './milestone.entity'; +import { ProjectMemberEntity } from './project-member.entity'; +import { TimesheetEntity } from './timesheet.entity'; + +export enum ProjectStatus { + DRAFT = 'draft', + ACTIVE = 'active', + COMPLETED = 'completed', + CANCELLED = 'cancelled', + ON_HOLD = 'on_hold', +} + +export enum ProjectPrivacy { + PUBLIC = 'public', + PRIVATE = 'private', + FOLLOWERS = 'followers', +} + +@Entity({ schema: 'projects', name: 'projects' }) +@Index('idx_projects_tenant', ['tenantId']) +@Index('idx_projects_company', ['companyId']) +@Index('idx_projects_manager', ['managerId']) +@Index('idx_projects_partner', ['partnerId']) +@Index('idx_projects_status', ['status']) +@Index('idx_projects_code', ['code']) +export class ProjectEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'company_id' }) + companyId: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + code: string | null; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'manager_id' }) + managerId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'partner_id' }) + partnerId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'analytic_account_id' }) + analyticAccountId: string | null; + + @Column({ type: 'date', nullable: true, name: 'date_start' }) + dateStart: Date | null; + + @Column({ type: 'date', nullable: true, name: 'date_end' }) + dateEnd: Date | null; + + @Column({ + type: 'enum', + enum: ProjectStatus, + default: ProjectStatus.DRAFT, + nullable: false, + }) + status: ProjectStatus; + + @Column({ + type: 'enum', + enum: ProjectPrivacy, + default: ProjectPrivacy.PUBLIC, + nullable: false, + }) + privacy: ProjectPrivacy; + + @Column({ type: 'boolean', default: true, nullable: false, name: 'allow_timesheets' }) + allowTimesheets: boolean; + + @Column({ type: 'varchar', length: 20, nullable: true }) + color: string | null; + + // Relations + @OneToMany(() => TaskEntity, (task) => task.project) + tasks: TaskEntity[]; + + @OneToMany(() => MilestoneEntity, (milestone) => milestone.project) + milestones: MilestoneEntity[]; + + @OneToMany(() => ProjectMemberEntity, (member) => member.project) + members: ProjectMemberEntity[]; + + @OneToMany(() => TimesheetEntity, (timesheet) => timesheet.project) + timesheets: TimesheetEntity[]; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + deletedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) + deletedBy: string | null; +} diff --git a/src/modules/projects/entities/task.entity.ts b/src/modules/projects/entities/task.entity.ts new file mode 100644 index 0000000..8fdd82a --- /dev/null +++ b/src/modules/projects/entities/task.entity.ts @@ -0,0 +1,125 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { ProjectEntity } from './project.entity'; +import { TimesheetEntity } from './timesheet.entity'; + +export enum TaskStatus { + TODO = 'todo', + IN_PROGRESS = 'in_progress', + REVIEW = 'review', + DONE = 'done', + CANCELLED = 'cancelled', +} + +export enum TaskPriority { + LOW = 'low', + NORMAL = 'normal', + HIGH = 'high', + URGENT = 'urgent', +} + +@Entity({ schema: 'projects', name: 'tasks' }) +@Index('idx_tasks_tenant', ['tenantId']) +@Index('idx_tasks_project', ['projectId']) +@Index('idx_tasks_stage', ['stageId']) +@Index('idx_tasks_parent', ['parentId']) +@Index('idx_tasks_assigned', ['assignedTo']) +@Index('idx_tasks_status', ['status']) +@Index('idx_tasks_priority', ['priority']) +@Index('idx_tasks_deadline', ['dateDeadline']) +export class TaskEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'project_id' }) + projectId: string; + + @Column({ type: 'uuid', nullable: true, name: 'stage_id' }) + stageId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'parent_id' }) + parentId: string | null; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'assigned_to' }) + assignedTo: string | null; + + @Column({ type: 'date', nullable: true, name: 'date_deadline' }) + dateDeadline: Date | null; + + @Column({ type: 'decimal', precision: 10, scale: 2, default: 0, name: 'estimated_hours' }) + estimatedHours: number; + + @Column({ + type: 'enum', + enum: TaskPriority, + default: TaskPriority.NORMAL, + nullable: false, + }) + priority: TaskPriority; + + @Column({ + type: 'enum', + enum: TaskStatus, + default: TaskStatus.TODO, + nullable: false, + }) + status: TaskStatus; + + @Column({ type: 'int', default: 0, nullable: false }) + sequence: number; + + @Column({ type: 'varchar', length: 20, nullable: true }) + color: string | null; + + // Relations + @ManyToOne(() => ProjectEntity, (project) => project.tasks, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'project_id' }) + project: ProjectEntity; + + @ManyToOne(() => TaskEntity, (task) => task.children, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'parent_id' }) + parent: TaskEntity | null; + + @OneToMany(() => TaskEntity, (task) => task.parent) + children: TaskEntity[]; + + @OneToMany(() => TimesheetEntity, (timesheet) => timesheet.task) + timesheets: TimesheetEntity[]; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + deletedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) + deletedBy: string | null; +} diff --git a/src/modules/projects/entities/timesheet.entity.ts b/src/modules/projects/entities/timesheet.entity.ts index 3bdb6b3..851e98d 100644 --- a/src/modules/projects/entities/timesheet.entity.ts +++ b/src/modules/projects/entities/timesheet.entity.ts @@ -5,7 +5,11 @@ import { CreateDateColumn, UpdateDateColumn, Index, + ManyToOne, + JoinColumn, } from 'typeorm'; +import { ProjectEntity } from './project.entity'; +import { TaskEntity } from './task.entity'; export enum TimesheetStatus { DRAFT = 'draft', @@ -90,4 +94,13 @@ export class TimesheetEntity { @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) updatedBy: string | null; + + // Relations + @ManyToOne(() => ProjectEntity, (project) => project.timesheets, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'project_id' }) + project: ProjectEntity; + + @ManyToOne(() => TaskEntity, (task) => task.timesheets, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'task_id' }) + task: TaskEntity | null; } diff --git a/src/modules/projects/index.ts b/src/modules/projects/index.ts new file mode 100644 index 0000000..9577684 --- /dev/null +++ b/src/modules/projects/index.ts @@ -0,0 +1,17 @@ +/** + * Projects Module + * + * Generic project management module for ERP Construccion. + * Provides project, task, timesheet, milestone, and team member management. + * + * @module Projects + */ + +// Entities +export * from './entities'; + +// Services +export * from './services'; + +// Controllers +export * from './controllers'; diff --git a/src/modules/projects/services/index.ts b/src/modules/projects/services/index.ts new file mode 100644 index 0000000..251562b --- /dev/null +++ b/src/modules/projects/services/index.ts @@ -0,0 +1,6 @@ +export * from './project.service'; +export * from './task.service'; +export * from './timesheet.service'; +export * from './milestone.service'; +export * from './project-member.service'; +export * from './project-stage.service'; diff --git a/src/modules/projects/services/milestone.service.ts b/src/modules/projects/services/milestone.service.ts new file mode 100644 index 0000000..2dec7c3 --- /dev/null +++ b/src/modules/projects/services/milestone.service.ts @@ -0,0 +1,266 @@ +/** + * MilestoneService - Project Milestone Management Service + * + * Provides CRUD operations for project milestones. + * Supports multi-tenant architecture with ServiceContext pattern. + * + * @module Projects + */ + +import { DataSource, Repository } from 'typeorm'; +import { MilestoneEntity, MilestoneStatus } from '../entities/milestone.entity'; +import { ServiceContext, PaginatedResult } from './project.service'; + +export interface CreateMilestoneDto { + projectId: string; + name: string; + description?: string; + dateDeadline?: string; +} + +export interface UpdateMilestoneDto { + name?: string; + description?: string | null; + dateDeadline?: string | null; + status?: MilestoneStatus; +} + +export interface MilestoneFilters { + projectId?: string; + status?: MilestoneStatus; + search?: string; +} + +export class MilestoneService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(MilestoneEntity); + } + + /** + * Find milestone by ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + }, + }); + } + + /** + * Find all milestones with pagination and filters + */ + async findAll( + ctx: ServiceContext, + filters: MilestoneFilters = {}, + page = 1, + limit = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const qb = this.repository + .createQueryBuilder('m') + .where('m.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.projectId) { + qb.andWhere('m.project_id = :projectId', { projectId: filters.projectId }); + } + if (filters.status) { + qb.andWhere('m.status = :status', { status: filters.status }); + } + if (filters.search) { + qb.andWhere('m.name ILIKE :search', { search: `%${filters.search}%` }); + } + + qb.orderBy('m.date_deadline', 'ASC').addOrderBy('m.created_at', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Create a new milestone + */ + async create(ctx: ServiceContext, dto: CreateMilestoneDto): Promise { + const entity = this.repository.create({ + tenantId: ctx.tenantId, + projectId: dto.projectId, + name: dto.name, + description: dto.description || null, + dateDeadline: dto.dateDeadline ? new Date(dto.dateDeadline) : null, + status: MilestoneStatus.PENDING, + createdBy: ctx.userId || null, + }); + + return this.repository.save(entity); + } + + /** + * Update a milestone + */ + async update( + ctx: ServiceContext, + id: string, + dto: UpdateMilestoneDto + ): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return null; + } + + if (dto.name !== undefined) entity.name = dto.name; + if (dto.description !== undefined) entity.description = dto.description; + if (dto.dateDeadline !== undefined) { + entity.dateDeadline = dto.dateDeadline ? new Date(dto.dateDeadline) : null; + } + if (dto.status !== undefined) entity.status = dto.status; + + entity.updatedBy = ctx.userId || null; + + return this.repository.save(entity); + } + + /** + * Delete a milestone + */ + async delete(ctx: ServiceContext, id: string): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return false; + } + + await this.repository.delete(id); + return true; + } + + /** + * Complete a milestone + */ + async complete(ctx: ServiceContext, id: string): Promise { + return this.update(ctx, id, { status: MilestoneStatus.COMPLETED }); + } + + /** + * Cancel a milestone + */ + async cancel(ctx: ServiceContext, id: string): Promise { + return this.update(ctx, id, { status: MilestoneStatus.CANCELLED }); + } + + /** + * Reset milestone to pending + */ + async reset(ctx: ServiceContext, id: string): Promise { + return this.update(ctx, id, { status: MilestoneStatus.PENDING }); + } + + /** + * Find milestones by project + */ + async findByProject( + ctx: ServiceContext, + projectId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { projectId }, page, limit); + } + + /** + * Find pending milestones + */ + async findPending( + ctx: ServiceContext, + projectId?: string, + page = 1, + limit = 20 + ): Promise> { + const filters: MilestoneFilters = { status: MilestoneStatus.PENDING }; + if (projectId) { + filters.projectId = projectId; + } + return this.findAll(ctx, filters, page, limit); + } + + /** + * Find overdue milestones + */ + async findOverdue( + ctx: ServiceContext, + projectId?: string, + page = 1, + limit = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const qb = this.repository + .createQueryBuilder('m') + .where('m.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('m.date_deadline < :today', { today: new Date() }) + .andWhere('m.status = :status', { status: MilestoneStatus.PENDING }); + + if (projectId) { + qb.andWhere('m.project_id = :projectId', { projectId }); + } + + qb.orderBy('m.date_deadline', 'ASC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Find upcoming milestones (within next N days) + */ + async findUpcoming( + ctx: ServiceContext, + days = 7, + projectId?: string, + page = 1, + limit = 20 + ): Promise> { + const skip = (page - 1) * limit; + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + days); + + const qb = this.repository + .createQueryBuilder('m') + .where('m.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('m.date_deadline >= :today', { today: new Date() }) + .andWhere('m.date_deadline <= :futureDate', { futureDate }) + .andWhere('m.status = :status', { status: MilestoneStatus.PENDING }); + + if (projectId) { + qb.andWhere('m.project_id = :projectId', { projectId }); + } + + qb.orderBy('m.date_deadline', 'ASC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } +} diff --git a/src/modules/projects/services/project-member.service.ts b/src/modules/projects/services/project-member.service.ts new file mode 100644 index 0000000..1cb02c8 --- /dev/null +++ b/src/modules/projects/services/project-member.service.ts @@ -0,0 +1,263 @@ +/** + * ProjectMemberService - Project Team Member Management Service + * + * Provides team member management for projects. + * Supports multi-tenant architecture with ServiceContext pattern. + * + * @module Projects + */ + +import { DataSource, Repository } from 'typeorm'; +import { ProjectMemberEntity, ProjectMemberRole } from '../entities/project-member.entity'; +import { ServiceContext, PaginatedResult } from './project.service'; + +export interface AddMemberDto { + projectId: string; + userId: string; + role?: string; +} + +export interface UpdateMemberRoleDto { + role: string; +} + +export interface ProjectMemberFilters { + projectId?: string; + userId?: string; + role?: string; +} + +export class ProjectMemberService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(ProjectMemberEntity); + } + + /** + * Find member by ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + }, + }); + } + + /** + * Find member by project and user + */ + async findByProjectAndUser( + ctx: ServiceContext, + projectId: string, + userId: string + ): Promise { + return this.repository.findOne({ + where: { + projectId, + userId, + tenantId: ctx.tenantId, + }, + }); + } + + /** + * Find all members with pagination and filters + */ + async findAll( + ctx: ServiceContext, + filters: ProjectMemberFilters = {}, + page = 1, + limit = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const qb = this.repository + .createQueryBuilder('pm') + .where('pm.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.projectId) { + qb.andWhere('pm.project_id = :projectId', { projectId: filters.projectId }); + } + if (filters.userId) { + qb.andWhere('pm.user_id = :userId', { userId: filters.userId }); + } + if (filters.role) { + qb.andWhere('pm.role = :role', { role: filters.role }); + } + + qb.orderBy('pm.created_at', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Add a member to a project + */ + async addMember(ctx: ServiceContext, dto: AddMemberDto): Promise { + // Check if already a member + const existing = await this.findByProjectAndUser(ctx, dto.projectId, dto.userId); + if (existing) { + throw new Error('User is already a member of this project'); + } + + const entity = this.repository.create({ + tenantId: ctx.tenantId, + projectId: dto.projectId, + userId: dto.userId, + role: dto.role || ProjectMemberRole.MEMBER, + }); + + return this.repository.save(entity); + } + + /** + * Update member role + */ + async updateRole( + ctx: ServiceContext, + id: string, + dto: UpdateMemberRoleDto + ): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return null; + } + + entity.role = dto.role; + + return this.repository.save(entity); + } + + /** + * Remove a member from a project + */ + async removeMember(ctx: ServiceContext, id: string): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return false; + } + + await this.repository.delete(id); + return true; + } + + /** + * Remove a member by project and user + */ + async removeMemberByProjectAndUser( + ctx: ServiceContext, + projectId: string, + userId: string + ): Promise { + const entity = await this.findByProjectAndUser(ctx, projectId, userId); + if (!entity) { + return false; + } + + await this.repository.delete(entity.id); + return true; + } + + /** + * Find members by project + */ + async findByProject( + ctx: ServiceContext, + projectId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { projectId }, page, limit); + } + + /** + * Find projects where user is a member + */ + async findProjectsByUser( + ctx: ServiceContext, + userId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { userId }, page, limit); + } + + /** + * Check if user is a member of a project + */ + async isMember(ctx: ServiceContext, projectId: string, userId: string): Promise { + const member = await this.findByProjectAndUser(ctx, projectId, userId); + return member !== null; + } + + /** + * Get user role in a project + */ + async getUserRole( + ctx: ServiceContext, + projectId: string, + userId: string + ): Promise { + const member = await this.findByProjectAndUser(ctx, projectId, userId); + return member?.role || null; + } + + /** + * Count members in a project + */ + async countMembers(ctx: ServiceContext, projectId: string): Promise { + return this.repository.count({ + where: { projectId, tenantId: ctx.tenantId }, + }); + } + + /** + * Add multiple members to a project + */ + async addMembers( + ctx: ServiceContext, + projectId: string, + userIds: string[], + role?: string + ): Promise { + const added: ProjectMemberEntity[] = []; + + for (const userId of userIds) { + try { + const member = await this.addMember(ctx, { + projectId, + userId, + role: role || ProjectMemberRole.MEMBER, + }); + added.push(member); + } catch { + // Skip if already a member + } + } + + return added; + } + + /** + * Remove all members from a project + */ + async removeAllMembers(ctx: ServiceContext, projectId: string): Promise { + const result = await this.repository.delete({ + projectId, + tenantId: ctx.tenantId, + }); + + return result.affected || 0; + } +} diff --git a/src/modules/projects/services/project-stage.service.ts b/src/modules/projects/services/project-stage.service.ts new file mode 100644 index 0000000..031661b --- /dev/null +++ b/src/modules/projects/services/project-stage.service.ts @@ -0,0 +1,257 @@ +/** + * ProjectStageService - Kanban Stage Management Service + * + * Provides stage/column management for project task boards. + * Supports multi-tenant architecture with ServiceContext pattern. + * + * @module Projects + */ + +import { DataSource, Repository, IsNull } from 'typeorm'; +import { ProjectStageEntity } from '../entities/project-stage.entity'; +import { ServiceContext, PaginatedResult } from './project.service'; + +export interface CreateStageDto { + projectId?: string; + name: string; + sequence?: number; + fold?: boolean; + isClosed?: boolean; +} + +export interface UpdateStageDto { + name?: string; + sequence?: number; + fold?: boolean; + isClosed?: boolean; +} + +export interface StageFilters { + projectId?: string | null; + isClosed?: boolean; +} + +export class ProjectStageService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(ProjectStageEntity); + } + + /** + * Find stage by ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + }, + }); + } + + /** + * Find all stages with pagination and filters + */ + async findAll( + ctx: ServiceContext, + filters: StageFilters = {}, + page = 1, + limit = 50 + ): Promise> { + const skip = (page - 1) * limit; + + const qb = this.repository + .createQueryBuilder('s') + .where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.projectId !== undefined) { + if (filters.projectId === null) { + qb.andWhere('s.project_id IS NULL'); + } else { + qb.andWhere('(s.project_id = :projectId OR s.project_id IS NULL)', { + projectId: filters.projectId, + }); + } + } + + if (filters.isClosed !== undefined) { + qb.andWhere('s.is_closed = :isClosed', { isClosed: filters.isClosed }); + } + + qb.orderBy('s.sequence', 'ASC').addOrderBy('s.created_at', 'ASC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Create a new stage + */ + async create(ctx: ServiceContext, dto: CreateStageDto): Promise { + // Get next sequence + let sequence = dto.sequence; + if (sequence === undefined) { + const maxSeq = await this.repository + .createQueryBuilder('s') + .select('COALESCE(MAX(s.sequence), 0) + 1', 'nextSeq') + .where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere( + dto.projectId ? 's.project_id = :projectId' : 's.project_id IS NULL', + dto.projectId ? { projectId: dto.projectId } : {} + ) + .getRawOne(); + sequence = parseInt(maxSeq?.nextSeq || '1'); + } + + const entity = this.repository.create({ + tenantId: ctx.tenantId, + projectId: dto.projectId || null, + name: dto.name, + sequence, + fold: dto.fold ?? false, + isClosed: dto.isClosed ?? false, + }); + + return this.repository.save(entity); + } + + /** + * Update a stage + */ + async update( + ctx: ServiceContext, + id: string, + dto: UpdateStageDto + ): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return null; + } + + if (dto.name !== undefined) entity.name = dto.name; + if (dto.sequence !== undefined) entity.sequence = dto.sequence; + if (dto.fold !== undefined) entity.fold = dto.fold; + if (dto.isClosed !== undefined) entity.isClosed = dto.isClosed; + + return this.repository.save(entity); + } + + /** + * Delete a stage + */ + async delete(ctx: ServiceContext, id: string): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return false; + } + + await this.repository.delete(id); + return true; + } + + /** + * Find global stages (not project-specific) + */ + async findGlobalStages( + ctx: ServiceContext, + page = 1, + limit = 50 + ): Promise> { + return this.findAll(ctx, { projectId: null }, page, limit); + } + + /** + * Find stages for a project (includes global + project-specific) + */ + async findByProject( + ctx: ServiceContext, + projectId: string, + page = 1, + limit = 50 + ): Promise> { + return this.findAll(ctx, { projectId }, page, limit); + } + + /** + * Reorder stages + */ + async reorder( + ctx: ServiceContext, + stageIds: string[] + ): Promise { + const updated: ProjectStageEntity[] = []; + + for (let i = 0; i < stageIds.length; i++) { + const stage = await this.update(ctx, stageIds[i], { sequence: i + 1 }); + if (stage) { + updated.push(stage); + } + } + + return updated; + } + + /** + * Create default stages for a project + */ + async createDefaultStages( + ctx: ServiceContext, + projectId?: string + ): Promise { + const defaultStages = [ + { name: 'Backlog', sequence: 1, fold: true, isClosed: false }, + { name: 'To Do', sequence: 2, fold: false, isClosed: false }, + { name: 'In Progress', sequence: 3, fold: false, isClosed: false }, + { name: 'Review', sequence: 4, fold: false, isClosed: false }, + { name: 'Done', sequence: 5, fold: false, isClosed: true }, + ]; + + const stages: ProjectStageEntity[] = []; + for (const stage of defaultStages) { + const created = await this.create(ctx, { + projectId, + ...stage, + }); + stages.push(created); + } + + return stages; + } + + /** + * Toggle fold state + */ + async toggleFold(ctx: ServiceContext, id: string): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return null; + } + + return this.update(ctx, id, { fold: !entity.fold }); + } + + /** + * Count stages for a project + */ + async countStages(ctx: ServiceContext, projectId?: string): Promise { + const qb = this.repository + .createQueryBuilder('s') + .where('s.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (projectId) { + qb.andWhere('(s.project_id = :projectId OR s.project_id IS NULL)', { projectId }); + } else { + qb.andWhere('s.project_id IS NULL'); + } + + return qb.getCount(); + } +} diff --git a/src/modules/projects/services/project.service.ts b/src/modules/projects/services/project.service.ts new file mode 100644 index 0000000..3de5d13 --- /dev/null +++ b/src/modules/projects/services/project.service.ts @@ -0,0 +1,381 @@ +/** + * ProjectService - Generic Project Management Service + * + * Provides CRUD operations and lifecycle management for projects. + * Supports multi-tenant architecture with ServiceContext pattern. + * + * @module Projects + */ + +import { DataSource, Repository, IsNull } from 'typeorm'; +import { ProjectEntity, ProjectStatus, ProjectPrivacy } from '../entities/project.entity'; +import { TaskEntity } from '../entities/task.entity'; +import { TimesheetEntity } from '../entities/timesheet.entity'; +import { MilestoneEntity } from '../entities/milestone.entity'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface CreateProjectDto { + companyId: string; + name: string; + code?: string; + description?: string; + managerId?: string; + partnerId?: string; + analyticAccountId?: string; + dateStart?: string; + dateEnd?: string; + privacy?: ProjectPrivacy; + allowTimesheets?: boolean; + color?: string; +} + +export interface UpdateProjectDto { + name?: string; + code?: string | null; + description?: string | null; + managerId?: string | null; + partnerId?: string | null; + analyticAccountId?: string | null; + dateStart?: string | null; + dateEnd?: string | null; + status?: ProjectStatus; + privacy?: ProjectPrivacy; + allowTimesheets?: boolean; + color?: string | null; +} + +export interface ProjectFilters { + companyId?: string; + managerId?: string; + partnerId?: string; + status?: ProjectStatus; + search?: string; +} + +export interface ProjectStats { + totalTasks: number; + completedTasks: number; + inProgressTasks: number; + completionPercentage: number; + totalHours: number; + totalMilestones: number; + completedMilestones: number; +} + +export class ProjectService { + private repository: Repository; + private taskRepository: Repository; + private timesheetRepository: Repository; + private milestoneRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(ProjectEntity); + this.taskRepository = dataSource.getRepository(TaskEntity); + this.timesheetRepository = dataSource.getRepository(TimesheetEntity); + this.milestoneRepository = dataSource.getRepository(MilestoneEntity); + } + + /** + * Find project by ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + } + + /** + * Find all projects with pagination and filters + */ + async findAll( + ctx: ServiceContext, + filters: ProjectFilters = {}, + page = 1, + limit = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const qb = this.repository + .createQueryBuilder('p') + .where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('p.deleted_at IS NULL'); + + if (filters.companyId) { + qb.andWhere('p.company_id = :companyId', { companyId: filters.companyId }); + } + if (filters.managerId) { + qb.andWhere('p.manager_id = :managerId', { managerId: filters.managerId }); + } + if (filters.partnerId) { + qb.andWhere('p.partner_id = :partnerId', { partnerId: filters.partnerId }); + } + if (filters.status) { + qb.andWhere('p.status = :status', { status: filters.status }); + } + if (filters.search) { + qb.andWhere('(p.name ILIKE :search OR p.code ILIKE :search)', { + search: `%${filters.search}%`, + }); + } + + qb.orderBy('p.created_at', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Create a new project + */ + async create(ctx: ServiceContext, dto: CreateProjectDto): Promise { + const entity = this.repository.create({ + tenantId: ctx.tenantId, + companyId: dto.companyId, + name: dto.name, + code: dto.code || null, + description: dto.description || null, + managerId: dto.managerId || null, + partnerId: dto.partnerId || null, + analyticAccountId: dto.analyticAccountId || null, + dateStart: dto.dateStart ? new Date(dto.dateStart) : null, + dateEnd: dto.dateEnd ? new Date(dto.dateEnd) : null, + privacy: dto.privacy || ProjectPrivacy.PUBLIC, + allowTimesheets: dto.allowTimesheets ?? true, + color: dto.color || null, + status: ProjectStatus.DRAFT, + createdBy: ctx.userId || null, + }); + + return this.repository.save(entity); + } + + /** + * Update a project + */ + async update( + ctx: ServiceContext, + id: string, + dto: UpdateProjectDto + ): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return null; + } + + if (dto.name !== undefined) entity.name = dto.name; + if (dto.code !== undefined) entity.code = dto.code; + if (dto.description !== undefined) entity.description = dto.description; + if (dto.managerId !== undefined) entity.managerId = dto.managerId; + if (dto.partnerId !== undefined) entity.partnerId = dto.partnerId; + if (dto.analyticAccountId !== undefined) entity.analyticAccountId = dto.analyticAccountId; + if (dto.dateStart !== undefined) { + entity.dateStart = dto.dateStart ? new Date(dto.dateStart) : null; + } + if (dto.dateEnd !== undefined) { + entity.dateEnd = dto.dateEnd ? new Date(dto.dateEnd) : null; + } + if (dto.status !== undefined) entity.status = dto.status; + if (dto.privacy !== undefined) entity.privacy = dto.privacy; + if (dto.allowTimesheets !== undefined) entity.allowTimesheets = dto.allowTimesheets; + if (dto.color !== undefined) entity.color = dto.color; + + entity.updatedBy = ctx.userId || null; + + return this.repository.save(entity); + } + + /** + * Soft delete a project + */ + async softDelete(ctx: ServiceContext, id: string): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return false; + } + + entity.deletedAt = new Date(); + entity.deletedBy = ctx.userId || null; + await this.repository.save(entity); + return true; + } + + /** + * Get project with relations (tasks, milestones, members) + */ + async findWithRelations(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + relations: ['tasks', 'milestones', 'members'], + }); + } + + /** + * Get project statistics + */ + async getStats(ctx: ServiceContext, id: string): Promise { + const project = await this.findById(ctx, id); + if (!project) { + return null; + } + + // Get task counts + const totalTasks = await this.taskRepository.count({ + where: { projectId: id, tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + + const completedTasks = await this.taskRepository.count({ + where: { projectId: id, tenantId: ctx.tenantId, deletedAt: IsNull(), status: 'done' as any }, + }); + + const inProgressTasks = await this.taskRepository.count({ + where: { projectId: id, tenantId: ctx.tenantId, deletedAt: IsNull(), status: 'in_progress' as any }, + }); + + // Get total hours from timesheets + const hoursResult = await this.timesheetRepository + .createQueryBuilder('t') + .select('SUM(t.hours)', 'total') + .where('t.project_id = :projectId', { projectId: id }) + .andWhere('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .getRawOne(); + + const totalHours = parseFloat(hoursResult?.total || '0'); + + // Get milestone counts + const totalMilestones = await this.milestoneRepository.count({ + where: { projectId: id, tenantId: ctx.tenantId }, + }); + + const completedMilestones = await this.milestoneRepository.count({ + where: { projectId: id, tenantId: ctx.tenantId, status: 'completed' as any }, + }); + + return { + totalTasks, + completedTasks, + inProgressTasks, + completionPercentage: totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0, + totalHours, + totalMilestones, + completedMilestones, + }; + } + + /** + * Activate a project (change status to active) + */ + async activate(ctx: ServiceContext, id: string): Promise { + return this.update(ctx, id, { status: ProjectStatus.ACTIVE }); + } + + /** + * Complete a project + */ + async complete(ctx: ServiceContext, id: string): Promise { + return this.update(ctx, id, { status: ProjectStatus.COMPLETED }); + } + + /** + * Cancel a project + */ + async cancel(ctx: ServiceContext, id: string): Promise { + return this.update(ctx, id, { status: ProjectStatus.CANCELLED }); + } + + /** + * Put project on hold + */ + async hold(ctx: ServiceContext, id: string): Promise { + return this.update(ctx, id, { status: ProjectStatus.ON_HOLD }); + } + + /** + * Find projects by company + */ + async findByCompany( + ctx: ServiceContext, + companyId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { companyId }, page, limit); + } + + /** + * Find projects by manager + */ + async findByManager( + ctx: ServiceContext, + managerId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { managerId }, page, limit); + } + + /** + * Find active projects + */ + async findActive( + ctx: ServiceContext, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { status: ProjectStatus.ACTIVE }, page, limit); + } + + /** + * Duplicate a project (without tasks/milestones) + */ + async duplicate( + ctx: ServiceContext, + id: string, + newName: string, + newCode?: string + ): Promise { + const original = await this.findById(ctx, id); + if (!original) { + return null; + } + + return this.create(ctx, { + companyId: original.companyId, + name: newName, + code: newCode, + description: original.description || undefined, + managerId: original.managerId || undefined, + partnerId: original.partnerId || undefined, + analyticAccountId: original.analyticAccountId || undefined, + privacy: original.privacy, + allowTimesheets: original.allowTimesheets, + color: original.color || undefined, + }); + } +} diff --git a/src/modules/projects/services/task.service.ts b/src/modules/projects/services/task.service.ts new file mode 100644 index 0000000..f585c78 --- /dev/null +++ b/src/modules/projects/services/task.service.ts @@ -0,0 +1,402 @@ +/** + * TaskService - Task Management Service + * + * Provides CRUD operations and task management for projects. + * Supports multi-tenant architecture with ServiceContext pattern. + * + * @module Projects + */ + +import { DataSource, Repository, IsNull } from 'typeorm'; +import { TaskEntity, TaskStatus, TaskPriority } from '../entities/task.entity'; +import { TimesheetEntity } from '../entities/timesheet.entity'; +import { ServiceContext, PaginatedResult } from './project.service'; + +export interface CreateTaskDto { + projectId: string; + stageId?: string; + parentId?: string; + name: string; + description?: string; + assignedTo?: string; + dateDeadline?: string; + estimatedHours?: number; + priority?: TaskPriority; + color?: string; +} + +export interface UpdateTaskDto { + stageId?: string | null; + parentId?: string | null; + name?: string; + description?: string | null; + assignedTo?: string | null; + dateDeadline?: string | null; + estimatedHours?: number | null; + priority?: TaskPriority; + status?: TaskStatus; + sequence?: number; + color?: string | null; +} + +export interface TaskFilters { + projectId?: string; + stageId?: string; + parentId?: string; + assignedTo?: string; + status?: TaskStatus; + priority?: TaskPriority; + search?: string; +} + +export interface TaskWithHours extends TaskEntity { + spentHours?: number; +} + +export class TaskService { + private repository: Repository; + private timesheetRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(TaskEntity); + this.timesheetRepository = dataSource.getRepository(TimesheetEntity); + } + + /** + * Find task by ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + } + + /** + * Find task by ID with spent hours + */ + async findByIdWithHours(ctx: ServiceContext, id: string): Promise { + const task = await this.findById(ctx, id); + if (!task) { + return null; + } + + const hoursResult = await this.timesheetRepository + .createQueryBuilder('t') + .select('SUM(t.hours)', 'total') + .where('t.task_id = :taskId', { taskId: id }) + .andWhere('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .getRawOne(); + + return { + ...task, + spentHours: parseFloat(hoursResult?.total || '0'), + }; + } + + /** + * Find all tasks with pagination and filters + */ + async findAll( + ctx: ServiceContext, + filters: TaskFilters = {}, + page = 1, + limit = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const qb = this.repository + .createQueryBuilder('t') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL'); + + if (filters.projectId) { + qb.andWhere('t.project_id = :projectId', { projectId: filters.projectId }); + } + if (filters.stageId) { + qb.andWhere('t.stage_id = :stageId', { stageId: filters.stageId }); + } + if (filters.parentId) { + qb.andWhere('t.parent_id = :parentId', { parentId: filters.parentId }); + } + if (filters.assignedTo) { + qb.andWhere('t.assigned_to = :assignedTo', { assignedTo: filters.assignedTo }); + } + if (filters.status) { + qb.andWhere('t.status = :status', { status: filters.status }); + } + if (filters.priority) { + qb.andWhere('t.priority = :priority', { priority: filters.priority }); + } + if (filters.search) { + qb.andWhere('t.name ILIKE :search', { search: `%${filters.search}%` }); + } + + qb.orderBy('t.sequence', 'ASC').addOrderBy('t.created_at', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Create a new task + */ + async create(ctx: ServiceContext, dto: CreateTaskDto): Promise { + // Get next sequence for project + const maxSeq = await this.repository + .createQueryBuilder('t') + .select('COALESCE(MAX(t.sequence), 0) + 1', 'nextSeq') + .where('t.project_id = :projectId', { projectId: dto.projectId }) + .andWhere('t.deleted_at IS NULL') + .getRawOne(); + + const entity = this.repository.create({ + tenantId: ctx.tenantId, + projectId: dto.projectId, + stageId: dto.stageId || null, + parentId: dto.parentId || null, + name: dto.name, + description: dto.description || null, + assignedTo: dto.assignedTo || null, + dateDeadline: dto.dateDeadline ? new Date(dto.dateDeadline) : null, + estimatedHours: dto.estimatedHours || 0, + priority: dto.priority || TaskPriority.NORMAL, + status: TaskStatus.TODO, + sequence: parseInt(maxSeq?.nextSeq || '1'), + color: dto.color || null, + createdBy: ctx.userId || null, + }); + + return this.repository.save(entity); + } + + /** + * Update a task + */ + async update( + ctx: ServiceContext, + id: string, + dto: UpdateTaskDto + ): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return null; + } + + // Prevent circular parent reference + if (dto.parentId !== undefined && dto.parentId === id) { + throw new Error('A task cannot be its own parent'); + } + + if (dto.stageId !== undefined) entity.stageId = dto.stageId; + if (dto.parentId !== undefined) entity.parentId = dto.parentId; + if (dto.name !== undefined) entity.name = dto.name; + if (dto.description !== undefined) entity.description = dto.description; + if (dto.assignedTo !== undefined) entity.assignedTo = dto.assignedTo; + if (dto.dateDeadline !== undefined) { + entity.dateDeadline = dto.dateDeadline ? new Date(dto.dateDeadline) : null; + } + if (dto.estimatedHours !== undefined) { + entity.estimatedHours = dto.estimatedHours ?? 0; + } + if (dto.priority !== undefined) entity.priority = dto.priority; + if (dto.status !== undefined) entity.status = dto.status; + if (dto.sequence !== undefined) entity.sequence = dto.sequence; + if (dto.color !== undefined) entity.color = dto.color; + + entity.updatedBy = ctx.userId || null; + + return this.repository.save(entity); + } + + /** + * Soft delete a task + */ + async softDelete(ctx: ServiceContext, id: string): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return false; + } + + entity.deletedAt = new Date(); + entity.deletedBy = ctx.userId || null; + await this.repository.save(entity); + return true; + } + + /** + * Move task to a different stage and sequence + */ + async move( + ctx: ServiceContext, + id: string, + stageId: string | null, + sequence: number + ): Promise { + return this.update(ctx, id, { stageId, sequence }); + } + + /** + * Assign task to a user + */ + async assign( + ctx: ServiceContext, + id: string, + userId: string | null + ): Promise { + return this.update(ctx, id, { assignedTo: userId }); + } + + /** + * Change task status + */ + async changeStatus( + ctx: ServiceContext, + id: string, + status: TaskStatus + ): Promise { + return this.update(ctx, id, { status }); + } + + /** + * Mark task as done + */ + async complete(ctx: ServiceContext, id: string): Promise { + return this.changeStatus(ctx, id, TaskStatus.DONE); + } + + /** + * Mark task as in progress + */ + async start(ctx: ServiceContext, id: string): Promise { + return this.changeStatus(ctx, id, TaskStatus.IN_PROGRESS); + } + + /** + * Cancel a task + */ + async cancel(ctx: ServiceContext, id: string): Promise { + return this.changeStatus(ctx, id, TaskStatus.CANCELLED); + } + + /** + * Find tasks by project + */ + async findByProject( + ctx: ServiceContext, + projectId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { projectId }, page, limit); + } + + /** + * Find tasks assigned to a user + */ + async findByAssignee( + ctx: ServiceContext, + assignedTo: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { assignedTo }, page, limit); + } + + /** + * Find subtasks of a parent task + */ + async findSubtasks( + ctx: ServiceContext, + parentId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { parentId }, page, limit); + } + + /** + * Find overdue tasks + */ + async findOverdue( + ctx: ServiceContext, + projectId?: string, + page = 1, + limit = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const qb = this.repository + .createQueryBuilder('t') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL') + .andWhere('t.date_deadline < :today', { today: new Date() }) + .andWhere('t.status NOT IN (:...completedStatuses)', { + completedStatuses: [TaskStatus.DONE, TaskStatus.CANCELLED], + }); + + if (projectId) { + qb.andWhere('t.project_id = :projectId', { projectId }); + } + + qb.orderBy('t.date_deadline', 'ASC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Get task with timesheets + */ + async findWithTimesheets(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + relations: ['timesheets'], + }); + } + + /** + * Duplicate a task + */ + async duplicate( + ctx: ServiceContext, + id: string, + newName?: string + ): Promise { + const original = await this.findById(ctx, id); + if (!original) { + return null; + } + + return this.create(ctx, { + projectId: original.projectId, + stageId: original.stageId || undefined, + name: newName || `${original.name} (copy)`, + description: original.description || undefined, + priority: original.priority, + estimatedHours: original.estimatedHours, + color: original.color || undefined, + }); + } +} diff --git a/src/modules/projects/services/timesheet.service.ts b/src/modules/projects/services/timesheet.service.ts new file mode 100644 index 0000000..6c38939 --- /dev/null +++ b/src/modules/projects/services/timesheet.service.ts @@ -0,0 +1,435 @@ +/** + * TimesheetService - Time Tracking Service + * + * Provides time entry management with approval workflow. + * Supports multi-tenant architecture with ServiceContext pattern. + * + * @module Projects + */ + +import { DataSource, Repository } from 'typeorm'; +import { TimesheetEntity, TimesheetStatus } from '../entities/timesheet.entity'; +import { ProjectEntity } from '../entities/project.entity'; +import { ServiceContext, PaginatedResult } from './project.service'; + +export interface CreateTimesheetDto { + companyId: string; + projectId: string; + taskId?: string; + date: string; + hours: number; + description?: string; + billable?: boolean; +} + +export interface UpdateTimesheetDto { + taskId?: string | null; + date?: string; + hours?: number; + description?: string | null; + billable?: boolean; +} + +export interface TimesheetFilters { + companyId?: string; + projectId?: string; + taskId?: string; + userId?: string; + status?: TimesheetStatus; + dateFrom?: string; + dateTo?: string; + billable?: boolean; + invoiced?: boolean; +} + +export interface TimesheetSummary { + totalHours: number; + billableHours: number; + nonBillableHours: number; + approvedHours: number; + pendingHours: number; + rejectedHours: number; +} + +export class TimesheetService { + private repository: Repository; + private projectRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(TimesheetEntity); + this.projectRepository = dataSource.getRepository(ProjectEntity); + } + + /** + * Find timesheet by ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + }, + }); + } + + /** + * Find all timesheets with pagination and filters + */ + async findAll( + ctx: ServiceContext, + filters: TimesheetFilters = {}, + page = 1, + limit = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const qb = this.repository + .createQueryBuilder('t') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.companyId) { + qb.andWhere('t.company_id = :companyId', { companyId: filters.companyId }); + } + if (filters.projectId) { + qb.andWhere('t.project_id = :projectId', { projectId: filters.projectId }); + } + if (filters.taskId) { + qb.andWhere('t.task_id = :taskId', { taskId: filters.taskId }); + } + if (filters.userId) { + qb.andWhere('t.user_id = :userId', { userId: filters.userId }); + } + if (filters.status) { + qb.andWhere('t.status = :status', { status: filters.status }); + } + if (filters.dateFrom) { + qb.andWhere('t.date >= :dateFrom', { dateFrom: filters.dateFrom }); + } + if (filters.dateTo) { + qb.andWhere('t.date <= :dateTo', { dateTo: filters.dateTo }); + } + if (filters.billable !== undefined) { + qb.andWhere('t.billable = :billable', { billable: filters.billable }); + } + if (filters.invoiced !== undefined) { + qb.andWhere('t.invoiced = :invoiced', { invoiced: filters.invoiced }); + } + + qb.orderBy('t.date', 'DESC').addOrderBy('t.created_at', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Create a new timesheet entry + */ + async create(ctx: ServiceContext, dto: CreateTimesheetDto): Promise { + // Validate hours + if (dto.hours <= 0 || dto.hours > 24) { + throw new Error('Hours must be between 0 and 24'); + } + + // Validate project exists and allows timesheets + const project = await this.projectRepository.findOne({ + where: { id: dto.projectId, tenantId: ctx.tenantId }, + }); + + if (!project) { + throw new Error('Project not found'); + } + + if (!project.allowTimesheets) { + throw new Error('This project does not allow timesheets'); + } + + const entity = this.repository.create({ + tenantId: ctx.tenantId, + companyId: dto.companyId, + projectId: dto.projectId, + taskId: dto.taskId || null, + userId: ctx.userId!, + date: new Date(dto.date), + hours: dto.hours, + description: dto.description || null, + billable: dto.billable ?? true, + invoiced: false, + status: TimesheetStatus.DRAFT, + createdBy: ctx.userId || null, + }); + + return this.repository.save(entity); + } + + /** + * Update a timesheet entry + */ + async update( + ctx: ServiceContext, + id: string, + dto: UpdateTimesheetDto + ): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return null; + } + + // Only draft timesheets can be edited + if (entity.status !== TimesheetStatus.DRAFT) { + throw new Error('Only draft timesheets can be edited'); + } + + // Only the owner can edit + if (entity.userId !== ctx.userId) { + throw new Error('You can only edit your own timesheets'); + } + + if (dto.taskId !== undefined) entity.taskId = dto.taskId; + if (dto.date !== undefined) entity.date = new Date(dto.date); + if (dto.hours !== undefined) { + if (dto.hours <= 0 || dto.hours > 24) { + throw new Error('Hours must be between 0 and 24'); + } + entity.hours = dto.hours; + } + if (dto.description !== undefined) entity.description = dto.description; + if (dto.billable !== undefined) entity.billable = dto.billable; + + entity.updatedBy = ctx.userId || null; + + return this.repository.save(entity); + } + + /** + * Delete a timesheet entry + */ + async delete(ctx: ServiceContext, id: string): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return false; + } + + // Only draft timesheets can be deleted + if (entity.status !== TimesheetStatus.DRAFT) { + throw new Error('Only draft timesheets can be deleted'); + } + + // Only the owner can delete + if (entity.userId !== ctx.userId) { + throw new Error('You can only delete your own timesheets'); + } + + await this.repository.delete(id); + return true; + } + + /** + * Submit timesheet for approval + */ + async submit(ctx: ServiceContext, id: string): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return null; + } + + if (entity.status !== TimesheetStatus.DRAFT) { + throw new Error('Only draft timesheets can be submitted'); + } + + if (entity.userId !== ctx.userId) { + throw new Error('You can only submit your own timesheets'); + } + + entity.status = TimesheetStatus.SUBMITTED; + entity.updatedBy = ctx.userId || null; + + return this.repository.save(entity); + } + + /** + * Approve a timesheet + */ + async approve(ctx: ServiceContext, id: string): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return null; + } + + if (entity.status !== TimesheetStatus.SUBMITTED) { + throw new Error('Only submitted timesheets can be approved'); + } + + entity.status = TimesheetStatus.APPROVED; + entity.approvedBy = ctx.userId || null; + entity.approvedAt = new Date(); + entity.updatedBy = ctx.userId || null; + + return this.repository.save(entity); + } + + /** + * Reject a timesheet + */ + async reject(ctx: ServiceContext, id: string): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return null; + } + + if (entity.status !== TimesheetStatus.SUBMITTED) { + throw new Error('Only submitted timesheets can be rejected'); + } + + entity.status = TimesheetStatus.REJECTED; + entity.updatedBy = ctx.userId || null; + + return this.repository.save(entity); + } + + /** + * Get my timesheets (for current user) + */ + async findMyTimesheets( + ctx: ServiceContext, + filters: Omit = {}, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { ...filters, userId: ctx.userId }, page, limit); + } + + /** + * Get pending approvals + */ + async findPendingApprovals( + ctx: ServiceContext, + filters: Omit = {}, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { ...filters, status: TimesheetStatus.SUBMITTED }, page, limit); + } + + /** + * Get timesheet summary for a project + */ + async getProjectSummary(ctx: ServiceContext, projectId: string): Promise { + const results = await this.repository + .createQueryBuilder('t') + .select([ + 'SUM(t.hours) as total_hours', + 'SUM(CASE WHEN t.billable = true THEN t.hours ELSE 0 END) as billable_hours', + 'SUM(CASE WHEN t.billable = false THEN t.hours ELSE 0 END) as non_billable_hours', + 'SUM(CASE WHEN t.status = :approved THEN t.hours ELSE 0 END) as approved_hours', + 'SUM(CASE WHEN t.status IN (:...pending) THEN t.hours ELSE 0 END) as pending_hours', + 'SUM(CASE WHEN t.status = :rejected THEN t.hours ELSE 0 END) as rejected_hours', + ]) + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.project_id = :projectId', { projectId }) + .setParameters({ + approved: TimesheetStatus.APPROVED, + pending: [TimesheetStatus.DRAFT, TimesheetStatus.SUBMITTED], + rejected: TimesheetStatus.REJECTED, + }) + .getRawOne(); + + return { + totalHours: parseFloat(results?.total_hours || '0'), + billableHours: parseFloat(results?.billable_hours || '0'), + nonBillableHours: parseFloat(results?.non_billable_hours || '0'), + approvedHours: parseFloat(results?.approved_hours || '0'), + pendingHours: parseFloat(results?.pending_hours || '0'), + rejectedHours: parseFloat(results?.rejected_hours || '0'), + }; + } + + /** + * Get timesheet summary for a user in a date range + */ + async getUserSummary( + ctx: ServiceContext, + userId: string, + dateFrom: string, + dateTo: string + ): Promise { + const results = await this.repository + .createQueryBuilder('t') + .select([ + 'SUM(t.hours) as total_hours', + 'SUM(CASE WHEN t.billable = true THEN t.hours ELSE 0 END) as billable_hours', + 'SUM(CASE WHEN t.billable = false THEN t.hours ELSE 0 END) as non_billable_hours', + 'SUM(CASE WHEN t.status = :approved THEN t.hours ELSE 0 END) as approved_hours', + 'SUM(CASE WHEN t.status IN (:...pending) THEN t.hours ELSE 0 END) as pending_hours', + 'SUM(CASE WHEN t.status = :rejected THEN t.hours ELSE 0 END) as rejected_hours', + ]) + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.user_id = :userId', { userId }) + .andWhere('t.date >= :dateFrom', { dateFrom }) + .andWhere('t.date <= :dateTo', { dateTo }) + .setParameters({ + approved: TimesheetStatus.APPROVED, + pending: [TimesheetStatus.DRAFT, TimesheetStatus.SUBMITTED], + rejected: TimesheetStatus.REJECTED, + }) + .getRawOne(); + + return { + totalHours: parseFloat(results?.total_hours || '0'), + billableHours: parseFloat(results?.billable_hours || '0'), + nonBillableHours: parseFloat(results?.non_billable_hours || '0'), + approvedHours: parseFloat(results?.approved_hours || '0'), + pendingHours: parseFloat(results?.pending_hours || '0'), + rejectedHours: parseFloat(results?.rejected_hours || '0'), + }; + } + + /** + * Mark timesheets as invoiced + */ + async markAsInvoiced( + ctx: ServiceContext, + ids: string[], + invoiceId: string + ): Promise { + const result = await this.repository + .createQueryBuilder() + .update(TimesheetEntity) + .set({ invoiced: true, invoiceId, updatedBy: ctx.userId || null }) + .where('id IN (:...ids)', { ids }) + .andWhere('tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('status = :status', { status: TimesheetStatus.APPROVED }) + .andWhere('invoiced = false') + .execute(); + + return result.affected || 0; + } + + /** + * Find billable but not invoiced timesheets + */ + async findBillableNotInvoiced( + ctx: ServiceContext, + projectId?: string, + page = 1, + limit = 20 + ): Promise> { + const filters: TimesheetFilters = { + billable: true, + invoiced: false, + status: TimesheetStatus.APPROVED, + }; + + if (projectId) { + filters.projectId = projectId; + } + + return this.findAll(ctx, filters, page, limit); + } +} diff --git a/src/modules/sales/controllers/index.ts b/src/modules/sales/controllers/index.ts new file mode 100644 index 0000000..ed93403 --- /dev/null +++ b/src/modules/sales/controllers/index.ts @@ -0,0 +1,7 @@ +/** + * Sales Controllers Index + * @module Sales (MAI-010) + */ + +export { default as quotationController } from './quotation.controller'; +export { default as salesOrderController } from './sales-order.controller'; diff --git a/src/modules/sales/controllers/quotation.controller.ts b/src/modules/sales/controllers/quotation.controller.ts new file mode 100644 index 0000000..38155f0 --- /dev/null +++ b/src/modules/sales/controllers/quotation.controller.ts @@ -0,0 +1,530 @@ +/** + * Quotation Controller + * API endpoints para gestión de cotizaciones de venta + * + * @module Sales (MAI-010) + * @prefix /api/v1/quotations + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + QuotationService, + CreateQuotationDto, + UpdateQuotationDto, + CreateQuotationItemDto, + UpdateQuotationItemDto, +} from '../services/quotation.service'; + +const router = Router(); +const quotationService = new QuotationService(null as any); + +/** + * GET /api/v1/quotations + * Lista todas las cotizaciones del tenant + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { + partnerId, + salesRepId, + status, + dateFrom, + dateTo, + minTotal, + maxTotal, + search, + page, + limit, + } = req.query; + + const result = await quotationService.findAll( + { tenantId }, + { + partnerId: partnerId as string, + salesRepId: salesRepId as string, + status: status as string, + dateFrom: dateFrom ? new Date(dateFrom as string) : undefined, + dateTo: dateTo ? new Date(dateTo as string) : undefined, + minTotal: minTotal ? Number(minTotal) : undefined, + maxTotal: maxTotal ? Number(maxTotal) : undefined, + search: search as string, + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined, + }, + ); + + return res.json({ + success: true, + data: result.data, + total: result.total, + page: result.page, + limit: result.limit, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/quotations/statistics + * Estadísticas de cotizaciones + */ +router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { dateFrom, dateTo } = req.query; + const stats = await quotationService.getStatistics( + { tenantId }, + dateFrom ? new Date(dateFrom as string) : undefined, + dateTo ? new Date(dateTo as string) : undefined, + ); + + return res.json({ success: true, data: stats }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/quotations/pending + * Cotizaciones pendientes (borrador o enviadas) + */ +router.get('/pending', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data = await quotationService.findPending({ tenantId }); + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/quotations/expired + * Cotizaciones vencidas + */ +router.get('/expired', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data = await quotationService.findExpired({ tenantId }); + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/quotations/by-partner/:partnerId + * Cotizaciones por cliente + */ +router.get('/by-partner/:partnerId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data = await quotationService.findByPartner({ tenantId }, req.params.partnerId); + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/quotations/by-number/:number + * Buscar cotización por número + */ +router.get('/by-number/:number', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const quotation = await quotationService.findByNumber({ tenantId }, req.params.number); + if (!quotation) { + return res.status(404).json({ error: 'Cotización no encontrada' }); + } + + return res.json({ success: true, data: quotation }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/quotations/:id + * Obtiene una cotización por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const quotation = await quotationService.findById({ tenantId }, req.params.id); + if (!quotation) { + return res.status(404).json({ error: 'Cotización no encontrada' }); + } + + return res.json({ success: true, data: quotation }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/quotations/:id/with-items + * Obtiene una cotización con sus líneas + */ +router.get('/:id/with-items', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const quotation = await quotationService.findByIdWithItems({ tenantId }, req.params.id); + if (!quotation) { + return res.status(404).json({ error: 'Cotización no encontrada' }); + } + + return res.json({ success: true, data: quotation }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/quotations + * Crea una nueva cotización + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: CreateQuotationDto = req.body; + + if (!dto.partnerId) { + return res.status(400).json({ error: 'partnerId es requerido' }); + } + + const quotation = await quotationService.create({ tenantId, userId }, dto); + return res.status(201).json({ success: true, data: quotation }); + } catch (error: any) { + return next(error); + } +}); + +/** + * PATCH /api/v1/quotations/:id + * Actualiza una cotización + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: UpdateQuotationDto = req.body; + + const quotation = await quotationService.update({ tenantId, userId }, req.params.id, dto); + if (!quotation) { + return res.status(404).json({ error: 'Cotización no encontrada' }); + } + + return res.json({ success: true, data: quotation }); + } catch (error: any) { + if (error.message?.includes('Cannot update')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/quotations/:id/items + * Agrega una línea a la cotización + */ +router.post('/:id/items', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: CreateQuotationItemDto = req.body; + + if (!dto.productName || dto.quantity === undefined || dto.unitPrice === undefined) { + return res.status(400).json({ error: 'productName, quantity y unitPrice son requeridos' }); + } + + const item = await quotationService.addItem({ tenantId, userId }, req.params.id, dto); + if (!item) { + return res.status(404).json({ error: 'Cotización no encontrada' }); + } + + return res.status(201).json({ success: true, data: item }); + } catch (error: any) { + if (error.message?.includes('Cannot add')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * PATCH /api/v1/quotations/:id/items/:itemId + * Actualiza una línea de la cotización + */ +router.patch('/:id/items/:itemId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: UpdateQuotationItemDto = req.body; + + const item = await quotationService.updateItem( + { tenantId, userId }, + req.params.id, + req.params.itemId, + dto, + ); + + if (!item) { + return res.status(404).json({ error: 'Línea no encontrada' }); + } + + return res.json({ success: true, data: item }); + } catch (error: any) { + if (error.message?.includes('Cannot update')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * DELETE /api/v1/quotations/:id/items/:itemId + * Elimina una línea de la cotización + */ +router.delete('/:id/items/:itemId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const deleted = await quotationService.removeItem( + { tenantId, userId }, + req.params.id, + req.params.itemId, + ); + + if (!deleted) { + return res.status(404).json({ error: 'Línea no encontrada' }); + } + + return res.json({ success: true, message: 'Línea eliminada' }); + } catch (error: any) { + if (error.message?.includes('Cannot remove')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/quotations/:id/send + * Envía la cotización al cliente + */ +router.post('/:id/send', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const quotation = await quotationService.send({ tenantId, userId }, req.params.id); + + if (!quotation) { + return res.status(404).json({ error: 'Cotización no encontrada' }); + } + + return res.json({ success: true, data: quotation, message: 'Cotización enviada' }); + } catch (error: any) { + if (error.message?.includes('Only draft')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/quotations/:id/accept + * Acepta la cotización + */ +router.post('/:id/accept', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const quotation = await quotationService.accept({ tenantId, userId }, req.params.id); + + if (!quotation) { + return res.status(404).json({ error: 'Cotización no encontrada' }); + } + + return res.json({ success: true, data: quotation, message: 'Cotización aceptada' }); + } catch (error: any) { + if (error.message?.includes('Only')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/quotations/:id/reject + * Rechaza la cotización + */ +router.post('/:id/reject', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const quotation = await quotationService.reject({ tenantId, userId }, req.params.id); + + if (!quotation) { + return res.status(404).json({ error: 'Cotización no encontrada' }); + } + + return res.json({ success: true, data: quotation, message: 'Cotización rechazada' }); + } catch (error: any) { + if (error.message?.includes('Only')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/quotations/:id/expire + * Marca la cotización como vencida + */ +router.post('/:id/expire', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const quotation = await quotationService.markExpired({ tenantId, userId }, req.params.id); + + if (!quotation) { + return res.status(404).json({ error: 'Cotización no encontrada' }); + } + + return res.json({ success: true, data: quotation, message: 'Cotización marcada como vencida' }); + } catch (error: any) { + if (error.message?.includes('Cannot expire')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/quotations/:id/duplicate + * Duplica una cotización + */ +router.post('/:id/duplicate', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const quotation = await quotationService.duplicate({ tenantId, userId }, req.params.id); + + if (!quotation) { + return res.status(404).json({ error: 'Cotización no encontrada' }); + } + + return res.status(201).json({ + success: true, + data: quotation, + message: 'Cotización duplicada', + }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/quotations/:id + * Elimina una cotización (soft delete) + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const deleted = await quotationService.softDelete({ tenantId, userId }, req.params.id); + + if (!deleted) { + return res.status(404).json({ error: 'Cotización no encontrada' }); + } + + return res.json({ success: true, message: 'Cotización eliminada' }); + } catch (error: any) { + if (error.message?.includes('Cannot delete')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +export default router; diff --git a/src/modules/sales/controllers/sales-order.controller.ts b/src/modules/sales/controllers/sales-order.controller.ts new file mode 100644 index 0000000..f3ea783 --- /dev/null +++ b/src/modules/sales/controllers/sales-order.controller.ts @@ -0,0 +1,639 @@ +/** + * Sales Order Controller + * API endpoints para gestión de órdenes de venta + * + * @module Sales (MAI-010) + * @prefix /api/v1/sales-orders + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + SalesOrderService, + CreateSalesOrderDto, + UpdateSalesOrderDto, + CreateSalesOrderItemDto, + UpdateSalesOrderItemDto, +} from '../services/sales-order.service'; + +const router = Router(); +const salesOrderService = new SalesOrderService(null as any); + +/** + * GET /api/v1/sales-orders + * Lista todas las órdenes de venta del tenant + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { + partnerId, + userId, + salesTeamId, + status, + invoiceStatus, + deliveryStatus, + dateFrom, + dateTo, + minTotal, + maxTotal, + search, + page, + limit, + } = req.query; + + const result = await salesOrderService.findAll( + { tenantId }, + { + partnerId: partnerId as string, + userId: userId as string, + salesTeamId: salesTeamId as string, + status: status as string, + invoiceStatus: invoiceStatus as string, + deliveryStatus: deliveryStatus as string, + dateFrom: dateFrom ? new Date(dateFrom as string) : undefined, + dateTo: dateTo ? new Date(dateTo as string) : undefined, + minTotal: minTotal ? Number(minTotal) : undefined, + maxTotal: maxTotal ? Number(maxTotal) : undefined, + search: search as string, + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined, + }, + ); + + return res.json({ + success: true, + data: result.data, + total: result.total, + page: result.page, + limit: result.limit, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/sales-orders/statistics + * Estadísticas de órdenes de venta + */ +router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { dateFrom, dateTo } = req.query; + const stats = await salesOrderService.getStatistics( + { tenantId }, + dateFrom ? new Date(dateFrom as string) : undefined, + dateTo ? new Date(dateTo as string) : undefined, + ); + + return res.json({ success: true, data: stats }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/sales-orders/pending-delivery + * Órdenes pendientes de entrega + */ +router.get('/pending-delivery', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data = await salesOrderService.findPendingDelivery({ tenantId }); + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/sales-orders/pending-invoice + * Órdenes pendientes de facturar + */ +router.get('/pending-invoice', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data = await salesOrderService.findPendingInvoice({ tenantId }); + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/sales-orders/by-partner/:partnerId + * Órdenes por cliente + */ +router.get('/by-partner/:partnerId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data = await salesOrderService.findByPartner({ tenantId }, req.params.partnerId); + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/sales-orders/by-quotation/:quotationId + * Orden generada desde una cotización + */ +router.get('/by-quotation/:quotationId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const order = await salesOrderService.findByQuotation({ tenantId }, req.params.quotationId); + if (!order) { + return res.status(404).json({ error: 'Orden no encontrada para esta cotización' }); + } + + return res.json({ success: true, data: order }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/sales-orders/by-name/:name + * Buscar orden por número + */ +router.get('/by-name/:name', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const order = await salesOrderService.findByName({ tenantId }, req.params.name); + if (!order) { + return res.status(404).json({ error: 'Orden no encontrada' }); + } + + return res.json({ success: true, data: order }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/sales-orders/:id + * Obtiene una orden por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const order = await salesOrderService.findById({ tenantId }, req.params.id); + if (!order) { + return res.status(404).json({ error: 'Orden no encontrada' }); + } + + return res.json({ success: true, data: order }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/sales-orders/:id/with-items + * Obtiene una orden con sus líneas + */ +router.get('/:id/with-items', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const order = await salesOrderService.findByIdWithItems({ tenantId }, req.params.id); + if (!order) { + return res.status(404).json({ error: 'Orden no encontrada' }); + } + + return res.json({ success: true, data: order }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/sales-orders + * Crea una nueva orden de venta + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const companyId = req.headers['x-company-id'] as string; + const dto: CreateSalesOrderDto = req.body; + + if (!dto.partnerId || !dto.currencyId) { + return res.status(400).json({ error: 'partnerId y currencyId son requeridos' }); + } + + const order = await salesOrderService.create({ tenantId, userId, companyId }, dto); + return res.status(201).json({ success: true, data: order }); + } catch (error: any) { + return next(error); + } +}); + +/** + * POST /api/v1/sales-orders/from-quotation/:quotationId + * Crea una orden desde una cotización aceptada + */ +router.post('/from-quotation/:quotationId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const companyId = req.headers['x-company-id'] as string; + + const order = await salesOrderService.createFromQuotation( + { tenantId, userId, companyId }, + req.params.quotationId, + ); + + if (!order) { + return res.status(404).json({ error: 'Cotización no encontrada' }); + } + + return res.status(201).json({ + success: true, + data: order, + message: 'Orden creada desde cotización', + }); + } catch (error: any) { + if (error.message?.includes('Only accepted')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * PATCH /api/v1/sales-orders/:id + * Actualiza una orden de venta + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: UpdateSalesOrderDto = req.body; + + const order = await salesOrderService.update({ tenantId, userId }, req.params.id, dto); + if (!order) { + return res.status(404).json({ error: 'Orden no encontrada' }); + } + + return res.json({ success: true, data: order }); + } catch (error: any) { + if (error.message?.includes('Can only update')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/sales-orders/:id/items + * Agrega una línea a la orden + */ +router.post('/:id/items', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: CreateSalesOrderItemDto = req.body; + + if (!dto.productName || dto.quantity === undefined || dto.unitPrice === undefined) { + return res.status(400).json({ error: 'productName, quantity y unitPrice son requeridos' }); + } + + const item = await salesOrderService.addItem({ tenantId, userId }, req.params.id, dto); + if (!item) { + return res.status(404).json({ error: 'Orden no encontrada' }); + } + + return res.status(201).json({ success: true, data: item }); + } catch (error: any) { + if (error.message?.includes('Can only add')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * PATCH /api/v1/sales-orders/:id/items/:itemId + * Actualiza una línea de la orden + */ +router.patch('/:id/items/:itemId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: UpdateSalesOrderItemDto = req.body; + + const item = await salesOrderService.updateItem( + { tenantId, userId }, + req.params.id, + req.params.itemId, + dto, + ); + + if (!item) { + return res.status(404).json({ error: 'Línea no encontrada' }); + } + + return res.json({ success: true, data: item }); + } catch (error: any) { + if (error.message?.includes('Can only update')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * DELETE /api/v1/sales-orders/:id/items/:itemId + * Elimina una línea de la orden + */ +router.delete('/:id/items/:itemId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const deleted = await salesOrderService.removeItem( + { tenantId, userId }, + req.params.id, + req.params.itemId, + ); + + if (!deleted) { + return res.status(404).json({ error: 'Línea no encontrada' }); + } + + return res.json({ success: true, message: 'Línea eliminada' }); + } catch (error: any) { + if (error.message?.includes('Can only remove')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/sales-orders/:id/send + * Envía la orden al cliente + */ +router.post('/:id/send', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const order = await salesOrderService.send({ tenantId, userId }, req.params.id); + + if (!order) { + return res.status(404).json({ error: 'Orden no encontrada' }); + } + + return res.json({ success: true, data: order, message: 'Orden enviada' }); + } catch (error: any) { + if (error.message?.includes('Only draft')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/sales-orders/:id/confirm + * Confirma la orden de venta + */ +router.post('/:id/confirm', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const order = await salesOrderService.confirm({ tenantId, userId }, req.params.id); + + if (!order) { + return res.status(404).json({ error: 'Orden no encontrada' }); + } + + return res.json({ success: true, data: order, message: 'Orden confirmada' }); + } catch (error: any) { + if (error.message?.includes('Can only confirm')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/sales-orders/:id/cancel + * Cancela la orden de venta + */ +router.post('/:id/cancel', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const order = await salesOrderService.cancel({ tenantId, userId }, req.params.id); + + if (!order) { + return res.status(404).json({ error: 'Orden no encontrada' }); + } + + return res.json({ success: true, data: order, message: 'Orden cancelada' }); + } catch (error: any) { + if (error.message?.includes('Cannot cancel')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/sales-orders/:id/done + * Marca la orden como completada + */ +router.post('/:id/done', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const order = await salesOrderService.markDone({ tenantId, userId }, req.params.id); + + if (!order) { + return res.status(404).json({ error: 'Orden no encontrada' }); + } + + return res.json({ success: true, data: order, message: 'Orden completada' }); + } catch (error: any) { + if (error.message?.includes('Only confirmed')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * PATCH /api/v1/sales-orders/:id/items/:itemId/delivery + * Actualiza la cantidad entregada de una línea + */ +router.patch('/:id/items/:itemId/delivery', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const { quantityDelivered } = req.body; + + if (quantityDelivered === undefined) { + return res.status(400).json({ error: 'quantityDelivered es requerido' }); + } + + const item = await salesOrderService.updateItemDelivery( + { tenantId, userId }, + req.params.id, + req.params.itemId, + Number(quantityDelivered), + ); + + if (!item) { + return res.status(404).json({ error: 'Línea no encontrada' }); + } + + return res.json({ success: true, data: item, message: 'Entrega actualizada' }); + } catch (error: any) { + if (error.message?.includes('cannot exceed')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * PATCH /api/v1/sales-orders/:id/invoice-status + * Actualiza el estado de facturación + */ +router.patch('/:id/invoice-status', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const { status } = req.body; + + if (!status || !['pending', 'partial', 'invoiced'].includes(status)) { + return res.status(400).json({ error: 'status debe ser pending, partial o invoiced' }); + } + + const order = await salesOrderService.updateInvoiceStatus( + { tenantId, userId }, + req.params.id, + status, + ); + + if (!order) { + return res.status(404).json({ error: 'Orden no encontrada' }); + } + + return res.json({ success: true, data: order, message: 'Estado de facturación actualizado' }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/sales-orders/:id + * Elimina una orden (soft delete) + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const deleted = await salesOrderService.softDelete({ tenantId, userId }, req.params.id); + + if (!deleted) { + return res.status(404).json({ error: 'Orden no encontrada' }); + } + + return res.json({ success: true, message: 'Orden eliminada' }); + } catch (error: any) { + if (error.message?.includes('Cannot delete')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +export default router; diff --git a/src/modules/sales/index.ts b/src/modules/sales/index.ts new file mode 100644 index 0000000..8f4c65c --- /dev/null +++ b/src/modules/sales/index.ts @@ -0,0 +1,17 @@ +/** + * Sales Module Index + * + * Módulo de ventas para gestión de cotizaciones y órdenes de venta + * de unidades inmobiliarias (departamentos, lotes, viviendas). + * + * @module Sales (MAI-010) + */ + +// Entities +export * from './entities'; + +// Services +export * from './services'; + +// Controllers +export * from './controllers'; diff --git a/src/modules/sales/services/index.ts b/src/modules/sales/services/index.ts new file mode 100644 index 0000000..d05d079 --- /dev/null +++ b/src/modules/sales/services/index.ts @@ -0,0 +1,7 @@ +/** + * Sales Services Index + * @module Sales (MAI-010) + */ + +export * from './quotation.service'; +export * from './sales-order.service'; diff --git a/src/modules/sales/services/quotation.service.ts b/src/modules/sales/services/quotation.service.ts new file mode 100644 index 0000000..10a1355 --- /dev/null +++ b/src/modules/sales/services/quotation.service.ts @@ -0,0 +1,704 @@ +/** + * QuotationService - Servicio de cotizaciones de venta + * + * Gestión de cotizaciones para venta de unidades inmobiliarias + * (departamentos, lotes, viviendas). + * + * @module Sales (MAI-010) + */ + +import { Repository, DataSource } from 'typeorm'; +import { Quotation } from '../entities/quotation.entity'; +import { QuotationItem } from '../entities/quotation-item.entity'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateQuotationDto { + partnerId: string; + partnerName?: string; + partnerEmail?: string; + billingAddress?: object; + shippingAddress?: object; + quotationDate?: Date; + validUntil?: Date; + expectedCloseDate?: Date; + salesRepId?: string; + currency?: string; + paymentTermDays?: number; + paymentMethod?: string; + notes?: string; + internalNotes?: string; + termsAndConditions?: string; + items?: CreateQuotationItemDto[]; +} + +export interface UpdateQuotationDto { + partnerName?: string; + partnerEmail?: string; + billingAddress?: object; + shippingAddress?: object; + validUntil?: Date; + expectedCloseDate?: Date; + salesRepId?: string; + currency?: string; + paymentTermDays?: number; + paymentMethod?: string; + notes?: string; + internalNotes?: string; + termsAndConditions?: string; +} + +export interface CreateQuotationItemDto { + productId?: string; + productSku?: string; + productName: string; + description?: string; + quantity: number; + uom?: string; + unitPrice: number; + discountPercent?: number; + taxRate?: number; +} + +export interface UpdateQuotationItemDto { + productName?: string; + description?: string; + quantity?: number; + uom?: string; + unitPrice?: number; + discountPercent?: number; + taxRate?: number; +} + +export interface QuotationFilters { + partnerId?: string; + salesRepId?: string; + status?: string; + dateFrom?: Date; + dateTo?: Date; + minTotal?: number; + maxTotal?: number; + search?: string; + page?: number; + limit?: number; +} + +export class QuotationService { + private readonly quotationRepo: Repository; + private readonly itemRepo: Repository; + private readonly dataSource: DataSource; + + constructor(dataSource: DataSource) { + this.dataSource = dataSource; + this.quotationRepo = dataSource.getRepository(Quotation); + this.itemRepo = dataSource.getRepository(QuotationItem); + } + + async findAll( + ctx: ServiceContext, + filters: QuotationFilters = {}, + ): Promise<{ data: Quotation[]; total: number; page: number; limit: number }> { + const page = filters.page || 1; + const limit = filters.limit || 50; + const skip = (page - 1) * limit; + + const queryBuilder = this.quotationRepo + .createQueryBuilder('q') + .where('q.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('q.deleted_at IS NULL'); + + if (filters.partnerId) { + queryBuilder.andWhere('q.partner_id = :partnerId', { partnerId: filters.partnerId }); + } + + if (filters.salesRepId) { + queryBuilder.andWhere('q.sales_rep_id = :salesRepId', { salesRepId: filters.salesRepId }); + } + + if (filters.status) { + queryBuilder.andWhere('q.status = :status', { status: filters.status }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('q.quotation_date >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('q.quotation_date <= :dateTo', { dateTo: filters.dateTo }); + } + + if (filters.minTotal !== undefined) { + queryBuilder.andWhere('q.total >= :minTotal', { minTotal: filters.minTotal }); + } + + if (filters.maxTotal !== undefined) { + queryBuilder.andWhere('q.total <= :maxTotal', { maxTotal: filters.maxTotal }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(q.quotation_number ILIKE :search OR q.partner_name ILIKE :search)', + { search: `%${filters.search}%` }, + ); + } + + const [data, total] = await queryBuilder + .orderBy('q.created_at', 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.quotationRepo.findOne({ + where: { + id, + tenantId: ctx.tenantId, + }, + }); + } + + async findByIdWithItems(ctx: ServiceContext, id: string): Promise { + const quotation = await this.findById(ctx, id); + if (!quotation) { + return null; + } + + const items = await this.itemRepo.find({ + where: { quotationId: id }, + order: { lineNumber: 'ASC' }, + }); + + return { ...quotation, items } as Quotation & { items: QuotationItem[] }; + } + + async findByNumber(ctx: ServiceContext, quotationNumber: string): Promise { + return this.quotationRepo.findOne({ + where: { + quotationNumber, + tenantId: ctx.tenantId, + }, + }); + } + + async findByPartner(ctx: ServiceContext, partnerId: string): Promise { + return this.quotationRepo + .createQueryBuilder('q') + .where('q.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('q.deleted_at IS NULL') + .andWhere('q.partner_id = :partnerId', { partnerId }) + .orderBy('q.created_at', 'DESC') + .getMany(); + } + + async findPending(ctx: ServiceContext): Promise { + return this.quotationRepo + .createQueryBuilder('q') + .where('q.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('q.deleted_at IS NULL') + .andWhere('q.status IN (:...statuses)', { statuses: ['draft', 'sent'] }) + .orderBy('q.created_at', 'DESC') + .getMany(); + } + + async findExpired(ctx: ServiceContext): Promise { + return this.quotationRepo + .createQueryBuilder('q') + .where('q.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('q.deleted_at IS NULL') + .andWhere('q.status IN (:...statuses)', { statuses: ['draft', 'sent'] }) + .andWhere('q.valid_until < CURRENT_DATE') + .orderBy('q.valid_until', 'ASC') + .getMany(); + } + + private async generateQuotationNumber(ctx: ServiceContext): Promise { + const year = new Date().getFullYear(); + const prefix = `COT-${year}-`; + + const lastQuotation = await this.quotationRepo + .createQueryBuilder('q') + .where('q.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('q.quotation_number LIKE :prefix', { prefix: `${prefix}%` }) + .orderBy('q.quotation_number', 'DESC') + .getOne(); + + let sequence = 1; + if (lastQuotation) { + const lastNumber = lastQuotation.quotationNumber.replace(prefix, ''); + sequence = parseInt(lastNumber, 10) + 1; + } + + return `${prefix}${sequence.toString().padStart(5, '0')}`; + } + + private calculateItemTotals(item: CreateQuotationItemDto): { + discountAmount: number; + taxAmount: number; + subtotal: number; + total: number; + } { + const quantity = Number(item.quantity) || 0; + const unitPrice = Number(item.unitPrice) || 0; + const discountPercent = Number(item.discountPercent) || 0; + const taxRate = Number(item.taxRate) ?? 16; + + const grossAmount = quantity * unitPrice; + const discountAmount = grossAmount * (discountPercent / 100); + const subtotal = grossAmount - discountAmount; + const taxAmount = subtotal * (taxRate / 100); + const total = subtotal + taxAmount; + + return { + discountAmount: Math.round(discountAmount * 100) / 100, + taxAmount: Math.round(taxAmount * 100) / 100, + subtotal: Math.round(subtotal * 100) / 100, + total: Math.round(total * 100) / 100, + }; + } + + private calculateQuotationTotals(items: Array<{ subtotal: number; taxAmount: number; discountAmount: number }>): { + subtotal: number; + taxAmount: number; + discountAmount: number; + total: number; + } { + const subtotal = items.reduce((sum, item) => sum + Number(item.subtotal), 0); + const taxAmount = items.reduce((sum, item) => sum + Number(item.taxAmount), 0); + const discountAmount = items.reduce((sum, item) => sum + Number(item.discountAmount), 0); + const total = subtotal + taxAmount; + + return { + subtotal: Math.round(subtotal * 100) / 100, + taxAmount: Math.round(taxAmount * 100) / 100, + discountAmount: Math.round(discountAmount * 100) / 100, + total: Math.round(total * 100) / 100, + }; + } + + async create(ctx: ServiceContext, dto: CreateQuotationDto): Promise { + const quotationNumber = await this.generateQuotationNumber(ctx); + + return this.dataSource.transaction(async (manager) => { + const quotationRepo = manager.getRepository(Quotation); + const itemRepo = manager.getRepository(QuotationItem); + + const quotation = quotationRepo.create({ + tenantId: ctx.tenantId, + createdBy: ctx.userId, + quotationNumber, + partnerId: dto.partnerId, + partnerName: dto.partnerName, + partnerEmail: dto.partnerEmail, + billingAddress: dto.billingAddress, + shippingAddress: dto.shippingAddress, + quotationDate: dto.quotationDate ?? new Date(), + validUntil: dto.validUntil, + expectedCloseDate: dto.expectedCloseDate, + salesRepId: dto.salesRepId ?? ctx.userId, + currency: dto.currency ?? 'MXN', + paymentTermDays: dto.paymentTermDays ?? 0, + paymentMethod: dto.paymentMethod, + notes: dto.notes, + internalNotes: dto.internalNotes, + termsAndConditions: dto.termsAndConditions, + status: 'draft', + subtotal: 0, + taxAmount: 0, + discountAmount: 0, + total: 0, + }); + + const savedQuotation = await quotationRepo.save(quotation); + + if (dto.items && dto.items.length > 0) { + const itemEntities: QuotationItem[] = []; + + for (let i = 0; i < dto.items.length; i++) { + const itemDto = dto.items[i]; + const calculated = this.calculateItemTotals(itemDto); + + const item = itemRepo.create({ + quotationId: savedQuotation.id, + lineNumber: i + 1, + productId: itemDto.productId, + productSku: itemDto.productSku, + productName: itemDto.productName, + description: itemDto.description, + quantity: itemDto.quantity, + uom: itemDto.uom ?? 'PZA', + unitPrice: itemDto.unitPrice, + discountPercent: itemDto.discountPercent ?? 0, + taxRate: itemDto.taxRate ?? 16, + ...calculated, + }); + + itemEntities.push(item); + } + + await itemRepo.save(itemEntities); + + const totals = this.calculateQuotationTotals(itemEntities); + savedQuotation.subtotal = totals.subtotal; + savedQuotation.taxAmount = totals.taxAmount; + savedQuotation.discountAmount = totals.discountAmount; + savedQuotation.total = totals.total; + + await quotationRepo.save(savedQuotation); + } + + return savedQuotation; + }); + } + + async update(ctx: ServiceContext, id: string, dto: UpdateQuotationDto): Promise { + const quotation = await this.findById(ctx, id); + if (!quotation) { + return null; + } + + if (quotation.status === 'converted') { + throw new Error('Cannot update a converted quotation'); + } + + Object.assign(quotation, { + ...dto, + updatedBy: ctx.userId, + }); + + return this.quotationRepo.save(quotation); + } + + async addItem(ctx: ServiceContext, quotationId: string, dto: CreateQuotationItemDto): Promise { + const quotation = await this.findById(ctx, quotationId); + if (!quotation) { + return null; + } + + if (quotation.status === 'converted') { + throw new Error('Cannot add items to a converted quotation'); + } + + return this.dataSource.transaction(async (manager) => { + const itemRepo = manager.getRepository(QuotationItem); + + const lastItem = await itemRepo + .createQueryBuilder('i') + .where('i.quotation_id = :quotationId', { quotationId }) + .orderBy('i.line_number', 'DESC') + .getOne(); + + const lineNumber = (lastItem?.lineNumber ?? 0) + 1; + const calculated = this.calculateItemTotals(dto); + + const item = itemRepo.create({ + quotationId, + lineNumber, + productId: dto.productId, + productSku: dto.productSku, + productName: dto.productName, + description: dto.description, + quantity: dto.quantity, + uom: dto.uom ?? 'PZA', + unitPrice: dto.unitPrice, + discountPercent: dto.discountPercent ?? 0, + taxRate: dto.taxRate ?? 16, + ...calculated, + }); + + const savedItem = await itemRepo.save(item); + + await this.recalculateQuotationTotals(manager, quotationId); + + return savedItem; + }); + } + + async updateItem( + ctx: ServiceContext, + quotationId: string, + itemId: string, + dto: UpdateQuotationItemDto, + ): Promise { + const quotation = await this.findById(ctx, quotationId); + if (!quotation) { + return null; + } + + if (quotation.status === 'converted') { + throw new Error('Cannot update items on a converted quotation'); + } + + const item = await this.itemRepo.findOne({ + where: { id: itemId, quotationId }, + }); + + if (!item) { + return null; + } + + return this.dataSource.transaction(async (manager) => { + const itemRepo = manager.getRepository(QuotationItem); + + Object.assign(item, dto); + + const calculated = this.calculateItemTotals({ + quantity: item.quantity, + unitPrice: item.unitPrice, + discountPercent: item.discountPercent, + taxRate: item.taxRate, + productName: item.productName, + }); + + Object.assign(item, calculated); + + const savedItem = await itemRepo.save(item); + + await this.recalculateQuotationTotals(manager, quotationId); + + return savedItem; + }); + } + + async removeItem(ctx: ServiceContext, quotationId: string, itemId: string): Promise { + const quotation = await this.findById(ctx, quotationId); + if (!quotation) { + return false; + } + + if (quotation.status === 'converted') { + throw new Error('Cannot remove items from a converted quotation'); + } + + return this.dataSource.transaction(async (manager) => { + const itemRepo = manager.getRepository(QuotationItem); + + const result = await itemRepo.delete({ id: itemId, quotationId }); + if (!result.affected) { + return false; + } + + await this.recalculateQuotationTotals(manager, quotationId); + + return true; + }); + } + + private async recalculateQuotationTotals( + manager: any, + quotationId: string, + ): Promise { + const quotationRepo = manager.getRepository(Quotation); + const itemRepo = manager.getRepository(QuotationItem); + + const items = await itemRepo.find({ + where: { quotationId }, + }); + + const totals = this.calculateQuotationTotals(items); + + await quotationRepo.update({ id: quotationId }, totals); + } + + async send(ctx: ServiceContext, id: string): Promise { + const quotation = await this.findById(ctx, id); + if (!quotation) { + return null; + } + + if (quotation.status !== 'draft') { + throw new Error('Only draft quotations can be sent'); + } + + quotation.status = 'sent'; + quotation.updatedBy = ctx.userId; + + return this.quotationRepo.save(quotation); + } + + async accept(ctx: ServiceContext, id: string): Promise { + const quotation = await this.findById(ctx, id); + if (!quotation) { + return null; + } + + if (quotation.status !== 'sent' && quotation.status !== 'draft') { + throw new Error('Only draft or sent quotations can be accepted'); + } + + quotation.status = 'accepted'; + quotation.updatedBy = ctx.userId; + + return this.quotationRepo.save(quotation); + } + + async reject(ctx: ServiceContext, id: string): Promise { + const quotation = await this.findById(ctx, id); + if (!quotation) { + return null; + } + + if (quotation.status !== 'sent' && quotation.status !== 'draft') { + throw new Error('Only draft or sent quotations can be rejected'); + } + + quotation.status = 'rejected'; + quotation.updatedBy = ctx.userId; + + return this.quotationRepo.save(quotation); + } + + async markExpired(ctx: ServiceContext, id: string): Promise { + const quotation = await this.findById(ctx, id); + if (!quotation) { + return null; + } + + if (quotation.status === 'converted' || quotation.status === 'accepted') { + throw new Error('Cannot expire a converted or accepted quotation'); + } + + quotation.status = 'expired'; + quotation.updatedBy = ctx.userId; + + return this.quotationRepo.save(quotation); + } + + async markConverted(ctx: ServiceContext, id: string, orderId: string): Promise { + const quotation = await this.findById(ctx, id); + if (!quotation) { + return null; + } + + if (quotation.status !== 'accepted') { + throw new Error('Only accepted quotations can be converted to orders'); + } + + quotation.status = 'converted'; + quotation.convertedToOrder = true; + quotation.orderId = orderId; + quotation.convertedAt = new Date(); + quotation.updatedBy = ctx.userId; + + return this.quotationRepo.save(quotation); + } + + async duplicate(ctx: ServiceContext, id: string): Promise { + const original = await this.findByIdWithItems(ctx, id); + if (!original) { + return null; + } + + const items = (original as any).items as QuotationItem[] || []; + + const newQuotation = await this.create(ctx, { + partnerId: original.partnerId, + partnerName: original.partnerName, + partnerEmail: original.partnerEmail, + billingAddress: original.billingAddress, + shippingAddress: original.shippingAddress, + salesRepId: original.salesRepId, + currency: original.currency, + paymentTermDays: original.paymentTermDays, + paymentMethod: original.paymentMethod, + notes: original.notes, + internalNotes: original.internalNotes, + termsAndConditions: original.termsAndConditions, + items: items.map((item) => ({ + productId: item.productId, + productSku: item.productSku, + productName: item.productName, + description: item.description, + quantity: item.quantity, + uom: item.uom, + unitPrice: item.unitPrice, + discountPercent: item.discountPercent, + taxRate: item.taxRate, + })), + }); + + return newQuotation; + } + + async getStatistics(ctx: ServiceContext, dateFrom?: Date, dateTo?: Date): Promise<{ + totalQuotations: number; + draft: number; + sent: number; + accepted: number; + rejected: number; + expired: number; + converted: number; + totalValue: number; + acceptedValue: number; + conversionRate: number; + }> { + const queryBuilder = this.quotationRepo + .createQueryBuilder('q') + .where('q.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('q.deleted_at IS NULL'); + + if (dateFrom) { + queryBuilder.andWhere('q.quotation_date >= :dateFrom', { dateFrom }); + } + + if (dateTo) { + queryBuilder.andWhere('q.quotation_date <= :dateTo', { dateTo }); + } + + const quotations = await queryBuilder.getMany(); + + const draft = quotations.filter((q) => q.status === 'draft'); + const sent = quotations.filter((q) => q.status === 'sent'); + const accepted = quotations.filter((q) => q.status === 'accepted'); + const rejected = quotations.filter((q) => q.status === 'rejected'); + const expired = quotations.filter((q) => q.status === 'expired'); + const converted = quotations.filter((q) => q.status === 'converted'); + + const totalValue = quotations.reduce((sum, q) => sum + Number(q.total), 0); + const acceptedValue = [...accepted, ...converted].reduce((sum, q) => sum + Number(q.total), 0); + + const sentOrBetter = quotations.filter((q) => + q.status !== 'draft' + ).length; + + const conversionRate = sentOrBetter > 0 + ? ((accepted.length + converted.length) / sentOrBetter) * 100 + : 0; + + return { + totalQuotations: quotations.length, + draft: draft.length, + sent: sent.length, + accepted: accepted.length, + rejected: rejected.length, + expired: expired.length, + converted: converted.length, + totalValue, + acceptedValue, + conversionRate: Math.round(conversionRate * 100) / 100, + }; + } + + async softDelete(ctx: ServiceContext, id: string): Promise { + const quotation = await this.findById(ctx, id); + if (!quotation) { + return false; + } + + if (quotation.status === 'converted') { + throw new Error('Cannot delete a converted quotation'); + } + + const result = await this.quotationRepo.update( + { id, tenantId: ctx.tenantId }, + { deletedAt: new Date(), updatedBy: ctx.userId }, + ); + + return (result.affected ?? 0) > 0; + } +} diff --git a/src/modules/sales/services/sales-order.service.ts b/src/modules/sales/services/sales-order.service.ts new file mode 100644 index 0000000..8c35bba --- /dev/null +++ b/src/modules/sales/services/sales-order.service.ts @@ -0,0 +1,831 @@ +/** + * SalesOrderService - Servicio de órdenes de venta + * + * Gestión de órdenes de venta para unidades inmobiliarias + * (departamentos, lotes, viviendas). + * Soporta flujo Order-to-Cash completo. + * + * @module Sales (MAI-010) + */ + +import { Repository, DataSource } from 'typeorm'; +import { SalesOrder } from '../entities/sales-order.entity'; +import { SalesOrderItem } from '../entities/sales-order-item.entity'; +import { Quotation } from '../entities/quotation.entity'; +import { QuotationItem } from '../entities/quotation-item.entity'; + +interface ServiceContext { + tenantId: string; + userId?: string; + companyId?: string; +} + +export interface CreateSalesOrderDto { + quotationId?: string; + partnerId: string; + clientOrderRef?: string; + orderDate?: Date; + validityDate?: Date; + commitmentDate?: Date; + currencyId: string; + pricelistId?: string; + paymentTermId?: string; + userId?: string; + salesTeamId?: string; + invoicePolicy?: 'order' | 'delivery'; + notes?: string; + termsConditions?: string; + items?: CreateSalesOrderItemDto[]; +} + +export interface UpdateSalesOrderDto { + clientOrderRef?: string; + validityDate?: Date; + commitmentDate?: Date; + pricelistId?: string; + paymentTermId?: string; + userId?: string; + salesTeamId?: string; + invoicePolicy?: 'order' | 'delivery'; + notes?: string; + termsConditions?: string; +} + +export interface CreateSalesOrderItemDto { + productId?: string; + productSku?: string; + productName: string; + description?: string; + quantity: number; + uom?: string; + unitPrice: number; + unitCost?: number; + discountPercent?: number; + taxRate?: number; + lotNumber?: string; + serialNumber?: string; +} + +export interface UpdateSalesOrderItemDto { + productName?: string; + description?: string; + quantity?: number; + uom?: string; + unitPrice?: number; + unitCost?: number; + discountPercent?: number; + taxRate?: number; + lotNumber?: string; + serialNumber?: string; +} + +export interface SalesOrderFilters { + partnerId?: string; + userId?: string; + salesTeamId?: string; + status?: string; + invoiceStatus?: string; + deliveryStatus?: string; + dateFrom?: Date; + dateTo?: Date; + minTotal?: number; + maxTotal?: number; + search?: string; + page?: number; + limit?: number; +} + +export class SalesOrderService { + private readonly orderRepo: Repository; + private readonly itemRepo: Repository; + private readonly quotationRepo: Repository; + private readonly quotationItemRepo: Repository; + private readonly dataSource: DataSource; + + constructor(dataSource: DataSource) { + this.dataSource = dataSource; + this.orderRepo = dataSource.getRepository(SalesOrder); + this.itemRepo = dataSource.getRepository(SalesOrderItem); + this.quotationRepo = dataSource.getRepository(Quotation); + this.quotationItemRepo = dataSource.getRepository(QuotationItem); + } + + async findAll( + ctx: ServiceContext, + filters: SalesOrderFilters = {}, + ): Promise<{ data: SalesOrder[]; total: number; page: number; limit: number }> { + const page = filters.page || 1; + const limit = filters.limit || 50; + const skip = (page - 1) * limit; + + const queryBuilder = this.orderRepo + .createQueryBuilder('o') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL'); + + if (filters.partnerId) { + queryBuilder.andWhere('o.partner_id = :partnerId', { partnerId: filters.partnerId }); + } + + if (filters.userId) { + queryBuilder.andWhere('o.user_id = :userId', { userId: filters.userId }); + } + + if (filters.salesTeamId) { + queryBuilder.andWhere('o.sales_team_id = :salesTeamId', { salesTeamId: filters.salesTeamId }); + } + + if (filters.status) { + queryBuilder.andWhere('o.status = :status', { status: filters.status }); + } + + if (filters.invoiceStatus) { + queryBuilder.andWhere('o.invoice_status = :invoiceStatus', { invoiceStatus: filters.invoiceStatus }); + } + + if (filters.deliveryStatus) { + queryBuilder.andWhere('o.delivery_status = :deliveryStatus', { deliveryStatus: filters.deliveryStatus }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('o.order_date >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('o.order_date <= :dateTo', { dateTo: filters.dateTo }); + } + + if (filters.minTotal !== undefined) { + queryBuilder.andWhere('o.amount_total >= :minTotal', { minTotal: filters.minTotal }); + } + + if (filters.maxTotal !== undefined) { + queryBuilder.andWhere('o.amount_total <= :maxTotal', { maxTotal: filters.maxTotal }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(o.name ILIKE :search OR o.client_order_ref ILIKE :search)', + { search: `%${filters.search}%` }, + ); + } + + const [data, total] = await queryBuilder + .orderBy('o.created_at', 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.orderRepo.findOne({ + where: { + id, + tenantId: ctx.tenantId, + }, + }); + } + + async findByIdWithItems(ctx: ServiceContext, id: string): Promise { + const order = await this.findById(ctx, id); + if (!order) { + return null; + } + + const items = await this.itemRepo.find({ + where: { orderId: id }, + order: { lineNumber: 'ASC' }, + }); + + return { ...order, items } as SalesOrder & { items: SalesOrderItem[] }; + } + + async findByName(ctx: ServiceContext, name: string): Promise { + return this.orderRepo.findOne({ + where: { + name, + tenantId: ctx.tenantId, + }, + }); + } + + async findByPartner(ctx: ServiceContext, partnerId: string): Promise { + return this.orderRepo + .createQueryBuilder('o') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .andWhere('o.partner_id = :partnerId', { partnerId }) + .orderBy('o.created_at', 'DESC') + .getMany(); + } + + async findByQuotation(ctx: ServiceContext, quotationId: string): Promise { + return this.orderRepo.findOne({ + where: { + quotationId, + tenantId: ctx.tenantId, + }, + }); + } + + async findPendingDelivery(ctx: ServiceContext): Promise { + return this.orderRepo + .createQueryBuilder('o') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .andWhere('o.status = :status', { status: 'sale' }) + .andWhere('o.delivery_status IN (:...statuses)', { statuses: ['pending', 'partial'] }) + .orderBy('o.commitment_date', 'ASC') + .getMany(); + } + + async findPendingInvoice(ctx: ServiceContext): Promise { + return this.orderRepo + .createQueryBuilder('o') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .andWhere('o.status = :status', { status: 'sale' }) + .andWhere('o.invoice_status IN (:...statuses)', { statuses: ['pending', 'partial'] }) + .orderBy('o.order_date', 'ASC') + .getMany(); + } + + private async generateOrderName(ctx: ServiceContext): Promise { + const year = new Date().getFullYear(); + const prefix = `SO-${year}-`; + + const lastOrder = await this.orderRepo + .createQueryBuilder('o') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.name LIKE :prefix', { prefix: `${prefix}%` }) + .orderBy('o.name', 'DESC') + .getOne(); + + let sequence = 1; + if (lastOrder) { + const lastNumber = lastOrder.name.replace(prefix, ''); + sequence = parseInt(lastNumber, 10) + 1; + } + + return `${prefix}${sequence.toString().padStart(5, '0')}`; + } + + private calculateItemTotals(item: CreateSalesOrderItemDto): { + discountAmount: number; + taxAmount: number; + subtotal: number; + total: number; + } { + const quantity = Number(item.quantity) || 0; + const unitPrice = Number(item.unitPrice) || 0; + const discountPercent = Number(item.discountPercent) || 0; + const taxRate = Number(item.taxRate) ?? 16; + + const grossAmount = quantity * unitPrice; + const discountAmount = grossAmount * (discountPercent / 100); + const subtotal = grossAmount - discountAmount; + const taxAmount = subtotal * (taxRate / 100); + const total = subtotal + taxAmount; + + return { + discountAmount: Math.round(discountAmount * 100) / 100, + taxAmount: Math.round(taxAmount * 100) / 100, + subtotal: Math.round(subtotal * 100) / 100, + total: Math.round(total * 100) / 100, + }; + } + + private calculateOrderTotals(items: Array<{ subtotal: number; taxAmount: number }>): { + amountUntaxed: number; + amountTax: number; + amountTotal: number; + } { + const amountUntaxed = items.reduce((sum, item) => sum + Number(item.subtotal), 0); + const amountTax = items.reduce((sum, item) => sum + Number(item.taxAmount), 0); + const amountTotal = amountUntaxed + amountTax; + + return { + amountUntaxed: Math.round(amountUntaxed * 100) / 100, + amountTax: Math.round(amountTax * 100) / 100, + amountTotal: Math.round(amountTotal * 100) / 100, + }; + } + + async create(ctx: ServiceContext, dto: CreateSalesOrderDto): Promise { + const orderName = await this.generateOrderName(ctx); + + return this.dataSource.transaction(async (manager) => { + const orderRepo = manager.getRepository(SalesOrder); + const itemRepo = manager.getRepository(SalesOrderItem); + + const order = orderRepo.create({ + tenantId: ctx.tenantId, + companyId: ctx.companyId || ctx.tenantId, + createdBy: ctx.userId, + name: orderName, + quotationId: dto.quotationId, + partnerId: dto.partnerId, + clientOrderRef: dto.clientOrderRef, + orderDate: dto.orderDate ?? new Date(), + validityDate: dto.validityDate, + commitmentDate: dto.commitmentDate, + currencyId: dto.currencyId, + pricelistId: dto.pricelistId, + paymentTermId: dto.paymentTermId, + userId: dto.userId ?? ctx.userId, + salesTeamId: dto.salesTeamId, + invoicePolicy: dto.invoicePolicy ?? 'order', + notes: dto.notes, + termsConditions: dto.termsConditions, + status: 'draft', + invoiceStatus: 'pending', + deliveryStatus: 'pending', + amountUntaxed: 0, + amountTax: 0, + amountTotal: 0, + }); + + const savedOrder = await orderRepo.save(order); + + if (dto.items && dto.items.length > 0) { + const itemEntities: SalesOrderItem[] = []; + + for (let i = 0; i < dto.items.length; i++) { + const itemDto = dto.items[i]; + const calculated = this.calculateItemTotals(itemDto); + + const item = itemRepo.create({ + orderId: savedOrder.id, + lineNumber: i + 1, + productId: itemDto.productId, + productSku: itemDto.productSku, + productName: itemDto.productName, + description: itemDto.description, + quantity: itemDto.quantity, + quantityReserved: 0, + quantityShipped: 0, + quantityDelivered: 0, + quantityReturned: 0, + uom: itemDto.uom ?? 'PZA', + unitPrice: itemDto.unitPrice, + unitCost: itemDto.unitCost ?? 0, + discountPercent: itemDto.discountPercent ?? 0, + taxRate: itemDto.taxRate ?? 16, + lotNumber: itemDto.lotNumber, + serialNumber: itemDto.serialNumber, + status: 'pending', + ...calculated, + }); + + itemEntities.push(item); + } + + await itemRepo.save(itemEntities); + + const totals = this.calculateOrderTotals(itemEntities); + savedOrder.amountUntaxed = totals.amountUntaxed; + savedOrder.amountTax = totals.amountTax; + savedOrder.amountTotal = totals.amountTotal; + + await orderRepo.save(savedOrder); + } + + return savedOrder; + }); + } + + async createFromQuotation(ctx: ServiceContext, quotationId: string): Promise { + const quotation = await this.quotationRepo.findOne({ + where: { id: quotationId, tenantId: ctx.tenantId }, + }); + + if (!quotation) { + return null; + } + + if (quotation.status !== 'accepted') { + throw new Error('Only accepted quotations can be converted to orders'); + } + + const quotationItems = await this.quotationItemRepo.find({ + where: { quotationId }, + order: { lineNumber: 'ASC' }, + }); + + const order = await this.create(ctx, { + quotationId, + partnerId: quotation.partnerId, + currencyId: quotation.currency, + paymentTermId: undefined, + notes: quotation.notes ?? undefined, + termsConditions: quotation.termsAndConditions ?? undefined, + items: quotationItems.map((item) => ({ + productId: item.productId, + productSku: item.productSku, + productName: item.productName, + description: item.description, + quantity: item.quantity, + uom: item.uom, + unitPrice: item.unitPrice, + discountPercent: item.discountPercent, + taxRate: item.taxRate, + })), + }); + + quotation.status = 'converted'; + quotation.convertedToOrder = true; + quotation.orderId = order.id; + quotation.convertedAt = new Date(); + quotation.updatedBy = ctx.userId; + + await this.quotationRepo.save(quotation); + + return order; + } + + async update(ctx: ServiceContext, id: string, dto: UpdateSalesOrderDto): Promise { + const order = await this.findById(ctx, id); + if (!order) { + return null; + } + + if (order.status !== 'draft' && order.status !== 'sent') { + throw new Error('Can only update draft or sent orders'); + } + + Object.assign(order, { + ...dto, + updatedBy: ctx.userId, + }); + + return this.orderRepo.save(order); + } + + async addItem(ctx: ServiceContext, orderId: string, dto: CreateSalesOrderItemDto): Promise { + const order = await this.findById(ctx, orderId); + if (!order) { + return null; + } + + if (order.status !== 'draft' && order.status !== 'sent') { + throw new Error('Can only add items to draft or sent orders'); + } + + return this.dataSource.transaction(async (manager) => { + const itemRepo = manager.getRepository(SalesOrderItem); + + const lastItem = await itemRepo + .createQueryBuilder('i') + .where('i.order_id = :orderId', { orderId }) + .orderBy('i.line_number', 'DESC') + .getOne(); + + const lineNumber = (lastItem?.lineNumber ?? 0) + 1; + const calculated = this.calculateItemTotals(dto); + + const item = itemRepo.create({ + orderId, + lineNumber, + productId: dto.productId, + productSku: dto.productSku, + productName: dto.productName, + description: dto.description, + quantity: dto.quantity, + quantityReserved: 0, + quantityShipped: 0, + quantityDelivered: 0, + quantityReturned: 0, + uom: dto.uom ?? 'PZA', + unitPrice: dto.unitPrice, + unitCost: dto.unitCost ?? 0, + discountPercent: dto.discountPercent ?? 0, + taxRate: dto.taxRate ?? 16, + lotNumber: dto.lotNumber, + serialNumber: dto.serialNumber, + status: 'pending', + ...calculated, + }); + + const savedItem = await itemRepo.save(item); + + await this.recalculateOrderTotals(manager, orderId); + + return savedItem; + }); + } + + async updateItem( + ctx: ServiceContext, + orderId: string, + itemId: string, + dto: UpdateSalesOrderItemDto, + ): Promise { + const order = await this.findById(ctx, orderId); + if (!order) { + return null; + } + + if (order.status !== 'draft' && order.status !== 'sent') { + throw new Error('Can only update items on draft or sent orders'); + } + + const item = await this.itemRepo.findOne({ + where: { id: itemId, orderId }, + }); + + if (!item) { + return null; + } + + return this.dataSource.transaction(async (manager) => { + const itemRepo = manager.getRepository(SalesOrderItem); + + Object.assign(item, dto); + + const calculated = this.calculateItemTotals({ + quantity: item.quantity, + unitPrice: item.unitPrice, + discountPercent: item.discountPercent, + taxRate: item.taxRate, + productName: item.productName, + }); + + Object.assign(item, calculated); + + const savedItem = await itemRepo.save(item); + + await this.recalculateOrderTotals(manager, orderId); + + return savedItem; + }); + } + + async removeItem(ctx: ServiceContext, orderId: string, itemId: string): Promise { + const order = await this.findById(ctx, orderId); + if (!order) { + return false; + } + + if (order.status !== 'draft' && order.status !== 'sent') { + throw new Error('Can only remove items from draft or sent orders'); + } + + return this.dataSource.transaction(async (manager) => { + const itemRepo = manager.getRepository(SalesOrderItem); + + const result = await itemRepo.delete({ id: itemId, orderId }); + if (!result.affected) { + return false; + } + + await this.recalculateOrderTotals(manager, orderId); + + return true; + }); + } + + private async recalculateOrderTotals( + manager: any, + orderId: string, + ): Promise { + const orderRepo = manager.getRepository(SalesOrder); + const itemRepo = manager.getRepository(SalesOrderItem); + + const items = await itemRepo.find({ + where: { orderId }, + }); + + const totals = this.calculateOrderTotals(items); + + await orderRepo.update({ id: orderId }, totals); + } + + async confirm(ctx: ServiceContext, id: string): Promise { + const order = await this.findById(ctx, id); + if (!order) { + return null; + } + + if (order.status !== 'draft' && order.status !== 'sent') { + throw new Error('Can only confirm draft or sent orders'); + } + + order.status = 'sale'; + order.confirmedAt = new Date(); + order.confirmedBy = ctx.userId ?? null; + order.updatedBy = ctx.userId ?? null; + + return this.orderRepo.save(order); + } + + async send(ctx: ServiceContext, id: string): Promise { + const order = await this.findById(ctx, id); + if (!order) { + return null; + } + + if (order.status !== 'draft') { + throw new Error('Only draft orders can be sent'); + } + + order.status = 'sent'; + order.updatedBy = ctx.userId ?? null; + + return this.orderRepo.save(order); + } + + async cancel(ctx: ServiceContext, id: string): Promise { + const order = await this.findById(ctx, id); + if (!order) { + return null; + } + + if (order.status === 'done' || order.status === 'cancelled') { + throw new Error('Cannot cancel a completed or already cancelled order'); + } + + if (order.deliveryStatus === 'delivered') { + throw new Error('Cannot cancel an order that has been fully delivered'); + } + + if (order.invoiceStatus === 'invoiced') { + throw new Error('Cannot cancel an order that has been fully invoiced'); + } + + order.status = 'cancelled'; + order.cancelledAt = new Date(); + order.cancelledBy = ctx.userId ?? null; + order.updatedBy = ctx.userId ?? null; + + await this.itemRepo.update( + { orderId: id }, + { status: 'cancelled' }, + ); + + return this.orderRepo.save(order); + } + + async markDone(ctx: ServiceContext, id: string): Promise { + const order = await this.findById(ctx, id); + if (!order) { + return null; + } + + if (order.status !== 'sale') { + throw new Error('Only confirmed orders can be marked as done'); + } + + order.status = 'done'; + order.updatedBy = ctx.userId ?? null; + + return this.orderRepo.save(order); + } + + async updateDeliveryStatus(ctx: ServiceContext, id: string): Promise { + const order = await this.findByIdWithItems(ctx, id); + if (!order) { + return null; + } + + const items = (order as any).items as SalesOrderItem[]; + + const totalQuantity = items.reduce((sum, item) => sum + Number(item.quantity), 0); + const deliveredQuantity = items.reduce((sum, item) => sum + Number(item.quantityDelivered), 0); + + let deliveryStatus: 'pending' | 'partial' | 'delivered'; + if (deliveredQuantity === 0) { + deliveryStatus = 'pending'; + } else if (deliveredQuantity >= totalQuantity) { + deliveryStatus = 'delivered'; + } else { + deliveryStatus = 'partial'; + } + + order.deliveryStatus = deliveryStatus; + order.updatedBy = ctx.userId ?? null; + + return this.orderRepo.save(order); + } + + async updateInvoiceStatus(ctx: ServiceContext, id: string, status: 'pending' | 'partial' | 'invoiced'): Promise { + const order = await this.findById(ctx, id); + if (!order) { + return null; + } + + order.invoiceStatus = status; + order.updatedBy = ctx.userId ?? null; + + return this.orderRepo.save(order); + } + + async updateItemDelivery( + ctx: ServiceContext, + orderId: string, + itemId: string, + quantityDelivered: number, + ): Promise { + const item = await this.itemRepo.findOne({ + where: { id: itemId, orderId }, + }); + + if (!item) { + return null; + } + + if (quantityDelivered > item.quantity) { + throw new Error('Delivered quantity cannot exceed ordered quantity'); + } + + item.quantityDelivered = quantityDelivered; + item.status = quantityDelivered >= item.quantity ? 'delivered' : 'shipped'; + + const savedItem = await this.itemRepo.save(item); + + await this.updateDeliveryStatus(ctx, orderId); + + return savedItem; + } + + async getStatistics(ctx: ServiceContext, dateFrom?: Date, dateTo?: Date): Promise<{ + totalOrders: number; + draft: number; + sent: number; + confirmed: number; + done: number; + cancelled: number; + totalRevenue: number; + pendingDelivery: number; + pendingInvoice: number; + averageOrderValue: number; + }> { + const queryBuilder = this.orderRepo + .createQueryBuilder('o') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL'); + + if (dateFrom) { + queryBuilder.andWhere('o.order_date >= :dateFrom', { dateFrom }); + } + + if (dateTo) { + queryBuilder.andWhere('o.order_date <= :dateTo', { dateTo }); + } + + const orders = await queryBuilder.getMany(); + + const draft = orders.filter((o) => o.status === 'draft'); + const sent = orders.filter((o) => o.status === 'sent'); + const confirmed = orders.filter((o) => o.status === 'sale'); + const done = orders.filter((o) => o.status === 'done'); + const cancelled = orders.filter((o) => o.status === 'cancelled'); + + const activeOrders = orders.filter((o) => o.status !== 'cancelled' && o.status !== 'draft'); + const totalRevenue = activeOrders.reduce((sum, o) => sum + Number(o.amountTotal), 0); + + const pendingDelivery = orders.filter( + (o) => o.status === 'sale' && (o.deliveryStatus === 'pending' || o.deliveryStatus === 'partial'), + ).length; + + const pendingInvoice = orders.filter( + (o) => o.status === 'sale' && (o.invoiceStatus === 'pending' || o.invoiceStatus === 'partial'), + ).length; + + const averageOrderValue = activeOrders.length > 0 + ? totalRevenue / activeOrders.length + : 0; + + return { + totalOrders: orders.length, + draft: draft.length, + sent: sent.length, + confirmed: confirmed.length, + done: done.length, + cancelled: cancelled.length, + totalRevenue, + pendingDelivery, + pendingInvoice, + averageOrderValue: Math.round(averageOrderValue * 100) / 100, + }; + } + + async softDelete(ctx: ServiceContext, id: string): Promise { + const order = await this.findById(ctx, id); + if (!order) { + return false; + } + + if (order.status === 'sale' || order.status === 'done') { + throw new Error('Cannot delete confirmed or completed orders'); + } + + const result = await this.orderRepo.update( + { id, tenantId: ctx.tenantId }, + { deletedAt: new Date(), updatedBy: ctx.userId }, + ); + + return (result.affected ?? 0) > 0; + } +}