diff --git a/PLAN_RENOVACIONES.md b/PLAN_RENOVACIONES.md new file mode 100644 index 0000000..bcab74a --- /dev/null +++ b/PLAN_RENOVACIONES.md @@ -0,0 +1,681 @@ +# Plan de Trabajo — Módulo de Renovaciones y Contratos + +> Sistema completo de gestión de servicios, clientes, contratos y notificaciones automáticas de vencimiento. + +--- + +## Índice + +1. [Visión General](#1-visión-general) +2. [Arquitectura de Datos](#2-arquitectura-de-datos) +3. [Módulos del Sistema](#3-módulos-del-sistema) +4. [Flujo de Trabajo](#4-flujo-de-trabajo) +5. [Sistema de Notificaciones](#5-sistema-de-notificaciones) +6. [Plantillas de Correo](#6-plantillas-de-correo) +7. [Configuración SMTP](#7-configuración-smtp) +8. [Plan de Implementación por Fases](#8-plan-de-implementación-por-fases) +9. [Estructura de Archivos](#9-estructura-de-archivos) +10. [API Endpoints](#10-api-endpoints) + +--- + +## 1. Visión General + +El módulo de **Renovaciones** permite a la empresa gestionar el ciclo de vida completo de sus servicios vendidos a clientes: creación de catálogo, asignación a clientes con fechas de vencimiento, envío automático y manual de correos de cobro/renovación, y configuración total de notificaciones programadas. + +### Capacidades principales + +| Capacidad | Descripción | +|-----------|-------------| +| Catálogo de servicios | Crear servicios con precio, tipo (renovable/único), periodicidad | +| Gestión de clientes | CRUD completo de clientes con datos de contacto | +| Contratos / Asignaciones | Relacionar clientes con servicios, definir fechas de inicio y vencimiento | +| Agrupación de correos | Si varios servicios vencen la misma fecha → un solo correo con el total | +| Notificaciones programadas | Configurar N días antes del vencimiento, con cron interno | +| Editor de plantillas | Editor visual con preview en tiempo real | +| Configuración SMTP | Panel para cambiar servidor de correo sin reiniciar | + +--- + +## 2. Arquitectura de Datos + +### Modelo Entidad-Relación + +``` +clientes ─────────── contratos ─────────── servicios + │ │ │ + │ ├── fecha_inicio ├── tipo: renovable / unico + │ ├── fecha_vencimiento ├── precio + │ ├── estado ├── periodicidad (mensual/anual/etc) + │ └── notas └── descripcion + │ + └── email, telefono, empresa, ... + +contratos ──── notificaciones_log + ├── fecha_envio + ├── tipo (aviso / vencimiento / recordatorio) + └── estado (enviado / fallido / pendiente) + +smtp_config (tabla singleton) +plantillas_correo +notificacion_reglas (días antes, activo, plantilla_id) +``` + +### Tablas SQL + +```sql +-- Servicios / Productos +CREATE TABLE servicios ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + nombre TEXT NOT NULL, + descripcion TEXT, + precio NUMERIC(12,2) NOT NULL DEFAULT 0, + moneda TEXT NOT NULL DEFAULT 'COP', + tipo TEXT NOT NULL DEFAULT 'renovable', -- 'renovable' | 'unico' + periodicidad TEXT, -- 'mensual' | 'trimestral' | 'semestral' | 'anual' | NULL + activo BOOLEAN NOT NULL DEFAULT true +); + +-- Clientes +CREATE TABLE clientes ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + nombre TEXT NOT NULL, + empresa TEXT, + email TEXT NOT NULL, + email_cc TEXT, -- correos adicionales separados por coma + telefono TEXT, + documento TEXT, + notas TEXT, + activo BOOLEAN NOT NULL DEFAULT true +); + +-- Contratos / Asignaciones +CREATE TABLE contratos ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + cliente_id BIGINT NOT NULL REFERENCES clientes(id), + servicio_id BIGINT NOT NULL REFERENCES servicios(id), + fecha_inicio DATE NOT NULL, + fecha_vencimiento DATE NOT NULL, + precio_acordado NUMERIC(12,2), -- puede diferir del precio base + estado TEXT NOT NULL DEFAULT 'activo', -- 'activo' | 'vencido' | 'cancelado' | 'renovado' + auto_renovar BOOLEAN NOT NULL DEFAULT false, + notas TEXT +); + +-- Reglas de notificación (configurable por días antes del vencimiento) +CREATE TABLE notificacion_reglas ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + nombre TEXT NOT NULL, -- ej: "Aviso 30 días antes" + dias_antes INT NOT NULL, -- ej: 30, 15, 7, 1 + plantilla_id BIGINT REFERENCES plantillas_correo(id), + activo BOOLEAN NOT NULL DEFAULT true, + aplica_a TEXT NOT NULL DEFAULT 'todos' -- 'todos' | 'renovable' | 'unico' +); + +-- Plantillas de correo +CREATE TABLE plantillas_correo ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + nombre TEXT NOT NULL, + asunto TEXT NOT NULL, + cuerpo_html TEXT NOT NULL, -- soporta variables: {{.ClienteNombre}}, {{.Servicios}}, {{.Total}}, etc. + tipo TEXT NOT NULL DEFAULT 'renovacion' -- 'renovacion' | 'vencimiento' | 'pago' | 'personalizado' +); + +-- Log de notificaciones enviadas +CREATE TABLE notificaciones_log ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + cliente_id BIGINT REFERENCES clientes(id), + regla_id BIGINT REFERENCES notificacion_reglas(id), + contratos_ids TEXT, -- JSON array de IDs agrupados + fecha_envio TIMESTAMPTZ, + estado TEXT NOT NULL DEFAULT 'pendiente', -- 'enviado' | 'fallido' | 'pendiente' + error_msg TEXT, + asunto TEXT, + preview_html TEXT -- copia del correo enviado +); + +-- Configuración SMTP (singleton, solo 1 fila activa) +CREATE TABLE smtp_config ( + id BIGSERIAL PRIMARY KEY, + updated_at TIMESTAMPTZ, + host TEXT NOT NULL, + port INT NOT NULL DEFAULT 587, + username TEXT NOT NULL, + password TEXT NOT NULL, -- almacenado cifrado (AES-256) + encryption TEXT NOT NULL DEFAULT 'tls', -- 'tls' | 'ssl' | 'none' + from_address TEXT NOT NULL, + from_name TEXT NOT NULL, + activo BOOLEAN NOT NULL DEFAULT true +); +``` + +--- + +## 3. Módulos del Sistema + +### 3.1 Catálogo de Servicios (`/app/servicios`) + +**Funcionalidades:** +- Listar servicios con filtro por tipo y estado +- Crear/editar/eliminar servicio +- Campos: nombre, descripción, precio, moneda, tipo (renovable | único), periodicidad +- Badge visual diferenciando renovables de únicos +- Indicador de cuántos contratos activos tiene cada servicio + +**Campos del formulario:** + +| Campo | Tipo | Requerido | Notas | +|-------|------|-----------|-------| +| Nombre | texto | ✅ | | +| Descripción | textarea | ❌ | | +| Precio | decimal | ✅ | | +| Moneda | select | ✅ | COP / USD / EUR | +| Tipo | radio | ✅ | Renovable / Único | +| Periodicidad | select | si renovable | Mensual / Trimestral / Semestral / Anual | +| Activo | toggle | ✅ | | + +--- + +### 3.2 Clientes (`/app/clientes`) + +**Funcionalidades:** +- CRUD completo de clientes +- Vista detalle del cliente con todos sus contratos activos/vencidos +- Historial de correos enviados al cliente +- Múltiples emails CC por cliente + +**Campos del formulario:** + +| Campo | Tipo | Requerido | +|-------|------|-----------| +| Nombre completo | texto | ✅ | +| Empresa / Razón social | texto | ❌ | +| Email principal | email | ✅ | +| Emails CC | texto (separados por coma) | ❌ | +| Teléfono / WhatsApp | texto | ❌ | +| Documento (NIT/CC) | texto | ❌ | +| Notas internas | textarea | ❌ | +| Activo | toggle | ✅ | + +--- + +### 3.3 Contratos / Asignaciones (`/app/contratos`) + +**Vista principal:** +- Tabla con columnas: Cliente, Servicio, Fecha inicio, Fecha vencimiento, Estado, Días restantes +- Filtros: por cliente, por servicio, por estado, por rango de fechas +- Indicador visual de urgencia (verde > 30 días, amarillo 1-30 días, rojo vencido) +- Botón "Renovar" que crea un nuevo contrato a partir del vencido + +**Formulario de asignación:** + +| Campo | Tipo | Notas | +|-------|------|-------| +| Cliente | select buscable | autocompletado | +| Servicio | select buscable | muestra precio base | +| Fecha inicio | date | default: hoy | +| Fecha vencimiento | date | calculada automáticamente según periodicidad | +| Precio acordado | decimal | editable, pre-rellena con precio del servicio | +| Auto-renovar | toggle | crea nuevo contrato automáticamente al vencer | +| Notas | textarea | | + +**Vista detalle del contrato:** +- Historial de renovaciones anteriores +- Timeline de notificaciones enviadas +- Botón "Enviar correo manual" +- Botón "Renovar ahora" + +--- + +### 3.4 Reglas de Notificación (`/app/notificaciones/reglas`) + +Permite configurar cuándo y qué correo enviar antes del vencimiento. + +**Ejemplos de reglas:** + +| Regla | Días antes | Plantilla | Aplica a | +|-------|-----------|-----------|----------| +| Aviso temprano | 30 | Plantilla "Renovación próxima" | Renovables | +| Recordatorio | 7 | Plantilla "Vence en 7 días" | Todos | +| Último aviso | 1 | Plantilla "Vence mañana" | Todos | +| Vencido | 0 | Plantilla "Servicio vencido" | Todos | + +**Campos:** + +| Campo | Tipo | Notas | +|-------|------|-------| +| Nombre de la regla | texto | | +| Días antes del vencimiento | número | 0 = día del vencimiento | +| Plantilla de correo | select | | +| Aplica a | select | Todos / Solo renovables / Solo únicos | +| Activo | toggle | | + +--- + +### 3.5 Plantillas de Correo (`/app/notificaciones/plantillas`) + +**Editor visual con:** +- Editor de HTML (textarea con resaltado) +- Panel de variables disponibles (click to insert) +- Preview en tiempo real lado a lado +- Envío de correo de prueba a email específico + +**Variables disponibles en plantillas:** + +``` +{{.ClienteNombre}} → Nombre del cliente +{{.ClienteEmpresa}} → Empresa del cliente +{{.Servicios}} → Tabla HTML de servicios (nombre, vencimiento, precio) +{{.Total}} → Total en moneda local +{{.FechaVencimiento}} → Fecha de vencimiento (la más próxima si hay varias) +{{.DiasRestantes}} → Días que faltan para vencer +{{.LinkPago}} → URL de pago (configurable) +{{.EmpresaNombre}} → Nombre de la empresa (desde config) +{{.FechaActual}} → Fecha de hoy +``` + +**Tipo de plantillas:** + +| Tipo | Uso | +|------|-----| +| `renovacion` | Aviso de renovación próxima | +| `vencimiento` | El servicio está a punto de vencer / vencido | +| `pago` | Confirmación o solicitud de pago | +| `personalizado` | Uso libre / envío manual | + +--- + +### 3.6 Historial de Envíos (`/app/notificaciones/historial`) + +- Tabla con todos los correos enviados +- Filtro por cliente, fecha, estado (enviado/fallido) +- Ver HTML del correo enviado (modal preview) +- Reenviar correo fallido con un clic + +--- + +### 3.7 Configuración SMTP (`/app/configuracion/smtp`) + +Panel para configurar el servidor de correo saliente sin tocar archivos. + +**Campos:** + +| Campo | Tipo | Default | +|-------|------|---------| +| Host SMTP | texto | smtp.gmail.com | +| Puerto | número | 587 | +| Usuario | email | | +| Contraseña | password (cifrada) | | +| Cifrado | select | TLS / SSL / Ninguno | +| Nombre remitente | texto | U-site | +| Email remitente | email | | + +- Botón **"Probar conexión"** — envía correo de prueba y muestra resultado +- La config se guarda en BD (cifrada) y sobrescribe la del `config.yml` en runtime + +--- + +## 4. Flujo de Trabajo + +### Flujo de creación de un contrato + +``` +1. Crear servicio en catálogo + ↓ +2. Crear/seleccionar cliente + ↓ +3. Crear contrato (asignar servicio + fechas + precio) + ↓ +4. Sistema calcula automáticamente + cuándo disparar notificaciones + según reglas configuradas + ↓ +5. Cron diario evalúa contratos + y dispara correos agrupados +``` + +### Lógica de agrupación de correos + +``` +Para cada cliente con contratos próximos a vencer: + │ + ├── mismo_dia = contratos donde fecha_vencimiento == misma fecha + │ └── → UN solo correo con tabla de servicios + total sumado + │ + └── fechas_distintas = contratos con fechas diferentes + └── → UN correo por cada fecha de vencimiento distinta +``` + +**Ejemplo práctico:** +- Cliente "Empresa ABC" tiene: + - Dominio → vence 15 mayo → correo independiente + - Hosting + SSL → ambos vencen 15 mayo → **un solo correo con total combinado** + - Mantenimiento → vence 30 mayo → correo independiente + +--- + +## 5. Sistema de Notificaciones + +### Arquitectura del Cron + +El sistema usa un **cron interno en Go** (librería `robfig/cron`) que se inicia al arrancar la aplicación. + +```go +// Se ejecuta diariamente a las 8:00 AM +cron.AddFunc("0 8 * * *", jobs.ProcesarVencimientos) +``` + +### Job: `ProcesarVencimientos` + +``` +1. Obtener todas las reglas activas (ordenadas por dias_antes) +2. Para cada regla: + a. Buscar contratos donde: + - estado = 'activo' + - fecha_vencimiento = HOY + dias_antes + - NO exista ya un envío del mismo tipo en notificaciones_log + b. Agrupar contratos encontrados por cliente + fecha_vencimiento + c. Para cada grupo: + - Renderizar plantilla con datos del grupo + - Enviar correo (con CC si aplica) + - Registrar en notificaciones_log +3. Si auto_renovar = true y estado = vencido: + - Crear nuevo contrato con nueva fecha de vencimiento + - Actualizar estado del anterior a 'renovado' +``` + +### Estados de un contrato + +``` +activo ──────────── (cron notifica) ──────────── vence + │ │ + ├── renovado (si auto_renovar o manual) │ + │ │ + └── cancelado (manual) vencido +``` + +--- + +## 6. Plantillas de Correo + +### Variables en el cuerpo HTML + +El motor de plantillas usa `html/template` de Go (ya usado en el proyecto). + +### Plantilla base sugerida — Renovación + +```html + + + +
+ +
+

