Compare commits

...

51 Commits

Author SHA1 Message Date
ordinarthur
461ab9bcd9 feat(deploy): app.rubis.arthurbarre.fr — image, manifests K3s, route Traefik
Some checks failed
Build & Deploy Landing / build-and-deploy (push) Successful in 31s
Build & Deploy App / build-and-deploy (push) Failing after 46s
Premier déploiement de l'app SaaS (apps/api + apps/web) — distinct de la
landing déjà sur rubis.arthurbarre.fr. Architecture :
- Image unique (Dockerfile.app, multi-stage) : AdonisJS sert l'API ET le
  SPA static via @adonisjs/static + wildcard fallback pour TanStack Router
- Workers BullMQ tournent dans le même process Node (cf. start/queue.ts)
- Redis 7 dans le namespace rubis (PVC local-path 1Gi)
- Migrations en init-container avant le serveur (idempotent)

Infra :
- K3s namespace rubis (déjà existant) — ajout deploy/svc rubis-app + redis
- NodePort 30110 → Traefik → app.rubis.arthurbarre.fr (TLS Let's Encrypt)
- Postgres : base rubis_prod + user rubis créés sur 10.10.10.3
- MinIO : bucket rubis-prod-invoices créé via mc
- Secrets K3s posés via kubectl create secret (APP_KEY généré, DB pwd
  généré, MinIO root creds réutilisées, Resend/Mistral keys)
- DNS OVH A record app.rubis créé (id 5413305619)
- CI Gitea : .gitea/workflows/deploy-app.yml séparé du workflow landing,
  filtres sur paths apps/**, packages/**, Dockerfile.app, k3s/app/**

Code app :
- Static middleware @adonisjs/static configuré
- Wildcard route SPA fallback en fin de routes.ts
- Fix erreurs strict TS qui bloquaient le build vite (unused vars,
  Client missing contactFirstName/LastName dans MSW)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 02:01:39 +02:00
ordinarthur
ca95dde9b3 style(web): cursor-pointer global sur les éléments interactifs
Tailwind v4 ne pose plus cursor:pointer sur <button> par défaut, ce qui
rendait l'app un peu morte au survol. Plutôt que d'ajouter cursor-pointer
sur chaque composant, on le pose une fois pour toutes en CSS de base sur :
- button, role="button", a[href], summary, label[for], select
- inputs cliquables (submit, button, reset, checkbox, radio)

Les éléments désactivés (disabled, aria-disabled) basculent en
cursor:not-allowed pour signaler clairement l'état.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 00:42:13 +02:00
ordinarthur
5c7dbc2eba fix(plans/ai): contexte plan + interdiction Mustache sections
Bugs remontés sur les générations IA :
- Le modèle utilisait `{{#var}}...{{/var}}` (sections Mustache) pour
  gérer les fallbacks de prénom — notre interpréteur ne fait que de
  la substitution simple, donc le charabia s'affichait dans l'email.
- La signature était dupliquée : l'IA écrivait le nom à la main puis
  ajoutait `{{signature}}`.
- Le contexte du plan (nom + description) n'était pas transmis, donc
  les générations étaient déconnectées du sens du plan parent.

Corrections du SYSTEM_PROMPT :
- Section "Syntaxe des variables" explicite : substitution simple
  uniquement, INTERDICTION des `{{#...}}` / `{{^...}}` / conditionnels
- Section "Tu n'es PAS obligé d'utiliser toutes les variables"
  → l'IA pioche celles qui rendent le message naturel
- Règle : terminer toujours par {{signature}} sur sa propre ligne,
  ne JAMAIS réécrire le nom de l'expéditeur après (la variable
  contient déjà nom + entreprise + formule de politesse)

Backend
- ai_relance_generator : type GenerateRelanceInput accepte planName
  + planDescription (à la place de l'ancien planContext fourre-tout)
- user message structuré en sections # Plan parent / # Cette relance
  / # Brief de l'utilisateur, plus lisible pour le modèle
- ai_controller validator : accepte planName + planDescription

Frontend
- AiGenerateModal accepte planName + planDescription en props et
  les passe à l'API
- Affiche le nom du plan dans la description de la modale
- Bloc dépliable "Variables que l'IA peut insérer (sans obligation)"
  pour montrer à l'utilisateur ce qui est dispo
- StepMessages passe draft.name + draft.description au modal
- MSW handler aligné sur le nouveau contrat

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:48:57 +02:00
ordinarthur
0a3b8523ef feat(plans/wizard): éditeur avec icônes de tonalité + toggle de sélection
- Champ Décalage retiré : on change le timing en cliquant une autre
  case du calendrier (delete + click ailleurs), c'est plus aligné
  avec la métaphore calendrier
- Tonalité passée d'un select à un groupe de 4 boutons icônes :
  · Doux → Smile (sourire chaleureux)
  · Standard → MessageCircle (bulle de conversation polie)
  · Ferme → AlertTriangle (alerte mesurée)
  · Strict → Gavel (marteau de juge)
  Chaque bouton actif prend la couleur de fond de sa tonalité, plus
  visuel et compact qu'un dropdown
- Header de l'éditeur : la pastille colorée devient une pastille avec
  l'icône de tonalité dedans → on lit la tonalité d'un coup d'œil
- Toggle : re-cliquer la case déjà sélectionnée la désélectionne
  (retour à l'état "vue d'ensemble" avec le hint), au lieu d'avoir
  une sélection permanente

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:34:33 +02:00
ordinarthur
07712da774 fix(plans/wizard): calendrier vraiment pratique (3 problèmes UX)
Issues remontées :
- Cellules étirées en horizontal sur desktop (rectangles plats)
- Échéance et "Doux" indiscernables (tous deux en rubis-glow)
- Pas de feedback au clic, "Étape 1" déjà affichée par défaut sans
  qu'on l'ait sélectionnée
- "Étape 1 · 20 juin · J+5" pas parlant
- Cliquer sur une case vide ne faisait rien

Refonte :
- Calendrier max-w-md mx-auto + cells aspect-square → carrés équilibrés
- Échéance = bg-rubis solide (pas glow) + ◆ blanc + ombre rubis →
  visuellement distincte de toutes les tonalités
- Cellule étape = couleur tonalité + badge "J+X" en coin haut-droit
- Sélection forte : ring-4 + scale-1.08 + shadow-rubis-hover sur la
  case sélectionnée → impossible de la rater
- Default selectedIdx = -1 (pas de présélection) → hint clair :
  "Touchez une case colorée pour modifier, ou un jour vide pour ajouter"
- **Click sur case vide → crée une étape à cet offset**, triée par
  ordre temporel (insertion smart, pas en bout). Plus l'usage le plus
  naturel de l'outil : "je veux relancer le 5 juin" → clic.
- Click sur échéance → toast explicatif (pas une no-op silencieuse)
- Header de l'éditeur : "Relance du **5 juin** · J-10" (pas "Étape 1")
- Hover sur jour vide : "+" rubis apparaît → affordance d'ajout claire
- Hors plage [-30, +180] ou >= 8 étapes : cellule disabled, toast info

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:29:39 +02:00
ordinarthur
149f60dbb0 refactor(plans/wizard): calendrier compact en outil de navigation
Le calendrier précédent prenait toute la page (1-2 mois empilés en
pleine taille). Refonte en mini-calendrier de navigation :

- Un seul mois affiché à la fois, navigation prev/next via chevrons
- Auto-jump au mois de l'étape sélectionnée pour ne jamais perdre
  la cellule de vue
- Cellules h-9 fixe (plus de aspect-square qui gonflait sur écran large)
- Header compact : juste mois + chevrons (pas de gros titre)
- Légende inline une ligne ("◆ Échéance le X · couleur = tonalité")
- Éditeur compact en dessous : 1 ligne d'en-tête (◆ tonalité · étape N
  · 18 mai · J+3) + 1 ligne 2-cols (input offset + select tonalité +
  bouton supprimer en icône). Plus de Field / hint volumineux.
- Footer : bouton Ajouter en pleine largeur (sauf compteur 3/8 à droite)

Hauteur totale ~400px en pratique vs 700px+ avant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:22:13 +02:00
ordinarthur
a136c54501 feat(plans/wizard): cadence sur calendrier mensuel avec tonalités
Remplace la liste verticale par une vraie visu calendrier qui ancre
chaque étape sur une date concrète, ce qui donne du sens au timing.

- Date d'échéance fictive : le 15 du mois prochain (stable, prévisible,
  laisse de la marge avant/après pour offsets négatifs comme positifs)
- Cellule échéance = ◆ rubis plein sur fond rubis-glow + shadow rubis,
  jour mis en exergue
- Cellule étape = couleur de fond pleine selon la tonalité (Doux =
  rubis-glow, Standard = cream-2, Ferme = ink, Strict = rubis-deep)
  avec affichage J+X / numéro du jour
- Cellule jour normal = numéro muted, today souligné en rubis-glow
- Click sur cellule étape → sélection, l'éditeur (offset, ton,
  supprimer) apparaît directement sous le calendrier
- Légende des tonalités juste sous l'en-tête
- Affiche tous les mois entre la 1re et la dernière étape (échéance
  incluse) — typiquement 1 à 2 mois en pratique
- Mêmes raccourcis qu'avant : OffsetInput string-controlled qui accepte
  les états intermédiaires "" et "-"

Suppression de CadenceTimeline.tsx (la liste verticale précédente).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:18:27 +02:00
ordinarthur
05ad3fa5cf refactor(plans/wizard): refonte cadence en liste verticale lisible (mobile + desktop)
Le précédent layout avec ◆ rotatés en timeline causait des collisions
visuelles sur mobile (les coins du diamant débordaient sur les labels et
la ligne de connexion). Inutilisable.

Nouvelle approche, inspirée des éditeurs de séquences éprouvés
(Mailchimp, Klaviyo) : liste verticale de cards de step, identique
sur mobile et desktop. Plus prévisible, plus lisible, mêmes tap targets.

- Chaque step = card cliquable avec : numéro d'ordre, ◆ accent (petit,
  coloré par tonalité, signature de marque sans gêner la lecture),
  J+X, label de tonalité, bouton retirer aligné dans le flux
- La card sélectionnée (rubis border + shadow) révèle l'éditeur inline
  (Décalage + Tonalité) directement sous l'en-tête → pas de panneau
  séparé, pas de saut de focus, l'utilisateur édite ce qu'il vient
  de taper
- Bouton "Ajouter une étape" en pleine largeur en pied de liste
- L'avertissement mise-en-demeure (validation manuelle) s'affiche dans
  la card sélectionnée
- OffsetInput déplacé dans CadenceTimeline avec le reste de l'éditeur ;
  duplication supprimée du fichier route

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:10:32 +02:00
ordinarthur
24cbf35902 fix(plans/wizard): variables dans le sujet + UX mobile resserrée
Variables
- Le clic sur un chip de variable insère désormais au curseur du dernier
  champ focus (sujet OU corps), pas seulement dans le corps. On capture
  la position via onSelect/onClick/onKeyUp/onBlur et on utilise mousedown
  + preventDefault sur les chips pour que le focus ne quitte pas le champ
  ciblé avant l'insertion. Le label sous les chips indique en live
  quel champ est ciblé.
- OffsetInput (étape Cadence) : composant string-controlled qui accepte
  les états intermédiaires "" et "-" pour ne plus avoir le 0 fantôme
  quand on efface pour ressaisir un offset négatif.

Mobile
- Bottom nav (Annuler/Continuer) sticky en bas sur mobile, en flux normal
  sur desktop. Safe-area inset respectée.
- Header du wizard : back button compact (icône seule sous sm), compteur
  d'étape toujours visible, stepper centré.
- Card padding adaptatif (p-5 sm:p-7 lg:p-9).
- Step 3 — sélecteur d'étape : scroll horizontal sur mobile (au lieu de
  wrap), évite l'effet escalier avec 5 étapes.
- Step 3 — body textarea : min-h adaptatif (180px mobile, 260px sm+).
- CadenceTimeline : rail horizontal masqué sous lg ; en mobile, ligne
  verticale fine entre les nœuds (cohérent identité ◆) ; bouton retirer
  visible en permanence sur mobile (les hover-only ne marchent pas tactile).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:05:34 +02:00
ordinarthur
9e531e32a9 feat(plans): wizard de création de plan custom + génération IA Mistral
Backend
- migration : champs contact_first_name / contact_last_name (nullable)
  sur clients pour personnaliser les variables de relance
- POST /api/v1/plans : création de plan custom avec slug auto-généré
  (suffixé en cas de collision, "nouveau"/"new"/"create" réservés)
- POST /api/v1/ai/generate-relance : génération de subject+body via
  mistral-small-latest, avec brief utilisateur et tonalité ciblée
- mail_dispatcher : nouvelles variables {{daysLate}}, {{issueDate}},
  {{user.fullName}}, {{user.companyName}}, {{client.contactFirstName}},
  {{client.contactLastName}} (helper buildRelanceVars exposé pour preview)
- send_relance_job preload désormais l'organization pour exposer son name

Frontend
- /plans/nouveau : wizard 4 étapes (Identité → Cadence → Messages → Récap)
  - Stepper en haut, navigation guidée, validation par étape
  - Étape 1 : nom + tonalité globale (4 cards Doux/Standard/Ferme/Strict)
    avec aperçu de la cadence par défaut associée
  - Étape 2 : timeline horizontale (rail rubis-glow + nœuds ◆ teintés
    selon la tonalité), édition décalage/ton de l'étape sélectionnée
  - Étape 3 : édition par étape avec preview live à droite, chips de
    variables cliquables, bouton "Générer avec l'IA" qui ouvre une modale
    Mistral (brief + résultat + régénérer)
  - Étape 4 : récap avec preview de chaque email rendu sur un client fictif
- Détection des variables sensibles → warning si X clients existants n'ont
  pas le champ contactFirstName/contactLastName rempli (UX informative,
  fallback vide à l'envoi)
- "Dupliquer" sur chaque card de plan → /plans/nouveau?from=<slug>
  pour pré-remplir le wizard à partir d'un plan existant
- ClientCreateDialog : ajout des champs prénom/nom du contact dédié
- TEMPLATE_VARIABLES étendu, helper renderTemplate côté front en miroir
  exact de l'implémentation API
- MSW handlers ai/plans/clients alignés sur le nouveau contrat

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 22:55:00 +02:00
ordinarthur
8742cabebf add marketting 2026-05-06 22:22:42 +02:00
ordinarthur
a790455ae1 feat(api): bascule des envois mail sur Resend (fin de Mailpit en dev)
- MAIL_DRIVER=resend par défaut, from rubis@arthurbarre.fr (domaine vérifié)
- replyTo posé sur user.email dans les relances : les réponses des clients
  reviennent au patron de la TPE, pas dans notre boîte transactionnelle
- ajout d'une commande Ace `send:test-email` pour valider la conf
  (driver, from, SPF/DKIM/clé API) sans passer par tout le flow facture

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 22:22:33 +02:00
ordinarthur
b8dec6d494 update la relance par mail 2026-05-06 19:02:39 +02:00
ordinarthur
5e41e2a9fa add ocr + add factures 2026-05-06 18:47:35 +02:00
ordinarthur
c4486d9e5e fix(api): exception handler normalise toutes les erreurs en { errors: [...] }
3 tests Japa étaient en échec à cause de réponses non conformes au contrat backend.md §6 :

- E_INVALID_CREDENTIALS (Adonis auth) renvoyait 400 au lieu de 401 → mappé explicitement vers 401 + code 'invalid_credentials'
- Custom Exception (status + code + message) côté controllers (ex. client_email_required) sortait en shape Adonis par défaut { message, name, code } → wrap en { errors: [{ code, message }] }
- E_VALIDATION_ERROR de Vine relayé proprement (au cas où, déjà géré en pratique)

L'enveloppe { errors: [...] } est maintenant garantie pour toutes les erreurs HTTP. Le SPA peut switch sur errors[0].code sans deviner la shape.

Tests : 50/50 passent.
2026-05-06 15:55:27 +02:00
ordinarthur
554ae4ba4a test(api): tests fonctionnels Invoices + Imports + Dashboard
invoices.spec.ts (10 cas) :
- Création : 201 + rubisEarned=1 (bonus saisie) + status=pending
- Création client à la volée : si nom non matché + email fourni → client créé
- 422 client_email_required si pas d'email pour création à la volée
- Si planId fourni : RelanceTasks scheduled créées (assertion sur DB count)
- Numéro unique par org : 422 sur duplicate (testant l'exception handler)
- mark-paid idempotent : 2e appel ne re-bumpe pas rubisEarned ni org.rubis_count
- mark-paid annule les RelanceTasks scheduled (passe à cancelled)
- Cross-org : user B → 404 sur mark-paid d'une facture de A
- GET /invoices : pagination + meta total/page
- GET /invoices/counts : agrège par status

imports.spec.ts (6 cas) :
- POST /upload mock JSON : 1 batch + N drafts en pending, mock OCR rempli les fields
- Refus > 20 fichiers
- Validate transforme draft en Invoice + status=validated + invoiceId set
- Validate sur draft déjà processed → 409 draft_already_processed
- Skip → status=skipped, idempotent
- DELETE batch → CASCADE supprime les drafts

dashboard.spec.ts (6 cas) :
- KPIs zéros sur org vierge
- factureToRelance compte les invoices pending
- Après mark-paid : encaisseCents et rubisCount bumpent (org.rubisCount agrégé)
- Activity vide sur org sans actions
- Activity loggue invoice_paid après mark-paid (label dans le feed)
- Top-late liste les clients avec invoices actives en retard (dueDate < today)
2026-05-06 15:53:14 +02:00
ordinarthur
691b5fd09f test(api): tests fonctionnels Clients + Plans (CRUD + cross-org + validation)
Helper response.ts : `body<T>()` pour caster Tuyau strict response shapes (Tuyau type chaque code de statut comme une union, assertStatus ne narrow pas → on cast explicitement vers ApiOk<T>/ApiError/ApiConflict<T>).

clients.spec.ts (16 cas) :
- POST /clients : refus sans email (422 + field=email), refus SIRET ≠ 14 chiffres, création OK avec UUID + association org, doublon nom case-insensitive (409 + payload existing)
- GET /clients : isolation cross-org (user A ne voit pas les clients de B), withStats=1 enrichit (zéros sans factures), recherche q ILIKE
- Perms cross-org : user B → 404 sur GET/PATCH d'un client de A, l'objet ne bouge pas

plans.spec.ts (7 cas) :
- GET /plans : 4 plans pré-fournis avec steps préchargés, isolation cross-org (UUIDs disjoints entre A et B)
- GET /plans/:slug : steps ordonnés, 404 si inconnu
- PATCH /plans/:slug : remplace les steps en bloc dans une tx, rejette tone invalide, cross-org (B édite SA copie sans toucher celle de A)
2026-05-06 15:51:03 +02:00
ordinarthur
fc66d80f56 test(api): setup Japa + tests fonctionnels auth (signup/login/logout/onboarding)
Setup :
- .env.test étoffé : DRIVE_DISK=fs, MAIL_DRIVER=smtp local, OCR_PROVIDER=mock. Réutilise la DB rubis_dev avec global transactions par test (rollback auto, isolation parfaite).
- Schedulers (relance + checkin) détectent NODE_ENV=test et skippent BullMQ.add. Les tasks DB sont quand même créées (utiles pour assertions) mais aucun job orphelin n'arrive en Redis après rollback de tx.
- helpers/auth.ts : factory createTestUser() qui crée org + user + 4 plans pré-fournis dans une tx, retourne user/org/accessToken/bearer header. createTwoOrgs() pour les tests cross-org à venir.

Tests fonctionnels auth (tests/functional/auth.spec.ts) :
- Signup : crée user + org + 4 plans pré-fournis (vérifie les slugs), refuse email mal formé / password court / email déjà pris
- Login : émet AuthSession avec credentials valides, rejette mauvais password / email inconnu
- Bearer auth : 401 sans token, 401 avec token bidon, 200 avec token valide
- Logout : révoque le token courant, requêtes suivantes en 401
- Onboarding : PATCH /organizations/me pose onboardingCompletedAt à la 1re mise du nom, idempotent ensuite

Pour lancer : `pnpm -F api test`
2026-05-06 15:45:11 +02:00
ordinarthur
01f3edcf08 fix(api): découple APP_URL de HOST dans .env.example
HOST=0.0.0.0 c'est bien pour le bind (IPv4 + IPv6), mais en interpolant `APP_URL=http://\${HOST}:\${PORT}` on se retrouve avec des liens "http://0.0.0.0:3333" dans les emails check-in / relance — non cliquables. APP_URL est maintenant explicitement "http://localhost:3333" (ou le vrai domaine en prod).
2026-05-06 15:41:18 +02:00
ordinarthur
f1a9549b01 fix(api): 23505 PG → 422 propre + schedulers Redis non-bloquants
- ExceptionHandler : convertit les violations de contrainte unique PG (23505) en réponse `{ errors: [{ code: 'duplicate', field, message }] }` 422 au lieu d'un 500 avec stack pg-protocol. Extrait le nom de colonne via regex sur le `detail` PG.
- InvoicesController.store + ImportBatchesController.validateDraft : wrap les appels schedulers (Redis side-effect, hors tx) dans try/catch + logger.warn. Si Redis flanche, l'invoice est créée et la requête HTTP retourne 201 normalement — l'utilisateur peut re-déclencher la programmation plus tard. Évite qu'une panne Redis casse le path de saisie.
2026-05-06 15:39:04 +02:00
ordinarthur
299f7beb63 fix(api): swap ':' → '-' dans les BullMQ jobIds (interdit en 5.x)
BullMQ 5+ refuse les ':' dans les Custom Ids (validateOptions throws "Custom Id cannot contain :"). On utilisait `relance:<taskId>` et `checkin:<taskId>` pour assurer l'idempotence — passe en `relance-<taskId>` / `checkin-<taskId>`.
2026-05-06 15:37:03 +02:00
ordinarthur
0e8d0f3853 fix(api): default HOST=0.0.0.0 dans .env.example (piège IPv4/IPv6 macOS)
Sur macOS récent, "localhost" résout d'abord en ::1 (IPv6). Si Node bind sur "localhost", il n'écoute que sur ::1. Les clients HTTP (Bruno, certaines libs Node) qui tapent 127.0.0.1 explicitement se prennent un ECONNREFUSED alors que le serveur tourne. 0.0.0.0 bind toutes les interfaces (v4 + v6), pas de surprise.
2026-05-06 15:34:25 +02:00
ordinarthur
cfa302ce9a fix(api): boot tolérant à Redis injoignable
Si Redis n'est pas dispo au démarrage, registerWorker peut throw lors de l'instantiation BullMQ. On wrap dans try/catch et on log un warning — l'API HTTP démarre quand même, ce qui permet de tester l'app en dev même quand docker-compose n'est pas up. Les jobs ne tourneront pas tant que Redis n'est pas joignable + serveur restart.
2026-05-06 15:33:15 +02:00
ordinarthur
94263c6447 feat(api): check-in flow — email à l'user + endpoints publics paid/pending
Le check-in remplace l'intégration banking V1 (cf. CLAUDE.md → Glossaire) :
avant que la 1re relance ne parte, on demande à l'user "as-tu été payé ?"
via email, et il clique sur l'un des 2 liens publics.

Service checkin_token.ts : génération + hash SHA-256. 32 bytes random base64url, plain dans le mail, hash en DB (CheckinTask.token_hash unique).

Service checkin_scheduler.ts :
- scheduleCheckinForInvoice(invoice) : crée 1 CheckinTask à dueDate (now+1min si dueDate dans le passé). Idempotent par invoice — cancel les scheduled précédents avant.
- cancelCheckinForInvoice(invoiceId) : appelé par mark-paid pour stopper.

Job send_checkin_job.ts : worker queue 'checkins', skip si invoice paid/cancelled (no-op), construit l'URL avec le plain token (passé dans le payload du job, pas relu DB), appelle sendCheckinEmail.

mail_dispatcher.ts : sendCheckinEmail() — texte brut, destinataire = user (pas client !), 2 URLs (paid / pending), TTL 24h annoncé.

Controller CheckinController :
- GET /api/v1/checkin/:token/paid : status=answered + answer=paid + mark invoice paid (mêmes effets que POST /invoices/:id/mark-paid : rubis +1, ActivityEvent invoice_paid avec label "via check-in", cancelFutureRelances). Idempotent : 2e click → redirect "already_answered".
- GET /api/v1/checkin/:token/pending : status=answered + answer=still_pending. Les relances suivent leur cours.
- Validation : lookup hash, expiry (sentAt + 24h), redirects propres pour invalid / expired / already_answered.

Routes : nouveau group public `checkin` (PAS de middleware.auth) à côté du group auth, sous /api/v1.

Triggers branchés :
- InvoicesController.store et ImportBatchesController.validateDraft → scheduleCheckinForInvoice après création
- InvoicesController.markPaid → cancelCheckinForInvoice dans la tx

start/queue.ts : registerWorker('checkins', sendCheckinJob).

env : nouveau WEB_URL (URL du SPA pour redirects), default localhost:5173 en dev.

Bruno : nouveau dossier 08-Checkin avec doc complète du flow + 2 requêtes (paid / pending). var d'env `checkinToken` à remplir manuellement après avoir reçu l'email dans Mailpit.
2026-05-06 15:31:40 +02:00
ordinarthur
a6b35dfe7a feat(api): RelanceTask + CheckinTask + worker BullMQ qui envoie les relances
Migrations :
- relance_tasks (uuid id, organization_id FK CASCADE [scope direct sans join], invoice_id FK CASCADE, plan_step_id FK RESTRICT, send_at, status ENUM scheduled/sent/cancelled/failed, sent_at, queue_job_id pour cancel via BullMQ.remove). Indexes (org,status), (invoice_id), (send_at).
- checkin_tasks (uuid id, org_id, invoice_id, send_at, token_hash unique [SHA-256 du HMAC, TTL 24h], status ENUM scheduled/sent/answered/expired, answer 'paid'|'still_pending'). Pas encore branché — flow check-in arrivera dans un commit séparé (cf. backend.md §13.3).

Schema rules : status enums + answer typés.

Models RelanceTask + CheckinTask avec belongsTo Invoice / PlanStep.

Service relance_scheduler.ts :
- scheduleRelancesForInvoice(invoice) : pour chaque step du plan, calcule sendAt = dueDate + offsetDays. Si sendAt < now (facture importée en retard), on programme à `now + 1min` plutôt que skip — l'utilisateur "rattrape" une dette de relance, l'envoi immédiat est cohérent. Crée la RelanceTask + enqueue BullMQ avec delay, retry 5x exponential, jobId = `relance:<taskId>` pour idempotency. Cancelle les tasks scheduled existantes avant de re-programmer (gestion changement de plan).
- cancelFutureRelances(invoiceId, trx) : appelé par mark-paid pour stopper la chaîne.

Service queue.ts :
- getQueue(name) singleton lazy par queue
- registerWorker(name, handler) avec concurrency 5, log failed/completed
- shutdownQueue() pour le terminating hook Adonis

start/queue.ts (preload) : registerWorker('relances', sendRelanceJob) seulement quand `app.getEnvironment() === 'web'` (pas en tests/REPL — pas de connexion Redis pendant Japa).

Job send_relance_job.ts :
- Idempotent : si task.status !== 'scheduled', no-op
- Hook critique : si invoice paid/cancelled entre-temps, task.status = cancelled
- Mise en demeure (step.requiresManualValidation) : on n'envoie PAS, on log un activity_event 'warning_drafted' (cf. CLAUDE.md → Principes : validation manuelle obligatoire)
- Sinon : sendRelanceEmail + task.status=sent + invoice.rubisEarned+1 + organizations.rubis_count+1 + activity_event 'relance_sent'. Si invoice.status='pending', passe en 'in_relance' (sortie de l'état silencieux).

Service mail_dispatcher.ts : sendRelanceEmail interpole step.subject/body via mini moteur Mustache-like (renderTemplate, services/template.ts) avec {{client.name}}/{{numero}}/{{amount}}/{{dueDate}}/{{signature}}, puis @adonisjs/mail.use(MAIL_DRIVER) → Mailpit en dev, Resend en prod. Texte brut V1.

Triggers branchés :
- InvoicesController.store : si planId, scheduleRelancesForInvoice après création
- ImportBatchesController.validateDraft : pareil
- InvoicesController.markPaid : cancelFutureRelances dans la même tx que le paiement

#jobs/* ajouté aux imports package.json. Adonisrc preload start/queue.ts.

Bruno : doc 05-Invoices/04 Create maj avec instructions pour tester l'envoi immédiat (dueDate dans le passé → relance à now+1min → email visible dans Mailpit http://localhost:8025).
2026-05-06 15:24:46 +02:00
ordinarthur
19dd71bd93 feat(api): MistralOcrProvider + multipart upload sur /invoices/upload
MistralOcrProvider (app/services/ocr/mistral_ocr_provider.ts) :
- Pipeline 2 étapes : POST /v1/ocr (mistral-ocr-latest) → markdown structuré, puis POST /v1/chat/completions (mistral-large-latest) avec response_format json_schema strict pour extraire les champs typés (clientName/Email, numero, amountTtcCents, issueDate, dueDate) + un objet `_conf` pour la confiance par champ.
- Télécharge le PDF depuis Drive (MinIO en dev) via getArrayBuffer, encode en base64 pour le data URI.
- Throw clair si storageKey null (incompatible avec le mode JSON {filenames}).
- Throw au constructor si MISTRAL_API_KEY manquante.

getOcrProvider() retourne maintenant vraiment Mistral quand OCR_PROVIDER=mistral (plus de fallback silencieux sur mock).

Multipart upload sur POST /invoices/upload :
- Détecte Content-Type. Si multipart/form-data : itère sur `files[]`, valide ext (pdf/png/jpg/jpeg) + size (10mb), upload chaque fichier vers `import-drafts/<orgId>/<draftId>.<ext>` via @adonisjs/drive, puis appelle createImportBatch avec sources [{filename, storageKey}].
- Si JSON : route compat conservée pour le mode démo.

Refactor service import_batch :
- Nouvelle fonction createImportBatch(orgId, sources) générique
- createImportBatchFromFilenames() devient un wrapper compat (storageKey null)
- OCR exécuté HORS transaction (calls réseau Mistral lents — 3-8s par PDF — pas de raison de tenir un lock PG)

Bruno :
- 06-Imports/02 Upload (multipart Mistral).bru — nouveau, body multipart-form avec @file() à sélectionner. Doc : setup .env, where to find files in MinIO console, latence Mistral.
- Renumérote 03/04/05/06 (Get batch / Validate / Skip / Cancel).
- Met à jour 01 Upload (mock) doc pour pointer vers 02 pour le vrai OCR.

Pour tester :
1. .env → OCR_PROVIDER=mistral + MISTRAL_API_KEY=...
2. Restart pnpm dev:api
3. Bruno → Imports → 02 Upload (multipart Mistral) → sélectionne un PDF
4. Bruno → Imports → 03 Get batch (drafts ont pdfStorageKey + extracted depuis l'OCR)
2026-05-06 15:17:11 +02:00
ordinarthur
57e1d0d0be update frontend ( tarpin bo ) 2026-05-06 15:15:07 +02:00
ordinarthur
704f472729 feat(api): dashboard kpis + activity feed + top-late + ActivityEvent
Migration activity_events (uuid id, organization_id FK CASCADE, kind ENUM PG natif relance_sent/invoice_paid/invoice_imported/warning_drafted, at, label HTML léger, meta jsonb). Append-only — pas de mutation. Index (org, at).

Schema rules : kind typé en union + meta typé { invoiceId?, clientId?, planStepOrder? }.

Service activity_recorder.ts : recordActivity({orgId, kind, label, meta, trx?}). Branché dans :
- InvoicesController.markPaid → invoice_paid
- ImportBatchesController.validateDraft → invoice_imported
À venir : SendRelanceJob (relance_sent + warning_drafted) quand BullMQ sera là.

Service dashboard.ts :
- computeKpis(orgId) : 1 requête FILTER pour les counts par status + 1 requête pour les sommes paid this month / prev month / DSO. miseEnDemeurePending=0 et percentile=undefined V1 (placeholders honnêtes plutôt que faux chiffres).
- topLatePayers(orgId, 5) : INNER JOIN clients + agrégation count() par client_id, due_date < today + status actif.

Controller DashboardController :
- GET /dashboard/kpis : computeKpis
- GET /dashboard/activity : 20 derniers events de l'org, plus récent en tête
- GET /dashboard/top-late : top 5

Routes /api/v1/dashboard/* (auth requise).

Bruno : nouveau dossier 07-Dashboard avec 3 requêtes documentées.

Pour générer du contenu activity feed : encaisser une facture (Invoices → Mark paid) ou valider un draft (Imports → Validate). KPIs : créer des factures puis les marquer payées (paidAt rentre dans les sommes).
2026-05-06 15:10:58 +02:00
ordinarthur
5d3408fafa feat(api): refresh tokens custom (cookie httpOnly + rotation panic-mode)
Pattern hybride (cf. backend.md §7) : access token Bearer 30min en JSON + refresh token 30j en cookie httpOnly `rubis_refresh` géré custom au-dessus d'@adonisjs/auth qui ne ship pas de primitive refresh.

Migration refresh_tokens (uuid id, user_id FK CASCADE, hashed_token unique [SHA-256, 64 chars hex], expires_at, last_used_at nullable, revoked_at nullable, ip_address, user_agent). Index user_id + expires_at.

Service refresh_token.ts :
- issueRefreshToken(user, ctx) : génère 32 bytes random → base64url → hash SHA-256 stocké, plain dans le cookie httpOnly + secure (en prod) + sameSite strict + path=/api/v1/auth (le browser n'envoie le cookie que sur les routes auth, pas chaque requête API).
- consumeRefreshToken(ctx) : lookup par hash, validation expiry/revoked. Si on présente un token DÉJÀ révoqué, panic mode : tous les refresh tokens actifs du user sont invalidés (signal de vol — le vrai propriétaire devra se re-logger).
- revokeCurrentRefreshToken / revokeAllForUser pour logout et le panic.

Service auth_session.ts : factorise emitAuthSession(user, ctx) qui crée access + refresh + retourne l'AuthSession. Utilisé par signup / login / refresh — DRY.

Controllers :
- POST /auth/signup : emitAuthSession après tx (org + plans + user).
- POST /auth/login : emitAuthSession après verifyCredentials.
- POST /auth/refresh (nouveau) : consumeRefreshToken → emitAuthSession. Rotation : l'ancien token devient révoqué, le nouveau est posé. SPA-side : appelé au boot pour rehydrater + après 401 silencieux.
- POST /account/logout : User.accessTokens.delete + revokeCurrentRefreshToken + clearCookie.

CORS a déjà credentials: true → le cookie traverse cross-origin si origin allowed.

Bruno : nouvelle requête `Auth/04 Refresh.bru` + folder doc + flow décrit dans README. Bruno honore la cookie jar nativement, donc aucun setup additionnel pour tester.

⚠️ Le contrôleur Refresh est nouveau → le registre Tuyau-généré .adonisjs/server/controllers.ts sera régénéré au prochain `pnpm dev:api` (la regen est un effet de bord du boot Adonis, on ne peut pas la déclencher seule). Avant ce premier boot, `pnpm typecheck` échouera sur l'absence de `controllers.Refresh` dans le registre.
2026-05-06 15:05:06 +02:00
ordinarthur
c7714e3e8a feat(api): import OCR (batch + drafts) avec MockOcrProvider
Migrations :
- import_batches (uuid id, organization_id FK CASCADE)
- import_drafts (uuid id, batch_id FK CASCADE, filename, pdf_storage_key nullable, extracted/edited/confidence en jsonb, status ENUM PG natif pending/validated/skipped, invoice_id FK SET NULL)

Schema rules : tape précisément extracted/edited/confidence (sinon `any`) + status enum.

Services :
- OcrProvider : interface (storageKey + filename → champs avec confiance par champ)
- MockOcrProvider : génère des champs plausibles depuis le filename (numero parsed via regex, montants random multiples de 50cts, dates ISO décalées) + 30 % de cas avec emails à confiance basse pour simuler la review UX
- getOcrProvider() : sélectionne via OCR_PROVIDER env var (default mock, mistral en attente d'ADR-020)
- createImportBatchFromFilenames : compose extracted/edited/confidence par draft, tente un match client immédiat (case-insensitive sur le nom) pour pré-remplir clientId
- resolveClient extrait dans un service partagé (3 priorités : clientId → match nom → création + email requis), réutilisé par invoices_controller et import_batches_controller

Endpoints (auth + scope par organization) :
- POST /invoices/upload : V1 mock body { filenames[] }, 201 → ImportBatch avec ses drafts. Multipart upload réel quand Mistral arrivera, contrat de réponse identique.
- GET /invoices/import-batch/:id : poll pendant la review
- POST /invoices/import-batch/:id/drafts/:draftId/validate : crée Invoice (résolution client) + draft.status=validated + draft.invoiceId
- POST .../drafts/:draftId/skip : draft.status=skipped (idempotent)
- DELETE /invoices/import-batch/:id : CASCADE drop drafts, les invoices validées restent

Routes : ordre soigné — /upload, /counts, /import-batch/* AVANT /:id pour éviter le shadowing.

Bruno : nouveau dossier 06-Imports avec 5 requêtes documentées + capture batchId/draftId dans l'env local. README mis à jour avec le parcours étendu (étapes 11-13).
2026-05-06 14:51:37 +02:00
ordinarthur
27cfa9ac13 docs(bruno): collection complète des routes API + environnement local
Collection Bruno (.bru text files, comme Postman mais file-based versionable) qui couvre l'API V1 actuelle. Open Collection → bruno/ → sélectionner l'environnement "local".

Domaines couverts (22 requêtes) :
- 00-Auth : Signup, Login, Logout
- 01-Account : Get/Update profile
- 02-Organizations : Get/Update my org
- 03-Clients : List, List+stats, Search, Create, Create duplicate (409), Create without email (422), Get detail, Update
- 04-Plans : List, Get by slug, Update (steps remplacés)
- 05-Invoices : List, List+filters, Counts, Create, Get detail, Mark paid

Environnement local (bruno/environments/local.bru) :
- baseUrl, email/password/fullName en dur
- token, userId, organizationId, clientId, invoiceId remplis automatiquement par les script:post-response

Chaque requête a :
- assertions Chai (statut, shape de la réponse)
- bloc docs avec sémantique métier + erreurs typiques
- inheritance auth Bearer via folder.bru pour ne pas répéter le header

Mise à jour de docs/tech/dev-setup.md pour pointer vers la collection.

Le parcours recommandé Signup → Update org → Create client → Create invoice → Mark paid couvre le happy path et permet de checker rubisCount qui s'incrémente.
2026-05-06 14:40:55 +02:00
ordinarthur
005af557c2 feat(api): domaine Invoice + endpoints CRUD + branche stats Client/Plan
Migration invoices : uuid id, organization_id FK CASCADE, client_id FK RESTRICT (on n'efface pas les factures si l'utilisateur supprime un client par erreur — audit/comptable), plan_id FK SET NULL, numero, amount_ttc_cents (int, jamais float), issue_date, due_date, status ENUM PG natif (pending/awaiting_user_confirmation/in_relance/paid/litigation/cancelled), pdf_storage_key, notes, rubis_earned, paid_at. Indexes (org,status), (org,client_id), (org,due_date), unique (org,numero).

Modèles : Invoice avec belongsTo Organization/Client/Plan. Client et Plan étendus avec hasMany Invoice maintenant que la table existe.

Endpoints :
- GET /invoices : filtres status/q/clientId/page, tri actionnable (awaiting_user_confirmation puis in_relance puis pending puis litigation puis paid puis cancelled), pagination simple 50/page (cursor-based en V2).
- GET /invoices/counts : compteurs par statut pour les chips dashboard, requête agrégée groupBy.
- GET /invoices/:id : détail enrichi avec client + plan préchargés + timeline composée par buildTimeline() (étapes du plan calées sur due_date, états past/current/future).
- POST /invoices : saisie manuelle. Résolution client en 3 étapes (clientId → match par nom → création à la volée avec email REQUIS, sinon 422 client_email_required). Bonus +1 rubis à la création.
- POST /invoices/:id/mark-paid : status=paid + paid_at + bonus +1 rubis (sur invoice + sur organization.rubis_count). Idempotent.

L'ordre des routes /invoices/counts AVANT /invoices/:id est critique sinon `:id` matche "counts".

Branche les vraies stats :
- ClientStats : agrégation PG une seule requête (count, count actives, count en retard, paid_count, sum paid_cents, sum pending_cents, last_activity) avec FILTER clauses et casting enum::text. Plus de TODO/zéros.
- PlansController : usageCount calculé pareil (factures actives référençant le plan).

Skip pour l'instant (ImportBatch domain à venir) : POST /invoices/upload, GET /invoices/import-batch/*, validate/skip drafts.
2026-05-06 14:33:46 +02:00
ordinarthur
692b514fe9 feat(api): domaine Plan + PlanStep + provisioning des 4 plans pré-fournis
Migrations :
- plans (uuid id, organization_id FK CASCADE, slug nullable, name, description, is_default). Unique (organization_id, slug) — un slug max par org.
- plan_steps (uuid id, plan_id FK CASCADE, order, offset_days, tone ENUM PG natif, subject, body, requires_manual_validation).

Schema rules : override du tone (introspection PG → 'any', on précise l'union).

Modèles Plan (belongsTo Organization, hasMany PlanStep) et PlanStep (belongsTo Plan).

Décision : plans dupliqués par organisation au signup (pas de table globale partagée). Permet l'édition isolée par org sans toucher aux templates des autres tenants. Le service `provisionDefaultPlans(orgId, trx)` est idempotent et appelé depuis NewAccountController dans la transaction de création.

Source de vérité des 4 plans (Standard B2B, Rapide, Patient, Ferme) dans app/services/default_plans.ts — alignée sur apps/web/src/mocks/seed.ts.

Endpoints :
- GET /plans : liste enrichie avec usageCount (à 0 tant qu'Invoice n'est pas câblé).
- GET /plans/:slug : détail (lookup par slug pour URL stable côté SPA).
- PATCH /plans/:slug : édition partielle. Les steps sont remplacés en bloc dans une transaction (pas de diff fin id-par-id, plus simple et prévisible).

POST plan custom = V2 (cf. backend.md §5.5).
2026-05-06 14:25:06 +02:00
ordinarthur
b6006ad1f7 feat(api): domaine Client + CRUD /api/v1/clients
Migration clients (uuid id, organization_id FK uuid CASCADE, name, email REQUIS, phone, address, siret, notes). Index sur organization_id.

Modèle Client avec belongsTo Organization. La relation hasMany Invoice est volontairement omise tant que le domaine Invoice n'est pas câblé.

Validators Vine alignés sur le contrat MSW :
- create : name 2-120, email requis avec format, siret 14 chiffres si fourni
- update : tout optionnel
- email REQUIS au create — pivot produit, pas de relance possible sans

Endpoints (auth requise, scopés par organizationId du user courant) :
- GET /clients?withStats=1&q= : liste filtrée + recherche, enrichissement stats optionnel, tri par actionnabilité (retards d'abord) quand withStats
- GET /clients/:id : détail (id en UUID via router.matchers.uuid())
- POST /clients : 201 + détection doublon par nom case-insensitive → 409 avec payload `existing` (le SPA peut proposer "voir le client existant")
- PATCH /clients/:id : merge partiel

Service ClientStats avec interface bulkComputeClientStats() qui retourne EMPTY pour l'instant — sera vraiment branché quand Invoice arrive. Le contrat reste stable côté SPA, juste les compteurs à 0.

Sérialisation : pour les listes avec stats per-item, on instancie le transformer manuellement (`new ClientTransformer(c).toObject()`) plutôt que de passer par BaseTransformer.transform() qui retourne un Item nested non-unwrappable hors clé directe de serialize().
2026-05-06 14:13:13 +02:00
ordinarthur
1d3b6a3f8f chore(api): UUID partout pour les PK et FK
Convention dure : tous les identifiants applicatifs sont des UUID v4 générés par PG (default gen_random_uuid()), aucun increments/serial même pour les tables techniques.

- CLAUDE.md → "Conventions techniques" : règle énoncée explicitement (anti-énumération, multi-tenant, génération côté client, dumps propres).
- docs/tech/backend.md §4.0 : exemple de migration + raisons.
- 4 migrations existantes réécrites en uuid (users, auth_access_tokens, organizations, alter users.organization_id). Les access tokens d'Adonis acceptent un tokenable_id uuid sans changement côté provider.
- Transformers nettoyés : plus de String(id), les UUID sont déjà des string.
- DB régénérée from scratch (migrations sont éditées avant tout déploiement, pas un cas où un autre dev a une DB en prod).
2026-05-06 13:58:11 +02:00
ordinarthur
eeb4ce25b8 feat(api): domaine Organization + endpoints /organizations/me
- Migrations 'organizations' (id, name, siret, monthly_volume_bucket, rubis_count, onboarding_completed_at) + alter users (organization_id FK + signature).
- Modèle Organization avec relation hasMany Users, User étendu avec belongsTo Organization.
- Signup transactionnel : crée une org vide ('') puis l'user, puis émet le access token. Le nom de l'org reste vide tant que l'utilisateur n'a pas franchi la première étape de l'onboarding (PATCH /organizations/me).
- Réponses /auth/* alignées sur le contrat SPA AuthSession : { data: { accessToken, expiresAt, user } }. Drop passwordConfirmation (le SPA n'envoie pas ce champ).
- Endpoints :
  - GET /account/profile (déjà), PATCH /account/profile (nouveau, fullName/email/signature).
  - GET /organizations/me + PATCH /organizations/me (name/siret/monthlyVolumeBucket).
- Pose automatique d'onboardingCompletedAt à la première mise en place du nom de l'org — remplace l'astuce 'signature !== null' utilisée côté MSW.
- Transformers convertissent les IDs en string (pour matcher packages/shared/src/types).
- HMR boundaries élargies : transformers/validators/services se rechargent maintenant à chaud (sinon les modifs ne sont pas vues sans restart manuel).
2026-05-06 13:51:47 +02:00
ordinarthur
274f2a8270 feat(api): install + configure bouncer, mail, limiter, drive, bullmq
Stack backend complète selon docs/tech/backend.md §2 :

- @adonisjs/bouncer : configure standard, middleware initialize_bouncer simplifié (API JSON-only, pas d'Edge views).
- @adonisjs/limiter : store Redis par défaut, throttler global défini dans start/limiter.ts.
- @adonisjs/mail : transports SMTP (Mailpit en dev) + Resend (prod).
- @adonisjs/drive : services fs (fallback) + S3 (MinIO en dev, prod plus tard).
- bullmq + ioredis : config queue.ts définit la connection Redis et la liste des queues (ocr, relances, checkins, kpis). Worker à câbler dans le commit suivant.
- @aws-sdk/client-s3 + s3-request-presigner pour le driver flydrive S3.

Pas de @rlanz/bull-queue : peer Adonis 6.5, plus maintenu — on consomme BullMQ directement.
2026-05-06 13:25:00 +02:00
ordinarthur
a8c7ab539a chore(dev): swap mailhog → mailpit (multi-arch, maintenu)
mailhog n'est plus maintenu et ne ship qu'en amd64 — sur Apple Silicon ça déclenche un warning Rosetta. Mailpit est le successeur drop-in (mêmes ports SMTP 1025 / UI 8025), multi-arch, activement maintenu.
2026-05-06 13:02:36 +02:00
ordinarthur
4a6c778e7c chore(api): docker-compose dev (PG/Redis/MinIO/Mailhog) + bascule sur Postgres
- docker-compose.dev.yml à la racine : PG 16, Redis 7, MinIO + bucket auto, Mailhog. Ports décalés (5433, 6380, 9100…) pour éviter les collisions locales.
- apps/api/config/database.ts : Postgres en default, SQLite reste accessible via DB_CONNECTION=sqlite.
- start/env.ts : validation des nouvelles vars (PG, Redis, S3, Mail, OCR, refresh tokens).
- .env.example complété, scripts pnpm dev:up/down/logs/reset à la racine.
- docs/tech/dev-setup.md pour expliquer la stack locale.
2026-05-06 12:57:42 +02:00
ordinarthur
c52f46468f docs: guide d'implémentation backend (docs/tech/backend.md)
Miroir de docs/tech/frontend.md, ancré sur :
- Le scaffold Adonis 7 déjà en place (apps/api/)
- Le contrat exact que le SPA consomme (handlers MSW =
  source de vérité du shape attendu)
- Les types/schemas dans packages/shared
- Les ADR 014 (stack), 015 (monorepo), 016 (PG), 017 (auth),
  018 (storage)

20 sections : vue d'ensemble, stack interne, repo layout,
domain models (Lucid), routes API par domaine, conventions
de réponse, auth Bearer + refresh httpOnly custom, Tuyau,
validation Vine, storage MinIO, OCR pipeline, email outbound,
background jobs (BullMQ), tests Japa, migrations + seeders,
variables d'env, Dockerfile + K3s deployment, pointeurs
vers l'existant, ADRs encore à trancher (019 à 025),
évolutions V2+.

Règle d'or rappelée plusieurs fois : avant de coder un
endpoint, regarder le handler MSW correspondant — le SPA
est déjà branché à cette surface, c'est exactement ce que
l'API doit servir.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 12:37:41 +02:00
ordinarthur
8cec9d2f33 feat(web): page /parametres complète (compte, entreprise, signature, danger)
Remplace le placeholder par 4 sections fonctionnelles, chacune avec son
form indépendant et son Save (blast radius clair : modifier sa signature
ne sauvegarde pas l'org).

Layout : sections verticales avec gap large, pas de tabs ni sidebar
interne en V1 (mono-utilisateur, peu de surface). Pattern type Linear /
Stripe : eyebrow + titre + description à gauche (280px), Card form à
droite (1fr). Empilé sur mobile.

Sections :

1. Compte — AccountForm : fullName + email. Synchronise authStore
   après save → topbar greeting / sidebar avatar se mettent à jour
   live. Save désactivé si form.state.isDirty=false.

2. Entreprise — OrganizationForm : nom + SIRET (14 chiffres) + chips
   volume mensuel (réutilise le pattern de l'onboarding step 2).
   Fetch GET /organizations/me, PATCH au save, setQueryData pour
   éviter un refetch.

3. Signature — SignatureForm : Textarea + aperçu live dans Card flat
   avec eyebrow + Sparkles (cohérent onboarding step 3). PATCH
   /account/profile avec field signature.

4. Zone danger — DangerZone, variant 'danger' sur SettingsSection
   (border rubis-deep/30 dashed + bg rubis-glow/20 — sobre, pas
   alarmiste). Logout fonctionnel (duplique UserMenu, c'est OK et
   attendu dans les paramètres). Suppression compte disabled
   (bientôt) avec mention 'RGPD article 17'.

Composants nouveaux :
- SettingsSection : pattern visuel commun, prop tone='default'|'danger'
- AccountForm, OrganizationForm, SignatureForm, DangerZone

MSW : ajout GET /api/v1/organizations/me (on n'avait que le PATCH).

Bundle prod : 116.21 KB gzip core (-1.76 KB grâce au tree-shaking
mutualisé des deps form).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 12:29:31 +02:00
ordinarthur
16120ed3e0 feat(web): création client (modale) + email required + SIRET optionnel
Réflexion produit : email required vs optionnel.

Le coeur de Rubis = relances email automatiques. Sans email client →
aucune relance ne peut partir → la fiche client est inutilisable pour
le coeur du produit. Décision : email REQUIRED partout, plutôt que
laisser créer des fiches mortes.

Type Client (packages/shared) :
- email: string (était string | null)
- siret: string | null ajouté (optionnel mais recommandé pour mises
  en demeure formelles + intégrations comptables V2 type Pennylane)

ClientCreateDialog (modale "+ Nouveau client" sur /clients) :
- Email required avec validator Zod min(1).email()
- SIRET ajouté côte-à-côte avec Téléphone (validator 14 chiffres
  ou vide, inputMode='numeric', espaces tolérés à la frappe)
- Adresse postale déplacée full-width (lisibilité)
- Hints éducatifs : 'Préférez compta@/facturation@ à une nominative',
  'Recommandé pour les mises en demeure', 'Requise pour les mises en
  demeure formelles'

Field component aligned :
- Label/hint en haut, input en bas (mt-auto sur le wrapper input)
- Quand 2 Fields sont côte-à-côte avec hints de longueur différente,
  les inputs restent alignés au bas — le hint plus long étire le haut
- Erreur reste collée sous l'input (pas en bas de la cellule)

MSW :
- POST /clients schema strict : email required, siret 14 chiffres si fourni
- Détection doublon par nom (409) conservée
- Handlers création de client implicites (saisie facture, OCR review)
  refusent maintenant la création quand email manquant : 422 ciblé
  'Email du client requis — Rubis en a besoin pour envoyer les relances.'
  Si l'user pick un client existant via le combobox → email déjà en
  DB, pas demandé.

Migration mockDb :
- Anciens clients sans siret → null
- Anciens clients avec email null (cas test) → placeholder dérivé du
  slug du nom (contact@boulangerie-martin.fr) — éditable, juste évite
  un crash au load. slugifyClientName() supprime SARL/SAS/EURL et accents.

Détail /clients/$id :
- SIRET ajouté dans la barre meta du header (Hash icon Lucide +
  tabular-nums) — affiché seulement si rempli
- Email plus conditionnel (toujours présent maintenant)

Seeds :
- Boulangerie Martin SARL : SIRET 82345678900012
- Cabinet Rousseau : SIRET 53412987600028
- Atelier Durand, Garage Lemoine, Studio Lefèvre : siret null
  (pour tester les deux cas dans la liste)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 12:25:37 +02:00
ordinarthur
f34cc97327 feat(web): /clients liste + détail + persistance session mock
Page /clients (liste) :
- Header dynamique : 'X factures en retard chez Y clients' en rubis-deep
  s'il y en a, sinon 'Tout est calme côté clients.'
- Recherche par nom/email (param q côté serveur, debounce naturel via
  TanStack Query staleTime: 10s)
- Table desktop / cards mobile (cohérent avec /factures)
- Tri serveur : retards d'abord (actionable), puis activité récente
- Empty state distincts (recherche vide vs jamais de clients)
- Lien depuis 'Voir tout' du panel TopLatePayers du dashboard fonctionne
- '+ Nouveau client' disabled (V2)

Page /clients/$id (détail) :
- Header : eyebrow contextuel, nom h1, infos contact (mail clickable,
  phone, address) avec icônes Lucide ink-3
- 4 KPI cards en grille : Factures actives (avec sub-info 'N en retard'
  rubis-deep si pertinent), En attente, Encaissé total, Factures payées
- Liste des factures du client (cliquables vers /factures/$id) avec
  StatusBadge sans icône (compact)
- Notes internes : Textarea avec autosave on blur via PATCH /clients/:id

MSW :
- GET /clients?withStats=1&q= : enrichit avec compteurs + montants +
  lastActivityAt. Tri par retards d'abord
- GET /clients/:id : détail enrichi + invoices triées plus récentes
- PATCH /clients/:id : édition Zod
- mockDb.updateClient(orgId, id, patch) ajouté

Persistance session mock (stay logged in après reload) :
- mocks/sessionStore.ts : helpers localStorage simulant le cookie
  httpOnly côté serveur. TTL 30j (= refresh token typique). SPA n'y
  accède jamais directement, seul MSW touche cette persistance.
- POST /auth/{login,signup} : sessionStore.set après succès
- POST /auth/logout : sessionStore.clear (clean disconnect)
- POST /auth/refresh : retourne la session stockée + recharge le user
  depuis mockDb au cas où il a été modifié (signature post-onboarding etc.)
- main.tsx : bootstrapSession() avant le 1er render (silent refresh).
  Évite le flash redirect /login pour les users déjà connectés.

Architecture : le SPA n'accède jamais directement à localStorage —
il passe toujours par HTTP (/auth/refresh). Quand on branchera le vrai
backend Adonis, on supprime juste mocks/sessionStore.ts et le pattern
continue à marcher (cookie httpOnly remplace localStorage côté serveur).

queryKeys.clients.list ajouté pour le param de recherche.

Bundle prod : 117.92 KB gzip core (stable +0.28 vs avant).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 12:06:32 +02:00
ordinarthur
6de2711aa8 feat(web): OCR review utilise ClientCombobox au lieu d'un Input libre
L'écran de review OCR avait un Input texte libre pour le nom du client,
ce qui faisait qu'on créait un nouveau client à chaque validation même
quand le nom matchait un client existant — doublons assurés.

Maintenant l'OCR fait le matching en amont :
- L'extraction côté MSW (fakeOcrExtract) cherche un client existant par
  nom case-insensitive et pré-remplit clientId dans extracted/edited.
  Confidence clientName = 1 quand match (vs 0.95 sinon).
- DraftFields type ajoute clientId: string | null
- draftFieldsSchema (validation) ajoute clientId nullable

Côté UI :
- L'Input clientName devient un ClientCombobox (le même que pour la
  saisie manuelle — chunk mutualisé 26 KB gzip)
- Border rubis quand un client existant est sélectionné
- Hint contextuel sur le Field :
  · clientId set → "Lié à un client existant ✓"
  · clientId null + nom ≥ 2 chars → "Nouveau client — sera créé à la validation."
  · Sinon → "Tapez pour rechercher ou créer un client."

Validate handler MSW (résolution client en cascade) :
1. clientId explicite (combobox) → utilise direct, zéro lookup
2. Match par nom case-insensitive sur les clients existants → utilise si match
3. Création à la volée si rien ne matche
Fallback création si clientId fourni mais introuvable.

Migration mockDb : les batches d'import seedés avant l'ajout du champ
sont patchés à load() avec clientId ?? null (spread des données stockées
d'abord pour ne pas écraser les snapshots récents).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:55:12 +02:00
ordinarthur
cfd3680bb4 feat(web): saisie manuelle de facture (modale Radix Dialog)
Modale 'Nouvelle facture' (cf. wireframe 2.3) accessible depuis 4 points :
- Topbar '+ Saisir' (était disabled)
- /factures/import bouton 'Saisir manuellement' (header)
- Dropzone empty state sur /factures (variant full)
- (Reachable de partout dans _app/* via le topbar)

Composants ajoutés :
- Dialog : wrapper Radix Dialog stylé (overlay ink/35 + blur, content
  bg-cream + border-line + shadow-card, close button discret, animations
  fade+zoom). Header / Title / Description / Footer / Close.
- ClientCombobox : autocomplete maison (pas Radix Combobox qui n'existe
  pas, pas cmdk overkill). Input + dropdown filtré, click-outside ferme,
  Escape ferme, option 'Créer le client « X »' quand pas de match exact.
  Border rubis quand un client existant est sélectionné.
- ManualInvoiceDialog : form complet (TanStack Form + validateurs Zod
  par champ). Client (combobox), N° + date émission (côte-à-côte), montant
  + échéance relative 15/30/45/60/90j (Select Radix), plan de relance.

Architecture clean :
- ManualInvoiceProvider au sommet d'AppLayout rend la modale une seule
  fois (un seul réseau de portals Radix)
- Hook useManualInvoice() expose open()/close()/isOpen, accessible
  depuis n'importe quelle route enfant sans plumber des callbacks
- État local de la modale (pas dans l'URL — propre pour V1)

Logique métier MSW :
- GET /api/v1/clients (autocomplete)
- POST /api/v1/invoices : résolution client (clientId fourni → utilise,
  sinon match par nom case-insensitive, sinon création à la volée).
  +1 rubis bonus saisie.
- Conversion relativeDueDays (15/30/45/60/90) → dueDate absolue à la
  soumission

Bug fix montant TTC :
- L'input était contrôlé avec value={(cents/100).toFixed(2)} → reformat
  à chaque keystroke écrasait '10000' en '1.00' (impossible de taper
  des gros montants)
- Passé en defaultValue (uncontrolled) avec step='any' + inputMode='decimal'
- Accepte virgule FR (1240,50) et point (1240.50)
- DialogContent unmount à la fermeture → defaultValue ré-évalué à
  chaque réouverture (reset OK)

Bouton '+ Saisir' du topbar plus disabled, bouton 'Saisir manuellement'
de /factures/import plus disabled. Le bouton dans la dropzone (variant
full) reçoit un onManualEntry prop optionnel.

Bundle prod : 117.62 KB gzip core (+0.06 KB), useManualInvoiceDialog
chunk 6.68 KB gzip, Select chunk 25.14 KB gzip (partagé OCR + plan
editor + manual entry).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:50:46 +02:00
ordinarthur
965a92da8f feat(web): /factures/import — page focused d'import via bouton topbar
Le bouton '+ Importer factures' du topbar avait un Button inerte. Il
ouvre maintenant une vraie page focused dédiée :

- Route /factures/import (factures_.import.tsx) avec breadcrumb,
  eyebrow, H1 'Importer *plusieurs* factures.', lede explicatif,
  dropzone full-page avec mutation upload câblée
- Drop-catcher de page comme sur /factures (drop n'importe où marche)
- 3 hints discrets en bas (Formats / Confidentiel / Reprenable) pour
  rassurer le user au moment décisif de l'upload

Routing nesting fix :
- Renommé factures_.import.\$batchId.tsx → factures_.import_.\$batchId.tsx
- Trailing underscore sur 'import_' escape la nouvelle landing parent
- Les 2 routes sont maintenant siblings sous _app :
  · /factures/import → factures_.import.tsx
  · /factures/import/\$batchId → factures_.import_.\$batchId.tsx

Topbar AppLayout :
- '+ Importer factures' = Button asChild + Link to /factures/import
  (middle-click / cmd-click / right-click ouvrent un nouvel onglet)
- '+ Saisir' reste disabled (placeholder modale 2.3, prochaine étape)

Bundle prod : 117.56 KB gzip core (stable, +0.06 vs avant).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:35:59 +02:00
ordinarthur
86dae64eb4 feat(web): import OCR — drop fichier → review batch → factures créées
Boucle import complète (cf. wireframe 2.2) :
1. Drop PDF/PNG/JPG sur /factures (dropzone full-page si vide, compact en
   bas si populée, OU drop n'importe où sur la page grâce au drop-catcher
   de route — évite que le browser ouvre le fichier dans un onglet)
2. POST /invoices/upload → MSW génère un batch avec drafts pré-remplis
   (OCR simulé : nom client aléatoire depuis 7 entreprises plausibles,
   montant random, dates calibrées, confidences variables) + délai 800ms
3. Toast "X factures extraites. Vérifions ensemble." + navigate vers
   /factures/import/$batchId
4. Page review step-by-step : PDF preview à gauche + form à droite,
   champs douteux (confidence < 0.7) surlignés border-rubis + hint
   inline, bandeau warning rubis-glow si plusieurs champs incertains
5. Valider & suivante → POST validate → crée la facture en mockDb
   (nouveau client si nom inconnu) + 1 rubis bonus → la suivante
   apparaît automatiquement
6. Skip ou Annuler le batch entier disponibles à tout moment
7. Fin de batch → toast bilan ("X validées · Y ignorées") → /factures

Composants ajoutés :
- PdfPreview : placeholder anti-générique (pas un viewer gris) — header
  mono filename + "page A4" simulée avec barres bg-ink/15 et bg-rubis-glow
- Select : wrapper Radix Select stylé (Trigger / Content / Item) cohérent
  avec Input (1px line, focus rubis-glow ring, item sélectionné rubis + ✓)

Dropzone amélioré :
- Filtre fichier plus tolérant : MIME OU extension (Finder/Explorer
  envoient parfois type === ""), erreur dédiée taille vs format
- Mode isUploading : titre devient "On lit vos factures…", spinner
  sur le bouton Parcourir

MSW handlers (invoices.ts) :
- POST /invoices/upload : crée batch + drafts avec OCR simulé
- GET /invoices/import-batch/:id
- POST /invoices/import-batch/:id/drafts/:draftId/validate
- POST /invoices/import-batch/:id/drafts/:draftId/skip
- DELETE /invoices/import-batch/:id

mockDb étendu :
- importBatches store + StoredImportDraft type
- createImportBatch / findImportBatch / updateImportDraft / deleteImportBatch
- createInvoice / createClient / listClientsForOrg

Bug fix migration :
- Le sessionStorage stockait des snapshots d'avant l'ajout du champ
  importBatches → db.importBatches undefined → push() crashait. Ajout
  d'une migration douce dans load() qui patche les champs manquants
  avec les défauts du seed (pas de perte de données existantes).

Bundle : 117.50 KB gzip core. Route factures_.import._batchId 10.26 KB
gzip — la plus grosse à cause de Radix Select + state form complexe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:26:31 +02:00
ordinarthur
b5b67056aa feat(web): plans bibliothèque + éditeur
Bibliothèque /plans (cf. wireframe 3.1) :
- Grid responsive 1/2/3 cols avec PlanCard + CreatePlanCard placeholder
- PlanCard : titre, chip meta (un seul à la fois), aperçu 3 étapes avec
  ◆ rotated comme bullet, footer usage + lien "Modifier →"
- Le plan le plus utilisé reçoit le badge "✦ Le plus utilisé" (rubis-glow
  + Sparkles), les autres gardent leur label de tonalité (Doux / Standard
  / Ferme / Strict). Pas de "PLAN PAR DÉFAUT" partout — info tautologique
  vu que les 4 plans seedés sont des défauts.
- Chips de tonalité adoucis (bg-cream-2 ou rubis-glow, plus de fills lourds)
- Skeleton pulsé pendant le fetch

Éditeur /plans/$slug (cf. wireframe 3.2, route _app/plans_.$slug pour
escape la layout parent) :
- Header : eyebrow humeur + nom + compteur d'usage + boutons Dupliquer
  (V2) / Enregistrer (fonctionnel, désactivé tant que pas de changements)
- Layout 2-col responsive (1fr / 1.4fr) :
  · Gauche : cadence — list de StepCard cliquables, état sélectionné avec
    border-rubis + shadow-rubis, "+ Ajouter une étape" disabled (V2)
  · Droite : éditeur d'email — Card avec subject (Input), body (Textarea
    mono 10 rows), grille de variables-chips
- Variable insertion fonctionnelle : clic = insertion au curseur via
  selectionStart/End du textarea, label FR + token mono ({{numero}})
- Bandeau warning rubis-glow quand l'étape est requiresManualValidation :
  "Validation manuelle obligatoire. L'email est généré en brouillon"
- Save fonctionnel : isDirty calculé via JSON.stringify, mutation PATCH
  /plans/:slug, invalidate cache plans.all + setQueryData detail, toast
- Sync state local ↔ serveur via useEffect sur plan.id+updatedAt

MSW :
- handlers/plans.ts : GET /plans (avec usageCount), GET /plans/:slug,
  PATCH /plans/:slug (validation Zod, recompose ids manquants)
- mockDb : findPlanBySlug, listPlansForOrg, updatePlan
- Calcul usageCount : factures du plan en statut != paid && != cancelled

Lib /plans.ts :
- TONE_LABELS : Amical / Standard / Ferme / Mise en demeure (FR)
- planMoodLabel + planOverallTone (humeur globale = ton de la dernière étape)
- TEMPLATE_VARIABLES : 5 variables avec token + label FR + preview

Bundle prod : 117.31 KB gzip core (stable). plans 2.06 KB gzip,
plans_._slug 3.28 KB gzip — la plus grosse route chunk vu sa complexité
(form + variables + state local).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:05:36 +02:00
ordinarthur
14d0e982e9 feat(web): _app shell + dashboard + factures liste & détail
Layout shell pour l'app authentifiée :
- routes/_app.tsx : pathless layout avec auth-guard + onboarding-guard
  (signature null → redirect onboarding/compte)
- AppLayout : grid sidebar + topbar + main + tab bar mobile
- AppSidebar (lg+) : nav verticale + mini compteur rubis en bas
- MobileTabBar : 4 onglets fixed bottom (Accueil, Factures, Plans, Réglages)
- AppTopbar : sticky bg-cream/85 + backdrop-blur, greeting + date sur desktop,
  brand sur mobile
- UserMenu : Radix Popover, avatar initiales rubis, logout mutation

Dashboard / (cf. wireframe 4.1) :
- RubisHero : ◆ 56px + drop-shadow rubis-tinted, "X rubis gagnés" en italic
  rubis sur "gagnés", verbalisation conversion en heures, progression mensuelle
- 4 KpiCard scannables : À relancer, En cours, Encaissé, DSO
  (delta en rubis-deep si intent positif, jamais de vert succès)
- ActivityFeed : journal du jour avec icônes Lucide tonalisées
- TopLatePayers : "Retards récurrents" (pas "mauvais payeurs", cf. marque)
- Quick actions mobile (+ Photo de facture / + Saisir)

Factures liste /factures (wireframe 2.4 + 2.1) :
- 3 états : 0 facture → dropzone full-page · filtre vide → mini-empty
  · populated → filter chips + table desktop / cards mobile
- FilterChips : sync URL (validateSearch zod), counts entre parenthèses
- InvoiceTable : ligne entière cliquable (onClick + role=link + onKeyDown),
  chevron Link séparé pour right-click "ouvrir nouvel onglet"
- InvoiceCardList : version mobile aérée
- StatusBadge : 6 statuts mappés palette marque (rubis solide pour "À valider",
  ink pour "En relance", crème+✓ pour "Encaissée")
- Skeleton pulsé pendant le fetch

Détail facture /factures/$id (wireframe 4.2) :
- Header : eyebrow client + numéro + montant + échéance + délai (J−4 rouge)
  + StatusBadge inline
- Actions : Marquer encaissée (mutation + bonus rubis + invalidate)
- Layout 2-col : Timeline (1.4fr) + sidepanel client/notes (1fr)
- Timeline primitive : pastilles passé/présent/futur (rubis-glow ✓ /
  rubis solide + Clock + ring glow / cercle vide)

Bug fix routing :
- factures.$id.tsx était nesté sous factures.tsx (flat naming TanStack Router)
  → la liste s'affichait à la place de la détail. Renommé factures_.$id.tsx
  pour escape le layout parent. URL inchangée (/factures/$id).

Placeholders soignés : /plans, /clients, /parametres avec EmptyState draft
(bordure pointillée + message qui assume "ça arrive").

MSW étendu :
- mocks/seed.ts : 5 clients, 4 plans avec étapes complètes (Standard B2B,
  Rapide, Patient, Ferme), 10 invoices avec statuses variés calibrés
  sur le wireframe
- handlers/dashboard.ts : GET /dashboard/{kpis,activity,top-late}
- handlers/invoices.ts : GET /invoices (filtres + tri par priorité statut),
  GET /invoices/counts, GET /invoices/:id (timeline calculée depuis le plan),
  POST /invoices/:id/mark-paid (passe en paid + bonus rubis)

Lib étendue :
- format : formatDueDelta (J+10, J−4 avec − typographique), isOverdue
- routes/index.tsx supprimé (remplacé par _app/index.tsx)

Bundle prod : 117 KB gzip core, chaque route en chunk dédié (dashboard
inline dans _app, factures 3.69 KB gzip, factures._id 2.22 KB gzip).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 10:49:06 +02:00
ordinarthur
332bf0bcda feat(web): /signup + 3-step onboarding flow
Nouvelles routes :
- /signup : inscription (fullName + email + password) → /onboarding/compte
- /onboarding : layout avec brand + stepper, auth-guard
- /onboarding/compte : étape 1 (nom + email, prefilled depuis la session)
- /onboarding/entreprise : étape 2 (nom, SIRET optionnel, chips volume)
- /onboarding/signature : étape 3 (signature email + aperçu live)

Nouvelles primitives UI :
- <Card variant="default|flat|hero" padding="sm|md|lg">
- <Stepper> wizard horizontal (current rubis, done rubis-glow + ✓, todo line)
- <Chip selected> : pastille pill, glow + deep quand sélectionnée (le rubis
  plein reste réservé aux CTA, cf. règle "le rubis est rare")
- <Textarea> : mêmes règles a11y/focus que <Input>

MSW handlers étendus :
- PATCH /api/v1/account/profile (fullName, email, signature)
- PATCH /api/v1/organizations/me (name, siret, monthlyVolumeBucket)
- mockDb : ajout des organizations, méthodes updateUser/updateOrg

Wiring :
- /login → "Créer un compte" pointe vers /signup (avant : loop)
- /login succès → /  (au lieu de /login)
- /  → /onboarding/compte si auth, /login sinon (placeholder dashboard)
- /onboarding/signature succès → /

Bundle prod : 113.87 KB gzip core (-2 KB grâce à MSW exclu en prod via
import.meta.env.DEV). Chaque route en chunk dédié (1-2 KB gzip).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 10:22:53 +02:00
ordinarthur
8d3bab6a89 feat: scaffold frontend monorepo + first /login screen
Monorepo Turborepo (pnpm workspaces) avec 3 packages :

- apps/web : SPA React 19 + Vite 8 + Tailwind v4 (CSS-first)
  • TanStack Router (file-based, auto code-splitting), Query, Form
  • Radix primitives bruts + CVA + clsx + tailwind-merge
  • MSW pour mocker l'API tant qu'Adonis n'est pas branché
  • Polices Bricolage Grotesque + Inter self-hostées via fontsource
  • Tokens marque (rubis, cream, ink) exposés via @theme
  • Primitives maison : Gem, Brand, Eyebrow, Button, Input, Field
  • Route /login full flow : TanStack Form + Zod + mutation Query

- apps/api : Adonis 7 (kit api, scaffold via create-adonisjs)
  • Auth access tokens (Bearer) — cf. ADR-017
  • Tuyau core déjà câblé pour la génération de types
  • Routes /api/v1/auth/{signup,login} + /api/v1/account/{profile,logout}
  • Minimal — uniquement le pont front ↔ back

- packages/shared : types TS + schemas Zod + constantes
  • Source unique de vérité partagée api ↔ web
  • Domaines : User, Org, Auth, Client, Invoice, Plan

Tooling racine : Turbo, ESLint v9 flat, Prettier, husky, lint-staged.

CLAUDE.md et docs/decisions.md mis à jour avec ADR-014 à ADR-018
(stack, monorepo, PG existant, Bearer tokens, MinIO existant)
et le pointeur vers docs/tech/architecture.md.

Logo Rubis déplacé de landing/assets/ vers /assets/ (source unique
réutilisée par la landing et l'app).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 10:10:48 +02:00
319 changed files with 38976 additions and 10 deletions

View File

@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(pnpm -F api typecheck)",
"Bash(pnpm -F @rubis/web typecheck)"
]
}
}

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

View File

@ -0,0 +1,90 @@
name: Build & Deploy App
# Workflow pour l'app SaaS (apps/api AdonisJS + apps/web React) déployée
# sur app.rubis.arthurbarre.fr. Image distincte de la landing.
on:
push:
branches: [main]
paths:
- 'apps/**'
- 'packages/**'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'package.json'
- 'turbo.json'
- 'Dockerfile.app'
- 'k3s/app/**'
- '.gitea/workflows/deploy-app.yml'
env:
REGISTRY: git.arthurbarre.fr
IMAGE: ordinarthur/rubis-app
NAMESPACE: rubis
DEPLOYMENT: rubis-app
CONTAINER: app
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ordinarthur
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push app image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.app
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE }}:cache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE }}:cache,mode=max
- name: Install kubectl
run: |
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
mv kubectl /usr/local/bin/
- name: Deploy to K3s
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
kubectl apply -f k3s/namespace.yml
# Idempotent : on (re)pose le pull secret du registry Gitea + le
# secret applicatif n'est PAS recréé ici (créé manuellement au
# premier deploy via kubectl, contient des creds qui ne
# transitent jamais par le CI).
kubectl -n $NAMESPACE create secret docker-registry gitea-registry \
--docker-server=$REGISTRY \
--docker-username=ordinarthur \
--docker-password=${{ secrets.REGISTRY_PASSWORD }} \
--dry-run=client -o yaml | kubectl apply -f -
# Apply Redis + app manifests (idempotent)
kubectl apply -f k3s/app/
# Pin l'image avec le sha du commit pour rolling update propre.
# Le init-container migrate utilise la même image et tourne avant
# le serveur — migrations idempotentes via ace migration:run.
kubectl -n $NAMESPACE set image deployment/$DEPLOYMENT \
$CONTAINER=$REGISTRY/$IMAGE:${{ github.sha }}
# Patch aussi le init container (même image)
kubectl -n $NAMESPACE patch deployment $DEPLOYMENT \
--type='json' \
-p="[{\"op\":\"replace\",\"path\":\"/spec/template/spec/initContainers/0/image\",\"value\":\"$REGISTRY/$IMAGE:${{ github.sha }}\"}]"
kubectl -n $NAMESPACE rollout status deployment/$DEPLOYMENT --timeout=300s

View File

@ -1,8 +1,17 @@
name: Build & Deploy
name: Build & Deploy Landing
# Workflow pour la landing static (rubis.arthurbarre.fr).
# L'app SaaS (apps/api + apps/web) a son propre workflow : deploy-app.yml.
on:
push:
branches: [main]
paths:
- 'landing/**'
- 'Dockerfile'
- 'k3s/namespace.yml'
- 'k3s/deployment.yml'
- 'k3s/service.yml'
- '.gitea/workflows/deploy.yml'
env:
REGISTRY: git.arthurbarre.fr

29
.gitignore vendored
View File

@ -1,4 +1,33 @@
.DS_Store
node_modules/
assets/test-invoices/
# Env files (never commit secrets)
.env
.env.local
.env.*.local
# Build artefacts
dist/
build/
*.tsbuildinfo
# Tooling caches
.turbo/
.cache/
coverage/
.eslintcache
# Adonis generated types (regenerated from API source)
apps/api/.adonisjs/
# Generated by TanStack Router
apps/web/src/routeTree.gen.ts
# Generated by MSW (vendored worker)
apps/web/public/mockServiceWorker.js
# Editor
.vscode/
.idea/
*.swp

4
.lintstagedrc.json Normal file
View File

@ -0,0 +1,4 @@
{
"*.{ts,tsx,js,jsx}": ["prettier --write", "eslint --fix"],
"*.{json,md,css,yml,yaml}": ["prettier --write"]
}

10
.prettierignore Normal file
View File

@ -0,0 +1,10 @@
node_modules
dist
build
.turbo
.adonisjs
coverage
pnpm-lock.yaml
landing/index.html
**/routeTree.gen.ts
**/*.gen.ts

11
.prettierrc Normal file
View File

@ -0,0 +1,11 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"bracketSpacing": true,
"endOfLine": "lf"
}

169
AGENTS.md Normal file
View File

@ -0,0 +1,169 @@
# Rubis Sur l'Ongle
> **Le SaaS de relance de factures impayées pour TPE-PME françaises.** Drag-and-drop, OCR, plans de relance automatiques. 1 rubis = 10 minutes libérées.
Ce fichier est le contexte top-level. Il est court, dense, scannable. Pour les détails, voir `/docs/`.
---
## En une phrase
Vos factures se relancent toutes seules pendant que vous travaillez.
## Cible
TPE-PME françaises, 5 à 50 salariés, qui émettent 10 à 200 factures par mois, sans crédit manager dédié. Le décideur teste lui-même le produit (pas de cycle de vente long).
## Promesse de valeur
- **5 heures par semaine récupérées** (benchmark : 8h → <3h après automatisation).
- **Tonalité émotionnelle** : on vend du temps libéré, pas de la trésorerie. Le rubis gagné est la métrique-héros, pas le DSO.
- **2 à 3 clics maximum** pour lancer une relance sur une nouvelle facture.
## Principes produit (toujours valides)
1. **3 clics maximum** pour lancer une relance sur une facture neuve. Idéalement 2 si bien configuré.
2. **Mobile et desktop** — la photo de facture depuis le téléphone est un usage clé.
3. **Pure-player relance** — on ne fait pas CRM, pas facturation, pas comptabilité. On fait une chose et on la fait bien.
4. **Respectueux du client final** — le ton monte avec le retard, jamais avant. Pas d'agressivité par défaut.
5. **Le rubis est une vraie devise produit** — 1 rubis = 10 min libérées. La gamification doit être tangible et défendable.
## Identité de marque (TLDR)
| | |
|---|---|
| **Logo** | Direction A — gem facetté géométrique. Le ◆ est un symbole produit autant qu'un logo. |
| **Couleur primaire** | `#9F1239` — rubis profond légèrement violacé. *Anti-Coca-Cola.* |
| **Couleur secondaires** | `#771328` (deep), `#C9415C` (light), `#FBE4EA` (glow) |
| **Neutres** | Crème `#FAF7F2`, encre chaude `#1A1410`. Jamais de blanc pur, jamais de noir pur. |
| **Typo display** | Bricolage Grotesque (500800), Google Fonts |
| **Typo body** | Inter (400700), Google Fonts |
| **Icônes** | Lucide (regular weight) |
| **Pas de** | or, bleu, vert, violet, emojis joaillerie 💎💰, mot "recouvrement" en com publique |
Voir `/docs/marque.md` pour la référence complète et `/brand-identity.html` pour la présentation visuelle (note : la mention de l'or accent dans ce fichier est obsolète, à ignorer).
## Voix
Direct, concret, chaleureux, précis, empathique. *On parle comme un bon associé, pas comme une DAF.*
- ✓ "Vos factures relancées toutes seules."
- ✗ "Optimisez votre processus de recouvrement amiable."
## Glossaire
- **Rubis** : unité de gamification. **1 rubis = 10 minutes libérées** = 1 relance qu'on n'a pas eu à faire à la main.
- **Plan de relance** : cadence d'emails automatisés (ex. J+3, J+10, J+20). Chaque facture est associée à un plan.
- **Étape** : un email programmé dans un plan (ex. "J+10 — relance ferme").
- **Check-in** : email envoyé **à l'utilisateur** (pas au client) pour confirmer si une facture a été payée avant l'envoi de la prochaine relance. Remplace l'intégration banking en V1.
- **Mise en demeure** : étape ferme du plan. **Toujours sous validation manuelle** via modale de confirmation, jamais auto.
- **DSO** : Days Sales Outstanding. Métrique secondaire dans l'app, jamais dans la com publique.
- **LME** : loi de modernisation de l'économie (2008). Plafonne les délais de paiement à 60 jours (ou 45 jours fin de mois). Sanctions DGCCRF jusqu'à 2 M€.
## Périmètre V1
### IN
- Auth email/password + Google SSO
- Onboarding 3 étapes (compte, entreprise, signature email)
- Upload drag-and-drop + OCR factures (PDF, PNG, JPG)
- Saisie manuelle (fallback)
- Bibliothèque de plans (4 plans fournis par défaut)
- Éditeur de plan (cadence + templates email avec variables)
- Check-in email à l'utilisateur (cadence configurable) → confirme si payé → relance ou stop
- Dashboard avec compteur rubis + KPIs (à relancer, encaissé, DSO)
- Liste filtrable des factures
- Détail facture avec timeline des relances
- App mobile (web responsive)
### OUT (V2 ou plus tard)
- **SMS** — uniquement plan le plus cher en V2
- **Multi-utilisateurs** — uniquement plans payants en V2
- **Intégration banking / réconciliation auto** — l'architecture V1 doit l'anticiper, mais l'implémentation est V2+
- Multi-langues, multi-devises (FR/EUR only en V1)
- Intégration ERP/comptable (Sage, Pennylane, Quickbooks)
## Pricing (esquisse, à valider)
| Plan | Prix | Limite |
|---|---|---|
| **Free** | 0 € | 5 factures actives en relance, 1 utilisateur |
| **Pro** | 19 €/mois | Factures illimitées, OCR illimité, 1 utilisateur |
| **Business** | 49 €/mois | + multi-utilisateurs, + branding email, + SMS (V2) |
Argument de vente : *"moins cher qu'une heure de votre temps mensuel"*.
## Décisions clés validées (résumé)
Voir `/docs/decisions.md` pour le log complet avec rationale.
- 1 rubis = 10 minutes libérées
- Logo direction A (gem facetté), wordmark à monter en parallèle plus tard
- Palette rubis chaude, sans or, sans bleu
- Typo Bricolage Grotesque + Inter
- Iconographie Lucide
- Mise en demeure : validation manuelle obligatoire (modale)
- SMS et multi-users : V2 + plans payants seulement
- Banking intégration : pas en V1, remplacée par check-in emails
## Stack technique
| Couche | Choix | Source |
|---|---|---|
| Backend | **AdonisJS v7** (TS, MVC, Lucid ORM, auth & jobs intégrés) | ADR-014 |
| Frontend | **React + Vite** | ADR-014 |
| Routing client | **TanStack Router** | ADR-014 |
| State serveur | **TanStack Query** | ADR-014 |
| Base de données | **PostgreSQL** | ADR-014 |
| Hosting | **Proxmox + K3s** (perso) | ADR-014 |
| OCR provider | à benchmarker | ADR-020 (en attente) |
| Email outbound | à benchmarker | ADR-021 (en attente) |
**Architecture** : monorepo (`apps/api` + `apps/web` + `packages/shared`), API REST AdonisJS Bearer-auth, SPA React/Vite séparé, PG et MinIO existants sur LXC Proxmox. Détails dans `/docs/tech/architecture.md`.
**Décisions cadres** : ADR-014 (stack), ADR-015 (monorepo), ADR-016 (PG Proxmox existant), ADR-017 (Bearer tokens), ADR-018 (MinIO existant).
### Conventions techniques (cross-cutting)
- **Identifiants : UUID partout.** Toutes les PK et FK applicatives sont des UUID v4 (PG `uuid` avec default `gen_random_uuid()`). **Jamais d'`increments`/`serial`**, même pour les tables internes (auth tokens, sessions, refresh tokens, etc.). Les UUID protègent de l'énumération, simplifient la fédération multi-tenant et évitent les fuites de volumes par incrément. Les transformers exposent les UUID directement en string — pas de cast nécessaire.
## Documents associés
| Fichier | Rôle |
|---|---|
| `/AGENTS.md` (ce fichier) | Contexte top-level, toujours en tête |
| `/landing/index.html` | Landing page brand-applied, déployée (waitlist V1) |
| `/landing/favicon.{svg,ico,png}` | Set complet de favicons + apple-touch-icon |
| `/landing/site.webmanifest` | Manifest PWA (theme `#9F1239`, background `#FAF7F2`) |
| `/landing/assets/logo.png` | Logo Rubis original (généré, source pour les favicons) |
| `/docs/produit.md` | Spec produit détaillée (features, flows, IN/OUT V1) |
| `/docs/marque.md` | Référence marque écrite (palette, typo, voix, do/don't) |
| `/docs/decisions.md` | Log de décisions avec rationale (format ADR-light) |
| `/docs/wireframes-mvp.html` | Wireframes low-fi des 13 écrans MVP |
| `/docs/brand-identity.html` | Présentation visuelle de la marque (4 logos, applications) |
| `/docs/munitions-marketing.md` | Stats marché, concurrents, positionnement, copy |
| `/docs/tech/architecture.md` | Architecture technique : composants, flux, topologie, conventions |
| `/Dockerfile` | Build nginx-alpine servant `/landing/` sur port 80 |
| `/k3s/` | Manifests Kubernetes (namespace, deployment, service) |
| `/.Codex/deploy-memory.md` | Procédure de déploiement (Gitea CI ou manuel) |
## Déploiement
- **Image** : `git.arthurbarre.fr/ordinarthur/rubis:latest`
- **Domaine actuel** (temporaire) : https://rubis.arthurbarre.fr
- **Build** : `COPY landing/` → nginx servi sur port 80
- Voir `.Codex/deploy-memory.md` pour la procédure complète.
## Questions ouvertes
- **Stack technique app produit** à formaliser (la landing tourne en static nginx, mais le SaaS lui-même reste à scoper)
- **Conversion 1 rubis = 10 min** validée mais à confirmer en user testing après MVP
- **Wordmark "rubis" avec gem-i** (direction C) à monter en complément du logo A à un moment
- **Provider OCR** à benchmarker (Mindee, Document AI, Textract, Tesseract)
- **Endpoint waitlist** à câbler dans `/landing/index.html` (Resend, Formspree, ou API perso)
- **Domaine définitif** à acheter (le sous-domaine actuel est temporaire)
---
*Dernière mise à jour : 2026-05-05 · Maintenu par Arthur + Codex.*

View File

@ -109,9 +109,24 @@ Voir `/docs/decisions.md` pour le log complet avec rationale.
## Stack technique
À confirmer avec Arthur. Stack choisie mais pas encore documentée. *À remplir lors de la prochaine session technique.*
| Couche | Choix | Source |
|---|---|---|
| Backend | **AdonisJS v7** (TS, MVC, Lucid ORM, auth & jobs intégrés) | ADR-014 |
| Frontend | **React + Vite** | ADR-014 |
| Routing client | **TanStack Router** | ADR-014 |
| State serveur | **TanStack Query** | ADR-014 |
| Base de données | **PostgreSQL** | ADR-014 |
| Hosting | **Proxmox + K3s** (perso) | ADR-014 |
| OCR provider | à benchmarker | ADR-020 (en attente) |
| Email outbound | à benchmarker | ADR-021 (en attente) |
Ce qu'on sait : TypeScript, le reste à formaliser (framework, DB, OCR provider, email provider, hosting, jobs).
**Architecture** : monorepo (`apps/api` + `apps/web` + `packages/shared`), API REST AdonisJS Bearer-auth, SPA React/Vite séparé, PG et MinIO existants sur LXC Proxmox. Détails dans `/docs/tech/architecture.md`.
**Décisions cadres** : ADR-014 (stack), ADR-015 (monorepo), ADR-016 (PG Proxmox existant), ADR-017 (Bearer tokens), ADR-018 (MinIO existant).
### Conventions techniques (cross-cutting)
- **Identifiants : UUID partout.** Toutes les PK et FK applicatives sont des UUID v4 (PG `uuid` avec default `gen_random_uuid()`). **Jamais d'`increments`/`serial`**, même pour les tables internes (auth tokens, sessions, refresh tokens, etc.). Les UUID protègent de l'énumération, simplifient la fédération multi-tenant et évitent les fuites de volumes par incrément. Les transformers exposent les UUID directement en string — pas de cast nécessaire.
## Documents associés
@ -128,6 +143,9 @@ Ce qu'on sait : TypeScript, le reste à formaliser (framework, DB, OCR provider,
| `/docs/wireframes-mvp.html` | Wireframes low-fi des 13 écrans MVP |
| `/docs/brand-identity.html` | Présentation visuelle de la marque (4 logos, applications) |
| `/docs/munitions-marketing.md` | Stats marché, concurrents, positionnement, copy |
| `/docs/marketing/playbook.md` | Playbook acquisition premiers clients : ICP, Dream 100, channels, templates outreach |
| `/docs/tech/architecture.md` | Architecture technique : composants, flux, topologie, conventions |
| `/docs/tech/frontend.md` | Guide d'implémentation frontend (deps, Tailwind, TanStack, Tuyau) |
| `/Dockerfile` | Build nginx-alpine servant `/landing/` sur port 80 |
| `/k3s/` | Manifests Kubernetes (namespace, deployment, service) |
| `/.claude/deploy-memory.md` | Procédure de déploiement (Gitea CI ou manuel) |

111
Dockerfile.app Normal file
View File

@ -0,0 +1,111 @@
# syntax=docker/dockerfile:1.7
# =============================================================================
# Rubis Sur l'Ongle — image production de l'app SaaS (apps/api + apps/web)
# Sert app.rubis.arthurbarre.fr. La landing (rubis.arthurbarre.fr) reste sur
# une image séparée — Dockerfile à la racine, nginx static.
# =============================================================================
#
# Multi-stage :
# - base : node 22 alpine + pnpm + tini
# - deps : install workspace deps (cache friendly via manifests d'abord)
# - build : build shared, web, api ; copie le SPA dans apps/api/build/public
# - runner : copie le repo "pruned" prod, lance node bin/server.js
#
# Choix architectural : un seul process Node sert l'API ET le SPA static
# (via le static middleware AdonisJS + un fallback wildcard pour SPA routing).
# Les workers BullMQ tournent dans le même process (cf. start/queue.ts).
# =============================================================================
ARG NODE_VERSION=22.13.1
ARG PNPM_VERSION=10.0.0
# -----------------------------------------------------------------------------
# base — node + pnpm + tini
# -----------------------------------------------------------------------------
FROM node:${NODE_VERSION}-alpine AS base
ARG PNPM_VERSION
RUN apk add --no-cache libc6-compat tini && \
corepack enable && \
corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /repo
# -----------------------------------------------------------------------------
# deps — install workspace (devDeps inclus, on en a besoin pour les builds)
# -----------------------------------------------------------------------------
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.base.json ./
COPY apps/api/package.json ./apps/api/
COPY apps/web/package.json ./apps/web/
COPY packages/shared/package.json ./packages/shared/
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# -----------------------------------------------------------------------------
# build — shared → web → api, puis copie du SPA dans le build de l'API
# -----------------------------------------------------------------------------
FROM deps AS build
COPY packages/shared ./packages/shared
COPY apps/web ./apps/web
COPY apps/api ./apps/api
# Builds :
# - @rubis/shared : pas de build (TS source consommé directement via exports).
# - Web : on appelle vite build directement (le `tsc -b` du script de prod
# fait remonter des erreurs DOM dans @tanstack/router-core sans cache
# .tsbuildinfo ; le typecheck est fait en CI séparément).
# - API : `node ace build` (canonique AdonisJS V7) — produit apps/api/build
# avec compiled JS, package.json runtime, et metaFiles configurés.
#
# Note : ce build peut planter en cross-compile ARM→amd64 (swc/core), donc
# en local sur Mac silicon, builder pour --platform linux/arm64. Le CI
# Gitea tourne nativement sur linux/amd64 et n'a pas le problème.
RUN pnpm --filter @rubis/web exec vite build && \
pnpm --filter @rubis/api build
# Le SPA static va dans apps/api/build/public/ pour être servi par le static
# middleware AdonisJS. AdonisJS ne copie pas public/ par défaut dans build/
# (metaFiles vide), on le fait manuellement ici.
RUN mkdir -p apps/api/build/public && \
cp -r apps/web/dist/. apps/api/build/public/
# Prune les devDeps. Les symlinks pnpm vers les workspace packages
# (@rubis/shared) restent valides car on garde le repo en place.
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --prod --frozen-lockfile=false
# -----------------------------------------------------------------------------
# runner — runtime minimal, user non-root
# -----------------------------------------------------------------------------
FROM base AS runner
RUN addgroup -g 1001 -S nodejs && adduser -S adonis -u 1001
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3333 \
LOG_LEVEL=info
WORKDIR /app
# On copie tout le repo pruned (node_modules inclus avec les symlinks
# workspace). C'est plus gros qu'une image "deploy" pure, mais ça évite
# les pièges de résolution workspace pour V1.
COPY --from=build --chown=adonis:nodejs /repo /app
USER adonis
WORKDIR /app/apps/api
EXPOSE 3333
# Healthcheck léger : le serveur HTTP doit répondre 200 sur /api/v1/.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:3333/ >/dev/null 2>&1 || exit 1
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "build/bin/server.js"]

22
apps/api/.editorconfig Normal file
View File

@ -0,0 +1,22 @@
# http://editorconfig.org
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.json]
insert_final_newline = unset
[**.min.js]
indent_style = unset
insert_final_newline = unset
[MakeFile]
indent_style = space
[*.md]
trim_trailing_whitespace = false

80
apps/api/.env.example Normal file
View File

@ -0,0 +1,80 @@
# Node
TZ=UTC
PORT=3333
HOST=0.0.0.0
NODE_ENV=development
# App
LOG_LEVEL=info
APP_KEY=
# APP_URL est l'URL publique (utilisée dans les emails check-in/relance,
# les redirects, etc.). Volontairement découplée de HOST : on bind sur
# 0.0.0.0 mais on expose `localhost` (en dev) ou le vrai domaine (en prod).
APP_URL=http://localhost:3333
# Session
SESSION_DRIVER=cookie
#--------------------------------------------------------------------
# CORS (configure allowed origins for API access)
#--------------------------------------------------------------------
# CORS_ORIGIN=http://localhost:5173,http://localhost:3000
#--------------------------------------------------------------------
# Database (Postgres via docker-compose.dev.yml)
#--------------------------------------------------------------------
DB_CONNECTION=postgres
PG_HOST=localhost
PG_PORT=5433
PG_USER=rubis
PG_PASSWORD=rubis
PG_DB_NAME=rubis_dev
#--------------------------------------------------------------------
# Redis (BullMQ + cache)
#--------------------------------------------------------------------
REDIS_HOST=localhost
REDIS_PORT=6380
REDIS_PASSWORD=
#--------------------------------------------------------------------
# Storage (MinIO via S3 driver)
#--------------------------------------------------------------------
DRIVE_DISK=s3
S3_ENDPOINT=http://localhost:9100
S3_REGION=fr-par
S3_BUCKET=rubis-invoices
S3_ACCESS_KEY=rubis
S3_SECRET_KEY=rubis-dev-secret
S3_FORCE_PATH_STYLE=true
#--------------------------------------------------------------------
# Mail (Resend par défaut, Mailpit en fallback dev via MAIL_DRIVER=smtp)
#--------------------------------------------------------------------
MAIL_FROM_ADDRESS=rubis@arthurbarre.fr
MAIL_FROM_NAME=Rubis Sur l'Ongle
MAIL_DRIVER=resend
RESEND_API_KEY=
# Fallback Mailpit (si MAIL_DRIVER=smtp)
SMTP_HOST=localhost
SMTP_PORT=1025
#--------------------------------------------------------------------
# OCR (Mistral)
#--------------------------------------------------------------------
OCR_PROVIDER=mistral
MISTRAL_API_KEY=
#--------------------------------------------------------------------
# Web (URL du SPA, utilisée pour les redirects post-checkin)
#--------------------------------------------------------------------
WEB_URL=http://localhost:5173
#--------------------------------------------------------------------
# Auth (refresh tokens)
#--------------------------------------------------------------------
ACCESS_TOKEN_TTL_MINUTES=30
REFRESH_TOKEN_TTL_DAYS=30
COOKIE_DOMAIN=
COOKIE_SECURE=false
LIMITER_STORE=redis

12
apps/api/.env.test Normal file
View File

@ -0,0 +1,12 @@
NODE_ENV=test
SESSION_DRIVER=memory
# Désactive les vraies connexions Redis/MinIO/SMTP pendant les tests.
# Les schedulers détectent NODE_ENV=test et skip BullMQ.add.
DRIVE_DISK=fs
MAIL_DRIVER=smtp
SMTP_HOST=localhost
SMTP_PORT=1025
OCR_PROVIDER=mock
# Utilise la même DB que dev avec global transactions par test (rollback).
# Si tu veux une DB séparée : crée `rubis_test` dans Postgres et override
# PG_DB_NAME=rubis_test ici.

26
apps/api/.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# Dependencies and AdonisJS build
node_modules
build
tmp/*
!tmp/.gitkeep
# Secrets
.env
.env.local
.env.production.local
.env.development.local
# Frontend assets compiled code
public/assets
# Build tools specific
npm-debug.log
yarn-error.log
# Editors specific
.fleet
.idea
.vscode
# Platform specific
.DS_Store

3
apps/api/.prettierignore Normal file
View File

@ -0,0 +1,3 @@
.adonisjs
node_modules
build

27
apps/api/ace.js Normal file
View File

@ -0,0 +1,27 @@
/*
|--------------------------------------------------------------------------
| JavaScript entrypoint for running ace commands
|--------------------------------------------------------------------------
|
| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD
| PROCESS.
|
| See docs.adonisjs.com/guides/typescript-build-process#creating-production-build
|
| Since, we cannot run TypeScript source code using "node" binary, we need
| a JavaScript entrypoint to run ace commands.
|
| This file registers the "ts-node/esm" hook with the Node.js module system
| and then imports the "bin/console.ts" file.
|
*/
/**
* Register hook to process TypeScript files using @poppinss/ts-exec
*/
import '@poppinss/ts-exec'
/**
* Import ace console entrypoint
*/
await import('./bin/console.js')

128
apps/api/adonisrc.ts Normal file
View File

@ -0,0 +1,128 @@
import { indexEntities } from '@adonisjs/core'
import { defineConfig } from '@adonisjs/core/app'
import { generateRegistry } from '@tuyau/core/hooks'
import { indexPolicies } from '@adonisjs/bouncer'
export default defineConfig({
/*
|--------------------------------------------------------------------------
| Experimental flags
|--------------------------------------------------------------------------
|
| The following features will be enabled by default in the next major release
| of AdonisJS. You can opt into them today to avoid any breaking changes
| during upgrade.
|
*/
experimental: {},
/*
|--------------------------------------------------------------------------
| Commands
|--------------------------------------------------------------------------
|
| List of ace commands to register from packages. The application commands
| will be scanned automatically from the "./commands" directory.
|
*/
commands: [
() => import('@adonisjs/core/commands'),
() => import('@adonisjs/lucid/commands'),
() => import('@adonisjs/session/commands'),
() => import('@adonisjs/bouncer/commands')
],
/*
|--------------------------------------------------------------------------
| Service providers
|--------------------------------------------------------------------------
|
| List of service providers to import and register when booting the
| application
|
*/
providers: [
() => import('@adonisjs/core/providers/app_provider'),
() => import('@adonisjs/core/providers/hash_provider'),
{
file: () => import('@adonisjs/core/providers/repl_provider'),
environment: ['repl', 'test'],
},
() => import('@adonisjs/core/providers/vinejs_provider'),
() => import('@adonisjs/session/session_provider'),
() => import('@adonisjs/shield/shield_provider'),
() => import('@adonisjs/lucid/database_provider'),
() => import('@adonisjs/cors/cors_provider'),
() => import('@adonisjs/auth/auth_provider'),
() => import('#providers/api_provider'),
() => import('@adonisjs/bouncer/bouncer_provider'),
() => import('@adonisjs/limiter/limiter_provider'),
() => import('@adonisjs/mail/mail_provider'),
() => import('@adonisjs/drive/drive_provider'),
() => import('@adonisjs/static/static_provider')
],
/*
|--------------------------------------------------------------------------
| Preloads
|--------------------------------------------------------------------------
|
| List of modules to import before starting the application.
|
*/
preloads: [
() => import('#start/routes'),
() => import('#start/kernel'),
() => import('#start/validator'),
() => import('#start/queue'),
],
/*
|--------------------------------------------------------------------------
| Tests
|--------------------------------------------------------------------------
|
| List of test suites to organize tests by their type. Feel free to remove
| and add additional suites.
|
*/
tests: {
suites: [
{
files: ['tests/unit/**/*.spec.{ts,js}'],
name: 'unit',
timeout: 2000,
},
{
files: ['tests/functional/**/*.spec.{ts,js}'],
name: 'functional',
timeout: 30000,
},
],
forceExit: false,
},
/*
|--------------------------------------------------------------------------
| Metafiles
|--------------------------------------------------------------------------
|
| A collection of files you want to copy to the build folder when creating
| the production build.
|
*/
metaFiles: [{
pattern: 'public/**',
reloadServer: false,
}],
hooks: {
init: [
indexEntities({
transformers: { enabled: true },
}),
generateRegistry(),
indexPolicies()
],
},
})

View File

@ -0,0 +1,23 @@
/*
|--------------------------------------------------------------------------
| Bouncer abilities
|--------------------------------------------------------------------------
|
| You may export multiple abilities from this file and pre-register them
| when creating the Bouncer instance.
|
| Pre-registered policies and abilities can be referenced as a string by their
| name. Also they are must if want to perform authorization inside Edge
| templates.
|
*/
import { Bouncer } from '@adonisjs/bouncer'
/**
* Delete the following ability to start from
* scratch
*/
export const editUser = Bouncer.ability(() => {
return true
})

View File

@ -0,0 +1,33 @@
import User from '#models/user'
import { loginValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http'
import { emitAuthSession } from '#services/auth_session'
import { revokeCurrentRefreshToken } from '#services/refresh_token'
export default class AccessTokensController {
/**
* POST /auth/login vérifie credentials + émet AuthSession.
*/
async store(ctx: HttpContext) {
const { email, password } = await ctx.request.validateUsing(loginValidator)
const user = await User.verifyCredentials(email, password)
const session = await emitAuthSession(user, ctx)
return ctx.serialize(session)
}
/**
* POST /account/logout révoque l'access token courant + le refresh
* token + clear le cookie.
*/
async destroy(ctx: HttpContext) {
const user = ctx.auth.getUserOrFail()
if (user.currentAccessToken) {
await User.accessTokens.delete(user, user.currentAccessToken.identifier)
}
await revokeCurrentRefreshToken(ctx)
ctx.response.status(204)
return null
}
}

View File

@ -0,0 +1,53 @@
import vine from '@vinejs/vine'
import { Exception } from '@adonisjs/core/exceptions'
import type { HttpContext } from '@adonisjs/core/http'
import { generateRelance } from '#services/ai_relance_generator'
const RELANCE_TONES = ['amical', 'courtois', 'ferme', 'mise_en_demeure'] as const
const generateRelanceValidator = vine.create({
tone: vine.enum(RELANCE_TONES),
offsetDays: vine.number().min(-30).max(180),
// Brief libre. On accepte vide : Mistral génère alors un message standard
// pour la tonalité + timing donnés.
prompt: vine.string().maxLength(1000).optional(),
// Contexte du plan parent — nom + description, pour cohérence inter-étapes.
planName: vine.string().maxLength(80).optional(),
planDescription: vine.string().maxLength(500).optional(),
})
/**
* Endpoints IA. V1 : uniquement génération de templates de relance pour le
* wizard de création de plan custom. Mistral est déjà utilisé pour l'OCR
* (cf. mistral_ocr_provider.ts) on réutilise la même clé API.
*/
export default class AiController {
/**
* POST /ai/generate-relance
*
* Génère subject + body avec des placeholders Mustache prêts à insérer.
* L'utilisateur peut régénérer pour avoir une variante.
*/
async generateRelance({ auth, request, response }: HttpContext) {
auth.getUserOrFail() // auth requise
const payload = await request.validateUsing(generateRelanceValidator)
try {
const result = await generateRelance({
tone: payload.tone,
offsetDays: payload.offsetDays,
prompt: payload.prompt ?? '',
planName: payload.planName,
planDescription: payload.planDescription,
})
return response.json({ data: result })
} catch (err) {
// On wrap pour passer par le handler global et garder le format
// d'erreur uniforme côté front.
throw new Exception(
err instanceof Error ? err.message : 'Génération IA indisponible',
{ status: 502, code: 'ai_generation_failed' }
)
}
}
}

View File

@ -0,0 +1,142 @@
import CheckinTask from '#models/checkin_task'
import Invoice from '#models/invoice'
import { hashCheckinToken } from '#services/checkin_token'
import { recordActivity } from '#services/activity_recorder'
import { cancelFutureRelances, scheduleRelancesForInvoice } from '#services/relance_scheduler'
import db from '@adonisjs/lucid/services/db'
import env from '#start/env'
import { DateTime } from 'luxon'
import type { HttpContext } from '@adonisjs/core/http'
const CHECKIN_TTL_HOURS = 24
/**
* Construit l'URL de redirect SPA selon le résultat. Le SPA lit ces
* query params pour afficher un toast et router l'utilisateur.
*/
function spaRedirectUrl(
result: 'paid' | 'pending' | 'expired' | 'invalid' | 'already_answered',
invoice?: Pick<Invoice, 'id' | 'numero'>
): string {
const base = env.get('WEB_URL', 'http://localhost:5173')
const params = new URLSearchParams({ checkin: result })
if (invoice) params.set('invoice', invoice.numero)
const path = invoice ? `/factures/${invoice.id}` : '/'
return `${base}${path}?${params.toString()}`
}
type ResolvedTask = { task: CheckinTask; invoice: Invoice } | { redirect: string }
/**
* Lookup + validation commune aux deux endpoints (paid / pending).
* Retourne soit la task validée soit une URL de redirect d'erreur.
*/
async function resolveCheckin(token: string): Promise<ResolvedTask> {
const hashed = hashCheckinToken(token)
const task = await CheckinTask.query().where('token_hash', hashed).first()
if (!task) {
return { redirect: spaRedirectUrl('invalid') }
}
if (task.status === 'answered') {
const inv = await Invoice.find(task.invoiceId)
return { redirect: spaRedirectUrl('already_answered', inv ?? undefined) }
}
// Expiration : 24h après l'envoi (sentAt). Tant qu'elle n'a pas été
// envoyée, le link n'est pas censé exister côté user — sécurité belt.
if (task.sentAt && task.sentAt.plus({ hours: CHECKIN_TTL_HOURS }) < DateTime.now()) {
task.status = 'expired'
await task.save()
return { redirect: spaRedirectUrl('expired') }
}
const invoice = await Invoice.query().where('id', task.invoiceId).preload('client').first()
if (!invoice) {
return { redirect: spaRedirectUrl('invalid') }
}
return { task, invoice }
}
export default class CheckinController {
/**
* GET /api/v1/checkin/:token/paid
*
* L'utilisateur clique "j'ai é payé". On marque la facture payée +
* cancel les relances futures + bonus rubis (idempotent avec mark-paid).
* Redirect SPA avec `?checkin=paid&invoice=<numero>`.
*
* Public : pas d'auth Bearer, c'est un lien dans un email.
*/
async respondPaid({ params, response }: HttpContext) {
const result = await resolveCheckin(params.token)
if ('redirect' in result) {
return response.redirect(result.redirect)
}
const { task, invoice } = result
await db.transaction(async (trx) => {
task.useTransaction(trx)
task.status = 'answered'
task.answer = 'paid'
task.answeredAt = DateTime.now()
await task.save()
// Mark paid (mêmes effets que POST /invoices/:id/mark-paid).
if (invoice.status !== 'paid') {
invoice.useTransaction(trx)
invoice.status = 'paid'
invoice.paidAt = DateTime.now()
invoice.rubisEarned = invoice.rubisEarned + 1
await invoice.save()
await trx
.from('organizations')
.where('id', invoice.organizationId)
.increment('rubis_count', 1)
await recordActivity({
organizationId: invoice.organizationId,
kind: 'invoice_paid',
label: `Facture <b>${invoice.numero}</b> marquée encaissée via check-in`,
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
trx,
})
await cancelFutureRelances(invoice.id, trx)
}
})
return response.redirect(spaRedirectUrl('paid', invoice))
}
/**
* GET /api/v1/checkin/:token/pending
*
* L'utilisateur clique "toujours en attente". On marque la task
* answered, puis on programme les relances client.
*/
async respondPending({ params, response }: HttpContext) {
const result = await resolveCheckin(params.token)
if ('redirect' in result) {
return response.redirect(result.redirect)
}
const { task, invoice } = result
await db.transaction(async (trx) => {
if (invoice.planId) {
invoice.useTransaction(trx)
await scheduleRelancesForInvoice(invoice, trx)
}
task.useTransaction(trx)
task.status = 'answered'
task.answer = 'still_pending'
task.answeredAt = DateTime.now()
await task.save()
})
return response.redirect(spaRedirectUrl('pending', invoice))
}
}

View File

@ -0,0 +1,172 @@
import Client from '#models/client'
import ClientTransformer from '#transformers/client_transformer'
import { createClientValidator, updateClientValidator } from '#validators/client'
import { bulkComputeClientStats } from '#services/client_stats'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
/**
* Petite cohérence d'identification orgnisation : si l'utilisateur
* n'en a pas, on est dans un état illégal V1 on bloque ferme.
*/
function requireOrgId(auth: HttpContext['auth']): string {
const user = auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
return user.organizationId
}
/**
* Sérialisation directe (instanciation manuelle du transformer pour
* éviter le wrapper Item utile quand on doit fusionner des stats
* computed par-dessus chaque client dans une liste).
*/
function serializeClient(c: Client) {
return new ClientTransformer(c).toObject()
}
export default class ClientsController {
/**
* GET /clients?withStats=1&q=
*
* Sans `withStats`, retour à plat (utilisé par le combobox de saisie).
* Avec `withStats`, chaque client est enrichi des compteurs de factures
* et trié par actionnabilité (retards d'abord, puis activité récente).
*/
async index({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const withStats = request.input('withStats') === '1'
const q = (request.input('q') ?? '').toString().trim().toLowerCase()
const query = Client.query().where('organization_id', organizationId)
if (q.length > 0) {
query.where((b) => {
b.whereILike('name', `%${q}%`).orWhereILike('email', `%${q}%`)
})
}
const clients = await query.exec()
if (!withStats) {
// Tri alphabétique par défaut pour le combobox.
clients.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
return response.json({ data: clients.map(serializeClient) })
}
const statsMap = await bulkComputeClientStats(
organizationId,
clients.map((c) => c.id)
)
const enriched = clients.map((c) => ({
...serializeClient(c),
...statsMap.get(c.id)!,
}))
// Tri actionnable : retards d'abord, puis activité récente.
enriched.sort((a, b) => {
if (a.lateInvoiceCount !== b.lateInvoiceCount) {
return b.lateInvoiceCount - a.lateInvoiceCount
}
const aLast = a.lastActivityAt ?? ''
const bLast = b.lastActivityAt ?? ''
return bLast.localeCompare(aLast)
})
return response.json({ data: enriched })
}
/**
* GET /clients/:id détail enrichi (stats + invoices à venir).
*/
async show({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const client = await Client.query()
.where('organization_id', organizationId)
.where('id', params.id)
.first()
if (!client) {
throw new Exception('Client introuvable', { status: 404, code: 'not_found' })
}
const statsMap = await bulkComputeClientStats(organizationId, [client.id])
const stats = statsMap.get(client.id)!
return response.json({
data: {
...serializeClient(client),
...stats,
invoices: [], // TODO: brancher quand le domaine Invoice arrive
},
})
}
/**
* POST /clients création manuelle.
* Détecte les doublons de nom (case-insensitive) et renvoie 409 avec
* la fiche existante pour permettre au SPA de proposer "voir le client".
*/
async store({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(createClientValidator)
// Doublon → 409 (cf. clients.ts MSW pour le contrat exact).
const existing = await Client.query()
.where('organization_id', organizationId)
.whereILike('name', payload.name)
.first()
if (existing) {
return response.status(409).json({
errors: [
{
code: 'duplicate_client',
message: `Un client nommé « ${existing.name} » existe déjà.`,
field: 'name',
},
],
existing: serializeClient(existing),
})
}
const created = await Client.create({
organizationId,
name: payload.name,
email: payload.email,
contactFirstName: payload.contactFirstName ?? null,
contactLastName: payload.contactLastName ?? null,
phone: payload.phone ?? null,
address: payload.address ?? null,
siret: payload.siret ?? null,
notes: payload.notes ?? null,
})
return response.status(201).json({ data: serializeClient(created) })
}
/**
* PATCH /clients/:id édition partielle.
*/
async update({ auth, request, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(updateClientValidator)
const client = await Client.query()
.where('organization_id', organizationId)
.where('id', params.id)
.first()
if (!client) {
throw new Exception('Client introuvable', { status: 404, code: 'not_found' })
}
client.merge(payload)
await client.save()
return response.json({ data: serializeClient(client) })
}
}

View File

@ -0,0 +1,65 @@
import ActivityEvent from '#models/activity_event'
import { computeKpis, topLatePayers } from '#services/dashboard'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
const ACTIVITY_DEFAULT_LIMIT = 20
function requireOrgId(auth: HttpContext['auth']): string {
const user = auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
return user.organizationId
}
export default class DashboardController {
/**
* GET /dashboard/kpis
*
* Cf. service dashboard.ts quelques metrics V1 sont placeholder
* (miseEnDemeurePending=0 tant que RelanceTask pas branché, percentile
* undefined tant que cohorte trop petite).
*/
async kpis({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const data = await computeKpis(organizationId)
return response.json({ data })
}
/**
* GET /dashboard/activity
*
* Journal append-only. Limit 20 par défaut, plus récent en tête.
*/
async activity({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const events = await ActivityEvent.query()
.where('organization_id', organizationId)
.orderBy('at', 'desc')
.limit(ACTIVITY_DEFAULT_LIMIT)
return response.json({
data: events.map((e) => ({
id: e.id,
kind: e.kind,
at: e.at.toISO()!,
label: e.label,
meta: e.meta,
})),
})
}
/**
* GET /dashboard/top-late
*
* Top 5 clients avec le plus de factures en retard (status actif +
* due_date dépassée).
*/
async topLate({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const data = await topLatePayers(organizationId)
return response.json({ data })
}
}

View File

@ -0,0 +1,267 @@
import ImportBatch from '#models/import_batch'
import Invoice from '#models/invoice'
import Plan from '#models/plan'
import ImportBatchTransformer, { serializeDraft } from '#transformers/import_batch_transformer'
import InvoiceTransformer from '#transformers/invoice_transformer'
import { uploadValidator, validateDraftValidator } from '#validators/import_batch'
import { resolveClient } from '#services/resolve_client'
import {
createImportBatch,
createImportBatchFromFilenames,
type ImportSource,
} from '#services/import_batch'
import { recordActivity } from '#services/activity_recorder'
import { scheduleCheckinForInvoice } from '#services/checkin_scheduler'
import logger from '@adonisjs/core/services/logger'
import drive from '@adonisjs/drive/services/main'
import { createReadStream } from 'node:fs'
import { randomUUID } from 'node:crypto'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon'
function requireOrgId(auth: HttpContext['auth']): string {
const user = auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
return user.organizationId
}
function serializeBatch(b: ImportBatch) {
return new ImportBatchTransformer(b).toObject()
}
async function loadBatchOrFail(organizationId: string, id: string): Promise<ImportBatch> {
const batch = await ImportBatch.query()
.where('organization_id', organizationId)
.where('id', id)
.preload('drafts', (q) => q.orderBy('created_at', 'asc'))
.first()
if (!batch) {
throw new Exception('Batch introuvable', { status: 404, code: 'not_found' })
}
return batch
}
export default class ImportBatchesController {
/**
* POST /invoices/upload démarre un batch OCR.
*
* Deux modes selon Content-Type :
* - **multipart/form-data** : champ `files[]` avec les vrais PDFs.
* Stockage MinIO + OCR (mock OU mistral selon OCR_PROVIDER).
* - **application/json** : `{ filenames: string[] }` (V1 démo).
* Aucun fichier stocké ne marche QU'AVEC OCR_PROVIDER=mock.
*/
async upload(ctx: HttpContext) {
const { auth, request, response } = ctx
const organizationId = requireOrgId(auth)
const isMultipart = (request.header('content-type') ?? '').startsWith('multipart/')
if (isMultipart) {
const files = request.files('files', {
size: '10mb',
extnames: ['pdf', 'png', 'jpg', 'jpeg'],
})
if (files.length === 0) {
return response.status(422).json({
errors: [
{ code: 'validation_failed', field: 'files', message: 'Au moins un fichier requis' },
],
})
}
// Upload vers Drive (MinIO) AVANT l'OCR — l'OCR Mistral télécharge
// depuis Drive donc il faut que le fichier soit déjà posé.
// Clé : import-drafts/<orgId>/<draftId>.<ext> — pas de batchId
// dans la clé car le batch est créé après.
const sources: ImportSource[] = []
for (const f of files) {
if (!f.isValid || !f.tmpPath || !f.extname) {
return response.status(422).json({
errors: [
{
code: 'validation_failed',
field: 'files',
message: f.errors?.[0]?.message ?? 'Fichier invalide',
},
],
})
}
const draftKey = randomUUID()
const storageKey = `import-drafts/${organizationId}/${draftKey}.${f.extname}`
await drive.use().putStream(storageKey, createReadStream(f.tmpPath))
sources.push({
filename: f.clientName ?? `${draftKey}.${f.extname}`,
storageKey,
})
}
const batch = await createImportBatch(organizationId, sources)
return response.status(201).json({ data: serializeBatch(batch) })
}
// Mode JSON — compat V1 démo.
const { filenames } = await request.validateUsing(uploadValidator)
const batch = await createImportBatchFromFilenames(organizationId, filenames)
return response.status(201).json({ data: serializeBatch(batch) })
}
/**
* GET /invoices/import-batch/:id état courant d'un batch.
* Le SPA poll cet endpoint pendant la review (drafts pending validated/skipped).
*/
async show({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const batch = await loadBatchOrFail(organizationId, params.id)
return response.json({ data: serializeBatch(batch) })
}
/**
* POST /invoices/import-batch/:id/drafts/:draftId/validate
*
* L'utilisateur valide un draft → on crée l'Invoice avec les champs
* éventuellement édités. Même logique de résolution client que POST
* /invoices (clientId match nom création + email requis).
*/
async validateDraft({ auth, params, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const fields = await request.validateUsing(validateDraftValidator)
const batch = await loadBatchOrFail(organizationId, params.id)
const draft = batch.drafts.find((d) => d.id === params.draftId)
if (!draft) {
throw new Exception('Brouillon introuvable', { status: 404, code: 'not_found' })
}
if (draft.status !== 'pending') {
throw new Exception('Brouillon déjà traité', {
status: 409,
code: 'draft_already_processed',
})
}
const invoice = await db.transaction(async (trx) => {
const result = await resolveClient(
organizationId,
{
clientId: fields.clientId,
clientName: fields.clientName,
clientEmail: fields.clientEmail,
},
trx
)
if ('errorCode' in result) {
throw new Exception(
'Email du client requis — Rubis en a besoin pour envoyer les relances.',
{ status: 422, code: result.errorCode }
)
}
const client = result.client
// Plan : si fourni, doit appartenir à l'org.
let planId: string | null = null
if (fields.planId) {
const plan = await Plan.query({ client: trx })
.where('organization_id', organizationId)
.where('id', fields.planId)
.first()
if (plan) planId = plan.id
}
const created = await Invoice.create(
{
organizationId,
clientId: client.id,
planId,
numero: fields.numero,
amountTtcCents: fields.amountTtcCents,
issueDate: DateTime.fromISO(fields.issueDate),
dueDate: DateTime.fromISO(fields.dueDate),
status: 'pending',
rubisEarned: 1, // bonus import OCR (cf. CLAUDE.md → glossaire)
pdfStorageKey: draft.pdfStorageKey,
notes: null,
paidAt: null,
},
{ client: trx }
)
draft.useTransaction(trx)
draft.status = 'validated'
draft.edited = {
clientId: client.id,
clientName: client.name,
clientEmail: client.email,
numero: fields.numero,
amountTtcCents: fields.amountTtcCents,
issueDate: fields.issueDate,
dueDate: fields.dueDate,
planId,
}
draft.invoiceId = created.id
await draft.save()
await recordActivity({
organizationId,
kind: 'invoice_imported',
label: `Facture <b>${created.numero}</b> importée et validée`,
meta: { invoiceId: created.id, clientId: client.id },
trx,
})
return created
})
await invoice.load('client')
await invoice.load('plan')
try {
await scheduleCheckinForInvoice(invoice)
} catch (err) {
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule checkin')
}
return response.status(201).json({ data: new InvoiceTransformer(invoice).toObject() })
}
/**
* POST /invoices/import-batch/:id/drafts/:draftId/skip
* Marque un brouillon comme skippé (l'utilisateur ne veut pas le valider).
*/
async skipDraft({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const batch = await loadBatchOrFail(organizationId, params.id)
const draft = batch.drafts.find((d) => d.id === params.draftId)
if (!draft) {
throw new Exception('Brouillon introuvable', { status: 404, code: 'not_found' })
}
if (draft.status === 'validated') {
throw new Exception('Brouillon déjà validé', {
status: 409,
code: 'draft_already_processed',
})
}
if (draft.status !== 'skipped') {
draft.status = 'skipped'
await draft.save()
}
return response.json({ data: serializeDraft(draft) })
}
/**
* DELETE /invoices/import-batch/:id annule le batch entier.
* CASCADE supprime les drafts. Les invoices validées (si y'en a déjà)
* restent intactes, le FK draft.invoice_id est SET NULL côté ImportDraft.
*/
async destroy({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const batch = await loadBatchOrFail(organizationId, params.id)
await batch.delete()
return response.status(204).send('')
}
}

View File

@ -0,0 +1,376 @@
import Invoice from '#models/invoice'
import Plan from '#models/plan'
import RelanceTask from '#models/relance_task'
import InvoiceTransformer from '#transformers/invoice_transformer'
import { createInvoiceValidator, listInvoicesValidator } from '#validators/invoice'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon'
import { resolveClient } from '#services/resolve_client'
import { recordActivity } from '#services/activity_recorder'
import { cancelFutureRelances } from '#services/relance_scheduler'
import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler'
import logger from '@adonisjs/core/services/logger'
const PAGE_SIZE = 50
// Priorité d'affichage côté liste : ce qui est actionnable d'abord.
const STATUS_PRIORITY: Record<string, number> = {
awaiting_user_confirmation: 0,
in_relance: 1,
pending: 2,
litigation: 3,
paid: 4,
cancelled: 5,
}
function requireOrgId(auth: HttpContext['auth']): string {
const user = auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
return user.organizationId
}
function serializeInvoice(i: Invoice) {
return new InvoiceTransformer(i).toObject()
}
/**
* Construit la timeline d'une facture en composant les étapes du plan
* avec l'état courant (V1 simplifié les RelanceTask viendront plus tard).
*
* - étapes dont sendDay <= aujourd'hui : 'past' (envoyées)
* - étape actuelle (la prochaine future) : 'current'
* - étapes futures : 'future'
*/
function buildTimeline(
invoice: Invoice,
relanceTasks: RelanceTask[] = []
): Array<{
id: string
state: 'past' | 'current' | 'future'
when: string
what: string
}> {
const events: Array<{
id: string
state: 'past' | 'current' | 'future'
when: string
what: string
}> = [
{
id: `${invoice.id}__issued`,
state: 'past',
when: `${formatShortDate(invoice.issueDate)} · facture émise`,
what: 'Importée',
},
]
if (invoice.plan?.steps?.length && invoice.status !== 'paid' && invoice.status !== 'cancelled') {
const dueMs = invoice.dueDate.toMillis()
const nowMs = DateTime.now().toMillis()
const taskByStepId = new Map(relanceTasks.map((task) => [task.planStepId, task]))
let currentSet = false
for (const step of invoice.plan.steps.slice().sort((a, b) => a.order - b.order)) {
const sendMs = dueMs + step.offsetDays * 24 * 60 * 60 * 1000
const task = taskByStepId.get(step.id)
const stepDate = task?.sentAt ?? task?.sendAt ?? DateTime.fromMillis(sendMs)
const labelStep = `J${step.offsetDays >= 0 ? '+' : ''}${step.offsetDays} — Étape ${step.order + 1}`
let state: 'past' | 'current' | 'future'
if (task?.status === 'sent') state = 'past'
else if (task?.status === 'scheduled' && task.sendAt.toMillis() < nowMs) state = 'current'
else if (!task && invoice.status === 'pending' && !currentSet) {
state = 'current'
currentSet = true
} else if (!currentSet) {
state = 'current'
currentSet = true
} else state = 'future'
const subject = step.subject.replace('{{numero}}', invoice.numero)
const what = task
? task.status === 'sent'
? `Email envoyé · "${subject}"`
: `Email programmé · "${subject}"`
: invoice.status === 'pending'
? `À programmer après check-in · "${subject}"`
: `Relance non programmée · "${subject}"`
events.push({
id: `${invoice.id}__step_${step.order}`,
state,
when: `${formatShortDate(stepDate)} · ${labelStep}`,
what,
})
}
}
if (invoice.status === 'paid' && invoice.paidAt) {
events.push({
id: `${invoice.id}__paid`,
state: 'past',
when: `${formatShortDate(invoice.paidAt)} · facture encaissée`,
what: 'Marquée encaissée — relances stoppées',
})
}
return events
}
function formatShortDate(d: DateTime): string {
return d.toFormat('dd/LL/yyyy')
}
export default class InvoicesController {
/**
* GET /invoices?status=&q=&clientId=&page=
*/
async index({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const filters = await request.validateUsing(listInvoicesValidator)
const query = Invoice.query()
.where('organization_id', organizationId)
.preload('client')
.preload('plan')
if (filters.status && filters.status !== 'all') {
query.where('status', filters.status)
}
if (filters.clientId) {
query.where('client_id', filters.clientId)
}
if (filters.q) {
const q = filters.q.toLowerCase()
query.where((b) => {
b.whereILike('numero', `%${q}%`).orWhereExists((sub) => {
sub
.from('clients')
.whereColumn('clients.id', 'invoices.client_id')
.whereILike('clients.name', `%${q}%`)
})
})
}
const invoices = await query.exec()
// Tri : actionnable d'abord (status priority), puis échéance croissante.
invoices.sort((a, b) => {
const dp = (STATUS_PRIORITY[a.status] ?? 99) - (STATUS_PRIORITY[b.status] ?? 99)
if (dp !== 0) return dp
return a.dueDate.toMillis() - b.dueDate.toMillis()
})
// Pagination simple en V1 (cf. backend.md §6 — cursor-based plus tard).
const page = filters.page ?? 1
const total = invoices.length
const sliced = invoices.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
return response.json({
data: sliced.map(serializeInvoice),
meta: { total, page },
})
}
/**
* GET /invoices/counts compteurs par statut pour les chips dashboard.
*/
async counts({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const rows = await db
.from('invoices')
.where('organization_id', organizationId)
.select('status')
.count('* as count')
.groupBy('status')
const counts = {
all: 0,
pending: 0,
in_relance: 0,
awaiting_user_confirmation: 0,
paid: 0,
litigation: 0,
cancelled: 0,
}
for (const r of rows) {
const c = Number(r.count)
counts.all += c
const s = r.status as keyof typeof counts
if (s in counts) counts[s] = c
}
return response.json({ data: counts })
}
/**
* GET /invoices/:id détail enrichi (client + plan + timeline).
*/
async show({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const invoice = await Invoice.query()
.where('organization_id', organizationId)
.where('id', params.id)
.preload('client')
.preload('plan', (q) => q.preload('steps'))
.first()
if (!invoice) {
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
}
const data = serializeInvoice(invoice)
const relanceTasks = await RelanceTask.query()
.where('invoice_id', invoice.id)
.whereNot('status', 'cancelled')
return response.json({
data: {
...data,
client: invoice.client && {
id: invoice.client.id,
name: invoice.client.name,
email: invoice.client.email,
phone: invoice.client.phone,
address: invoice.client.address,
siret: invoice.client.siret,
},
plan: invoice.plan && {
id: invoice.plan.id,
slug: invoice.plan.slug,
name: invoice.plan.name,
steps: (invoice.plan.steps ?? [])
.slice()
.sort((a, b) => a.order - b.order)
.map((s) => ({
id: s.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
})),
},
timeline: buildTimeline(invoice, relanceTasks),
},
})
}
/**
* POST /invoices saisie manuelle.
*/
async store({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const fields = await request.validateUsing(createInvoiceValidator)
const invoice = await db.transaction(async (trx) => {
const result = await resolveClient(organizationId, fields, trx)
if ('errorCode' in result) {
throw new Exception(
'Email du client requis — Rubis en a besoin pour envoyer les relances.',
{ status: 422, code: result.errorCode }
)
}
const client = result.client
// Vérification plan (s'il est fourni, doit appartenir à l'org).
let planId: string | null = null
if (fields.planId) {
const plan = await Plan.query({ client: trx })
.where('organization_id', organizationId)
.where('id', fields.planId)
.first()
if (plan) planId = plan.id
}
return Invoice.create(
{
organizationId,
clientId: client.id,
planId,
numero: fields.numero,
amountTtcCents: fields.amountTtcCents,
issueDate: DateTime.fromISO(fields.issueDate),
dueDate: DateTime.fromISO(fields.dueDate),
status: 'pending',
rubisEarned: 1, // bonus saisie initiale (cf. CLAUDE.md → glossaire)
pdfStorageKey: null,
notes: null,
paidAt: null,
},
{ client: trx }
)
})
await invoice.load('client')
await invoice.load('plan')
// Programme uniquement le check-in (envoyé à dueDate). Les relances
// client ne partent qu'après confirmation "toujours en attente".
try {
await scheduleCheckinForInvoice(invoice)
} catch (err) {
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule checkin')
}
return response.status(201).json({ data: serializeInvoice(invoice) })
}
/**
* POST /invoices/:id/mark-paid
* Marque encaissée + bonus +1 rubis (à la fois sur invoice.rubisEarned
* et sur organization.rubisCount). Annule toutes les relances futures.
*/
async markPaid({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const invoice = await Invoice.query()
.where('organization_id', organizationId)
.where('id', params.id)
.preload('client')
.preload('plan')
.first()
if (!invoice) {
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
}
if (invoice.status === 'paid') {
// Idempotent : déjà payée, on renvoie l'état courant sans bumper.
return response.json({ data: serializeInvoice(invoice) })
}
await db.transaction(async (trx) => {
invoice.useTransaction(trx)
invoice.status = 'paid'
invoice.paidAt = DateTime.now()
invoice.rubisEarned = invoice.rubisEarned + 1
await invoice.save()
// Bump du compteur agrégé sur l'organisation
await trx.from('organizations').where('id', organizationId).increment('rubis_count', 1)
// Journal d'activité (cf. dashboard activity feed).
await recordActivity({
organizationId,
kind: 'invoice_paid',
label: `Facture <b>${invoice.numero}</b> marquée encaissée`,
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
trx,
})
// Annule toutes les relances + le check-in programmés pour cette
// facture (idempotent, BullMQ.remove peut échouer silencieusement
// si le job a déjà été consommé).
await cancelFutureRelances(invoice.id, trx)
await cancelCheckinForInvoice(invoice.id, trx)
})
return response.json({ data: serializeInvoice(invoice) })
}
}

View File

@ -0,0 +1,31 @@
import User from '#models/user'
import Organization from '#models/organization'
import { signupValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http'
import db from '@adonisjs/lucid/services/db'
import { provisionDefaultPlans } from '#services/default_plans'
import { emitAuthSession } from '#services/auth_session'
export default class NewAccountController {
/**
* POST /auth/signup
* Crée organisation + 4 plans pré-fournis + user dans une transaction,
* puis émet une AuthSession (access token JSON + refresh cookie httpOnly).
*/
async store(ctx: HttpContext) {
const { fullName, email, password } = await ctx.request.validateUsing(signupValidator)
const user = await db.transaction(async (trx) => {
const org = await Organization.create({ name: '' }, { client: trx })
await provisionDefaultPlans(org.id, trx)
return User.create(
{ email, password, fullName, organizationId: org.id },
{ client: trx }
)
})
const session = await emitAuthSession(user, ctx)
ctx.response.status(201)
return ctx.serialize(session)
}
}

View File

@ -0,0 +1,47 @@
import Organization from '#models/organization'
import OrganizationTransformer from '#transformers/organization_transformer'
import { updateOrganizationValidator } from '#validators/organization'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import { DateTime } from 'luxon'
export default class OrganizationsController {
/**
* GET /organizations/me l'organisation de l'utilisateur courant.
*/
async show({ auth, serialize }: HttpContext) {
const user = auth.getUserOrFail()
if (user.organizationId === null) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
const org = await Organization.findOrFail(user.organizationId)
return serialize(OrganizationTransformer.transform(org))
}
/**
* PATCH /organizations/me onboarding step 2.
* Marque `onboardingCompletedAt` dès qu'un nom est posé pour la
* première fois (heuristique simple : pour l'instant un nom non vide
* suffit à considérer l'organisation comme "configurée").
*/
async update({ auth, request, serialize }: HttpContext) {
const user = auth.getUserOrFail()
if (user.organizationId === null) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
const payload = await request.validateUsing(updateOrganizationValidator)
const org = await Organization.findOrFail(user.organizationId)
const wasUnnamed = org.name.trim().length === 0
org.merge(payload)
if (wasUnnamed && (payload.name?.trim().length ?? 0) > 0 && !org.onboardingCompletedAt) {
org.onboardingCompletedAt = DateTime.now()
}
await org.save()
return serialize(OrganizationTransformer.transform(org))
}
}

View File

@ -0,0 +1,228 @@
import Plan from '#models/plan'
import PlanStep from '#models/plan_step'
import PlanTransformer from '#transformers/plan_transformer'
import { createPlanValidator, updatePlanValidator } from '#validators/plan'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db'
/**
* Slug à partir d'un nom de plan : minuscules, ASCII safe, tirets.
* On garantit l'unicité par org en suffixant un compteur si collision.
*/
function slugify(input: string): string {
return input
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60) || 'plan'
}
// Slugs réservés côté front (routes statiques type /plans/nouveau).
// Si l'utilisateur nomme son plan "nouveau", on suffixe d'office.
const RESERVED_SLUGS = new Set(['nouveau', 'new', 'create'])
async function nextAvailableSlug(organizationId: string, base: string): Promise<string> {
const start = RESERVED_SLUGS.has(base) ? `${base}-1` : base
const existing = await Plan.query()
.where('organization_id', organizationId)
.whereILike('slug', `${base}%`)
.select('slug')
const taken = new Set(existing.map((p) => p.slug))
if (!taken.has(start) && !RESERVED_SLUGS.has(start)) return start
for (let i = 2; i < 100; i++) {
const candidate = `${base}-${i}`
if (!taken.has(candidate) && !RESERVED_SLUGS.has(candidate)) return candidate
}
return `${base}-${Date.now()}`
}
const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
function requireOrgId(auth: HttpContext['auth']): string {
const user = auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
}
return user.organizationId
}
function serializePlan(p: Plan) {
return new PlanTransformer(p).toObject()
}
/**
* Compte combien de factures actives (non payées, non annulées) référencent
* chaque plan d'une org. Utilisé pour enrichir la liste avec un badge "X
* factures utilisent ce plan" utile avant édition pour signaler l'impact.
*/
async function bulkComputePlanUsage(
organizationId: string,
planIds: string[]
): Promise<Map<string, number>> {
const map = new Map<string, number>()
for (const id of planIds) map.set(id, 0)
if (planIds.length === 0) return map
const rows = await db
.from('invoices')
.where('organization_id', organizationId)
.whereIn('plan_id', planIds)
.whereRaw(`status::text in ${ACTIVE_INVOICE_STATUSES}`)
.select('plan_id')
.count('* as count')
.groupBy('plan_id')
for (const r of rows) {
map.set(r.plan_id, Number(r.count))
}
return map
}
export default class PlansController {
/**
* GET /plans liste enrichie avec compteurs d'usage.
*/
async index({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const plans = await Plan.query()
.where('organization_id', organizationId)
.preload('steps')
.orderBy('is_default', 'desc')
.orderBy('name', 'asc')
const usage = await bulkComputePlanUsage(
organizationId,
plans.map((p) => p.id)
)
const data = plans.map((p) => ({
...serializePlan(p),
usageCount: usage.get(p.id) ?? 0,
}))
return response.json({ data })
}
/**
* GET /plans/:slug détail.
* Le SPA lookup par slug pour les plans pré-fournis (URL stable et
* lisible : /parametres/plans/standard-30j).
*/
async show({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const plan = await Plan.query()
.where('organization_id', organizationId)
.where('slug', params.slug)
.preload('steps')
.first()
if (!plan) {
throw new Exception('Plan introuvable', { status: 404, code: 'not_found' })
}
const usage = await bulkComputePlanUsage(organizationId, [plan.id])
return response.json({
data: { ...serializePlan(plan), usageCount: usage.get(plan.id) ?? 0 },
})
}
/**
* PATCH /plans/:slug édite nom, description et/ou recompose les étapes.
*
* Recomposition des steps : on ne fait pas de diff fin (id par id), on
* remplace tout le set en transaction. Plus simple, plus prévisible, et
* idiomatique côté UX (l'utilisateur a édité son plan dans son ensemble).
*/
async update({ auth, params, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(updatePlanValidator)
const plan = await Plan.query()
.where('organization_id', organizationId)
.where('slug', params.slug)
.first()
if (!plan) {
throw new Exception('Plan introuvable', { status: 404, code: 'not_found' })
}
await db.transaction(async (trx) => {
plan.useTransaction(trx)
if (payload.name !== undefined) plan.name = payload.name
if (payload.description !== undefined) plan.description = payload.description
await plan.save()
if (payload.steps !== undefined) {
// Remplace tout le set
await PlanStep.query({ client: trx }).where('plan_id', plan.id).delete()
await PlanStep.createMany(
payload.steps.map((s) => ({
planId: plan.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
})),
{ client: trx }
)
}
})
await plan.load('steps')
return response.json({ data: serializePlan(plan) })
}
/**
* POST /plans création d'un plan custom.
*
* Slug auto-généré depuis `name`, suffixé en cas de collision dans l'org.
* Le plan custom n'est pas marqué `isDefault` il peut être supprimé
* (V2) sans toucher à la bibliothèque.
*/
async store({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(createPlanValidator)
const baseSlug = slugify(payload.name)
const slug = await nextAvailableSlug(organizationId, baseSlug)
const plan = await db.transaction(async (trx) => {
const created = await Plan.create(
{
organizationId,
slug,
name: payload.name,
description: payload.description ?? '',
isDefault: false,
},
{ client: trx }
)
await PlanStep.createMany(
payload.steps.map((s) => ({
planId: created.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
})),
{ client: trx }
)
return created
})
await plan.load('steps')
return response.status(201).json({ data: serializePlan(plan) })
}
}

View File

@ -0,0 +1,25 @@
import UserTransformer from '#transformers/user_transformer'
import { updateProfileValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class ProfileController {
/**
* GET /account/profile
*/
async show({ auth, serialize }: HttpContext) {
return serialize(UserTransformer.transform(auth.getUserOrFail()))
}
/**
* PATCH /account/profile
*/
async update({ auth, request, serialize }: HttpContext) {
const user = auth.getUserOrFail()
const payload = await request.validateUsing(updateProfileValidator)
user.merge(payload)
await user.save()
return serialize(UserTransformer.transform(user))
}
}

View File

@ -0,0 +1,32 @@
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import { consumeRefreshToken } from '#services/refresh_token'
import { emitAuthSession } from '#services/auth_session'
export default class RefreshController {
/**
* POST /auth/refresh
*
* Lit le cookie `rubis_refresh` (httpOnly), valide son hash en DB,
* révoque l'ancien et émet une AuthSession fraîche (nouveau access
* token + nouveau refresh cookie posé via emitAuthSession).
*
* Codes d'erreur :
* - 401 no_session : pas de cookie envoyé
* - 401 session_expired : cookie inconnu, expiré, ou révoqué
* (réutilisation d'un token révoqué = vol présumé panic mode :
* tous les tokens actifs du user sont invalidés)
*/
async handle(ctx: HttpContext) {
const result = await consumeRefreshToken(ctx)
if ('errorCode' in result) {
throw new Exception(
result.errorCode === 'no_session' ? 'Pas de session active' : 'Session expirée',
{ status: 401, code: result.errorCode }
)
}
const session = await emitAuthSession(result.user, ctx)
return ctx.serialize(session)
}
}

View File

@ -0,0 +1,96 @@
import app from '@adonisjs/core/services/app'
import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http'
/**
* Exception handler API JSON-only. Normalise toutes les erreurs vers la
* shape `{ errors: [{ code, message, field? }] }` documentée dans
* backend.md §6.
*
* Conversions :
* - PG 23505 (unique violation) 422 `duplicate` avec field extrait
* - E_INVALID_CREDENTIALS 401 `invalid_credentials`
* - Vine validation errors 422 (déjà géré par Adonis, on relaie)
* - Exception custom avec code & status propage tel quel sous shape errors
* - Reste fallback super.handle()
*/
export default class HttpExceptionHandler extends ExceptionHandler {
protected debug = !app.inProduction
async handle(error: unknown, ctx: HttpContext) {
if (!isObject(error)) return super.handle(error, ctx)
// Postgres unique violation → 422 propre (pas un 500 avec stack pg-protocol).
if (error.code === '23505') {
const detail = typeof error.detail === 'string' ? error.detail : ''
const fieldMatch = detail.match(/Key \(([^)]+)\)=/)
const field = fieldMatch?.[1]?.split(',')[0]?.trim()
ctx.response.status(422)
return ctx.response.json({
errors: [
{
code: 'duplicate',
message: 'Cette valeur existe déjà.',
field: field ?? undefined,
},
],
})
}
// Adonis auth — mauvais credentials. Le default est 400, on veut 401.
if (error.code === 'E_INVALID_CREDENTIALS') {
ctx.response.status(401)
return ctx.response.json({
errors: [
{
code: 'invalid_credentials',
message: 'Email ou mot de passe incorrect',
},
],
})
}
// Vine — validation errors. Adonis sort déjà des messages structurés,
// on les relaie en `errors[]`.
if (error.code === 'E_VALIDATION_ERROR' && Array.isArray(error.messages)) {
ctx.response.status(422)
return ctx.response.json({
errors: error.messages.map((m) => ({
code: 'validation_failed',
message: typeof m === 'object' && m && 'message' in m ? String(m.message) : '',
field: typeof m === 'object' && m && 'field' in m ? String(m.field) : undefined,
rule: typeof m === 'object' && m && 'rule' in m ? String(m.rule) : undefined,
})),
})
}
// Custom Exception levée par les controllers : on a `status` + `code`
// + `message`. On les passe en shape `errors[]`.
if (
typeof error.status === 'number' &&
typeof error.code === 'string' &&
typeof error.message === 'string' &&
error.status >= 400 &&
error.status < 600
) {
ctx.response.status(error.status)
return ctx.response.json({
errors: [
{
code: error.code,
message: error.message,
},
],
})
}
return super.handle(error, ctx)
}
async report(error: unknown, ctx: HttpContext) {
return super.report(error, ctx)
}
}
function isObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === 'object'
}

View File

@ -0,0 +1,69 @@
import CheckinTask from '#models/checkin_task'
import Invoice from '#models/invoice'
import User from '#models/user'
import { sendCheckinEmail } from '#services/mail_dispatcher'
import env from '#start/env'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
/**
* Worker BullMQ pour la queue `checkins`.
*
* Idempotent : si la task n'est plus `scheduled` (déjà envoyée ou
* expirée parce que la facture a é marquée payée entre-temps),
* no-op.
*
* Le `plain` token est passé dans le payload du job (pas relu depuis
* la DB on n'a que le hash), pour pouvoir construire les URLs.
*/
export async function sendCheckinJob(jobData: { taskId: string; plain: string }) {
const task = await CheckinTask.find(jobData.taskId)
if (!task) {
logger.warn({ taskId: jobData.taskId }, 'checkin task not found, skipping')
return
}
if (task.status !== 'scheduled') {
return
}
const invoice = await Invoice.query()
.where('id', task.invoiceId)
.preload('client')
.first()
if (!invoice) {
task.status = 'expired'
await task.save()
return
}
// Si la facture a été payée/annulée entre la programmation et l'exécution,
// on n'envoie pas le check-in (l'utilisateur sait déjà).
if (invoice.status === 'paid' || invoice.status === 'cancelled') {
task.status = 'expired'
await task.save()
return
}
const user = await User.query().where('organization_id', invoice.organizationId).first()
if (!user) {
task.status = 'expired'
await task.save()
return
}
const apiUrl = env.get('APP_URL', 'http://localhost:3333')
const paidUrl = `${apiUrl}/api/v1/checkin/${jobData.plain}/paid`
const pendingUrl = `${apiUrl}/api/v1/checkin/${jobData.plain}/pending`
await sendCheckinEmail({
invoice,
client: invoice.client,
user,
paidUrl,
pendingUrl,
})
task.status = 'sent'
task.sentAt = DateTime.now()
await task.save()
}

View File

@ -0,0 +1,122 @@
import RelanceTask from '#models/relance_task'
import Invoice from '#models/invoice'
import User from '#models/user'
import { sendRelanceEmail } from '#services/mail_dispatcher'
import { recordActivity } from '#services/activity_recorder'
import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
/**
* Worker BullMQ pour la queue `relances`. Idempotent : si la task n'est
* plus `scheduled` (déjà envoyée, annulée, ou échouée définitivement),
* no-op.
*
* Cas critiques :
* - Invoice payée/annulée entre temps cancel la task (pas d'envoi)
* - Step `requires_manual_validation` (mise en demeure) on n'envoie
* PAS, on log un activity_event 'warning_drafted' que l'utilisateur
* devra valider manuellement (cf. CLAUDE.md Principes produit).
* - Sinon : envoi de l'email + bump rubis (1 rubis = 10 min libérées).
*/
export async function sendRelanceJob(jobData: { taskId: string }) {
const task = await RelanceTask.query()
.where('id', jobData.taskId)
.preload('planStep')
.first()
if (!task) {
logger.warn({ taskId: jobData.taskId }, 'relance task not found, skipping')
return
}
if (task.status !== 'scheduled') {
logger.info({ taskId: task.id, status: task.status }, 'relance task not scheduled, skipping')
return
}
const invoice = await Invoice.query()
.where('id', task.invoiceId)
.preload('client')
.preload('organization')
.first()
if (!invoice) {
task.status = 'cancelled'
await task.save()
return
}
// Hook critique : la facture peut avoir été payée entre la programmation
// et l'exécution. On vérifie avant d'envoyer.
if (invoice.status === 'paid' || invoice.status === 'cancelled') {
task.status = 'cancelled'
await task.save()
return
}
const step = task.planStep
const user = await User.query().where('organization_id', invoice.organizationId).first()
// Mise en demeure : on génère un brouillon, on n'envoie pas (cf. CLAUDE.md).
if (step.requiresManualValidation) {
await db.transaction(async (trx) => {
task.useTransaction(trx)
task.status = 'sent' // On considère la task "traitée" — le brouillon est l'output
task.sentAt = DateTime.now()
await task.save()
await recordActivity({
organizationId: invoice.organizationId,
kind: 'warning_drafted',
label: `Brouillon mise en demeure prêt — <b>${invoice.client.name}</b> (${invoice.numero})`,
meta: {
invoiceId: invoice.id,
clientId: invoice.clientId,
planStepOrder: step.order,
},
trx,
})
})
return
}
// Envoi normal
await sendRelanceEmail({
invoice,
client: invoice.client,
step,
user,
organization: invoice.organization,
})
await db.transaction(async (trx) => {
task.useTransaction(trx)
task.status = 'sent'
task.sentAt = DateTime.now()
await task.save()
invoice.useTransaction(trx)
// Première relance envoyée → status passe en `in_relance` (la facture
// sort de l'état "pending" silencieux).
if (invoice.status === 'pending') {
invoice.status = 'in_relance'
}
invoice.rubisEarned = invoice.rubisEarned + 1
await invoice.save()
await trx
.from('organizations')
.where('id', invoice.organizationId)
.increment('rubis_count', 1)
await recordActivity({
organizationId: invoice.organizationId,
kind: 'relance_sent',
label: `Relance J${step.offsetDays >= 0 ? '+' : ''}${step.offsetDays} envoyée à <b>${invoice.client.name}</b>`,
meta: {
invoiceId: invoice.id,
clientId: invoice.clientId,
planStepOrder: step.order,
},
trx,
})
})
}

View File

@ -0,0 +1,20 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import type { Authenticators } from '@adonisjs/auth/types'
/**
* Auth middleware is used authenticate HTTP requests and deny
* access to unauthenticated users.
*/
export default class AuthMiddleware {
async handle(
ctx: HttpContext,
next: NextFn,
options: {
guards?: (keyof Authenticators)[]
} = {}
) {
await ctx.auth.authenticateUsing(options.guards)
return next()
}
}

View File

@ -0,0 +1,19 @@
import { Logger } from '@adonisjs/core/logger'
import { HttpContext } from '@adonisjs/core/http'
import { type NextFn } from '@adonisjs/core/types/http'
/**
* The container bindings middleware binds classes to their request
* specific value using the container resolver.
*
* - We bind "HttpContext" class to the "ctx" object
* - And bind "Logger" class to the "ctx.logger" object
*/
export default class ContainerBindingsMiddleware {
handle(ctx: HttpContext, next: NextFn) {
ctx.containerResolver.bindValue(HttpContext, ctx)
ctx.containerResolver.bindValue(Logger, ctx.logger)
return next()
}
}

View File

@ -0,0 +1,9 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class ForceJsonResponseMiddleware {
handle(ctx: HttpContext, next: NextFn) {
ctx.request.request.headers.accept = 'application/json'
return next()
}
}

View File

@ -0,0 +1,37 @@
import * as abilities from '#abilities/main'
import { policies } from '#generated/policies'
import { Bouncer } from '@adonisjs/bouncer'
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* Init bouncer middleware is used to create a bouncer instance
* during an HTTP request
*/
export default class InitializeBouncerMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
/**
* Create bouncer instance for the ongoing HTTP request.
* We will pull the user from the HTTP context.
*/
ctx.bouncer = new Bouncer(
() => ctx.auth.user || null,
abilities,
policies
).setContainerResolver(ctx.containerResolver)
// API JSON-only : pas d'intégration Edge views à partager.
return next()
}
}
declare module '@adonisjs/core/http' {
export interface HttpContext {
bouncer: Bouncer<
Exclude<HttpContext['auth']['user'], undefined>,
typeof abilities,
typeof policies
>
}
}

View File

@ -0,0 +1,16 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* Silent auth middleware can be used as a global middleware to silent check
* if the user is logged-in or not.
*
* The request continues as usual, even when the user is not logged-in.
*/
export default class SilentAuthMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
await ctx.auth.check()
return next()
}
}

View File

@ -0,0 +1,9 @@
import { ActivityEventSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
export default class ActivityEvent extends ActivityEventSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
}

View File

@ -0,0 +1,9 @@
import { CheckinTaskSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Invoice from '#models/invoice'
export default class CheckinTask extends CheckinTaskSchema {
@belongsTo(() => Invoice)
declare invoice: BelongsTo<typeof Invoice>
}

View File

@ -0,0 +1,13 @@
import { ClientSchema } from '#database/schema'
import { belongsTo, hasMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
import Invoice from '#models/invoice'
export default class Client extends ClientSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
@hasMany(() => Invoice)
declare invoices: HasMany<typeof Invoice>
}

View File

@ -0,0 +1,13 @@
import { ImportBatchSchema } from '#database/schema'
import { belongsTo, hasMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
import ImportDraft from '#models/import_draft'
export default class ImportBatch extends ImportBatchSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
@hasMany(() => ImportDraft, { foreignKey: 'batchId' })
declare drafts: HasMany<typeof ImportDraft>
}

View File

@ -0,0 +1,13 @@
import { ImportDraftSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import ImportBatch from '#models/import_batch'
import Invoice from '#models/invoice'
export default class ImportDraft extends ImportDraftSchema {
@belongsTo(() => ImportBatch, { foreignKey: 'batchId' })
declare batch: BelongsTo<typeof ImportBatch>
@belongsTo(() => Invoice, { foreignKey: 'invoiceId' })
declare invoice: BelongsTo<typeof Invoice>
}

View File

@ -0,0 +1,17 @@
import { InvoiceSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
import Client from '#models/client'
import Plan from '#models/plan'
export default class Invoice extends InvoiceSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
@belongsTo(() => Client)
declare client: BelongsTo<typeof Client>
@belongsTo(() => Plan)
declare plan: BelongsTo<typeof Plan>
}

View File

@ -0,0 +1,9 @@
import { OrganizationSchema } from '#database/schema'
import { hasMany } from '@adonisjs/lucid/orm'
import type { HasMany } from '@adonisjs/lucid/types/relations'
import User from '#models/user'
export default class Organization extends OrganizationSchema {
@hasMany(() => User)
declare users: HasMany<typeof User>
}

View File

@ -0,0 +1,17 @@
import { PlanSchema } from '#database/schema'
import { belongsTo, hasMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
import PlanStep from '#models/plan_step'
import Invoice from '#models/invoice'
export default class Plan extends PlanSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
@hasMany(() => PlanStep, { foreignKey: 'planId' })
declare steps: HasMany<typeof PlanStep>
@hasMany(() => Invoice)
declare invoices: HasMany<typeof Invoice>
}

View File

@ -0,0 +1,9 @@
import { PlanStepSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Plan from '#models/plan'
export default class PlanStep extends PlanStepSchema {
@belongsTo(() => Plan)
declare plan: BelongsTo<typeof Plan>
}

View File

@ -0,0 +1,9 @@
import { RefreshTokenSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import User from '#models/user'
export default class RefreshToken extends RefreshTokenSchema {
@belongsTo(() => User)
declare user: BelongsTo<typeof User>
}

View File

@ -0,0 +1,13 @@
import { RelanceTaskSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Invoice from '#models/invoice'
import PlanStep from '#models/plan_step'
export default class RelanceTask extends RelanceTaskSchema {
@belongsTo(() => Invoice)
declare invoice: BelongsTo<typeof Invoice>
@belongsTo(() => PlanStep, { foreignKey: 'planStepId' })
declare planStep: BelongsTo<typeof PlanStep>
}

View File

@ -0,0 +1,24 @@
import { UserSchema } from '#database/schema'
import hash from '@adonisjs/core/services/hash'
import { compose } from '@adonisjs/core/helpers'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { type AccessToken, DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Organization from '#models/organization'
export default class User extends compose(UserSchema, withAuthFinder(hash)) {
static accessTokens = DbAccessTokensProvider.forModel(User)
declare currentAccessToken?: AccessToken
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
get initials() {
const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
if (first && last) {
return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase()
}
return `${first.slice(0, 2)}`.toUpperCase()
}
}

View File

@ -0,0 +1,40 @@
import { DateTime } from 'luxon'
import ActivityEvent from '#models/activity_event'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
type EventKind = 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
type RecordOpts = {
organizationId: string
kind: EventKind
label: string
meta?: Record<string, unknown>
at?: DateTime
trx?: TransactionClientContract
}
/**
* Enregistre un événement dans le journal d'activité (append-only).
* Appelé depuis :
* - SendRelanceJob (relance_sent)
* - InvoicesController.markPaid (invoice_paid)
* - ImportBatchesController.validateDraft (invoice_imported)
* - SendRelanceJob quand step.requires_manual_validation (warning_drafted)
*
* Les labels acceptent un HTML léger (<b>) pour permettre au SPA de
* mettre en gras les noms d'entité toujours composé côté serveur,
* jamais d'input utilisateur brut.
*/
export async function recordActivity(opts: RecordOpts): Promise<ActivityEvent> {
const { organizationId, kind, label, meta = {}, at, trx } = opts
return ActivityEvent.create(
{
organizationId,
kind,
label,
meta,
at: at ?? DateTime.now(),
},
trx ? { client: trx } : undefined
)
}

View File

@ -0,0 +1,156 @@
import env from '#start/env'
const MISTRAL_API = 'https://api.mistral.ai/v1'
// Modèle chat rapide et bon en français pour générer du texte court.
// `mistral-small-latest` est ~10x moins cher que `mistral-large` et
// largement suffisant pour 3 paragraphes de relance.
const GENERATION_MODEL = 'mistral-small-latest'
export type RelanceTone = 'amical' | 'courtois' | 'ferme' | 'mise_en_demeure'
export type GenerateRelanceInput = {
/** Tonalité ciblée — guide le ton du modèle. */
tone: RelanceTone
/** Position de l'étape dans le plan (J+3, J+10…). Influence l'urgence. */
offsetDays: number
/** Brief libre rédigé par l'utilisateur (ex. "rappelle qu'on accepte les virements"). */
prompt: string
/** Nom du plan parent — donne du contexte au modèle (ex. "Clients fidèles"). */
planName?: string
/** Description du plan parent — quand utiliser ce plan, ICP visé. */
planDescription?: string
}
export type GenerateRelanceOutput = {
subject: string
body: string
}
const TONE_GUIDANCE: Record<RelanceTone, string> = {
amical:
"Ton chaleureux et bienveillant, presque comme un message à un partenaire de confiance. Pas de pression. On commence par 'Bonjour' suivi du prénom si dispo, sinon du nom de l'entreprise.",
courtois:
'Ton professionnel et factuel. Poli, neutre, pas de chaleur excessive ni de menace. Standard B2B.',
ferme:
"Ton ferme et direct. Rappelle l'engagement contractuel. Reste poli mais sans formule de politesse excessive. Pas d'agressivité.",
mise_en_demeure:
"Ton formel et juridique. Mentionne explicitement 'mise en demeure', un délai de paiement (8 jours), et les conséquences légales (pénalités de retard, voie judiciaire). Reste factuel, pas émotionnel.",
}
const SYSTEM_PROMPT = `Tu rédiges des emails de relance de factures impayées en français pour une TPE-PME française.
# Règles de rédaction
- Toujours en français.
- Vouvoie systématiquement le destinataire (B2B France).
- Concis : 4 à 8 phrases maximum pour le corps.
- Une salutation au début, et termine TOUJOURS le corps par {{signature}} sur sa propre ligne. **Ne jamais réécrire le nom de l'expéditeur ni l'entreprise à la main après {{signature}}** : la variable contient déjà tout (nom + entreprise + formule de politesse choisie par l'utilisateur).
# Syntaxe des variables IMPORTANT
- Utilise UNIQUEMENT la substitution simple \`{{nom.de.variable}}\`.
- N'utilise JAMAIS la syntaxe de sections \`{{#var}}...{{/var}}\`, \`{{^var}}...{{/var}}\`, ni aucune syntaxe conditionnelle. Notre interpréteur ne fait que de la substitution simple — toute syntaxe avancée s'affichera telle quelle dans l'email final.
- Tu n'es **PAS obligé** d'utiliser toutes les variables. Choisis celles qui rendent le message naturel et utile. Mieux vaut un message simple et clair qu'un message bourré de variables.
# Variables disponibles
- {{client.name}} : raison sociale du client (toujours rempli)
- {{client.contactFirstName}} : prénom du contact (peut être vide à l'envoi — dans ce cas la variable s'efface silencieusement, donc préfère une formule qui marche dans les deux cas, ex. "Bonjour {{client.contactFirstName}}," l'absence du prénom donne juste "Bonjour ,")
- {{client.contactLastName}} : nom du contact (peut être vide)
- {{numero}} : numéro de la facture
- {{amount}} : montant TTC formaté (ex. "1 240,00 €")
- {{dueDate}} : date d'échéance (ex. "15/04/2026")
- {{issueDate}} : date d'émission
- {{daysLate}} : jours de retard (entier peut être négatif si la relance est avant échéance)
- {{user.fullName}} : nom de l'expéditeur (rarement utile dans le corps si on a déjà {{signature}})
- {{user.companyName}} : nom de l'entreprise expéditrice
- {{signature}} : bloc signature complet termine TOUJOURS le corps par cette variable
# Format de retour
JSON strict avec deux clés :
- "subject" : sujet (max 100 caractères, naturel, peut contenir {{numero}})
- "body" : corps de l'email`
/**
* Génère un email de relance via Mistral. Retourne `{ subject, body }`
* avec des placeholders Mustache prêts à être interpolés à l'envoi.
*
* Coût : ~0.0001 par appel sur `mistral-small-latest` (négligeable).
*/
export async function generateRelance(input: GenerateRelanceInput): Promise<GenerateRelanceOutput> {
const apiKey = env.get('MISTRAL_API_KEY', '')
if (!apiKey) {
throw new Error('MISTRAL_API_KEY manquante : génération IA indisponible.')
}
const offsetExplanation =
input.offsetDays < 0
? `${Math.abs(input.offsetDays)} jours **avant** l'échéance (rappel anticipé)`
: input.offsetDays === 0
? "le **jour J** de l'échéance"
: `${input.offsetDays} jours **après** l'échéance (la facture est en retard)`
const userMessage = [
'# Plan parent',
input.planName ? `Nom : ${input.planName}` : 'Nom : (non précisé)',
input.planDescription
? `Description : ${input.planDescription}`
: 'Description : (aucune)',
'',
'# Cette relance',
`Tonalité : ${input.tone}${TONE_GUIDANCE[input.tone]}`,
`Timing : J${input.offsetDays >= 0 ? '+' : ''}${input.offsetDays}${offsetExplanation}.`,
'',
"# Brief de l'utilisateur",
input.prompt.trim() ||
'(aucun brief — rédige un message standard pour cette tonalité et ce timing, en restant naturel)',
].join('\n')
const res = await fetch(`${MISTRAL_API}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: GENERATION_MODEL,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userMessage },
],
response_format: {
type: 'json_schema',
json_schema: {
name: 'relance_email',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
properties: {
subject: { type: 'string' },
body: { type: 'string' },
},
required: ['subject', 'body'],
},
},
},
temperature: 0.7,
}),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`Mistral génération relance → HTTP ${res.status}: ${text}`)
}
const json = (await res.json()) as {
choices?: { message?: { content?: string } }[]
}
const content = json?.choices?.[0]?.message?.content
if (typeof content !== 'string') {
throw new Error('Mistral chat: pas de content string dans la réponse')
}
const parsed = JSON.parse(content) as GenerateRelanceOutput
return {
subject: parsed.subject.slice(0, 200),
body: parsed.body.slice(0, 5000),
}
}

View File

@ -0,0 +1,36 @@
import { DateTime } from 'luxon'
import User from '#models/user'
import UserTransformer from '#transformers/user_transformer'
import env from '#start/env'
import { issueRefreshToken } from '#services/refresh_token'
import type { HttpContext } from '@adonisjs/core/http'
/**
* Émet une AuthSession complète : access token en JSON + refresh token
* en cookie httpOnly. Utilisé par signup et login.
*
* Format de réponse aligné sur packages/shared/src/types/auth.ts :
* `{ data: { accessToken, expiresAt, user } }`
*/
export async function emitAuthSession(
user: User,
ctx: HttpContext
): Promise<{
accessToken: string
expiresAt: string
user: ReturnType<UserTransformer['toObject']>
}> {
const accessToken = await User.accessTokens.create(user)
await issueRefreshToken(user, ctx)
const ttlMin = env.get('ACCESS_TOKEN_TTL_MINUTES', 30)
const expiresAt =
accessToken.expiresAt?.toISOString() ??
DateTime.now().plus({ minutes: ttlMin }).toISO()!
return {
accessToken: accessToken.value!.release(),
expiresAt,
user: new UserTransformer(user).toObject(),
}
}

View File

@ -0,0 +1,106 @@
import { DateTime } from 'luxon'
import CheckinTask from '#models/checkin_task'
import Invoice from '#models/invoice'
import { getQueue } from '#services/queue'
import { generateCheckinToken } from '#services/checkin_token'
import app from '@adonisjs/core/services/app'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
const CHECKIN_QUEUE = 'checkins'
function shouldEnqueue(): boolean {
return app.getEnvironment() !== 'test'
}
/**
* Programme un check-in pour une facture.
*
* V1 : 1 check-in par facture, envoyé à `dueDate` (pile à l'échéance).
* Si dueDate est dans le passé envoie immédiat (à `now + 1min`),
* pour que les factures importées en retard reçoivent quand même un
* check-in.
*
* Le token est généré ici (plain) on retourne le plain pour permettre
* au caller de le passer dans des emails de test si besoin, mais en
* pratique seul le hash est stocké et lu via SendCheckinJob.
*
* Idempotent par invoice : si une CheckinTask `scheduled` existe déjà,
* on la cancelle d'abord puis on en crée une nouvelle (cas re-scheduling
* après changement de dueDate).
*
* En tests : la task DB est créée mais l'enqueue BullMQ est skippé
* (les tx auto-rollback laisseraient des jobs orphelins en Redis sinon).
*/
export async function scheduleCheckinForInvoice(
invoice: Invoice,
trx?: TransactionClientContract
): Promise<{ task: CheckinTask; plain: string } | null> {
// Cancel l'éventuelle CheckinTask scheduled précédente.
const existing = await CheckinTask.query(trx ? { client: trx } : undefined)
.where('invoice_id', invoice.id)
.where('status', 'scheduled')
const queue = shouldEnqueue() ? getQueue(CHECKIN_QUEUE) : null
for (const t of existing) {
if (queue) await queue.remove(`checkin-${t.id}`).catch(() => {})
t.useTransaction(trx ?? (null as never))
t.status = 'expired'
await t.save()
}
const now = DateTime.now()
const sendAtRaw = invoice.dueDate
const sendAt = sendAtRaw < now ? now.plus({ minutes: 1 }) : sendAtRaw
const { plain, hashed } = generateCheckinToken()
const task = await CheckinTask.create(
{
organizationId: invoice.organizationId,
invoiceId: invoice.id,
sendAt,
tokenHash: hashed,
status: 'scheduled',
sentAt: null,
answeredAt: null,
answer: null,
},
trx ? { client: trx } : undefined
)
if (queue) {
const delay = Math.max(0, sendAt.toMillis() - now.toMillis())
await queue.add(
'send-checkin',
{ taskId: task.id, plain },
{
delay,
jobId: `checkin-${task.id}`,
attempts: 3,
backoff: { type: 'exponential', delay: 30_000 },
}
)
}
return { task, plain }
}
/**
* Annule le check-in scheduled d'une facture (appelé par mark-paid).
*/
export async function cancelCheckinForInvoice(
invoiceId: string,
trx?: TransactionClientContract
): Promise<void> {
const tasks = await CheckinTask.query(trx ? { client: trx } : undefined)
.where('invoice_id', invoiceId)
.where('status', 'scheduled')
if (tasks.length === 0) return
const queue = shouldEnqueue() ? getQueue(CHECKIN_QUEUE) : null
for (const t of tasks) {
if (queue) await queue.remove(`checkin-${t.id}`).catch(() => {})
t.useTransaction(trx ?? (null as never))
t.status = 'expired'
await t.save()
}
}

View File

@ -0,0 +1,16 @@
import crypto from 'node:crypto'
/**
* Tokens check-in : 32 bytes random base64url. On stocke le hash
* SHA-256 en DB (CheckinTask.token_hash). Pas de signature HMAC : le
* token est purement opaque, sa "signature" c'est sa présence en DB.
*/
export function generateCheckinToken(): { plain: string; hashed: string } {
const plain = crypto.randomBytes(32).toString('base64url')
const hashed = crypto.createHash('sha256').update(plain).digest('hex')
return { plain, hashed }
}
export function hashCheckinToken(plain: string): string {
return crypto.createHash('sha256').update(plain).digest('hex')
}

View File

@ -0,0 +1,91 @@
import db from '@adonisjs/lucid/services/db'
/**
* Stats agrégées d'un client. Calculées on-the-fly à partir des invoices
* (V1 : pas de cache, le volume reste raisonnable). Si le perf devient un
* sujet, on cachera dans Redis avec invalidation post-mutation invoice.
*/
export type ClientStats = {
invoiceCount: number
activeInvoiceCount: number
lateInvoiceCount: number
paidInvoiceCount: number
paidLifetimeCents: number
pendingLifetimeCents: number
lastActivityAt: string | null
}
export const EMPTY_CLIENT_STATS: ClientStats = {
invoiceCount: 0,
activeInvoiceCount: 0,
lateInvoiceCount: 0,
paidInvoiceCount: 0,
paidLifetimeCents: 0,
pendingLifetimeCents: 0,
lastActivityAt: null,
}
/**
* Calcule les stats pour un ensemble de clients d'une org en une seule
* requête agrégée par client_id. Les clients sans facture reçoivent EMPTY.
*
* @returns Map clientId ClientStats
*/
export async function bulkComputeClientStats(
organizationId: string,
clientIds: string[]
): Promise<Map<string, ClientStats>> {
const map = new Map<string, ClientStats>()
for (const id of clientIds) {
map.set(id, { ...EMPTY_CLIENT_STATS })
}
if (clientIds.length === 0) return map
const today = new Date()
today.setHours(0, 0, 0, 0)
const ACTIVE = "('pending','in_relance','awaiting_user_confirmation')"
const rows = await db
.from('invoices')
.where('organization_id', organizationId)
.whereIn('client_id', clientIds)
.select('client_id')
.select(db.raw('count(*)::int as invoice_count'))
.select(db.raw(`count(*) filter (where status::text in ${ACTIVE})::int as active_count`))
.select(
db.raw(
`count(*) filter (where status::text in ${ACTIVE} and due_date < ?)::int as late_count`,
[today]
)
)
.select(db.raw(`count(*) filter (where status = 'paid')::int as paid_count`))
.select(
db.raw(`coalesce(sum(amount_ttc_cents) filter (where status = 'paid'), 0)::int as paid_cents`)
)
.select(
db.raw(
`coalesce(sum(amount_ttc_cents) filter (where status::text in ${ACTIVE}), 0)::int as pending_cents`
)
)
.select(db.raw('max(updated_at) as last_activity'))
.groupBy('client_id')
for (const r of rows) {
map.set(r.client_id, {
invoiceCount: r.invoice_count,
activeInvoiceCount: r.active_count,
lateInvoiceCount: r.late_count,
paidInvoiceCount: r.paid_count,
paidLifetimeCents: r.paid_cents,
pendingLifetimeCents: r.pending_cents,
lastActivityAt:
r.last_activity instanceof Date
? r.last_activity.toISOString()
: (r.last_activity as string | null),
})
}
return map
}

View File

@ -0,0 +1,150 @@
import db from '@adonisjs/lucid/services/db'
import Organization from '#models/organization'
import { DateTime } from 'luxon'
const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
export type DashboardKpis = {
rubisCount: number
rubisThisMonth: number
// 1 rubis = 10 minutes libérées (cf. CLAUDE.md → glossaire)
hoursLiberatedThisMonth: number
encaisseCents: number
encaisseDeltaCents: number
dsoDays: number
dsoDeltaDays: number
factureToRelance: number
factureInRelance: number
factureNewToday: number
miseEnDemeurePending: number
monthlyGoalProgress: number
// Rang relatif à la cohorte (placeholder V1, calculé en V2 avec assez de data)
percentile?: number
}
function startOfMonth(d: DateTime): Date {
return d.startOf('month').toJSDate()
}
function startOfDay(d: DateTime): Date {
return d.startOf('day').toJSDate()
}
/**
* Calcule les KPIs dashboard pour une organisation.
*
* V1 implémentation simple sans cache. Quelques metrics avancés
* (DSO, percentile) sont à 0 ou null tant qu'on a pas assez d'historique.
* Le contrat reste stable côté SPA.
*/
export async function computeKpis(organizationId: string): Promise<DashboardKpis> {
const now = DateTime.now()
const monthStart = startOfMonth(now)
const todayStart = startOfDay(now)
const prevMonthStart = startOfMonth(now.minus({ months: 1 }))
const org = await Organization.findOrFail(organizationId)
// Counts par statut + factures récentes
const counts = (await db
.from('invoices')
.where('organization_id', organizationId)
.select(
db.raw(`count(*) filter (where status = 'pending')::int as to_relance`),
db.raw(`count(*) filter (where status = 'in_relance')::int as in_relance`),
db.raw(`count(*) filter (where created_at >= ?)::int as new_today`, [todayStart])
)
.first()) as { to_relance: number; in_relance: number; new_today: number } | undefined
// Sommes d'encaissement (paid_at) ce mois et le précédent
const paidStats = (await db
.from('invoices')
.where('organization_id', organizationId)
.where('status', 'paid')
.select(
db.raw(
`coalesce(sum(amount_ttc_cents) filter (where paid_at >= ?), 0)::int as this_month`,
[monthStart]
),
db.raw(
`coalesce(sum(amount_ttc_cents) filter (where paid_at >= ? and paid_at < ?), 0)::int as prev_month`,
[prevMonthStart, monthStart]
),
db.raw(
`coalesce(sum(rubis_earned) filter (where paid_at >= ?), 0)::int as rubis_this_month`,
[monthStart]
),
db.raw(
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400) filter (where paid_at >= ?), 0)::int as dso_this_month`,
[monthStart]
),
db.raw(
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400) filter (where paid_at >= ? and paid_at < ?), 0)::int as dso_prev_month`,
[prevMonthStart, monthStart]
)
)
.first()) as
| {
this_month: number
prev_month: number
rubis_this_month: number
dso_this_month: number
dso_prev_month: number
}
| undefined
const encaisseCents = paidStats?.this_month ?? 0
const encaisseDeltaCents = encaisseCents - (paidStats?.prev_month ?? 0)
const rubisThisMonth = paidStats?.rubis_this_month ?? 0
const dsoDays = paidStats?.dso_this_month ?? 0
const dsoDeltaDays = dsoDays - (paidStats?.dso_prev_month ?? 0)
return {
rubisCount: org.rubisCount,
rubisThisMonth,
hoursLiberatedThisMonth: rubisThisMonth * 10,
encaisseCents,
encaisseDeltaCents,
dsoDays,
dsoDeltaDays,
factureToRelance: counts?.to_relance ?? 0,
factureInRelance: counts?.in_relance ?? 0,
factureNewToday: counts?.new_today ?? 0,
// Mise en demeure pending — sera calculé quand RelanceTask est branché
// (count des steps requires_manual_validation programmées). Pour V1 : 0.
miseEnDemeurePending: 0,
// Goal progress (V1 placeholder) : ratio rubis_count / 250 (objectif
// mensuel arbitraire). À paramétrer plus tard.
monthlyGoalProgress: Math.min(100, Math.round((rubisThisMonth / 25) * 100)),
percentile: undefined,
}
}
/**
* Top des clients en retard (top 5 par défaut).
* Compte les factures actives dont due_date est dépassée, agrégées par client.
*/
export async function topLatePayers(
organizationId: string,
limit = 5
): Promise<Array<{ clientId: string; name: string; lateInvoicesCount: number }>> {
const today = startOfDay(DateTime.now())
const rows = await db
.from('invoices')
.innerJoin('clients', 'clients.id', 'invoices.client_id')
.where('invoices.organization_id', organizationId)
.whereRaw(`invoices.status::text in ${ACTIVE_INVOICE_STATUSES}`)
.where('invoices.due_date', '<', today)
.groupBy('clients.id', 'clients.name')
.select('clients.id as client_id', 'clients.name as name')
.select(db.raw('count(*)::int as late_invoices_count'))
.orderBy('late_invoices_count', 'desc')
.limit(limit)
return rows.map((r) => ({
clientId: r.client_id,
name: r.name,
lateInvoicesCount: r.late_invoices_count,
}))
}

View File

@ -0,0 +1,205 @@
/**
* Source de vérité des 4 plans pré-fournis (cf. CLAUDE.md Périmètre V1).
* Dupliqués dans chaque organisation à la création (signup) V1 mono-tenant
* mais l'isolation est totale, on peut éditer le plan d'une org sans toucher
* aux autres.
*
* Les valeurs (cadences, tons, sujets) doivent rester alignées sur le seed
* MSW (apps/web/src/mocks/seed.ts SEED_PLANS) tant que les deux coexistent.
*/
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import Plan from '#models/plan'
import PlanStep from '#models/plan_step'
type DefaultStep = {
order: number
offsetDays: number
tone: 'amical' | 'courtois' | 'ferme' | 'mise_en_demeure'
subject: string
body: string
requiresManualValidation: boolean
}
type DefaultPlan = {
slug: string
name: string
description: string
steps: DefaultStep[]
}
export const DEFAULT_PLANS: DefaultPlan[] = [
{
slug: 'standard-30j',
name: 'Standard B2B',
description:
'Cadence sobre, ton qui monte progressivement. Pour la majorité des clients sérieux.',
steps: [
{
order: 0,
offsetDays: 3,
tone: 'amical',
subject: 'Petit rappel — facture {{numero}}',
body:
"Bonjour {{client.name}},\n\nNous espérons que tout va bien. Un petit rappel concernant la facture {{numero}} d'un montant de {{amount}}, échue le {{dueDate}}.\n\nMerci d'avance,\n{{signature}}",
requiresManualValidation: false,
},
{
order: 1,
offsetDays: 10,
tone: 'courtois',
subject: 'Relance — facture {{numero}} en retard',
body:
"Bonjour {{client.name}},\n\nSauf erreur de notre part, la facture {{numero}} d'un montant de {{amount}} reste impayée.\n\nMerci de procéder au règlement dans les meilleurs délais.\n\n{{signature}}",
requiresManualValidation: false,
},
{
order: 2,
offsetDays: 25,
tone: 'ferme',
subject: 'Mise en demeure — facture {{numero}}',
body:
"Bonjour {{client.name}},\n\nMalgré nos relances, la facture {{numero}} d'un montant de {{amount}} reste impayée. Nous vous mettons en demeure de régler sous 8 jours.\n\n{{signature}}",
requiresManualValidation: true,
},
],
},
{
slug: 'rapide-15j',
name: 'Rapide',
description: 'Cadence resserrée pour les factures récurrentes ou les délais courts.',
steps: [
{
order: 0,
offsetDays: 1,
tone: 'amical',
subject: 'Facture {{numero}} échue',
body: 'Bonjour, petit rappel pour la facture {{numero}}.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 1,
offsetDays: 7,
tone: 'courtois',
subject: 'Relance facture {{numero}}',
body: 'La facture {{numero}} reste impayée à ce jour. Merci de régulariser.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 2,
offsetDays: 15,
tone: 'ferme',
subject: 'Mise en demeure {{numero}}',
body: 'Mise en demeure formelle de payer sous 8 jours.\n\n{{signature}}',
requiresManualValidation: true,
},
],
},
{
slug: 'patient-60j',
name: 'Patient',
description: 'Pour les clients de longue date. On laisse respirer avant de relancer.',
steps: [
{
order: 0,
offsetDays: 15,
tone: 'amical',
subject: 'Facture {{numero}}',
body: 'Bonjour, simple rappel.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 1,
offsetDays: 30,
tone: 'courtois',
subject: 'Relance facture {{numero}}',
body: 'Merci de régulariser dans les meilleurs délais.\n\n{{signature}}',
requiresManualValidation: false,
},
],
},
{
slug: 'ferme-7j',
name: 'Ferme',
description: 'Cadence stricte pour les clients à risque ou les retards récurrents.',
steps: [
{
order: 0,
offsetDays: 1,
tone: 'courtois',
subject: 'Facture {{numero}}',
body: 'Premier rappel.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 1,
offsetDays: 5,
tone: 'ferme',
subject: 'Relance ferme {{numero}}',
body: 'Le règlement est attendu sous 48h.\n\n{{signature}}',
requiresManualValidation: false,
},
{
order: 2,
offsetDays: 10,
tone: 'mise_en_demeure',
subject: 'Mise en demeure {{numero}}',
body: 'Mise en demeure formelle.\n\n{{signature}}',
requiresManualValidation: true,
},
],
},
]
/**
* Provisionne les 4 plans par défaut pour une organisation fraîchement créée.
* Idempotent : si l'org a déjà un plan avec un slug, on n'écrase pas.
*
* À appeler dans la transaction de signup.
*/
export async function provisionDefaultPlans(
organizationId: string,
trx: TransactionClientContract
): Promise<Plan[]> {
const existing = await Plan.query({ client: trx })
.where('organization_id', organizationId)
.whereIn(
'slug',
DEFAULT_PLANS.map((p) => p.slug)
)
.select('slug')
const existingSlugs = new Set(existing.map((p) => p.slug))
const created: Plan[] = []
for (const tpl of DEFAULT_PLANS) {
if (existingSlugs.has(tpl.slug)) continue
const plan = await Plan.create(
{
organizationId,
slug: tpl.slug,
name: tpl.name,
description: tpl.description,
isDefault: true,
},
{ client: trx }
)
await PlanStep.createMany(
tpl.steps.map((s) => ({
planId: plan.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
})),
{ client: trx }
)
created.push(plan)
}
return created
}

View File

@ -0,0 +1,160 @@
import db from '@adonisjs/lucid/services/db'
import ImportBatch from '#models/import_batch'
import ImportDraft from '#models/import_draft'
import Client from '#models/client'
import Plan from '#models/plan'
import { getOcrProvider } from '#services/ocr/index'
import type { OcrResult } from '#services/ocr/ocr_provider'
export type DraftFields = {
clientId: string | null
clientName: string
clientEmail: string | null
numero: string
amountTtcCents: number
issueDate: string
dueDate: string
planId: string | null
}
export type DraftConfidence = Partial<{
clientId: number
clientName: number
clientEmail: number
numero: number
amountTtcCents: number
issueDate: number
dueDate: number
planId: number
}>
/**
* Une "source" de draft : un filename + (optionnellement) une storageKey
* MinIO du PDF stocké. Mock OCR ignore storageKey, Mistral l'exige.
*/
export type ImportSource = {
filename: string
storageKey: string | null
}
/**
* Compose `extracted` + `confidence` à partir du résultat OCR. Tente un
* match client immédiat (case-insensitive) pour pré-remplir `clientId`.
*/
async function buildDraftFromOcr(
organizationId: string,
ocr: OcrResult,
defaultPlanId: string | null
): Promise<{ extracted: DraftFields; confidence: DraftConfidence }> {
const matchedClient = await Client.query()
.where('organization_id', organizationId)
.whereILike('name', ocr.fields.clientName.value)
.first()
return {
extracted: {
clientId: matchedClient?.id ?? null,
clientName: matchedClient?.name ?? ocr.fields.clientName.value,
clientEmail: matchedClient?.email ?? ocr.fields.clientEmail.value,
numero: ocr.fields.numero.value,
amountTtcCents: ocr.fields.amountTtcCents.value,
issueDate: ocr.fields.issueDate.value,
dueDate: ocr.fields.dueDate.value,
planId: defaultPlanId,
},
confidence: {
clientName: matchedClient ? 1 : ocr.fields.clientName.confidence,
clientEmail: ocr.fields.clientEmail.confidence,
numero: ocr.fields.numero.confidence,
amountTtcCents: ocr.fields.amountTtcCents.confidence,
issueDate: ocr.fields.issueDate.confidence,
dueDate: ocr.fields.dueDate.confidence,
planId: 1,
},
}
}
/**
* Crée un batch + N drafts à partir de N sources (filename + storageKey).
* Le provider OCR (mock ou mistral) est résolu à l'intérieur.
*
* - Mock : storageKey=null OK, extraction depuis filename
* - Mistral : storageKey requis, extraction depuis le PDF stocké
*/
export async function createImportBatch(
organizationId: string,
sources: ImportSource[]
): Promise<ImportBatch> {
const ocr = getOcrProvider()
// Plan par défaut = premier is_default de l'org (provisionné au signup).
const defaultPlan = await Plan.query()
.where('organization_id', organizationId)
.where('is_default', true)
.orderBy('name', 'asc')
.first()
// OCR fait HORS transaction (calls réseau lents, on ne tient pas de lock
// PG pendant). Si l'OCR échoue, l'erreur remonte avant le INSERT.
type DraftPayload = {
filename: string
storageKey: string | null
extracted: DraftFields
edited: DraftFields
confidence: DraftConfidence
}
const drafts: DraftPayload[] = []
for (const src of sources) {
const result = await ocr.extract(src)
const { extracted, confidence } = await buildDraftFromOcr(
organizationId,
result,
defaultPlan?.id ?? null
)
drafts.push({
filename: src.filename,
storageKey: src.storageKey,
extracted,
edited: { ...extracted },
confidence,
})
}
return db.transaction(async (trx) => {
const batch = await ImportBatch.create({ organizationId }, { client: trx })
for (const d of drafts) {
await ImportDraft.create(
{
batchId: batch.id,
filename: d.filename,
pdfStorageKey: d.storageKey,
extracted: d.extracted,
edited: d.edited,
confidence: d.confidence,
status: 'pending',
invoiceId: null,
},
{ client: trx }
)
}
await batch.load('drafts')
return batch
})
}
/**
* Wrapper compat : V1 mock JSON `{filenames}` sources avec storageKey null.
* @deprecated Préférer `createImportBatch` avec sources explicites.
*/
export async function createImportBatchFromFilenames(
organizationId: string,
filenames: string[]
): Promise<ImportBatch> {
return createImportBatch(
organizationId,
filenames.map((filename) => ({ filename, storageKey: null }))
)
}

View File

@ -0,0 +1,154 @@
import mail from '@adonisjs/mail/services/main'
import env from '#start/env'
import { DateTime } from 'luxon'
import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
import type Invoice from '#models/invoice'
import type Client from '#models/client'
import type PlanStep from '#models/plan_step'
import type User from '#models/user'
import type Organization from '#models/organization'
type RelancePayload = {
invoice: Invoice
client: Client
step: PlanStep
user: User | null
organization?: Organization | null
}
/**
* Construit l'objet `vars` interpolé dans subject/body. Exposé pour
* permettre la preview côté contrôleur (wizard de création de plan)
* avec les mêmes variables que ce qui sera réellement envoyé.
*
* Variables disponibles :
* - {{client.name}}, {{client.email}}
* - {{client.contactFirstName}}, {{client.contactLastName}} (peuvent être vides)
* - {{numero}}, {{amount}}, {{dueDate}}, {{issueDate}}
* - {{daysLate}} (jours de retard depuis dueDate, négatif = avant échéance)
* - {{user.fullName}}, {{user.companyName}}
* - {{signature}}
*/
export function buildRelanceVars({
invoice,
client,
user,
organization,
}: {
invoice: Pick<Invoice, 'numero' | 'amountTtcCents' | 'dueDate' | 'issueDate'>
client: Pick<Client, 'name' | 'email' | 'contactFirstName' | 'contactLastName'>
user: Pick<User, 'fullName' | 'signature' | 'email'> | null
organization?: Pick<Organization, 'name'> | null
}) {
const dueDate = invoice.dueDate.toJSDate()
// Jours de retard arrondis à l'entier (UTC pour cohérence).
const daysLate = Math.floor(
DateTime.utc().startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days
)
return {
client: {
name: client.name,
email: client.email,
contactFirstName: client.contactFirstName ?? '',
contactLastName: client.contactLastName ?? '',
},
user: {
fullName: user?.fullName ?? '',
companyName: organization?.name ?? '',
},
numero: invoice.numero,
amount: formatAmountFr(invoice.amountTtcCents),
dueDate: formatDateFr(dueDate),
issueDate: formatDateFr(invoice.issueDate.toJSDate()),
daysLate: String(daysLate),
signature: user?.signature ?? user?.fullName ?? '',
}
}
/**
* Envoie un email de relance à un client à partir d'un step.
* Le subject/body du step contiennent des placeholders Mustache-like
* qu'on interpole avant l'envoi (cf. `buildRelanceVars`).
*
* Le mailer effectif est piloté par MAIL_DRIVER (`smtp` Mailpit en dev,
* `resend` en prod).
*/
export async function sendRelanceEmail({
invoice,
client,
step,
user,
organization,
}: RelancePayload) {
const vars = buildRelanceVars({ invoice, client, user, organization })
const subject = renderTemplate(step.subject, vars)
const body = renderTemplate(step.body, vars)
const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
await mailer.send((m) => {
m.from(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"))
.to(client.email, client.name)
.subject(subject)
// Texte brut pour V1 — on ajoutera un template HTML quand on aura
// décidé d'un look graphique pour les relances.
.text(body)
// Reply-To pointe sur l'utilisateur Rubis : si le client final répond
// à la relance, sa réponse arrive chez le patron de la TPE, pas dans
// notre boîte transactionnelle.
if (user?.email) {
m.replyTo(user.email, user.fullName ?? user.email)
}
})
}
type CheckinPayload = {
invoice: Invoice
client: Client
user: User
paidUrl: string
pendingUrl: string
}
/**
* Envoie le check-in à l'**utilisateur** (pas au client). Lui demande
* si la facture a é payée, avec 2 liens publics qui modifient l'état
* côté API et redirigent ensuite vers le SPA.
*
* Texte brut V1. Un template HTML viendra quand on aura figé le look
* graphique (cf. ADR-021).
*/
export async function sendCheckinEmail({
invoice,
client,
user,
paidUrl,
pendingUrl,
}: CheckinPayload) {
const subject = `Facture ${invoice.numero} — payée par ${client.name} ?`
const body = `Bonjour ${user.fullName ?? ''},
La facture ${invoice.numero} (${formatAmountFr(invoice.amountTtcCents)}) émise pour ${client.name}
arrive à échéance aujourd'hui (${formatDateFr(invoice.dueDate.toJSDate())}).
Avant que Rubis n'envoie la première relance, dites-nous vous en êtes :
J'ai é payé(e), pas besoin de relancer :
${paidUrl}
Toujours en attente, lance la relance comme prévu :
${pendingUrl}
Ces liens expirent dans 24h.
Merci,
L'équipe Rubis`
const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
await mailer.send((m) => {
m.from(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"))
.to(user.email, user.fullName ?? user.email)
.subject(subject)
.text(body)
})
}

View File

@ -0,0 +1,20 @@
import env from '#start/env'
import type { OcrProvider } from '#services/ocr/ocr_provider'
import { MockOcrProvider } from '#services/ocr/mock_ocr_provider'
import { MistralOcrProvider } from '#services/ocr/mistral_ocr_provider'
/**
* Résout l'implémentation OCR à utiliser selon OCR_PROVIDER.
*
* - `mock` (default) : MockOcrProvider, données plausibles depuis filename.
* Compatible avec /invoices/upload en mode JSON `{filenames}`.
* - `mistral` : MistralOcrProvider. Nécessite un PDF stocké (multipart
* upload) + MISTRAL_API_KEY. Pas compatible avec le mode JSON.
*/
export function getOcrProvider(): OcrProvider {
const provider = env.get('OCR_PROVIDER', 'mock')
if (provider === 'mistral') {
return new MistralOcrProvider()
}
return new MockOcrProvider()
}

View File

@ -0,0 +1,213 @@
import drive from '@adonisjs/drive/services/main'
import env from '#start/env'
import type { OcrProvider, OcrResult } from '#services/ocr/ocr_provider'
const MISTRAL_API = 'https://api.mistral.ai/v1'
// Modèle OCR dédié de Mistral — extrait le texte structuré d'un doc.
const OCR_MODEL = 'mistral-ocr-latest'
// Modèle chat pour la 2e étape (markdown → JSON typé via json_schema strict).
const EXTRACTION_MODEL = 'mistral-large-latest'
const MIME_BY_EXT: Record<string, string> = {
pdf: 'application/pdf',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
}
const SYSTEM_PROMPT = `Tu es un extracteur de factures françaises B2B.
Tu reçois le markdown d'une facture (issu d'une OCR) et tu retournes un
JSON strict avec les champs demandés.
Règles :
- amountTtcCents : montant TTC en centimes (entier). Pas le HT.
- issueDate / dueDate : ISO 8601 datetime UTC à 09:00 (ex. "2026-04-15T09:00:00.000Z").
- clientEmail : null si absent ou illisible (pas d'invention).
- numero : tel qu'imprimé sur la facture.
- Si un champ est ambigu, mets une confiance basse (0.30.6).`
/**
* Provider OCR Mistral. Pipeline en 2 étapes :
* 1. POST /v1/ocr avec le PDF en data URI base64 markdown structuré
* 2. POST /v1/chat/completions avec le markdown + json_schema strict
* extraction typée des champs
*
* Nécessite un PDF réel (storageKey non null). Pour le dev sans PDF,
* utiliser OCR_PROVIDER=mock.
*/
export class MistralOcrProvider implements OcrProvider {
private apiKey: string
constructor() {
const key = env.get('MISTRAL_API_KEY', '')
if (!key) {
throw new Error(
'MISTRAL_API_KEY manquante. Posez la dans .env ou bascule OCR_PROVIDER=mock.'
)
}
this.apiKey = key
}
async extract(input: {
storageKey: string | null
filename: string
}): Promise<OcrResult> {
if (!input.storageKey) {
throw new Error(
`MistralOcrProvider exige un PDF stocké (storageKey). Filename "${input.filename}" reçu sans storageKey — utiliser OCR_PROVIDER=mock pour les uploads sans fichier réel.`
)
}
// 1. Télécharge le fichier depuis Drive (MinIO en dev) puis encode en base64.
const buffer = await this.downloadAsBuffer(input.storageKey)
const mimeType = this.mimeTypeFromFilename(input.filename)
const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}`
// 2. OCR → markdown
const ocrJson = await this.postJson('/ocr', {
model: OCR_MODEL,
document: this.documentPayload(dataUri, mimeType),
})
const markdown = (ocrJson?.pages ?? [])
.map((p: { markdown?: string }) => p.markdown ?? '')
.join('\n\n')
.trim()
if (!markdown) {
throw new Error("Mistral OCR n'a retourné aucun texte exploitable")
}
// 3. Extraction structurée via chat avec json_schema strict.
const extracted = await this.extractFields(markdown)
return {
fields: {
clientName: { value: extracted.clientName, confidence: extracted._conf.clientName },
clientEmail: { value: extracted.clientEmail, confidence: extracted._conf.clientEmail },
numero: { value: extracted.numero, confidence: extracted._conf.numero },
amountTtcCents: {
value: extracted.amountTtcCents,
confidence: extracted._conf.amountTtcCents,
},
issueDate: { value: extracted.issueDate, confidence: extracted._conf.issueDate },
dueDate: { value: extracted.dueDate, confidence: extracted._conf.dueDate },
},
rawProviderResponse: { ocr: ocrJson, extracted },
}
}
private async downloadAsBuffer(storageKey: string): Promise<Buffer> {
const arr = await drive.use().getArrayBuffer(storageKey)
return Buffer.from(arr)
}
private mimeTypeFromFilename(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
const mimeType = MIME_BY_EXT[ext]
if (!mimeType) {
throw new Error(`Format OCR non supporté pour "${filename}"`)
}
return mimeType
}
private documentPayload(
dataUri: string,
mimeType: string
):
| { type: 'document_url'; document_url: string }
| { type: 'image_url'; image_url: string } {
if (mimeType === 'application/pdf') {
return { type: 'document_url', document_url: dataUri }
}
return { type: 'image_url', image_url: dataUri }
}
private async postJson(path: string, body: unknown): Promise<any> {
const res = await fetch(`${MISTRAL_API}${path}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`Mistral ${path} → HTTP ${res.status}: ${text}`)
}
return res.json()
}
private async extractFields(markdown: string): Promise<{
clientName: string
clientEmail: string | null
numero: string
amountTtcCents: number
issueDate: string
dueDate: string
_conf: Record<string, number>
}> {
const json = await this.postJson('/chat/completions', {
model: EXTRACTION_MODEL,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: markdown },
],
response_format: {
type: 'json_schema',
json_schema: {
name: 'invoice_fields',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
properties: {
clientName: { type: 'string' },
clientEmail: { type: ['string', 'null'] },
numero: { type: 'string' },
amountTtcCents: { type: 'integer' },
issueDate: { type: 'string' },
dueDate: { type: 'string' },
_conf: {
type: 'object',
additionalProperties: false,
properties: {
clientName: { type: 'number' },
clientEmail: { type: 'number' },
numero: { type: 'number' },
amountTtcCents: { type: 'number' },
issueDate: { type: 'number' },
dueDate: { type: 'number' },
},
required: [
'clientName',
'clientEmail',
'numero',
'amountTtcCents',
'issueDate',
'dueDate',
],
},
},
required: [
'clientName',
'clientEmail',
'numero',
'amountTtcCents',
'issueDate',
'dueDate',
'_conf',
],
},
},
},
temperature: 0,
})
const content = json?.choices?.[0]?.message?.content
if (typeof content !== 'string') {
throw new Error('Mistral chat: pas de content string dans la réponse')
}
return JSON.parse(content)
}
}

View File

@ -0,0 +1,84 @@
import type { OcrProvider, OcrResult } from '#services/ocr/ocr_provider'
const SAMPLE_CLIENT_NAMES = [
'Boulangerie Martin SARL',
'Atelier Durand',
'Cabinet Rousseau',
'Garage Lemoine',
'Studio Lefèvre',
'Pharmacie Bertrand',
'Imprimerie Moreau',
] as const
function rand<T>(arr: readonly T[]): T {
return arr[Math.floor(Math.random() * arr.length)]!
}
function randomAmountCents(): number {
// entre 80 € et 8 000 €, multiple de 50 cts pour rester crédible
return Math.floor(((Math.random() * 7920 + 80) * 100) / 50) * 50
}
function numeroFromFilename(filename: string): string {
const match = filename.match(/(\d{2,5})/u)
const yr = new Date().getFullYear()
const seq = match?.[1] ?? Math.floor(Math.random() * 9000 + 1000).toString()
return `F-${yr}-${seq.padStart(4, '0')}`
}
function isoDaysFromNow(days: number): string {
const d = new Date()
d.setDate(d.getDate() + days)
d.setHours(9, 0, 0, 0)
return d.toISOString()
}
function slugify(s: string): string {
return s
.toLowerCase()
.replace(/sarl|sa|sas/giu, '')
.replace(/[^a-z]+/giu, '-')
.replace(/^-+|-+$/gu, '')
}
/**
* Implémentation OCR mock : génère des champs plausibles depuis le filename
* + injecte volontairement quelques confidences basses (~30 %) pour signaler
* "champ douteux" dans l'UI de review.
*
* Aucun appel réseau, aucun PDF téléchargé. Quand Mistral arrive, on swap
* cette classe via le container Adonis sans toucher au reste.
*/
export class MockOcrProvider implements OcrProvider {
async extract(input: {
storageKey: string | null
filename: string
}): Promise<OcrResult> {
const clientName = rand(SAMPLE_CLIENT_NAMES)
// 30 % de chance d'avoir un email douteux (low confidence) — déclenche
// la pastille "à vérifier" dans la UI de review.
const emailLowConf = Math.random() < 0.3
const email = emailLowConf ? null : `compta@${slugify(clientName)}.fr`
return {
fields: {
clientName: { value: clientName, confidence: 0.95 },
clientEmail: {
value: email,
confidence: emailLowConf ? 0.42 : 0.88,
},
numero: { value: numeroFromFilename(input.filename), confidence: 0.97 },
amountTtcCents: { value: randomAmountCents(), confidence: 0.93 },
issueDate: {
value: isoDaysFromNow(-15 - Math.floor(Math.random() * 10)),
confidence: 0.9,
},
dueDate: {
value: isoDaysFromNow(15 + Math.floor(Math.random() * 20)),
confidence: emailLowConf ? 0.65 : 0.92,
},
},
rawProviderResponse: { provider: 'mock', filename: input.filename },
}
}
}

View File

@ -0,0 +1,33 @@
/**
* Interface OCR abstraction switchable (cf. backend.md §11.1).
*
* Implémentations :
* - MockOcrProvider : retour plausible depuis le filename, pour les démos
* et le dev sans Mistral. C'est le default en V1 (OCR_PROVIDER=mock).
* - MistralOcrProvider : à venir (ADR-020), appel API Mistral avec PDF
* téléchargé depuis MinIO.
*/
export interface OcrProvider {
extract(input: { storageKey: string | null; filename: string }): Promise<OcrResult>
}
export type OcrFieldName =
| 'clientName'
| 'clientEmail'
| 'numero'
| 'amountTtcCents'
| 'issueDate'
| 'dueDate'
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 8601
dueDate: { value: string; confidence: number }
}
/** Trace brute du provider — utile pour debug / re-process / audit. */
rawProviderResponse?: unknown
}

View File

@ -0,0 +1,60 @@
import { Queue, Worker, type Processor } from 'bullmq'
import { redisConnection, queueNames, type QueueName } from '#config/queue'
import logger from '@adonisjs/core/services/logger'
/**
* Wrappers BullMQ partagés. Chaque queue a 1 instance Queue (producer)
* et N workers (consumers) avec le bon handler.
*
* V1 : on garde tout en mémoire process workers et HTTP partagent le
* même Node. Quand le volume justifie le coût, on extrait les workers
* dans un Deployment K3s séparé (cf. backend.md §13.4).
*/
const queues = new Map<QueueName, Queue>()
const workers: Worker[] = []
export function getQueue(name: QueueName): Queue {
let q = queues.get(name)
if (!q) {
q = new Queue(name, { connection: redisConnection })
queues.set(name, q)
}
return q
}
export type JobHandler<T = unknown> = Processor<T>
/**
* Enregistre un Worker BullMQ sur une queue. Démarre tout de suite.
* Appelé par start/queue.ts au boot pour câbler les handlers.
*/
export function registerWorker<T = unknown>(name: QueueName, handler: JobHandler<T>): Worker {
const worker = new Worker<T>(name, handler, {
connection: redisConnection,
concurrency: 5,
})
worker.on('failed', (job, err) => {
logger.error({ err, queue: name, jobId: job?.id }, 'job failed')
})
worker.on('completed', (job) => {
logger.info({ queue: name, jobId: job.id }, 'job completed')
})
workers.push(worker)
return worker
}
/**
* Stoppe proprement tous les workers + queues. Appelé au shutdown du
* process via Adonis terminating hook.
*/
export async function shutdownQueue(): Promise<void> {
await Promise.all(workers.map((w) => w.close()))
await Promise.all(Array.from(queues.values()).map((q) => q.close()))
}
/**
* Liste des noms de queue (re-export du config pour ne pas exposer la
* connection Redis ailleurs dans l'app).
*/
export const QUEUES = queueNames

View File

@ -0,0 +1,151 @@
import crypto from 'node:crypto'
import { DateTime } from 'luxon'
import RefreshToken from '#models/refresh_token'
import User from '#models/user'
import env from '#start/env'
import type { HttpContext } from '@adonisjs/core/http'
export const REFRESH_COOKIE_NAME = 'rubis_refresh'
/**
* Génère un token plain (32 bytes random base64url ~43 chars), retourne
* { plain, hashed } pour ne stocker que le hashed côté DB.
*
* SHA-256 suffit : le token est un opaque random non humain, pas un mot
* de passe pas besoin de bcrypt/argon (contrairement aux passwords).
*/
function generateToken(): { plain: string; hashed: string } {
const plain = crypto.randomBytes(32).toString('base64url')
const hashed = crypto.createHash('sha256').update(plain).digest('hex')
return { plain, hashed }
}
function hashToken(plain: string): string {
return crypto.createHash('sha256').update(plain).digest('hex')
}
function ttlDays(): number {
return env.get('REFRESH_TOKEN_TTL_DAYS', 30)
}
/**
* Pose le cookie httpOnly avec le token plain. Le SPA ne peut pas le lire
* en JS c'est ce qui le protège du XSS, contrairement à localStorage.
*
* `path: /api/v1/auth` : le browser n'envoie le cookie qu'aux endpoints
* d'auth, pas sur chaque requête API. Réduit la surface d'attaque CSRF.
*/
function setRefreshCookie(ctx: HttpContext, plain: string) {
const maxAgeSeconds = ttlDays() * 24 * 60 * 60
ctx.response.cookie(REFRESH_COOKIE_NAME, plain, {
httpOnly: true,
secure: env.get('COOKIE_SECURE', false),
sameSite: 'strict',
path: '/api/v1/auth',
domain: env.get('COOKIE_DOMAIN') || undefined,
maxAge: maxAgeSeconds,
})
}
function clearRefreshCookie(ctx: HttpContext) {
ctx.response.clearCookie(REFRESH_COOKIE_NAME, {
path: '/api/v1/auth',
domain: env.get('COOKIE_DOMAIN') || undefined,
})
}
/**
* Crée un refresh token pour un user et pose le cookie correspondant.
* Appelé après signup et login.
*/
export async function issueRefreshToken(
user: User,
ctx: HttpContext
): Promise<{ token: RefreshToken; plain: string }> {
const { plain, hashed } = generateToken()
const token = await RefreshToken.create({
userId: user.id,
hashedToken: hashed,
expiresAt: DateTime.now().plus({ days: ttlDays() }),
lastUsedAt: null,
revokedAt: null,
ipAddress: ctx.request.ip(),
userAgent: ctx.request.header('user-agent') ?? null,
})
setRefreshCookie(ctx, plain)
return { token, plain }
}
/**
* Valide le cookie reçu et révoque l'ancien token. Retourne le user
* authentifié le contrôleur appelle ensuite `issueRefreshToken` (via
* emitAuthSession) pour poser un nouveau cookie. Rotation complète.
*
* Si le user envoie un token déjà révoqué, on suppose un vol potentiel
* et on révoque TOUS les tokens actifs du user (panic mode).
*/
export async function consumeRefreshToken(
ctx: HttpContext
): Promise<{ user: User } | { errorCode: 'no_session' | 'session_expired' }> {
const cookie = ctx.request.cookie(REFRESH_COOKIE_NAME)
if (!cookie) return { errorCode: 'no_session' }
const hashed = hashToken(cookie)
const stored = await RefreshToken.query().where('hashed_token', hashed).first()
if (!stored) {
clearRefreshCookie(ctx)
return { errorCode: 'session_expired' }
}
// Token déjà révoqué = signal de vol potentiel : on coupe tout pour
// l'user concerné. Le vrai propriétaire devra se re-logger.
if (stored.revokedAt) {
await revokeAllForUser(stored.userId)
clearRefreshCookie(ctx)
return { errorCode: 'session_expired' }
}
if (stored.expiresAt < DateTime.now()) {
stored.revokedAt = DateTime.now()
await stored.save()
clearRefreshCookie(ctx)
return { errorCode: 'session_expired' }
}
stored.revokedAt = DateTime.now()
stored.lastUsedAt = DateTime.now()
await stored.save()
const user = await User.findOrFail(stored.userId)
return { user }
}
/**
* Révoque le token courant (utilisé par /account/logout).
* Pas de panic l'user demande explicitement la déconnexion.
*/
export async function revokeCurrentRefreshToken(ctx: HttpContext): Promise<void> {
const cookie = ctx.request.cookie(REFRESH_COOKIE_NAME)
if (cookie) {
const hashed = hashToken(cookie)
await RefreshToken.query()
.where('hashed_token', hashed)
.whereNull('revoked_at')
.update({ revoked_at: DateTime.now().toSQL() })
}
clearRefreshCookie(ctx)
}
/**
* Révoque tous les tokens d'un user (panic mode si vol détecté, ou
* appelable par "déconnecter toutes mes sessions").
*/
export async function revokeAllForUser(userId: string): Promise<void> {
await RefreshToken.query()
.where('user_id', userId)
.whereNull('revoked_at')
.update({ revoked_at: DateTime.now().toSQL() })
}

View File

@ -0,0 +1,157 @@
import { DateTime } from 'luxon'
import RelanceTask from '#models/relance_task'
import Plan from '#models/plan'
import type Invoice from '#models/invoice'
import { getQueue } from '#services/queue'
import app from '@adonisjs/core/services/app'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
const RELANCE_QUEUE = 'relances'
/**
* En tests, les RelanceTasks DB sont créées (utile pour assertions) mais
* l'enqueue BullMQ est skippé : les tx auto-rollback laisseraient des jobs
* orphelins en Redis sinon, et on ne veut pas dépendre d'une instance
* Redis live pour tourner les tests.
*/
function shouldEnqueue(): boolean {
return app.getEnvironment() !== 'test'
}
/**
* Programme toutes les relances d'une facture selon son plan.
*
* - Pour chaque step du plan, calcule sendAt = invoice.dueDate + offsetDays
* - Crée une RelanceTask `scheduled`
* - Enqueue un BullMQ job `send-relance` avec delay = sendAt - now
*
* Si une facture est déjà en retard quand l'utilisateur confirme "toujours
* en attente", on n'envoie pas toutes les étapes passées d'un coup :
* la première étape éligible part à `now + 1 min`, puis les suivantes
* gardent l'écart du plan à partir de ce nouveau départ.
*
* Idempotent par invoice.id : si des tasks `scheduled` existent déjà
* pour cette facture, on les annule avant de re-programmer (cas on
* change de plan).
*/
export async function scheduleRelancesForInvoice(
invoice: Invoice,
trx?: TransactionClientContract
): Promise<RelanceTask[]> {
if (!invoice.planId) return []
const plan = await Plan.query(trx ? { client: trx } : undefined)
.where('id', invoice.planId)
.preload('steps', (q) => q.orderBy('order', 'asc'))
.first()
if (!plan) return []
const alreadyActive = await RelanceTask.query(trx ? { client: trx } : undefined)
.where('invoice_id', invoice.id)
.whereIn('status', ['scheduled', 'sent'])
if (alreadyActive.length > 0) {
return alreadyActive
}
// Cancel les tasks scheduled existantes (re-scheduling après changement
// de plan ou de dueDate).
const existing = await RelanceTask.query(trx ? { client: trx } : undefined)
.where('invoice_id', invoice.id)
.where('status', 'scheduled')
const queue = shouldEnqueue() ? getQueue(RELANCE_QUEUE) : null
for (const t of existing) {
if (t.queueJobId && queue) {
await queue.remove(t.queueJobId).catch(() => {
// Ignore — le job peut déjà être consommé.
})
}
t.useTransaction(trx ?? (null as never))
t.status = 'cancelled'
await t.save()
}
const now = DateTime.now()
const created: RelanceTask[] = []
const steps = plan.steps.slice().sort((a, b) => a.order - b.order)
const firstOverdueStep = steps.find(
(step) => invoice.dueDate.plus({ days: step.offsetDays }) < now
)
const catchUpAnchor = firstOverdueStep
? {
offsetDays: firstOverdueStep.offsetDays,
sendAt: now.plus({ minutes: 1 }),
}
: null
for (const step of steps) {
const sendAtRaw = invoice.dueDate.plus({ days: step.offsetDays })
const sendAt =
catchUpAnchor && step.offsetDays >= catchUpAnchor.offsetDays
? catchUpAnchor.sendAt.plus({
days: step.offsetDays - catchUpAnchor.offsetDays,
})
: sendAtRaw
const task = await RelanceTask.create(
{
organizationId: invoice.organizationId,
invoiceId: invoice.id,
planStepId: step.id,
sendAt,
status: 'scheduled',
sentAt: null,
queueJobId: null,
},
trx ? { client: trx } : undefined
)
const delay = Math.max(0, sendAt.toMillis() - now.toMillis())
const job = queue
? await queue.add(
'send-relance',
{ taskId: task.id },
{
delay,
// Idempotency : un seul job actif par task.
// BullMQ 5+ interdit `:` dans les custom jobIds → tiret.
jobId: `relance-${task.id}`,
// Retry exponentiel — si Mailpit est down, BullMQ retry 5x avec
// backoff (cf. backend.md §13.2).
attempts: 5,
backoff: { type: 'exponential', delay: 30_000 },
}
)
: null
task.queueJobId = job?.id ?? null
await task.save()
created.push(task)
}
return created
}
/**
* Annule toutes les relances futures d'une facture (appelé quand on
* mark-paid ou cancel une invoice). Les tasks déjà `sent` restent
* intactes c'est de l'historique.
*/
export async function cancelFutureRelances(
invoiceId: string,
trx?: TransactionClientContract
): Promise<void> {
const tasks = await RelanceTask.query(trx ? { client: trx } : undefined)
.where('invoice_id', invoiceId)
.where('status', 'scheduled')
if (tasks.length === 0) return
const queue = shouldEnqueue() ? getQueue(RELANCE_QUEUE) : null
for (const t of tasks) {
if (t.queueJobId && queue) {
await queue.remove(t.queueJobId).catch(() => {})
}
t.useTransaction(trx ?? (null as never))
t.status = 'cancelled'
await t.save()
}
}

View File

@ -0,0 +1,61 @@
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import Client from '#models/client'
export type ResolveClientInput = {
clientId?: string | null
clientName: string
clientEmail?: string | null
}
export type ResolveClientResult =
| { client: Client; created: boolean }
| { errorCode: 'client_email_required' }
/**
* Résolution client à la création de facture / validation d'import OCR.
*
* Priorité (mêmes règles côté API que côté MSW) :
* 1. `clientId` fourni + existant dans l'org utilise tel quel.
* 2. Match par nom (case-insensitive) sur les clients de l'org.
* 3. Création à la volée `clientEmail` REQUIS, sinon
* `{ errorCode: 'client_email_required' }`.
*
* Le contrôleur appelant transforme l'erreur en HTTP 422 avec le code stable.
*/
export async function resolveClient(
organizationId: string,
fields: ResolveClientInput,
trx: TransactionClientContract
): Promise<ResolveClientResult> {
if (fields.clientId) {
const c = await Client.query({ client: trx })
.where('organization_id', organizationId)
.where('id', fields.clientId)
.first()
if (c) return { client: c, created: false }
}
const matched = await Client.query({ client: trx })
.where('organization_id', organizationId)
.whereILike('name', fields.clientName)
.first()
if (matched) return { client: matched, created: false }
if (!fields.clientEmail) {
return { errorCode: 'client_email_required' }
}
const created = await Client.create(
{
organizationId,
name: fields.clientName,
email: fields.clientEmail,
phone: null,
address: null,
siret: null,
notes: null,
},
{ client: trx }
)
return { client: created, created: true }
}

View File

@ -0,0 +1,37 @@
/**
* Mini interpolateur Mustache-like utilisé pour les sujets/corps des
* emails de relance. Supporte les chemins pointés (`{{client.name}}`).
*
* Volontairement simple : pas d'expressions, pas de conditions, pas de
* boucles. Si un chemin manque, retourne "" (silencieux l'utilisateur
* verra un blanc, pas une exception).
*/
export function renderTemplate(template: string, vars: Record<string, unknown>): string {
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path: string) => {
const parts = path.split('.')
let val: unknown = vars
for (const p of parts) {
if (val == null || typeof val !== 'object') return ''
val = (val as Record<string, unknown>)[p]
}
return val == null ? '' : String(val)
})
}
/**
* Helper d'affichage montant : 12400 "124,00 €".
*/
export function formatAmountFr(cents: number): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
}).format(cents / 100)
}
/**
* Helper d'affichage date : ISO/Date "15/04/2026".
*/
export function formatDateFr(d: Date | string): string {
const date = typeof d === 'string' ? new Date(d) : d
return new Intl.DateTimeFormat('fr-FR').format(date)
}

View File

@ -0,0 +1,22 @@
import type Client from '#models/client'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class ClientTransformer extends BaseTransformer<Client> {
toObject() {
const c = this.resource
return {
id: c.id,
organizationId: c.organizationId,
name: c.name,
email: c.email,
contactFirstName: c.contactFirstName,
contactLastName: c.contactLastName,
phone: c.phone,
address: c.address,
siret: c.siret,
notes: c.notes,
createdAt: c.createdAt.toISO()!,
updatedAt: c.updatedAt?.toISO() ?? c.createdAt.toISO()!,
}
}
}

View File

@ -0,0 +1,33 @@
import type ImportBatch from '#models/import_batch'
import type ImportDraft from '#models/import_draft'
import { BaseTransformer } from '@adonisjs/core/transformers'
function serializeDraft(d: ImportDraft) {
return {
id: d.id,
filename: d.filename,
pdfStorageKey: d.pdfStorageKey,
extracted: d.extracted,
edited: d.edited,
confidence: d.confidence,
status: d.status,
invoiceId: d.invoiceId,
createdAt: d.createdAt.toISO()!,
updatedAt: d.updatedAt?.toISO() ?? d.createdAt.toISO()!,
}
}
export default class ImportBatchTransformer extends BaseTransformer<ImportBatch> {
toObject() {
const b = this.resource
return {
id: b.id,
organizationId: b.organizationId,
drafts: (b.drafts ?? []).map(serializeDraft),
createdAt: b.createdAt.toISO()!,
updatedAt: b.updatedAt?.toISO() ?? b.createdAt.toISO()!,
}
}
}
export { serializeDraft }

View File

@ -0,0 +1,30 @@
import type Invoice from '#models/invoice'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class InvoiceTransformer extends BaseTransformer<Invoice> {
toObject() {
const i = this.resource
return {
id: i.id,
organizationId: i.organizationId,
clientId: i.clientId,
// Le SPA affiche `clientName` dans la liste — c'est lu depuis la
// relation préchargée, sinon vide. La V1 MSW dénormalisait ce champ
// dans la table invoice, on préfère le préchargement côté API.
clientName: i.client?.name ?? '',
numero: i.numero,
amountTtcCents: i.amountTtcCents,
issueDate: i.issueDate.toISO()!,
dueDate: i.dueDate.toISO()!,
status: i.status,
planId: i.planId,
planName: i.plan?.name ?? null,
pdfStorageKey: i.pdfStorageKey,
notes: i.notes,
rubisEarned: i.rubisEarned,
paidAt: i.paidAt?.toISO() ?? null,
createdAt: i.createdAt.toISO()!,
updatedAt: i.updatedAt?.toISO() ?? i.createdAt.toISO()!,
}
}
}

View File

@ -0,0 +1,19 @@
import type Organization from '#models/organization'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class OrganizationTransformer extends BaseTransformer<Organization> {
toObject() {
const o = this.resource
return {
// UUID natif (cf. CLAUDE.md → Conventions techniques).
id: o.id,
name: o.name,
siret: o.siret,
monthlyVolumeBucket: o.monthlyVolumeBucket,
rubisCount: o.rubisCount,
onboardingCompletedAt: o.onboardingCompletedAt?.toISO() ?? null,
createdAt: o.createdAt.toISO()!,
updatedAt: o.updatedAt?.toISO() ?? o.createdAt.toISO()!,
}
}
}

View File

@ -0,0 +1,34 @@
import type Plan from '#models/plan'
import type PlanStep from '#models/plan_step'
import { BaseTransformer } from '@adonisjs/core/transformers'
function serializeStep(s: PlanStep) {
return {
id: s.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
}
}
export default class PlanTransformer extends BaseTransformer<Plan> {
toObject() {
const p = this.resource
// p.steps doit être préchargé par le controller (preload('steps'))
const steps = (p.steps ?? []).slice().sort((a, b) => a.order - b.order)
return {
id: p.id,
organizationId: p.organizationId,
slug: p.slug,
name: p.name,
description: p.description,
isDefault: p.isDefault,
steps: steps.map(serializeStep),
createdAt: p.createdAt.toISO()!,
updatedAt: p.updatedAt?.toISO() ?? p.createdAt.toISO()!,
}
}
}

View File

@ -0,0 +1,19 @@
import type User from '#models/user'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class UserTransformer extends BaseTransformer<User> {
toObject() {
const u = this.resource
return {
// id et organizationId sont des UUID (cf. CLAUDE.md → Conventions techniques).
id: u.id,
email: u.email,
fullName: u.fullName,
organizationId: u.organizationId,
signature: u.signature,
initials: u.initials,
createdAt: u.createdAt.toISO()!,
updatedAt: u.updatedAt?.toISO() ?? u.createdAt.toISO()!,
}
}
}

View File

@ -0,0 +1,41 @@
import vine from '@vinejs/vine'
const name = () => vine.string().minLength(2).maxLength(120)
const email = () => vine.string().email().maxLength(254)
// SIRET = 14 chiffres exactement (cf. INSEE).
const siret = () => vine.string().regex(/^\d{14}$/)
const phone = () => vine.string().maxLength(40)
const address = () => vine.string().maxLength(500)
const notes = () => vine.string().maxLength(2000)
// Prénom/nom du contact dédié — utilisés comme variables dans les templates
// custom ({{client.contactFirstName}}). Optionnels.
const contactName = () => vine.string().minLength(1).maxLength(80)
/**
* Validator pour POST /clients. Email **requis** : sans email, Rubis ne
* peut pas relancer (pivot produit, cf. CLAUDE.md Principes).
*/
export const createClientValidator = vine.create({
name: name(),
email: email(),
contactFirstName: contactName().nullable().optional(),
contactLastName: contactName().nullable().optional(),
phone: phone().nullable().optional(),
address: address().nullable().optional(),
siret: siret().nullable().optional(),
notes: notes().nullable().optional(),
})
/**
* Validator pour PATCH /clients/:id. Tous les champs optionnels.
*/
export const updateClientValidator = vine.create({
name: name().optional(),
email: email().optional(),
contactFirstName: contactName().nullable().optional(),
contactLastName: contactName().nullable().optional(),
phone: phone().nullable().optional(),
address: address().nullable().optional(),
siret: siret().nullable().optional(),
notes: notes().nullable().optional(),
})

View File

@ -0,0 +1,29 @@
import vine from '@vinejs/vine'
/**
* POST /invoices/upload V1 mock.
*
* Accepte un tableau de filenames (pas de fichiers réels). Quand on
* branchera Mistral + MinIO, on switchera sur multipart `files[]` avec
* upload effectif des PDFs. Le contrat côté SPA reste le même.
*/
export const uploadValidator = vine.create({
filenames: vine.array(vine.string().minLength(1).maxLength(500)).minLength(1).maxLength(20),
})
/**
* POST /invoices/import-batch/:id/drafts/:draftId/validate.
*
* Le SPA envoie les `edited` finaux (peut différer de `extracted` si
* l'utilisateur a corrigé). On les normalise puis on crée l'invoice.
*/
export const validateDraftValidator = vine.create({
clientId: vine.string().uuid().nullable(),
clientName: vine.string().minLength(1).maxLength(120),
clientEmail: vine.string().email().nullable(),
numero: vine.string().minLength(1).maxLength(50),
amountTtcCents: vine.number().min(1),
issueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
dueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
planId: vine.string().uuid().nullable(),
})

View File

@ -0,0 +1,42 @@
import vine from '@vinejs/vine'
const INVOICE_STATUSES = [
'pending',
'awaiting_user_confirmation',
'in_relance',
'paid',
'litigation',
'cancelled',
] as const
/**
* Filtres GET /invoices?status=&q=&clientId=&page=
*/
export const listInvoicesValidator = vine.create({
status: vine.enum([...INVOICE_STATUSES, 'all'] as const).optional(),
q: vine.string().maxLength(120).optional(),
clientId: vine.string().uuid().optional(),
page: vine.number().min(1).optional(),
})
/**
* POST /invoices saisie manuelle.
*
* Le SPA peut envoyer :
* - clientId d'un client existant (combobox a sélectionné une fiche), OU
* - clientName seul on tente de matcher par nom, sinon création à la
* volée mais alors clientEmail est REQUIS (pivot produit, cf. Client).
*
* On ne peut pas exprimer "email requis si pas de match" en Vine pur, donc
* c'est le contrôleur qui retourne 422 `client_email_required` si besoin.
*/
export const createInvoiceValidator = vine.create({
clientId: vine.string().uuid().optional(),
clientName: vine.string().minLength(2).maxLength(120),
clientEmail: vine.string().email().nullable().optional(),
numero: vine.string().minLength(1).maxLength(50),
amountTtcCents: vine.number().min(1),
issueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
dueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
planId: vine.string().uuid().nullable().optional(),
})

View File

@ -0,0 +1,14 @@
import vine from '@vinejs/vine'
const MONTHLY_VOLUME_BUCKETS = ['moins-10', '10-50', '50-100', '100-200', 'plus-200'] as const
/**
* Validator pour PATCH /organizations/me. Tous les champs optionnels :
* l'utilisateur peut compléter au fil de l'onboarding.
*/
export const updateOrganizationValidator = vine.create({
name: vine.string().minLength(2).maxLength(120).optional(),
// SIRET = 14 chiffres exactement, sinon null pour réinitialiser.
siret: vine.string().regex(/^\d{14}$/).nullable().optional(),
monthlyVolumeBucket: vine.enum(MONTHLY_VOLUME_BUCKETS).nullable().optional(),
})

View File

@ -0,0 +1,36 @@
import vine from '@vinejs/vine'
const RELANCE_TONES = ['amical', 'courtois', 'ferme', 'mise_en_demeure'] as const
const planStep = vine.object({
// id optionnel : présent si on édite une étape existante, absent pour
// une création (le contrôleur le générera).
id: vine.string().optional(),
order: vine.number().min(0),
// Plage : -30 (rappel avant échéance) à 180 jours (gros retards).
offsetDays: vine.number().min(-30).max(180),
tone: vine.enum(RELANCE_TONES),
subject: vine.string().minLength(1).maxLength(200),
body: vine.string().minLength(1).maxLength(5000),
requiresManualValidation: vine.boolean(),
})
/**
* Validator pour PATCH /plans/:slug. Tous les champs optionnels l'éditeur
* front peut envoyer juste `name` ou juste `steps` selon ce qu'il modifie.
*/
export const updatePlanValidator = vine.create({
name: vine.string().minLength(1).maxLength(80).optional(),
description: vine.string().maxLength(500).optional(),
steps: vine.array(planStep).minLength(1).maxLength(10).optional(),
})
/**
* Validator pour POST /plans création d'un plan custom.
* Le slug est généré côté contrôleur depuis le name.
*/
export const createPlanValidator = vine.create({
name: vine.string().minLength(1).maxLength(80),
description: vine.string().maxLength(500).optional(),
steps: vine.array(planStep).minLength(1).maxLength(10),
})

View File

@ -0,0 +1,35 @@
import vine from '@vinejs/vine'
/**
* Shared rules for email and password.
*/
const email = () => vine.string().email().maxLength(254)
const password = () => vine.string().minLength(8).maxLength(72)
/**
* Validator pour /auth/signup. Contrat aligné sur le SPA (Zod
* `registerSchema` dans packages/shared). Pas de passwordConfirmation
* côté API : la confirmation visuelle est une affaire de formulaire.
*/
export const signupValidator = vine.create({
email: email().unique({ table: 'users', column: 'email' }),
password: password(),
fullName: vine.string().minLength(2).maxLength(120),
})
/**
* Validator pour /auth/login.
*/
export const loginValidator = vine.create({
email: email(),
password: vine.string(),
})
/**
* Validator pour /account/profile (PATCH). Tous les champs optionnels.
*/
export const updateProfileValidator = vine.create({
fullName: vine.string().minLength(2).maxLength(120).optional(),
email: email().optional(),
signature: vine.string().maxLength(500).optional(),
})

47
apps/api/bin/console.ts Normal file
View File

@ -0,0 +1,47 @@
/*
|--------------------------------------------------------------------------
| Ace entry point
|--------------------------------------------------------------------------
|
| The "console.ts" file is the entrypoint for booting the AdonisJS
| command-line framework and executing commands.
|
| Commands do not boot the application, unless the currently running command
| has "options.startApp" flag set to true.
|
*/
await import('reflect-metadata')
const { Ignitor, prettyPrintError } = await import('@adonisjs/core')
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.ace()
.handle(process.argv.splice(2))
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})

45
apps/api/bin/server.ts Normal file
View File

@ -0,0 +1,45 @@
/*
|--------------------------------------------------------------------------
| HTTP server entrypoint
|--------------------------------------------------------------------------
|
| The "server.ts" file is the entrypoint for starting the AdonisJS HTTP
| server. Either you can run this file directly or use the "serve"
| command to run this file and monitor file changes
|
*/
await import('reflect-metadata')
const { Ignitor, prettyPrintError } = await import('@adonisjs/core')
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.httpServer()
.start()
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})

62
apps/api/bin/test.ts Normal file
View File

@ -0,0 +1,62 @@
/*
|--------------------------------------------------------------------------
| Test runner entrypoint
|--------------------------------------------------------------------------
|
| The "test.ts" file is the entrypoint for running tests using Japa.
|
| Either you can run this file directly or use the "test"
| command to run this file and monitor file changes.
|
*/
process.env.NODE_ENV = 'test'
import 'reflect-metadata'
import { Ignitor, prettyPrintError } from '@adonisjs/core'
import { configure, processCLIArgs, run } from '@japa/runner'
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.testRunner()
.configure(async (app) => {
const { runnerHooks, ...config } = await import('../tests/bootstrap.js')
processCLIArgs(process.argv.splice(2))
configure({
...app.rcFile.tests,
...config,
...{
setup: runnerHooks.setup,
teardown: runnerHooks.teardown.concat([() => app.terminate()]),
},
})
})
.run(() => run())
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})

View File

@ -0,0 +1,61 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import mail from '@adonisjs/mail/services/main'
import env from '#start/env'
/**
* Envoie un email de test via le mailer courant (typiquement Resend)
* pour valider la conf SPF/DKIM/clé API sans passer par toute la chaîne
* facture job BullMQ.
*
* node ace send:test-email arthur@example.com
* node ace send:test-email arthur@example.com --reply-to=patron@tpe.fr
*/
export default class SendTestEmail extends BaseCommand {
static commandName = 'send:test-email'
static description = 'Envoie un email de test via le mailer configuré (Resend en prod)'
static options: CommandOptions = {
startApp: true,
}
@args.string({ description: 'Adresse destinataire' })
declare to: string
@flags.string({ description: 'Adresse de reply-to (optionnelle)' })
declare replyTo?: string
async run() {
const driver = env.get('MAIL_DRIVER', 'smtp')
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr')
const fromName = env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle")
this.logger.info(`Driver: ${driver}`)
this.logger.info(`From: ${fromName} <${fromAddress}>`)
this.logger.info(`To: ${this.to}`)
if (this.replyTo) this.logger.info(`ReplyTo: ${this.replyTo}`)
const mailer = mail.use(driver)
const response = await mailer.send((m) => {
m.from(fromAddress, fromName)
.to(this.to)
.subject('[Rubis] Test d\'envoi via Resend')
.text(
`Bonjour,\n\n` +
`Ceci est un email de test envoyé depuis Rubis Sur l'Ongle.\n` +
`Si vous recevez ce message, la conf Resend (SPF/DKIM/API key) est OK.\n\n` +
`Driver utilisé : ${driver}\n` +
`Date : ${new Date().toISOString()}\n\n` +
`— L'équipe Rubis`
)
if (this.replyTo) m.replyTo(this.replyTo)
})
this.logger.success('Email envoyé')
// Resend renvoie un messageId dans la réponse — utile pour retrouver
// le log dans le dashboard.
if (response?.messageId) {
this.logger.info(`messageId: ${response.messageId}`)
}
}
}

93
apps/api/config/app.ts Normal file
View File

@ -0,0 +1,93 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/core/http'
/**
* The app key is used for encrypting cookies, generating signed URLs,
* and by the "encryption" module.
*
* The encryption module will fail to decrypt data if the key is lost or
* changed. Therefore it is recommended to keep the app key secure.
*/
export const appKey = env.get('APP_KEY')
/**
* The app URL can be used in various places where you want to create absolute
* URLs to your application. For example, when sending emails, images should
* use absolute URLs.
*/
export const appUrl = env.get('APP_URL')
/**
* The configuration settings used by the HTTP server
*/
export const http = defineConfig({
/**
* Generate a unique request id for each incoming request.
* Useful to correlate logs and debug a request flow.
*/
generateRequestId: true,
/**
* Allow HTTP method spoofing via the "_method" form/query parameter.
* This lets HTML forms target PUT/PATCH/DELETE routes while still
* submitting with POST.
*/
allowMethodSpoofing: false,
/**
* Enabling async local storage will let you access HTTP context
* from anywhere inside your application.
*/
useAsyncLocalStorage: false,
/**
* Redirect configuration controls the behavior of
* response.redirect().back() and query string forwarding.
*/
redirect: {
/**
* When enabled, all redirects automatically carry over the current
* request's query string parameters to the redirect destination.
* Use withQs(false) to opt out for a specific redirect.
*/
forwardQueryString: true,
},
/**
* Manage cookies configuration. The settings for the session id cookie are
* defined inside the "config/session.ts" file.
*/
cookie: {
/**
* Restrict the cookie to a specific domain.
* Keep empty to use the current host.
*/
domain: '',
/**
* Restrict the cookie to a URL path. '/' means all routes.
*/
path: '/',
/**
* Default lifetime for cookies managed by the HTTP layer.
*/
maxAge: '2h',
/**
* Prevent JavaScript access to the cookie in the browser.
*/
httpOnly: true,
/**
* Send cookies only over HTTPS in production.
*/
secure: app.inProduction,
/**
* Cross-site policy for cookie sending.
*/
sameSite: 'lax',
},
})

50
apps/api/config/auth.ts Normal file
View File

@ -0,0 +1,50 @@
import { defineConfig } from '@adonisjs/auth'
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'
import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens'
import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types'
const authConfig = defineConfig({
/**
* Default guard used when no guard is explicitly specified.
*/
default: 'api',
guards: {
/**
* Token-based guard for stateless API authentication.
*/
api: tokensGuard({
provider: tokensUserProvider({
tokens: 'accessTokens',
model: () => import('#models/user'),
}),
}),
/**
* Session-based guard for browser authentication.
*/
web: sessionGuard({
/**
* Enable persistent login using remember-me tokens.
*/
useRememberMeTokens: false,
provider: sessionUserProvider({
model: () => import('#models/user'),
}),
}),
},
})
export default authConfig
/**
* Inferring types from the configured auth
* guards.
*/
declare module '@adonisjs/auth/types' {
export interface Authenticators extends InferAuthenticators<typeof authConfig> {}
}
declare module '@adonisjs/core/types' {
interface EventsList extends InferAuthEvents<Authenticators> {}
}

View File

@ -0,0 +1,78 @@
import { defineConfig } from '@adonisjs/core/bodyparser'
const bodyParserConfig = defineConfig({
/**
* Parse request bodies for these HTTP methods.
* Keep this aligned with methods that receive payloads in your routes.
*/
allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
/**
* Config for the "application/x-www-form-urlencoded"
* content-type parser.
*/
form: {
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Content types handled by the form parser.
*/
types: ['application/x-www-form-urlencoded'],
},
/**
* Config for the JSON parser.
*/
json: {
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Content types handled by the JSON parser.
*/
types: [
'application/json',
'application/json-patch+json',
'application/vnd.api+json',
'application/csp-report',
],
},
/**
* Config for the "multipart/form-data" content-type parser.
* File uploads are handled by the multipart parser.
*/
multipart: {
/**
* Automatically process uploaded files into the system tmp directory.
*/
autoProcess: true,
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Routes where multipart processing is handled manually.
*/
processManually: [],
/**
* Maximum accepted payload size for multipart requests.
*/
limit: '20mb',
/**
* Content types handled by the multipart parser.
*/
types: ['multipart/form-data'],
},
})
export default bodyParserConfig

50
apps/api/config/cors.ts Normal file
View File

@ -0,0 +1,50 @@
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/cors'
/**
* Configuration options to tweak the CORS policy. The following
* options are documented on the official documentation website.
*
* https://docs.adonisjs.com/guides/security/cors
*/
const corsConfig = defineConfig({
/**
* Enable or disable CORS handling globally.
*/
enabled: true,
/**
* In development, allow every origin to simplify local front/backend setup.
* In production, keep an explicit allowlist (empty by default, so no
* cross-origin browser access is allowed until configured).
*/
origin: app.inDev ? true : [],
/**
* HTTP methods accepted for cross-origin requests.
*/
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
/**
* Reflect request headers by default. Use a string array to restrict
* allowed headers.
*/
headers: true,
/**
* Response headers exposed to the browser.
*/
exposeHeaders: [],
/**
* Allow cookies/authorization headers on cross-origin requests.
*/
credentials: true,
/**
* Cache CORS preflight response for N seconds.
*/
maxAge: 90,
})
export default corsConfig

View File

@ -0,0 +1,51 @@
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/lucid'
import env from '#start/env'
const dbConfig = defineConfig({
/**
* Postgres en dev/prod. SQLite reste accessible via DB_CONNECTION=sqlite
* pour les tests rapides ou un usage offline.
*/
connection: env.get('DB_CONNECTION', 'postgres'),
connections: {
postgres: {
client: 'pg',
connection: {
host: env.get('PG_HOST'),
port: env.get('PG_PORT'),
user: env.get('PG_USER'),
password: env.get('PG_PASSWORD'),
database: env.get('PG_DB_NAME'),
},
migrations: {
naturalSort: true,
paths: ['database/migrations'],
},
schemaGeneration: {
enabled: true,
rulesPaths: ['./database/schema_rules.js'],
},
debug: app.inDev,
},
sqlite: {
client: 'better-sqlite3',
connection: {
filename: app.tmpPath('db.sqlite3'),
},
useNullAsDefault: true,
migrations: {
naturalSort: true,
paths: ['database/migrations'],
},
schemaGeneration: {
enabled: true,
rulesPaths: ['./database/schema_rules.js'],
},
},
},
})
export default dbConfig

39
apps/api/config/drive.ts Normal file
View File

@ -0,0 +1,39 @@
import { defineConfig, services } from '@adonisjs/drive'
import type { InferDriveDisks } from '@adonisjs/drive/types'
import env from '#start/env'
const driveConfig = defineConfig({
default: env.get('DRIVE_DISK', 's3'),
/**
* Stockage local (filesystem) utilisé en fallback si MinIO indisponible.
* Bucket par défaut : storage/uploads (ignoré par git).
*/
services: {
fs: services.fs({
location: 'storage/uploads',
visibility: 'private',
}),
/**
* MinIO via le driver S3 (S3-compatible).
*/
s3: services.s3({
credentials: {
accessKeyId: env.get('S3_ACCESS_KEY', ''),
secretAccessKey: env.get('S3_SECRET_KEY', ''),
},
endpoint: env.get('S3_ENDPOINT'),
region: env.get('S3_REGION', 'fr-par'),
bucket: env.get('S3_BUCKET', 'rubis-invoices'),
forcePathStyle: env.get('S3_FORCE_PATH_STYLE', true),
visibility: 'private',
}),
},
})
export default driveConfig
declare module '@adonisjs/drive/types' {
export interface DriveDisks extends InferDriveDisks<typeof driveConfig> {}
}

View File

@ -0,0 +1,34 @@
import env from '#start/env'
import { defineConfig, drivers } from '@adonisjs/core/encryption'
const encryptionConfig = defineConfig({
/**
* Default encryption driver used by the application.
*/
default: 'gcm',
list: {
gcm: drivers.aes256gcm({
/**
* Keys used for encryption/decryption.
* First key encrypts, all keys are tried for decryption.
*/
keys: [env.get('APP_KEY')],
/**
* Stable identifier for this driver.
*/
id: 'gcm',
}),
},
})
export default encryptionConfig
/**
* Inferring types for the list of encryptors you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface EncryptorsList extends InferEncryptors<typeof encryptionConfig> {}
}

75
apps/api/config/hash.ts Normal file
View File

@ -0,0 +1,75 @@
import { defineConfig, drivers } from '@adonisjs/core/hash'
/**
* Hashing configuration.
*
* This starter uses Node.js scrypt under the hood.
* Node.js reference: https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback
*/
const hashConfig = defineConfig({
/**
* Default hasher used by the application.
*/
default: 'scrypt',
list: {
/**
* Scrypt is memory-hard, which makes brute-force attacks more expensive.
*/
scrypt: drivers.scrypt({
/**
* Work factor (Node alias: N / cost).
* Higher values increase security and CPU+memory usage.
*
* Tuning guideline:
* - Start with 16384.
* - Increase gradually (for example 32768) and benchmark login/signup latency.
* - Keep values practical for your slowest production machine.
*
* Node constraint: value must be a power of two greater than 1.
*/
cost: 16384,
/**
* Block size (Node alias: r / blockSize).
* Increases memory and CPU linearly.
*
* Tuning guideline:
* - Keep 8 unless you have a measured reason to change it.
* - Raise only with benchmark data, because memory usage grows quickly.
*/
blockSize: 8,
/**
* Parallelization (Node alias: p / parallelization).
* Controls how many independent computations are performed.
*
* Tuning guideline:
* - Keep 1 for most applications.
* - Increase only after load testing if your infrastructure benefits from it.
*/
parallelization: 1,
/**
* Maximum memory limit in bytes (Node alias: maxmem / maxMemory).
* Hashing throws if the estimated memory usage is above this limit.
* Node documents the check as approximately: 128 * N * r > maxmem.
*
* Tuning guideline:
* - Keep this aligned with your cost/blockSize choices.
* - Increase carefully on memory-constrained environments.
*/
maxMemory: 33554432,
}),
},
})
export default hashConfig
/**
* Inferring types for the list of hashers you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface HashersList extends InferHashers<typeof hashConfig> {}
}

View File

@ -0,0 +1,31 @@
import env from '#start/env'
import { defineConfig, stores } from '@adonisjs/limiter'
import type { InferLimiters } from '@adonisjs/limiter/types'
const limiterConfig = defineConfig({
default: env.get('LIMITER_STORE'),
stores: {
/**
* Redis store to save rate limiting data inside a
* redis database.
*
* It is recommended to use a separate database for
* the limiter connection.
*/
redis: stores.redis({}),
/**
* Memory store could be used during
* testing
*/
memory: stores.memory({})
},
})
export default limiterConfig
declare module '@adonisjs/limiter/types' {
export interface LimitersList extends InferLimiters<typeof limiterConfig> {}
}

