Bascule du domaine principal vers rubis.pro / app.rubis.pro : - K3s ConfigMaps (api.yml, web.yml) : APP_URL, WEB_URL, COOKIE_DOMAIN, OAUTH callbacks pointent vers app.rubis.pro - Dockerfile.web : ARG VITE_API_URL et VITE_PUBLIC_LANDING_URL - Workflows Gitea : commentaires + build args web → rubis.pro - Code API (mail_dispatcher, send_test_email, config/mail) : defaults env LANDING_URL et MAIL_FROM_ADDRESS migrés - Templates env (.env.example) idem - Docs (architecture, backend, frontend, brand-identity) idem - AGENTS.md / CLAUDE.md / deploy-memory : pointeurs domaine MAJ Note : MAIL_FROM_ADDRESS dans le secret K3s reste sur rubis@arthurbarre.fr tant que le domaine rubis.pro n'est pas Verified dans Resend. À switcher manuellement après vérif Resend. Compat : un 301 Traefik redirige rubis.arthurbarre.fr → rubis.pro (et app.X aussi) — config Ansible dans le repo proxmox. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1182 lines
42 KiB
Markdown
1182 lines
42 KiB
Markdown
# Guide d'implémentation — Backend
|
||
|
||
> Version : 0.1 · Dernière maj : 2026-05-06
|
||
> Décisions de référence : ADR-014 (stack), ADR-015 (monorepo), ADR-016 (PG), ADR-017 (auth), ADR-018 (storage).
|
||
|
||
Ce document est le **guide pratique d'implémentation de l'API**. Il complète `architecture.md` (qui décrit le **quoi**) en expliquant le **comment** : commandes, snippets, conventions, et — surtout — le **contrat exact** que le SPA attend déjà côté front (les mocks MSW de `apps/web/src/mocks/handlers/` sont la source de vérité du contrat actuel).
|
||
|
||
**À lire avant** :
|
||
|
||
- `/CLAUDE.md` — contexte top-level
|
||
- `/docs/produit.md` — flows utilisateur, IN/OUT V1
|
||
- `/docs/tech/architecture.md` — vue d'ensemble du système
|
||
- `/docs/tech/frontend.md` — guide d'implémentation du SPA (utile pour comprendre ce que le back doit servir)
|
||
- `/packages/shared/src/` — types et schemas Zod déjà partagés
|
||
- `/apps/web/src/mocks/handlers/` — **le contrat API tel qu'il est consommé**
|
||
|
||
---
|
||
|
||
## 1. Vue d'ensemble
|
||
|
||
L'API (`apps/api/`) est un **AdonisJS v7** en TypeScript, qui sert :
|
||
|
||
- **JSON-only** sur `/api/v1/*` (pas de Inertia, pas de Hypermedia — le SPA est un consommateur séparé)
|
||
- **Auth Bearer** stateless via `@adonisjs/auth` access_tokens (cf. ADR-017), avec un refresh token cookie httpOnly géré custom par-dessus
|
||
- **Tuyau** pour générer le client TS typé consommé par le SPA → contrat API ↔ web verrouillé par le compilateur
|
||
- **PostgreSQL** comme base relationnelle (cf. ADR-016, instance LXC Proxmox existante)
|
||
- **MinIO** pour les pièces jointes (cf. ADR-018)
|
||
- **Provider OCR** externe (à benchmarker, ADR-020)
|
||
- **Provider Email** externe (à benchmarker, ADR-021)
|
||
- **Background jobs** pour l'OCR différé, les relances programmées, les check-ins
|
||
|
||
Le scaffold initial a été créé via `pnpm create adonisjs@latest -- apps/api --kit=api --pkg=pnpm`, kit `api`. Ça nous a déjà donné :
|
||
|
||
- Auth `access_tokens` configurée (`config/auth.ts`)
|
||
- Tuyau core installé (`@tuyau/core`) + script `node ace tuyau:generate`
|
||
- Routes auth de base : `POST /auth/signup`, `POST /auth/login`, `POST /account/logout`, `GET /account/profile`
|
||
- Lucid + Vine + CORS + Shield + Session
|
||
|
||
Tout le reste (org, clients, factures, plans, jobs, OCR, email) est à construire par-dessus.
|
||
|
||
---
|
||
|
||
## 2. Stack interne
|
||
|
||
| Couche | Choix | Rôle |
|
||
|---|---|---|
|
||
| Framework | `@adonisjs/core` v7 | HTTP, IoC, providers, ace CLI |
|
||
| ORM | `@adonisjs/lucid` | PG, migrations, query builder, relations |
|
||
| Auth | `@adonisjs/auth` (access_tokens) | Bearer tokens, middleware `auth()` |
|
||
| Authz | `@adonisjs/bouncer` | Policies pour les permissions (V1 mono-user, prêt V2 multi-user) |
|
||
| Validation | `@vinejs/vine` | Validateurs typés natifs Adonis, mappés sur les schemas Zod de `packages/shared` |
|
||
| Mail | `@adonisjs/mail` | Templates + provider switchable (Resend / Postmark / SES) |
|
||
| Queue | `@rlanz/bull-queue` (BullMQ) | Jobs différés (OCR, envoi email, check-ins) |
|
||
| Cache / queue backend | Redis | Backend de BullMQ + cache des KPIs dashboard |
|
||
| Rate-limit | `@adonisjs/limiter` | 5 req/min sur `/auth/*`, 10/h sur upload |
|
||
| Tests | `@japa/runner` + `@japa/api-client` | Tests d'intégration HTTP |
|
||
| Type-sharing front | `@tuyau/core` | Génère `.adonisjs/api.ts` consommé par le SPA |
|
||
| HTTP client | `@adonisjs/limiter` + `ky` (côté SPA) | — |
|
||
| Storage | `@adonisjs/drive` (S3 driver MinIO) | Abstraction stockage PDFs |
|
||
|
||
### Dépendances déjà installées par le starter API
|
||
|
||
Voir `apps/api/package.json`. À ajouter pour V1 :
|
||
|
||
```bash
|
||
cd apps/api
|
||
pnpm add @adonisjs/bouncer @adonisjs/mail @adonisjs/limiter @adonisjs/drive
|
||
pnpm add @rlanz/bull-queue bullmq ioredis
|
||
pnpm add @aws-sdk/client-s3 # pour MinIO via le driver S3 d'@adonisjs/drive
|
||
pnpm add resend # ou postmark / @aws-sdk/client-ses selon ADR-021
|
||
node ace add @adonisjs/bouncer
|
||
node ace add @adonisjs/mail --providers=resend
|
||
node ace add @adonisjs/limiter
|
||
node ace add @adonisjs/drive --services=s3
|
||
node ace add @rlanz/bull-queue
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Repo layout (apps/api)
|
||
|
||
Reflète les conventions Adonis 7 + un découpage `services/` pour la logique métier hors HTTP.
|
||
|
||
```
|
||
apps/api/
|
||
├── app/
|
||
│ ├── controllers/ # HTTP only — pas de logique métier
|
||
│ │ ├── auth/
|
||
│ │ │ ├── access_tokens_controller.ts # POST /auth/login + DELETE
|
||
│ │ │ ├── new_account_controller.ts # POST /auth/signup
|
||
│ │ │ └── refresh_controller.ts # POST /auth/refresh — V1 custom
|
||
│ │ ├── account/
|
||
│ │ │ ├── profile_controller.ts # GET + PATCH /account/profile
|
||
│ │ │ └── logout_controller.ts # POST /account/logout
|
||
│ │ ├── organizations_controller.ts # GET + PATCH /organizations/me
|
||
│ │ ├── clients_controller.ts # CRUD /clients
|
||
│ │ ├── plans_controller.ts # CRUD /plans
|
||
│ │ ├── invoices_controller.ts # CRUD /invoices + actions
|
||
│ │ ├── invoices_upload_controller.ts # POST /invoices/upload
|
||
│ │ ├── import_batches_controller.ts # /invoices/import-batch/*
|
||
│ │ └── dashboard_controller.ts # /dashboard/*
|
||
│ ├── models/ # Lucid models, 1 par entité
|
||
│ │ ├── user.ts
|
||
│ │ ├── organization.ts
|
||
│ │ ├── client.ts
|
||
│ │ ├── invoice.ts
|
||
│ │ ├── plan.ts
|
||
│ │ ├── plan_step.ts
|
||
│ │ ├── relance_task.ts
|
||
│ │ ├── checkin_task.ts
|
||
│ │ ├── activity_event.ts
|
||
│ │ ├── import_batch.ts
|
||
│ │ └── import_draft.ts
|
||
│ ├── services/ # Logique métier réutilisable
|
||
│ │ ├── ocr/
|
||
│ │ │ ├── ocr_provider.ts # Interface
|
||
│ │ │ ├── mindee_provider.ts # Implémentation
|
||
│ │ │ └── tesseract_provider.ts
|
||
│ │ ├── mail/
|
||
│ │ │ └── mail_dispatcher.ts # Wrapper @adonisjs/mail
|
||
│ │ ├── relance_scheduler.ts # Programme les RelanceTasks d'une facture
|
||
│ │ ├── checkin_dispatcher.ts # Génère les tokens + envoie les check-ins
|
||
│ │ ├── rubis_calculator.ts # 1 rubis = 10 min, recalcul org.rubisCount
|
||
│ │ └── activity_recorder.ts # Crée des ActivityEvent
|
||
│ ├── jobs/ # BullMQ jobs
|
||
│ │ ├── process_ocr_job.ts
|
||
│ │ ├── send_relance_job.ts
|
||
│ │ ├── send_checkin_job.ts
|
||
│ │ └── recompute_kpis_job.ts
|
||
│ ├── mails/ # @adonisjs/mail templates
|
||
│ │ ├── relance_amical_mail.ts
|
||
│ │ ├── relance_courtois_mail.ts
|
||
│ │ ├── relance_ferme_mail.ts
|
||
│ │ ├── mise_en_demeure_mail.ts
|
||
│ │ └── checkin_mail.ts
|
||
│ ├── policies/ # Bouncer
|
||
│ │ ├── invoice_policy.ts # canView / canEdit / canDelete
|
||
│ │ └── plan_policy.ts
|
||
│ ├── validators/ # Vine schemas (miroir packages/shared)
|
||
│ │ ├── auth.ts
|
||
│ │ ├── client.ts
|
||
│ │ ├── invoice.ts
|
||
│ │ └── plan.ts
|
||
│ ├── middleware/
|
||
│ │ ├── auth_middleware.ts # déjà scaffold
|
||
│ │ ├── force_json_response_middleware.ts
|
||
│ │ └── rate_limiter_middleware.ts
|
||
│ ├── exceptions/
|
||
│ │ └── handler.ts # Convertit les exceptions en JSON `{ errors: [...] }`
|
||
│ └── transformers/ # Sérialisation pour les réponses
|
||
│ ├── client_transformer.ts
|
||
│ ├── invoice_transformer.ts
|
||
│ └── plan_transformer.ts
|
||
├── config/
|
||
│ ├── auth.ts # déjà scaffold (access_tokens)
|
||
│ ├── database.ts # PG en V1
|
||
│ ├── drive.ts # MinIO via S3 driver
|
||
│ ├── mail.ts # Resend ou autre
|
||
│ ├── queue.ts # BullMQ + Redis
|
||
│ ├── limiter.ts # Rate limiting
|
||
│ └── shield.ts # CSP, CORS allowlist
|
||
├── start/
|
||
│ ├── routes.ts # routes par domaine, importées
|
||
│ ├── routes/
|
||
│ │ ├── auth.ts
|
||
│ │ ├── account.ts
|
||
│ │ ├── clients.ts
|
||
│ │ ├── plans.ts
|
||
│ │ ├── invoices.ts
|
||
│ │ └── dashboard.ts
|
||
│ └── kernel.ts
|
||
├── database/
|
||
│ ├── migrations/
|
||
│ │ ├── XXXX_create_users_table.ts # déjà scaffold
|
||
│ │ ├── XXXX_create_access_tokens_table.ts # déjà scaffold
|
||
│ │ ├── XXXX_create_refresh_tokens_table.ts # à créer
|
||
│ │ ├── XXXX_create_organizations_table.ts
|
||
│ │ ├── XXXX_create_clients_table.ts
|
||
│ │ ├── XXXX_create_plans_table.ts
|
||
│ │ ├── XXXX_create_plan_steps_table.ts
|
||
│ │ ├── XXXX_create_invoices_table.ts
|
||
│ │ ├── XXXX_create_relance_tasks_table.ts
|
||
│ │ ├── XXXX_create_checkin_tasks_table.ts
|
||
│ │ ├── XXXX_create_import_batches_table.ts
|
||
│ │ ├── XXXX_create_import_drafts_table.ts
|
||
│ │ └── XXXX_create_activity_events_table.ts
|
||
│ ├── seeders/
|
||
│ │ ├── default_plans_seeder.ts # 4 plans pré-fournis (cf. seed.ts MSW)
|
||
│ │ └── demo_data_seeder.ts # comptes démo (dev seulement)
|
||
│ └── factories/ # Factories Lucid pour les tests
|
||
├── tests/
|
||
│ ├── functional/ # Tests HTTP via @japa/api-client
|
||
│ │ ├── auth.spec.ts
|
||
│ │ ├── invoices.spec.ts
|
||
│ │ └── ...
|
||
│ └── unit/
|
||
└── package.json
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Domain models
|
||
|
||
Le modèle est **scopé par `organization_id` partout** — V1 mono-user mais multi-tenant ready dès le schéma. Tous les Lucid queries doivent filtrer sur `organization_id` (en pratique : un middleware ou un scope global qui injecte ce filtre).
|
||
|
||
### 4.0 Identifiants — UUID partout
|
||
|
||
**Convention dure** : toutes les PK et FK applicatives sont des UUID (PG `uuid` avec default `gen_random_uuid()`). **Pas d'`increments`/`serial`**, y compris pour les tables techniques (`auth_access_tokens`, `refresh_tokens`, `import_batches`, etc.).
|
||
|
||
```ts
|
||
// migration
|
||
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
|
||
table.uuid('organization_id').notNullable().references('id').inTable('organizations').onDelete('CASCADE')
|
||
```
|
||
|
||
```ts
|
||
// model — id est typé string, le schema generator le déduit du type uuid
|
||
@column({ isPrimary: true })
|
||
declare id: string
|
||
```
|
||
|
||
Côté transformers, on expose l'UUID tel quel (pas de cast `String(id)`).
|
||
|
||
**Pourquoi** :
|
||
1. **Anti-énumération** : `/invoices/42` révèle qu'on en a au moins 42, ce qui peut leak des volumes business.
|
||
2. **Fédération multi-tenant** : pas besoin d'un namespace par tenant pour éviter les collisions, l'UUID est globalement unique.
|
||
3. **Génération côté client** : permet d'optimiste-créer un objet (UUID généré côté SPA) avant le round-trip API.
|
||
4. **Pas de surprise sur les exports** : un dump CSV qui sort des `id=1,2,3` est un bug latent.
|
||
|
||
### 4.1 User
|
||
|
||
```ts
|
||
// app/models/user.ts
|
||
import { BaseModel, column, belongsTo } from '@adonisjs/lucid/orm'
|
||
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
|
||
import { DateTime } from 'luxon'
|
||
import Organization from '#models/organization'
|
||
|
||
const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
|
||
uids: ['email'],
|
||
passwordColumnName: 'password',
|
||
})
|
||
|
||
export default class User extends compose(BaseModel, AuthFinder) {
|
||
@column({ isPrimary: true })
|
||
declare id: string
|
||
|
||
@column()
|
||
declare email: string
|
||
|
||
@column({ serializeAs: null })
|
||
declare password: string
|
||
|
||
@column({ columnName: 'full_name' })
|
||
declare fullName: string
|
||
|
||
@column({ columnName: 'organization_id' })
|
||
declare organizationId: string
|
||
|
||
@column()
|
||
declare signature: string | null
|
||
|
||
@column.dateTime({ autoCreate: true })
|
||
declare createdAt: DateTime
|
||
|
||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||
declare updatedAt: DateTime
|
||
|
||
@belongsTo(() => Organization)
|
||
declare organization: BelongsTo<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('MINIO_ACCESS_KEY'),
|
||
secretAccessKey: env.get('MINIO_SECRET_KEY'),
|
||
},
|
||
endpoint: env.get('MINIO_ENDPOINT'), // http://lxc-minio:9000
|
||
region: 'fr-par',
|
||
bucket: 'rubis-invoices',
|
||
forcePathStyle: true, // requis pour MinIO
|
||
visibility: 'private',
|
||
}),
|
||
},
|
||
})
|
||
```
|
||
|
||
### Conventions de keys
|
||
|
||
```
|
||
invoices/<orgId>/<invoiceId>/<filename>
|
||
import-drafts/<orgId>/<batchId>/<draftId>.<ext>
|
||
backups/pg/<date>.dump
|
||
```
|
||
|
||
### Upload depuis le contrôleur
|
||
|
||
```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)
|
||
|
||
---
|
||
|
||
## 13. Background jobs
|
||
|
||
### 13.1 Stack
|
||
|
||
`@rlanz/bull-queue` (Adonis 7 wrapper de BullMQ) + Redis comme backend.
|
||
|
||
```ts
|
||
// config/queue.ts
|
||
const queueConfig = defineConfig({
|
||
connection: {
|
||
host: env.get('REDIS_HOST'),
|
||
port: env.get('REDIS_PORT'),
|
||
},
|
||
queues: {
|
||
ocr: { name: 'ocr', concurrency: 2 },
|
||
relances: { name: 'relances', concurrency: 5 },
|
||
checkins: { name: 'checkins', concurrency: 5 },
|
||
},
|
||
})
|
||
```
|
||
|
||
### 13.2 Jobs
|
||
|
||
| Job | Trigger | Idempotent | Retry |
|
||
|---|---|---|---|
|
||
| `ProcessOcrJob` | POST /invoices/upload | oui (status=validated/skipped → no-op) | 3× exponential backoff |
|
||
| `SendRelanceJob` | RelanceTask.sendAt | oui (status=sent → no-op) | 5× |
|
||
| `SendCheckinJob` | CheckinTask.sendAt | oui | 3× |
|
||
| `RecomputeKpisJob` | nightly cron + post-mutation | oui | 1× |
|
||
|
||
### 13.3 Programmation des relances
|
||
|
||
Quand l'utilisateur valide une facture (`POST /invoices/:id/validate-import` ou création manuelle) :
|
||
|
||
1. On lit `invoice.planId` → `plan.steps`
|
||
2. Pour chaque step, on calcule `sendAt = invoice.dueDate + step.offsetDays`
|
||
3. On crée une `RelanceTask` (status=scheduled) + on enqueue un `SendRelanceJob` avec `delay: sendAt - now`
|
||
4. On crée éventuellement un `CheckinTask` à `sendAt - 2j` (cf. flow check-in)
|
||
|
||
À l'exécution du job :
|
||
|
||
```ts
|
||
async handle({ relanceTaskId }) {
|
||
const task = await RelanceTask.findOrFail(relanceTaskId)
|
||
if (task.status !== 'scheduled') return // idempotent
|
||
|
||
const invoice = await Invoice.query()
|
||
.where('id', task.invoiceId)
|
||
.preload('client')
|
||
.preload('plan', q => q.preload('steps'))
|
||
.firstOrFail()
|
||
|
||
// Hook critique : la facture peut être devenue paid entre-temps
|
||
if (invoice.status === 'paid' || invoice.status === 'cancelled') {
|
||
task.status = 'cancelled'
|
||
await task.save()
|
||
return
|
||
}
|
||
|
||
const step = invoice.plan.steps.find(s => s.id === task.planStepId)
|
||
if (step.requiresManualValidation) {
|
||
// Mise en demeure : on génère un brouillon, on n'envoie pas
|
||
await activityRecorder.draft(invoice, step)
|
||
return
|
||
}
|
||
|
||
await mailDispatcher.sendRelance({ invoice, step })
|
||
task.status = 'sent'
|
||
task.sentAt = DateTime.now()
|
||
await task.save()
|
||
|
||
await activityRecorder.relanceSent(invoice, step)
|
||
await rubisCalculator.creditOne(invoice.organizationId) // +1 rubis
|
||
}
|
||
```
|
||
|
||
### 13.4 Worker Pod
|
||
|
||
K3s pod séparé du pod web `rubis-api` :
|
||
|
||
```yaml
|
||
- Deployment: rubis-worker
|
||
command: ["node", "ace", "queue:listen", "--queue=ocr,relances,checkins"]
|
||
replicas: 1 # V1, scale up plus tard
|
||
```
|
||
|
||
---
|
||
|
||
## 14. Tests — Japa
|
||
|
||
Tests d'intégration HTTP via `@japa/api-client`. Pattern :
|
||
|
||
```ts
|
||
// tests/functional/clients.spec.ts
|
||
import { test } from '@japa/runner'
|
||
import { OrganizationFactory, UserFactory, ClientFactory } from '#database/factories'
|
||
|
||
test.group('Clients API', (group) => {
|
||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||
|
||
test('POST /clients refuse sans email', async ({ client }) => {
|
||
const user = await UserFactory.create()
|
||
const response = await client
|
||
.post('/api/v1/clients')
|
||
.json({ name: 'Boulangerie Test' })
|
||
.loginAs(user)
|
||
|
||
response.assertStatus(422)
|
||
response.assertBodyContains({
|
||
errors: [{ code: 'validation_failed', field: 'email' }],
|
||
})
|
||
})
|
||
|
||
test('POST /clients crée un client avec email', async ({ client }) => {
|
||
const user = await UserFactory.create()
|
||
const response = await client
|
||
.post('/api/v1/clients')
|
||
.json({
|
||
name: 'Boulangerie Test',
|
||
email: 'compta@boulangerie-test.fr',
|
||
})
|
||
.loginAs(user)
|
||
|
||
response.assertStatus(201)
|
||
response.assertBodyContains({ data: { name: 'Boulangerie Test' } })
|
||
})
|
||
})
|
||
```
|
||
|
||
**Couverture cible V1** :
|
||
|
||
- Auth flows complets (signup, login, refresh, logout, expired token)
|
||
- Permissions cross-org (un user A ne peut pas voir/modifier les ressources du user B)
|
||
- Validation Vine (toutes les contraintes critiques : email required client, SIRET format, etc.)
|
||
- Idempotence des jobs OCR / Relance
|
||
- Edge cases métier : `mark-paid` annule les RelanceTasks futures, plan édité ne casse pas les invoices en cours
|
||
|
||
---
|
||
|
||
## 15. Migrations + seeders
|
||
|
||
### 15.1 Migrations
|
||
|
||
Adonis Lucid, fichiers `database/migrations/<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
|
||
MINIO_ENDPOINT=http://lxc-minio.proxmox.local:9000
|
||
MINIO_ACCESS_KEY=<secret>
|
||
MINIO_SECRET_KEY=<secret>
|
||
MINIO_INVOICES_BUCKET=rubis-invoices
|
||
MINIO_BACKUPS_BUCKET=rubis-backups
|
||
|
||
# Mail (Resend)
|
||
MAIL_FROM_ADDRESS=relances@rubis.pro
|
||
MAIL_FROM_NAME=Rubis Sur l'Ongle
|
||
RESEND_API_KEY=<secret>
|
||
|
||
# OCR (Mindee — cf. ADR-020 pending)
|
||
OCR_PROVIDER=mindee
|
||
MINDEE_API_KEY=<secret>
|
||
|
||
# Auth refresh tokens
|
||
REFRESH_TOKEN_TTL_DAYS=30
|
||
ACCESS_TOKEN_TTL_MINUTES=30
|
||
COOKIE_DOMAIN=.rubis.pro
|
||
COOKIE_SECURE=true
|
||
```
|
||
|
||
Validation au boot via `start/env.ts` (Adonis 7 valide chaque var avec un schema Vine).
|
||
|
||
---
|
||
|
||
## 17. Build & déploiement
|
||
|
||
### 17.1 Build local
|
||
|
||
```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.
|
||
|
||
---
|
||
|
||
## 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`.*
|