feat: plantillas de correo en portal IT (listar, crear, editar)

This commit is contained in:
Nurfog committed 2026-09-21 12:30:43 -03:00
1 parent 9710d995b8
commit b6ca254a82
7 files changed
+666 -2

No files matched your search

+1
View File
@@ -17,6 +17,7 @@
| `/actualizar` | sí | Redeploy + webhook + toggle visibilidad | | `/actualizar` | sí | Redeploy + webhook + toggle visibilidad |
| `/eliminar` | sí | Baja de módulos (con/sin código) | | `/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` | 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/modules`, `/api/stats` | no/sí | Proxy al deployer (token server-side) |
| `/api/deployer/*` | sí | Proxy genérico al deployer | | `/api/deployer/*` | sí | Proxy genérico al deployer |
| `/api/auth`, `/api/me` | — | Sesión HMAC 12h en cookie httpOnly | | `/api/auth`, `/api/me` | — | Sesión HMAC 12h en cookie httpOnly |
@@ -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<Response> {
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<string, unknown> = {};
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);
}
}
+68
View File
@@ -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<Response> {
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<Response> {
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);
}
}
+18 -2
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link";
import SamShell from "@/components/SamShell"; import SamShell from "@/components/SamShell";
import { refreshCorreo, useCorreoModulos } from "@/lib/correo"; import { refreshCorreo, useCorreoModulos } from "@/lib/correo";
import { refreshModules, useModules, useMe } from "@/lib/store"; import { refreshModules, useModules, useMe } from "@/lib/store";
@@ -82,8 +83,23 @@ export default function Correo() {
allowLogout allowLogout
backHref="/" backHref="/"
> >
<div className="alert alert-light section-header mb-3 py-2 px-3"> <div className="alert alert-light section-header mb-3 py-2 px-3 d-flex align-items-center justify-content-between flex-wrap gap-2">
<i className="bx bx-envelope me-1" /> Correo · Módulos autorizados <span>
<i className="bx bx-envelope me-1" /> Correo · Módulos autorizados
</span>
<span className="d-flex gap-2">
<Link href="/correo" className="btn btn-sm btn-primary">
<i className="bx bx-envelope me-1" />
Módulos
</Link>
<Link
href="/correo/plantillas"
className="btn btn-sm btn-outline-secondary"
>
<i className="bx bx-file-blank me-1" />
Plantillas
</Link>
</span>
</div> </div>
{error && <div className="alert alert-danger">{error}</div>} {error && <div className="alert alert-danger">{error}</div>}
+458
View File
@@ -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<string | null>(null);
const [busy, setBusy] = useState<string | null>(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 (
<SamShell
brand="IT"
subtitle="Correo · Plantillas"
menu={itMenu(modules)}
activeHref="/correo/plantillas"
topUser={me?.email || "SAM"}
allowLogout
backHref="/"
>
<div className="alert alert-light section-header mb-3 py-2 px-3 d-flex align-items-center justify-content-between flex-wrap gap-2">
<span>
<i className="bx bx-file-blank me-1" /> Correo · Plantillas
</span>
<span className="d-flex gap-2">
<Link href="/correo" className="btn btn-sm btn-outline-secondary">
<i className="bx bx-envelope me-1" />
Módulos
</Link>
<Link
href="/correo/plantillas"
className="btn btn-sm btn-primary"
>
<i className="bx bx-file-blank me-1" />
Plantillas
</Link>
</span>
</div>
{error && <div className="alert alert-danger">{error}</div>}
{plantillas.error && (
<div className="alert alert-danger">{plantillas.error}</div>
)}
{msg && (
<div className={`alert ${msg.ok ? "alert-success" : "alert-danger"}`}>
{msg.text}
</div>
)}
<div className="app-card p-3 mb-3">
<div className="d-flex gap-2 flex-wrap align-items-center mb-3">
<input
type="text"
className="form-control"
style={{ maxWidth: 320 }}
placeholder="Buscar por key, nombre o asunto…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => refreshPlantillas()}
title="Recargar"
>
<i className="bx bx-refresh me-1" />
Recargar
</button>
<small className="text-muted ms-auto">
{filtered.length} de {rows.length} plantillas · variables{" "}
<code>{"{{Clave}}"}</code>
</small>
</div>
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Key</th>
<th>Nombre</th>
<th>Asunto</th>
<th>Estado</th>
<th className="text-end">Acciones</th>
</tr>
</thead>
<tbody>
{filtered.map((t) => (
<tr
key={t.key}
className={sel === t.key ? "table-primary" : ""}
>
<td>
<code className="small">{t.key}</code>
</td>
<td className="small">{t.name}</td>
<td className="small text-truncate" style={{ maxWidth: 280 }}>
{t.subject}
</td>
<td>
{t.isActive ? (
<span className="badge bg-success">Activa</span>
) : (
<span className="badge bg-secondary">Inactiva</span>
)}
</td>
<td className="text-end">
<div className="d-flex gap-1 justify-content-end">
<button
className="btn btn-sm btn-outline-primary"
disabled={busy !== null}
onClick={() => pick(t)}
title="Ver / editar"
>
<i className="bx bx-edit" />
</button>
<button
className={`btn btn-sm ${t.isActive ? "btn-outline-secondary" : "btn-outline-success"}`}
disabled={busy !== null}
onClick={() => toggleActive(t)}
title={t.isActive ? "Desactivar" : "Activar"}
>
{busy === `toggle:${t.key}` ? (
<span className="spinner-border spinner-border-sm" />
) : (
<i
className={`bx ${t.isActive ? "bx-pause" : "bx-play"}`}
/>
)}
</button>
</div>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={5} className="text-center text-muted py-4">
{rows.length === 0
? "Sin plantillas en el banco."
: "Sin coincidencias."}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{selected && (
<div className="app-card p-3 mb-3">
<h6 className="fw-bold mb-3">
<i className="bx bx-edit me-1" />
Editar <code>{selected.key}</code>
</h6>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label small fw-bold">Asunto</label>
<input
type="text"
className="form-control"
value={eSubject}
onChange={(e) => setESubject(e.target.value)}
/>
<label className="form-label small fw-bold mt-3">
Cuerpo HTML (usa <code>{"{{Clave}}"}</code>)
</label>
<textarea
className="form-control font-monospace small"
rows={10}
value={eBody}
onChange={(e) => setEBody(e.target.value)}
/>
<div className="form-check mt-3">
<input
className="form-check-input"
type="checkbox"
id="tpl-active"
checked={eActive}
onChange={(e) => setEActive(e.target.checked)}
/>
<label className="form-check-label small" htmlFor="tpl-active">
Plantilla activa (los envíos con plantilla inactiva fallan)
</label>
</div>
<div className="d-flex gap-2 mt-3">
<button
className="btn btn-primary btn-sm"
disabled={busy !== null}
onClick={save}
>
{busy === `save:${selected.key}` ? (
<span className="spinner-border spinner-border-sm" />
) : (
<>
<i className="bx bx-save me-1" />
Guardar
</>
)}
</button>
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => setSel(null)}
>
Cerrar
</button>
</div>
</div>
<div className="col-md-6">
<label className="form-label small fw-bold">Vista previa</label>
<div className="border rounded p-3 bg-white">
<div className="small text-muted mb-1">Asunto:</div>
<div className="fw-bold mb-3">{eSubject || "—"}</div>
<div className="small text-muted mb-1">Cuerpo:</div>
<div
className="small"
dangerouslySetInnerHTML={{
__html: eBody || "<span class='text-muted'>—</span>",
}}
/>
</div>
</div>
</div>
</div>
)}
<div className="app-card p-3">
<h6 className="fw-bold mb-3">
<i className="bx bx-plus me-1" />
Nueva plantilla
</h6>
<div className="row g-2">
<div className="col-md-3">
<input
type="text"
className="form-control"
placeholder="key (ej: pago_ok)"
value={fKey}
onChange={(e) => setFKey(e.target.value)}
/>
</div>
<div className="col-md-3">
<input
type="text"
className="form-control"
placeholder="Nombre (ej: Pago recibido)"
value={fName}
onChange={(e) => setFName(e.target.value)}
/>
</div>
<div className="col-md-3">
<input
type="text"
className="form-control"
placeholder="Módulo (default: shared)"
value={fModule}
onChange={(e) => setFModule(e.target.value)}
/>
</div>
<div className="col-md-3">
<input
type="text"
className="form-control"
placeholder="Asunto (ej: Pago {{Folio}} recibido)"
value={fSubject}
onChange={(e) => setFSubject(e.target.value)}
/>
</div>
<div className="col-12">
<textarea
className="form-control font-monospace small"
rows={4}
placeholder="Cuerpo HTML con {{variables}} (ej: <p>Hola {{Nombre}}, …</p>)"
value={fBody}
onChange={(e) => setFBody(e.target.value)}
/>
</div>
</div>
<div className="d-flex gap-2 mt-3 align-items-center flex-wrap">
<button
className="btn btn-primary btn-sm"
disabled={busy !== null}
onClick={create}
>
{busy === "create" ? (
<span className="spinner-border spinner-border-sm" />
) : (
<>
<i className="bx bx-plus me-1" />
Crear plantilla
</>
)}
</button>
<small className="text-muted">
Key <code>[a-z0-9_]</code> 2-64. Se guarda en{" "}
<code>mail.templates</code> y queda disponible para{" "}
<code>POST /api/email/send</code> como{" "}
<code>templateKey</code>.
</small>
</div>
</div>
</SamShell>
);
}
+72
View File
@@ -12,6 +12,14 @@ export interface CorreoModule {
isActive: boolean; isActive: boolean;
} }
export interface CorreoTemplate {
key: string;
name: string;
subject: string;
bodyHtml: string;
isActive: boolean;
}
interface CorreoSnap { interface CorreoSnap {
data: CorreoModule[] | null; data: CorreoModule[] | null;
error: string | null; error: string | null;
@@ -70,3 +78,67 @@ export function useCorreoModulos(): CorreoSnap {
error: null, error: null,
})); }));
} }
// ============================================================
// Plantillas del banco único mail.templates (GET/POST/PUT vía
// /api/correo/plantillas). Mismo patrón de store que módulos.
// ============================================================
interface PlantillasSnap {
data: CorreoTemplate[] | null;
error: string | null;
}
async function fetchPlantillas(): Promise<PlantillasSnap> {
try {
const res = await fetch("/api/correo/plantillas", { cache: "no-store" });
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.message || j.error || `HTTP ${res.status}`);
return { data: Array.isArray(j) ? j : [], error: null };
} catch (e) {
return {
data: null,
error: e instanceof Error ? e.message : "Error al cargar plantillas",
};
}
}
let psnap: PlantillasSnap = { data: null, error: null };
let pstarted = false;
const plisteners = new Set<() => void>();
function pnotify() {
plisteners.forEach((l) => l());
}
export function refreshPlantillas() {
fetchPlantillas()
.then((next) => {
psnap = next;
})
.catch(() => {})
.finally(() => pnotify());
}
function psubscribe(cb: () => void) {
if (!pstarted) {
pstarted = true;
refreshPlantillas();
}
plisteners.add(cb);
return () => {
plisteners.delete(cb);
if (plisteners.size === 0) pstarted = false;
};
}
function psnapshot(): PlantillasSnap {
return psnap;
}
export function useCorreoPlantillas(): PlantillasSnap {
return useSyncExternalStore(psubscribe, psnapshot, () => ({
data: null,
error: null,
}));
}
+1
View File
@@ -452,5 +452,6 @@ export function itMenu(modules: ModuleState[]): MenuItem[] {
{ label: "Actualizar", icon: "bx-refresh", href: "/actualizar" }, { label: "Actualizar", icon: "bx-refresh", href: "/actualizar" },
{ label: "Eliminar", icon: "bx-trash", href: "/eliminar" }, { label: "Eliminar", icon: "bx-trash", href: "/eliminar" },
{ label: "Correo", icon: "bx-envelope", href: "/correo" }, { label: "Correo", icon: "bx-envelope", href: "/correo" },
{ label: "Plantillas", icon: "bx-file-blank", href: "/correo/plantillas" },
]; ];
} }