U-site

+
+ +
+

Hola {{.ClienteNombre}},

+

Te recordamos que los siguientes servicios vencen próximamente:

+ + + + + + + + + + + + {{range .Servicios}} + + + + + + {{end}} + + + + + + + +
ServicioVencimientoValor
{{.Nombre}}{{.FechaVencimiento}}{{.Precio}}
Total{{.Total}}
+ +

+ + Renovar ahora → + +

+
+ +
+ © 2026 U-site — {{.EmpresaNombre}} +
+
+ + +``` + +### Preview en tiempo real + +- Frontend: Alpine.js + ` + + + +
+ + + diff --git a/resources/views/renovaciones/plantillas.html b/resources/views/renovaciones/plantillas.html new file mode 100644 index 0000000..1c80cc0 --- /dev/null +++ b/resources/views/renovaciones/plantillas.html @@ -0,0 +1,269 @@ +
+
+ Cargando... +
+ +
+
+
+

Plantillas de Correo

+

Editor de plantillas HTML para notificaciones

+
+
+ + +
+
+ +
+ + + + + + + + + + + + + + + +
NombreAsuntoTipo
Sin plantillas
+
+ +
+ Total: +
+ + + +
+
+
+ + +
+
+

+ + +
+ Variables disponibles: + {{.ClienteNombre}}, + {{.ClienteEmpresa}}, + {{.FechaVencimiento}}, + {{.DiasRestantes}}, + {{.Total}}, + {{range .Servicios}}...{{.Nombre}} {{.Precio}}...{{end}} +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+
+

