Monorepo Turborepo (pnpm workspaces) avec 3 packages :
- apps/web : SPA React 19 + Vite 8 + Tailwind v4 (CSS-first)
• TanStack Router (file-based, auto code-splitting), Query, Form
• Radix primitives bruts + CVA + clsx + tailwind-merge
• MSW pour mocker l'API tant qu'Adonis n'est pas branché
• Polices Bricolage Grotesque + Inter self-hostées via fontsource
• Tokens marque (rubis, cream, ink) exposés via @theme
• Primitives maison : Gem, Brand, Eyebrow, Button, Input, Field
• Route /login full flow : TanStack Form + Zod + mutation Query
- apps/api : Adonis 7 (kit api, scaffold via create-adonisjs)
• Auth access tokens (Bearer) — cf. ADR-017
• Tuyau core déjà câblé pour la génération de types
• Routes /api/v1/auth/{signup,login} + /api/v1/account/{profile,logout}
• Minimal — uniquement le pont front ↔ back
- packages/shared : types TS + schemas Zod + constantes
• Source unique de vérité partagée api ↔ web
• Domaines : User, Org, Auth, Client, Invoice, Plan
Tooling racine : Turbo, ESLint v9 flat, Prettier, husky, lint-staged.
CLAUDE.md et docs/decisions.md mis à jour avec ADR-014 à ADR-018
(stack, monorepo, PG existant, Bearer tokens, MinIO existant)
et le pointeur vers docs/tech/architecture.md.
Logo Rubis déplacé de landing/assets/ vers /assets/ (source unique
réutilisée par la landing et l'app).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
457 lines
18 KiB
Markdown
457 lines
18 KiB
Markdown
# Architecture technique — Rubis Sur l'Ongle
|
|
|
|
> Version : 0.1 · Dernière maj : 2026-05-05
|
|
> Décisions de référence : ADR-014 (stack), ADR-015 (repo), ADR-016 (PG), ADR-017 (auth), ADR-018 (storage). Voir `/docs/decisions.md`.
|
|
|
|
Ce document est la source de vérité technique. Quand le code et ce fichier divergent, on tranche en discussion et on met à jour ici.
|
|
|
|
---
|
|
|
|
## 1. Vue d'ensemble
|
|
|
|
```
|
|
┌────────────────────────────┐
|
|
│ Internet (HTTPS) │
|
|
└─────────────┬──────────────┘
|
|
│
|
|
┌─────────────▼──────────────┐
|
|
│ Traefik (Proxmox gateway) │
|
|
│ rubis.arthurbarre.fr │
|
|
│ app.rubis-sur-l-ongle.fr │
|
|
└─────────────┬──────────────┘
|
|
│
|
|
┌──────────────────┼──────────────────┐
|
|
│ │ │
|
|
┌────────▼────────┐ ┌──────▼──────┐ ┌────────▼────────┐
|
|
│ Pod: web-static │ │ Pod: api │ │ Pod: landing │
|
|
│ nginx + Vite │ │ AdonisJS │ │ (déjà déployé) │
|
|
│ build │ │ Node │ │ │
|
|
└──────────────────┘ └──────┬─────┘ └─────────────────┘
|
|
│
|
|
┌───────────────────────┼───────────────────────┐
|
|
│ │ │
|
|
┌────────▼─────────┐ ┌────────▼────────┐ ┌─────────▼────────┐
|
|
│ LXC: PostgreSQL │ │ LXC: MinIO │ │ Provider OCR │
|
|
│ (existant, pool │ │ (existant, │ │ (à benchmarker) │
|
|
│ Proxmox) │ │ S3-compatible) │ │ │
|
|
└──────────────────┘ └─────────────────┘ └──────────────────┘
|
|
│
|
|
┌────────▼─────────┐
|
|
│ Provider Email │
|
|
│ (à benchmarker) │
|
|
└──────────────────┘
|
|
```
|
|
|
|
**Composants** :
|
|
|
|
| Composant | Rôle | Hosting | Status |
|
|
|---|---|---|---|
|
|
| `apps/web` | SPA React Vite — interface utilisateur | nginx pod K3s (build statique) | À écrire |
|
|
| `apps/api` | API REST AdonisJS — logique métier, jobs, email | Pod Node K3s | À écrire |
|
|
| `packages/shared` | Types TS, schemas Zod, constantes communes | npm workspace local | À écrire |
|
|
| `landing` | Landing publique waitlist | nginx pod K3s | ✅ Déployé |
|
|
| PostgreSQL | Base de données métier | LXC Proxmox existant | ✅ En place |
|
|
| MinIO | Stockage PDF + pièces jointes (S3-compat) | LXC Proxmox existant | ✅ En place |
|
|
| Provider OCR | Extraction texte des factures | Externe (HTTPS) | ADR-020 à venir |
|
|
| Provider Email | Envoi outbound (relances + check-in) | Externe (HTTPS) | ADR-021 à venir |
|
|
|
|
---
|
|
|
|
## 2. Repo layout (monorepo)
|
|
|
|
```
|
|
rubis/
|
|
├── apps/
|
|
│ ├── api/ # AdonisJS v7 backend
|
|
│ │ ├── app/ # Controllers, models, services
|
|
│ │ ├── config/ # Auth, database, mail, queue
|
|
│ │ ├── database/
|
|
│ │ │ ├── migrations/
|
|
│ │ │ └── seeders/
|
|
│ │ ├── start/ # Routes, kernel
|
|
│ │ ├── tests/
|
|
│ │ ├── ace.js # CLI Adonis
|
|
│ │ ├── package.json
|
|
│ │ └── tsconfig.json
|
|
│ └── web/ # React + Vite SPA
|
|
│ ├── src/
|
|
│ │ ├── routes/ # TanStack Router (file-based)
|
|
│ │ ├── components/
|
|
│ │ ├── lib/ # api client, query keys, utils
|
|
│ │ └── main.tsx
|
|
│ ├── public/
|
|
│ ├── index.html
|
|
│ ├── vite.config.ts
|
|
│ ├── package.json
|
|
│ └── tsconfig.json
|
|
├── packages/
|
|
│ └── shared/ # Code partagé api ↔ web
|
|
│ ├── src/
|
|
│ │ ├── types/ # Types TS (DTOs API)
|
|
│ │ ├── schemas/ # Schemas Zod (validation)
|
|
│ │ └── constants/ # Énums, règles métier
|
|
│ ├── package.json
|
|
│ └── tsconfig.json
|
|
├── landing/ # Landing publique (déjà déployée)
|
|
├── docs/ # Documentation
|
|
│ ├── produit.md
|
|
│ ├── marque.md
|
|
│ ├── decisions.md
|
|
│ └── tech/ # Doc technique
|
|
│ └── architecture.md # (ce fichier)
|
|
├── k3s/ # Manifests Kubernetes
|
|
├── Dockerfile.api # Build image api
|
|
├── Dockerfile.web # Build image web (nginx + bundle)
|
|
├── pnpm-workspace.yaml
|
|
├── package.json # Scripts root, devDependencies communes
|
|
├── tsconfig.base.json # Config TS partagée
|
|
└── CLAUDE.md
|
|
```
|
|
|
|
**Outils monorepo** :
|
|
- **pnpm workspaces** — léger, rapide, gestion native des liens symboliques entre packages
|
|
- **TypeScript project references** — résout les imports cross-package sans build préalable
|
|
- **Turborepo** *(optionnel, à voir au volume)* — cache + parallélisation des scripts
|
|
|
|
**Commandes racine** typiques :
|
|
```bash
|
|
pnpm install # installe tout
|
|
pnpm -F api dev # dev API
|
|
pnpm -F web dev # dev SPA
|
|
pnpm -F api migration:run # migrations DB
|
|
pnpm -F api test # tests API
|
|
pnpm build # build api + web pour prod
|
|
```
|
|
|
|
---
|
|
|
|
## 3. apps/api — AdonisJS v7
|
|
|
|
### Stack interne
|
|
- **AdonisJS v7** + **Lucid ORM** (PG)
|
|
- **`@adonisjs/auth`** — access tokens Bearer (stateless)
|
|
- **`@adonisjs/bouncer`** — autorisations par policy (admin/lecture/édition pour V2 multi-users)
|
|
- **`@adonisjs/mail`** — emails outbound (provider à choisir ADR-021)
|
|
- **`@adonisjs/queue`** ou **BullMQ** — jobs différés (relances programmées, OCR, check-ins)
|
|
- **`@adonisjs/limiter`** — rate limiting sur les routes publiques (login, signup)
|
|
- **Vine** (validateur natif Adonis 7) ou **Zod** côté API pour validation des payloads
|
|
|
|
### Conventions de routes
|
|
Toutes les routes API sous `/api/v1/`. Versioning explicite — V2 vivra côté `/api/v2/` sans casser V1.
|
|
|
|
```
|
|
POST /api/v1/auth/register
|
|
POST /api/v1/auth/login
|
|
POST /api/v1/auth/logout
|
|
POST /api/v1/auth/refresh
|
|
|
|
GET /api/v1/me
|
|
PATCH /api/v1/me
|
|
GET /api/v1/organizations/:id
|
|
|
|
GET /api/v1/invoices
|
|
POST /api/v1/invoices # create manual
|
|
POST /api/v1/invoices/upload # OCR pipeline
|
|
GET /api/v1/invoices/:id
|
|
PATCH /api/v1/invoices/:id
|
|
DELETE /api/v1/invoices/:id
|
|
POST /api/v1/invoices/:id/relance # relance manuelle
|
|
POST /api/v1/invoices/:id/mark-paid
|
|
|
|
GET /api/v1/plans
|
|
POST /api/v1/plans
|
|
PATCH /api/v1/plans/:id
|
|
DELETE /api/v1/plans/:id
|
|
|
|
GET /api/v1/clients
|
|
POST /api/v1/clients
|
|
PATCH /api/v1/clients/:id
|
|
|
|
GET /api/v1/dashboard/kpis
|
|
GET /api/v1/dashboard/activity
|
|
```
|
|
|
|
### Conventions de réponse
|
|
- JSON systématique
|
|
- Format succès : `{ data: ..., meta?: { ... } }`
|
|
- Format erreur : `{ errors: [{ code, message, field? }] }`
|
|
- Codes HTTP standards (200, 201, 204, 400, 401, 403, 404, 422, 500)
|
|
- Pagination cursor-based pour les listes (préférable à offset pour les flux modifiés en temps réel)
|
|
|
|
---
|
|
|
|
## 4. apps/web — React + Vite
|
|
|
|
### Stack interne
|
|
- **React 19** + **Vite 6**
|
|
- **TanStack Router** — routing file-based (à privilégier), search params type-safe pour les filtres facture
|
|
- **TanStack Query** — cache + invalidation + optimistic updates pour le state serveur
|
|
- **TailwindCSS** *(à confirmer)* — utility-first, cohérent avec les couleurs de marque
|
|
- **Lucide React** pour les icônes
|
|
- **Bricolage Grotesque + Inter** via Google Fonts (cohérent landing)
|
|
|
|
### Auth côté SPA (cf. ADR-017)
|
|
- **Access token** stocké en mémoire (variable de module / state Query) — pas localStorage pour éviter XSS
|
|
- **Refresh token** en cookie httpOnly + SameSite=Strict
|
|
- Au boot du SPA : appel `/auth/refresh` pour obtenir un nouvel access token (silent reauth)
|
|
- Si refresh échoue → redirect `/login`
|
|
|
|
### Organisation des routes
|
|
File-based via TanStack Router :
|
|
```
|
|
src/routes/
|
|
├── __root.tsx # Layout global + AuthGate
|
|
├── login.tsx
|
|
├── signup.tsx
|
|
├── _app/ # Routes protégées
|
|
│ ├── _app.tsx # Layout app (sidebar, header, brand)
|
|
│ ├── index.tsx # Dashboard
|
|
│ ├── factures.tsx # Liste factures
|
|
│ ├── factures.$id.tsx # Détail facture
|
|
│ ├── plans.tsx # Bibliothèque plans
|
|
│ ├── plans.$id.tsx # Éditeur plan
|
|
│ ├── clients.tsx
|
|
│ └── parametres.tsx
|
|
└── onboarding/
|
|
├── compte.tsx
|
|
├── entreprise.tsx
|
|
└── signature.tsx
|
|
```
|
|
|
|
---
|
|
|
|
## 5. packages/shared — types et schémas partagés
|
|
|
|
Le but : un client API typé fortement, sans duplication de définitions.
|
|
|
|
```ts
|
|
// packages/shared/src/types/invoice.ts
|
|
export type InvoiceStatus = 'pending' | 'awaiting_user_confirmation' | 'paid' | 'in_relance' | 'litigation' | 'cancelled'
|
|
|
|
export type Invoice = {
|
|
id: string
|
|
numero: string
|
|
clientId: string
|
|
amountTtc: number
|
|
dueDate: string // ISO
|
|
status: InvoiceStatus
|
|
planId: string | null
|
|
// ...
|
|
}
|
|
```
|
|
|
|
```ts
|
|
// packages/shared/src/schemas/invoice.ts
|
|
import { z } from 'zod'
|
|
|
|
export const createInvoiceSchema = z.object({
|
|
numero: z.string().min(1),
|
|
clientId: z.string().uuid(),
|
|
amountTtc: z.number().positive(),
|
|
dueDate: z.string().datetime(),
|
|
// ...
|
|
})
|
|
|
|
export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>
|
|
```
|
|
|
|
**Avantage** : Adonis valide avec ce schéma, le SPA valide avec le même, le type est inféré une seule fois.
|
|
|
|
---
|
|
|
|
## 6. Flux de données critiques
|
|
|
|
### 6.1 Upload + OCR + création facture
|
|
|
|
```
|
|
SPA (drag & drop PDF)
|
|
│
|
|
│ POST /api/v1/invoices/upload (multipart)
|
|
▼
|
|
api: stocke le PDF dans MinIO (bucket rubis-invoices)
|
|
│
|
|
│ retourne { uploadId, status: 'processing' }
|
|
▼
|
|
api: enqueue job ProcessOcr(uploadId)
|
|
│
|
|
▼
|
|
worker: récupère le PDF depuis MinIO
|
|
│
|
|
│ appel HTTP vers OCR provider
|
|
▼
|
|
worker: parse les champs extraits, crée l'Invoice en DB (status: pending)
|
|
│
|
|
▼
|
|
SPA: poll ou WebSocket → reçoit l'Invoice prête à valider
|
|
```
|
|
|
|
**Points d'attention** : le job OCR doit être idempotent (même uploadId rejoué = pas de duplicate). Le SPA peut afficher un spinner pendant les 3-10 secondes d'OCR.
|
|
|
|
### 6.2 Programmation des relances
|
|
|
|
```
|
|
SPA: utilisateur clique "Valider" sur l'Invoice
|
|
│
|
|
│ PATCH /api/v1/invoices/:id (status: scheduled, planId: …)
|
|
▼
|
|
api: créé N RelanceTasks (une par étape du plan)
|
|
chaque RelanceTask a un sendAt (calculé d'après dueDate + offset étape)
|
|
│
|
|
▼
|
|
queue: tâches en attente
|
|
│
|
|
│ ┌─ avant chaque relance, créer aussi un CheckinTask (T-2j) ─┐
|
|
▼ ▼ ▼
|
|
worker @ sendAt: vérifie l'état de l'Invoice (toujours pending ?)
|
|
│
|
|
│ si invoice.status === 'pending' → envoie l'email
|
|
│ sinon → no-op (l'invoice a été marquée payée entre-temps)
|
|
```
|
|
|
|
### 6.3 Check-in email à l'utilisateur
|
|
|
|
```
|
|
worker @ checkinTask.sendAt:
|
|
│
|
|
│ génère un token signé (avec invoice.id + reply_action: 'paid' | 'not_paid')
|
|
▼
|
|
api: envoie un email à l'utilisateur (pas au client) avec 2 boutons
|
|
│
|
|
│ chaque bouton = lien GET /api/v1/checkin/:token (action embeddée)
|
|
▼
|
|
utilisateur clique "Oui, j'ai été payé"
|
|
│
|
|
▼
|
|
api: GET /checkin/:token → vérifie token, marque invoice.status = 'paid'
|
|
→ annule les RelanceTasks futures de cette invoice
|
|
→ redirect SPA avec confirmation
|
|
```
|
|
|
|
**Sécurité** : le token doit être signé (HMAC ou JWT court) et avoir une durée limitée (24h après émission). Pas d'auth Bearer requise pour ce endpoint car c'est un click depuis email.
|
|
|
|
### 6.4 Authentification Bearer
|
|
|
|
```
|
|
SPA: POST /api/v1/auth/login { email, password }
|
|
│
|
|
│ api valide credentials, crée AccessToken (TTL 30 min) + RefreshToken (TTL 30j httpOnly cookie)
|
|
▼
|
|
SPA reçoit { accessToken, user } — accessToken stocké en mémoire
|
|
│
|
|
│ chaque requête API : Authorization: Bearer <accessToken>
|
|
▼
|
|
30 min plus tard : 401 sur appel API
|
|
│
|
|
▼
|
|
SPA: POST /api/v1/auth/refresh (cookie httpOnly envoyé auto)
|
|
│
|
|
│ api valide refresh, émet nouvel accessToken
|
|
▼
|
|
SPA retry l'appel original avec nouveau token
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Topologie de déploiement
|
|
|
|
### Réseau Proxmox
|
|
|
|
| Resource | Type | Rôle |
|
|
|---|---|---|
|
|
| Cluster K3s | Pool VMs Proxmox | Orchestration des pods app |
|
|
| LXC `postgres` | LXC dédié | PostgreSQL — accessible aux pods K3s via réseau interne |
|
|
| LXC `minio` | LXC dédié | MinIO — accessible aux pods K3s via réseau interne |
|
|
| Traefik | Reverse proxy | TLS termination + routing par hostname |
|
|
|
|
### Pods K3s
|
|
|
|
```yaml
|
|
# Namespace: rubis
|
|
- Deployment: rubis-api # AdonisJS Node, port 3333
|
|
- Deployment: rubis-web # nginx, sert le bundle Vite, port 80
|
|
- Deployment: rubis-landing # déjà existant
|
|
- Service: rubis-api-svc # ClusterIP
|
|
- Service: rubis-web-svc # ClusterIP
|
|
- Service: postgres-external # ExternalName → IP du LXC postgres
|
|
- Service: minio-external # ExternalName → IP du LXC minio
|
|
- Secret: rubis-config # DB credentials, MinIO credentials, OCR API key, mail API key
|
|
- IngressRoute (Traefik) :
|
|
api.rubis-sur-l-ongle.fr → rubis-api-svc:3333
|
|
app.rubis-sur-l-ongle.fr → rubis-web-svc:80
|
|
rubis-sur-l-ongle.fr → rubis-landing-svc:80
|
|
```
|
|
|
|
### Pipeline CI Gitea
|
|
|
|
```
|
|
git push gitea main
|
|
↓
|
|
.gitea/workflows/build.yml
|
|
↓
|
|
build & push images :
|
|
- git.arthurbarre.fr/ordinarthur/rubis-api:<sha>
|
|
- git.arthurbarre.fr/ordinarthur/rubis-web:<sha>
|
|
↓
|
|
kubectl rollout (api + web)
|
|
↓
|
|
healthchecks readinessProbe → service public
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Conventions de code
|
|
|
|
| Domaine | Convention |
|
|
|---|---|
|
|
| Branches | `feat/<short-desc>`, `fix/<short-desc>`, `chore/<…>` |
|
|
| Commits | [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`) |
|
|
| TypeScript | `strict: true`, pas de `any` (sauf justifié + commenté), `noUncheckedIndexedAccess: true` |
|
|
| Linting | ESLint + Prettier, config partagée via `tsconfig.base.json` + `.eslintrc.cjs` racine |
|
|
| Tests | Japa (Adonis) pour API, Vitest pour shared/, Playwright pour E2E utilisateur (V2) |
|
|
| Migrations | Versionnées, jamais éditées rétroactivement (cf. principe ADR du log de décisions) |
|
|
| Secrets | Jamais en clair dans le repo. `.env.local` git-ignoré, secrets K3s pour la prod |
|
|
|
|
---
|
|
|
|
## 9. Sécurité & RGPD
|
|
|
|
- **Hébergement français** (Proxmox France) — conforme RGPD pour la cible TPE-PME
|
|
- **Chiffrement at-rest** : disque Proxmox chiffré (LUKS) — à confirmer côté infra
|
|
- **Chiffrement in-transit** : TLS partout (Traefik), connexions PG et MinIO en SSL interne
|
|
- **Rate limiting** : `@adonisjs/limiter` sur `/auth/*` (5 req/min par IP), routes OCR (10/h par utilisateur)
|
|
- **CORS** : whitelist stricte (`app.rubis-sur-l-ongle.fr` uniquement) — refus des origines tierces
|
|
- **CSRF** : non-applicable car auth via Bearer header (pas cookie session). Les endpoints email check-in utilisent des tokens signés à TTL court.
|
|
- **Backups** :
|
|
- PG : dump quotidien dans MinIO (`rubis-backups/pg/<date>.dump`)
|
|
- MinIO : snapshot Proxmox du LXC quotidien
|
|
- Retention : 30 jours mini (à confirmer)
|
|
- **Suppression** : RGPD Article 17 — endpoint `DELETE /api/v1/me` qui purge data utilisateur + factures + pièces jointes
|
|
|
|
---
|
|
|
|
## 10. Décisions encore en attente
|
|
|
|
À trancher avant fin V1, par ordre de priorité :
|
|
|
|
| # | Sujet | Échéance suggérée |
|
|
|---|---|---|
|
|
| 019 | **Domain model** (entités, relations, index) | Avant la 1ère migration |
|
|
| 020 | **Provider OCR** (Mindee, Document AI, Textract, Tesseract) | Avant l'implémentation du job ProcessOcr |
|
|
| 021 | **Provider email** (Resend, Postmark, SendGrid, AWS SES) | Avant l'implémentation des relances |
|
|
| 022 | **Pricing exact** (Free 5 factures ? Pro 19 €/mois ?) | Avant le payment flow |
|
|
| 023 | **Endpoint waitlist** (Resend / Formspree / API Adonis) | Au push de la landing en prod |
|
|
|
|
---
|
|
|
|
## 11. Évolutions V2+ anticipées
|
|
|
|
- **Multi-utilisateurs** : tables `organizations` et `memberships` à prévoir dès la V1 (même si UI mono-user)
|
|
- **SMS** : provider Twilio/OVH abstrait derrière un service `MessageDispatcher` qui route email/sms selon plan + cadence
|
|
- **Intégration banking** : webhook entrant sur `/api/v1/banking/payment-confirmed` qui marque les invoices payées automatiquement (le check-in email V1 devient fallback)
|
|
- **Intégrations comptables** (Pennylane/Sage) : modèle d'événement abstrait `invoice.created` exportable en webhook sortant
|
|
- **API publique** : sous `/api/v1/public/*` avec abilities/scopes par token (lecture seule, écriture limitée)
|
|
|
|
---
|
|
|
|
*Maintenu par Arthur + Claude. Ce document est versionné — les changements significatifs passent par un ADR dans `/docs/decisions.md`.*
|