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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user