rubis/docs/tech/backend.md
ordinarthur 9eaac0c7ef docs(audit-2/3): aligner doc tech sur le code livré
Audit cross-doc/code, batch tech : architecture, backend, frontend,
dev-setup. Corrige les claims qui pouvaient induire un dev en erreur
(noms de services K3s, hostnames Traefik, Tuyau, queue wrapper,
seeders, env vars, polices).

architecture.md
- Composants : status « À écrire » → «  Déployé » (apps/web,
  apps/api, packages/shared) ; ajout Redis Deployment K3s ; OCR =
  Mistral choisi ; mail = Resend (sortant) + OVH MX (entrant)
  validés
- §7.5 Pods K3s : noms réels (rubis-api / rubis-web / rubis-landing
  / rubis-redis, pas de *-svc) ; pas d'IngressRoute api.rubis.pro
  (l'API est servie via app.rubis.pro/api/* proxifié par nginx du
  pod web) ; PG/MinIO en URL directe dans la ConfigMap, pas de
  Service ExternalName
- §10 Décisions en attente : ADRs 019-024 mises à jour
  (tranchées / obsolètes), suppression du wording « à venir » pour
  les choix déjà figés dans le code

backend.md
- Note de cohérence en tête : pointe vers start/routes.ts comme
  source de vérité de la surface API (~80 routes — Stripe,
  Demo, AI, Microsoft SSO, admin blog, posts publics, KPIs
  timeseries) que cette doc n'inventorie pas exhaustivement
- §1 Vue d'ensemble : Tuyau marqué « non utilisé en pratique »
  (présent en deps mais zéro import côté SPA), partage de types
  via packages/shared. OCR Mistral choisi. Mail Resend choisi.
  BullMQ direct (workers inline pod API). Sentry ADR-024.