Vista previa

+ +
+ +
+
+ + +
+
+

Enviar correo de prueba

+ + +
+ + +
+
+
+ + +
+
+

¿Eliminar plantilla?

+

Esta acción no se puede deshacer.

+
+ + +
+
+
+ +
+
+ + diff --git a/resources/views/renovaciones/reglas.html b/resources/views/renovaciones/reglas.html new file mode 100644 index 0000000..35d7cc4 --- /dev/null +++ b/resources/views/renovaciones/reglas.html @@ -0,0 +1,202 @@ +
+
+ Cargando... +
+ +
+
+
+

Reglas de Notificación

+

Define cuándo y cómo se envían las alertas automáticas

+
+ +
+ +
+ + + + + + + + + + + + + + + + + +
NombreDías antesPlantillaAplica aEstado
Sin reglas configuradas
+
+
+ + +
+
+

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+

¿Eliminar regla?

+

Esta acción no se puede deshacer.

+
+ + +
+
+
+ +
+
+ + diff --git a/resources/views/renovaciones/servicios.html b/resources/views/renovaciones/servicios.html new file mode 100644 index 0000000..ed56e6f --- /dev/null +++ b/resources/views/renovaciones/servicios.html @@ -0,0 +1,227 @@ +
+ +
+ Cargando... +
+ +
+ +
+
+

