Initial commit: ERP skeleton + CLAUDE.md
This commit is contained in:
commit
3e75772fb3
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
59
CLAUDE.md
Normal file
59
CLAUDE.md
Normal 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
1522
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
18
prisma/migrations/0001_init/migration.sql
Normal file
18
prisma/migrations/0001_init/migration.sql
Normal 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");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
29
prisma/schema.prisma
Normal 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")
|
||||
}
|
||||
33
src/modules/auth/auth.routes.ts
Normal file
33
src/modules/auth/auth.routes.ts
Normal 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' });
|
||||
});
|
||||
}
|
||||
35
src/modules/auth/auth.service.ts
Normal file
35
src/modules/auth/auth.service.ts
Normal 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
29
src/plugins/jwt.ts
Normal 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
18
src/plugins/prisma.ts
Normal 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
16
src/seed.ts
Normal 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
25
src/server.ts
Normal 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
15
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user