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
+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);
}
}