feat(notificaciones): edit summary recipients in the UI
NOTIFICATION_ADMIN_EMAILS made "add Beto to the summaries" a redeploy — the wrong unit of work for a list that changes when office staff change. Adds `app_settings`, a key/value table for the configuration staff must be able to change without a deploy, and `SettingsService`, which resolves every key db -> env -> default and reports which of the three a value came from. That ladder is what makes the move safe: a deployment behaves exactly as before until somebody saves in the UI, and the screen can say "this is still coming from the deployment" rather than implying somebody chose it. - new ability `setting:manage` (ADMIN) — deliberately above `notification:send`, since redirecting the audit summaries is how someone would quietly stop them being read - GET/PUT /notifications/settings/admin-emails; read is open to any logged-in user so the UI can display the list, write is gated - resolved per job, not cached at boot, or we would reintroduce exactly the restart-to-apply behaviour being removed - a saved empty list means "nobody" and does NOT fall through to the env, or clearing the field would keep mailing the people just removed Credentials stay in env — see the model doc for where the line is drawn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import {
|
||||
getNotificationAdminEmails,
|
||||
setNotificationAdminEmails,
|
||||
type NotificationAdminEmails,
|
||||
} from "@/lib/api";
|
||||
import { formatDateTime } from "@/lib/labels";
|
||||
|
||||
/**
|
||||
* Who receives the per-job summary email.
|
||||
*
|
||||
* This used to be NOTIFICATION_ADMIN_EMAILS in the deployment environment,
|
||||
* which made "add Beto to the summaries" a redeploy. It is now a stored
|
||||
* setting; the env var still acts as the fallback until someone saves here,
|
||||
* so nothing changes for a deployment that never touches this screen.
|
||||
*/
|
||||
|
||||
const SOURCE_NOTE: Record<NotificationAdminEmails["source"], string> = {
|
||||
db: "Guardado desde esta pantalla.",
|
||||
env: "Viene de la configuración del despliegue (NOTIFICATION_ADMIN_EMAILS). Al guardar aquí, este valor toma precedencia.",
|
||||
default: "Nadie lo ha configurado; se están usando los valores por omisión.",
|
||||
};
|
||||
|
||||
export function AdminEmailsSetting() {
|
||||
const canEdit = useCan("setting:manage");
|
||||
|
||||
const [setting, setSetting] = useState<NotificationAdminEmails | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await getNotificationAdminEmails();
|
||||
setSetting(data);
|
||||
setDraft(data.value.join(", "));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
const emails = draft
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const data = await setNotificationAdminEmails(emails);
|
||||
setSetting(data);
|
||||
setDraft(data.value.join(", "));
|
||||
setEditing(false);
|
||||
setSaved(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
setDraft(setting?.value.join(", ") ?? "");
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
if (!setting) {
|
||||
return (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Destinatarios del resumen</h2>
|
||||
{error ? (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted small" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
Cargando…
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="section-title">Destinatarios del resumen</h2>
|
||||
<p className="muted small" style={{ marginTop: 4, marginBottom: 0, maxWidth: 620 }}>
|
||||
Después de cada envío se manda un correo interno con el resultado
|
||||
(enviados, omitidos, fallidos). Estas son las direcciones que lo
|
||||
reciben. No afecta a los correos que reciben los clientes.
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && !editing && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="field" style={{ marginBottom: 8 }}>
|
||||
<span className="field-label">
|
||||
Correos separados por coma (vacío = no enviar resumen a nadie)
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={draft}
|
||||
disabled={saving}
|
||||
placeholder="alguien@ejemplo.com, otro@ejemplo.com"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? "Guardando…" : "Guardar"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={saving}
|
||||
onClick={cancel}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{setting.value.length === 0 ? (
|
||||
<span className="empty-inline">
|
||||
Nadie recibe el resumen de los envíos.
|
||||
</span>
|
||||
) : (
|
||||
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{setting.value.map((email) => (
|
||||
<li key={email} className="mono">
|
||||
{email}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="section-note" style={{ marginTop: 10, marginBottom: 0 }}>
|
||||
{SOURCE_NOTE[setting.source]}
|
||||
{setting.updatedAt &&
|
||||
` Última edición: ${formatDateTime(setting.updatedAt)}.`}
|
||||
{saved && " Guardado."}
|
||||
</p>
|
||||
{!canEdit && (
|
||||
<p className="section-note" style={{ marginTop: 6, marginBottom: 0 }}>
|
||||
Solo un ADMIN puede cambiar esta lista.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
NOTIFICATION_TYPE_LABELS,
|
||||
} from "@/lib/labels";
|
||||
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
|
||||
import { AdminEmailsSetting } from "@/components/AdminEmailsSetting";
|
||||
import {
|
||||
getNotificationStats,
|
||||
runAccountStatus,
|
||||
@@ -350,6 +351,8 @@ export function NotificacionesServicios() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<AdminEmailsSetting />
|
||||
|
||||
{lastResult && (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Última respuesta</h2>
|
||||
|
||||
@@ -1186,6 +1186,31 @@ export function listNotificationLog(
|
||||
return apiFetch<NotificationLogPage>(`/notifications/log${tail ? `?${tail}` : ""}`);
|
||||
}
|
||||
|
||||
/** Where a setting's current value came from — shown so an operator can tell
|
||||
* "nobody has set this, you are seeing the deploy's value" from "somebody
|
||||
* set this on purpose". */
|
||||
export type SettingSource = "db" | "env" | "default";
|
||||
|
||||
export interface NotificationAdminEmails {
|
||||
value: string[];
|
||||
source: SettingSource;
|
||||
updatedAt: string | null;
|
||||
updatedById: string | null;
|
||||
}
|
||||
|
||||
export function getNotificationAdminEmails(): Promise<NotificationAdminEmails> {
|
||||
return apiFetch<NotificationAdminEmails>("/notifications/settings/admin-emails");
|
||||
}
|
||||
|
||||
export function setNotificationAdminEmails(
|
||||
emails: string[],
|
||||
): Promise<NotificationAdminEmails> {
|
||||
return apiFetch<NotificationAdminEmails>("/notifications/settings/admin-emails", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ emails }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getNotificationStats(
|
||||
servicio?: NotificationServicio[],
|
||||
): Promise<NotificationStats> {
|
||||
|
||||
@@ -28,7 +28,8 @@ export type Ability =
|
||||
| "lookup:manage"
|
||||
| "user:manage"
|
||||
| "db:manage"
|
||||
| "notification:send";
|
||||
| "notification:send"
|
||||
| "setting:manage";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user