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>
This commit is contained in:
@@ -21,6 +21,7 @@ export type Ability =
|
||||
| "customer:create"
|
||||
| "customer:update"
|
||||
| "customer:delete"
|
||||
| "customer:portal-access"
|
||||
| "policy:create"
|
||||
| "policy:update"
|
||||
| "policy:delete"
|
||||
@@ -48,6 +49,12 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
||||
"customer:create": "STAFF",
|
||||
"customer:update": "STAFF",
|
||||
"customer:delete": "ADMIN",
|
||||
// Assigning a portal NUMid is granting someone the ability to log in to
|
||||
// my.jorgecuadros.com and read an account, so it sits above customer:update:
|
||||
// editing a phone number is the day job, handing out portal identity is not.
|
||||
// It is also close to irreversible in practice — the id is what the customer
|
||||
// then types at every login.
|
||||
"customer:portal-access": "MANAGER",
|
||||
"policy:create": "STAFF",
|
||||
"policy:update": "STAFF",
|
||||
"policy:delete": "MANAGER",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AbilityGuard } from "../auth/ability.guard";
|
||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { NumidService } from "./numid.service";
|
||||
import { CreateCustomerDto } from "./create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||
|
||||
@@ -24,6 +25,7 @@ import { UpdateCustomerDto } from "./update-customer.dto";
|
||||
export class CustomersController {
|
||||
constructor(
|
||||
private readonly customers: CustomersService,
|
||||
private readonly numids: NumidService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
@@ -36,6 +38,13 @@ export class CustomersController {
|
||||
return this.customers.stats();
|
||||
}
|
||||
|
||||
/** Reusable portal ids, lowest first. Declared above `:id` so the literal
|
||||
* path is not swallowed by the wildcard route. */
|
||||
@Get("numid/candidates")
|
||||
async numidCandidates() {
|
||||
return { candidates: await this.numids.emptyCandidates() };
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query("query") query?: string,
|
||||
@@ -95,4 +104,27 @@ export class CustomersController {
|
||||
void this.audit.log(this.actingId(req), "customer.restore", { customerId: id });
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give this customer a portal NUMid so they can log in to
|
||||
* my.jorgecuadros.com. Idempotent — a customer who already has one gets it
|
||||
* back rather than a second identity.
|
||||
*/
|
||||
@Post(":id/portal-access")
|
||||
@RequireAbility("customer:portal-access")
|
||||
async portalAccess(@Param("id") id: string, @Req() req: Request) {
|
||||
const allocation = await this.numids.allocate(id);
|
||||
if (allocation.origin !== "existing") {
|
||||
// Logged with the origin and the previous holder: a recycled id is the one
|
||||
// case where reading this record later has to answer "whose number was
|
||||
// this before, and was it taken or minted".
|
||||
void this.audit.log(this.actingId(req), "customer.portal-access", {
|
||||
customerId: id,
|
||||
numid: allocation.numid,
|
||||
origin: allocation.origin,
|
||||
previousCustomerId: allocation.previousCustomerId,
|
||||
});
|
||||
}
|
||||
return allocation;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SettingsModule } from "../settings/settings.module";
|
||||
import { CustomersController } from "./customers.controller";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { NumidService } from "./numid.service";
|
||||
|
||||
@Module({
|
||||
imports: [SettingsModule],
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService],
|
||||
providers: [CustomersService, NumidService],
|
||||
exports: [NumidService],
|
||||
})
|
||||
export class CustomersModule {}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { ConflictException, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { NumidService } from "./numid.service";
|
||||
|
||||
/**
|
||||
* What matters about the allocator is the two things it must never do: hand the
|
||||
* same id to two customers, and hand out a recycled id while Access can still
|
||||
* take it back. Both are tested here; the emptiness SQL itself is exercised
|
||||
* against real data by scripts/numid-audit.mjs.
|
||||
*/
|
||||
|
||||
interface Options {
|
||||
existingRef?: { legacyId: string } | null;
|
||||
archived?: boolean;
|
||||
missing?: boolean;
|
||||
recycle?: boolean;
|
||||
empty?: { numid: string; refId: string; customerId: string }[];
|
||||
max?: number | null;
|
||||
/** Make the first N create() calls fail the unique key, as a race would. */
|
||||
createConflicts?: number;
|
||||
/** Make updateMany report "nothing matched", as a lost recycle race would. */
|
||||
recycleMisses?: number;
|
||||
}
|
||||
|
||||
function build(opts: Options = {}) {
|
||||
const created: { legacyId: string }[] = [];
|
||||
let conflictsLeft = opts.createConflicts ?? 0;
|
||||
let missesLeft = opts.recycleMisses ?? 0;
|
||||
|
||||
const prisma = {
|
||||
customer: {
|
||||
findUnique: jest.fn().mockResolvedValue(
|
||||
opts.missing ? null : { id: "cust-new", archivedAt: opts.archived ? new Date() : null },
|
||||
),
|
||||
},
|
||||
customerLegacyRef: {
|
||||
findFirst: jest.fn().mockResolvedValue(opts.existingRef ?? null),
|
||||
updateMany: jest.fn().mockImplementation(() => {
|
||||
if (missesLeft > 0) {
|
||||
missesLeft -= 1;
|
||||
return Promise.resolve({ count: 0 });
|
||||
}
|
||||
return Promise.resolve({ count: 1 });
|
||||
}),
|
||||
create: jest.fn().mockImplementation(({ data }: { data: { legacyId: string } }) => {
|
||||
if (conflictsLeft > 0) {
|
||||
conflictsLeft -= 1;
|
||||
return Promise.reject(
|
||||
new Prisma.PrismaClientKnownRequestError("dup", {
|
||||
code: "P2002",
|
||||
clientVersion: "5",
|
||||
}),
|
||||
);
|
||||
}
|
||||
created.push(data);
|
||||
return Promise.resolve(data);
|
||||
}),
|
||||
},
|
||||
// Two different raw queries share one mock: the MAX lookup returns a single
|
||||
// {max} row, everything else is the empty-candidate list.
|
||||
$queryRaw: jest.fn().mockImplementation((sql: { strings?: string[]; sql?: string }) => {
|
||||
const text = String((sql as unknown as { sql?: string }).sql ?? "");
|
||||
if (text.includes("MAX(")) return Promise.resolve([{ max: opts.max ?? null }]);
|
||||
return Promise.resolve(opts.empty ?? []);
|
||||
}),
|
||||
};
|
||||
|
||||
const settings = {
|
||||
numidRecycleEmpty: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ value: opts.recycle ?? false, source: "default" }),
|
||||
};
|
||||
|
||||
return {
|
||||
service: new NumidService(prisma as never, settings as never),
|
||||
prisma,
|
||||
created,
|
||||
};
|
||||
}
|
||||
|
||||
describe("NUMid allocation", () => {
|
||||
it("returns the id a customer already holds instead of minting a second one", async () => {
|
||||
// A double-clicked button must not fork the customer's portal identity.
|
||||
const { service, prisma } = build({ existingRef: { legacyId: "501" } });
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||
numid: "501",
|
||||
origin: "existing",
|
||||
});
|
||||
expect(prisma.customerLegacyRef.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allocates one past the highest id in the pool", async () => {
|
||||
const { service, created } = build({ max: 1171 });
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||
numid: "1172",
|
||||
origin: "new",
|
||||
});
|
||||
expect(created[0]).toMatchObject({
|
||||
sourceSystem: "utilities",
|
||||
sourceTable: "DATGRAL",
|
||||
legacyId: "1172",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts at 1 when the pool is empty", async () => {
|
||||
const { service } = build({ max: null });
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toMatchObject({ numid: "1" });
|
||||
});
|
||||
|
||||
it("does NOT recycle while the setting is off, even with candidates free", async () => {
|
||||
// The default has to be the safe one: every reusable id still exists in
|
||||
// Access, and a --sync run reassigns it back to its Access owner.
|
||||
const { service, prisma } = build({
|
||||
max: 1171,
|
||||
empty: [{ numid: "1089", refId: "ref-1089", customerId: "cust-old" }],
|
||||
});
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||
numid: "1172",
|
||||
origin: "new",
|
||||
});
|
||||
expect(prisma.customerLegacyRef.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("takes the lowest empty id once recycling is switched on", async () => {
|
||||
const { service, prisma } = build({
|
||||
recycle: true,
|
||||
max: 1171,
|
||||
empty: [
|
||||
{ numid: "1089", refId: "ref-1089", customerId: "cust-old" },
|
||||
{ numid: "1094", refId: "ref-1094", customerId: "cust-other" },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||
numid: "1089",
|
||||
origin: "recycled",
|
||||
previousCustomerId: "cust-old",
|
||||
});
|
||||
// Guarded on the owner read a moment ago, so a ref that moved underneath us
|
||||
// matches nothing rather than being stolen.
|
||||
expect(prisma.customerLegacyRef.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "ref-1089", customerId: "cust-old" },
|
||||
data: { customerId: "cust-new" },
|
||||
});
|
||||
});
|
||||
|
||||
it("skips a candidate that someone else took first", async () => {
|
||||
const { service } = build({
|
||||
recycle: true,
|
||||
max: 1171,
|
||||
recycleMisses: 1,
|
||||
empty: [
|
||||
{ numid: "1089", refId: "ref-1089", customerId: "cust-old" },
|
||||
{ numid: "1094", refId: "ref-1094", customerId: "cust-other" },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||
numid: "1094",
|
||||
origin: "recycled",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to a new id when recycling is on but nothing is free", async () => {
|
||||
const { service } = build({ recycle: true, max: 1171, empty: [] });
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||
numid: "1172",
|
||||
origin: "new",
|
||||
});
|
||||
});
|
||||
|
||||
it("retries when two writers pick the same id", async () => {
|
||||
// The unique key on (sourceSystem, sourceTable, legacyId) is what decides
|
||||
// the winner; the loser must retry, never overwrite.
|
||||
const { service, prisma } = build({ max: 1171, createConflicts: 1 });
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||
numid: "1172",
|
||||
origin: "new",
|
||||
});
|
||||
expect(prisma.customerLegacyRef.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("gives up loudly rather than looping forever", async () => {
|
||||
const { service } = build({ max: 1171, createConflicts: 99 });
|
||||
|
||||
await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it("refuses an archived customer", async () => {
|
||||
const { service } = build({ archived: true });
|
||||
|
||||
await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it("refuses a customer that does not exist", async () => {
|
||||
const { service } = build({ missing: true });
|
||||
|
||||
await expect(service.allocate("nope")).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
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.
|
||||
*/
|
||||
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<string | null> {
|
||||
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<string[]> {
|
||||
const rows = await this.prisma.$queryRaw<EmptyRow[]>(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<Allocation> {
|
||||
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<boolean> {
|
||||
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<Allocation | null> {
|
||||
const rows = await this.prisma.$queryRaw<EmptyRow[]>(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<Allocation> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export const SETTING_KEYS = {
|
||||
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. */
|
||||
@@ -169,6 +171,39 @@ export class SettingsService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 } });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ContextReports } from "@/components/ContextReports";
|
||||
import {
|
||||
archiveCustomer,
|
||||
getCustomer,
|
||||
grantPortalAccess,
|
||||
policyDocumentDownloadUrl,
|
||||
propertyDocumentDownloadUrl,
|
||||
restoreCustomer,
|
||||
@@ -151,9 +152,43 @@ function CustomerActions({
|
||||
}) {
|
||||
const canEdit = useCan("customer:update");
|
||||
const canDelete = useCan("customer:delete");
|
||||
const canGrantPortal = useCan("customer:portal-access");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const archived = customer.archivedAt != null;
|
||||
|
||||
// The portal NUMid is a legacy ref, not a column: (utilities, DATGRAL) is the
|
||||
// "Security Number" my.jorgecuadros.com asks for. An insurance ref is a
|
||||
// different id space entirely and does not let anyone log in, so both columns
|
||||
// are checked — matching on sourceTable alone would hide the button from
|
||||
// customers who cannot actually reach the portal.
|
||||
const hasPortalId = customer.legacyRefs.some(
|
||||
(r) => r.sourceSystem === "utilities" && r.sourceTable === "DATGRAL",
|
||||
);
|
||||
|
||||
async function grantPortal() {
|
||||
if (
|
||||
!window.confirm(
|
||||
"¿Asignar un número de portal a este cliente? Con él podrá entrar a " +
|
||||
"my.jorgecuadros.com.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { numid, origin } = await grantPortalAccess(customer.id);
|
||||
window.alert(
|
||||
origin === "existing"
|
||||
? `Este cliente ya tenía el número de portal ${numid}.`
|
||||
: `Número de portal asignado: ${numid}.`,
|
||||
);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
window.alert((e as Error)?.message ?? "No se pudo completar la acción.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleArchive() {
|
||||
const verb = archived ? "restaurar" : "archivar";
|
||||
if (!window.confirm(`¿Seguro que desea ${verb} este cliente?`)) return;
|
||||
@@ -169,11 +204,23 @@ function CustomerActions({
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit && !canDelete) return null;
|
||||
const showPortal = canGrantPortal && !hasPortalId && !archived;
|
||||
if (!canEdit && !canDelete && !showPortal) return null;
|
||||
|
||||
return (
|
||||
<div className="row-actions">
|
||||
{archived && <span className="badge badge-negative">Archivado</span>}
|
||||
{showPortal && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={grantPortal}
|
||||
disabled={busy}
|
||||
title="Asigna el número que el cliente usa para entrar al portal"
|
||||
>
|
||||
Habilitar acceso al portal
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Link href={`/clientes/${customer.id}/editar`} className="btn btn-outline">
|
||||
Editar
|
||||
|
||||
@@ -232,6 +232,20 @@ export function restoreCustomer(id: string): Promise<CustomerDetail> {
|
||||
return apiFetch<CustomerDetail>(`/customers/${id}/restore`, { method: "POST" });
|
||||
}
|
||||
|
||||
export interface NumidAllocation {
|
||||
numid: string;
|
||||
/** "existing" when the customer already had one — the call is idempotent. */
|
||||
origin: "existing" | "new" | "recycled";
|
||||
previousCustomerId?: string;
|
||||
}
|
||||
|
||||
/** Give a customer the portal NUMid they log in to my.jorgecuadros.com with. */
|
||||
export function grantPortalAccess(id: string): Promise<NumidAllocation> {
|
||||
return apiFetch<NumidAllocation>(`/customers/${id}/portal-access`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ Policies module */
|
||||
|
||||
/** Renewal horizon in days, shared by the list, stats and detail calls so the
|
||||
|
||||
@@ -9,6 +9,7 @@ export type Ability =
|
||||
| "customer:create"
|
||||
| "customer:update"
|
||||
| "customer:delete"
|
||||
| "customer:portal-access"
|
||||
| "policy:create"
|
||||
| "policy:update"
|
||||
| "policy:delete"
|
||||
|
||||
Reference in New Issue
Block a user