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