From b1cddbac6c1004d1b44c881f8b5e800387f3e291 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Tue, 15 Sep 2026 17:28:52 -0300 Subject: [PATCH] feat: correo via banco mail, rol supervisor y lint en cero --- README.md | 20 +- frontend/src/app/(app)/page.tsx | 1 - .../app/(app)/settings/departments/page.tsx | 19 +- .../src/app/(app)/settings/email/page.tsx | 349 +++++++++++++++++- frontend/src/app/(app)/settings/sla/page.tsx | 19 +- frontend/src/app/(app)/settings/team/page.tsx | 34 +- frontend/src/app/(app)/tickets/[id]/page.tsx | 15 +- frontend/src/app/(app)/tickets/new/page.tsx | 4 +- frontend/src/app/(app)/tickets/page.tsx | 79 ++-- frontend/src/app/(auth)/login/page.tsx | 4 +- frontend/src/app/api/correos/route.ts | 128 +++++++ frontend/src/app/layout.tsx | 16 +- frontend/src/components/Sidebar.tsx | 8 +- frontend/src/lib/api.ts | 85 ++++- frontend/src/lib/auth.tsx | 3 +- 15 files changed, 677 insertions(+), 107 deletions(-) create mode 100644 frontend/src/app/api/correos/route.ts diff --git a/README.md b/README.md index 4e0cbb7..cf7679b 100644 --- a/README.md +++ b/README.md @@ -41,16 +41,29 @@ pnpm dev # Frontend en http://localhost:3000 ``` -Variable de entorno (`frontend/.env.local`, no versionado): +Variables de entorno (`frontend/.env.local`, no versionado): ``` -NEXT_PUBLIC_API_URL=https://api.ichn.local # dev (.254) +NEXT_PUBLIC_API_URL=https://api.ichn.local # dev (.254), cliente (bundle) # NEXT_PUBLIC_API_URL=https://api.devops.norteamericano.cl # prod (.4) + +# Solo servidor (route /api/correos → POST /api/email/send, jamás NEXT_PUBLIC_): +# MAIL_API_KEY=clave del módulo helpdesk (POST /api/email/modules, se muestra una vez) +# SAM_API_URL=https://api.ichn.local # default: NEXT_PUBLIC_API_URL, luego prod +# MAIL_MODULE=helpdesk # default helpdesk ``` Auth: login con `idUsuario` + password (valida contra `sige_sam_v3.usuario` en el backend). El primer integrante del equipo queda `admin` (bootstrap). +Roles del equipo (`helpdesk.team_members`): + +| Rol | Alcance | +|---|---| +| `admin` | Todo + Configuración (departamentos, equipo, SLA, correo) | +| `agent` | Todos los tickets | +| `supervisor` | Solo su departamento (+ sus propios tickets); requiere departamento asignado; no puede mover tickets de departamento ni gestionar configuración | + ## Deploy Lo publica el deployer de SAM desde este repo (módulo `helpdesk`): @@ -69,4 +82,5 @@ Base: `NEXT_PUBLIC_API_URL/api/helpdesk` - `GET|POST /groups` · `PUT|DELETE /groups/{id}` (admin) - `GET|POST /users` · `PUT /users/{id}` (admin) - `GET|POST /sla` · `PUT /sla/{id}` (admin) -- `GET /email/templates` · `PUT /email/templates/{key}` · `GET /email/log` · `POST /email/test` (admin) +- `GET /email/templates` · `POST /email/templates` · `PUT /email/templates/{key}` · `GET /email/log?module=helpdesk&limit=` · `GET|POST /email/modules` · `DELETE /email/modules/{m}` (admin, banco único `mail.*`) +- `POST /api/correos` (route propio server-side → `POST /api/email/send` con `X-Module: helpdesk` + `X-Api-Key`) · `POST /helpdesk/email/inbound/sync` (admin, inbound Gmail sin cambios) diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index d4034e9..8c9dbcc 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from "react"; import StatsCard from "@/components/StatsCard"; -import BadgeStatus from "@/components/BadgeStatus"; import { getTicketStats, type DashboardStats } from "@/lib/api"; import Link from "next/link"; diff --git a/frontend/src/app/(app)/settings/departments/page.tsx b/frontend/src/app/(app)/settings/departments/page.tsx index f97bd98..a6981b4 100644 --- a/frontend/src/app/(app)/settings/departments/page.tsx +++ b/frontend/src/app/(app)/settings/departments/page.tsx @@ -38,13 +38,18 @@ export default function DepartmentsPage() { }, []); useEffect(() => { - if (authLoading) return; - if (user?.role !== "admin") { - setLoading(false); - return; - } - fetchGroups(); - }, [fetchGroups, authLoading, user?.role]); + if (authLoading || user?.role !== "admin") return; + getGroups() + .then((g) => { + setGroups(g); + setError(""); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : "Error al cargar"); + setGroups([]); + }) + .finally(() => setLoading(false)); + }, [authLoading, user?.role]); const openCreate = () => { setEditing(null); diff --git a/frontend/src/app/(app)/settings/email/page.tsx b/frontend/src/app/(app)/settings/email/page.tsx index 0f9e1c1..cc98f89 100644 --- a/frontend/src/app/(app)/settings/email/page.tsx +++ b/frontend/src/app/(app)/settings/email/page.tsx @@ -3,12 +3,17 @@ import { useEffect, useState, useCallback } from "react"; import { getEmailTemplates, + createEmailTemplate, updateEmailTemplate, getEmailLog, sendTestEmail, syncInboundEmail, + getEmailModules, + registerEmailModule, + revokeEmailModule, type EmailTemplate, type EmailLogEntry, + type EmailModule, } from "@/lib/api"; import { useAuth } from "@/lib/auth"; import Modal from "@/components/Modal"; @@ -31,6 +36,8 @@ export default function EmailPage() { const { user, isLoading: authLoading } = useAuth(); const [templates, setTemplates] = useState([]); const [log, setLog] = useState([]); + const [modules, setModules] = useState([]); + const [logModule, setLogModule] = useState("helpdesk"); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [showModal, setShowModal] = useState(false); @@ -40,6 +47,24 @@ export default function EmailPage() { const [isActive, setIsActive] = useState(true); const [saving, setSaving] = useState(false); const [modalError, setModalError] = useState(""); + // Crear plantilla (POST /api/email/templates) + const [showCreate, setShowCreate] = useState(false); + const [newKey, setNewKey] = useState(""); + const [newName, setNewName] = useState(""); + const [newSubject, setNewSubject] = useState(""); + const [newBody, setNewBody] = useState(""); + const [newModule, setNewModule] = useState("helpdesk"); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(""); + // Módulos (GET/POST/DELETE /api/email/modules) + const [newModuleName, setNewModuleName] = useState(""); + const [registering, setRegistering] = useState(false); + const [registerResult, setRegisterResult] = useState<{ + ok: boolean; + text: string; + apiKey?: string; + } | null>(null); + const [revoking, setRevoking] = useState(null); const [testTo, setTestTo] = useState(""); const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null); const [testing, setTesting] = useState(false); @@ -50,26 +75,45 @@ export default function EmailPage() { setLoading(true); setError(""); try { - const [t, l] = await Promise.all([getEmailTemplates(), getEmailLog(50)]); + const [t, l, m] = await Promise.all([ + getEmailTemplates(), + getEmailLog(50, logModule), + getEmailModules(), + ]); setTemplates(t); setLog(l); + setModules(m); } catch (err) { setError(err instanceof Error ? err.message : "Error al cargar"); setTemplates([]); setLog([]); + setModules([]); } finally { setLoading(false); } - }, []); + }, [logModule]); useEffect(() => { - if (authLoading) return; - if (user?.role !== "admin") { - setLoading(false); - return; - } - fetchData(); - }, [fetchData, authLoading, user?.role]); + if (authLoading || user?.role !== "admin") return; + Promise.all([ + getEmailTemplates(), + getEmailLog(50, logModule), + getEmailModules(), + ]) + .then(([t, l, m]) => { + setTemplates(t); + setLog(l); + setModules(m); + setError(""); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : "Error al cargar"); + setTemplates([]); + setLog([]); + setModules([]); + }) + .finally(() => setLoading(false)); + }, [authLoading, user?.role, logModule]); const openEdit = (t: EmailTemplate) => { setEditing(t); @@ -103,6 +147,87 @@ export default function EmailPage() { } }; + const openCreate = () => { + setNewKey(""); + setNewName(""); + setNewSubject(""); + setNewBody(""); + setNewModule("helpdesk"); + setCreateError(""); + setShowCreate(true); + }; + + const handleCreate = async () => { + const key = newKey.trim().toLowerCase(); + const modName = (newModule.trim().toLowerCase() || "helpdesk"); + if (!/^[a-z0-9_]{2,64}$/.test(key)) { + setCreateError("Key inválida ([a-z0-9_], 2-64)"); + return; + } + if (!newName.trim() || !newSubject.trim() || !newBody.trim()) { + setCreateError("Nombre, asunto y cuerpo son requeridos"); + return; + } + if (!/^[a-z0-9-]{2,32}$/.test(modName)) { + setCreateError("Módulo inválido ([a-z0-9-], 2-32)"); + return; + } + setCreating(true); + setCreateError(""); + try { + await createEmailTemplate({ + key, + name: newName.trim(), + subject: newSubject.trim(), + bodyHtml: newBody.trim(), + module: modName, + }); + setShowCreate(false); + fetchData(); + } catch (err) { + setCreateError(err instanceof Error ? err.message : "Error al crear"); + } finally { + setCreating(false); + } + }; + + const handleRegister = async () => { + const modName = newModuleName.trim().toLowerCase(); + if (!/^[a-z0-9-]{2,32}$/.test(modName)) { + setRegisterResult({ ok: false, text: "Módulo inválido ([a-z0-9-], 2-32)" }); + return; + } + setRegistering(true); + setRegisterResult(null); + try { + const r = await registerEmailModule(modName); + setRegisterResult({ + ok: true, + text: `Clave de "${r.module}" generada. Se muestra una sola vez.`, + apiKey: r.apiKey, + }); + setNewModuleName(""); + fetchData(); + } catch (err) { + setRegisterResult({ ok: false, text: err instanceof Error ? err.message : "Error al registrar" }); + } finally { + setRegistering(false); + } + }; + + const handleRevoke = async (modName: string) => { + if (!window.confirm(`¿Revocar la clave del módulo "${modName}"? Dejará de poder usar POST /api/email/send.`)) return; + setRevoking(modName); + try { + await revokeEmailModule(modName); + fetchData(); + } catch (err) { + setRegisterResult({ ok: false, text: err instanceof Error ? err.message : "Error al revocar" }); + } finally { + setRevoking(null); + } + }; + const handleTest = async () => { if (!testTo.trim() || !testTo.includes("@")) { setTestResult({ ok: false, text: "Destino inválido" }); @@ -111,8 +236,9 @@ export default function EmailPage() { setTesting(true); setTestResult(null); try { - await sendTestEmail(testTo.trim()); - setTestResult({ ok: true, text: `Correo de prueba enviado a ${testTo.trim()}` }); + const r = await sendTestEmail(testTo.trim()); + const sent = typeof r.sent === "number" ? ` (${r.sent} enviado(s))` : ""; + setTestResult({ ok: true, text: `Correo de prueba enviado a ${testTo.trim()}${sent} vía módulo helpdesk.` }); fetchData(); } catch (err) { setTestResult({ ok: false, text: err instanceof Error ? err.message : "Error al enviar" }); @@ -155,8 +281,12 @@ export default function EmailPage() { return (
-
+

