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>
90 lines
2.9 KiB
TypeScript
90 lines
2.9 KiB
TypeScript
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",
|
|
]);
|
|
});
|
|
});
|