From a491ef3eed02e8916a83d0150dff7d49077af5e4 Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Sun, 2 Aug 2026 11:58:42 -0700 Subject: [PATCH] feat(notificaciones): edit summary recipients in the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitea/workflows/deploy-galactus.yml | 5 +- apps/api/src/auth/abilities.ts | 7 +- .../notifications/notifications.controller.ts | 53 ++++- .../src/notifications/notifications.module.ts | 3 +- .../notifications/notifications.service.ts | 26 +-- apps/api/src/settings/settings.module.ts | 14 ++ .../api/src/settings/settings.service.spec.ts | 89 ++++++++ apps/api/src/settings/settings.service.ts | 128 ++++++++++++ .../web/src/components/AdminEmailsSetting.tsx | 196 ++++++++++++++++++ .../components/NotificacionesServicios.tsx | 3 + apps/web/src/lib/api.ts | 25 +++ apps/web/src/lib/types.ts | 3 +- deploy/jorgecuadros-app.env.example | 6 +- docs/MASS_EMAIL_NOTIFICATIONS.md | 26 ++- .../20260802140000_app_settings/migration.sql | 19 ++ packages/database/prisma/schema.prisma | 27 +++ 16 files changed, 604 insertions(+), 26 deletions(-) create mode 100644 apps/api/src/settings/settings.module.ts create mode 100644 apps/api/src/settings/settings.service.spec.ts create mode 100644 apps/api/src/settings/settings.service.ts create mode 100644 apps/web/src/components/AdminEmailsSetting.tsx create mode 100644 packages/database/prisma/migrations/20260802140000_app_settings/migration.sql diff --git a/.gitea/workflows/deploy-galactus.yml b/.gitea/workflows/deploy-galactus.yml index 8a1ed98..839bf84 100644 --- a/.gitea/workflows/deploy-galactus.yml +++ b/.gitea/workflows/deploy-galactus.yml @@ -49,7 +49,10 @@ # SES_FROM_NAME display name, optional # SES_ACCESS_KEY / SES_SECRET_KEY # 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 # serves every deployment. # - The runner (which lives on cubex) must be able to reach BOTH diff --git a/apps/api/src/auth/abilities.ts b/apps/api/src/auth/abilities.ts index 94c58db..a1e0671 100644 --- a/apps/api/src/auth/abilities.ts +++ b/apps/api/src/auth/abilities.ts @@ -40,7 +40,8 @@ export type Ability = | "lookup:manage" | "user:manage" | "db:manage" - | "notification:send"; + | "notification:send" + | "setting:manage"; /** Minimum role required for each ability. */ export const ABILITY_MIN: Record = { @@ -79,6 +80,10 @@ export const ABILITY_MIN: Record = { // a STAFF user typing one customer receipt is fine; a STAFF user firing // 260 mail merges on the customer base is not. "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[]; diff --git a/apps/api/src/notifications/notifications.controller.ts b/apps/api/src/notifications/notifications.controller.ts index 2fbb0ba..b9c4f96 100644 --- a/apps/api/src/notifications/notifications.controller.ts +++ b/apps/api/src/notifications/notifications.controller.ts @@ -1,9 +1,11 @@ import { + BadRequestException, Body, Controller, Get, HttpCode, Post, + Put, Query, Req, UseGuards, @@ -15,11 +17,21 @@ import { EmailNotificationType, } from "@jorgecuadros/database"; 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 { AbilityGuard } from "../auth/ability.guard"; import { RequireAbility } from "../auth/require-ability.decorator"; import { AuditService } from "../common/audit.service"; +import { invalidEmails, SettingsService } from "../settings/settings.service"; import { NotificationFlagsDto } from "./notification.types"; import { NotificationsService } from "./notifications.service"; @@ -46,6 +58,15 @@ class ListLogDto { @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 { return (req.user as { id: string }).id; } @@ -62,6 +83,7 @@ export class NotificationsController { constructor( private readonly svc: NotificationsService, private readonly audit: AuditService, + private readonly settings: SettingsService, ) {} /* -------------------------------------------------------------- triggers */ @@ -199,6 +221,35 @@ export class NotificationsController { 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 * `status` wins. "Omitidos" covers both SKIPPED_* variants, which is why * this returns a list rather than a single value. */ diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts index c4bf55b..c0382dd 100644 --- a/apps/api/src/notifications/notifications.module.ts +++ b/apps/api/src/notifications/notifications.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { NotificationLogModule } from "./notification-log.module"; +import { SettingsModule } from "../settings/settings.module"; import { NotificationsController } from "./notifications.controller"; import { NotificationsService } from "./notifications.service"; @@ -12,7 +13,7 @@ import { NotificationsService } from "./notifications.service"; * service methods are already the entry points they would call. */ @Module({ - imports: [NotificationLogModule], + imports: [NotificationLogModule, SettingsModule], controllers: [NotificationsController], providers: [NotificationsService], exports: [NotificationsService], diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts index fadf3e6..9496090 100644 --- a/apps/api/src/notifications/notifications.service.ts +++ b/apps/api/src/notifications/notifications.service.ts @@ -1,5 +1,4 @@ import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { Currency, EmailNotificationServicio, @@ -10,6 +9,7 @@ import { } from "@jorgecuadros/database"; import { MailService } from "../mail/mail.service"; import { PrismaService } from "../prisma/prisma.service"; +import { SettingsService } from "../settings/settings.service"; import { NotificationLogService } from "./notification-log.service"; import { SendAttempt, @@ -75,26 +75,12 @@ const PAYMENT_LOOKBACK_HOURS = 24; export class NotificationsService { 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( private readonly prisma: PrismaService, private readonly mail: MailService, private readonly log: NotificationLogService, - config: ConfigService, - ) { - const csv = config.get("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"]; - } - } + private readonly settings: SettingsService, + ) {} /* ============================================================================ * 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. */ private async adminSummary(subject: string, response: NotificationJobResponse) { 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 { const { messageId } = await this.mail.send({ to, diff --git a/apps/api/src/settings/settings.module.ts b/apps/api/src/settings/settings.module.ts new file mode 100644 index 0000000..001caf0 --- /dev/null +++ b/apps/api/src/settings/settings.module.ts @@ -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 {} diff --git a/apps/api/src/settings/settings.service.spec.ts b/apps/api/src/settings/settings.service.spec.ts new file mode 100644 index 0000000..0d5ddc0 --- /dev/null +++ b/apps/api/src/settings/settings.service.spec.ts @@ -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", + ]); + }); +}); diff --git a/apps/api/src/settings/settings.service.ts b/apps/api/src/settings/settings.service.ts new file mode 100644 index 0000000..eb17792 --- /dev/null +++ b/apps/api/src/settings/settings.service.ts @@ -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 { + 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> { + 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("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> { + 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 }, + }); + } +} diff --git a/apps/web/src/components/AdminEmailsSetting.tsx b/apps/web/src/components/AdminEmailsSetting.tsx new file mode 100644 index 0000000..0f60efc --- /dev/null +++ b/apps/web/src/components/AdminEmailsSetting.tsx @@ -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 = { + 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(null); + const [draft, setDraft] = useState(""); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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 ( +
+

