Files
jorgecuadros-platform/apps/api/src/settings/settings.service.ts
T
rmancinasandClaude Opus 5 6a97242fc3
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m59s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m13s
feat(customers): allocate portal NUMids, with an audit for reusable ones
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>
2026-08-06 20:04:13 -07:00

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 },
});
}
}