Initial scaffold: unified customer/insurance/utilities platform

Next.js + NestJS + Prisma (MySQL) monorepo replacing the legacy PHP
internal app. Includes a session-based auth module with Argon2 password
hashing and global input validation (replacing the old app's SQL
injection and plaintext password comparison), the full target Prisma
schema for customers/insurance/utilities/shared ledger/bank register,
Docker Compose + Dockerfiles, and an Access-to-staging migration
pipeline (migration/) already run against the real source databases.

See PLAN.md and RESUME.md for the full architecture and session history.
This commit is contained in:
2026-07-22 01:29:56 -07:00
commit 27118f0df2
39 changed files with 10341 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@jorgecuadros/api",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"lint": "eslint \"src/**/*.ts\"",
"test": "jest"
},
"dependencies": {
"@jorgecuadros/database": "0.1.0",
"@nestjs/common": "^10.4.4",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.4",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.4",
"argon2": "^0.41.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"express-session": "^1.18.0",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@nestjs/testing": "^10.4.4",
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.0",
"@types/jest": "^29.5.13",
"@types/node": "^20.16.11",
"@types/passport-local": "^1.0.38",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.6.3"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Controller, Get } from "@nestjs/common";
@Controller()
export class AppController {
@Get("health")
health() {
return { status: "ok" };
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { PrismaModule } from "./prisma/prisma.module";
import { UsersModule } from "./users/users.module";
import { AuthModule } from "./auth/auth.module";
import { AppController } from "./app.controller";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
UsersModule,
AuthModule,
],
controllers: [AppController],
})
export class AppModule {}
+38
View File
@@ -0,0 +1,38 @@
import { Controller, Get, HttpCode, Post, Req, Res, UseGuards } from "@nestjs/common";
import { Request, Response } from "express";
import { LocalAuthGuard } from "./local-auth.guard";
import { AuthenticatedGuard } from "./authenticated.guard";
import { LoginDto } from "./login.dto";
@Controller("auth")
export class AuthController {
// LoginDto is only used for request-shape documentation/validation here —
// the actual credential check happens inside LocalStrategy via Passport,
// which populates req.user before this handler runs.
@UseGuards(LocalAuthGuard)
@Post("login")
@HttpCode(200)
login(@Req() req: Request, @Res({ passthrough: true }) _res: Response, _body?: LoginDto) {
return req.user;
}
@UseGuards(AuthenticatedGuard)
@Get("me")
me(@Req() req: Request) {
return req.user;
}
@Post("logout")
@HttpCode(200)
logout(@Req() req: Request) {
return new Promise((resolve, reject) => {
req.logout((err) => {
if (err) {
reject(err);
return;
}
resolve({ success: true });
});
});
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from "@nestjs/common";
import { PassportModule } from "@nestjs/passport";
import { UsersModule } from "../users/users.module";
import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller";
import { LocalStrategy } from "./local.strategy";
import { SessionSerializer } from "./session.serializer";
@Module({
imports: [UsersModule, PassportModule.register({ session: true })],
providers: [AuthService, LocalStrategy, SessionSerializer],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}
+36
View File
@@ -0,0 +1,36 @@
import { Injectable } from "@nestjs/common";
import * as argon2 from "argon2";
import { UsersService } from "../users/users.service";
import type { User } from "@jorgecuadros/database";
export type SafeUser = Omit<User, "passwordHash">;
@Injectable()
export class AuthService {
constructor(private readonly usersService: UsersService) {}
/**
* Replaces the old app's `passwd = '$password'` plaintext SQL comparison
* (src/core/auth.php) with a constant-time hash verification. Returns
* null on any failure — callers should not distinguish "no such user"
* from "wrong password" in their response.
*/
async validateUser(email: string, password: string): Promise<SafeUser | null> {
const user = await this.usersService.findByEmail(email);
if (!user || !user.active) {
return null;
}
const passwordMatches = await argon2.verify(user.passwordHash, password);
if (!passwordMatches) {
return null;
}
const { passwordHash: _passwordHash, ...safeUser } = user;
return safeUser;
}
static async hashPassword(plainTextPassword: string): Promise<string> {
return argon2.hash(plainTextPassword);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Request } from "express";
/**
* Replaces the old app's validate_session() (src/core/auth.php), which every
* page had to remember to call manually. Here it's a guard attached via
* @UseGuards(AuthenticatedGuard) — a controller that forgets it simply has
* no route, instead of silently serving unauthenticated data.
*/
@Injectable()
export class AuthenticatedGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
return request.isAuthenticated();
}
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class LocalAuthGuard extends AuthGuard("local") {}
+19
View File
@@ -0,0 +1,19 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import { AuthService, SafeUser } from "./auth.service";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super({ usernameField: "email", passwordField: "password" });
}
async validate(email: string, password: string): Promise<SafeUser> {
const user = await this.authService.validateUser(email, password);
if (!user) {
throw new UnauthorizedException("Invalid email or password");
}
return user;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from "class-validator";
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(1)
password!: string;
}
+25
View File
@@ -0,0 +1,25 @@
import { Injectable } from "@nestjs/common";
import { PassportSerializer } from "@nestjs/passport";
import { UsersService } from "../users/users.service";
import { SafeUser } from "./auth.service";
@Injectable()
export class SessionSerializer extends PassportSerializer {
constructor(private readonly usersService: UsersService) {
super();
}
serializeUser(user: SafeUser, done: (err: Error | null, id: string) => void) {
done(null, user.id);
}
async deserializeUser(id: string, done: (err: Error | null, user: SafeUser | null) => void) {
const user = await this.usersService.findById(id);
if (!user) {
done(null, null);
return;
}
const { passwordHash: _passwordHash, ...safeUser } = user;
done(null, safeUser);
}
}
+49
View File
@@ -0,0 +1,49 @@
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import * as session from "express-session";
import * as passport from "passport";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Every request body is validated and stripped of unknown fields before it
// reaches a controller — this is the structural replacement for the old
// app's complete lack of input validation (src/core/db.php took every
// $_POST field straight into a SQL string).
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
})
);
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) {
throw new Error("SESSION_SECRET must be set (see .env.example)");
}
app.use(
session({
secret: sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
maxAge: 1000 * 60 * 60 * 8, // 8-hour session, matches a staff workday
},
})
);
app.use(passport.initialize());
app.use(passport.session());
app.enableCors({ credentials: true, origin: process.env.WEB_ORIGIN ?? "http://localhost:3000" });
const port = process.env.PORT ? Number(process.env.PORT) : 3001;
await app.listen(port);
}
bootstrap();
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+13
View File
@@ -0,0 +1,13 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@jorgecuadros/database";
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { UsersService } from "./users.service";
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
+16
View File
@@ -0,0 +1,16 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import type { User } from "@jorgecuadros/database";
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { email } });
}
findById(id: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { id } });
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node"
}
}