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:
@@ -11,7 +11,7 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jorgecuadros/database": "0.1.0",
|
||||
"@jorgecuadros/database": "workspace:*",
|
||||
"@nestjs/common": "^10.4.4",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.4",
|
||||
@@ -33,6 +33,7 @@
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^20.16.11",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
|
||||
@@ -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();
|
||||
@@ -3,6 +3,7 @@ import { ConfigModule } from "@nestjs/config";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { UsersModule } from "./users/users.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { CustomersModule } from "./customers/customers.module";
|
||||
import { AppController } from "./app.controller";
|
||||
|
||||
@Module({
|
||||
@@ -11,6 +12,7 @@ import { AppController } from "./app.controller";
|
||||
PrismaModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
CustomersModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ExecutionContext, Injectable } from "@nestjs/common";
|
||||
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()
|
||||
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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user