Servicios

+

Catálogo de servicios ofrecidos

+
+
+ + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
NombreTipoPeriodicidadPrecioEstado
Sin registros
+
+ + +
+ Total: +
+ + + +
+
+
+ + +
+
+

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+ +

¿Eliminar servicio?

+

Esta acción no se puede deshacer.

+
+ + +
+
+
+ + +
+
+ + diff --git a/resources/views/renovaciones/smtp.html b/resources/views/renovaciones/smtp.html new file mode 100644 index 0000000..40e0eae --- /dev/null +++ b/resources/views/renovaciones/smtp.html @@ -0,0 +1,133 @@ +
+
+ Cargando... +
+ +
+
+

Configuración SMTP

+

Servidor de correo saliente para las notificaciones automáticas

+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ +
+
+ + +
+ Nota: Al guardar, la configuración se aplica inmediatamente sin necesidad de reiniciar el servidor. + La contraseña se almacena cifrada con AES-256. +
+
+ +
+
+ + diff --git a/rest/controllers/cliente_controller.go b/rest/controllers/cliente_controller.go new file mode 100644 index 0000000..91456de --- /dev/null +++ b/rest/controllers/cliente_controller.go @@ -0,0 +1,83 @@ +package controllers + +import ( + "math" + "strconv" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +func ClientesView(c *fiber.Ctx) error { + return c.Render("renovaciones/clientes", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func GetClientes(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + limit, _ := strconv.Atoi(c.Query("limit", "10")) + if page < 1 { + page = 1 + } + offset := (page - 1) * limit + records, total, err := models.GetAllClientes(limit, offset, c.Query("search")) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "registros": records, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + "limit": limit, + }) +} + +func GetClientesSelect(c *fiber.Ctx) error { + records, err := models.GetAllClientesSelect() + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(records) +} + +func CreateCliente(c *fiber.Ctx) error { + var m models.Cliente + if err := c.BodyParser(&m); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + m.Activo = true + if err := models.CreateCliente(m); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(fiber.Map{"message": "Cliente creado", "ok": true}) +} + +func UpdateCliente(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + var m models.Cliente + if err := c.BodyParser(&m); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + m.ID = uint(id) + if err := models.UpdateCliente(m); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Actualizado", "ok": true}) +} + +func DeleteCliente(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + if err := models.DeleteCliente(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Eliminado", "ok": true}) +} diff --git a/rest/controllers/contrato_controller.go b/rest/controllers/contrato_controller.go new file mode 100644 index 0000000..c101032 --- /dev/null +++ b/rest/controllers/contrato_controller.go @@ -0,0 +1,206 @@ +package controllers + +import ( + "math" + "strconv" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" +) + +func ContratosView(c *fiber.Ctx) error { + return c.Render("renovaciones/contratos", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func GetContratos(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + limit, _ := strconv.Atoi(c.Query("limit", "10")) + if page < 1 { + page = 1 + } + offset := (page - 1) * limit + records, total, err := models.GetAllContratos(limit, offset, c.Query("search"), c.Query("estado")) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + // Enriquecer con días restantes + type ContratoDTO struct { + models.Contrato + DiasRestantes int `json:"dias_restantes"` + Urgencia string `json:"urgencia"` // verde | amarillo | rojo + } + dtos := make([]ContratoDTO, len(records)) + now := time.Now() + for i, r := range records { + dias := int(r.FechaVencimiento.Sub(now).Hours() / 24) + urgencia := "verde" + if dias <= 0 { + urgencia = "rojo" + } else if dias <= 30 { + urgencia = "amarillo" + } + dtos[i] = ContratoDTO{Contrato: r, DiasRestantes: dias, Urgencia: urgencia} + } + return c.JSON(fiber.Map{ + "registros": dtos, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + "limit": limit, + }) +} + +func CreateContrato(c *fiber.Ctx) error { + type Input struct { + ClienteID uint `json:"cliente_id" form:"cliente_id"` + ServicioID uint `json:"servicio_id" form:"servicio_id"` + FechaInicio string `json:"fecha_inicio" form:"fecha_inicio"` + FechaVencimiento string `json:"fecha_vencimiento" form:"fecha_vencimiento"` + PrecioAcordado float64 `json:"precio_acordado" form:"precio_acordado"` + AutoRenovar bool `json:"auto_renovar" form:"auto_renovar"` + Notas string `json:"notas" form:"notas"` + } + var inp Input + if err := c.BodyParser(&inp); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + fi, err := time.Parse("2006-01-02", inp.FechaInicio) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Fecha inicio inválida"}) + } + fv, err := time.Parse("2006-01-02", inp.FechaVencimiento) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Fecha vencimiento inválida"}) + } + m := models.Contrato{ + ClienteID: inp.ClienteID, + ServicioID: inp.ServicioID, + FechaInicio: fi, + FechaVencimiento: fv, + PrecioAcordado: inp.PrecioAcordado, + AutoRenovar: inp.AutoRenovar, + Notas: inp.Notas, + Estado: "activo", + } + if err := models.CreateContrato(m); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(fiber.Map{"message": "Contrato creado", "ok": true}) +} + +func UpdateContrato(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + type Input struct { + Estado string `json:"estado" form:"estado"` + FechaVencimiento string `json:"fecha_vencimiento" form:"fecha_vencimiento"` + PrecioAcordado float64 `json:"precio_acordado" form:"precio_acordado"` + AutoRenovar bool `json:"auto_renovar" form:"auto_renovar"` + Notas string `json:"notas" form:"notas"` + } + var inp Input + if err := c.BodyParser(&inp); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + existing, err := models.GetContratoByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "No encontrado"}) + } + if inp.FechaVencimiento != "" { + fv, err := time.Parse("2006-01-02", inp.FechaVencimiento) + if err == nil { + existing.FechaVencimiento = fv + } + } + if inp.Estado != "" { + existing.Estado = inp.Estado + } + existing.PrecioAcordado = inp.PrecioAcordado + existing.AutoRenovar = inp.AutoRenovar + existing.Notas = inp.Notas + if err := models.UpdateContrato(*existing); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Actualizado", "ok": true}) +} + +func RenovarContrato(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + existing, err := models.GetContratoByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "No encontrado"}) + } + // Calcular nueva fecha según periodicidad del servicio + nuevaInicio := existing.FechaVencimiento.AddDate(0, 0, 1) + nuevaVenc := calcularFechaVencimiento(nuevaInicio, existing.Servicio.Periodicidad) + + nuevo := models.Contrato{ + ClienteID: existing.ClienteID, + ServicioID: existing.ServicioID, + FechaInicio: nuevaInicio, + FechaVencimiento: nuevaVenc, + PrecioAcordado: existing.PrecioAcordado, + AutoRenovar: existing.AutoRenovar, + Estado: "activo", + Notas: "Renovación automática desde contrato #" + strconv.Itoa(int(existing.ID)), + } + if err := models.CreateContrato(nuevo); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + // Marcar anterior como renovado + existing.Estado = "renovado" + models.UpdateContrato(*existing) + + return c.JSON(fiber.Map{"message": "Renovado", "ok": true}) +} + +func EnviarCorreoContrato(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + contrato, err := models.GetContratoByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "No encontrado"}) + } + if err := services.EnviarCorreoManual(contrato); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Correo enviado", "ok": true}) +} + +func DeleteContrato(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + if err := models.DeleteContrato(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Eliminado", "ok": true}) +} + +func calcularFechaVencimiento(desde time.Time, periodicidad string) time.Time { + switch periodicidad { + case "mensual": + return desde.AddDate(0, 1, 0) + case "trimestral": + return desde.AddDate(0, 3, 0) + case "semestral": + return desde.AddDate(0, 6, 0) + case "anual": + return desde.AddDate(1, 0, 0) + default: + return desde.AddDate(1, 0, 0) + } +} diff --git a/rest/controllers/notificacion_controller.go b/rest/controllers/notificacion_controller.go new file mode 100644 index 0000000..bfed4fb --- /dev/null +++ b/rest/controllers/notificacion_controller.go @@ -0,0 +1,222 @@ +package controllers + +import ( + "math" + "strconv" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" +) + +// ─── Plantillas ───────────────────────────────────────────────────────────── + +func PlantillasView(c *fiber.Ctx) error { + return c.Render("renovaciones/plantillas", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func GetPlantillas(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + limit, _ := strconv.Atoi(c.Query("limit", "10")) + if page < 1 { + page = 1 + } + offset := (page - 1) * limit + records, total, err := models.GetAllPlantillas(limit, offset, c.Query("search")) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "registros": records, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + }) +} + +func CreatePlantilla(c *fiber.Ctx) error { + var m models.PlantillaCorreo + if err := c.BodyParser(&m); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if err := models.CreatePlantilla(m); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(fiber.Map{"message": "Plantilla creada", "ok": true}) +} + +func UpdatePlantilla(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + var updates map[string]interface{} + if err := c.BodyParser(&updates); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if err := models.UpdatePlantilla(uint(id), updates); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Actualizado", "ok": true}) +} + +func DeletePlantilla(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + if err := models.DeletePlantilla(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Eliminado", "ok": true}) +} + +func PreviewPlantilla(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + p, err := models.GetPlantillaByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "No encontrada"}) + } + html, err := services.RenderPlantilla(p, services.DatosEjemplo()) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"html": html, "ok": true}) +} + +func TestEnvioPlantilla(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + var body struct { + Email string `json:"email"` + } + if err := c.BodyParser(&body); err != nil || body.Email == "" { + return c.Status(400).JSON(fiber.Map{"error": "Email requerido"}) + } + p, err := models.GetPlantillaByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "No encontrada"}) + } + if err := services.EnviarCorreoPrueba(body.Email, p); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Correo enviado", "ok": true}) +} + +// ─── Reglas ────────────────────────────────────────────────────────────────── + +func ReglasView(c *fiber.Ctx) error { + return c.Render("renovaciones/reglas", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func GetReglas(c *fiber.Ctx) error { + records, err := models.GetAllReglas() + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"registros": records, "total": len(records)}) +} + +func CreateRegla(c *fiber.Ctx) error { + var m models.NotificacionRegla + if err := c.BodyParser(&m); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if err := models.CreateRegla(m); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(fiber.Map{"message": "Regla creada", "ok": true}) +} + +func UpdateRegla(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + var m models.NotificacionRegla + if err := c.BodyParser(&m); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + m.ID = uint(id) + if err := models.UpdateRegla(m); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Actualizado", "ok": true}) +} + +func DeleteRegla(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + if err := models.DeleteRegla(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Eliminado", "ok": true}) +} + +// ─── Historial ─────────────────────────────────────────────────────────────── + +func HistorialView(c *fiber.Ctx) error { + return c.Render("renovaciones/historial", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func GetHistorial(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + limit, _ := strconv.Atoi(c.Query("limit", "20")) + if page < 1 { + page = 1 + } + offset := (page - 1) * limit + records, total, err := models.GetAllLogs(limit, offset) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "registros": records, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + }) +} + +func ReenviarNotificacion(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + log, err := models.GetLogByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "No encontrado"}) + } + if err := services.ReenviarLog(log); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Reenviado", "ok": true}) +} + +func VerPreviewLog(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + log, err := models.GetLogByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "No encontrado"}) + } + return c.JSON(fiber.Map{"html": log.PreviewHTML, "asunto": log.Asunto, "ok": true}) +} diff --git a/rest/controllers/servicio_controller.go b/rest/controllers/servicio_controller.go new file mode 100644 index 0000000..9cb1431 --- /dev/null +++ b/rest/controllers/servicio_controller.go @@ -0,0 +1,83 @@ +package controllers + +import ( + "math" + "strconv" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +func ServiciosView(c *fiber.Ctx) error { + return c.Render("renovaciones/servicios", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func GetServicios(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + limit, _ := strconv.Atoi(c.Query("limit", "10")) + if page < 1 { + page = 1 + } + offset := (page - 1) * limit + records, total, err := models.GetAllServicios(limit, offset, c.Query("search")) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "registros": records, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + "limit": limit, + }) +} + +func GetServiciosSelect(c *fiber.Ctx) error { + records, err := models.GetAllServiciosSelect() + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(records) +} + +func CreateServicio(c *fiber.Ctx) error { + var m models.Servicio + if err := c.BodyParser(&m); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + m.Activo = true + if err := models.CreateServicio(m); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(fiber.Map{"message": "Servicio creado", "ok": true}) +} + +func UpdateServicio(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + var m models.Servicio + if err := c.BodyParser(&m); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + m.ID = uint(id) + if err := models.UpdateServicio(m); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Actualizado", "ok": true}) +} + +func DeleteServicio(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + if err := models.DeleteServicio(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Eliminado", "ok": true}) +} diff --git a/rest/controllers/smtp_config_controller.go b/rest/controllers/smtp_config_controller.go new file mode 100644 index 0000000..6861224 --- /dev/null +++ b/rest/controllers/smtp_config_controller.go @@ -0,0 +1,94 @@ +package controllers + +import ( + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/app" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/utils" +) + +func SmtpConfigView(c *fiber.Ctx) error { + cfg, _ := models.GetSmtpConfig() + return c.Render("renovaciones/smtp", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + "config": cfg, + }, "layouts/main") +} + +func GetSmtpConfig(c *fiber.Ctx) error { + cfg, err := models.GetSmtpConfig() + if err != nil { + return c.JSON(fiber.Map{"ok": false, "config": nil}) + } + // No exponer contraseña en claro + cfg.Password = "***" + return c.JSON(fiber.Map{"ok": true, "config": cfg}) +} + +func SaveSmtpConfig(c *fiber.Ctx) error { + type Input struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + Encryption string `json:"encryption"` + FromAddress string `json:"from_address"` + FromName string `json:"from_name"` + } + var inp Input + if err := c.BodyParser(&inp); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + + // Cifrar contraseña solo si se provee una nueva (no es placeholder) + passEncrypted := inp.Password + if inp.Password != "" && inp.Password != "***" { + passEncrypted = utils.Encrypt(inp.Password, app.Http.Server.Key) + } else if inp.Password == "***" { + // Mantener contraseña ya guardada + if existing, err := models.GetSmtpConfig(); err == nil { + passEncrypted = existing.Password + } + } + + cfg := models.SmtpConfig{ + Host: inp.Host, + Port: inp.Port, + Username: inp.Username, + Password: passEncrypted, + Encryption: inp.Encryption, + FromAddress: inp.FromAddress, + FromName: inp.FromName, + } + + if err := models.SaveSmtpConfig(cfg); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + // Recargar el mailer en memoria con la nueva config + app.Http.Mail.Host = cfg.Host + app.Http.Mail.Port = cfg.Port + app.Http.Mail.Username = cfg.Username + app.Http.Mail.Password = utils.Decrypt(passEncrypted, app.Http.Server.Key) + app.Http.Mail.Encryption = cfg.Encryption + app.Http.Mail.FromAddress = cfg.FromAddress + app.Http.Mail.FromName = cfg.FromName + app.Http.Mail.SetupMailer() + + return c.JSON(fiber.Map{"message": "Configuración guardada", "ok": true}) +} + +func TestSmtpConfig(c *fiber.Ctx) error { + var body struct { + Email string `json:"email"` + } + if err := c.BodyParser(&body); err != nil || body.Email == "" { + return c.Status(400).JSON(fiber.Map{"error": "Email de destino requerido"}) + } + htmlBody := "

