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