rubis/docs/tech/backend.md
ordinarthur 1d3b6a3f8f chore(api): UUID partout pour les PK et FK
Convention dure : tous les identifiants applicatifs sont des UUID v4 générés par PG (default gen_random_uuid()), aucun increments/serial même pour les tables techniques.

- CLAUDE.md → "Conventions techniques" : règle énoncée explicitement (anti-énumération, multi-tenant, génération côté client, dumps propres).
- docs/tech/backend.md §4.0 : exemple de migration + raisons.
- 4 migrations existantes réécrites en uuid (users, auth_access_tokens, organizations, alter users.organization_id). Les access tokens d'Adonis acceptent un tokenable_id uuid sans changement côté provider.
- Transformers nettoyés : plus de String(id), les UUID sont déjà des string.
- DB régénérée from scratch (migrations sont éditées avant tout déploiement, pas un cas où un autre dev a une DB en prod).
2026-05-06 13:58:11 +02:00

1182 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Guide d'implémentation — Backend
> Version : 0.1 · Dernière maj : 2026-05-06
> Décisions de référence : ADR-014 (stack), ADR-015 (monorepo), ADR-016 (PG), ADR-017 (auth), ADR-018 (storage).
Ce document est le **guide pratique d'implémentation de l'API**. Il complète `architecture.md` (qui décrit le **quoi**) en expliquant le **comment** : commandes, snippets, conventions, et — surtout — le **contrat exact** que le SPA attend déjà côté front (les mocks MSW de `apps/web/src/mocks/handlers/` sont la source de vérité du contrat actuel).
**À lire avant** :
- `/CLAUDE.md` — contexte top-level
- `/docs/produit.md` — flows utilisateur, IN/OUT V1
- `/docs/tech/architecture.md` — vue d'ensemble du système
- `/docs/tech/frontend.md` — guide d'implémentation du SPA (utile pour comprendre ce que le back doit servir)
- `/packages/shared/src/` — types et schemas Zod déjà partagés
- `/apps/web/src/mocks/handlers/`**le contrat API tel qu'il est consommé**
---
## 1. Vue d'ensemble
L'API (`apps/api/`) est un **AdonisJS v7** en TypeScript, qui sert :
- **JSON-only** sur `/api/v1/*` (pas de Inertia, pas de Hypermedia — le SPA est un consommateur séparé)
- **Auth Bearer** stateless via `@adonisjs/auth` access_tokens (cf. ADR-017), avec un refresh token cookie httpOnly géré custom par-dessus
- **Tuyau** pour générer le client TS typé consommé par le SPA → contrat API ↔ web verrouillé par le compilateur
- **PostgreSQL** comme base relationnelle (cf. ADR-016, instance LXC Proxmox existante)
- **MinIO** pour les pièces jointes (cf. ADR-018)
- **Provider OCR** externe (à benchmarker, ADR-020)
- **Provider Email** externe (à benchmarker, ADR-021)
- **Background jobs** pour l'OCR différé, les relances programmées, les check-ins
Le scaffold initial a été créé via `pnpm create adonisjs@latest -- apps/api --kit=api --pkg=pnpm`, kit `api`. Ça nous a déjà donné :
- Auth `access_tokens` configurée (`config/auth.ts`)
- Tuyau core installé (`@tuyau/core`) + script `node ace tuyau:generate`
- Routes auth de base : `POST /auth/signup`, `POST /auth/login`, `POST /account/logout`, `GET /account/profile`
- Lucid + Vine + CORS + Shield + Session
Tout le reste (org, clients, factures, plans, jobs, OCR, email) est à construire par-dessus.
---
## 2. Stack interne
| Couche | Choix | Rôle |
|---|---|---|
| Framework | `@adonisjs/core` v7 | HTTP, IoC, providers, ace CLI |
| ORM | `@adonisjs/lucid` | PG, migrations, query builder, relations |
| Auth | `@adonisjs/auth` (access_tokens) | Bearer tokens, middleware `auth()` |
| Authz | `@adonisjs/bouncer` | Policies pour les permissions (V1 mono-user, prêt V2 multi-user) |
| Validation | `@vinejs/vine` | Validateurs typés natifs Adonis, mappés sur les schemas Zod de `packages/shared` |
| Mail | `@adonisjs/mail` | Templates + provider switchable (Resend / Postmark / SES) |
| Queue | `@rlanz/bull-queue` (BullMQ) | Jobs différés (OCR, envoi email, check-ins) |
| Cache / queue backend | Redis | Backend de BullMQ + cache des KPIs dashboard |
| Rate-limit | `@adonisjs/limiter` | 5 req/min sur `/auth/*`, 10/h sur upload |
| Tests | `@japa/runner` + `@japa/api-client` | Tests d'intégration HTTP |
| Type-sharing front | `@tuyau/core` | Génère `.adonisjs/api.ts` consommé par le SPA |
| HTTP client | `@adonisjs/limiter` + `ky` (côté SPA) | — |
| Storage | `@adonisjs/drive` (S3 driver MinIO) | Abstraction stockage PDFs |
### Dépendances déjà installées par le starter API
Voir `apps/api/package.json`. À ajouter pour V1 :
```bash
cd apps/api
pnpm add @adonisjs/bouncer @adonisjs/mail @adonisjs/limiter @adonisjs/drive
pnpm add @rlanz/bull-queue bullmq ioredis
pnpm add @aws-sdk/client-s3 # pour MinIO via le driver S3 d'@adonisjs/drive
pnpm add resend # ou postmark / @aws-sdk/client-ses selon ADR-021
node ace add @adonisjs/bouncer
node ace add @adonisjs/mail --providers=resend
node ace add @adonisjs/limiter
node ace add @adonisjs/drive --services=s3
node ace add @rlanz/bull-queue
```
---
## 3. Repo layout (apps/api)
Reflète les conventions Adonis 7 + un découpage `services/` pour la logique métier hors HTTP.
```
apps/api/
├── app/
│ ├── controllers/ # HTTP only — pas de logique métier
│ │ ├── auth/
│ │ │ ├── access_tokens_controller.ts # POST /auth/login + DELETE
│ │ │ ├── new_account_controller.ts # POST /auth/signup
│ │ │ └── refresh_controller.ts # POST /auth/refresh — V1 custom
│ │ ├── account/
│ │ │ ├── profile_controller.ts # GET + PATCH /account/profile
│ │ │ └── logout_controller.ts # POST /account/logout
│ │ ├── organizations_controller.ts # GET + PATCH /organizations/me
│ │ ├── clients_controller.ts # CRUD /clients
│ │ ├── plans_controller.ts # CRUD /plans
│ │ ├── invoices_controller.ts # CRUD /invoices + actions
│ │ ├── invoices_upload_controller.ts # POST /invoices/upload
│ │ ├── import_batches_controller.ts # /invoices/import-batch/*
│ │ └── dashboard_controller.ts # /dashboard/*
│ ├── models/ # Lucid models, 1 par entité
│ │ ├── user.ts
│ │ ├── organization.ts
│ │ ├── client.ts
│ │ ├── invoice.ts
│ │ ├── plan.ts
│ │ ├── plan_step.ts
│ │ ├── relance_task.ts
│ │ ├── checkin_task.ts
│ │ ├── activity_event.ts
│ │ ├── import_batch.ts
│ │ └── import_draft.ts
│ ├── services/ # Logique métier réutilisable
│ │ ├── ocr/
│ │ │ ├── ocr_provider.ts # Interface
│ │ │ ├── mindee_provider.ts # Implémentation
│ │ │ └── tesseract_provider.ts
│ │ ├── mail/
│ │ │ └── mail_dispatcher.ts # Wrapper @adonisjs/mail
│ │ ├── relance_scheduler.ts # Programme les RelanceTasks d'une facture
│ │ ├── checkin_dispatcher.ts # Génère les tokens + envoie les check-ins
│ │ ├── rubis_calculator.ts # 1 rubis = 10 min, recalcul org.rubisCount
│ │ └── activity_recorder.ts # Crée des ActivityEvent
│ ├── jobs/ # BullMQ jobs
│ │ ├── process_ocr_job.ts
│ │ ├── send_relance_job.ts
│ │ ├── send_checkin_job.ts
│ │ └── recompute_kpis_job.ts
│ ├── mails/ # @adonisjs/mail templates
│ │ ├── relance_amical_mail.ts
│ │ ├── relance_courtois_mail.ts
│ │ ├── relance_ferme_mail.ts
│ │ ├── mise_en_demeure_mail.ts
│ │ └── checkin_mail.ts
│ ├── policies/ # Bouncer
│ │ ├── invoice_policy.ts # canView / canEdit / canDelete
│ │ └── plan_policy.ts
│ ├── validators/ # Vine schemas (miroir packages/shared)
│ │ ├── auth.ts
│ │ ├── client.ts
│ │ ├── invoice.ts
│ │ └── plan.ts
│ ├── middleware/
│ │ ├── auth_middleware.ts # déjà scaffold
│ │ ├── force_json_response_middleware.ts
│ │ └── rate_limiter_middleware.ts
│ ├── exceptions/
│ │ └── handler.ts # Convertit les exceptions en JSON `{ errors: [...] }`
│ └── transformers/ # Sérialisation pour les réponses
│ ├── client_transformer.ts
│ ├── invoice_transformer.ts
│ └── plan_transformer.ts
├── config/
│ ├── auth.ts # déjà scaffold (access_tokens)
│ ├── database.ts # PG en V1
│ ├── drive.ts # MinIO via S3 driver
│ ├── mail.ts # Resend ou autre
│ ├── queue.ts # BullMQ + Redis
│ ├── limiter.ts # Rate limiting
│ └── shield.ts # CSP, CORS allowlist
├── start/
│ ├── routes.ts # routes par domaine, importées
│ ├── routes/
│ │ ├── auth.ts
│ │ ├── account.ts
│ │ ├── clients.ts
│ │ ├── plans.ts
│ │ ├── invoices.ts
│ │ └── dashboard.ts
│ └── kernel.ts
├── database/
│ ├── migrations/
│ │ ├── XXXX_create_users_table.ts # déjà scaffold
│ │ ├── XXXX_create_access_tokens_table.ts # déjà scaffold
│ │ ├── XXXX_create_refresh_tokens_table.ts # à créer
│ │ ├── XXXX_create_organizations_table.ts
│ │ ├── XXXX_create_clients_table.ts
│ │ ├── XXXX_create_plans_table.ts
│ │ ├── XXXX_create_plan_steps_table.ts
│ │ ├── XXXX_create_invoices_table.ts
│ │ ├── XXXX_create_relance_tasks_table.ts
│ │ ├── XXXX_create_checkin_tasks_table.ts
│ │ ├── XXXX_create_import_batches_table.ts
│ │ ├── XXXX_create_import_drafts_table.ts
│ │ └── XXXX_create_activity_events_table.ts
│ ├── seeders/
│ │ ├── default_plans_seeder.ts # 4 plans pré-fournis (cf. seed.ts MSW)
│ │ └── demo_data_seeder.ts # comptes démo (dev seulement)
│ └── factories/ # Factories Lucid pour les tests
├── tests/
│ ├── functional/ # Tests HTTP via @japa/api-client
│ │ ├── auth.spec.ts
│ │ ├── invoices.spec.ts
│ │ └── ...
│ └── unit/
└── package.json
```
---
## 4. Domain models
Le modèle est **scopé par `organization_id` partout** — V1 mono-user mais multi-tenant ready dès le schéma. Tous les Lucid queries doivent filtrer sur `organization_id` (en pratique : un middleware ou un scope global qui injecte ce filtre).
### 4.0 Identifiants — UUID partout
**Convention dure** : toutes les PK et FK applicatives sont des UUID (PG `uuid` avec default `gen_random_uuid()`). **Pas d'`increments`/`serial`**, y compris pour les tables techniques (`auth_access_tokens`, `refresh_tokens`, `import_batches`, etc.).
```ts
// migration
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
table.uuid('organization_id').notNullable().references('id').inTable('organizations').onDelete('CASCADE')
```
```ts
// model — id est typé string, le schema generator le déduit du type uuid
@column({ isPrimary: true })
declare id: string
```
Côté transformers, on expose l'UUID tel quel (pas de cast `String(id)`).
**Pourquoi** :
1. **Anti-énumération** : `/invoices/42` révèle qu'on en a au moins 42, ce qui peut leak des volumes business.
2. **Fédération multi-tenant** : pas besoin d'un namespace par tenant pour éviter les collisions, l'UUID est globalement unique.
3. **Génération côté client** : permet d'optimiste-créer un objet (UUID généré côté SPA) avant le round-trip API.
4. **Pas de surprise sur les exports** : un dump CSV qui sort des `id=1,2,3` est un bug latent.
### 4.1 User
```ts
// app/models/user.ts
import { BaseModel, column, belongsTo } from '@adonisjs/lucid/orm'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { DateTime } from 'luxon'
import Organization from '#models/organization'
const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
uids: ['email'],
passwordColumnName: 'password',
})
export default class User extends compose(BaseModel, AuthFinder) {
@column({ isPrimary: true })
declare id: string
@column()
declare email: string
@column({ serializeAs: null })
declare password: string
@column({ columnName: 'full_name' })
declare fullName: string
@column({ columnName: 'organization_id' })
declare organizationId: string
@column()
declare signature: string | null
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
static accessTokens = DbAccessTokensProvider.forModel(User, {
expiresIn: '30 mins',
})
}
```
### 4.2 Organization
```ts
@column() declare name: string
@column() declare siret: string | null
@column({ columnName: 'monthly_volume_bucket' }) declare monthlyVolumeBucket: string | null
@column({ columnName: 'rubis_count' }) declare rubisCount: number
@column.dateTime({ columnName: 'onboarding_completed_at' })
declare onboardingCompletedAt: DateTime | null
```
> **Pattern** : `onboardingCompletedAt` côté serveur remplace l'astuce `signature !== null` du SPA. Une fois set, le SPA ne renvoie plus l'utilisateur dans le wizard.
### 4.3 Client
```ts
@column() declare name: string
@column() declare email: string // REQUIS — pivot du produit
@column() declare phone: string | null
@column() declare address: string | null
@column() declare siret: string | null // 14 chiffres si fourni
@column() declare notes: string | null
@hasMany(() => Invoice) declare invoices: HasMany<typeof Invoice>
```
### 4.4 Invoice
```ts
@column() declare numero: string
@column({ columnName: 'amount_ttc_cents' }) declare amountTtcCents: number
@column.dateTime({ columnName: 'issue_date' }) declare issueDate: DateTime
@column.dateTime({ columnName: 'due_date' }) declare dueDate: DateTime
@column() declare status: InvoiceStatus
@column({ columnName: 'plan_id' }) declare planId: string | null
@column({ columnName: 'pdf_storage_key' }) declare pdfStorageKey: string | null
@column() declare notes: string | null
@column({ columnName: 'rubis_earned' }) declare rubisEarned: number
@column({ columnName: 'paid_at' }) declare paidAt: DateTime | null
@belongsTo(() => Client) declare client: BelongsTo<typeof Client>
@belongsTo(() => Plan) declare plan: BelongsTo<typeof Plan>
@hasMany(() => RelanceTask) declare relanceTasks: HasMany<typeof RelanceTask>
```
**InvoiceStatus** (enum DB + TS, miroir `packages/shared/constants`) :
```
pending · in_relance · awaiting_user_confirmation · paid · litigation · cancelled
```
### 4.5 Plan + PlanStep
```ts
// Plan
@column() declare slug: string | null // null pour les plans custom
@column() declare name: string
@column() declare description: string
@column({ columnName: 'is_default' }) declare isDefault: boolean
@hasMany(() => PlanStep, { foreignKey: 'planId' }) declare steps: HasMany<typeof PlanStep>
// PlanStep
@column() declare order: number
@column({ columnName: 'offset_days' }) declare offsetDays: number
@column() declare tone: RelanceTone
@column() declare subject: string
@column() declare body: string
@column({ columnName: 'requires_manual_validation' })
declare requiresManualValidation: boolean
```
### 4.6 RelanceTask + CheckinTask
```ts
// RelanceTask : une étape de relance programmée pour une facture
@column({ columnName: 'invoice_id' }) declare invoiceId: string
@column({ columnName: 'plan_step_id' }) declare planStepId: string
@column.dateTime({ columnName: 'send_at' }) declare sendAt: DateTime
@column() declare status: 'scheduled' | 'sent' | 'cancelled' | 'failed'
@column({ columnName: 'sent_at' }) declare sentAt: DateTime | null
@column({ columnName: 'queue_job_id' }) declare queueJobId: string | null
// CheckinTask : email envoyé à l'utilisateur (pas au client) avant chaque relance
@column({ columnName: 'invoice_id' }) declare invoiceId: string
@column.dateTime({ columnName: 'send_at' }) declare sendAt: DateTime
@column() declare token: string // signé, TTL 24h
@column() declare status: 'scheduled' | 'sent' | 'answered' | 'expired'
```
### 4.7 ImportBatch + ImportDraft
Éphémères : créés à l'upload OCR, supprimés une fois tous les drafts validés ou skippés.
```ts
// ImportBatch
@column({ columnName: 'organization_id' }) declare organizationId: string
@hasMany(() => ImportDraft) declare drafts: HasMany<typeof ImportDraft>
// ImportDraft
@column() declare filename: string
@column({ columnName: 'pdf_storage_key' }) declare pdfStorageKey: string
@column() declare extracted: object // jsonb
@column() declare edited: object // jsonb
@column() declare confidence: object // jsonb
@column() declare status: 'pending' | 'validated' | 'skipped'
@column({ columnName: 'invoice_id' }) declare invoiceId: string | null
```
### 4.8 ActivityEvent
Pour le journal d'activité du dashboard. Append-only, pas de mutation.
```ts
@column({ columnName: 'organization_id' }) declare organizationId: string
@column() declare kind: 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
@column.dateTime() declare at: DateTime
@column() declare label: string // texte HTML simple (<b> autorisé)
@column() declare meta: object // jsonb : { invoiceId?, clientId?, ... }
```
---
## 5. Routes API
**Toutes** sous `/api/v1/`. Le SPA consomme exactement cette surface (cf. `apps/web/src/mocks/handlers/`). Versioning explicite : V2 vivra côté `/api/v2/` sans casser V1.
### 5.1 Auth (public)
| Méthode | Route | Body | Réponse |
|---|---|---|---|
| POST | `/auth/signup` | `{ email, password, fullName }` | `{ data: AuthSession }` (201) |
| POST | `/auth/login` | `{ email, password }` | `{ data: AuthSession }` |
| POST | `/auth/refresh` | — (cookie httpOnly) | `{ data: AuthSession }` ou 401 |
Réponse `AuthSession` :
```ts
{ accessToken: string, expiresAt: string /* ISO */, user: User }
```
### 5.2 Compte (auth)
| Méthode | Route | Body | Réponse |
|---|---|---|---|
| GET | `/account/profile` | — | `{ data: User }` |
| PATCH | `/account/profile` | `{ fullName?, email?, signature? }` | `{ data: User }` |
| POST | `/account/logout` | — | 204 (clear le refresh cookie) |
### 5.3 Organisation (auth)
| Méthode | Route | Body | Réponse |
|---|---|---|---|
| GET | `/organizations/me` | — | `{ data: Organization }` |
| PATCH | `/organizations/me` | `{ name?, siret?, monthlyVolumeBucket? }` | `{ data: Organization }` |
### 5.4 Clients (auth)
| Méthode | Route | Query | Body | Réponse |
|---|---|---|---|---|
| GET | `/clients` | `?withStats=1&q=` | — | `{ data: Client[] }` (avec stats si `withStats`) |
| GET | `/clients/:id` | — | — | `{ data: ClientDetail }` (avec invoices) |
| POST | `/clients` | — | `{ name, email, phone?, address?, siret?, notes? }` | `{ data: Client }` (201). 409 si doublon de nom. |
| PATCH | `/clients/:id` | — | `{ name?, email?, phone?, address?, siret?, notes? }` | `{ data: Client }` |
### 5.5 Plans (auth)
| Méthode | Route | Body | Réponse |
|---|---|---|---|
| GET | `/plans` | — | `{ data: PlanWithUsage[] }` |
| GET | `/plans/:slug` | — | `{ data: PlanWithUsage }` |
| POST | `/plans` | `{ name, description, steps[] }` | `{ data: Plan }` (V2) |
| PATCH | `/plans/:slug` | `{ name?, description?, steps? }` | `{ data: Plan }` |
### 5.6 Factures (auth)
| Méthode | Route | Query | Body | Réponse |
|---|---|---|---|---|
| GET | `/invoices` | `?status=&q=&clientId=&page=` | — | `{ data: InvoiceListItem[], meta: { total, page } }` |
| GET | `/invoices/counts` | — | — | `{ data: StatusCounts }` |
| GET | `/invoices/:id` | — | — | `{ data: InvoiceDetail }` (client + plan + timeline) |
| POST | `/invoices` | — | `{ clientId?, clientName, clientEmail, numero, amountTtcCents, issueDate, dueDate, planId? }` | `{ data: Invoice }` (201) |
| POST | `/invoices/:id/mark-paid` | — | — | `{ data: Invoice }` |
| POST | `/invoices/:id/relance` | — | — | `{ data: Invoice }` (relance manuelle, V1 si scope) |
### 5.7 Import OCR (auth)
| Méthode | Route | Body | Réponse |
|---|---|---|---|
| POST | `/invoices/upload` | `multipart` (files) — V1 mock accepte `{ filenames: string[] }` | `{ data: ImportBatch }` (201) |
| GET | `/invoices/import-batch/:id` | — | `{ data: ImportBatch }` |
| POST | `/invoices/import-batch/:id/drafts/:draftId/validate` | DraftFields édités | `{ data: Invoice }` (201) |
| POST | `/invoices/import-batch/:id/drafts/:draftId/skip` | — | `{ data: ImportDraft }` |
| DELETE | `/invoices/import-batch/:id` | — | 204 (annule le batch entier) |
### 5.8 Dashboard (auth)
| Méthode | Route | Réponse |
|---|---|---|
| GET | `/dashboard/kpis` | `{ data: DashboardKpis }` |
| GET | `/dashboard/activity` | `{ data: ActivityEvent[] }` |
| GET | `/dashboard/top-late` | `{ data: LatePayer[] }` |
### 5.9 Check-in email (public, signed token)
| Méthode | Route | Réponse |
|---|---|---|
| GET | `/checkin/:token` | Redirect HTML vers le SPA avec confirmation |
> Pas d'auth Bearer ici — l'utilisateur clique depuis un email reçu. Sécurité = HMAC sur le token (TTL 24h après émission).
---
## 6. Conventions de réponse
**Succès** :
```json
{ "data": { ... }, "meta": { "page": 1, "total": 23 } }
```
**Erreur** :
```json
{ "errors": [{ "code": "validation_failed", "message": "...", "field": "email" }] }
```
**Codes HTTP** : 200, 201, 204, 400, 401, 403, 404, 409, 422, 429, 500.
**Pagination** : `?page=N` simple en V1, on bascule sur cursor-based si volume → cf. ADR à venir.
**Le `errors[].code`** est une chaîne stable que le SPA peut switcher dessus pour des messages contextuels :
- `validation_failed`
- `invalid_credentials`
- `email_taken`
- `client_email_required`
- `duplicate_client`
- `unauthenticated`
- `not_found`
- `forbidden`
- `rate_limited`
L'`ExceptionHandler` (`app/exceptions/handler.ts`) transforme les exceptions Vine, Lucid, Auth en cette enveloppe.
---
## 7. Auth — Bearer + refresh httpOnly cookie
Cf. ADR-017. Pattern hybride : access token stateless (Adonis access_tokens) + refresh token custom en cookie httpOnly.
### 7.1 Ce qui est natif Adonis
`@adonisjs/auth` avec guard `tokensGuard` couvre :
- `POST /auth/login` → crée un access token (TTL 30 min) → renvoyé en JSON
- `middleware.auth()` valide le `Authorization: Bearer <token>` sur les routes protégées
- `User.accessTokens.create(user)` / `User.accessTokens.delete(user, token)`
### 7.2 Ce qu'on ajoute custom — refresh token cookie
Adonis ne ship pas de refresh-token primitive. On en construit un sobrement :
**Schéma `refresh_tokens`** :
```ts
{ id, userId, hashedToken, expiresAt, lastUsedAt, revokedAt, ipAddress, userAgent }
```
**Service `RefreshTokenService`** :
```ts
async create(user: User, ctx: HttpContext): Promise<{ token: string; expiresAt: DateTime }>
async rotate(plainToken: string, ctx: HttpContext): Promise<{ user: User; newToken: string }>
async revoke(plainToken: string): Promise<void>
async revokeAllForUser(userId: string): Promise<void>
```
Le token plain (32 bytes random) est envoyé au SPA via cookie :
```ts
ctx.response.cookie('rubis_refresh', plainToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/api/v1/auth',
maxAge: 30 * 24 * 60 * 60, // 30 jours
})
```
**Stocké hashé** côté DB (SHA-256 — pas besoin de bcrypt, c'est un opaque random pas un mot de passe). Sur `/auth/refresh`, on hash le cookie reçu, on cherche en DB, on vérifie expiry, on rotate (= invalide l'ancien, crée un nouveau, retourne nouveau access + cookie rotaté).
**Pourquoi rotation** : si quelqu'un vole le cookie, dès que le vrai user fait un refresh, le voleur perd l'accès (le rotated token devient invalide).
### 7.3 Endpoints
```ts
// start/routes/auth.ts
router.post('/auth/signup', '#controllers/auth/new_account_controller.store')
.as('auth.signup')
router.post('/auth/login', '#controllers/auth/access_tokens_controller.store')
.as('auth.login')
router.post('/auth/refresh', '#controllers/auth/refresh_controller.handle')
.as('auth.refresh')
router.post('/account/logout', '#controllers/account/logout_controller.handle')
.as('account.logout')
// logout révoque le refresh + delete l'access token
```
### 7.4 Côté SPA
- Login / signup : reçoit `{ accessToken, expiresAt, user }` en JSON + cookie `rubis_refresh` posé automatiquement par le browser. SPA stocke `accessToken` en mémoire.
- Au boot, le SPA appelle `POST /auth/refresh` (le browser envoie le cookie). Si OK → réhydrate `authStore`. Si 401 → reste anonyme.
- Si une requête API retourne 401, le SPA tente un silent refresh puis retry.
- Logout : `POST /account/logout` → revoke + clear cookie. SPA clear son `authStore`.
> Le mock MSW actuel (`apps/web/src/mocks/sessionStore.ts`) simule ce flow via `localStorage` — quand le vrai backend arrive, `localStorage` disparaît, le cookie httpOnly prend le relais. **Pas de changement côté SPA** sauf retirer MSW.
---
## 8. Tuyau — client typé pour le SPA
Tuyau génère `apps/api/.adonisjs/api.ts` qui décrit toutes les routes, leurs paramètres, et leurs réponses, exposé via `apps/api/package.json` `exports`. Le SPA importe ces types et appelle `tuyau.api.v1.invoices.$get()` avec autocomplete + type-check.
### 8.1 Exposition côté API
`apps/api/package.json` (déjà fait par le scaffold) :
```json
{
"exports": {
"./data": "./.adonisjs/client/data.d.ts",
"./registry": "./.adonisjs/client/registry/index.ts"
}
}
```
### 8.2 Annoter les routes
Chaque route reçoit un nom (`as()`) — utilisé par Tuyau pour générer les types nommés.
```ts
router.get('/invoices', '#controllers/invoices_controller.index')
.as('invoices.index')
.use(middleware.auth())
```
### 8.3 Génération
```bash
cd apps/api
node ace tuyau:generate # one-shot
pnpm tuyau:watch # dev — script à ajouter dans package.json
```
→ Régénère `.adonisjs/client/...` à chaque modif de routes/validators.
### 8.4 Côté SPA
```ts
// apps/web/src/lib/api.ts (à compléter quand on retire MSW)
import { createTuyau } from '@tuyau/client'
import { api } from '@rubis/api/registry'
export const tuyau = createTuyau({
api,
baseUrl: env.VITE_API_URL,
credentials: 'include', // pour envoyer le cookie refresh
headers: () => ({
Authorization: authStore.token ? `Bearer ${authStore.token}` : '',
}),
})
```
---
## 9. Validation — Vine
Adonis 7 utilise **Vine** comme validateur natif. On peut soit :
1. **Réécrire** les schemas en Vine (idiomatique Adonis, support natif des messages d'erreur i18n)
2. **Réutiliser** les schemas Zod de `packages/shared` côté serveur via `zod` + un wrapper qui transforme les `ZodError` en réponse 422 standardisée
Choix recommandé : **Vine côté API**, **Zod côté SPA** — on accepte la duplication parce qu'elle est minime (~5 schemas) et chaque côté reste idiomatique.
Exemple :
```ts
// apps/api/app/validators/client.ts
import vine from '@vinejs/vine'
export const createClientValidator = vine.compile(
vine.object({
name: vine.string().minLength(2).maxLength(120),
email: vine.string().email().maxLength(120), // requis !
phone: vine.string().maxLength(40).nullable().optional(),
address: vine.string().maxLength(500).nullable().optional(),
siret: vine.string().regex(/^\d{14}$/).nullable().optional(),
notes: vine.string().maxLength(2000).nullable().optional(),
}),
)
```
Le contrôleur appelle `await request.validateUsing(createClientValidator)` → throws `ValidationException` → l'exception handler transforme en `{ errors: [...] }` 422.
---
## 10. Storage — MinIO
`@adonisjs/drive` avec driver `s3` configuré pour MinIO (S3-compatible).
```ts
// config/drive.ts
const driveConfig = defineConfig({
default: 's3',
services: {
s3: services.s3({
credentials: {
accessKeyId: env.get('MINIO_ACCESS_KEY'),
secretAccessKey: env.get('MINIO_SECRET_KEY'),
},
endpoint: env.get('MINIO_ENDPOINT'), // http://lxc-minio:9000
region: 'fr-par',
bucket: 'rubis-invoices',
forcePathStyle: true, // requis pour MinIO
visibility: 'private',
}),
},
})
```
### Conventions de keys
```
invoices/<orgId>/<invoiceId>/<filename>
import-drafts/<orgId>/<batchId>/<draftId>.<ext>
backups/pg/<date>.dump
```
### Upload depuis le contrôleur
```ts
const file = ctx.request.file('file', { extnames: ['pdf', 'png', 'jpg', 'jpeg'], size: '10mb' })
if (!file) throw new BadRequest('Fichier manquant')
const key = `import-drafts/${orgId}/${batchId}/${draft.id}.${file.extname}`
await drive.use().putStream(key, file.stream)
draft.pdfStorageKey = key
await draft.save()
```
### URLs signées
Pour permettre au SPA de prévisualiser un PDF sans exposer MinIO directement :
```ts
const url = await drive.use().getSignedUrl(invoice.pdfStorageKey, { expiresIn: '15 mins' })
```
→ exposé via `GET /invoices/:id` dans la réponse `pdfPreviewUrl`.
---
## 11. OCR pipeline
### 11.1 Interface abstraite
```ts
// app/services/ocr/ocr_provider.ts
export interface OcrProvider {
extract(input: { storageKey: string; filename: string }): Promise<OcrResult>
}
export type OcrResult = {
fields: {
clientName: { value: string; confidence: number }
clientEmail: { value: string | null; confidence: number }
numero: { value: string; confidence: number }
amountTtcCents: { value: number; confidence: number }
issueDate: { value: string; confidence: number } // ISO
dueDate: { value: string; confidence: number }
}
rawProviderResponse?: unknown // pour debug / re-process
}
```
### 11.2 Providers V1 candidats (cf. ADR-020)
| Provider | Pour | Contre |
|---|---|---|
| **Mindee** | Modèle "Invoice" pré-entraîné FR, API simple | Coût par page |
| **Google Document AI** | Très bonne qualité, bon FR | Setup GCP, coûteux |
| **AWS Textract** | Robuste, bien intégré S3 | Setup AWS, peu spécialisé facture FR |
| **Tesseract local** | Gratuit, on-prem | Qualité OCR brute, pas d'extraction structurée — il faudrait écrire le parsing à la main |
Recommandation pour V1 : **Mindee** comme default, abstraction switchable (cf. interface). Tesseract en fallback dev.
### 11.3 Job différé
```ts
// app/jobs/process_ocr_job.ts
export default class ProcessOcrJob extends Job {
static get $$filepath() { return import.meta.url }
async handle({ batchId, draftId }: { batchId: string; draftId: string }) {
const draft = await ImportDraft.query()
.where('id', draftId)
.firstOrFail()
const ocr = container.make(OcrProvider)
const result = await ocr.extract({
storageKey: draft.pdfStorageKey,
filename: draft.filename,
})
draft.extracted = result.fields
draft.edited = { ...result.fields } // édition initiale = extraction
draft.confidence = mapConfidence(result.fields)
draft.status = 'pending' // prêt à review
await draft.save()
}
}
```
**Idempotence** : `processOcrJob` doit pouvoir être rejoué (cf. retry BullMQ). Si le draft est déjà `validated` ou `skipped`, no-op.
---
## 12. Email outbound
### 12.1 Interface
`@adonisjs/mail` avec Mailers (= providers) :
```ts
// config/mail.ts
const mailConfig = defineConfig({
default: 'resend',
from: { address: env.get('MAIL_FROM_ADDRESS'), name: 'Rubis' },
mailers: {
resend: services.resend({ key: env.get('RESEND_API_KEY') }),
},
})
```
### 12.2 Templates
Chaque ton a son template (`app/mails/relance_amical_mail.ts`, etc.) — Edge syntax pour le HTML.
Variables fournies :
```ts
{
client: { name, email },
invoice: { numero, amountTtc, dueDate, issueDate },
signature: user.signature,
unsubscribeUrl: ..., // RGPD
trackingPixelUrl: ..., // V2 — ouverture
}
```
Les templates utilisent les vars définies dans les step.subject / step.body du Plan, avec interpolation Mustache-like (cf. variables ` {{client.name}} ` etc.).
### 12.3 Provider switchable (cf. ADR-021)
| Provider | Pour | Contre |
|---|---|---|
| **Resend** | Simple, prix correct, bonne deliverability FR | Provider US |
| **Postmark** | Excellence transactionnelle | Cher au volume |
| **AWS SES** | Très peu cher au volume | Setup, less DX |
| **OVH** / **Mailjet** | EU souverain | DX moins bonne |
Recommandation V1 : **Resend** comme default, abstraction `@adonisjs/mail` permet de switch sans toucher la logique métier.
### 12.4 Anti-abuse
- Rate limit côté API (5 emails/heure par invoice)
- Vérification de l'adresse expéditrice (DNS configuré, SPF + DKIM signés)
- Mention "Envoyé via Rubis" obligatoire en footer (transparence pour le destinataire)
---
## 13. Background jobs
### 13.1 Stack
`@rlanz/bull-queue` (Adonis 7 wrapper de BullMQ) + Redis comme backend.
```ts
// config/queue.ts
const queueConfig = defineConfig({
connection: {
host: env.get('REDIS_HOST'),
port: env.get('REDIS_PORT'),
},
queues: {
ocr: { name: 'ocr', concurrency: 2 },
relances: { name: 'relances', concurrency: 5 },
checkins: { name: 'checkins', concurrency: 5 },
},
})
```
### 13.2 Jobs
| Job | Trigger | Idempotent | Retry |
|---|---|---|---|
| `ProcessOcrJob` | POST /invoices/upload | oui (status=validated/skipped → no-op) | 3× exponential backoff |
| `SendRelanceJob` | RelanceTask.sendAt | oui (status=sent → no-op) | 5× |
| `SendCheckinJob` | CheckinTask.sendAt | oui | 3× |
| `RecomputeKpisJob` | nightly cron + post-mutation | oui | 1× |
### 13.3 Programmation des relances
Quand l'utilisateur valide une facture (`POST /invoices/:id/validate-import` ou création manuelle) :
1. On lit `invoice.planId``plan.steps`
2. Pour chaque step, on calcule `sendAt = invoice.dueDate + step.offsetDays`
3. On crée une `RelanceTask` (status=scheduled) + on enqueue un `SendRelanceJob` avec `delay: sendAt - now`
4. On crée éventuellement un `CheckinTask` à `sendAt - 2j` (cf. flow check-in)
À l'exécution du job :
```ts
async handle({ relanceTaskId }) {
const task = await RelanceTask.findOrFail(relanceTaskId)
if (task.status !== 'scheduled') return // idempotent
const invoice = await Invoice.query()
.where('id', task.invoiceId)
.preload('client')
.preload('plan', q => q.preload('steps'))
.firstOrFail()
// Hook critique : la facture peut être devenue paid entre-temps
if (invoice.status === 'paid' || invoice.status === 'cancelled') {
task.status = 'cancelled'
await task.save()
return
}
const step = invoice.plan.steps.find(s => s.id === task.planStepId)
if (step.requiresManualValidation) {
// Mise en demeure : on génère un brouillon, on n'envoie pas
await activityRecorder.draft(invoice, step)
return
}
await mailDispatcher.sendRelance({ invoice, step })
task.status = 'sent'
task.sentAt = DateTime.now()
await task.save()
await activityRecorder.relanceSent(invoice, step)
await rubisCalculator.creditOne(invoice.organizationId) // +1 rubis
}
```
### 13.4 Worker Pod
K3s pod séparé du pod web `rubis-api` :
```yaml
- Deployment: rubis-worker
command: ["node", "ace", "queue:listen", "--queue=ocr,relances,checkins"]
replicas: 1 # V1, scale up plus tard
```
---
## 14. Tests — Japa
Tests d'intégration HTTP via `@japa/api-client`. Pattern :
```ts
// tests/functional/clients.spec.ts
import { test } from '@japa/runner'
import { OrganizationFactory, UserFactory, ClientFactory } from '#database/factories'
test.group('Clients API', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('POST /clients refuse sans email', async ({ client }) => {
const user = await UserFactory.create()
const response = await client
.post('/api/v1/clients')
.json({ name: 'Boulangerie Test' })
.loginAs(user)
response.assertStatus(422)
response.assertBodyContains({
errors: [{ code: 'validation_failed', field: 'email' }],
})
})
test('POST /clients crée un client avec email', async ({ client }) => {
const user = await UserFactory.create()
const response = await client
.post('/api/v1/clients')
.json({
name: 'Boulangerie Test',
email: 'compta@boulangerie-test.fr',
})
.loginAs(user)
response.assertStatus(201)
response.assertBodyContains({ data: { name: 'Boulangerie Test' } })
})
})
```
**Couverture cible V1** :
- Auth flows complets (signup, login, refresh, logout, expired token)
- Permissions cross-org (un user A ne peut pas voir/modifier les ressources du user B)
- Validation Vine (toutes les contraintes critiques : email required client, SIRET format, etc.)
- Idempotence des jobs OCR / Relance
- Edge cases métier : `mark-paid` annule les RelanceTasks futures, plan édité ne casse pas les invoices en cours
---
## 15. Migrations + seeders
### 15.1 Migrations
Adonis Lucid, fichiers `database/migrations/<timestamp>_<name>.ts`. Convention : **jamais éditer une migration commitée**, on en crée une nouvelle.
Ordre logique pour V1 :
```
1. organizations
2. users (FK organization_id)
3. refresh_tokens (FK user_id)
4. clients (FK organization_id)
5. plans + plan_steps
6. invoices (FK client_id, plan_id)
7. relance_tasks (FK invoice_id, plan_step_id)
8. checkin_tasks (FK invoice_id)
9. import_batches + import_drafts
10. activity_events
```
### 15.2 Seeders
`database/seeders/default_plans_seeder.ts` recrée les 4 plans pré-fournis (Standard B2B, Rapide, Patient, Ferme) — **identiques** à ceux dans `apps/web/src/mocks/seed.ts`. Source de vérité : on extrait les plans dans `packages/shared/seeds/` (à créer) pour qu'API et MSW partagent les mêmes données.
`database/seeders/demo_data_seeder.ts` (dev only) crée le compte `demo@rubis.fr` + ses clients + factures. Match `mocks/seed.ts`.
```bash
node ace migration:run
node ace db:seed
```
---
## 16. Variables d'environnement
`apps/api/.env` (déjà scaffold partiellement par Adonis) :
```bash
# App
NODE_ENV=production
TZ=UTC
PORT=3333
HOST=0.0.0.0
LOG_LEVEL=info
APP_KEY=<32-chars-random> # cookies + crypto
SESSION_DRIVER=cookie
# CORS
CORS_ORIGINS=https://app.rubis-sur-l-ongle.fr
# DB
DB_CONNECTION=postgres
PG_HOST=lxc-postgres.proxmox.local
PG_PORT=5432
PG_USER=rubis
PG_PASSWORD=<secret>
PG_DB_NAME=rubis_prod
# Redis (queue + cache)
REDIS_HOST=lxc-redis.proxmox.local
REDIS_PORT=6379
# MinIO
MINIO_ENDPOINT=http://lxc-minio.proxmox.local:9000
MINIO_ACCESS_KEY=<secret>
MINIO_SECRET_KEY=<secret>
MINIO_INVOICES_BUCKET=rubis-invoices
MINIO_BACKUPS_BUCKET=rubis-backups
# Mail (Resend)
MAIL_FROM_ADDRESS=relances@rubis-sur-l-ongle.fr
MAIL_FROM_NAME=Rubis Sur l'Ongle
RESEND_API_KEY=<secret>
# OCR (Mindee — cf. ADR-020 pending)
OCR_PROVIDER=mindee
MINDEE_API_KEY=<secret>
# Auth refresh tokens
REFRESH_TOKEN_TTL_DAYS=30
ACCESS_TOKEN_TTL_MINUTES=30
COOKIE_DOMAIN=.rubis-sur-l-ongle.fr
COOKIE_SECURE=true
```
Validation au boot via `start/env.ts` (Adonis 7 valide chaque var avec un schema Vine).
---
## 17. Build & déploiement
### 17.1 Build local
```bash
pnpm -F api build
# → produit apps/api/build/ (JS compilé + node_modules pruné)
```
### 17.2 Image Docker
`Dockerfile.api` à la racine :
```dockerfile
FROM node:22-alpine AS builder
RUN npm install -g pnpm
WORKDIR /app
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY apps/api ./apps/api
COPY packages/shared ./packages/shared
RUN pnpm install --frozen-lockfile
RUN pnpm -F api build
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/apps/api/build ./build
COPY --from=builder /app/apps/api/package.json ./
RUN npm install -g pnpm && pnpm install --prod --frozen-lockfile
EXPOSE 3333
CMD ["node", "build/bin/server.js"]
```
### 17.3 Pipeline CI Gitea
```
.gitea/workflows/build-api.yml
→ build & push image git.arthurbarre.fr/ordinarthur/rubis-api:<sha>
→ kubectl rollout sur namespace rubis
```
### 17.4 K3s
```yaml
- Deployment: rubis-api (port 3333, replicas: 2)
- Deployment: rubis-worker (queue listener, replicas: 1)
- Service: rubis-api-svc
- IngressRoute Traefik: api.rubis-sur-l-ongle.fr → rubis-api-svc:3333
- Secret: rubis-config (toutes les vars d'env)
```
### 17.5 Migrations à la release
CronJob K3s qui run `node ace migration:run --force` avant chaque rollout. Alternative : initContainer dans le deployment qui bloque le démarrage si une migration pending.
---
## 18. Pointeurs vers l'existant
Avant de coder un endpoint, **toujours consulter** :
- **Le contrat MSW** : `apps/web/src/mocks/handlers/<domain>.ts` — le SPA y est déjà branché, c'est exactement ce qu'on doit servir
- **Les types/schemas partagés** : `packages/shared/src/types/` et `packages/shared/src/schemas/` — réutiliser les types TS dans Lucid models, traduire les schemas Zod en validators Vine équivalents
- **Le seed MSW** : `apps/web/src/mocks/seed.ts` — base pour les seeders Adonis (plans pré-fournis identiques)
- **L'auth scaffold** : `apps/api/app/controllers/access_tokens_controller.ts` etc. — le squelette est là, on étend
- **L'architecture macro** : `docs/tech/architecture.md`
---
## 19. Décisions encore à prendre côté backend
| Sujet | Quand trancher | Notes |
|---|---|---|
| **ADR-019 — Domain model** détaillé (index, contraintes, soft-delete) | Avant la 1ère migration | Privilégier les FKs cascade + soft-delete sur Invoice/Client (audit) |
| **ADR-020 — Provider OCR** | Avant l'implémentation de `ProcessOcrJob` | Mindee semble en tête. Tester sur 50 factures réelles. |
| **ADR-021 — Provider email** | Avant la 1ère relance live | Resend default. Vérifier deliverability vers boîtes pro FR. |
| **ADR-022 — Pricing exact** | Avant le payment flow | Stripe ou GoCardless ? V1 = waitlist + manuel |
| **ADR-023 — Refresh token rotation policy** | Avant prod | Quand rotater (chaque refresh ? toutes les 24h ?) — sécurité vs UX |
| **ADR-024 — Backup PG strategy** | Avant les premières données prod | Dump quotidien + retention 30j minimum |
| **ADR-025 — Webhook in/out shape** | V2 — banking + comptable | Modèle d'événements abstrait `invoice.created`, `invoice.paid` etc. |
---
## 20. Évolutions V2+ anticipées
- **Multi-utilisateurs** : `memberships` table (user × organization × role). Le filtre `organization_id` actuel facilite la transition.
- **SMS** : provider (Twilio/OVH) abstrait derrière `MessageDispatcher` qui route email/sms selon plan + cadence.
- **Banking integration** : webhook entrant `/api/v1/banking/payment-confirmed` → mark invoice paid auto. Le check-in email V1 devient fallback.
- **Intégrations comptables** (Pennylane, Sage) : événements webhook sortants `invoice.*` exportables.
- **API publique** : sous `/api/v1/public/*` avec abilities/scopes par token (lecture seule, écriture limitée).
- **Recherche full-text** : PG `tsvector` sur `invoices.notes`, `clients.notes`, `clients.name` — indexe une fois les volumes pertinents.
---
*Maintenu par Arthur + Claude. Les décisions structurelles passent par un ADR dans `/docs/decisions.md`.*