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
@@ -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. */
@@ -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],
@@ -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<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"];
}
}
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,