Architecture
Architecture
Pulse ERP est une mono-app Nuxt 4 full-stack : un seul processus Node.js sert à la fois le rendu SSR et l'API Nitro REST. Pas de microservices, pas de message broker au départ — on n'y vient que si un besoin opérationnel le justifie.
docs/02-architecture.md. Pour l'auth et le RBAC, voir docs/05-auth-rbac.md.Stack technique
Runtime
Node.js 22 LTS · TypeScript 5.7+ strict (noUncheckedIndexedAccess, verbatimModuleSyntax) · pnpm workspaces
Frontend
Nuxt 4 SSR · Nuxt UI 4 · Tailwind CSS 4 · @nuxtjs/i18n v10 · date-fns v4 · dinero.js v2
Backend
Nitro file-based routing · Zod 3 · Prisma 5 · Better Auth 1.4+ · croner pour les jobs
Infrastructure
PostgreSQL 16 · Scaleway Object Storage (S3) · Brevo / Scaleway TEM · Docker Compose en local
Frontend — choix clés
| Concern | Choix |
|---|---|
| State | useState() Nuxt + composables (pas de Pinia tant que non nécessaire) |
| Data fetching | useFetch / useAsyncData / $fetch — dedup SSR automatique |
| Validation client | HTML5 + Nuxt UI form state (revalidation serveur systématique) |
| Tableaux | UTable + composant maison DataTable (pagination, tri, CSV) |
| Icons | @nuxt/icon — 200k+ icônes Iconify, tree-shakable |
| Utils | @vueuse/nuxt |
Backend — choix clés
| Concern | Choix |
|---|---|
| Routing | server/api/{path}.{method}.ts (file-based) |
| Handler | defineApiHandler() maison — perm check + parse + audit |
| Sessions | Cookie HttpOnly Secure SameSite=Lax, table session PostgreSQL, 30 j glissant |
| Stockage | Driver pluggable : filesystem (dev) · s3 (prod, Scaleway) |
Provider pluggable : console (dev) · brevo · scaleway-tem (prod) | |
| Queue / Cron | croner + table Job PostgreSQL (poll 5 s, pas de Redis) |
| Logs | pino JSON stdout — requestId, userId, organizationId, latencyMs |
| Erreurs | Classes ConflictError / ValidationError / NotFoundError / ForbiddenError mappées HTTP |
Architecture en couches
Route API server/api/**/*.ts
│ parse Zod · vérif permission · appel service
▼
Service server/services/*.service.ts
│ logique métier · transactions Prisma · audit log · events / jobs
▼
Repository server/repositories/*.repository.ts
│ Prisma pur — aucun métier, aucun side-effect
▼
PostgreSQL
- Une route API ne fait jamais de logique métier.
- Un service ne touche jamais Prisma directement — il passe par un repository.
- Un repository ne contient aucune règle métier (pas de validation, pas de calcul, pas de side-effect).
- Le frontend ne connaît jamais le schéma Prisma — il connaît les DTO Zod exposés par l'API.
DTO pattern
Les schémas Zod dans shared/dto/ sont la source de vérité du contrat API. Le type TypeScript est inféré du schéma, jamais défini à part.
// shared/dto/asset.dto.ts
export const AssetCreateDto = z.object({
label: z.string().min(1).max(120),
modelId: z.string().uuid(),
conditionState: z.enum(['NEW', 'EXCELLENT', 'GOOD', 'NEEDS_REPAIR']),
})
export type AssetCreateDto = z.infer<typeof AssetCreateDto>
Côté composable, on importe le type :
import type { AssetDto } from '~/../shared/dto/asset.dto'
export function useWorkspaceAssets() {
return useFetch<AssetDto[]>('/api/workspace/assets')
}
Structure du repo (monorepo pnpm)
PROJET-ERP-CRM-v2/
├── app/
│ ├── pulse/ ← application Nuxt ERP/CRM
│ │ ├── app/ # pages · layouts · composables · components · middleware
│ │ ├── server/ # api · services · repositories · lib · middleware · jobs
│ │ ├── shared/ # DTO Zod, enums, helpers purs (app ↔ server)
│ │ ├── prisma/ # schema.prisma · migrations/ · seed.ts
│ │ ├── i18n/locales/ # fr.json · en.json
│ │ └── tests/ # unit/ · integration/ · e2e/
│ └── docs/ ← site de documentation (projet séparé)
└── pnpm-workspace.yaml
pnpm dev, pnpm db:*, pnpm test*) se lancent depuis la racine du repo — ne pas cd app/pulse.Portails applicatifs
| Portail | Pages | Routes API |
|---|---|---|
| Console interne | app/pages/workspace/ | server/api/workspace/ |
| Portail client | app/pages/client/ | server/api/client/ |
| Portail bailleur / partenaire | app/pages/partner/ | server/api/partner/ |
| Super-admin | app/pages/admin/ | server/api/admin/ |
| Public (SAV QR, statut) | app/pages/public/ | server/api/public/ |
Patterns transverses
Multi-tenant — isolation systématique
Toute entité business porte organizationId NOT NULL. Trois lignes de défense :
- DB : index composite
(organizationId, …)sur toutes les requêtes ; FK versOrganization. - Repository : prend
organizationIden paramètre, l'injecte dans la clausewhere. Pas de surcharge possible. - Service : lit
organizationIddepuissession.activeOrganizationId, jamais depuis le body.
Audit log
Chaque mutation sensible journalise dans AuditLog (append-only, rétention 10 ans) :
await auditLog({
actorId: session.user.id,
organizationId: tenant.id,
action: 'lease_contract.unlock',
targetType: 'LeaseContract',
targetId: contractId,
payload: { reason, previousStatus },
ip: getRequestIP(event),
})
Gestion des erreurs
defineApiHandler attrape les classes d'erreur et sérialise une réponse uniforme :
{
"error": {
"code": "CONFLICT",
"message": "Le numéro de série existe déjà",
"details": { "field": "serialNumber" },
"requestId": "01HXY..."
}
}
Un intercepteur $fetch côté frontend mappe ces erreurs vers un toast.
State machines (workflows multi-étapes)
Les workflows complexes (cycle de vie affaire LLD, processus SAV) utilisent des state machines explicites dans server/services/*.workflow.ts avec une table de transitions validées. Chaque changement d'état émet un événement métier qui peut déclencher notifications, jobs asynchrones et audit log.
Jobs & cron
croner schedule les jobs définis dans server/jobs/. Chaque exécution est tracée dans la table Job (statut PENDING → RUNNING → SUCCEEDED / FAILED, max 5 tentatives, idempotence par clé de payload).
| Job | Fréquence |
|---|---|
send-pending-emails / send-pending-sms | toutes les 1 min |
lease-payment-reminder | 1×/jour |
pappers-refresh | 1×/semaine |
maintenance-preventive | 1×/jour |
cleanup-expired-tokens | 1×/jour |
audit-log-archive (export S3) | 1×/mois |
Conventions de code
Naming
| Élément | Convention | Exemple |
|---|---|---|
| Composants Vue | PascalCase, préfixé par domaine | AssetCreateForm |
| Composables | useXxx, retourne { data, pending, error, refresh } | useWorkspaceAssets |
| Routes API | kebab-case, name.method.ts | [id].patch.ts |
| Services | *.service.ts | lease.service.ts |
| Repositories | *.repository.ts | asset.repository.ts |
| DTO | PascalCase + suffixe Dto / Input | AssetCreateDto |
| Enums DB | SCREAMING_SNAKE_CASE | AssetCondition.NEEDS_REPAIR |
Conventional Commits
Enforced par Husky + commitlint. Format : <type>(<scope>): <description>.
Types : feat fix chore docs refactor test perf build ci.
Scopes : auth identity annuaire assets crm sales lease catalog files notif admin ai ui db infra docs.
Tests
| Niveau | Outil | Cible | Commande |
|---|---|---|---|
| Unit | Vitest | Services purs, utils, calculs Zod | pnpm test |
| Integration | Vitest + DB test | Services qui touchent Prisma | pnpm test:integration |
| E2E + a11y | Playwright + axe-core | Parcours critiques (login, contrat, signature) | pnpm test:e2e |
Règle fondamentale : chaque nouveau .service.ts → un .integration.test.ts dans tests/integration/. Utiliser createTenantFixture() de tests/helpers/tenant-isolation.ts, seed dans try {}, nettoyage dans finally { await fx.cleanup() }.
Avant toute PR :
pnpm test && pnpm test:integration && pnpm lint && pnpm typecheck
Sécurité & déploiement
Sécurité
- Auth : Better Auth — sessions cookie HttpOnly Secure SameSite=Lax, MFA TOTP, magic link.
- Headers :
security-headers.tsinjecte HSTS, CSP, X-Frame-Options DENY, Permissions-Policy. - Rate limiting : token bucket — 5 req/min/IP sur
POST /api/auth/*, 300 req/min/session surGET /api/*. - Secrets : aucun dans le repo ;
.envignoré par git ; rotation API keys tous les 90 j. - RGPD : hébergement Scaleway
fr-par, anonymisation (User.anonymizedAt), export données/api/account/data-export.
Environnements
| Env | URL | DB |
|---|---|---|
| local | http://localhost:3999 | Docker Postgres |
| staging | https://staging.pulse-erp.fr | Postgres managed |
| prod | https://app.pulse-erp.fr | Postgres managed HA |
Migrations DB
Stratégie zero-downtime : colonne d'abord nullable → déploiement code → backfill → NOT NULL en migration suivante. Ne jamais supprimer de fichier sous prisma/migrations/.
CI/CD
ci.yml(sur PR) : lint + typecheck + unit + build.e2e.yml(nightly) : Playwright sur staging.deploy-prod.yml(manuel) : promotion image staging → prod + migrations + smoke tests + rollback automatique si healthcheck KO.