51
apps/api/config/logger.ts Normal file
View File

@ -0,0 +1,51 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, syncDestination, targets } from '@adonisjs/core/logger'
const loggerConfig = defineConfig({
/**
* Default logger name used by ctx.logger and app logger calls.
*/
default: 'app',
loggers: {
app: {
/**
* Toggle this logger on/off.
*/
enabled: true,
/**
* Logger name shown in log records.
*/
name: env.get('APP_NAME'),
/**
* Minimum level to output (trace, debug, info, warn, error, fatal).
*/
level: env.get('LOG_LEVEL'),
/**
* Use sync destination in non-production for immediate flush.
*/
destination: !app.inProduction ? await syncDestination() : undefined,
/**
* Configure where logs are written.
*/
transport: {
targets: [targets.file({ destination: 1 })],
},
},
},
})
export default loggerConfig
/**
* Inferring types for the list of loggers you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface LoggersList extends InferLoggers<typeof loggerConfig> {}
}

46
apps/api/config/mail.ts Normal file
View File

@ -0,0 +1,46 @@
import env from '#start/env'
import { defineConfig, transports } from '@adonisjs/mail'
import type { InferMailers } from '@adonisjs/mail/types'
const mailConfig = defineConfig({
default: env.get('MAIL_DRIVER', 'smtp'),
from: {
address: env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'),
name: env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"),
},
/**
* Variables partagées par tous les templates Edge (logo, URL de base).
*/
globals: {
brandName: "Rubis Sur l'Ongle",
appUrl: env.get('APP_URL'),
},
mailers: {
/**
* SMTP Mailpit en dev (catch-all sur localhost:1025), n'importe quel
* relais SMTP en prod si on ne veut pas de provider tiers.
*/
smtp: transports.smtp({
host: env.get('SMTP_HOST', 'localhost'),
port: env.get('SMTP_PORT', 1025),
// Auth optionnelle — pas requise pour Mailpit
}),
/**
* Resend provider transactionnel par défaut en prod (cf. ADR-021).
*/
resend: transports.resend({
key: env.get('RESEND_API_KEY', ''),
baseUrl: 'https://api.resend.com',
}),
},
})
export default mailConfig
declare module '@adonisjs/mail/types' {
export interface MailersList extends InferMailers<typeof mailConfig> {}
}

