Files
jorgecuadros-platform/apps/web/src/components/AdminEmailsSetting.tsx
T
rmancinasandClaude Opus 5 a491ef3eed
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m32s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m28s
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>
2026-08-02 11:58:42 -07:00

197 lines
5.9 KiB
TypeScript

"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>
);
}