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

50 KiB
Raw Blame History

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

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

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

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

BullMQ direct (sans wrapper Adonis) (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

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

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.


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

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

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

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 :

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.