29
apps/api/config/queue.ts Normal file
View File

@ -0,0 +1,29 @@
import env from '#start/env'
import { type RedisOptions } from 'ioredis'
/**
* Connexion Redis partagée pour BullMQ. On garde un objet d'options
* (et pas une instance) parce que BullMQ instancie ses propres clients
* pour chaque queue/worker.
*/
export const redisConnection: RedisOptions = {
host: env.get('REDIS_HOST', 'localhost'),
port: env.get('REDIS_PORT', 6379),
password: env.get('REDIS_PASSWORD') || undefined,
// Requis par BullMQ pour les blocking commands.
maxRetriesPerRequest: null,
}
/**
* Liste des queues. La concurrence est appliquée côté worker.
* Ajouter une queue ici ajouter un Worker correspondant dans #start/queue.ts.
*/
export const queueNames = ['ocr', 'relances', 'checkins', 'kpis'] as const
export type QueueName = (typeof queueNames)[number]
export const queueConcurrency: Record<QueueName, number> = {
ocr: 2,
relances: 5,
checkins: 5,
kpis: 1,
}

View File

@ -0,0 +1,78 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, stores } from '@adonisjs/session'
const sessionConfig = defineConfig({
/**
* Enable or disable session support globally.
*/
enabled: true,
/**
* Cookie name storing the session identifier.
*/
cookieName: 'adonis-session',
/**
* When set to true, the session id cookie will be deleted
* once the user closes the browser.
*/
clearWithBrowser: false,
/**
* Define how long to keep the session data alive without
* any activity.
*/
age: '2h',
/**
* Configuration for session cookie and the
* cookie store.
*/
cookie: {
/**
* Restrict the cookie to a URL path. '/' means all routes.
*/
path: '/',
/**
* Prevent JavaScript access to the cookie in the browser.
*/
httpOnly: true,
/**
* Send cookies only over HTTPS in production.
*/
secure: app.inProduction,
/**
* Cross-site policy for cookie sending.
*/
sameSite: 'lax',
},
/**
* The store to use. Make sure to validate the environment
* variable in order to infer the store name without any
* errors.
*/
store: env.get('SESSION_DRIVER'),
/**
* List of configured stores. Refer documentation to see
* list of available stores and their config.
*/
stores: {
/**
* Store session data inside encrypted cookies.
*/
cookie: stores.cookie(),
/**
* Store session data inside the configured database.
*/
database: stores.database(),
},
})
export default sessionConfig

