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:
@@ -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<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
|
||||
// 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[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
} from "@/lib/labels";
|
||||
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
|
||||
import { AdminEmailsSetting } from "@/components/AdminEmailsSetting";
|
||||
import {
|
||||
getNotificationStats,
|
||||
runAccountStatus,
|
||||
@@ -350,6 +351,8 @@ export function NotificacionesServicios() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<AdminEmailsSetting />
|
||||
|
||||
{lastResult && (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Última respuesta</h2>
|
||||
|
||||
@@ -1186,6 +1186,31 @@ export function listNotificationLog(
|
||||
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(
|
||||
servicio?: NotificationServicio[],
|
||||
): Promise<NotificationStats> {
|
||||
|
||||
@@ -28,7 +28,8 @@ export type Ability =
|
||||
| "lookup:manage"
|
||||
| "user:manage"
|
||||
| "db:manage"
|
||||
| "notification:send";
|
||||
| "notification:send"
|
||||
| "setting:manage";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user