Bascule du domaine principal vers rubis.pro / app.rubis.pro : - K3s ConfigMaps (api.yml, web.yml) : APP_URL, WEB_URL, COOKIE_DOMAIN, OAUTH callbacks pointent vers app.rubis.pro - Dockerfile.web : ARG VITE_API_URL et VITE_PUBLIC_LANDING_URL - Workflows Gitea : commentaires + build args web → rubis.pro - Code API (mail_dispatcher, send_test_email, config/mail) : defaults env LANDING_URL et MAIL_FROM_ADDRESS migrés - Templates env (.env.example) idem - Docs (architecture, backend, frontend, brand-identity) idem - AGENTS.md / CLAUDE.md / deploy-memory : pointeurs domaine MAJ Note : MAIL_FROM_ADDRESS dans le secret K3s reste sur rubis@arthurbarre.fr tant que le domaine rubis.pro n'est pas Verified dans Resend. À switcher manuellement après vérif Resend. Compat : un 301 Traefik redirige rubis.arthurbarre.fr → rubis.pro (et app.X aussi) — config Ansible dans le repo proxmox. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
42 KiB
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/authaccess_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_tokensconfigurée (config/auth.ts) - Tuyau core installé (
@tuyau/core) + scriptnode 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 |
@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 :
- Anti-énumération :
/invoices/42révèle qu'on en a au moins 42, ce qui peut leak des volumes business. - Fédération multi-tenant : pas besoin d'un namespace par tenant pour éviter les collisions, l'UUID est globalement unique.
- 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.
- Pas de surprise sur les exports : un dump CSV qui sort des
id=1,2,3est 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 :
onboardingCompletedAtcôté serveur remplace l'astucesignature !== nulldu 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_failedinvalid_credentialsemail_takenclient_email_requiredduplicate_clientunauthenticatednot_foundforbiddenrate_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 JSONmiddleware.auth()valide leAuthorization: Bearer <token>sur les routes protégéesUser.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 :
{ 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 + cookierubis_refreshposé automatiquement par le browser. SPA stockeaccessTokenen mémoire. - Au boot, le SPA appelle
POST /auth/refresh(le browser envoie le cookie). Si OK → réhydrateauthStore. 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 sonauthStore.
Le mock MSW actuel (
apps/web/src/mocks/sessionStore.ts) simule ce flow vialocalStorage— quand le vrai backend arrive,localStoragedisparaî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 :
- Réécrire les schemas en Vine (idiomatique Adonis, support natif des messages d'erreur i18n)
- Réutiliser les schemas Zod de
packages/sharedcôté serveur viazod+ un wrapper qui transforme lesZodErroren 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)
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) :
- On lit
invoice.planId→plan.steps - Pour chaque step, on calcule
sendAt = invoice.dueDate + step.offsetDays - On crée une
RelanceTask(status=scheduled) + on enqueue unSendRelanceJobavecdelay: sendAt - now - 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-paidannule 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/etpackages/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.tsetc. — 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 :
membershipstable (user × organization × role). Le filtreorganization_idactuel facilite la transition. - SMS : provider (Twilio/OVH) abstrait derrière
MessageDispatcherqui 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
tsvectorsurinvoices.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.