The office files every customer in a numbered paper folder and reuses the number once an account closes. That number was the legacy NUMid, which is also an import key and the portal login, so reusing it meant re-pointing an identity. It is now customers.folderNumber: a plain label nothing joins on. - Unique only among non-archived customers, checked in CustomersService (create, update, restore). Archived customers keep theirs as history. - GET /customers/folder/next suggests the lowest free number; the new customer form pre-fills it. - Backfilled from the utilities NUMid (dev: 1,171 folders, 0 shared); the importer copies it on load. Insurance-only customers start without one. - Reports, the printed statement and the customer file show "Carpeta" instead of NUMid. The list searches by it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
137 lines
4.1 KiB
TypeScript
137 lines
4.1 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() };
|
|
}
|
|
|
|
/** Suggested folder number for a new customer. Above `:id` like the rest. */
|
|
@Get("folder/next")
|
|
async nextFolder() {
|
|
return { folderNumber: await this.customers.nextFolderNumber() };
|
|
}
|
|
|
|
@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;
|
|
}
|
|
}
|