The "Flags del envío" panel lived inside the Servicios tab and only
governed the four bulk jobs. The pólizas half had no debug at all, so
there was no way to test a renewal notice without mailing a real
customer. The panel now lives in the /notificaciones shell above the
tabs and both halves read it.
`debug` on the renewal path diverts to the same override inbox as the
servicios jobs and deliberately does NOT write the `RenewalNotice` row
or advance the sweep's `lastSuccessfulAt` — the customer was not
notified, so nothing may gate the letter they are still owed.
`ignoreDayRestriction` and `useEmailLimit` stay estado-de-cuenta-only
and are labelled as such.
Both automatic sweeps are now operator-editable. The renewal cadence
was a `@Cron("0 6 * * *")` literal and servicios had no automatic run
at all; both now resolve through `NotificationScheduleService`, which
stores the cadence in `app_settings` and reinstalls the cron job on
save — no redeploy, no restart. Defaults preserve current behaviour:
pólizas 06:00 daily, servicios off. A scheduled run never inherits the
UI flags; it always sends for real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
6.0 KiB
TypeScript
184 lines
6.0 KiB
TypeScript
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",
|
|
/** JSON cadence of the automatic servicios sweep. */
|
|
scheduleServicios: "notification.schedule.servicios",
|
|
/** JSON cadence of the automatic pólizas renewal sweep. */
|
|
schedulePolizas: "notification.schedule.polizas",
|
|
} 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();
|
|
}
|
|
|
|
/**
|
|
* Cadence of one automatic envío, stored as JSON.
|
|
*
|
|
* No env rung on this ladder: a schedule was never an environment variable
|
|
* (it was a `@Cron` literal in the source), so the only two sources are the
|
|
* operator's row and the caller's default — which is the previous hardcoded
|
|
* behaviour. A row that fails to parse is treated as absent and logged
|
|
* rather than thrown: a bad JSON blob must not take the scheduler down with
|
|
* it, and falling back to the shipped cadence is the safe reading.
|
|
*/
|
|
async notificationSchedule<T>(
|
|
kind: "servicios" | "polizas",
|
|
fallback: T,
|
|
): Promise<ResolvedSetting<T>> {
|
|
const key =
|
|
kind === "servicios"
|
|
? SETTING_KEYS.scheduleServicios
|
|
: SETTING_KEYS.schedulePolizas;
|
|
const row = await this.read(key);
|
|
if (row) {
|
|
try {
|
|
return {
|
|
value: { ...fallback, ...(JSON.parse(row.value) as T) },
|
|
source: "db",
|
|
updatedAt: row.updatedAt,
|
|
updatedById: row.updatedById,
|
|
};
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
`Setting ${key} is not valid JSON, using the default: ` +
|
|
`${(error as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
return { value: fallback, source: "default", updatedAt: null, updatedById: null };
|
|
}
|
|
|
|
async setNotificationSchedule(
|
|
kind: "servicios" | "polizas",
|
|
schedule: unknown,
|
|
userId: string,
|
|
): Promise<void> {
|
|
await this.write(
|
|
kind === "servicios"
|
|
? SETTING_KEYS.scheduleServicios
|
|
: SETTING_KEYS.schedulePolizas,
|
|
JSON.stringify(schedule),
|
|
userId,
|
|
);
|
|
}
|
|
|
|
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 },
|
|
});
|
|
}
|
|
}
|