Plantillas de Correo

+
{error && ( @@ -173,6 +303,7 @@ export default function EmailPage() { Plantilla + Módulo Asunto Estado Acciones @@ -181,13 +312,13 @@ export default function EmailPage() { {loading ? ( - +
) : templates.length === 0 ? ( - +
Sin plantillas
@@ -201,6 +332,9 @@ export default function EmailPage() { {t.key} + + {t.module ?? "helpdesk"} + {t.subject} {t.isActive ? ( @@ -227,6 +361,95 @@ export default function EmailPage() {
+
+
+
+ + Módulos autorizados +
+ {registerResult && ( +
+ {registerResult.text} + {registerResult.apiKey && ( +
+ {registerResult.apiKey} +
+ Guárdala en el .env del módulo como MAIL_API_KEY. No se vuelve a mostrar. + Registrar de nuevo rota la clave anterior. +
+
+ )} +
+ )} + {modules.length > 0 && ( +
+ + + + + + + + + + {modules.map((m) => ( + + + + + + ))} + +
MóduloEstadoAcciones
+ {m.module} + + {m.isActive ? ( + Activo + ) : ( + Revocado + )} + + +
+
+ )} +
+ setNewModuleName(e.target.value)} + style={{ maxWidth: 320 }} + /> + +
+ + La clave plana se muestra una sola vez (POST /api/email/modules). + +
+
+
@@ -259,6 +482,8 @@ export default function EmailPage() {
+ Va por el route propio /api/correos → POST /api/email/send con + módulo helpdesk. Requiere MAIL_API_KEY en el servidor. Si falla, revisa la bitácora y la configuración SMTP del backend.
@@ -295,10 +520,25 @@ export default function EmailPage() { -
- - Últimos envíos -
+
+
+ + Últimos envíos +
+ +
@@ -307,6 +547,7 @@ export default function EmailPage() { Fecha Plantilla + Módulo Ticket Destinatario Estado @@ -315,7 +556,7 @@ export default function EmailPage() { {log.length === 0 ? ( - + Sin envíos registrados @@ -328,6 +569,9 @@ export default function EmailPage() { {e.template} + + {e.module ?? "helpdesk"} + {e.ticketId ? `#${e.ticketId}` : "—"} {e.recipient} @@ -403,6 +647,73 @@ export default function EmailPage() { ))}
+ + setShowCreate(false)} + onSubmit={handleCreate} + submitLabel="Crear" + submitting={creating} + > + {createError && ( +
+ {createError} +
+ )} +
+ + setNewKey(e.target.value)} + placeholder="ticket_created" + autoFocus + /> +
+
+ + setNewName(e.target.value)} + placeholder="Ticket creado" + /> +
+
+ + setNewModule(e.target.value)} + placeholder="helpdesk" + /> +
+
+ + setNewSubject(e.target.value)} + /> +
+
+ +