Stack : `i18next` + `react-i18next` (≈ 42 kB → 13 kB gzip). Init avant
le 1er render dans `main.tsx` pour que les chaînes soient résolues dès
le bootstrap.
Détection de la locale au 1er load :
1. `localStorage["rubis:locale"]` (préférence explicite de l'user)
2. `navigator.language` (langue du navigateur)
3. fallback `fr`
→ un user EN voit l'app en EN dès la 1re visite sans intervention.
La fonction `setLocale()` persiste le choix + synchronise `<html lang>`.
Architecture :
- `src/i18n/{types,fr,en,index}.ts` — même pattern que landing :
FR fait foi, EN typé par `Dict = typeof fr`.
- `components/settings/LanguageSwitcher.tsx` — radio FR/EN dans
`/parametres` (section ajoutée en tête).
Surfaces traduites en V1 :
- shell : AppSidebar (nav + compteur rubis), MobileTabBar, UserMenu,
FallbackError.
- auth : login, signup, onboarding/compte.
- main : dashboard `_app/index`, `_app/parametres` (sections compte,
entreprise, signature, abonnement, facturation, marque, banque,
danger zone).
Routes restantes (`factures`, `clients`, `plans`, `insights`,
`admin.blog`, sous-routes parametres) restent en FR inline ; le dico
EN les anticipe déjà via `factures.*`, `clients.*`, `plans.*`,
`insights.*` — il suffira de hooker `useTranslation()` au moment de
traduire ces écrans.
Emails côté API restent FR — à brancher sur une `locale` org plus tard.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- invoice_uploaded : un event par draft (avec invoice_id, filename,
batch_id, batch_size) au lieu d'un event groupé { count }. Permet de
lister les uploads individuellement et de cibler une facture précise
dans les insights. Amount pas dispo à ce stade (OCR pas encore validé).
- payment_completed : nouvel event sur mark-paid, avec amount_cents,
amount_eur et payment_method (= 'manual' en V1). Alimente le funnel
facture → paiement et la liste « payeurs » côté PostHog.
- invoice_marked_paid : conservé tel quel pour ne pas casser l'insight
S5hMZkDE (Invoice paid vs relance launched).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Contexte pour les futures itérations agent : skill PostHog
integration-react-tanstack-router-file-based + rapport du wizard
(événements instrumentés, dashboards créés, next steps).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Setup PostHog côté SPA — pageviews TanStack Router + 10 events business
(signup, login SSO, upload facture, émission/brouillon facture native,
marquer payée, lancer relance, plan créé, checkout Stripe). PostHogProvider
dans __root.tsx, identify sur auth, proxy nginx /ingest/* → eu.i.posthog.com
pour contourner les adblockers. Token bake via build-arg CI
(POSTHOG_PROJECT_TOKEN, à ajouter côté Gitea Secrets).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Deux bugs visibles dans /parametres :
1. **Banque** — un user Free voyait la carte "Plan Pro ou Business
requis" alors que la feature est gated derrière BANKING_ENABLED=false
en prod (Powens KYC en cours). L'upgrade n'aurait rien débloqué.
Fix : la branche `comingSoon` court-circuite l'upsell, et le titre +
description de la SettingsSection bascule en mode teaser ("Bientôt :
votre banque connectée à Rubis") pour rester cohérent avec la carte
"Bientôt disponible" en dessous.
2. **Démonstration** — la section apparaissait pour tous les users,
alors que c'est un outil de prospection commerciale réservé aux
admins Rubis (horloge virtuelle + capture des emails). Déroutant
pour un user lambda.
Fix : section gated sur `user.isAdmin` côté UI, et split des routes
/demo côté API :
- GET /demo/state reste accessible à tous les users authed (sinon le
DemoClock dans AppLayout spam des 403 sur chaque page). Un user
normal reçoit `{active: false}` — pas de leak.
- GET /demo/inbox + POST /demo/start, /end, /tick : auth + admin.
Mutations et lecture des emails capturés.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Page split-view qui permet de composer une facture native dans Rubis
avec preview PDF en live (debounce 500 ms via POST /invoices/preview-pdf
→ Blob → objectURL → iframe).
UI
- Gauche : panneau d'édition organisé en cards (destinataire,
dates + plan, lignes éditables, thème + accent, notes).
- Droite : iframe sticky qui affiche le PDF rendu côté serveur. Loader
discret pendant la génération, fallback "sélectionnez un client" tant
qu'on n'a pas un payload minimal valide.
- Lignes : ajout/suppression, quantité décimale (heures, demi-jours),
taux TVA selon FRENCH_TVA_RATES, total HT recalculé live.
- Totaux client-side : mêmes règles d'arrondi (Math.round par ligne)
que invoice_totals.ts côté serveur — feedback instantané, le serveur
recalcule à la persistance.
- Footer sticky : "Enregistrer en brouillon" / "Émettre la facture",
avec rappel que l'émission consomme la séquence (irréversible).
API client
- `useCreateNativeInvoice` : POST /invoices/native, invalide les caches
invoices + counts.
- `previewInvoicePdf(input, signal)` : POST /invoices/preview-pdf qui
retourne un Blob (annulable via AbortSignal pour les frappes rapides).
- `api.postBlob` : helper générique POST+JSON → Blob (inverse de fetchBlob).
Defaults : les settings résolus de l'org (theme, accent, paymentTermsDays)
sont chargés une fois au mount et appliqués comme valeurs initiales.
Liste factures : remplace le bouton "Nouvelle facture" par deux actions
côte-à-côte — "Importer" (secondaire, mène à /factures/import) et
"Créer une facture" (primaire, mène à /factures/nouvelle).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ajoute la page de configuration de l'éditeur de factures natif côté SPA,
plus les hooks React Query pour /organizations/me/invoice-settings et
/invoice-themes.
Sections (chacune avec son propre Save → blast radius clair) :
1. Identité émetteur (raison sociale, forme juridique, adresse structurée,
SIREN/SIRET/TVA intracom, NAF, RCS, capital, contact). Snapshotée
à chaque émission dans `invoices.issuer_snapshot` — modifier ces
champs n'altère pas les factures déjà émises.
2. RIB (IBAN normalisé à l'enregistrement, BIC, nom de banque).
3. Numérotation avec aperçu live "FAC-2026-0042" — préfixe + prochain
numéro + padding éditables. Une fois la première facture émise, le
compteur s'auto-incrémente.
4. Mentions & délais — délai de paiement par défaut + textes légaux
préremplis (pénalités art. L441-10, escompte art. L441-9, libre).
5. Thème par défaut + couleur d'accent — galerie 4 thèmes avec previews
miniatures CSS (pas de PDF embed pour la galerie : trop lourd).
Ajoute aussi le lien vers /parametres/facturation depuis /parametres
(section "Facturation" placée avant "Marque").
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pose les fondations pour permettre aux utilisateurs de créer leurs
factures directement dans Rubis (en complément de l'upload OCR existant),
avec snapshots immuables, numérotation strict séquentielle (art. 242
nonies A CGI) et 4 thèmes pré-faits paramétrables.
Data model
- organizations.invoice_settings (JSONB) : thème par défaut, accent color,
préfixe et compteur de numérotation, mentions légales (pénalités,
escompte), identité émetteur (SIREN/SIRET/TVA intra/RCS/capital), RIB.
- clients enrichi : SIREN, TVA intra, adresse structurée (lines/zip/city
/country). Le champ address legacy reste pour les clients pré-feature.
- invoices enrichi : lines (JSONB), client_snapshot + issuer_snapshot
figés à l'émission, amount_ht/tva, tva_breakdown, payment_terms_days,
theme_slug + theme_accent_color, is_native, sequence_number (unique
per org), pdf_generated_at.
API
- GET/PATCH /organizations/me/invoice-settings (resolveInvoiceSettings)
- GET /invoice-themes (4 thèmes : classique, moderne, minimal, élégant)
- POST /invoices/native (séquence strict allouée en transaction,
totaux recalculés serveur, snapshots immuables)
- POST /invoices/preview-pdf (stream PDF sans persister, stub Phase 1)
Le rendu PDF lui-même (@react-pdf/renderer + templates) arrive en
Phase 2 ; le storeNative crée bien la facture mais pdf_storage_key
reste null jusqu'à Phase 2. Conformité Factur-X visée pour V1.5
(Q3-Q4 2026, avant l'échéance d'émission TPE-PME au 1er sept 2027).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ajoute un état intermédiaire entre "feature désactivée silencieusement"
et "feature pleinement active" : un teaser visible dans /parametres
pour les Pro/Business qui annonce que la connexion bancaire arrive,
avec une note rassurante sur la lecture seule. Permet d'annoncer la
feature aux users payants pendant le délai d'agrément AISP / KYC
Powens, sans risque de cliquer dans le vide.
- Nouveau flag d'env `BANKING_TEASER_ENABLED` (boolean, default false)
- `GET /banking/status` renvoie désormais `{ enabled, comingSoon }`
où comingSoon = !enabled && BANKING_TEASER_ENABLED
- `BankingSection` : nouveau composant `ComingSoonCard` (halo glow,
copy explicite sur l'agrément AISP en cours, rassurance lecture
seule) affiché quand comingSoon=true et l'org est Pro/Business
- `parametres.tsx` : la section "Banque" apparaît si enabled OU
comingSoon (au lieu de uniquement enabled)
- ConfigMap K3s : `BANKING_TEASER_ENABLED='true'` en prod pour
annoncer la feature aux clients payants pendant le KYC
Trois états possibles désormais :
enabled=true → feature active (post-KYC)
enabled=false + comingSoon=true → teaser "Bientôt disponible"
enabled=false + comingSoon=false → section invisible (kill switch dur)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Module banking complet en lecture seule via Powens (ex-Budget Insight)
pour détecter automatiquement les paiements clients et arrêter les
relances dès qu'une facture est payée. Réservé plans Pro / Business,
kill switch global BANKING_ENABLED désactivé en prod tant que le KYC
Powens n'est pas validé (cf. .claude/deploy-memory.md).
Backend (apps/api)
- PowensClient bas niveau : init user, code temporaire 30s, build
webview URL, list/get/delete connections, accounts, transactions,
vérif HMAC SHA-256 timing-safe pour webhook.
- BankingService : ensurePowensUser (chiffrement token via Adonis
encryption / APP_KEY), createWebviewUrl avec state HMAC anti-CSRF
(TTL 10 min), handleCallback (upsert connection + accounts +
fire-and-forget mail + sync 90j + reconcile), disconnect (DELETE
Powens + soft-revoke en DB), setReconciliationMode.
- Réconciliation : match transactions ↔ factures sur montant exact
+ label normalisé (numero ou nom client, NFD strip + alphanum).
Confiance HIGH (label matche) vs LOW (montant seul). Mode auto +
HIGH → invoice.status=paid + bonus rubis + cancel relances +
enqueuePaymentThanks (client) + sendInvoiceAutoPaidNotification
(user). Mode manual ou LOW → match_status='suggested' (UI V2).
- Webhook /webhooks/powens : vérif HMAC, lookup org par
powens_user_id, dispatch CONNECTION_SYNCED / NEW_TRANSACTIONS /
USER_SYNC_ENDED → sync incrémental 7j + reconcile, CONNECTION_ERROR
/ SCA_REQUIRED → update state + last_error. Réponse 200 immédiate
puis processing fire-and-forget pour ne pas timeout côté Powens.
- 4 migrations : bank_connections, bank_accounts, bank_transactions
+ colonnes powens_user_id (chiffré APP_KEY) et reconciliation_mode
sur organizations.
- 2 templates React Email : BankConnectedEmail (post-connection,
récap comptes + lien settings) et InvoiceAutoPaidNotificationEmail
(notif user après match auto, lien direct facture + libellé
bancaire détecté). Toujours en branding Rubis (notif Rubis → user,
jamais marque blanche).
- 2 commandes ace : banking:reconcile (rejoue le reconcile sans
reconnecter la banque) et banking:simulate-payment (injecte une
bank_transaction synthétique qui matche une facture, pour test E2E
sans devoir attendre un vrai virement sandbox).
- Kill switch isBankingEnabled() : flag BANKING_ENABLED + check des
credentials Powens. Endpoint public GET /banking/status renvoie
{ enabled }, /banking/powens/init throw 503 banking_disabled si OFF.
- Fix handler exceptions : UNIQUE violation composite (org, X)
rapporte désormais la vraie colonne en faute (numero/slug/…) avec
message lisible « Le numéro de facture "F2026-0013" existe déjà »,
au lieu d'un message ambigu sur organization_id.
Frontend (apps/web)
- /parametres : nouvelle SettingsSection "Banque" gated par kill
switch + plan Pro/Business. Si Free → upsell card avec CTA vers
/parametres/abonnement. Si Pro/Business sans banque → CTA "Connecter
une banque". Si banque connectée → carte avec accounts (IBAN
masqué FR76 **** **** **** 1234), solde, last sync, bouton
Déconnecter. Toggle Manuel/Auto pour reconciliation_mode.
- /parametres/banque/success : nouvelle route dédiée post-callback
avec badge ✓ animé + halo glow rubis, récap des comptes
synchronisés, 2 CTAs ("Voir mes paramètres" / "Retour dashboard"),
note sécurité "lecture seule, aucun déplacement de fonds".
- Hooks : useBankingStatus, useBankConnections (avec opt-out via
{ enabled }), useInitBanking, useDisconnectBank, useBankingSettings,
useUpdateBankingSettings.
Infrastructure (k3s)
- ConfigMap rubis-api-config : BANKING_ENABLED='false' par défaut,
BANKING_PROVIDER='powens', POWENS_DOMAIN='rubis',
POWENS_API_BASE_URL='https://rubis.biapi.pro/2.0/',
POWENS_REDIRECT_URI='https://app.rubis.pro/api/v1/banking/powens/callback'.
- Secret rubis-app-secrets : 3 nouvelles clés POWENS_CLIENT_ID,
POWENS_CLIENT_SECRET, POWENS_WEBHOOK_SECRET (valeurs sandbox posées
via kubectl patch, à remplacer post-KYC).
Sécurité
- Token Powens chiffré au repos via Adonis encryption (AES-256-GCM,
clé APP_KEY).
- State HMAC SHA-256 signé sur APP_KEY pour le flow webview
(anti-CSRF + porte l'org_id à travers le redirect).
- Webhook HMAC SHA-256 sur header BI-Signature avec
POWENS_WEBHOOK_SECRET, comparaison timing-safe.
- IBAN masqué côté API (transformer).
- Scope par org sur tous les endpoints (anti-IDOR).
- Rate limiting via le middleware Adonis existant.
- Idempotence DB : UNIQUE (org, powens_connection_id), (connection,
powens_account_id), (account, powens_id) → rejouer un event ou un
callback ne pose pas de problème.
Documentation
- /docs/tech/banking-setup.md : procédure complète setup dev avec
Cloudflare Quick Tunnel, compte sandbox Powens, whitelist URLs.
- /.claude/deploy-memory.md : section "Banking (Powens) — activation
prod" avec procédure en 6 étapes (KYC → secrets → ConfigMap →
flip flag → smoke test), snippet kubectl patch pour rotation
ciblée de secrets.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Complète la feature marque blanche initiée dans le commit précédent
(919ebfe). L'API backend est désormais consommée par une page
dédiée dans le SPA, et le changelog v1.11.0 décrit la feature complète
plutôt que la "première brique".
Livraison côté SPA :
- `apps/web/src/lib/brand.ts` — types BrandSettings/BrandTokens (miroir
serveur) + 5 hooks TanStack Query : useBrand (GET cache), useUpdateBrand
(PATCH), useUploadBrandLogo (multipart), useDeleteBrandLogo, et
useSendBrandTestEmail. Pas de retry sur le GET pour éviter de bombarder
/brand quand l'org n'est pas Business (403 définitif).
- `apps/web/src/components/settings/BrandEmailPreview.tsx` — mock fidèle
d'un email de relance qui réagit en direct aux color pickers. Copie la
structure HTML/CSS de relance_email.tsx + _layout.tsx (banner, body
pre-line, card récap, footer Rubis) pour que le user soit confiant que
son vrai email rendra pareil.
- `apps/web/src/routes/_app/parametres_.marque.tsx` — page éditeur
complète :
• Header avec retour
• Upsell card propre si l'org n'est pas Business (pas d'éditeur du tout
pour éviter de leak des controls qui throw 403 derrière)
• Form 2 colonnes desktop : zone upload logo (drop ou click) avec
preview sur le bandeau effectif, input nom expéditeur, color pickers
natifs (HTML5 + hex input) groupés en "Principales" (primary + banner)
et "Avancées" (7 autres, accordéon fermé par défaut)
• Live preview à droite (sticky desktop) qui se met à jour à chaque
keystroke / pick
• Actions : Enregistrer (diff draft → settings → PATCH), Réinitialiser
(tous les overrides à null), Envoyer un test (qui force l'enregistrement
préalable parce que le test utilise les tokens sauvegardés)
• Sémantique null/undefined respectée côté patch — undefined = pas
touché, null = reset au default Rubis sur ce champ précis
- Carte de navigation ajoutée dans `/parametres` qui linke vers
`/parametres/marque` avec libellé adaptatif (Business = "Configurer",
autres = "En savoir plus").
Changelog v1.11.0 réécrit pour décrire la feature complète et non plus
seulement la moitié backend. Un seul concept, une seule entrée changelog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Première moitié de la feature marque blanche : la machinerie complète qui
permet à un compte Business d'envoyer ses emails de relance avec son
propre logo, ses propres couleurs et son nom comme expéditeur, à la place
du branding Rubis.
Architecture :
- Nouvelle colonne JSONB `organizations.brand_settings` (12 tokens
customisables : logo, senderName, et 10 couleurs — primary, banner,
body bg, card bg, text, text muted, border, link, button text).
Null = palette Rubis intacte. Validation hex stricte (#RRGGBB).
- Service `#services/brand` avec `resolveBrandTokens(org)` qui merge
defaults + overrides en respectant le plan (couleurs/logo = Business
only ; senderName = cascade pour tous les plans). Mergeurs avec
sémantique "null = reset au default sur ce champ précis" pour les
patches partiels.
- Service mutualisé `#services/media_storage` qui remplace l'ancien
`blog_uploads.ts`. Scopes `blog` (4 MB, jpg/png/webp) et `brand-logo`
(1 MB, + svg accepté). Cleanup automatique du logo précédent lors
d'un remplacement (pas de versioning — la conv produit est "on écrase").
- Controller `BrandController` (5 endpoints) + middleware
`AssertBusinessPlanMiddleware` qui throw 403 `business_plan_required`
(code matché par le SPA pour l'upsell card).
- Refactor des 3 templates mail (relance, payment thanks, checkin) +
layout commun pour accepter `tokens: BrandTokens` en prop. Le
dispatcher résout les tokens per-org pour relance + remerciement
(= user → client, branded), et passe `DEFAULT_BRAND` au checkin
(= Rubis → user, toujours Rubis-branded).
- Routes publiques pour le logo : `/api/v1/uploads/brand-logos/:filename`
(sans auth, cache immutable, X-Content-Type-Options: nosniff pour les SVG).
UI self-service arrive dans la prochaine version (v1.12.0). En attendant,
un compte Business peut être configuré via Bruno / API directe.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Composant `<VersionToast/>` monté au root de la SPA qui s'affiche quand
l'utilisateur ouvre l'app sur une version plus récente que la dernière
qu'il a vue.
Mécanique :
- Source de vérité = `apps/web/src/version.ts` (constante semver, à bumper
manuellement à chaque release, en même temps que l'ajout du .md
correspondant dans `apps/landing/src/content/changelog/`).
- Comparaison à `localStorage["rubis:last-seen-version"]` au mount.
- 1re visite (clé absente) → on enregistre la version courante en silence,
pas de toast (sinon spam d'onboarding).
- Version identique à la dernière vue → rien.
- Version différente → toast Sonner persistant (`duration: Infinity`)
avec icône Sparkles rubis et action "Voir les nouveautés ↗" qui ouvre
`rubis.pro/changelog#<version>` dans un nouvel onglet. Au moment de
l'affichage on enregistre la version comme vue — donc même si l'user
ferme sans cliquer, plus de toast pour cette version (il a été informé).
- localStorage indisponible (private mode) → fail silent.
Version initiale : `1.10.0` (remerciement automatique au client, dernière
entrée du changelog).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Le PostTransformer ne renvoyait que contentHtml — l'éditeur admin avait
besoin du contentMd source pour permettre l'édition, et plantait avec
"Cannot read properties of undefined (reading 'replace')" dans countWords()
au mount.
* PostTransformer expose maintenant contentMd, status et createdAt en
plus de l'existant. Surcoût ~quelques KB par requête côté landing
publique (négligeable). Si volume devient un problème, on splittera
en PublicPostTransformer + AdminPostTransformer.
* admin.blog_.$id.tsx : nullish coalescing sur tous les champs string
au moment d'init le draft (defense in depth — si l'API renvoie
jamais un payload partiel, l'éditeur reste fonctionnel).
* countWords() accepte maintenant string | null | undefined.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Quand l'utilisateur confirme « Oui, payé » via check-in (lien email ou modale
in-app) ou marque une facture encaissée manuellement, on envoie automatiquement
un email de remerciement chaleureux au client final. Subject + body éditables
par plan (mêmes variables que les relances), avec fallback hardcodé si vide.
Gardé par la transition `* → paid` pour idempotence ; envoi async via BullMQ
avec retry exponentiel ; capture en mode démo.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Trois surfaces partagent désormais le même design system, Tailwind v4
et React 19 — au lieu d'avoir landing en HTML vanilla, app en React, et
blog en Adonis SSR :
* packages/ui — design system partagé (tokens Tailwind v4 + composants
TSX) extrait depuis apps/web : Brand, Gem, Button, Card, Chip, Eyebrow,
EmptyState. apps/web migre 41 imports vers @rubis/ui.
* apps/landing — nouvelle app Astro 6 SSR (rubis.pro), remplace l'ancienne
landing nginx vanilla. Embarque :
- Landing complète portée en sections React (Hero, Stats, Promise,
HowItWorks, Gamification, Legal, Pricing, FAQ, FinalCTA, Footnotes)
- Pages légales (mentions, confidentialité, CGV) via LegalLayout.astro
- Blog SSR (/blog, /blog/:slug) qui consomme /api/v1/posts
- sitemap.xml, blog/rss.xml, robots.txt en endpoints Astro
- SEO complet (canonical, hreflang, OG, Twitter Card, JSON-LD
Article/BreadcrumbList/Blog/SoftwareApplication)
* apps/api — BlogController réduit à 2 endpoints JSON (GET /api/v1/posts
+ GET /api/v1/posts/:slug). Suppression des templates SSR Adonis
(apps/api/app/blog/), de l'alias #blog/*, des deps react-dom et
@types/react-dom. PostTransformer + PostSummaryTransformer ajoutés.
Le service blog_renderer + le seeder + les 3 articles fondateurs
restent intacts (réutilisés par futurs admin + cron IA).
* Infra :
- Dockerfile.landing (multi-stage Node 22 + tini, Astro standalone)
- k3s/app/landing.yml (Deployment + Service rubis-landing:4321 +
ConfigMap avec API_URL=http://rubis-api.rubis.svc.cluster.local:3333)
- .gitea/workflows/deploy.yml mis à jour pour build rubis-landing
- .gitea/workflows/deploy-web.yml + Dockerfile.web : prennent en
compte packages/ui/ comme dépendance
- Suppression du Dockerfile nginx legacy + k3s/{deployment,service}.yml
- Suppression de landing/ (assets favicons migrés vers
apps/landing/public/)
* Docs : architecture.md (vue d'ensemble + §4bis apps/landing complet,
§3 endpoints JSON blog, layout monorepo), CLAUDE.md (stack technique,
documents associés, déploiement).
Note infra : l'ancien Deployment "rubis" (nginx) et son Service ne sont
PAS supprimés par la CI — à nettoyer manuellement après validation que
Traefik a été repointé sur rubis-landing:4321 dans le repo proxmox.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Intégration Sentry SaaS pour error monitoring + replay sur les 2 apps.
API (apps/api) :
- start/sentry.ts : init au plus tôt dans bin/server.ts (avant Ignitor)
pour capturer les erreurs de bootstrap. No-op si SENTRY_DSN_API absent.
- app/exceptions/handler.ts:report : captureException sur les 5xx avec
tags { url, method, status } et user.id (PII minimisée). 4xx filtrés
par beforeSend dans start/sentry.ts (validation, auth invalide = bruit).
- start/env.ts : SENTRY_DSN_API + APP_VERSION optionnels.
- bin/server.ts : import #start/sentry en 1er.
- @sentry/node + @sentry/profiling-node ajoutés au package.json.
Web (apps/web) :
- src/lib/sentry.ts : init au plus tôt dans main.tsx, BrowserTracing +
Replay (0% session, 100% sur erreur — économie quota free tier).
maskAllText + blockAllMedia pour privacy par défaut.
- src/lib/auth.ts : Sentry.setUser({ id }) au login, setUser(null) au
logout (corrélation cross-stack des erreurs front avec un user).
- src/main.tsx : ErrorBoundary autour de l'app avec FallbackError UX.
- vite.config.ts : @sentry/vite-plugin uploads les sourcemaps + les
SUPPRIME du dist/ final (filesToDeleteAfterUpload) pour ne pas leak
le code source via nginx en prod. Helper resolveAppVersion() pour
injecter le sha git en dev (le shell n'étant pas évaluable dans .env).
- src/lib/env.ts : VITE_SENTRY_DSN_WEB + VITE_APP_VERSION optionnels.
- .env.development : VITE_SENTRY_DSN_WEB (préfixé correctement pour
être exposé par Vite — l'ancienne SENTRY_DSN ne marchait pas).
- @sentry/react + @sentry/vite-plugin ajoutés au package.json.
CI Gitea :
- deploy-api.yml : kubectl set env APP_VERSION=${{ github.sha }}
runtime → release Sentry trackable au commit pour l'API.
- deploy-web.yml : build-args VITE_SENTRY_DSN_WEB, VITE_APP_VERSION,
SENTRY_AUTH_TOKEN, SENTRY_ORG injectés depuis les secrets Gitea.
- Dockerfile.web : ARG correspondants + propagation au stage build.
Privacy / sécurité (cf. ADR-024) :
- captureException tags : ctx.route?.pattern (pas l'URL réelle) →
les codes OAuth (?code=...) et tokens de check-in n'apparaissent
jamais dans les tags Sentry indexés.
- Sentry user context = user.id UUID seulement, pas d'email/nom.
- Sourcemaps en prod : uploadées à Sentry, supprimées du bundle.
- 4xx filtrées en amont (beforeSend) ET en aval (handler.ts:report).
- DSN public (by-design) commit-able, AUTH_TOKEN secret CI uniquement.
Sample rates (free tier 5K events / 50 replays par mois) :
- traces : 10% prod, 100% dev
- profiles : 100% (sampled par traces)
- replay session : 0% (économie quota)
- replay sur erreur : 100% (debug post-mortem)
Pré-requis runtime à configurer hors-repo :
- Secret K3s rubis-app-secrets : SENTRY_DSN_API
- Secrets Gitea Actions : SENTRY_DSN_WEB, SENTRY_AUTH_TOKEN, SENTRY_ORG
ADR-024 logué dans docs/decisions.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bascule du domaine principal vers rubis.pro / app.rubis.pro :
- K3s ConfigMaps (api.yml, web.yml) : APP_URL, WEB_URL,
COOKIE_DOMAIN, OAUTH callbacks pointent vers app.rubis.pro
- Dockerfile.web : ARG VITE_API_URL et VITE_PUBLIC_LANDING_URL
- Workflows Gitea : commentaires + build args web → rubis.pro
- Code API (mail_dispatcher, send_test_email, config/mail) :
defaults env LANDING_URL et MAIL_FROM_ADDRESS migrés
- Templates env (.env.example) idem
- Docs (architecture, backend, frontend, brand-identity) idem
- AGENTS.md / CLAUDE.md / deploy-memory : pointeurs domaine MAJ
Note : MAIL_FROM_ADDRESS dans le secret K3s reste sur
rubis@arthurbarre.fr tant que le domaine rubis.pro n'est pas
Verified dans Resend. À switcher manuellement après vérif Resend.
Compat : un 301 Traefik redirige rubis.arthurbarre.fr → rubis.pro
(et app.X aussi) — config Ansible dans le repo proxmox.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Quand l'user annule via le Customer Portal Stripe, la subscription reste
`active` jusqu'à la fin du cycle (cancel_at_period_end=true) — Stripe
n'envoie le `subscription.deleted` qu'à period_end. Avant ce commit, l'UI
affichait toujours "prochaine facture le 28 mai" comme avant l'annulation,
ce qui faisait croire à l'user qu'il allait re-payer.
Backend :
- Migration `cancel_at_period_end boolean DEFAULT false` sur orgs.
- `applySubscriptionToOrg` : lit le flag du Stripe Subscription et
persiste sur l'org.
- `handleSubscriptionDeleted` : reset le flag à false (cohérence DB).
- `OrgSubscriptionState` : nouveau champ `cancelAtPeriodEnd: boolean`.
- Endpoint `POST /api/v1/billing/reactivate` :
• Idempotent (si déjà actif → no-op + 200)
• Appelle `subscriptions.update(id, { cancel_at_period_end: false })`
• Persist le nouvel état sur l'org
Frontend :
- Hook `useReactivateSubscription` (mutation + invalidate billing query).
- `CurrentPlanStrip` :
• Détecte `isCancelling = plan !== 'free' && cancelAtPeriodEnd`
• Switch border/bg en mode rubis-deep + rubis-glow pour attirer l'œil
• Icône Clock à la place de Gem (visuel "compte à rebours")
• Badge "ANNULÉ" en uppercase
• Sous-titre : "Accès Pro jusqu'au DD/MM, puis retour automatique
au plan Free."
• Bouton primary "Réactiver" (RotateCcw icon) qui remplace "Gérer"
• Masque la progress bar Free (non pertinente)
- `SubscriptionState` type étendu avec `cancelAtPeriodEnd`.
- Test factory updated.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mensuel par défaut dans le toggle (au lieu de yearly).
Layout asymétrique 1fr/1.3fr/1fr avec Pro en hero card centrale, plus
large et plus dense visuellement que Free et Business — sort du pattern
"3 cards égales" générique des landing pages SaaS.
Identité Rubis :
- Gem 280px en filigrane sur la Pro card (rubis-glow, opacity 70%) —
le motif signature de la marque, pas un blob de gradient
- Bullets ◆ losanges rubis tournés (5×5px) au lieu des check-circles
Lucide génériques — cohérent avec PlanCard, Timeline, etc.
- Pastille "Le plus pris" avec mini-gem inline sur Pro
- Border rubis + shadow-card sur Pro, line + sobre pour les voisines
- Free : bg cream-2 (ton "découverte"), Business : bg blanc + accents
rubis-deep
- Strip "plan courant" : remplace le gros bloc card → ligne flex avec
gem + texte + mini progress bar 32px + bouton ghost. Discret.
Voice direct dans les bénéfices :
- Header narratif "Trois mois pour voir. Puis vous décidez."
- "Tester sans poser sa CB" (Free)
- "Pour les TPE qui n'ont plus jamais à y penser" (Pro titre)
- "Moins cher qu'une heure de votre temps mensuel" (Pro footer)
- Annulation 1-clic mentionné en sous-titre du toggle
Prix Pro XL 44px (vs 28px pour les voisines) — hiérarchie visuelle qui
guide l'œil vers l'option qu'on veut pousser.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mobile UX :
- Inputs/Textarea : font-size base (16px) sur mobile pour bloquer le zoom
iOS Safari au focus. Densité 15px préservée sur lg.
- KpiCard : padding p-4 + value text-22px sur mobile, p-7 + 28px sur lg.
Truncate pour éviter le débordement des €tots longs.
- Mobile tab bar : "Réglages" remplacé par "+ Nouvelle" → /factures/import
(Réglages reste accessible via l'avatar UserMenu).
- Brand topbar mobile : cliquable → retour dashboard.
- DemoClock : full-width mobile (inset-x-4), 300px droite sur lg.
- DemoEmailSlide : bottom-sheet sur mobile (slide-from-bottom + drag handle
+ safe-area-inset-bottom), slide-over droit sur lg.
- InAppCheckinModal : bottom-sheet mobile aussi, layout InvoiceCard
stacké pour éviter le squash sur 320px.
- Nouveau bouton "+ Nouvelle facture" dans le header /factures (visible
desktop + mobile, link → /factures/import).
- Wiring des actions mobile dashboard ("Photo de facture" + "Saisir") qui
n'avaient pas d'onClick — branchés sur /factures/import et le manual
dialog.
Check-in :
- "Relancer maintenant" sur la fiche facture : actif pour pending et
awaiting_user_confirmation. Délègue à inappRespondPending → schedule
relances + status → in_relance + record activity.
- InAppCheckinModal : drop le sessionStorage `rubis.checkin.dismissed`.
Au refocus de l'onglet, dismissed reset à false → la modale re-pop
si pending non vide. TanStack Query refetch sur focus en bonus.
Plans :
- PlanCard : ajout d'un mb-6 sur la liste des steps pour aérer le
divider du footer.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>