rubis/docs/tech/backend.md
ordinarthur 1acb273c1d
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 24s
docs: email infra rubis.pro (Resend sortant + OVH MX entrant)
Documentation post-migration du setup email :

- /docs/tech/backend.md §12.5 : architecture des 2 flux
  (Resend pour le sortant transactionnel via send.rubis.pro,
  OVH MX Plan pour l'entrant humain via @ rubis.pro)
- /CLAUDE.md : tableau récap email infra + maj domaine principal
  rubis.pro / app.rubis.pro, suppression de la question ouverte
  "domaine définitif" (résolue) et "endpoint waitlist" (remplacé
  par CTA app)
- /.claude/deploy-memory.md : section migration rubis.pro marquée
   avec checklist décommissionnement legacy
- /landing/confidentialite.html : remplace privacy@rubis.pro
  par contact@rubis.pro (alignement avec les boîtes OVH créées)

Adresses opérationnelles :
- contact@rubis.pro (général + RGPD)
- dev@rubis.pro (notifs techniques)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 21:48:35 +02:00

45 KiB
Raw Blame History

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 :

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.).

// migration
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
table.uuid('organization_id').notNullable().references('id').inTable('organizations').onDelete('CASCADE')
// 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

// 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

@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

@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

@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

// 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

// 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.

// 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.

@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 :

{ 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 :

{ "data": { ... }, "meta": { "page": 1, "total": 23 } }

Erreur :

{ "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.


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)

Adonis ne ship pas de refresh-token primitive. On en construit un sobrement :

Schéma refresh_tokens :

{ id, userId, hashedToken, expiresAt, lastUsedAt, revokedAt, ipAddress, userAgent }

Service RefreshTokenService :

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 :

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

// 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) :

{
  "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.

router.get('/invoices', '#controllers/invoices_controller.index')
  .as('invoices.index')
  .use(middleware.auth())

8.3 Génération

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

// 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 :

// 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).

// 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

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 :

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

// 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é

// 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) :

// 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 :

{
  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)

12.5 Infrastructure email du domaine rubis.pro

Deux flux distincts, deux providers, pas de conflit parce qu'ils utilisent des sous-domaines différents au niveau DNS.

Sortant (transactionnel) — Resend

Pour les emails envoyés depuis l'app (relances clients, check-in utilisateur, auth/password-reset, notifications billing).

Aspect Valeur
Provider Resend (cf. ADR-021)
Driver AdonisJS transports.resend() (config/mail.ts)
Adresse expéditrice relances@rubis.pro (configurable via MAIL_FROM_ADDRESS)
Auth DNS DKIM (TXT resend._domainkey) + SPF (TXT send) + return-path MX (send)
DMARC TXT _dmarcv=DMARC1; p=none; (mode monitoring)
Click tracking CNAME mail.rubis.prolinks1.resend-dns.com. (optionnel)

Délivrabilité : tous les records DNS doivent être en statut "Verified" dans le dashboard Resend avant de switcher MAIL_FROM_ADDRESS sur relances@rubis.pro côté secret K3s.

Entrant (humain) — OVH MX Plan

Pour les emails reçus par l'équipe (questions support, demandes RGPD, factures fournisseurs, etc.). Pas de webhook, vraies boîtes mail OVH avec IMAP/SMTP/webmail standard.

Adresse Usage
contact@rubis.pro Contact général + demandes RGPD (ref. mentions légales / confidentialité)
dev@rubis.pro Notifs techniques, abonnements aux outils dev (Gitea, monitoring, etc.)
Aspect Valeur
Provider OVH MX Plan (gratuit, inclus avec le domaine acheté chez OVH)
Capacité 5 boîtes 5 Go par domaine
MX records Auto-configurés par OVH (mx*.mail.ovh.net.) lors de l'activation MX Plan
Webmail https://www.ovh.com/mail/
Apps mobiles / desktop IMAP ssl0.ovh.net:993, SMTP ssl0.ovh.net:465

Important — coexistence avec Resend :

  • Resend utilise le sous-domaine send.rubis.pro pour son MX return-path.
  • OVH MX Plan utilise l'apex @ (rubis.pro) pour la réception.
  • Les deux MX cohabitent sans conflit dans la zone DNS, chacun sur son hostname.

⚠️ Si on active un jour Resend Inbound (V2 — capter les replies clients pour auto-bumper le statut invoice), il faudra choisir entre OVH MX Plan et Resend Inbound : les deux veulent le MX @. Plan probable : on garde OVH pour contact@/dev@/etc. (humains) et on crée un sous-domaine dédié style replies.rubis.pro pour Resend Inbound (sender Reply-To = <token>@replies.rubis.pro).


13. Background jobs

13.1 Stack

@rlanz/bull-queue (Adonis 7 wrapper de BullMQ) + Redis comme backend.

// 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.planIdplan.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 :

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 :

- 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 :

// 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.

node ace migration:run
node ace db:seed

16. Variables d'environnement

apps/api/.env (déjà scaffold partiellement par Adonis) :

# 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.pro

# 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.pro
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.pro
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

pnpm -F api build
# → produit apps/api/build/ (JS compilé + node_modules pruné)

17.2 Image Docker

Dockerfile.api à la racine :

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

- Deployment: rubis-api (port 3333, replicas: 2)
- Deployment: rubis-worker (queue listener, replicas: 1)
- Service: rubis-api-svc
- IngressRoute Traefik: api.rubis.pro → 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.