Customers created in the staff UI had no NUMid and so could not log in to my.jorgecuadros.com at all: the id is a CustomerLegacyRef row, not a column, and create() deliberately writes none. Allocation is a staff action (POST /customers/:id/portal-access, MANAGER) rather than part of create, because insurance is expected to move to the platform before utilities and an insurance-only customer has no reason to spend a utilities id. The audit that decides which ids are reusable took three passes. "Owns no rows" matches nobody -- migration gave all 1,171 NUMids a property and a transaction. "No transaction in N years" also matches nobody -- every customer carries a synthetic Jan-1 opening-balance row, so everyone looks active this year. Subtracting that row is what makes dormancy measurable, and it leaves 4 never-used ids and 10 dormant ones on dev. Two further traps are encoded in the queries: insurance/DATGRAL is a separate id space that reuses the sourceTable name and runs past 4,000, and ACCOUNT CANCELED is a transaction line type, not an account state -- all 8 customers carrying it have current-year activity. Recycling ships switched off (numid.recycleEmpty, default false). Every reusable id still exists in Access DATGRAL, and a --sync run reassigns refs with ON DUPLICATE KEY UPDATE customerId, so an id recycled before the utilities cutover is silently handed back to its Access owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
219 lines
7.4 KiB
TypeScript
219 lines
7.4 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",
|
|
/** Whether the NUMid allocator may reuse empty portal ids. */
|
|
numidRecycleEmpty: "numid.recycleEmpty",
|
|
} 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,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether the NUMid allocator may reuse empty portal ids instead of only
|
|
* issuing new ones.
|
|
*
|
|
* Defaults to OFF, and the default is the safety property rather than a
|
|
* preference: while Access remains the utilities master, every reusable id
|
|
* still exists in DATGRAL, and a `--sync` migration run reassigns the ref back
|
|
* to its Access owner (transform_customers.py:327). Recycling before utilities
|
|
* cuts over therefore hands out ids that quietly stop working. No env rung —
|
|
* this has never been an environment variable and should be flipped
|
|
* deliberately, in the UI, by someone who knows the cutover happened.
|
|
*/
|
|
async numidRecycleEmpty(): Promise<ResolvedSetting<boolean>> {
|
|
const row = await this.read(SETTING_KEYS.numidRecycleEmpty);
|
|
if (row) {
|
|
return {
|
|
value: row.value === "true",
|
|
source: "db",
|
|
updatedAt: row.updatedAt,
|
|
updatedById: row.updatedById,
|
|
};
|
|
}
|
|
return { value: false, source: "default", updatedAt: null, updatedById: null };
|
|
}
|
|
|
|
async setNumidRecycleEmpty(
|
|
enabled: boolean,
|
|
userId: string,
|
|
): Promise<ResolvedSetting<boolean>> {
|
|
await this.write(SETTING_KEYS.numidRecycleEmpty, String(enabled), userId);
|
|
return this.numidRecycleEmpty();
|
|
}
|
|
|
|
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 },
|
|
});
|
|
}
|
|
}
|