66 Commits

Author SHA1 Message Date
ordinarthur
6c3b5e36b9 feat(pwa): manifest installable + icons gem rubis sur fond crème
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 58s
Build & Deploy API / build-and-deploy (push) Successful in 1m35s
L'app est désormais installable sur écran d'accueil (Android via Chrome,
iOS via Safari → Partager → Sur l'écran d'accueil). Identité visuelle
strictement alignée sur le SPA :
  - background_color : crème (#FAF7F2)
  - theme_color      : rubis primaire (#9F1239)
  - icon             : gem 4-facettes sur canvas crème, padding 15% pour
                       safe-area maskable Android tout en remplissant
                       suffisamment iOS qui ne masque pas

3 formats générés depuis le SVG via @resvg/resvg-js :
  - icon-192.png + icon-512.png (manifest, splashscreen Android)
  - apple-touch-icon.png 180×180 (iOS home screen)
Plus la SVG vectorielle servie en favicon.

Le script `pnpm --filter @rubis/web run icons` re-génère tout depuis
`public/icon.svg` — utile si on retouche le design.

Drop le favicon.svg de 1.1 MB hérité du landing (vestige), remplacé par
notre gem propre à 1.1 KB.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:38:32 +02:00
ordinarthur
639191bef9 feat(layout): sidebar repliable + Gem SVG soignée partout
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 21s
Sidebar : nouveau bouton chevron en bas qui replie/déplie (240px ↔ 68px).
Choix persisté en localStorage (`rubis.sidebar.collapsed`). En mode replié :
  - Brand → gem seule (28px)
  - NavLinks → icône centrée + tooltip Radix au hover qui montre le label
  - Compteur rubis → version ultra-compacte (gem 16px + chiffre empilés),
    tooltip réaffiche "Rubis ce mois · ≈ Xh libérées"
  - Marqueur rubis vertical de l'item actif préservé

Gem : refonte du SVG. 4 facettes triangulaires se rejoignent au centre,
chacune avec une opacité différente (1.0 / 0.8 / 0.65 / 0.48) pour suggérer
le jeu de lumière d'une pierre taillée — sans gradient, qui devient pâteux
à petite taille. Contour propre `strokeLinejoin: round` pour la silhouette.

Drop `logo.png` : `<Brand/>` utilise maintenant la Gem SVG en interne. Plus
aucune dépendance à l'asset PNG bordé/arrondi qui rendait flou aux grosses
tailles. Toutes les surfaces (sidebar, RubisHero, compteur) partagent la
même icône scalable héritant de `currentColor`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:32:46 +02:00
ordinarthur
b96b62aab6 feat(seed): génération PDF cohérente par facture via @react-pdf/renderer
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 57s
Build & Deploy API / build-and-deploy (push) Successful in 1m40s
Chaque facture seedée a maintenant son propre PDF dont le contenu
matche exactement les meta DB (vendeur = org du user, client = client
réel, numéro / dates / montant cohérents). Plus de réutilisation
round-robin de PDFs disque non-cohérents.

Stack :
  - @react-pdf/renderer : composants React déclaratifs, StyleSheet
    inspiré du SPA (mêmes tokens couleur Rubis), même mental model que
    le frontend.
  - InvoiceDocument décomposé en sous-composants Header / Addresses /
    ItemsTable / Totals / Footer pour itération facile.
  - Items générés depuis un pool B2B (conseil, dev, audit, formation,
    livraison, photo, …) avec quantités/prix unitaires qui s'ajustent
    pour que la somme matche le total TTC stocké.

Le command `seed:demo --reset` :
  - wipe les invoice-pdfs/{orgId}/* sur MinIO (paginé)
  - re-génère 227 PDFs (27 actionnables + 200 historiques)
  - CA cumulé paid ≈ 400 K€ pile sur la cible

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 12:59:55 +02:00
ordinarthur
040e787ee5 chore(seed): rendre seed:demo utilisable en prod
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 58s
- Dockerfile.api : copie `assets/test-invoices/` dans l'image (les 27
  PDFs servent au seed démo, ~80KB, négligeable).
- factories.ts : ajout d'un 3e candidat de chemin pour couvrir le
  contexte prod où la commande tourne depuis `/app/apps/api/build`.

Permet de peupler une org démo en prod via :
  kubectl -n rubis exec -it deploy/rubis-api -- \\
    sh -c 'cd /app/apps/api/build && node ace.js seed:demo \\
      --email <user> --reset --org-name="<nom>"'

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 12:41:15 +02:00
ordinarthur
68ed8f2ec6 feat(api): logs fichier en dev + traces du flow relance/mail
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 29s
Build & Deploy API / build-and-deploy (push) Successful in 1m7s
config/logger.ts: en dev, on duplique les logs vers
apps/api/storage/logs/app.log via un `multistream` pino (pretty stdout
+ JSON file en parallèle). Plus fiable que `transport.targets` qui
tourne dans un worker thread et fail silencieusement quand le path
n'est pas accessible.

Logs ciblés sur le pipeline relance pour debug rapide :
  - relance_scheduler : tâche créée + delaySec + queueJobId
  - send_relance_job  : pick-up / skip / envoi / OK / KO
  - mail_dispatcher   : driver actif (smtp/resend) + send OK / err

.gitignore : storage/uploads + storage/logs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 12:37:20 +02:00
ordinarthur
92a9fac62b feat(checkin): modale in-app pour confirmer le paiement au login
Permet à l'user de répondre aux check-ins directement dans l'app, sans
passer par les liens email. Au mount du layout `_app`, on liste les
factures en `awaiting_user_confirmation` et on les présente une par une
dans une modale séquentielle :

  - "Oui, payée"     → mark paid + bonus rubis + cancel relances
  - "Non, en attente" → schedule relances + status → in_relance
  - "Plus tard"       → skip session-only

3 endpoints auth-protected sous /api/v1/checkin/inapp/ (déclarés AVANT
le groupe public à token sinon /:token/pending mange /inapp/pending).

La modale fait toujours confiance au serveur : queue = pending refetch,
display = queue[0], pas de cursor manuel — sinon on saute des factures
quand le serveur retire la réponse précédente.

Wording rassurant : "Aucune relance ne part sans votre validation".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 12:37:09 +02:00
ordinarthur
89c9a732d6 add chart details
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 29s
Build & Deploy API / build-and-deploy (push) Successful in 1m7s
2026-05-07 11:42:36 +02:00
ordinarthur
1633fb9bf0 add factories
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 59s
Build & Deploy API / build-and-deploy (push) Successful in 1m37s
2026-05-07 11:34:00 +02:00
ordinarthur
933c6496b1 feat(demo): mode démo live — horloge virtuelle + emails capturés
Permet de faire vivre Rubis en accéléré pour démontrer le produit à des
prospects, SANS impacter la prod. Les vrais users ont demoMode=false par
défaut → toute la logique démo est court-circuitée.

Architecture (priorité : zéro impact prod, codebase propre)

Phase 1 — Abstraction Clock
- Migration : organizations.demo_mode + virtual_now + demo_speed_factor
  (défaut false/null/1, zéro effet sur les orgs existantes)
- services/clock.ts : now(orgId?) → DateTime.utc() en prod, virtualNow
  en démo. Cache mémoire 250ms pour pas spammer la DB. Helpers
  setVirtualNow / setDemoMode pour les transitions.
- Refacto 7 fichiers : relance_scheduler, checkin_scheduler, dashboard,
  send_relance_job, send_checkin_job, mail_dispatcher (buildRelanceVars
  daysLate), activity_recorder, checkin_controller, invoices_controller
  (buildTimeline + markPaid). DateTime.now() → clock.now(orgId).
- Tests existants (51) passent identique → preuve que la prod est intacte.

Phase 2 — Capture emails + dispatch
- Migration : demo_captured_emails (kind, to, from, subject, body, sent_at,
  meta) — index sur (org, sent_at desc) pour l'inbox.
- services/demo/capture.ts : captureEmailIfDemo() — UNIQUE point de fork
  dans la prod (deux lignes dans mail_dispatcher : if captured return).
  Hors démo, fonction retourne false → flux Resend inchangé.
- services/demo/dispatch.ts : tickAndDispatch(orgId, target) → bump
  virtual_now, trouve les tasks dues (relance + checkin), invoke les
  handlers existants synchronement (skip BullMQ, propre). Retourne les
  events fired pour l'UI.
- POST /api/v1/demo/{start,end,tick} + GET /demo/{state,inbox}, toutes
  protégées par requireDemoOrg() (403 si demoMode=false).

Phase 3 — UI horloge "vivante"
- lib/demo.ts : useDemoState, useDemoTick (boucle rAF locale qui avance
  virtualNow à `speed * elapsed` jours/sec, sync backend toutes les
  250ms, auto-pause sur fired events). Pas de boutons +1j/+3j —
  l'horloge tourne vraiment.
- DemoClock (top-right, fixed) : date pleine en font display, rail
  rubis-glow avec pastille ◆ qui glisse vers le prochain event,
  play/pause + sélecteur 1x/2x/5x. Auto-cachée si demoMode=false.
- DemoEmailSlide : slide-over droite quand event fires — affiche
  l'email capturé (de/à/sujet/body) façon vrai client mail. Pause
  forcée tant que tous les events ne sont pas acquittés ("comme si
  le temps était vraiment passé").
- DemoToggle dans /parametres : démarrer/quitter le mode démo, avec
  copy explicite ("emails capturés, pas envoyés à de vrais clients").

Le code démo vit isolé dans services/demo/, controllers/demo_controller.ts,
components/demo/, lib/demo.ts. La prod ne référence ces fichiers QUE
via captureEmailIfDemo dans mail_dispatcher.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 10:42:59 +02:00
ordinarthur
6eb9ca4120 feat(ui): GlossaryTerm — tooltip de définition sur DSO / LME / Mise en demeure
Quand un terme métier apparaît (DSO moyen, LME, mise en demeure…), un
petit astérisque rubis à côté indique qu'il est hoverable. Au hover/focus
clavier, une popover s'affiche avec la définition courte (qui ce fait,
pourquoi ça compte, repère LME 30j).

Implémentation :
- components/ui/GlossaryTerm.tsx : wrap n'importe quel ReactNode + définition,
  utilise @radix-ui/react-tooltip (déjà dans la stack pour Dialog), Asterisk
  Lucide en marker, underline pointillée subtile pour signaler "interactif"
- lib/glossary.tsx : définitions centralisées (DSO, LME, mise en demeure,
  encaissé, rubis) — single source of truth, ton produit cohérent
- KpiCard.label / SummaryCard.label passent à React.ReactNode pour
  accepter le wrapping
- Wiring : "DSO moyen" sur dashboard (KpiCard + titre du chart) et /insights
  (récap + titre du chart). LME aussi taggée dans le sous-titre du DSO chart.

Aucun nouveau dep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 10:15:44 +02:00
ordinarthur
2d3766cc3d feat(dashboard): dataviz cohérente DA Rubis (3 charts + page Insights)
Backend
- Service dashboard.ts : computeTimeseries + computeClientTimeseries
  (helper fetchPaidByMonth DRY entre les deux). Buckets pré-créés sur
  N mois pour pas afficher de "trous" quand un mois n'a aucun paiement.
- GET /dashboard/timeseries?range=3|6|12 (paidByMonth + pipelineByStatus)
- GET /clients/:id/timeseries?range=3|6|12 (paidByMonth filtré)

Frontend — Recharts (43 deps, ~50KB gzip)
- components/charts/theme.ts : palette stricte (rubis + neutres chauds,
  pas de bleu/vert), couleurs statuts cohérentes avec les badges côté
  liste, format fr-FR pour les axes/tooltips
- ChartTooltip themed : carte cream + bordure rubis-glow, font Inter,
  tabular-nums, série label override
- EncaisseChart (area, dégradé rubis-glow → transparent)
- DsoTrendChart (line ink + référence pointillée à 30j = norme LME)
- PipelineChart (donut avec total au centre + PipelineLegend séparée)
- ClientPaidChart (bar chart compact pour fiche client)

Wiring
- Dashboard / : encaissé + DSO côte à côte, pipeline + top retards en dessous
- Fiche client /clients/:id : mini bar chart "encaissés sur 6 mois" entre
  les stats et la liste factures
- Page /insights : version pleine largeur des 3 charts + range selector
  3m/6m/12m + 3 cards récap (encaissé total, factures payées, DSO moyen).
  Lien "Insights" ajouté au sidebar desktop (icône TrendingUp).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 10:11:45 +02:00
ordinarthur
32fcb02108 feat(api): factories de démo + commande seed:demo --email pour peupler une org
Factories réutilisables (database/factories.ts) :
- makeClient — un client à partir de 8 templates FR (Boulangerie Martin,
  Maçonnerie Dupont, etc.) avec contact/SIRET/adresse réalistes
- makeInvoice — une facture avec status driving les dates et le rubis
  earned (pending = future, in_relance = échue récente, paid = paidAt
  cohérent, etc.)
- makeActivityForInvoice — events alignés sur le statut (import/relance/paid)
- seedDemoOrg — recette V1 : 8 clients + 15 factures réparties sur 5
  statuts (5 paid sur 6 mois, 4 in_relance, 2 awaiting_user_confirmation,
  3 pending, 1 litigation) → fait vivre dashboard, factures et DSO

Commande Ace seed:demo
- Args : --email <email> (obligatoire), --reset (wipe avant), --orgName
- Flow : trouve user, configure son org (nom + bucket), provision les
  4 plans par défaut (idempotent), seed la data, met à jour rubis_count
- Pose une signature email par défaut sur le user si vide
- Tout en transaction : pas d'état inconsistant si une étape plante

Usage :
  node ace seed:demo --email arthurbarre.js@gmail.com
  node ace seed:demo --email ... --reset --orgName="Maçonnerie Dupont"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 09:57:41 +02:00
ordinarthur
4113cb56d3 feat(import): preview réelle du PDF/image dans le volet gauche
Pourquoi ce n'était pas visible jusqu'à maintenant :
PdfPreview était un placeholder V1 (barres rubis-glow + nom du fichier).
Le commentaire dans le composant le disait explicitement : "Le vrai
render PDF arrivera quand le backend stockera réellement les fichiers
dans MinIO." Le backend stocke bien les uploads (cf. drive.use().putStream
dans ImportBatches.upload, storageKey dans la table import_drafts), mais
on n'avait jamais wiré l'endpoint de streaming ni le viewer côté SPA.

Backend
- GET /invoices/import-batch/:id/drafts/:draftId/pdf : stream le binaire
  depuis MinIO via Drive, content-type adapté (PDF/PNG/JPG), Cache-Control
  privé 5min, Content-Disposition inline pour permettre le rendu dans
  un <iframe>/<embed> sans téléchargement forcé. Auth Bearer (vérification
  d'org via loadBatchOrFail).

Frontend
- api.fetchBlob() : helper pour fetch binaires avec Bearer auto-injecté
  (le JSON-only existant ne marche pas pour les PDF).
- PdfPreview accepte batchId+draftId+pdfAvailable, fetch le binaire au
  mount, crée un object URL, affiche :
  · <iframe> pour les PDF (viewer Chrome/Safari natif)
  · <img> pour les images (PNG/JPG)
  · Spinner pendant le chargement, fallback "barres" si pdfAvailable=false
    (ex. mode mock MSW), erreur visible si 404 / network down
- Cleanup URL.revokeObjectURL au unmount pour pas leaker la mémoire
- pdfStorageKey ajouté au type ImportDraft côté SPA

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 09:49:08 +02:00
ordinarthur
2c2724c634 fix(clients): retourner les vraies factures du client (TODO restant)
Le détail client servait `invoices: []` en hardcoded — la liste sur
/clients/:id était toujours vide. On préload la relation client + plan
sur les factures de l'org filtrées par client_id, on trie par priorité
de statut puis échéance, et on serialize via InvoiceTransformer (même
shape que GET /invoices, donc le SPA peut réutiliser InvoiceListItem).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 09:43:16 +02:00
ordinarthur
ec2232e4b3 fix(auth/sso): SsoButton href utilise VITE_API_URL pour fonctionner en dev
En dev le SPA tourne sur :5173 et l'API sur :3333. Un href relatif
`/api/v1/auth/google/redirect` tape Vite (404). On préfixe par
`env.VITE_API_URL` (http://localhost:3333 en dev, https://app... en prod
où nginx reverse-proxy /api/* — donc l'URL est self-referential et
fonctionne dans les deux cas).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 09:40:41 +02:00
ordinarthur
7521e1fff6 feat(auth): Microsoft 365 SSO + factorisation helper SSO partagé
Backend
- Custom Ally driver Microsoft (Oauth2Driver) — Microsoft n'est pas dans
  les providers built-in, mais le driver dérive de Oauth2Driver en quelques
  lignes. Endpoints v2.0 (Microsoft Identity Platform), Graph /me pour le
  profil, fallback userPrincipalName si mail null (comptes perso).
- Tenant configurable via MICROSOFT_TENANT (défaut 'common' — accepte
  work/school + perso ; 'organizations' pour M365 strict).
- Migration 1400 : ajout microsoft_id nullable unique sur users.
- AuthMicrosoftController : redirect + callback (même pattern que Google).
- Refacto : extraction d'un service sso_session.ts (findOrCreateUserFromSso,
  nextRouteAfterSso, emitSsoSessionAndRedirect) → AuthGoogle + AuthMicrosoft
  partagent la logique.
- Routes /api/v1/auth/microsoft/{redirect,callback}.

Frontend
- Composant SsoButton générique (provider="google"|"microsoft") avec logo
  officiel inline pour chaque. Remplace l'ancien GoogleButton.
- Login + signup : pile verticale "Continuer avec Google" + "Continuer
  avec Microsoft", puis séparateur "ou", puis form email/password.
- Route SPA renommée /auth/google/complete → /auth/sso/complete (partagée
  entre les deux providers, la callback API redirige toujours dessus).
- Erreurs SSO sur /login : ?google=... ET ?microsoft=... → toast contextuel.

K3s
- ConfigMap rubis-api-config : ajout MICROSOFT_TENANT + MICROSOFT_CALLBACK_URL.
- Secret rubis-app-secrets : ajout MICROSOFT_CLIENT_ID + MICROSOFT_CLIENT_SECRET.

Doc
- .claude/deploy-memory.md : procédure Azure / Entra ID app registration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 09:38:38 +02:00
ordinarthur
ea539cd1d4 feat(auth): Google SSO via @adonisjs/ally
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 55s
Build & Deploy API / build-and-deploy (push) Successful in 1m35s
Backend
- @adonisjs/ally installé + provider Google configuré (config/ally.ts)
  scopes: userinfo.email + userinfo.profile (non-sensibles, validation
  auto par Google)
- Migration : ajoute google_id (nullable unique) sur users + rend password
  nullable (un user créé via Google n'a pas de mdp en base, il pourra
  l'activer plus tard via "mot de passe oublié")
- AuthGoogleController.redirect : entrée OAuth (le bouton SPA pointe ici)
- AuthGoogleController.callback : matche par google_id puis email,
  crée org+plans+user si nouveau, pose le refresh cookie httpOnly,
  redirige le browser vers le SPA /auth/google/complete?next=...
  (next = / pour user complet, /onboarding/entreprise pour nouveau)
- Routes : GET /api/v1/auth/google/{redirect,callback}
- Env : GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_CALLBACK_URL

Frontend
- Composant GoogleButton réutilisable (full-page redirect, pas fetch —
  OAuth nécessite navigation pour les cookies cross-origin Google)
- AuthDivider "ou" entre SSO et formulaire email/password
- Boutons ajoutés sur /login et /signup
- Route /auth/google/complete : appelle POST /api/v1/auth/refresh (le
  cookie posé par la callback est auto-envoyé), stocke access token +
  user dans authStore, navigue vers `next`. Échec → /login + toast.
- Toast d'erreur sur /login si on revient avec ?google=denied|error|...

K3s
- ConfigMap rubis-api-config : ajout GOOGLE_CALLBACK_URL prod
- Secret rubis-app-secrets : ajout GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET
  (posés via kubectl, pas dans le manifest)

Doc
- .claude/deploy-memory.md mis à jour avec la procédure Google Cloud
  Console (créer OAuth client, redirect URIs, écran de consentement)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 09:24:27 +02:00
ordinarthur
dd93249362 refactor(deploy): split monolithique en 2 services (rubis-web nginx + rubis-api Node)
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m16s
Build & Deploy API / build-and-deploy (push) Successful in 1m32s
Avant : une seule image (Dockerfile.app) qui bundle AdonisJS + SPA static.
Après : deux images, deux deployments, deux workflows CI avec path filters
indépendants.

Architecture
- rubis-web (NodePort 30110, exposé via Traefik)
  · nginx-alpine + SPA Vite dist + nginx.conf
  · sert /assets/* (cache 1y immutable), / (try_files index.html SPA fallback)
  · reverse-proxy /api/* → rubis-api.rubis.svc.cluster.local:3333
- rubis-api (ClusterIP, accessible uniquement depuis le cluster)
  · AdonisJS V7 + workers BullMQ dans le même process
  · init-container migrate (idempotent, depuis build/)
  · /api/v1/health pour les probes K3s + healthcheck Docker
- rubis-redis (ClusterIP, inchangé)

Bénéfices
- Build/deploy indépendants : changement front ne reconstruit pas l'API,
  changement API ne reconstruit pas le SPA
- nginx en frontal donne du gzip + cache long sur les assets fingerprintés
- API n'expose plus de surface publique (defense in depth)
- Routes plus simples : on retire le wildcard SPA fallback dans
  start/routes.ts (nginx s'en charge), on retire @adonisjs/static aurait
  été cohérent mais on le garde pour minimiser les diffs

Files
- Dockerfile.api (replaces Dockerfile.app, Node-only)
- Dockerfile.web (new, nginx)
- apps/web/nginx.conf (new)
- k3s/app/api.yml (replaces deployment.yml + service.yml, ClusterIP)
- k3s/app/web.yml (new, NodePort 30110)
- .gitea/workflows/deploy-{api,web}.yml (replaces deploy-app.yml)
- /api/v1/health route ajoutée

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 02:58:25 +02:00
ordinarthur
57907a5d68 fix(deploy): contourner @poppinss/ts-exec via tsx pour le build API
Some checks failed
Build & Deploy App / build-and-deploy (push) Failing after 30s
Le CI plantait avec ERR_UNKNOWN_FILE_EXTENSION sur bin/console.ts —
le hook @poppinss/ts-exec (basé sur @swc/core) ne s'enregistre pas
à temps avant l'import du premier .ts dans certains environnements
de build (vu local M-series ET CI Gitea linux/amd64).

Solution : appeler ace via tsx (esbuild-based, gère nativement .ts
dès le démarrage). Plus fiable cross-platform.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 02:34:55 +02:00
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
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
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