Backend: Customer module (list/search/detail) + working session auth + pnpm

Adds the unified Customer API against the migrated data:
- customers.service: list (search across name/email/phone/city/legacy id,
  business-line filter, pagination + per-row _count flags), detail (identity +
  legacyRefs + properties/services/trust + policies with installments/vehicles/
  drivers/beneficiaries/claims/docs + recent transactions + a per-domain/
  currency ledger summary), and stats.
- customers.controller: GET /customers, /customers/:id, /customers/stats,
  guarded by AuthenticatedGuard. Registered in AppModule.
- Fix LocalAuthGuard to call super.logIn so a session is actually established
  (login previously succeeded but persisted no session -> 403 afterwards).
- apps/api/scripts/seed-user.mjs: idempotent Argon2 admin seed.

Tooling: adopt pnpm as the package manager (machine npm is a pnpm shim that
ignores the workspaces field). Add pnpm-workspace.yaml (+ onlyBuiltDependencies
for argon2/prisma/nest native builds), switch the api's @jorgecuadros/database
dep to workspace:*, add @types/passport, track pnpm-lock.yaml, drop the stale
package-lock.json.

Verified end-to-end against the dev DB: login sets a session cookie; stats
returns 1682 customers / 526 both-lines / 45861 transactions; search + detail
return the full cross-line customer view (properties+services AND policies AND
a unified transaction statement).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 19:13:44 -07:00
co-authored by Claude Opus 4.8
parent 46d75473ba
commit 98f5cc20d8
11 changed files with 5963 additions and 8660 deletions
-2
View File
@@ -13,5 +13,3 @@ __pycache__/
packages/database/generated/ packages/database/generated/
.DS_Store .DS_Store
# local package-manager shim (machine npm is pnpm-aliased); npm is canonical
pnpm-lock.yaml
+2 -1
View File
@@ -11,7 +11,7 @@
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
"@jorgecuadros/database": "0.1.0", "@jorgecuadros/database": "workspace:*",
"@nestjs/common": "^10.4.4", "@nestjs/common": "^10.4.4",
"@nestjs/config": "^3.3.0", "@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.4", "@nestjs/core": "^10.4.4",
@@ -33,6 +33,7 @@
"@types/express-session": "^1.18.0", "@types/express-session": "^1.18.0",
"@types/jest": "^29.5.13", "@types/jest": "^29.5.13",
"@types/node": "^20.16.11", "@types/node": "^20.16.11",
"@types/passport": "^1.0.17",
"@types/passport-local": "^1.0.38", "@types/passport-local": "^1.0.38",
"jest": "^29.7.0", "jest": "^29.7.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
+24
View File
@@ -0,0 +1,24 @@
// Seed an initial staff sign-in user. Idempotent (upsert by email).
// Run with the target DATABASE_URL in the environment, e.g.:
// DATABASE_URL="mysql://..." node apps/api/scripts/seed-user.mjs
// Optional: SEED_EMAIL, SEED_PASSWORD, SEED_NAME (dev defaults below).
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const argon2 = require("argon2");
const { PrismaClient } = require("@jorgecuadros/database");
const email = process.env.SEED_EMAIL || "admin@jorgecuadros.local";
const password = process.env.SEED_PASSWORD || "ChangeMe!2026";
const name = process.env.SEED_NAME || "Administrador";
const prisma = new PrismaClient();
const passwordHash = await argon2.hash(password);
const user = await prisma.user.upsert({
where: { email },
update: { passwordHash, active: true, role: "ADMIN", name },
create: { email, passwordHash, active: true, role: "ADMIN", name },
});
console.log(`seeded user: ${user.email} (role ${user.role})`);
console.log(` password: ${password}`);
await prisma.$disconnect();
+2
View File
@@ -3,6 +3,7 @@ import { ConfigModule } from "@nestjs/config";
import { PrismaModule } from "./prisma/prisma.module"; import { PrismaModule } from "./prisma/prisma.module";
import { UsersModule } from "./users/users.module"; import { UsersModule } from "./users/users.module";
import { AuthModule } from "./auth/auth.module"; import { AuthModule } from "./auth/auth.module";
import { CustomersModule } from "./customers/customers.module";
import { AppController } from "./app.controller"; import { AppController } from "./app.controller";
@Module({ @Module({
@@ -11,6 +12,7 @@ import { AppController } from "./app.controller";
PrismaModule, PrismaModule,
UsersModule, UsersModule,
AuthModule, AuthModule,
CustomersModule,
], ],
controllers: [AppController], controllers: [AppController],
}) })
+16 -2
View File
@@ -1,5 +1,19 @@
import { Injectable } from "@nestjs/common"; import { ExecutionContext, Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport"; import { AuthGuard } from "@nestjs/passport";
import { Request } from "express";
/**
* Validates credentials via LocalStrategy AND establishes the session:
* super.logIn(request) calls passport's req.login, which serializes the user
* into the session store so subsequent requests carry an authenticated
* session cookie (otherwise login succeeds but no session is persisted).
*/
@Injectable() @Injectable()
export class LocalAuthGuard extends AuthGuard("local") {} export class LocalAuthGuard extends AuthGuard("local") {
async canActivate(context: ExecutionContext): Promise<boolean> {
const result = (await super.canActivate(context)) as boolean;
const request = context.switchToHttp().getRequest<Request>();
await super.logIn(request);
return result;
}
}
@@ -0,0 +1,31 @@
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { CustomersService } from "./customers.service";
@UseGuards(AuthenticatedGuard)
@Controller("customers")
export class CustomersController {
constructor(private readonly customers: CustomersService) {}
@Get("stats")
stats() {
return this.customers.stats();
}
@Get()
list(
@Query("query") query?: string,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
@Query("line") line?: "utility" | "insurance" | "both",
) {
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 });
}
@Get(":id")
detail(@Param("id") id: string) {
return this.customers.detail(id);
}
}
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { CustomersController } from "./customers.controller";
import { CustomersService } from "./customers.service";
@Module({
controllers: [CustomersController],
providers: [CustomersService],
})
export class CustomersModule {}
+148
View File
@@ -0,0 +1,148 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
export interface ListParams {
query?: string;
page: number;
pageSize: number;
line?: "utility" | "insurance" | "both";
}
@Injectable()
export class CustomersService {
constructor(private readonly prisma: PrismaService) {}
/** Unified customer list with search + business-line filter, paginated. */
async list({ query, page, pageSize, line }: ListParams) {
const where: Prisma.CustomerWhereInput = {};
if (query && query.trim()) {
const q = query.trim();
where.OR = [
{ name: { contains: q } },
{ email: { contains: q } },
{ phone: { contains: q } },
{ mobile: { contains: q } },
{ city: { contains: q } },
{ legacyRefs: { some: { legacyId: { contains: q } } } },
];
}
if (line === "utility") where.properties = { some: {} };
if (line === "insurance") where.policies = { some: {} };
if (line === "both") {
where.properties = { some: {} };
where.policies = { some: {} };
}
const [total, rows] = await this.prisma.$transaction([
this.prisma.customer.count({ where }),
this.prisma.customer.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { name: "asc" },
select: {
id: true,
name: true,
city: true,
state: true,
email: true,
phone: true,
mobile: true,
status: true,
_count: { select: { properties: true, policies: true, transactions: true } },
},
}),
]);
const items = rows.map((r) => ({
id: r.id,
name: r.name,
city: r.city,
state: r.state,
email: r.email,
phone: r.phone,
mobile: r.mobile,
status: r.status,
propertyCount: r._count.properties,
policyCount: r._count.policies,
transactionCount: r._count.transactions,
hasUtilities: r._count.properties > 0,
hasInsurance: r._count.policies > 0,
}));
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
}
/** Full unified customer view: identity + both business lines + ledger. */
async detail(id: string) {
const customer = await this.prisma.customer.findUnique({
where: { id },
include: {
legacyRefs: true,
properties: {
include: { services: true, trustAccount: true, documents: true },
},
policies: {
orderBy: { policyFrom: "desc" },
include: {
policyType: true,
insuranceProvider: true,
installments: { orderBy: { sequence: "asc" } },
vehicles: true,
insuredDrivers: true,
beneficiaries: true,
claims: true,
documents: true,
},
},
transactions: {
orderBy: { transactionDate: "desc" },
take: 100,
include: { type: true },
},
},
});
if (!customer) {
throw new NotFoundException(`Customer ${id} not found`);
}
// Ledger totals per domain + currency (the "one statement across both
// business lines" payoff), computed in the DB rather than in JS.
const summary = await this.prisma.transaction.groupBy({
by: ["domain", "currency"],
where: { customerId: id },
_sum: { amount: true },
_count: { _all: true },
});
return {
...customer,
transactionSummary: summary.map((s) => ({
domain: s.domain,
currency: s.currency,
total: s._sum.amount,
count: s._count._all,
})),
};
}
/** Top-line counts for a dashboard header. */
async stats() {
const [customers, withUtilities, withInsurance, policies, properties, transactions] =
await this.prisma.$transaction([
this.prisma.customer.count(),
this.prisma.customer.count({ where: { properties: { some: {} } } }),
this.prisma.customer.count({ where: { policies: { some: {} } } }),
this.prisma.policy.count(),
this.prisma.property.count(),
this.prisma.transaction.count(),
]);
const bothLines = await this.prisma.customer.count({
where: { properties: { some: {} }, policies: { some: {} } },
});
return { customers, withUtilities, withInsurance, bothLines, policies, properties, transactions };
}
}
-8655
View File
File diff suppressed because it is too large Load Diff
+5711
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
packages:
- "apps/*"
- "packages/*"
allowBuilds:
'@nestjs/core': set this to true or false
'@prisma/client': set this to true or false
'@prisma/engines': set this to true or false
argon2: set this to true or false
prisma: set this to true or false
# Native / postinstall build scripts we trust (argon2 native addon, Prisma
# engine download + client generation, Nest core). pnpm blocks build scripts
# by default; these are required for the API to run.
onlyBuiltDependencies:
- "@nestjs/core"
- "@prisma/client"
- "@prisma/engines"
- argon2
- prisma