Files
jorgecuadros-platform/apps/api/src/customers/customers.controller.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

131 lines
3.9 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import { Request } from "express";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
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";
@UseGuards(AuthenticatedGuard, AbilityGuard)
@Controller("customers")
export class CustomersController {
constructor(
private readonly customers: CustomersService,
private readonly numids: NumidService,
private readonly audit: AuditService,
) {}
private actingId(req: Request): string {
return (req.user as { id: string }).id;
}
@Get("stats")
stats() {
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,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
@Query("line") line?: "utility" | "insurance" | "both",
@Query("includeArchived") includeArchived?: string,
) {
const p = Math.max(1, Number(page) || 1);
const ps = Math.min(100, Math.max(1, Number(pageSize) || 25));
return this.customers.list({
query,
page: p,
pageSize: ps,
line,
includeArchived: includeArchived === "true",
});
}
@Get(":id")
detail(@Param("id") id: string) {
return this.customers.detail(id);
}
@Post()
@RequireAbility("customer:create")
async create(@Body() dto: CreateCustomerDto, @Req() req: Request) {
const c = await this.customers.create(dto);
void this.audit.log(this.actingId(req), "customer.create", { customerId: c.id, name: c.name });
return c;
}
@Patch(":id")
@RequireAbility("customer:update")
async update(
@Param("id") id: string,
@Body() dto: UpdateCustomerDto,
@Req() req: Request,
) {
const c = await this.customers.update(id, dto);
void this.audit.log(this.actingId(req), "customer.update", { customerId: id });
return c;
}
@Delete(":id")
@RequireAbility("customer:delete")
async archive(@Param("id") id: string, @Req() req: Request) {
const c = await this.customers.archive(id);
void this.audit.log(this.actingId(req), "customer.archive", { customerId: id });
return c;
}
@Post(":id/restore")
@RequireAbility("customer:delete")
async restore(@Param("id") id: string, @Req() req: Request) {
const c = await this.customers.restore(id);
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;
}
}