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:
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
@Get("health")
|
||||
health() {
|
||||
return { status: "ok" };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class LocalAuthGuard extends AuthGuard("local") {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
password!: string;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { PrismaService } from "./prisma.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { UsersService } from "./users.service";
|
||||
|
||||
@Module({
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@jorgecuadros/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^14.2.15",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.16.11",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Jorge Cuadros & Assoc.",
|
||||
description: "Unified customer, insurance, and utilities platform",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Jorge Cuadros & Assoc.</h1>
|
||||
<p>Unified customer platform — scaffold in progress.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user