feat(notificaciones): edit summary recipients in the UI
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m32s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m28s

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:
2026-08-02 11:58:42 -07:00
co-authored by Claude Opus 5
parent f4b92fa7a5
commit a491ef3eed
16 changed files with 604 additions and 26 deletions
+4 -1
View File
@@ -49,7 +49,10 @@
# SES_FROM_NAME display name, optional # SES_FROM_NAME display name, optional
# SES_ACCESS_KEY / SES_SECRET_KEY # SES_ACCESS_KEY / SES_SECRET_KEY
# SES_CONFIGURATION_SET optional, for bounce/complaint events # SES_CONFIGURATION_SET optional, for bounce/complaint events
# NOTIFICATION_ADMIN_EMAILS comma-separated summary recipients # NOTIFICATION_ADMIN_EMAILS fallback only — the summary recipients
# are edited in the UI and stored in
# app_settings; this is what a deployment
# uses until somebody saves them there
# These are NOT galactus-specific (no _GALACTUS suffix) — one SES identity # These are NOT galactus-specific (no _GALACTUS suffix) — one SES identity
# serves every deployment. # serves every deployment.
# - The runner (which lives on cubex) must be able to reach BOTH # - The runner (which lives on cubex) must be able to reach BOTH
+6 -1
View File
@@ -40,7 +40,8 @@ export type Ability =
| "lookup:manage" | "lookup:manage"
| "user:manage" | "user:manage"
| "db:manage" | "db:manage"
| "notification:send"; | "notification:send"
| "setting:manage";
/** Minimum role required for each ability. */ /** Minimum role required for each ability. */
export const ABILITY_MIN: Record<Ability, Role> = { export const ABILITY_MIN: Record<Ability, Role> = {
@@ -79,6 +80,10 @@ export const ABILITY_MIN: Record<Ability, Role> = {
// a STAFF user typing one customer receipt is fine; a STAFF user firing // a STAFF user typing one customer receipt is fine; a STAFF user firing
// 260 mail merges on the customer base is not. // 260 mail merges on the customer base is not.
"notification:send": "MANAGER", "notification:send": "MANAGER",
// Editing operator configuration. Above `notification:send` on purpose:
// firing a sweep is the day job, but changing WHERE the audit summaries
// land is how someone would quietly stop them being read.
"setting:manage": "ADMIN",
}; };
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[]; export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
@@ -1,9 +1,11 @@
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Get, Get,
HttpCode, HttpCode,
Post, Post,
Put,
Query, Query,
Req, Req,
UseGuards, UseGuards,
@@ -15,11 +17,21 @@ import {
EmailNotificationType, EmailNotificationType,
} from "@jorgecuadros/database"; } from "@jorgecuadros/database";
import { Transform, Type } from "class-transformer"; import { Transform, Type } from "class-transformer";
import { IsEnum, IsInt, IsOptional, Max, Min } from "class-validator"; import {
ArrayMaxSize,
IsArray,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
Min,
} from "class-validator";
import { AuthenticatedGuard } from "../auth/authenticated.guard"; import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard"; import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator"; import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service"; import { AuditService } from "../common/audit.service";
import { invalidEmails, SettingsService } from "../settings/settings.service";
import { NotificationFlagsDto } from "./notification.types"; import { NotificationFlagsDto } from "./notification.types";
import { NotificationsService } from "./notifications.service"; import { NotificationsService } from "./notifications.service";
@@ -46,6 +58,15 @@ class ListLogDto {
@IsOptional() @IsEnum(["sent", "failed", "skipped", "all"]) view?: "sent" | "failed" | "skipped" | "all"; @IsOptional() @IsEnum(["sent", "failed", "skipped", "all"]) view?: "sent" | "failed" | "skipped" | "all";
} }
/** An empty array is valid and means "send no summaries" — the cap only
* exists so a paste accident can't write an unbounded blob. */
class AdminEmailsDto {
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
emails!: string[];
}
function actingId(req: Request): string { function actingId(req: Request): string {
return (req.user as { id: string }).id; return (req.user as { id: string }).id;
} }
@@ -62,6 +83,7 @@ export class NotificationsController {
constructor( constructor(
private readonly svc: NotificationsService, private readonly svc: NotificationsService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly settings: SettingsService,
) {} ) {}
/* -------------------------------------------------------------- triggers */ /* -------------------------------------------------------------- triggers */
@@ -199,6 +221,35 @@ export class NotificationsController {
return this.svc.stats(q.servicio); return this.svc.stats(q.servicio);
} }
/* -------------------------------------------------------------- settings */
/** Who receives the per-job summary email. Readable by any logged-in user
* so the UI can show the current list; editing needs `setting:manage`. */
@Get("settings/admin-emails")
adminEmails() {
return this.settings.notificationAdminEmails();
}
@Put("settings/admin-emails")
@RequireAbility("setting:manage")
async setAdminEmails(@Body() dto: AdminEmailsDto, @Req() req: Request) {
const emails = dto.emails.map((e) => e.trim()).filter(Boolean);
const bad = invalidEmails(emails);
if (bad.length) {
throw new BadRequestException(
`Correo inválido: ${bad.join(", ")}`,
);
}
const result = await this.settings.setNotificationAdminEmails(
emails,
actingId(req),
);
void this.audit.log(actingId(req), "notification.settings.admin-emails", {
emails,
});
return result;
}
/** Resolve the UI's coarse view tabs to concrete statuses. An explicit /** Resolve the UI's coarse view tabs to concrete statuses. An explicit
* `status` wins. "Omitidos" covers both SKIPPED_* variants, which is why * `status` wins. "Omitidos" covers both SKIPPED_* variants, which is why
* this returns a list rather than a single value. */ * this returns a list rather than a single value. */
@@ -1,5 +1,6 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { NotificationLogModule } from "./notification-log.module"; import { NotificationLogModule } from "./notification-log.module";
import { SettingsModule } from "../settings/settings.module";
import { NotificationsController } from "./notifications.controller"; import { NotificationsController } from "./notifications.controller";
import { NotificationsService } from "./notifications.service"; import { NotificationsService } from "./notifications.service";
@@ -12,7 +13,7 @@ import { NotificationsService } from "./notifications.service";
* service methods are already the entry points they would call. * service methods are already the entry points they would call.
*/ */
@Module({ @Module({
imports: [NotificationLogModule], imports: [NotificationLogModule, SettingsModule],
controllers: [NotificationsController], controllers: [NotificationsController],
providers: [NotificationsService], providers: [NotificationsService],
exports: [NotificationsService], exports: [NotificationsService],
@@ -1,5 +1,4 @@
import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common"; import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { import {
Currency, Currency,
EmailNotificationServicio, EmailNotificationServicio,
@@ -10,6 +9,7 @@ import {
} from "@jorgecuadros/database"; } from "@jorgecuadros/database";
import { MailService } from "../mail/mail.service"; import { MailService } from "../mail/mail.service";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { SettingsService } from "../settings/settings.service";
import { NotificationLogService } from "./notification-log.service"; import { NotificationLogService } from "./notification-log.service";
import { import {
SendAttempt, SendAttempt,
@@ -75,26 +75,12 @@ const PAYMENT_LOOKBACK_HOURS = 24;
export class NotificationsService { export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name); private readonly logger = new Logger(NotificationsService.name);
/** Override addresses — comma-separated in env. Falls back to the
* legacy defaults so a fresh deploy still has somewhere to send. */
private readonly adminEmails: string[];
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly mail: MailService, private readonly mail: MailService,
private readonly log: NotificationLogService, private readonly log: NotificationLogService,
config: ConfigService, private readonly settings: SettingsService,
) { ) {}
const csv = config.get<string>("NOTIFICATION_ADMIN_EMAILS");
if (csv && csv.trim()) {
this.adminEmails = csv
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else {
this.adminEmails = ["rmancinas@freakma.net", "mpulido@freakma.net"];
}
}
/* ============================================================================ /* ============================================================================
* Public jobs — called by the controller and by future cron sweeps alike. * Public jobs — called by the controller and by future cron sweeps alike.
@@ -958,7 +944,11 @@ export class NotificationsService {
* response so it matches the legacy format verbatim. */ * response so it matches the legacy format verbatim. */
private async adminSummary(subject: string, response: NotificationJobResponse) { private async adminSummary(subject: string, response: NotificationJobResponse) {
const body = JSON.stringify(response); const body = JSON.stringify(response);
for (const to of this.adminEmails) { // Resolved per job, not cached at boot: the recipient list is edited in
// the UI while the app is running (SettingsService), and a cached copy
// would put us back to needing a restart for it to take effect.
const { value: adminEmails } = await this.settings.notificationAdminEmails();
for (const to of adminEmails) {
try { try {
const { messageId } = await this.mail.send({ const { messageId } = await this.mail.send({
to, to,
+14
View File
@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { SettingsService } from "./settings.service";
/**
* Operator-editable configuration. No controller of its own — each setting is
* exposed by the feature that owns it (summary recipients live under
* /notifications), so the validation and the permission live next to the
* thing they protect rather than behind a generic key/value endpoint.
*/
@Module({
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}
@@ -0,0 +1,89 @@
import { SettingsService, invalidEmails, parseEmailList } from "./settings.service";
/**
* The db → env → default ladder is the whole contract of this service: it is
* what lets the setting move out of the environment without changing how any
* existing deployment behaves.
*/
function build(row: { value: string } | null, env?: string) {
const prisma = {
appSetting: {
findUnique: jest.fn().mockResolvedValue(
row ? { key: "k", updatedAt: new Date("2026-08-02"), updatedById: "u1", ...row } : null,
),
upsert: jest.fn().mockResolvedValue({}),
},
};
const config = { get: jest.fn().mockReturnValue(env) };
return {
service: new SettingsService(prisma as never, config as never),
prisma,
};
}
describe("notification admin emails resolve db > env > default", () => {
it("prefers the stored row", async () => {
const { service } = build({ value: "a@x.com,b@x.com" }, "env@x.com");
await expect(service.notificationAdminEmails()).resolves.toMatchObject({
value: ["a@x.com", "b@x.com"],
source: "db",
updatedById: "u1",
});
});
it("falls back to the environment when nothing is stored", async () => {
const { service } = build(null, "env@x.com, other@x.com");
await expect(service.notificationAdminEmails()).resolves.toMatchObject({
value: ["env@x.com", "other@x.com"],
source: "env",
});
});
it("falls back to the built-in defaults when neither is set", async () => {
const { service } = build(null, undefined);
const resolved = await service.notificationAdminEmails();
expect(resolved.source).toBe("default");
expect(resolved.value).toHaveLength(2);
});
it("treats a stored empty list as 'nobody', not as unset", async () => {
// The regression this guards: falling through to env/defaults here would
// keep mailing people who were deliberately removed.
const { service } = build({ value: "" }, "env@x.com");
await expect(service.notificationAdminEmails()).resolves.toMatchObject({
value: [],
source: "db",
});
});
it("writes the list back as CSV", async () => {
const { service, prisma } = build({ value: "" });
await service.setNotificationAdminEmails(["a@x.com", "b@x.com"], "user-9");
expect(prisma.appSetting.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({ value: "a@x.com,b@x.com", updatedById: "user-9" }),
update: expect.objectContaining({ value: "a@x.com,b@x.com", updatedById: "user-9" }),
}),
);
});
});
describe("email list parsing", () => {
it("trims and drops blanks", () => {
expect(parseEmailList(" a@x.com , ,b@x.com ")).toEqual(["a@x.com", "b@x.com"]);
});
it("rejects entries that are not addresses at all", () => {
expect(invalidEmails(["ok@x.com", "nope", "also@bad"])).toEqual([
"nope",
"also@bad",
]);
});
});
+128
View File
@@ -0,0 +1,128 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PrismaService } from "../prisma/prisma.service";
/**
* Reader/writer for `app_settings` — the configuration staff can change
* without a redeploy.
*
* Every setting resolves through the same three-step ladder: the database row
* if an operator has set one, else the environment variable it used to live
* in, else a hardcoded default. That ordering is what makes this migration
* safe — an existing deployment keeps behaving exactly as it did until
* somebody edits the value in the UI, and `source` tells the UI which of the
* three it is looking at so "this came from the env, editing it here will
* take over" is visible rather than surprising.
*/
export const SETTING_KEYS = {
/** Comma-separated recipients of the per-job notification summary. */
notificationAdminEmails: "notification.adminEmails",
} as const;
/** Where a resolved value came from. Shown in the UI. */
export type SettingSource = "db" | "env" | "default";
export interface ResolvedSetting<T> {
value: T;
source: SettingSource;
updatedAt: Date | null;
updatedById: string | null;
}
/** Last resort when neither the database nor the environment says otherwise.
* Matches what `NotificationsService` hardcoded before this table existed. */
const DEFAULT_ADMIN_EMAILS = ["rmancinas@freakma.net", "mpulido@freakma.net"];
/** Deliberately permissive — this rejects "not an address at all", not
* "not deliverable". Only SES can tell us the latter, and a validator strict
* enough to argue with is a validator that blocks a legitimate address. */
const EMAIL_RE = /^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/;
export function parseEmailList(raw: string): string[] {
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
export function invalidEmails(list: string[]): string[] {
return list.filter((e) => !EMAIL_RE.test(e));
}
@Injectable()
export class SettingsService {
private readonly logger = new Logger(SettingsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {}
/**
* Recipients of the per-job summary email.
*
* Read on every send rather than cached at boot: the point of moving this
* out of the environment was that it changes while the app is running, and
* a cache would reintroduce exactly the restart-to-apply behaviour we are
* removing. It is one indexed primary-key lookup per sweep, not per email.
*/
async notificationAdminEmails(): Promise<ResolvedSetting<string[]>> {
const row = await this.read(SETTING_KEYS.notificationAdminEmails);
if (row) {
const parsed = parseEmailList(row.value);
// An empty stored value is a legitimate choice — "send no summaries" —
// and must not silently fall through to the env or the defaults, or an
// operator who cleared the field would keep receiving mail.
return {
value: parsed,
source: "db",
updatedAt: row.updatedAt,
updatedById: row.updatedById,
};
}
const env = this.config.get<string>("NOTIFICATION_ADMIN_EMAILS");
if (env && env.trim()) {
return {
value: parseEmailList(env),
source: "env",
updatedAt: null,
updatedById: null,
};
}
return {
value: [...DEFAULT_ADMIN_EMAILS],
source: "default",
updatedAt: null,
updatedById: null,
};
}
/** Persist the summary recipients. An empty list is stored as an empty
* string and means "nobody" — see the read path above. */
async setNotificationAdminEmails(
emails: string[],
userId: string,
): Promise<ResolvedSetting<string[]>> {
await this.write(
SETTING_KEYS.notificationAdminEmails,
emails.join(","),
userId,
);
return this.notificationAdminEmails();
}
private read(key: string) {
return this.prisma.appSetting.findUnique({ where: { key } });
}
private async write(key: string, value: string, userId: string) {
await this.prisma.appSetting.upsert({
where: { key },
create: { key, value, updatedById: userId },
update: { value, updatedById: userId },
});
}
}
@@ -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, NOTIFICATION_TYPE_LABELS,
} from "@/lib/labels"; } from "@/lib/labels";
import { NotificationLogPanel } from "@/components/NotificationLogPanel"; import { NotificationLogPanel } from "@/components/NotificationLogPanel";
import { AdminEmailsSetting } from "@/components/AdminEmailsSetting";
import { import {
getNotificationStats, getNotificationStats,
runAccountStatus, runAccountStatus,
@@ -350,6 +351,8 @@ export function NotificacionesServicios() {
</section> </section>
)} )}
<AdminEmailsSetting />
{lastResult && ( {lastResult && (
<section className="card" style={{ padding: 20 }}> <section className="card" style={{ padding: 20 }}>
<h2 className="section-title">Última respuesta</h2> <h2 className="section-title">Última respuesta</h2>
+25
View File
@@ -1186,6 +1186,31 @@ export function listNotificationLog(
return apiFetch<NotificationLogPage>(`/notifications/log${tail ? `?${tail}` : ""}`); 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( export function getNotificationStats(
servicio?: NotificationServicio[], servicio?: NotificationServicio[],
): Promise<NotificationStats> { ): Promise<NotificationStats> {
+2 -1
View File
@@ -28,7 +28,8 @@ export type Ability =
| "lookup:manage" | "lookup:manage"
| "user:manage" | "user:manage"
| "db:manage" | "db:manage"
| "notification:send"; | "notification:send"
| "setting:manage";
export interface AuthUser { export interface AuthUser {
id: string; id: string;
+4 -2
View File
@@ -56,6 +56,8 @@ SES_SECRET_KEY=
# Optional — only needed to publish bounce/complaint events. # Optional — only needed to publish bounce/complaint events.
SES_CONFIGURATION_SET= SES_CONFIGURATION_SET=
# Recipients of the per-job summary email. Comma-separated; unset falls back to # Recipients of the per-job summary email. NOW EDITABLE IN THE UI
# the defaults in NotificationsService. # (/notificaciones > Servicios > "Destinatarios del resumen", ADMIN only), so
# this is only the fallback for a deployment where nobody has set it there.
# A saved value takes precedence and this var is ignored from then on.
NOTIFICATION_ADMIN_EMAILS=rmancinas@freakma.net,mpulido@freakma.net NOTIFICATION_ADMIN_EMAILS=rmancinas@freakma.net,mpulido@freakma.net
+25 -1
View File
@@ -131,9 +131,14 @@ SES_SECRET_KEY=...
SES_FROM=mail@jorgecuadros.com SES_FROM=mail@jorgecuadros.com
SES_FROM_NAME=Information Server SES_FROM_NAME=Information Server
SES_CONFIGURATION_SET=... # optional SES_CONFIGURATION_SET=... # optional
NOTIFICATION_ADMIN_EMAILS=rmancinas@freakma.net,mpulido@freakma.net NOTIFICATION_ADMIN_EMAILS=rmancinas@freakma.net,mpulido@freakma.net # fallback only
``` ```
`NOTIFICATION_ADMIN_EMAILS` is no longer the source of truth. The summary
recipients are edited in the UI and stored in `app_settings`; the env var
is the fallback for a deployment where nobody has saved them yet. See
"Operator settings" below.
Without SES_* the API still boots and `MailService` falls back to stdout Without SES_* the API still boots and `MailService` falls back to stdout
in dev (`NODE_ENV !== "production"`). In production every send throws in dev (`NODE_ENV !== "production"`). In production every send throws
`ServiceUnavailableException` and the row is recorded as `FAILED`. `ServiceUnavailableException` and the row is recorded as `FAILED`.
@@ -164,6 +169,25 @@ Both render the same `NotificationLogPanel` ("Registro de envíos"), which
filters by servicio and by view (todos / enviados / fallidos / omitidos). filters by servicio and by view (todos / enviados / fallidos / omitidos).
STAFF users see the Servicios log read-only. STAFF users see the Servicios log read-only.
## Operator settings
`app_settings` holds the configuration staff change without a redeploy.
`SettingsService` resolves every key **db → env → default**, and reports
which of the three a value came from so the UI can say so. Adding a key
means adding a typed accessor there, not a generic getter.
Currently one key: `notification.adminEmails` (summary recipients), edited
on the Servicios tab, gated on `setting:manage` (ADMIN — above
`notification:send`, because redirecting the audit summaries is how someone
would stop them being read). Read on every job rather than cached, so an
edit takes effect on the next sweep with no restart. An empty saved list
means "nobody" and deliberately does **not** fall through to the env.
Credentials do not belong here. SES keys, `DATABASE_URL` and S3 config stay
in the environment: they are deployment identity, they must exist before
the app can reach its own database, and a table only widens who can read
them.
## Cron (future) ## Cron (future)
The four service methods (`runOutstandingPayments`, `runPaymentConfirmation`, The four service methods (`runOutstandingPayments`, `runPaymentConfirmation`,
@@ -0,0 +1,19 @@
-- Operator-editable configuration.
--
-- First tenant: the notification summary recipients, which were
-- NOTIFICATION_ADMIN_EMAILS in the environment. That list changes when office
-- staff change — a redeploy is the wrong unit of work for "Ana left, add
-- Beto" — so it belongs in the database with a UI, not in a stack env var.
--
-- Credentials stay in env. See the model doc in schema.prisma for where the
-- line is drawn.
-- CreateTable
CREATE TABLE `app_settings` (
`key` VARCHAR(191) NOT NULL,
`value` TEXT NOT NULL,
`updatedAt` DATETIME(3) NOT NULL,
`updatedById` VARCHAR(191) NULL,
PRIMARY KEY (`key`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+27
View File
@@ -1125,3 +1125,30 @@ model ScheduledJobState {
@@map("scheduled_job_states") @@map("scheduled_job_states")
} }
/// Operator-editable configuration — the settings that staff must be able to
/// change without a redeploy.
///
/// Deliberately NOT a home for everything in the environment. Credentials and
/// endpoints (SES keys, DATABASE_URL, S3) stay in env: they are deployment
/// identity, they must exist before the app can talk to its own database, and
/// putting a secret in a table only widens who can read it. What belongs here
/// is the opposite kind of value — no secret, changes on office business
/// rhythm rather than deploy rhythm, and wrong far more often than the
/// deployment is.
///
/// `value` is TEXT holding whatever encoding the owning feature defines
/// (a comma-separated list, a JSON blob). Each setting has exactly one reader,
/// which owns parsing and validation; there is no generic typed accessor,
/// because a schema-less bag with a typed façade is just a schema with the
/// checks moved somewhere easier to forget.
model AppSetting {
key String @id
value String @db.Text
updatedAt DateTime @updatedAt
/// Who last changed it. Null for rows written before the UI existed or by
/// a migration. Not an FK: a setting must outlive the user who set it.
updatedById String?
@@map("app_settings")
}