# 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 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`.*