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:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user