Destinatarios del resumen

+ {error ? ( +
+ {error} +
+ ) : ( +

+ Cargando… +

+ )} +
+ ); + } + + return ( +
+
+
+

Destinatarios del resumen

+

+ 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. +

+
+ {canEdit && !editing && ( + + )} +
+ + {error && ( +
+ {error} +
+ )} + + {editing ? ( +
+ +
+ + +
+
+ ) : ( +
+ {setting.value.length === 0 ? ( + + Nadie recibe el resumen de los envíos. + + ) : ( +
    + {setting.value.map((email) => ( +
  • + {email} +
  • + ))} +
+ )} +

+ {SOURCE_NOTE[setting.source]} + {setting.updatedAt && + ` Última edición: ${formatDateTime(setting.updatedAt)}.`} + {saved && " Guardado."} +

+ {!canEdit && ( +

+ Solo un ADMIN puede cambiar esta lista. +

+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/NotificacionesServicios.tsx b/apps/web/src/components/NotificacionesServicios.tsx index 96250c7..74fa640 100644 --- a/apps/web/src/components/NotificacionesServicios.tsx +++ b/apps/web/src/components/NotificacionesServicios.tsx @@ -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() { )} + + {lastResult && (

Última respuesta

diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 6d86b41..f4f77b3 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1186,6 +1186,31 @@ export function listNotificationLog( return apiFetch(`/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 { + return apiFetch("/notifications/settings/admin-emails"); +} + +export function setNotificationAdminEmails( + emails: string[], +): Promise { + return apiFetch("/notifications/settings/admin-emails", { + method: "PUT", + body: JSON.stringify({ emails }), + }); +} + export function getNotificationStats( servicio?: NotificationServicio[], ): Promise { diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 5275998..17484ff 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -28,7 +28,8 @@ export type Ability = | "lookup:manage" | "user:manage" | "db:manage" - | "notification:send"; + | "notification:send" + | "setting:manage"; export interface AuthUser { id: string; diff --git a/deploy/jorgecuadros-app.env.example b/deploy/jorgecuadros-app.env.example index 427eae3..1ca4064 100644 --- a/deploy/jorgecuadros-app.env.example +++ b/deploy/jorgecuadros-app.env.example @@ -56,6 +56,8 @@ SES_SECRET_KEY= # Optional — only needed to publish bounce/complaint events. SES_CONFIGURATION_SET= -# Recipients of the per-job summary email. Comma-separated; unset falls back to -# the defaults in NotificationsService. +# Recipients of the per-job summary email. NOW EDITABLE IN THE UI +# (/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 diff --git a/docs/MASS_EMAIL_NOTIFICATIONS.md b/docs/MASS_EMAIL_NOTIFICATIONS.md index 3eebddd..511a9ba 100644 --- a/docs/MASS_EMAIL_NOTIFICATIONS.md +++ b/docs/MASS_EMAIL_NOTIFICATIONS.md @@ -131,9 +131,14 @@ SES_SECRET_KEY=... SES_FROM=mail@jorgecuadros.com SES_FROM_NAME=Information Server 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 in dev (`NODE_ENV !== "production"`). In production every send throws `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). 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) The four service methods (`runOutstandingPayments`, `runPaymentConfirmation`, diff --git a/packages/database/prisma/migrations/20260802140000_app_settings/migration.sql b/packages/database/prisma/migrations/20260802140000_app_settings/migration.sql new file mode 100644 index 0000000..3b8421d --- /dev/null +++ b/packages/database/prisma/migrations/20260802140000_app_settings/migration.sql @@ -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; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 87ce761..d7b08a8 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -1125,3 +1125,30 @@ model ScheduledJobState { @@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") +}