95
apps/api/config/shield.ts Normal file
View File

@ -0,0 +1,95 @@
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
/**
* Configure CSP policies for your app. Refer documentation
* to learn more.
*/
csp: {
/**
* Enable the Content-Security-Policy header.
*/
enabled: false,
/**
* Per-resource CSP directives.
*/
directives: {},
/**
* Report violations without blocking resources.
*/
reportOnly: false,
},
/**
* Configure CSRF protection options. Refer documentation
* to learn more.
*/
csrf: {
/**
* Enable CSRF token verification for state-changing requests.
*/
enabled: false,
/**
* Route patterns to exclude from CSRF checks.
* Useful for external webhooks or API endpoints.
*/
exceptRoutes: [],
/**
* Expose an encrypted XSRF-TOKEN cookie for frontend HTTP clients.
*/
enableXsrfCookie: true,
/**
* HTTP methods protected by CSRF validation.
*/
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
},
/**
* Control how your website should be embedded inside
* iframes.
*/
xFrame: {
/**
* Enable the X-Frame-Options header.
*/
enabled: true,
/**
* Block all framing attempts. Default value is DENY.
*/
action: 'DENY',
},
/**
* Force browser to always use HTTPS.
*/
hsts: {
/**
* Enable the Strict-Transport-Security header.
*/
enabled: true,
/**
* HSTS policy duration remembered by browsers.
*/
maxAge: '180 days',
},
/**
* Disable browsers from sniffing content types and rely only
* on the response content-type header.
*/
contentTypeSniffing: {
/**
* Enable X-Content-Type-Options: nosniff.
*/
enabled: true,
},
})
export default shieldConfig

Some files were not shown because too many files have changed in this diff Show More