Test SMTP

La configuración SMTP funciona correctamente.

" + if err := app.Http.Mail.Send(body.Email, "Test SMTP - U-site", htmlBody); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Correo de prueba enviado", "ok": true}) +} diff --git a/rest/middlewares/login.go b/rest/middlewares/login.go index 574ec4f..7d5e094 100755 --- a/rest/middlewares/login.go +++ b/rest/middlewares/login.go @@ -1,11 +1,12 @@ package middlewares import ( + "net/url" + "github.com/gofiber/fiber/v2" "github.com/gookit/validate" "github.com/sujit-baniya/fiber-boilerplate/pkg/auth" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" - "net/url" ) func RedirectToHomePageOnLogin(c *fiber.Ctx) error { diff --git a/rest/routes/renovaciones.go b/rest/routes/renovaciones.go new file mode 100644 index 0000000..dc73088 --- /dev/null +++ b/rest/routes/renovaciones.go @@ -0,0 +1,63 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/rest/controllers" + "github.com/sujit-baniya/fiber-boilerplate/rest/middlewares" +) + +// RenovacionesRoutes registra todas las rutas del módulo de renovaciones/contratos +func RenovacionesRoutes(protected fiber.Router) { + // ─── Servicios ─────────────────────────────────────────────────── + protected.Get("/servicios", middlewares.MenuMiddleware, controllers.ServiciosView) + protected.Get("/api/servicios", controllers.GetServicios) + protected.Get("/api/servicios/select", controllers.GetServiciosSelect) + protected.Post("/api/servicios", controllers.CreateServicio) + protected.Put("/api/servicios/:id", controllers.UpdateServicio) + protected.Delete("/api/servicios/:id", controllers.DeleteServicio) + + // ─── Clientes ──────────────────────────────────────────────────── + protected.Get("/clientes", middlewares.MenuMiddleware, controllers.ClientesView) + protected.Get("/api/clientes", controllers.GetClientes) + protected.Get("/api/clientes/select", controllers.GetClientesSelect) + protected.Post("/api/clientes", controllers.CreateCliente) + protected.Put("/api/clientes/:id", controllers.UpdateCliente) + protected.Delete("/api/clientes/:id", controllers.DeleteCliente) + + // ─── Contratos ─────────────────────────────────────────────────── + protected.Get("/contratos", middlewares.MenuMiddleware, controllers.ContratosView) + protected.Get("/api/contratos", controllers.GetContratos) + protected.Post("/api/contratos", controllers.CreateContrato) + protected.Put("/api/contratos/:id", controllers.UpdateContrato) + protected.Delete("/api/contratos/:id", controllers.DeleteContrato) + protected.Post("/api/contratos/:id/renovar", controllers.RenovarContrato) + protected.Post("/api/contratos/:id/enviar-correo", controllers.EnviarCorreoContrato) + + // ─── Plantillas de correo ───────────────────────────────────────── + protected.Get("/plantillas-correo", middlewares.MenuMiddleware, controllers.PlantillasView) + protected.Get("/api/plantillas-correo", controllers.GetPlantillas) + protected.Post("/api/plantillas-correo", controllers.CreatePlantilla) + protected.Put("/api/plantillas-correo/:id", controllers.UpdatePlantilla) + protected.Delete("/api/plantillas-correo/:id", controllers.DeletePlantilla) + protected.Get("/api/plantillas-correo/:id/preview", controllers.PreviewPlantilla) + protected.Post("/api/plantillas-correo/:id/test", controllers.TestEnvioPlantilla) + + // ─── Reglas de notificación ─────────────────────────────────────── + protected.Get("/reglas-notificacion", middlewares.MenuMiddleware, controllers.ReglasView) + protected.Get("/api/reglas-notificacion", controllers.GetReglas) + protected.Post("/api/reglas-notificacion", controllers.CreateRegla) + protected.Put("/api/reglas-notificacion/:id", controllers.UpdateRegla) + protected.Delete("/api/reglas-notificacion/:id", controllers.DeleteRegla) + + // ─── Historial de notificaciones ───────────────────────────────── + protected.Get("/historial-notificaciones", middlewares.MenuMiddleware, controllers.HistorialView) + protected.Get("/api/historial-notificaciones", controllers.GetHistorial) + protected.Post("/api/historial-notificaciones/:id/reenviar", controllers.ReenviarNotificacion) + protected.Get("/api/historial-notificaciones/:id/preview", controllers.VerPreviewLog) + + // ─── Configuración SMTP ─────────────────────────────────────────── + protected.Get("/smtp-config", middlewares.MenuMiddleware, controllers.SmtpConfigView) + protected.Get("/api/smtp-config", controllers.GetSmtpConfig) + protected.Post("/api/smtp-config", controllers.SaveSmtpConfig) + protected.Post("/api/smtp-config/test", controllers.TestSmtpConfig) +} diff --git a/rest/routes/user.go b/rest/routes/user.go index bfb7bdb..4251ca5 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -96,6 +96,6 @@ func UserRoutes(app fiber.Router) { protected.Put("/tipodb/:id", controllers.UpdateTipoDb) protected.Delete("/tipodb/:id", controllers.DeleteTipoDb) - - + // ─── Módulo de Renovaciones / Contratos ────────────────────────── + RenovacionesRoutes(protected) }