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 { 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> { 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("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> { 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( kind: "servicios" | "polizas", fallback: T, ): Promise> { 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 { 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 }, }); } }