diff --git a/docs/tech/backend.md b/docs/tech/backend.md new file mode 100644 index 0000000..a3486fb --- /dev/null +++ b/docs/tech/backend.md @@ -0,0 +1,1157 @@ +# 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.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 + + 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 +``` + +### 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 +@belongsTo(() => Plan) declare plan: BelongsTo +@hasMany(() => RelanceTask) declare relanceTasks: HasMany +``` + +**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 + +// 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 + +// 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 ( 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 ` 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 +async revokeAllForUser(userId: string): Promise +``` + +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/// +import-drafts///. +backups/pg/.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 +} + +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/_.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= +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= +MINIO_SECRET_KEY= +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= + +# OCR (Mindee — cf. ADR-020 pending) +OCR_PROVIDER=mindee +MINDEE_API_KEY= + +# 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: + → 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/.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`.*