import { ConflictException, Injectable, Logger, NotFoundException, } from "@nestjs/common"; import { Prisma } from "@jorgecuadros/database"; import { PrismaService } from "../prisma/prisma.service"; import { SettingsService } from "../settings/settings.service"; /** * Allocation of the portal NUMid — the "Security Number" my.jorgecuadros.com * asks for at login. * * The NUMid is not a column on `Customer`. It is a `CustomerLegacyRef` row with * (sourceSystem='utilities', sourceTable='DATGRAL'), and `CustomersService.create` * deliberately writes none: a natively created customer has no legacy provenance. * The consequence is that every customer created in the staff UI is invisible to * the portal until this service gives them an id. * * WHY THIS IS NOT DONE AT CREATE TIME. Insurance is expected to move to the * platform before utilities, and an insurance-only customer has no reason to hold * a portal identity. Allocating on every create would spend utilities ids — and * the handful of reusable ones — on people who will never log in. So this is an * explicit staff action instead. */ /** The pair that identifies a portal NUMid. */ export const UTILITIES_SYSTEM = "utilities"; export const UTILITIES_TABLE = "DATGRAL"; /** * insurance/DATGRAL is a SEPARATE id space that reuses the same sourceTable name * and runs past 4,000. It must never be read as a NUMid, and never allocated * from: the portal cannot resolve those ids. Every query here filters on BOTH * columns for that reason, never on sourceTable alone. A customer can also hold * more than one insurance ref — 16 of them do, where several insurance rows * folded into one customer — so those are tested with EXISTS rather than joined. */ const POOL = { sourceSystem: UTILITIES_SYSTEM, sourceTable: UTILITIES_TABLE, } as const; export type AllocationOrigin = "existing" | "new" | "recycled"; export interface Allocation { numid: string; origin: AllocationOrigin; /** Set only on a recycle — the customer the id was taken from. */ previousCustomerId?: string; } /** * NUMids that were created and never used, safe for an allocator to take. * * THE TWO OBVIOUS RULES BOTH FIND NOTHING, which is why this one looks the way * it does. "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 in the current year. That row has to be subtracted before any * activity test means anything, which is what `bf` does below. * * The balance-forward row is matched in two shapes on purpose. * transform_transactions.py:120 mints a type literally named 'BALANCE FORWARD'; * databases loaded before that change carry the same rows with typeId NULL, * dated Jan 1, legacySourceTable='datos2'. Matching only the type name floors * nothing on such a database and turns the balance test into a raw lifetime sum * — the double-count that read the whole book as +20.6M MXN in credit before * d173c9e, and which here would mark live customers as empty. * * Services are tested as "any service" rather than "any ACTIVE service": a * deactivated water account is still a record of somebody having lived behind * this id. * * Kept in step with scripts/numid-audit.sql, which reports the same tier for a * human. That script is the reporting copy of this rule; change both together. */ const EMPTY_NUMID_SQL = Prisma.sql` WITH bf AS ( SELECT t.id, t.customerId FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId WHERE t.voidedAt IS NULL AND ( tt.nameEn = 'BALANCE FORWARD' OR (t.typeId IS NULL AND MONTH(t.transactionDate) = 1 AND DAY(t.transactionDate) = 1 AND t.legacySourceTable = 'datos2') ) ) SELECT r.legacyId AS numid, r.id AS refId, r.customerId AS customerId FROM customer_legacy_refs r JOIN customers c ON c.id = r.customerId WHERE r.sourceSystem = ${UTILITIES_SYSTEM} AND r.sourceTable = ${UTILITIES_TABLE} AND (c.email IS NULL OR c.email = '') AND NOT EXISTS (SELECT 1 FROM transactions t WHERE t.customerId = c.id AND t.voidedAt IS NULL AND t.id NOT IN (SELECT id FROM bf)) AND NOT EXISTS (SELECT 1 FROM transactions t WHERE t.customerId = c.id AND t.voidedAt IS NULL AND t.outstanding = 1) AND NOT EXISTS (SELECT 1 FROM property_services ps JOIN properties p ON p.id = ps.propertyId WHERE p.customerId = c.id) AND NOT EXISTS (SELECT 1 FROM policies p WHERE p.customerId = c.id) AND NOT EXISTS (SELECT 1 FROM vehicles v WHERE v.customerId = c.id) AND NOT EXISTS (SELECT 1 FROM trust_accounts ta JOIN properties p ON p.id = ta.propertyId WHERE p.customerId = c.id) AND NOT EXISTS (SELECT 1 FROM statement_documents s WHERE s.matchedCustomerId = c.id) AND NOT EXISTS (SELECT 1 FROM policy_ocr_documents o WHERE o.matchedCustomerId = c.id) AND NOT EXISTS (SELECT 1 FROM email_notification_log e WHERE e.customerId = c.id) AND NOT EXISTS (SELECT 1 FROM email_log e WHERE e.customerId = c.id) AND NOT EXISTS (SELECT 1 FROM account_status_history a WHERE a.customerId = c.id) AND NOT EXISTS (SELECT 1 FROM customer_legacy_refs i WHERE i.customerId = c.id AND i.sourceSystem = 'insurance') ORDER BY CAST(r.legacyId AS UNSIGNED)`; interface EmptyRow { numid: string; refId: string; customerId: string; } @Injectable() export class NumidService { private readonly logger = new Logger(NumidService.name); constructor( private readonly prisma: PrismaService, private readonly settings: SettingsService, ) {} /** The customer's portal id, or null if they have none. */ async current(customerId: string): Promise { const ref = await this.prisma.customerLegacyRef.findFirst({ where: { customerId, ...POOL }, select: { legacyId: true }, }); return ref?.legacyId ?? null; } /** Reusable ids, lowest first. Empty unless recycling is switched on. */ async emptyCandidates(): Promise { const rows = await this.prisma.$queryRaw(EMPTY_NUMID_SQL); return rows.map((r) => r.numid); } /** * Give a customer a portal NUMid. * * Idempotent: a customer who already holds one gets it back rather than a * second id, so a double-clicked button cannot fork an identity. */ async allocate(customerId: string): Promise { const customer = await this.prisma.customer.findUnique({ where: { id: customerId }, select: { id: true, archivedAt: true }, }); if (!customer) throw new NotFoundException(`Customer ${customerId} not found`); if (customer.archivedAt) { throw new ConflictException( "No se puede asignar un número de portal a un cliente archivado", ); } const existing = await this.current(customerId); if (existing) return { numid: existing, origin: "existing" }; const recycle = await this.recycleEnabled(); // Two writers can pick the same id between the read and the write. The // unique key on (sourceSystem, sourceTable, legacyId) is what actually // decides the winner; the loser retries and takes the next id rather than // silently overwriting. Bounded so a genuinely wedged pool fails loudly. for (let attempt = 0; attempt < 5; attempt++) { try { if (recycle) { const recycled = await this.tryRecycle(customerId); if (recycled) return recycled; } return await this.allocateNext(customerId); } catch (error) { if (!isUniqueViolation(error)) throw error; this.logger.warn( `NUMid allocation for ${customerId} lost a race (attempt ${attempt + 1}), retrying`, ); } } throw new ConflictException( "No se pudo asignar un número de portal; intente de nuevo", ); } /** * Whether the recycle tier is live. * * Off by default, and that default is the safe one while Access is still the * utilities master. Every id in the pool ALSO exists in Access DATGRAL, and a * `--sync` migration run upserts refs with ON DUPLICATE KEY UPDATE customerId * (transform_customers.py:327) — so an id recycled today is silently handed * back to its Access owner on the next sync, and the customer who was given it * loses their portal identity. Turn this on once utilities has cut over, or * for ids that have been deleted at the source. */ private async recycleEnabled(): Promise { const { value } = await this.settings.numidRecycleEmpty(); return value; } /** Re-point the lowest empty id at this customer. Null when none is free. */ private async tryRecycle(customerId: string): Promise { const rows = await this.prisma.$queryRaw(EMPTY_NUMID_SQL); for (const row of rows) { // Guarded by the owner we just read: if anything moved the ref in the // meantime the update matches nothing and we fall through to the next // candidate rather than stealing an id that is no longer empty. const moved = await this.prisma.customerLegacyRef.updateMany({ where: { id: row.refId, customerId: row.customerId }, data: { customerId }, }); if (moved.count === 1) { this.logger.log( `NUMid ${row.numid} recycled from ${row.customerId} to ${customerId}`, ); return { numid: row.numid, origin: "recycled", previousCustomerId: row.customerId, }; } } return null; } /** One past the highest id in the pool. */ private async allocateNext(customerId: string): Promise { const [{ max }] = await this.prisma.$queryRaw<{ max: number | null }[]>( // MAX over a CAST, not over the string: legacyId is VARCHAR, so a plain // MAX returns '999' as the highest of 1,171 rows and the allocator hands // out an id that is already taken. Prisma.sql`SELECT MAX(CAST(legacyId AS UNSIGNED)) AS max FROM customer_legacy_refs WHERE sourceSystem = ${UTILITIES_SYSTEM} AND sourceTable = ${UTILITIES_TABLE}`, ); const numid = String(Number(max ?? 0) + 1); await this.prisma.customerLegacyRef.create({ data: { customerId, ...POOL, legacyId: numid }, }); return { numid, origin: "new" }; } } function isUniqueViolation(error: unknown): boolean { return ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" ); }