- §2 Stack : queue = BullMQ direct (pas @rlanz/bull-queue, qui
  n'est pas installé) ; type-sharing = packages/shared
- §2 Dépendances : remplacé la todo-list pré-livraison par la
  liste réelle des packages dans apps/api/package.json
- §3 Repo layout : `database/factories/` (dossier) → `factories.ts`
  (mono-fichier) ; `database/seeders/{default_plans,demo_data}` →
  inexistants, services à la place
- §13.2 Jobs : ProcessOcrJob + RecomputeKpisJob retirés
  (n'existent pas — OCR synchrone via services/import_batch.ts,
  KPIs calculés on-the-fly). Liste des jobs réels :
  send_relance, send_checkin, send_payment_thanks
- env vars : MINIO_* → S3_* (cf. .env.example + manifest k3s) ;
  bucket prod = rubis-prod-invoices

frontend.md
- Note de cohérence en tête : Tuyau pas utilisé, tokens dans
  packages/ui (pas inline), polices @fontsource-variable (pas
  Google Fonts via <link>)
- §1 Vue d'ensemble : client API = fetch minimaliste dans
  apps/web/src/lib/api.ts ; périmètre livré = ~15 routes _app/
- §3 Polices : section Google Fonts → @fontsource-variable
  (avec note preload woff2 critique sur la landing Astro)
- §4 Routes : arbo `_onboarding/` (faux) → `onboarding/`
  (réel, segment URL) + ajout admin.blog*, clients_.$id, insights,
  parametres_.abonnement, plans_.nouveau, factures_.import
- §6 Tuyau : section marquée « historique, non utilisé en V1 »
  avec note explicative en tête
- §10 env vars : VITE_API_URL=https://api.rubis.pro → vide
  (proxifié same-origin par nginx) + ajout VITE_USE_MOCKS,
  VITE_SENTRY_DSN_WEB, VITE_APP_VERSION

dev-setup.md
- Mailhog → Mailpit (3 occurrences) — c'est ce qui tourne dans
  docker-compose.dev.yml

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:13:44 +02:00

1340 lines
50 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# Guide d'implémentation — Backend
> Version : 0.2 · Dernière maj : 2026-05-09
> Décisions de référence : ADR-014 (stack), ADR-015 (monorepo), ADR-016 (PG), ADR-017 (auth), ADR-018 (storage), ADR-024 (Sentry).
> ⚠️ **Note de cohérence (audit 2026-05-09)** : ce guide a été rédigé en phase de planification. Plusieurs claims ont divergé du code livré. Pour la **liste exhaustive des routes**, lire **`apps/api/start/routes.ts`** comme source de vérité (~80 routes : auth, factures, clients, plans, billing Stripe, blog admin/public, demo, AI, uploads, KPIs). Les modèles vivants sont dans `apps/api/app/models/`, les services dans `apps/api/app/services/`. Les sections ci-dessous sont valides **dans leur esprit** mais détaillent parfois des choix pré-livraison qui ont évolué.
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.
**À 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
- `/packages/shared/src/` — types et schemas Zod partagés (statuts, tons, plans, billing)
- `/apps/api/start/routes.ts`**contrat API actuel (source de vérité)**
- `/apps/api/app/services/` — logique métier (billing, posts, blog_uploads, default_plans, import_batch, etc.)
---
## 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
- **Type-sharing** via `packages/shared` (Zod schemas + types TS), consommé en workspace symlink par `apps/web` et `apps/landing`. *Note historique : la doc évoquait Tuyau pour générer un client TS typé — `@tuyau/core` est dans les deps mais n'est pas utilisé en pratique côté SPA, qui consomme l'API via un client `fetch()` minimaliste dans `apps/web/src/lib/api.ts`.*
- **PostgreSQL** comme base relationnelle (cf. ADR-016, instance LXC Proxmox existante, IP `10.10.10.3`)
- **MinIO** pour les pièces jointes (cf. ADR-018, namespace K3s `minio`, bucket `rubis-prod-invoices`)
- **OCR** : Mistral (`OCR_PROVIDER=mistral`)
- **Email outbound** : Resend (sub-domaine `send.rubis.pro`)
- **Email inbound** : OVH MX (`contact@rubis.pro`)
- **Background jobs** : BullMQ (Redis), workers inline dans le pod API (pas de pod worker séparé)
- **Billing** : Stripe (checkout, customer portal, webhook `/billing/webhook`)
- **Error tracking** : Sentry (cf. ADR-024)
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 | BullMQ direct + ioredis | Jobs différés (envoi relance, envoi confirmation, envoi remerciement paiement). Pas de wrapper Adonis. Code dans `app/services/queue.ts` |
| 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 | `packages/shared` (workspace) | Zod schemas + types TS (statuts, tons, plans, billing) — consommé par `apps/web` et `apps/landing` |
| HTTP client | `@adonisjs/limiter` + `ky` (côté SPA) | — |
| Storage | `@adonisjs/drive` (S3 driver MinIO) | Abstraction stockage PDFs |
### Dépendances installées (cf. `apps/api/package.json`)
Stack actuelle (V1 livrée) :
```jsonc
// apps/api/package.json (extrait)
"@adonisjs/auth", "@adonisjs/bouncer", "@adonisjs/mail",
"@adonisjs/limiter", "@adonisjs/drive", "@adonisjs/lucid",
"bullmq", "ioredis", // queues directes, pas de wrapper Adonis
"@aws-sdk/client-s3", // MinIO via driver S3
"resend", // email outbound
"@mistralai/mistralai", // OCR
"stripe", // billing
"@anthropic-ai/sdk", // génération IA blog
"@sentry/node", // ADR-024
```
Pas de BullMQ direct (sans wrapper Adonis), pas de `@tuyau/server` (le `@tuyau/core` historique n'est plus utilisé côté SPA).
---
## 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/
│ │ └── (pas de seeders Adonis : les 4 plans pré-fournis vivent dans
│ │ app/services/default_plans.ts et sont insérés à la création de
│ │ chaque organisation. Le mode démo est servi par
│ │ app/services/demo_simulator.ts, pas par un seeder.)
│ └── factories.ts # Factories Lucid (mono-fichier, pas de dossier — V1)
├── tests/
│ ├── functional/ # Tests HTTP via @japa/api-client
│ │ ├── auth.spec.ts
│ │ ├── invoices.spec.ts
│ │ └── ...
│ └── unit/
└── package.json
```
---
## 4. Domain models
Le modèle est **scopé par `organization_id` partout** — V1 mono-user mais multi-tenant ready dès le schéma. Tous les Lucid queries doivent filtrer sur `organization_id` (en pratique : un middleware ou un scope global qui injecte ce filtre).
### 4.0 Identifiants — UUID partout
**Convention dure** : toutes les PK et FK applicatives sont des UUID (PG `uuid` avec default `gen_random_uuid()`). **Pas d'`increments`/`serial`**, y compris pour les tables techniques (`auth_access_tokens`, `refresh_tokens`, `import_batches`, etc.).
```ts
// migration
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
table.uuid('organization_id').notNullable().references('id').inTable('organizations').onDelete('CASCADE')
```
```ts
// model — id est typé string, le schema generator le déduit du type uuid
@column({ isPrimary: true })
declare id: string
```
Côté transformers, on expose l'UUID tel quel (pas de cast `String(id)`).
**Pourquoi** :
1. **Anti-énumération** : `/invoices/42` révèle qu'on en a au moins 42, ce qui peut leak des volumes business.
2. **Fédération multi-tenant** : pas besoin d'un namespace par tenant pour éviter les collisions, l'UUID est globalement unique.
3. **Génération côté client** : permet d'optimiste-créer un objet (UUID généré côté SPA) avant le round-trip API.
4. **Pas de surprise sur les exports** : un dump CSV qui sort des `id=1,2,3` est un bug latent.
### 4.1 User
```ts
// app/models/user.ts
import { BaseModel, column, belongsTo } from '@adonisjs/lucid/orm'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { DateTime } from 'luxon'
import Organization from '#models/organization'
const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
uids: ['email'],
passwordColumnName: 'password',
})
export default class User extends compose(BaseModel, AuthFinder) {
@column({ isPrimary: true })
declare id: string
@column()
declare email: string
@column({ serializeAs: null })
declare password: string
@column({ columnName: 'full_name' })
declare fullName: string
@column({ columnName: 'organization_id' })
declare organizationId: string
@column()
declare signature: string | null
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
static accessTokens = DbAccessTokensProvider.forModel(User, {
expiresIn: '30 mins',
})
}
```
### 4.2 Organization
```ts
@column() declare name: string
@column() declare siret: string | null
@column({ columnName: 'monthly_volume_bucket' }) declare monthlyVolumeBucket: string | null
@column({ columnName: 'rubis_count' }) declare rubisCount: number
@column.dateTime({ columnName: 'onboarding_completed_at' })
declare onboardingCompletedAt: DateTime | null
```
> **Pattern** : `onboardingCompletedAt` côté serveur remplace l'astuce `signature !== null` du SPA. Une fois set, le SPA ne renvoie plus l'utilisateur dans le wizard.
### 4.3 Client
```ts
@column() declare name: string
@column() declare email: string // REQUIS — pivot du produit
@column() declare phone: string | null
@column() declare address: string | null
@column() declare siret: string | null // 14 chiffres si fourni
@column() declare notes: string | null
@hasMany(() => Invoice) declare invoices: HasMany<typeof Invoice>
```
### 4.4 Invoice
```ts
@column() declare numero: string
@column({ columnName: 'amount_ttc_cents' }) declare amountTtcCents: number
@column.dateTime({ columnName: 'issue_date' }) declare issueDate: DateTime
@column.dateTime({ columnName: 'due_date' }) declare dueDate: DateTime
@column() declare status: InvoiceStatus
@column({ columnName: 'plan_id' }) declare planId: string | null
@column({ columnName: 'pdf_storage_key' }) declare pdfStorageKey: string | null
@column() declare notes: string | null
@column({ columnName: 'rubis_earned' }) declare rubisEarned: number
@column({ columnName: 'paid_at' }) declare paidAt: DateTime | null
@belongsTo(() => Client) declare client: BelongsTo<typeof Client>
@belongsTo(() => Plan) declare plan: BelongsTo<typeof Plan>
@hasMany(() => RelanceTask) declare relanceTasks: HasMany<typeof RelanceTask>
```
**InvoiceStatus** (enum DB + TS, miroir `packages/shared/constants`) :
```
pending · in_relance · awaiting_user_confirmation · paid · litigation · cancelled
```
### 4.5 Plan + PlanStep
```ts
// Plan
@column() declare slug: string | null // null pour les plans custom
@column() declare name: string
@column() declare description: string
@column({ columnName: 'is_default' }) declare isDefault: boolean
@hasMany(() => PlanStep, { foreignKey: 'planId' }) declare steps: HasMany<typeof PlanStep>
// PlanStep
@column() declare order: number
@column({ columnName: 'offset_days' }) declare offsetDays: number
@column() declare tone: RelanceTone
@column() declare subject: string
@column() declare body: string
@column({ columnName: 'requires_manual_validation' })
declare requiresManualValidation: boolean
```
### 4.6 RelanceTask + CheckinTask
```ts
// RelanceTask : une étape de relance programmée pour une facture
@column({ columnName: 'invoice_id' }) declare invoiceId: string
@column({ columnName: 'plan_step_id' }) declare planStepId: string
@column.dateTime({ columnName: 'send_at' }) declare sendAt: DateTime
@column() declare status: 'scheduled' | 'sent' | 'cancelled' | 'failed'
@column({ columnName: 'sent_at' }) declare sentAt: DateTime | null
@column({ columnName: 'queue_job_id' }) declare queueJobId: string | null
// CheckinTask : email envoyé à l'utilisateur (pas au client) avant chaque relance
@column({ columnName: 'invoice_id' }) declare invoiceId: string
@column.dateTime({ columnName: 'send_at' }) declare sendAt: DateTime
@column() declare token: string // signé, TTL 24h
@column() declare status: 'scheduled' | 'sent' | 'answered' | 'expired'
```
### 4.7 ImportBatch + ImportDraft
Éphémères : créés à l'upload OCR, supprimés une fois tous les drafts validés ou skippés.
```ts
// ImportBatch
@column({ columnName: 'organization_id' }) declare organizationId: string
@hasMany(() => ImportDraft) declare drafts: HasMany<typeof ImportDraft>
// ImportDraft
@column() declare filename: string
@column({ columnName: 'pdf_storage_key' }) declare pdfStorageKey: string
@column() declare extracted: object // jsonb
@column() declare edited: object // jsonb
@column() declare confidence: object // jsonb
@column() declare status: 'pending' | 'validated' | 'skipped'
@column({ columnName: 'invoice_id' }) declare invoiceId: string | null
```
### 4.8 ActivityEvent
Pour le journal d'activité du dashboard. Append-only, pas de mutation.
```ts
@column({ columnName: 'organization_id' }) declare organizationId: string
@column() declare kind: 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
@column.dateTime() declare at: DateTime
@column() declare label: string // texte HTML simple (<b> autorisé)
@column() declare meta: object // jsonb : { invoiceId?, clientId?, ... }
```
---
## 5. Routes API
**Toutes** sous `/api/v1/`. Le SPA consomme exactement cette surface (cf. `apps/web/src/mocks/handlers/`). Versioning explicite : V2 vivra côté `/api/v2/` sans casser V1.
### 5.1 Auth (public)
| Méthode | Route | Body | Réponse |
|---|---|---|---|
| POST | `/auth/signup` | `{ email, password, fullName }` | `{ data: AuthSession }` (201) |
| POST | `/auth/login` | `{ email, password }` | `{ data: AuthSession }` |
| POST | `/auth/refresh` | — (cookie httpOnly) | `{ data: AuthSession }` ou 401 |
Réponse `AuthSession` :
```ts
{ accessToken: string, expiresAt: string /* ISO */, user: User }
```
### 5.2 Compte (auth)
| Méthode | Route | Body | Réponse |
|---|---|---|---|
| GET | `/account/profile` | — | `{ data: User }` |
| PATCH | `/account/profile` | `{ fullName?, email?, signature? }` | `{ data: User }` |
| POST | `/account/logout` | — | 204 (clear le refresh cookie) |
### 5.3 Organisation (auth)
| Méthode | Route | Body | Réponse |
|---|---|---|---|
| GET | `/organizations/me` | — | `{ data: Organization }` |
| PATCH | `/organizations/me` | `{ name?, siret?, monthlyVolumeBucket? }` | `{ data: Organization }` |
### 5.4 Clients (auth)
| Méthode | Route | Query | Body | Réponse |
|---|---|---|---|---|
| GET | `/clients` | `?withStats=1&q=` | — | `{ data: Client[] }` (avec stats si `withStats`) |
| GET | `/clients/:id` | — | — | `{ data: ClientDetail }` (avec invoices) |
| POST | `/clients` | — | `{ name, email, phone?, address?, siret?, notes? }` | `{ data: Client }` (201). 409 si doublon de nom. |
| PATCH | `/clients/:id` | — | `{ name?, email?, phone?, address?, siret?, notes? }` | `{ data: Client }` |
### 5.5 Plans (auth)
| Méthode | Route | Body | Réponse |
|---|---|---|---|
| GET | `/plans` | — | `{ data: PlanWithUsage[] }` |
| GET | `/plans/:slug` | — | `{ data: PlanWithUsage }` |
| POST | `/plans` | `{ name, description, steps[] }` | `{ data: Plan }` (V2) |
| PATCH | `/plans/:slug` | `{ name?, description?, steps? }` | `{ data: Plan }` |
### 5.6 Factures (auth)
| Méthode | Route | Query | Body | Réponse |
|---|---|---|---|---|
| GET | `/invoices` | `?status=&q=&clientId=&page=` | — | `{ data: InvoiceListItem[], meta: { total, page } }` |
| GET | `/invoices/counts` | — | — | `{ data: StatusCounts }` |
| GET | `/invoices/:id` | — | — | `{ data: InvoiceDetail }` (client + plan + timeline) |
| POST | `/invoices` | — | `{ clientId?, clientName, clientEmail, numero, amountTtcCents, issueDate, dueDate, planId? }` | `{ data: Invoice }` (201) |
| POST | `/invoices/:id/mark-paid` | — | — | `{ data: Invoice }` |
| POST | `/invoices/:id/relance` | — | — | `{ data: Invoice }` (relance manuelle, V1 si scope) |
### 5.7 Import OCR (auth)
| Méthode | Route | Body | Réponse |
|---|---|---|---|
| POST | `/invoices/upload` | `multipart` (files) — V1 mock accepte `{ filenames: string[] }` | `{ data: ImportBatch }` (201) |
| GET | `/invoices/import-batch/:id` | — | `{ data: ImportBatch }` |
| POST | `/invoices/import-batch/:id/drafts/:draftId/validate` | DraftFields édités | `{ data: Invoice }` (201) |
| POST | `/invoices/import-batch/:id/drafts/:draftId/skip` | — | `{ data: ImportDraft }` |
| DELETE | `/invoices/import-batch/:id` | — | 204 (annule le batch entier) |
### 5.8 Dashboard (auth)
| Méthode | Route | Réponse |
|---|---|---|
| GET | `/dashboard/kpis` | `{ data: DashboardKpis }` |
| GET | `/dashboard/activity` | `{ data: ActivityEvent[] }` |
| GET | `/dashboard/top-late` | `{ data: LatePayer[] }` |
### 5.9 Check-in email (public, signed token)
| Méthode | Route | Réponse |
|---|---|---|
| GET | `/checkin/:token` | Redirect HTML vers le SPA avec confirmation |
> Pas d'auth Bearer ici — l'utilisateur clique depuis un email reçu. Sécurité = HMAC sur le token (TTL 24h après émission).
---
## 6. Conventions de réponse
**Succès** :
```json
{ "data": { ... }, "meta": { "page": 1, "total": 23 } }
```
**Erreur** :
```json
{ "errors": [{ "code": "validation_failed", "message": "...", "field": "email" }] }
```
**Codes HTTP** : 200, 201, 204, 400, 401, 403, 404, 409, 422, 429, 500.
**Pagination** : `?page=N` simple en V1, on bascule sur cursor-based si volume → cf. ADR à venir.
**Le `errors[].code`** est une chaîne stable que le SPA peut switcher dessus pour des messages contextuels :
- `validation_failed`
- `invalid_credentials`
- `email_taken`
- `client_email_required`
- `duplicate_client`
- `unauthenticated`
- `not_found`
- `forbidden`
- `rate_limited`
L'`ExceptionHandler` (`app/exceptions/handler.ts`) transforme les exceptions Vine, Lucid, Auth en cette enveloppe.
---
## 7. Auth — Bearer + refresh httpOnly cookie
Cf. ADR-017. Pattern hybride : access token stateless (Adonis access_tokens) + refresh token custom en cookie httpOnly.
### 7.1 Ce qui est natif Adonis
`@adonisjs/auth` avec guard `tokensGuard` couvre :
- `POST /auth/login` → crée un access token (TTL 30 min) → renvoyé en JSON
- `middleware.auth()` valide le `Authorization: Bearer <token>` sur les routes protégées
- `User.accessTokens.create(user)` / `User.accessTokens.delete(user, token)`
### 7.2 Ce qu'on ajoute custom — refresh token cookie
Adonis ne ship pas de refresh-token primitive. On en construit un sobrement :
**Schéma `refresh_tokens`** :
```ts
{ id, userId, hashedToken, expiresAt, lastUsedAt, revokedAt, ipAddress, userAgent }
```
**Service `RefreshTokenService`** :
```ts
async create(user: User, ctx: HttpContext): Promise<{ token: string; expiresAt: DateTime }>
async rotate(plainToken: string, ctx: HttpContext): Promise<{ user: User; newToken: string }>
async revoke(plainToken: string): Promise<void>
async revokeAllForUser(userId: string): Promise<void>
```
Le token plain (32 bytes random) est envoyé au SPA via cookie :
```ts
ctx.response.cookie('rubis_refresh', plainToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/api/v1/auth',
maxAge: 30 * 24 * 60 * 60, // 30 jours
})
```
**Stocké hashé** côté DB (SHA-256 — pas besoin de bcrypt, c'est un opaque random pas un mot de passe). Sur `/auth/refresh`, on hash le cookie reçu, on cherche en DB, on vérifie expiry, on rotate (= invalide l'ancien, crée un nouveau, retourne nouveau access + cookie rotaté).
**Pourquoi rotation** : si quelqu'un vole le cookie, dès que le vrai user fait un refresh, le voleur perd l'accès (le rotated token devient invalide).
### 7.3 Endpoints
```ts
// start/routes/auth.ts
router.post('/auth/signup', '#controllers/auth/new_account_controller.store')
.as('auth.signup')
router.post('/auth/login', '#controllers/auth/access_tokens_controller.store')
.as('auth.login')
router.post('/auth/refresh', '#controllers/auth/refresh_controller.handle')
.as('auth.refresh')
router.post('/account/logout', '#controllers/account/logout_controller.handle')
.as('account.logout')
// logout révoque le refresh + delete l'access token
```
### 7.4 Côté SPA
- Login / signup : reçoit `{ accessToken, expiresAt, user }` en JSON + cookie `rubis_refresh` posé automatiquement par le browser. SPA stocke `accessToken` en mémoire.
- Au boot, le SPA appelle `POST /auth/refresh` (le browser envoie le cookie). Si OK → réhydrate `authStore`. Si 401 → reste anonyme.
- Si une requête API retourne 401, le SPA tente un silent refresh puis retry.
- Logout : `POST /account/logout` → revoke + clear cookie. SPA clear son `authStore`.
> Le mock MSW actuel (`apps/web/src/mocks/sessionStore.ts`) simule ce flow via `localStorage` — quand le vrai backend arrive, `localStorage` disparaît, le cookie httpOnly prend le relais. **Pas de changement côté SPA** sauf retirer MSW.
---
## 8. Tuyau — client typé pour le SPA
Tuyau génère `apps/api/.adonisjs/api.ts` qui décrit toutes les routes, leurs paramètres, et leurs réponses, exposé via `apps/api/package.json` `exports`. Le SPA importe ces types et appelle `tuyau.api.v1.invoices.$get()` avec autocomplete + type-check.
### 8.1 Exposition côté API
`apps/api/package.json` (déjà fait par le scaffold) :
```json
{
"exports": {
"./data": "./.adonisjs/client/data.d.ts",
"./registry": "./.adonisjs/client/registry/index.ts"
}
}
```
### 8.2 Annoter les routes
Chaque route reçoit un nom (`as()`) — utilisé par Tuyau pour générer les types nommés.
```ts
router.get('/invoices', '#controllers/invoices_controller.index')
.as('invoices.index')
.use(middleware.auth())
```
### 8.3 Génération
```bash
cd apps/api
node ace tuyau:generate # one-shot
pnpm tuyau:watch # dev — script à ajouter dans package.json
```
→ Régénère `.adonisjs/client/...` à chaque modif de routes/validators.
### 8.4 Côté SPA
```ts
// apps/web/src/lib/api.ts (à compléter quand on retire MSW)
import { createTuyau } from '@tuyau/client'
import { api } from '@rubis/api/registry'
export const tuyau = createTuyau({
api,
baseUrl: env.VITE_API_URL,
credentials: 'include', // pour envoyer le cookie refresh
headers: () => ({
Authorization: authStore.token ? `Bearer ${authStore.token}` : '',
}),
})
```
---
## 9. Validation — Vine
Adonis 7 utilise **Vine** comme validateur natif. On peut soit :
1. **Réécrire** les schemas en Vine (idiomatique Adonis, support natif des messages d'erreur i18n)
2. **Réutiliser** les schemas Zod de `packages/shared` côté serveur via `zod` + un wrapper qui transforme les `ZodError` en réponse 422 standardisée
Choix recommandé : **Vine côté API**, **Zod côté SPA** — on accepte la duplication parce qu'elle est minime (~5 schemas) et chaque côté reste idiomatique.
Exemple :
```ts
// apps/api/app/validators/client.ts
import vine from '@vinejs/vine'
export const createClientValidator = vine.compile(
vine.object({
name: vine.string().minLength(2).maxLength(120),
email: vine.string().email().maxLength(120), // requis !
phone: vine.string().maxLength(40).nullable().optional(),
address: vine.string().maxLength(500).nullable().optional(),
siret: vine.string().regex(/^\d{14}$/).nullable().optional(),
notes: vine.string().maxLength(2000).nullable().optional(),
}),
)
```
Le contrôleur appelle `await request.validateUsing(createClientValidator)` → throws `ValidationException` → l'exception handler transforme en `{ errors: [...] }` 422.
---
## 10. Storage — MinIO
`@adonisjs/drive` avec driver `s3` configuré pour MinIO (S3-compatible).
```ts
// config/drive.ts
const driveConfig = defineConfig({
default: 's3',
services: {
s3: services.s3({
credentials: {
accessKeyId: env.get('S3_ACCESS_KEY'),
secretAccessKey: env.get('S3_SECRET_KEY'),
},
endpoint: env.get('S3_ENDPOINT'), // http://minio.minio.svc.cluster.local:9000
region: env.get('S3_REGION'),
bucket: env.get('S3_BUCKET'), // rubis-prod-invoices en prod
forcePathStyle: true, // requis pour MinIO
visibility: 'private',
}),
},
})
```
### Conventions de keys
```
invoices/<orgId>/<invoiceId>/<filename>
import-drafts/<orgId>/<batchId>/<draftId>.<ext>
backups/pg/<date>.dump
```
### Upload depuis le contrôleur
```ts
const file = ctx.request.file('file', { extnames: ['pdf', 'png', 'jpg', 'jpeg'], size: '10mb' })
if (!file) throw new BadRequest('Fichier manquant')
const key = `import-drafts/${orgId}/${batchId}/${draft.id}.${file.extname}`
await drive.use().putStream(key, file.stream)
draft.pdfStorageKey = key
await draft.save()
```
### URLs signées
Pour permettre au SPA de prévisualiser un PDF sans exposer MinIO directement :
```ts
const url = await drive.use().getSignedUrl(invoice.pdfStorageKey, { expiresIn: '15 mins' })
```
→ exposé via `GET /invoices/:id` dans la réponse `pdfPreviewUrl`.
---
## 11. OCR pipeline
### 11.1 Interface abstraite
```ts
// app/services/ocr/ocr_provider.ts
export interface OcrProvider {
extract(input: { storageKey: string; filename: string }): Promise<OcrResult>
}
export type OcrResult = {
fields: {
clientName: { value: string; confidence: number }
clientEmail: { value: string | null; confidence: number }
numero: { value: string; confidence: number }
amountTtcCents: { value: number; confidence: number }
issueDate: { value: string; confidence: number } // ISO
dueDate: { value: string; confidence: number }
}
rawProviderResponse?: unknown // pour debug / re-process
}
```
### 11.2 Providers V1 candidats (cf. ADR-020)
| Provider | Pour | Contre |
|---|---|---|
| **Mindee** | Modèle "Invoice" pré-entraîné FR, API simple | Coût par page |
| **Google Document AI** | Très bonne qualité, bon FR | Setup GCP, coûteux |
| **AWS Textract** | Robuste, bien intégré S3 | Setup AWS, peu spécialisé facture FR |
| **Tesseract local** | Gratuit, on-prem | Qualité OCR brute, pas d'extraction structurée — il faudrait écrire le parsing à la main |
Recommandation pour V1 : **Mindee** comme default, abstraction switchable (cf. interface). Tesseract en fallback dev.
### 11.3 Job différé
```ts
// app/jobs/process_ocr_job.ts
export default class ProcessOcrJob extends Job {
static get $$filepath() { return import.meta.url }
async handle({ batchId, draftId }: { batchId: string; draftId: string }) {
const draft = await ImportDraft.query()
.where('id', draftId)
.firstOrFail()
const ocr = container.make(OcrProvider)
const result = await ocr.extract({
storageKey: draft.pdfStorageKey,
filename: draft.filename,
})
draft.extracted = result.fields
draft.edited = { ...result.fields } // édition initiale = extraction
draft.confidence = mapConfidence(result.fields)
draft.status = 'pending' // prêt à review
await draft.save()
}
}
```
**Idempotence** : `processOcrJob` doit pouvoir être rejoué (cf. retry BullMQ). Si le draft est déjà `validated` ou `skipped`, no-op.
---
## 12. Email outbound
### 12.1 Interface
`@adonisjs/mail` avec Mailers (= providers) :
```ts
// config/mail.ts
const mailConfig = defineConfig({
default: 'resend',
from: { address: env.get('MAIL_FROM_ADDRESS'), name: 'Rubis' },
mailers: {
resend: services.resend({ key: env.get('RESEND_API_KEY') }),
},
})
```
### 12.2 Templates
Chaque ton a son template (`app/mails/relance_amical_mail.ts`, etc.) — Edge syntax pour le HTML.
Variables fournies :
```ts
{
client: { name, email },
invoice: { numero, amountTtc, dueDate, issueDate },
signature: user.signature,
unsubscribeUrl: ..., // RGPD
trackingPixelUrl: ..., // V2 — ouverture
}
```
Les templates utilisent les vars définies dans les step.subject / step.body du Plan, avec interpolation Mustache-like (cf. variables ` {{client.name}} ` etc.).
### 12.3 Provider switchable (cf. ADR-021)
| Provider | Pour | Contre |
|---|---|---|
| **Resend** | Simple, prix correct, bonne deliverability FR | Provider US |
| **Postmark** | Excellence transactionnelle | Cher au volume |
| **AWS SES** | Très peu cher au volume | Setup, less DX |
| **OVH** / **Mailjet** | EU souverain | DX moins bonne |
Recommandation V1 : **Resend** comme default, abstraction `@adonisjs/mail` permet de switch sans toucher la logique métier.
### 12.4 Anti-abuse
- Rate limit côté API (5 emails/heure par invoice)
- Vérification de l'adresse expéditrice (DNS configuré, SPF + DKIM signés)
- Mention "Envoyé via Rubis" obligatoire en footer (transparence pour le destinataire)
### 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](https://resend.com) (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 `_dmarc``v=DMARC1; p=none;` (mode monitoring) |
| Click tracking | CNAME `mail.rubis.pro``links1.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
BullMQ direct (sans wrapper Adonis) (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
Réalité V1 (cf. `apps/api/app/jobs/`) :
| Job (fichier) | Trigger | Idempotent | Retry |
|---|---|---|---|
| `send_relance_job.ts` | RelanceTask.sendAt | oui (status=sent → no-op) | 5× |
| `send_checkin_job.ts` | CheckinTask.sendAt | oui | 3× |
| `send_payment_thanks_job.ts` | confirmation paiement (step 04 du flow) | oui | 3× |
**OCR** : pas de job dédié — l'extraction est synchrone via `app/services/import_batch.ts` (appel Mistral bloquant pendant l'upload). Si le volume monte, basculer en job différé.
**KPIs** : calculés on-the-fly à chaque GET dashboard/timeseries (cf. commentaire dans `start/routes.ts`). Pas de `RecomputeKpisJob` — pas de cache Redis pour les KPIs en V1.
### 13.3 Programmation des relances
Quand l'utilisateur valide une facture (`POST /invoices/:id/validate-import` ou création manuelle) :
1. On lit `invoice.planId``plan.steps`
2. Pour chaque step, on calcule `sendAt = invoice.dueDate + step.offsetDays`
3. On crée une `RelanceTask` (status=scheduled) + on enqueue un `SendRelanceJob` avec `delay: sendAt - now`
4. On crée éventuellement un `CheckinTask` à `sendAt - 2j` (cf. flow check-in)
À l'exécution du job :
```ts
async handle({ relanceTaskId }) {
const task = await RelanceTask.findOrFail(relanceTaskId)
if (task.status !== 'scheduled') return // idempotent
const invoice = await Invoice.query()
.where('id', task.invoiceId)
.preload('client')
.preload('plan', q => q.preload('steps'))
.firstOrFail()
// Hook critique : la facture peut être devenue paid entre-temps
if (invoice.status === 'paid' || invoice.status === 'cancelled') {
task.status = 'cancelled'
await task.save()
return
}
const step = invoice.plan.steps.find(s => s.id === task.planStepId)
if (step.requiresManualValidation) {
// Mise en demeure : on génère un brouillon, on n'envoie pas
await activityRecorder.draft(invoice, step)
return
}
await mailDispatcher.sendRelance({ invoice, step })
task.status = 'sent'
task.sentAt = DateTime.now()
await task.save()
await activityRecorder.relanceSent(invoice, step)
await rubisCalculator.creditOne(invoice.organizationId) // +1 rubis
}
```
### 13.4 Worker Pod
K3s pod séparé du pod web `rubis-api` :
```yaml
- Deployment: rubis-worker
command: ["node", "ace", "queue:listen", "--queue=ocr,relances,checkins"]
replicas: 1 # V1, scale up plus tard
```
---
## 14. Tests — Japa
Tests d'intégration HTTP via `@japa/api-client`. Pattern :
```ts
// tests/functional/clients.spec.ts
import { test } from '@japa/runner'
import { OrganizationFactory, UserFactory, ClientFactory } from '#database/factories'
test.group('Clients API', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('POST /clients refuse sans email', async ({ client }) => {
const user = await UserFactory.create()
const response = await client
.post('/api/v1/clients')
.json({ name: 'Boulangerie Test' })
.loginAs(user)
response.assertStatus(422)
response.assertBodyContains({
errors: [{ code: 'validation_failed', field: 'email' }],
})
})
test('POST /clients crée un client avec email', async ({ client }) => {
const user = await UserFactory.create()
const response = await client
.post('/api/v1/clients')
.json({
name: 'Boulangerie Test',
email: 'compta@boulangerie-test.fr',
})
.loginAs(user)
response.assertStatus(201)
response.assertBodyContains({ data: { name: 'Boulangerie Test' } })
})
})
```
**Couverture cible V1** :
- Auth flows complets (signup, login, refresh, logout, expired token)
- Permissions cross-org (un user A ne peut pas voir/modifier les ressources du user B)
- Validation Vine (toutes les contraintes critiques : email required client, SIRET format, etc.)
- Idempotence des jobs OCR / Relance
- Edge cases métier : `mark-paid` annule les RelanceTasks futures, plan édité ne casse pas les invoices en cours
---
## 15. Migrations + seeders
### 15.1 Migrations
Adonis Lucid, fichiers `database/migrations/<timestamp>_<name>.ts`. Convention : **jamais éditer une migration commitée**, on en crée une nouvelle.
Ordre logique pour V1 :
```
1. organizations
2. users (FK organization_id)
3. refresh_tokens (FK user_id)
4. clients (FK organization_id)
5. plans + plan_steps
6. invoices (FK client_id, plan_id)
7. relance_tasks (FK invoice_id, plan_step_id)
8. checkin_tasks (FK invoice_id)
9. import_batches + import_drafts
10. activity_events
```
### 15.2 Seeders
`database/seeders/default_plans_seeder.ts` recrée les 4 plans pré-fournis (Standard B2B, Rapide, Patient, Ferme) — **identiques** à ceux dans `apps/web/src/mocks/seed.ts`. Source de vérité : on extrait les plans dans `packages/shared/seeds/` (à créer) pour qu'API et MSW partagent les mêmes données.
`database/seeders/demo_data_seeder.ts` (dev only) crée le compte `demo@rubis.fr` + ses clients + factures. Match `mocks/seed.ts`.
```bash
node ace migration:run
node ace db:seed
```
---
## 16. Variables d'environnement
`apps/api/.env` (déjà scaffold partiellement par Adonis) :
```bash
# App
NODE_ENV=production
TZ=UTC
PORT=3333
HOST=0.0.0.0
LOG_LEVEL=info
APP_KEY=<32-chars-random> # cookies + crypto
SESSION_DRIVER=cookie
# CORS
CORS_ORIGINS=https://app.rubis.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
S3_ENDPOINT=http://minio.minio.svc.cluster.local:9000
S3_ACCESS_KEY=<secret>
S3_SECRET_KEY=<secret>
S3_REGION=us-east-1
S3_BUCKET=rubis-prod-invoices # bucket réel en prod (cf. k3s/app/api.yml)
# 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
```bash
pnpm -F api build
# → produit apps/api/build/ (JS compilé + node_modules pruné)
```
### 17.2 Image Docker
`Dockerfile.api` à la racine :
```dockerfile
FROM node:22-alpine AS builder
RUN npm install -g pnpm
WORKDIR /app
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY apps/api ./apps/api
COPY packages/shared ./packages/shared
RUN pnpm install --frozen-lockfile
RUN pnpm -F api build
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/apps/api/build ./build
COPY --from=builder /app/apps/api/package.json ./
RUN npm install -g pnpm && pnpm install --prod --frozen-lockfile
EXPOSE 3333
CMD ["node", "build/bin/server.js"]
```
### 17.3 Pipeline CI Gitea
```
.gitea/workflows/build-api.yml
→ build & push image git.arthurbarre.fr/ordinarthur/rubis-api:<sha>
→ kubectl rollout sur namespace rubis
```
### 17.4 K3s
```yaml
- Deployment: rubis-api (port 3333, replicas: 2)
- Deployment: rubis-worker (queue listener, replicas: 1)
- Service: rubis-api-svc
- IngressRoute Traefik: api.rubis.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.
---
## 17-bis. Observability — Sentry (ADR-024)
Error monitoring API via `@sentry/node`. Init au plus tôt dans
`bin/server.ts` AVANT le boot Ignitor pour capturer même les erreurs
de bootstrap.
### Configuration
```ts
// apps/api/start/sentry.ts
// No-op si SENTRY_DSN_API absent → dev local sans bruit Sentry par défaut
```
| Aspect | Valeur | Rationale |
|---|---|---|
| Sample rate traces | 10 % prod, 100 % dev | Quota free tier (5K events/mois) |
| Sample rate profiles | 100 % | Sampled par traces de toute façon |
| `release` | `APP_VERSION` runtime (sha git) | Set par `kubectl set env` post-deploy |
| User context | `user.id` UUID seulement | Pas d'email/nom (PII minimisée) |
| `beforeSend` | drop 4xx | Validation, auth invalide = bruit |
### Capture automatique
Le `report()` du `HttpExceptionHandler` (`apps/api/app/exceptions/handler.ts`)
capture **uniquement les 5xx** (status >= 500 ou status absent). Tout le
reste — `E_INVALID_CREDENTIALS`, `E_VALIDATION_ERROR`, custom 4xx —
n'arrive **jamais** dans Sentry.
```ts
// handler.ts:report (extrait)
const isServerError = !status || status >= 500
if (isServerError) {
Sentry.captureException(error, {
tags: {
// PATTERN de route (/api/v1/checkin/:token/paid), pas l'URL réelle
// → les codes OAuth, tokens checkin, etc. ne fuitent JAMAIS dans
// les tags Sentry indexés.
url: ctx.route?.pattern ?? ctx.request.url(false),
method: ctx.request.method(),
status: status?.toString() ?? '500',
},
user: ctx.auth?.user
? { id: String(ctx.auth.user.id) }
: undefined,
})
}
```
### Capturer manuellement (cas métier)
Dans un service ou job où une exception métier doit être tracée :
```ts
import * as Sentry from '@sentry/node'
try {
await mistralOcr.extract(buffer)
} catch (err) {
Sentry.captureException(err, {
tags: { feature: 'ocr', provider: 'mistral' },
extra: { batchId, fileSize: buffer.length },
})
throw err // re-throw pour que le contrôleur fasse son job de réponse
}
```
### Tester l'intégration en prod (E2E)
Endpoint debug `/api/v1/_debug/sentry-test` (gardé par
`NODE_ENV !== 'production'` OU `DEBUG_SENTRY_TEST=true`). Pour activer
temporairement sur la prod et lancer un test :
```bash
KUBECTL="kubectl --kubeconfig ~/dev/perso/proxmox/k3s/kubeconfig.yaml -n rubis"
$KUBECTL set env deploy/rubis-api DEBUG_SENTRY_TEST=true
$KUBECTL rollout status deploy/rubis-api --timeout=120s
curl -i https://app.rubis.pro/api/v1/_debug/sentry-test
# 500 attendu — vérifier Sentry sous 30s
$KUBECTL set env deploy/rubis-api DEBUG_SENTRY_TEST-
```
### Variables d'env requises
- **K3s secret `rubis-app-secrets`** : `SENTRY_DSN_API` (DSN privé du projet rubis-api)
- **K3s ConfigMap (set par CI)** : `APP_VERSION` = sha git pour le tag release
---
## 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`.*