From b6ca254a824df23ea67a07a175337279efd8b37b Mon Sep 17 00:00:00 2001 From: Nurfog Date: Mon, 21 Sep 2026 12:30:43 -0300 Subject: [PATCH] feat: plantillas de correo en portal IT (listar, crear, editar) --- README.md | 1 + src/app/api/correo/plantillas/[key]/route.ts | 48 ++ src/app/api/correo/plantillas/route.ts | 68 +++ src/app/correo/page.tsx | 20 +- src/app/correo/plantillas/page.tsx | 458 +++++++++++++++++++ src/lib/correo.ts | 72 +++ src/lib/sam.ts | 1 + 7 files changed, 666 insertions(+), 2 deletions(-) create mode 100644 src/app/api/correo/plantillas/[key]/route.ts create mode 100644 src/app/api/correo/plantillas/route.ts create mode 100644 src/app/correo/plantillas/page.tsx diff --git a/README.md b/README.md index c249dec..abf8a0e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ | `/actualizar` | sí | Redeploy + webhook + toggle visibilidad | | `/eliminar` | sí | Baja de módulos (con/sin código) | | `/correo` | sí | Claves de correo por módulo (`POST /api/email/modules`, banco `mail.*`) | +| `/correo/plantillas` | sí | Plantillas del banco `mail.templates` (listar, crear, editar, activar) | | `/api/modules`, `/api/stats` | no/sí | Proxy al deployer (token server-side) | | `/api/deployer/*` | sí | Proxy genérico al deployer | | `/api/auth`, `/api/me` | — | Sesión HMAC 12h en cookie httpOnly | diff --git a/src/app/api/correo/plantillas/[key]/route.ts b/src/app/api/correo/plantillas/[key]/route.ts new file mode 100644 index 0000000..8ae4cb5 --- /dev/null +++ b/src/app/api/correo/plantillas/[key]/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { + assertIT, + helpdeskFetch, + asResponse, + errResponse, +} from "@/lib/correo-server"; + +// PUT: actualiza subject / bodyHtml / isActive de una plantilla. +// Backend: PUT /api/email/templates/{key} (solo admin). +export async function PUT( + req: Request, + { params }: { params: Promise<{ key: string }> } +): Promise { + try { + await assertIT(req); + const key = (await params).key.trim().toLowerCase(); + if (!/^[a-z0-9_]{2,64}$/.test(key)) { + return NextResponse.json( + { message: "Key inválida ([a-z0-9_], 2-64)" }, + { status: 400 } + ); + } + const body = (await req.json().catch(() => ({}))) as { + subject?: string; + bodyHtml?: string; + isActive?: boolean; + }; + const payload: Record = {}; + if (body.subject !== undefined) payload.subject = body.subject; + if (body.bodyHtml !== undefined) payload.bodyHtml = body.bodyHtml; + if (body.isActive !== undefined) payload.isActive = body.isActive; + if (Object.keys(payload).length === 0) { + return NextResponse.json( + { message: "Nada que actualizar (subject, bodyHtml, isActive)" }, + { status: 400 } + ); + } + return await asResponse( + await helpdeskFetch(`/api/email/templates/${encodeURIComponent(key)}`, { + method: "PUT", + body: JSON.stringify(payload), + }) + ); + } catch (e) { + return errResponse(e); + } +} diff --git a/src/app/api/correo/plantillas/route.ts b/src/app/api/correo/plantillas/route.ts new file mode 100644 index 0000000..3fe657b --- /dev/null +++ b/src/app/api/correo/plantillas/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import { + assertIT, + helpdeskFetch, + asResponse, + errResponse, +} from "@/lib/correo-server"; + +const KEY_RE = /^[a-z0-9_]{2,64}$/; +const MODULE_RE = /^[a-z0-9-]{2,32}$/; + +// GET: lista plantillas del banco único mail.templates. +// Backend: GET /api/email/templates (módulo autorizado o admin; +// aquí siempre vamos como admin vía cuenta de servicio). +export async function GET(req: Request): Promise { + try { + await assertIT(req); + return await asResponse(await helpdeskFetch("/api/email/templates")); + } catch (e) { + return errResponse(e); + } +} + +// POST {key, name, subject, bodyHtml, module?}: crea plantilla. +// Valida formato antes de proxear (mismas reglas que EmailController). +export async function POST(req: Request): Promise { + try { + await assertIT(req); + const body = (await req.json().catch(() => ({}))) as { + key?: string; + name?: string; + subject?: string; + bodyHtml?: string; + module?: string; + }; + const key = (body.key ?? "").trim().toLowerCase(); + const name = (body.name ?? "").trim(); + const subject = (body.subject ?? "").trim(); + const bodyHtml = (body.bodyHtml ?? "").trim(); + const modName = (body.module ?? "shared").trim().toLowerCase(); + if (!KEY_RE.test(key)) { + return NextResponse.json( + { message: "Key inválida ([a-z0-9_], 2-64)" }, + { status: 400 } + ); + } + if (!name || !subject || !bodyHtml) { + return NextResponse.json( + { message: "name, subject y bodyHtml son requeridos" }, + { status: 400 } + ); + } + if (!MODULE_RE.test(modName)) { + return NextResponse.json( + { message: "Módulo inválido ([a-z0-9-], 2-32)" }, + { status: 400 } + ); + } + return await asResponse( + await helpdeskFetch("/api/email/templates", { + method: "POST", + body: JSON.stringify({ key, name, subject, bodyHtml, module: modName }), + }) + ); + } catch (e) { + return errResponse(e); + } +} diff --git a/src/app/correo/page.tsx b/src/app/correo/page.tsx index 5dd1c31..16dd56a 100644 --- a/src/app/correo/page.tsx +++ b/src/app/correo/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import Link from "next/link"; import SamShell from "@/components/SamShell"; import { refreshCorreo, useCorreoModulos } from "@/lib/correo"; import { refreshModules, useModules, useMe } from "@/lib/store"; @@ -82,8 +83,23 @@ export default function Correo() { allowLogout backHref="/" > -
- Correo · Módulos autorizados +
+ + Correo · Módulos autorizados + + + + + Módulos + + + + Plantillas + +
{error &&
{error}
} diff --git a/src/app/correo/plantillas/page.tsx b/src/app/correo/plantillas/page.tsx new file mode 100644 index 0000000..84b00cc --- /dev/null +++ b/src/app/correo/plantillas/page.tsx @@ -0,0 +1,458 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import SamShell from "@/components/SamShell"; +import { + refreshPlantillas, + useCorreoPlantillas, + type CorreoTemplate, +} from "@/lib/correo"; +import { useModules, useMe } from "@/lib/store"; +import { itMenu } from "@/lib/sam"; + +// Plantillas de correo (banco único mail.templates). +// Lista (GET), crea (POST key/name/subject/bodyHtml/module) y +// edita (PUT subject/bodyHtml/isActive). Las variables son +// {{Clave}} (case-sensitive) y las pone cada módulo al enviar. +export default function Plantillas() { + const { data, error } = useModules(); + const me = useMe(); + const modules = data?.modules ?? []; + const plantillas = useCorreoPlantillas(); + const rows = plantillas.data ?? []; + const rowsData = plantillas.data; + + const [q, setQ] = useState(""); + const [sel, setSel] = useState(null); + const [busy, setBusy] = useState(null); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + + // Form crear. + const [fKey, setFKey] = useState(""); + const [fName, setFName] = useState(""); + const [fSubject, setFSubject] = useState(""); + const [fBody, setFBody] = useState(""); + const [fModule, setFModule] = useState("shared"); + + // Form editar. + const [eSubject, setESubject] = useState(""); + const [eBody, setEBody] = useState(""); + const [eActive, setEActive] = useState(true); + + const filtered = useMemo(() => { + const list = rowsData ?? []; + const needle = q.trim().toLowerCase(); + if (!needle) return list; + return list.filter( + (t) => + t.key.toLowerCase().includes(needle) || + t.name.toLowerCase().includes(needle) || + t.subject.toLowerCase().includes(needle) + ); + }, [rowsData, q]); + + const selected: CorreoTemplate | null = + rows.find((t) => t.key === sel) ?? null; + + const pick = (t: CorreoTemplate) => { + setSel(t.key); + setESubject(t.subject); + setEBody(t.bodyHtml); + setEActive(t.isActive); + setMsg(null); + }; + + const create = async () => { + const key = fKey.trim().toLowerCase(); + if (!/^[a-z0-9_]{2,64}$/.test(key)) { + setMsg({ ok: false, text: "Key inválida ([a-z0-9_], 2-64)" }); + return; + } + if (!fName.trim() || !fSubject.trim() || !fBody.trim()) { + setMsg({ ok: false, text: "Nombre, asunto y cuerpo son requeridos" }); + return; + } + setBusy("create"); + setMsg(null); + try { + const res = await fetch("/api/correo/plantillas", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key, + name: fName.trim(), + subject: fSubject.trim(), + bodyHtml: fBody.trim(), + module: fModule.trim().toLowerCase() || "shared", + }), + cache: "no-store", + }); + const j = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(j.message || j.error || `HTTP ${res.status}`); + setMsg({ ok: true, text: `Plantilla "${key}" creada.` }); + setFKey(""); + setFName(""); + setFSubject(""); + setFBody(""); + refreshPlantillas(); + } catch (e) { + setMsg({ ok: false, text: e instanceof Error ? e.message : "Error" }); + } finally { + setBusy(null); + } + }; + + const save = async () => { + if (!selected) return; + if (!eSubject.trim() || !eBody.trim()) { + setMsg({ ok: false, text: "Asunto y cuerpo son requeridos" }); + return; + } + setBusy(`save:${selected.key}`); + setMsg(null); + try { + const res = await fetch( + `/api/correo/plantillas/${encodeURIComponent(selected.key)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + subject: eSubject.trim(), + bodyHtml: eBody.trim(), + isActive: eActive, + }), + cache: "no-store", + } + ); + const j = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(j.message || j.error || `HTTP ${res.status}`); + setMsg({ ok: true, text: `Plantilla "${selected.key}" actualizada.` }); + refreshPlantillas(); + } catch (e) { + setMsg({ ok: false, text: e instanceof Error ? e.message : "Error" }); + } finally { + setBusy(null); + } + }; + + const toggleActive = async (t: CorreoTemplate) => { + setBusy(`toggle:${t.key}`); + setMsg(null); + try { + const res = await fetch( + `/api/correo/plantillas/${encodeURIComponent(t.key)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isActive: !t.isActive }), + cache: "no-store", + } + ); + const j = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(j.message || j.error || `HTTP ${res.status}`); + // Si es la seleccionada, refleja el cambio en el form. + if (sel === t.key) setEActive(!t.isActive); + setMsg({ + ok: true, + text: `Plantilla "${t.key}" ${!t.isActive ? "activada" : "desactivada"}.`, + }); + refreshPlantillas(); + } catch (e) { + setMsg({ ok: false, text: e instanceof Error ? e.message : "Error" }); + } finally { + setBusy(null); + } + }; + + return ( + +
+ + Correo · Plantillas + + + + + Módulos + + + + Plantillas + + +
+ + {error &&
{error}
} + {plantillas.error && ( +
{plantillas.error}
+ )} + {msg && ( +
+ {msg.text} +
+ )} + +
+
+ setQ(e.target.value)} + /> + + + {filtered.length} de {rows.length} plantillas · variables{" "} + {"{{Clave}}"} + +
+ +
+ + + + + + + + + + + + {filtered.map((t) => ( + + + + + + + + ))} + {filtered.length === 0 && ( + + + + )} + +
KeyNombreAsuntoEstadoAcciones
+ {t.key} + {t.name} + {t.subject} + + {t.isActive ? ( + Activa + ) : ( + Inactiva + )} + +
+ + +
+
+ {rows.length === 0 + ? "Sin plantillas en el banco." + : "Sin coincidencias."} +
+
+
+ + {selected && ( +
+
+ + Editar {selected.key} +
+
+
+ + setESubject(e.target.value)} + /> + +