Initial commit: ERP skeleton + CLAUDE.md

This commit is contained in:
Louis-Andre 2026-03-09 09:10:16 +00:00
commit 3e75772fb3
14 changed files with 1838 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.env
*.log
.DS_Store

59
CLAUDE.md Normal file
View File

@ -0,0 +1,59 @@
# MetallKart ERP - CLAUDE.md
## Ce fichier est lu automatiquement par Claude Code CLI au debut de chaque session.
## Projet
ERP pour MetallKart (Tver, Russie), fabricant equipement entrepot/industriel depuis 2007.
App standalone Node.js/TypeScript + Fastify + Prisma + PostgreSQL.
Doit etre deployable sur serveur local en Russie, independant de n8n.
## Stack technique
- Runtime: Node.js (ESM, type:module)
- Framework: Fastify 4.x
- ORM: Prisma 5.x avec PostgreSQL 16
- Auth: JWT (fastify/jwt + bcryptjs)
- Validation: Zod
- Build: TypeScript 5.x, tsx pour dev
- DB staging: postgresql://metallkart:MK_PG_2026_staging!@172.19.0.5:5432/erp_staging
- Port: 3001
## Structure du projet
src/
server.ts - Point entree Fastify
seed.ts - Seed DB initial
plugins/
prisma.ts - Plugin connexion DB
jwt.ts - Plugin authentification
modules/
auth/ - Login, register, refresh token
prisma/
schema.prisma - Schema DB (source of truth)
## Conventions
- Chaque module dans src/modules/nom/
- Routes: nom.routes.ts, Services: nom.service.ts, Schemas: nom.schemas.ts
- Validation input via Zod schemas
- Roles: ADMIN, COMPTABLE, COMMERCIAL, PRODUCTION, USER
- Les textes metier en russe (noms champs DB en anglais, labels UI en russe)
- Toujours faire prisma migrate dev apres modification du schema
- Tester avec: tsx src/server.ts (dev) ou npm run build et npm start (prod)
## Modules implementes
- [x] auth (login, register, JWT refresh) - squelette cree 08/03/2026
## Modules a implementer
- [ ] suppliers (fournisseurs, champ INN obligatoire pour fournisseurs russes)
- [ ] products (catalogue produits MetallKart)
- [ ] orders (commandes clients)
- [ ] inventory (stock/entrepot)
- [ ] invoices (facturation)
- [ ] reports (rapports financiers)
## Erreurs connues et solutions
(Section a completer au fur et a mesure du developpement)
## Decisions architecturales
- 09/03/2026: Architecture v2 adoptee. n8n = orchestrateur leger (texte seul).
Claude Code CLI = moteur de dev (ecrit directement sur filesystem).
Code ne transite JAMAIS par des webhooks n8n.
- 09/03/2026: Backup via git push vers GitHub prive (francegruart).

1522
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "metallkart-erp",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "tsx src/server.ts",
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:seed": "tsx src/seed.ts"
},
"dependencies": {
"@fastify/cookie": "^9.3.1",
"@fastify/jwt": "^8.0.1",
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"fastify": "^4.28.1",
"fastify-plugin": "^4.5.1",
"jsonwebtoken": "^9.0.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^20.17.6",
"prisma": "^5.22.0",
"tsx": "^4.19.1",
"typescript": "^5.6.3"
}
}

View File

@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'COMPTABLE', 'COMMERCIAL', 'PRODUCTION', 'USER');
-- CreateTable
CREATE TABLE IF NOT EXISTS "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'USER',
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "users_email_key" ON "users"("email");

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

29
prisma/schema.prisma Normal file
View File

@ -0,0 +1,29 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
ADMIN
COMPTABLE
COMMERCIAL
PRODUCTION
USER
}
model User {
id String @id @default(uuid())
email String @unique
password String
name String
role Role @default(USER)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}

View File

@ -0,0 +1,33 @@
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { AuthService } from './auth.service.js';
const loginSchema = z.object({ email: z.string().email(), password: z.string().min(6) });
const refreshSchema = z.object({ refreshToken: z.string() });
export async function authRoutes(server: FastifyInstance) {
const svc = new AuthService(server);
server.post('/login', async (req, rep) => {
const r = loginSchema.safeParse(req.body);
if (!r.success) return rep.status(400).send({ error: 'Validation', details: r.error.flatten() });
try { return rep.send(await svc.login(r.data.email, r.data.password)); }
catch (e: any) { return rep.status(e.statusCode ?? 500).send({ error: e.message }); }
});
server.get('/me', { preHandler: [server.authenticate] }, async (req, rep) => {
try { return rep.send(await svc.me(req.user.id)); }
catch (e: any) { return rep.status(e.statusCode ?? 500).send({ error: e.message }); }
});
server.post('/refresh', async (req, rep) => {
const r = refreshSchema.safeParse(req.body);
if (!r.success) return rep.status(400).send({ error: 'Validation', details: r.error.flatten() });
try { return rep.send(await svc.refresh(r.data.refreshToken)); }
catch (e: any) { return rep.status(e.statusCode ?? 500).send({ error: e.message }); }
});
server.post('/logout', { preHandler: [server.authenticate] }, async (_req, rep) => {
return rep.send({ message: 'Deconnecte avec succes' });
});
}

View File

@ -0,0 +1,35 @@
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
export class AuthService {
constructor(private server: FastifyInstance) {}
async login(email: string, password: string) {
const user = await this.server.prisma.user.findUnique({ where: { email } });
if (!user || !user.active) throw { statusCode: 401, message: 'Email ou mot de passe incorrect' };
const valid = await bcrypt.compare(password, user.password);
if (!valid) throw { statusCode: 401, message: 'Email ou mot de passe incorrect' };
const payload = { id: user.id, email: user.email, role: user.role };
const accessToken = this.server.jwt.sign(payload);
const refreshToken = jwt.sign(payload, process.env.JWT_REFRESH_SECRET ?? 'fallback_refresh', { expiresIn: '30d' });
const { password: _p, ...safe } = user;
return { accessToken, refreshToken, user: safe };
}
async me(userId: string) {
const user = await this.server.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw { statusCode: 404, message: 'Utilisateur non trouve' };
const { password: _p, ...safe } = user;
return safe;
}
async refresh(refreshToken: string) {
try {
const p = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET ?? 'fallback_refresh') as { id: string; email: string; role: string };
return { accessToken: this.server.jwt.sign({ id: p.id, email: p.email, role: p.role }) };
} catch {
throw { statusCode: 401, message: 'Refresh token invalide ou expire' };
}
}
}

29
src/plugins/jwt.ts Normal file
View File

@ -0,0 +1,29 @@
import fp from 'fastify-plugin';
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
import fastifyJwt from '@fastify/jwt';
declare module 'fastify' {
interface FastifyInstance {
authenticate: (req: FastifyRequest, rep: FastifyReply) => Promise<void>;
}
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: { id: string; email: string; role: string };
user: { id: string; email: string; role: string };
}
}
const jwtPlugin: FastifyPluginAsync = fp(async (server) => {
await server.register(fastifyJwt, {
secret: process.env.JWT_SECRET ?? 'fallback_secret',
sign: { expiresIn: '8h' },
});
server.decorate('authenticate', async (req: FastifyRequest, rep: FastifyReply) => {
try { await req.jwtVerify(); }
catch { rep.status(401).send({ error: 'Unauthorized', message: 'Token invalide ou expire' }); }
});
});
export default jwtPlugin;

18
src/plugins/prisma.ts Normal file
View File

@ -0,0 +1,18 @@
import fp from 'fastify-plugin';
import { FastifyPluginAsync } from 'fastify';
import { PrismaClient } from '@prisma/client';
declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient;
}
}
const prismaPlugin: FastifyPluginAsync = fp(async (server) => {
const prisma = new PrismaClient({ log: ['error'] });
await prisma.$connect();
server.decorate('prisma', prisma);
server.addHook('onClose', async (s) => { await s.prisma.$disconnect(); });
});
export default prismaPlugin;

16
src/seed.ts Normal file
View File

@ -0,0 +1,16 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
const hash = await bcrypt.hash('Admin2026!', 12);
const admin = await prisma.user.upsert({
where: { email: 'admin@metallcart.ru' },
update: {},
create: { email: 'admin@metallcart.ru', password: hash, name: 'Louis-Andre', role: 'ADMIN', active: true },
});
console.log('Seed OK - Admin:', admin.email);
}
main().catch(console.error).finally(() => prisma.$disconnect());

25
src/server.ts Normal file
View File

@ -0,0 +1,25 @@
import Fastify from 'fastify';
import prismaPlugin from './plugins/prisma.js';
import jwtPlugin from './plugins/jwt.js';
import { authRoutes } from './modules/auth/auth.routes.js';
const server = Fastify({ logger: true });
async function start() {
try {
await server.register(prismaPlugin);
await server.register(jwtPlugin);
await server.register(authRoutes, { prefix: '/api/v1/auth' });
server.get('/health', async () => ({ status: 'ok', timestamp: new Date().toISOString(), service: 'metallkart-erp' }));
const port = parseInt(process.env.PORT ?? '3001');
const host = process.env.HOST ?? '0.0.0.0';
await server.listen({ port, host });
} catch (err) {
server.log.error(err);
process.exit(1);
}
}
start();

15
tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}