134 Commits

Author SHA1 Message Date
ordinarthur
7a112d3329 chore(claude-code): bypassPermissions + ask sur git commit/push
Auto-approuve toutes les actions Claude Code par défaut sauf `git commit`
et `git push` qui prompteront toujours — ces deux commandes restent sous
validation manuelle pour ne pas pousser accidentellement du code sans
validation explicite. Le skill /push déclenche naturellement ces deux
prompts à la fin de la release.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 00:58:32 +02:00
ordinarthur
fc66775109 feat(claude-code): skill /push pour release automatisée
Le ritual de release Rubis demande de bumper `apps/web/src/version.ts`
et d'ajouter `apps/landing/src/content/changelog/<x.y.z>.md` dans le
*même* commit — sinon le toast SPA pointe sur une ancre absente côté
`/changelog`. Ce skill orchestre les deux pour éliminer la classe entière
des releases désynchronisées.

Workflow :
 1. `git status` + `git diff` pour inspecter les changements pendants
 2. Détection heuristique du type (feature/improvement/fix) → bump
    semver correspondant (minor pour feature, patch sinon)
 3. AskUserQuestion pour titre + highlights (au ton brand : produit-only,
    pas de jargon tech, une phrase d'attitude max)
 4. Edit `version.ts` + Write nouveau `.md` avec frontmatter Zod-validé
 5. Stage + commit Conventional Commits (scope `release`)
 6. Push gitea/main

Edge cases couverts : aucun changement à committer, branche ≠ main,
build cassé pré-existant (ne bloque pas la release, juste flag), conflits
remote, hook pre-commit échoue (jamais d'amend / no-verify), version
déjà utilisée, versionnage incohérent entre `version.ts` et le dossier
changelog.

Trigger : `/push`, "release ça", "sors une nouvelle version", "push avec
changelog", "deploy" (sauf si l'user dit explicitement "sans bump").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 00:49:39 +02:00
ordinarthur
77d52ea95c docs: aligner top-level + produit + technique + decisions sur le changelog
Les commits récents ont introduit le changelog public, le toast SPA, et la
convention de release (bump version.ts + ajout du .md dans le même commit).
Les docs reflètent maintenant ce qui est en prod :

- CLAUDE.md : V1 IN gagne la mention `/changelog` avec le mécanisme MD
  versionné + toast SPA. Table "Documents associés" gagne 3 lignes
  (`apps/landing/src/content/changelog/`, `apps/web/src/version.ts`,
  `.claude/skills/push/`).
- produit.md : nouvelle §4.9 "Changelog public et toast de version" qui
  couvre le ton produit-only, le mécanisme du toast, la première visite
  silencieuse, le RSS et le SEO.
- tech/architecture.md : ajoute `/changelog` à la table de stratégie de
  rendu (SSG), met à jour l'arbre de fichiers `apps/landing/` avec
  `content.config.ts` + `content/changelog/` + `pages/changelog/`, et
  ajoute une sous-section "Mécanique du changelog (release workflow)"
  qui décrit le couplage `version.ts` ↔ `.md`. Côté SPA, ajoute la
  sous-section "Versionnage SPA + toast de release" avant la partie auth.
- decisions.md : ADR-022 nouvelle entrée — Changelog en Markdown
  versionné (pas en DB) avec rationale (release-coupled, pas d'admin à
  maintenir, review en PR, SSG = LCP optimal, schéma Zod).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 00:49:37 +02:00
ordinarthur
c4910889de feat(landing/changelog): glow rubis sur la dernière version, retire les pills sur les anciennes
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 52s
Le contexte "Nouveauté" sur 11 cartes à la suite devenait du bruit visuel
(toutes les anciennes versions ont été des nouveautés à un moment, hein).
Le pill de type est maintenant réservé à la version la plus récente — sur
les autres on garde seulement la chip de version + la date.

Pour compenser et ancrer le regard sur le dernier état du produit, la
carte "latest" gagne un glow rubis multi-couches :
- Ring serré 4 px en couleur rubis-glow (contour soft)
- Drop shadow proche teintée rubis (profondeur)
- Bloom large diffus (halo ambiant)
- Animation "respiration" 5 s ease-in-out infini (variation subtile sur
  l'intensité du bloom)
- Désactivée si `prefers-reduced-motion: reduce` côté user.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 00:16:08 +02:00
ordinarthur
847e7a3fc5 feat(web): toast "Nouvelle version" persistant + lien vers le changelog
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 35s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m6s
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>
2026-05-11 00:06:06 +02:00
ordinarthur
fc0d13e955 feat(landing): changelog public /changelog + flux RSS
Page Astro prerendered qui liste les versions livrées en reverse-chrono.
Contenu géré en MD files versionnés dans le repo via Astro content
collections (`src/content.config.ts`), pas de DB — chaque release =
1 fichier `src/content/changelog/<x.y.z>.md` ajouté en PR à côté du bump
de version applicatif.

11 entrées initiales (v1.0.0 → v1.10.0) couvrant le premier mois public :
lancement (OCR Mistral + plans par défaut + mode démo + Stripe), saisie
manuelle, SSO Google puis Microsoft, plans custom, templates email,
réécriture IA, insights, blog, marque blanche, remerciement automatique.

UI :
- Hero centré aligné DA landing (eyebrow rubis + h1 display + sub muted)
- 2 colonnes desktop : feed cartes (gauche) + sticky rail jump-nav (droite)
- Sur mobile/tablette : pas de rail, juste le feed
- Sticky rail : IntersectionObserver inline qui met en surbrillance la
  version courante quand l'user scrolle
- Anchors `#1.4.0` partageables, cliquables depuis le chip de chaque carte
- Type pills colorés : feature (rubis solid), improvement (cream-2),
  fix (line outline)
- Bullets losanges ◆ rubis cohérents avec le gem brand

SEO :
- `prerender = true` → HTML figé au build, LCP minimum
- JSON-LD WebPage avec mainEntity[TechArticle] par version → rich
  snippets Google
- Flux RSS 2.0 à `/changelog/rss.xml` (prerendered aussi)
- Auto-discovery RSS ajoutée au Layout (à côté de celle du blog)
- Lien Changelog ajouté au SiteFooter à côté de Blog

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 00:05:45 +02:00
ordinarthur
642747d762 docs(marketing): outils d'export logo + OG image + image LinkedIn
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 37s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m10s
Trois templates HTML autonomes pour générer les assets visuels sans
quitter le repo :

- `docs/logo-export.html` — outil interactif d'export du wordmark
  Rubis.pro en PNG (Bricolage Grotesque). 3 layouts (horizontal,
  vertical, texte seul), 5 fonds, 5 couleurs, taille 120 → 4096 px,
  format carré. Preset 120 px pour le logo de consent screen Google
  OAuth. Le canvas attend `document.fonts.ready` avant rendu pour
  éviter le fallback sans-serif.

- `docs/og-default-source.html` — recréation HTML pixel-perfect du
  `og-default.png` (1200×630) avec le nouveau wordmark `Rubis.pro` et
  `DSO*`. Export via Chrome DevTools "Capture node screenshot" pour
  remplacer `apps/landing/public/og-default.png`.

- `docs/linkedin-launch-source.html` — image carrée 1200×1200 pour
  un post de lancement LinkedIn. Format optimal pour le feed mobile
  (LinkedIn n'crop pas les carrés). Headline + stat-héros + brand.

Tous les gems sont des SVG inline (4 facettes + contour) — identité
visuelle 1:1 avec `<Gem/>` du SPA et `favicon.svg`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:29:44 +02:00
ordinarthur
3052a7e909 feat(landing): brand "Rubis.pro" + corrections copy
- Brand : suffixe `sur l'ongle` → `.pro` (attaché, muted, non italique).
  Propagé partout via `<Brand withSuffix>` — header/footer landing,
  topbar/sidebar SPA, login/signup/onboarding. Renforce l'identification
  brand requise par Google OAuth (gem seul jugé trop générique).

- Hero : dots groupés avec leur label (inline-flex) — ne se baladent plus
  en début de ligne au flex wrap. Ajout d'un topbar mock "Rubis.pro ·
  Tableau de bord" en haut de la carte hero pour identifier le mock comme
  un vrai dashboard. `DSO` → `DSO*`.

- Stats : "Trois chiffres qui devraient vous fâcher" → "Trois chiffres
  exorbitants". Sous-titre remplacé par "Et vous faites sûrement partie
  intégrante de ces enquêtes." (plus direct, moins gratuit).

- Promise : "Votre temps vaut plus que ça" → "Votre temps est plus
  précieux". Réécriture de l'amorce ("votre boîte / lundis soirs" →
  "votre entreprise / journées"). Ajout d'une ligne italique "Parfois
  moins, si votre plan par défaut est bien réglé".

- HowItWorks : Step 01 — suppression de "à la caisse" et de ", RIB"
  (l'OCR ne lit pas le RIB en V1). Step 03 — "La machine fait le reste"
  → "L'algorithme fait le reste".

- Gamification : suppression de "Pas un PDF abscons" (jargon inutile) et
  de "Et oui, on garde un classement amical" (le classement n'est pas en
  V1). `DSO` → `DSO*`.

- Footnotes : ajout de la définition DSO (Days Sales Outstanding) sous
  celle d'OCR.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 14:48:17 +02:00
ordinarthur
c3c9dbb408 add des betises
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 27s
2026-05-09 20:11:33 +02:00
ordinarthur
fb248553a8 docs(audit-3/3): aligner brand + marketing sur le code livré
Audit cross-doc/code, batch brand & marketing : nettoyage de l'or
accent obsolète, correction des chemins cassés, retrait du hashtag
#recouvrement des templates LinkedIn (mot interdit en com publique
par marque.md).

docs/brand-identity.html
- §3 Palette : retrait du swatch « Or discret #B89F6B » et de la
  description « accent or à doser » (CLAUDE.md indiquait le caveat
  depuis le début, mais le HTML lui-même n'avait jamais été
  nettoyé). Reformulation alignée avec marque.md (rubis +
  neutres only).
- §Notes finales : « Or accent : à utiliser très peu » → « 
  retiré de la palette » avec renvoi vers marque.md
- (Variable CSS --gold gardée car référencée dans deux contextes
  inactifs lignes 24/203 — non visible côté affichage. Non
  bloquant.)

docs/marque.md
- §Préambule : lien `/brand-identity.html` → `/docs/brand-identity.html`
  (chemin réel)
- §Typographie : « Source Google Fonts » → « self-hosted via
  @fontsource-variable/{bricolage-grotesque,inter} » (réalité
  package.json)
- Header bumpé 2026-05-05 v0.1 → 2026-05-09 v0.2

docs/munitions-marketing.md
- §Pricing : retrait de la promesse « intégration banking » sur
  le plan Business — explicitement V2+ (CLAUDE.md V1 OUT). Note
  le check-in email comme alternative V1.

docs/marketing/playbook.md
- §Templates LinkedIn : hashtag `#recouvrement` → `#impayés` +
  warning explicite renvoyant à marque.md:151
- §Annexe ressources : chemin obsolète `/landing/index.html` →
  chemin réel `apps/landing/src/pages/index.astro`

docs/marketing/launch-kit.md (NOUVEAU — était untracked)
- Tracked avec correction des 2 hashtags #recouvrement →
  #impayés (variantes A et B des posts LinkedIn). La stat
  AFDCC ligne 39 (« taux de recouvrement ») est gardée — terme
  technique cité dans un contexte factuel, pas un positionnement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:17:27 +02:00
ordinarthur
9eaac0c7ef docs(audit-2/3): aligner doc tech sur le code livré
Audit cross-doc/code, batch tech : architecture, backend, frontend,
dev-setup. Corrige les claims qui pouvaient induire un dev en erreur
(noms de services K3s, hostnames Traefik, Tuyau, queue wrapper,
seeders, env vars, polices).

architecture.md
- Composants : status « À écrire » → «  Déployé » (apps/web,
  apps/api, packages/shared) ; ajout Redis Deployment K3s ; OCR =
  Mistral choisi ; mail = Resend (sortant) + OVH MX (entrant)
  validés
- §7.5 Pods K3s : noms réels (rubis-api / rubis-web / rubis-landing
  / rubis-redis, pas de *-svc) ; pas d'IngressRoute api.rubis.pro
  (l'API est servie via app.rubis.pro/api/* proxifié par nginx du
  pod web) ; PG/MinIO en URL directe dans la ConfigMap, pas de
  Service ExternalName
- §10 Décisions en attente : ADRs 019-024 mises à jour
  (tranchées / obsolètes), suppression du wording « à venir » pour
  les choix déjà figés dans le code

backend.md
- Note de cohérence en tête : pointe vers start/routes.ts comme
  source de vérité de la surface API (~80 routes — Stripe,
  Demo, AI, Microsoft SSO, admin blog, posts publics, KPIs
  timeseries) que cette doc n'inventorie pas exhaustivement
- §1 Vue d'ensemble : Tuyau marqué « non utilisé en pratique »
  (présent en deps mais zéro import côté SPA), partage de types
  via packages/shared. OCR Mistral choisi. Mail Resend choisi.
  BullMQ direct (workers inline pod API). Sentry ADR-024.
- §2 Stack : queue = BullMQ direct (pas @rlanz/bull-queue, qui
  n'est pas installé) ; type-sharing = packages/shared
- §2 Dépendances : remplacé la todo-list pré-livraison par la
  liste réelle des packages dans apps/api/package.json
- §3 Repo layout : `database/factories/` (dossier) → `factories.ts`
  (mono-fichier) ; `database/seeders/{default_plans,demo_data}` →
  inexistants, services à la place
- §13.2 Jobs : ProcessOcrJob + RecomputeKpisJob retirés
  (n'existent pas — OCR synchrone via services/import_batch.ts,
  KPIs calculés on-the-fly). Liste des jobs réels :
  send_relance, send_checkin, send_payment_thanks
- env vars : MINIO_* → S3_* (cf. .env.example + manifest k3s) ;
  bucket prod = rubis-prod-invoices

frontend.md
- Note de cohérence en tête : Tuyau pas utilisé, tokens dans
  packages/ui (pas inline), polices @fontsource-variable (pas
  Google Fonts via <link>)
- §1 Vue d'ensemble : client API = fetch minimaliste dans
  apps/web/src/lib/api.ts ; périmètre livré = ~15 routes _app/
- §3 Polices : section Google Fonts → @fontsource-variable
  (avec note preload woff2 critique sur la landing Astro)
- §4 Routes : arbo `_onboarding/` (faux) → `onboarding/`
  (réel, segment URL) + ajout admin.blog*, clients_.$id, insights,
  parametres_.abonnement, plans_.nouveau, factures_.import
- §6 Tuyau : section marquée « historique, non utilisé en V1 »
  avec note explicative en tête
- §10 env vars : VITE_API_URL=https://api.rubis.pro → vide
  (proxifié same-origin par nginx) + ajout VITE_USE_MOCKS,
  VITE_SENTRY_DSN_WEB, VITE_APP_VERSION

dev-setup.md
- Mailhog → Mailpit (3 occurrences) — c'est ce qui tourne dans
  docker-compose.dev.yml

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:13:44 +02:00
ordinarthur
801168fc74 docs(audit-1/3): aligner top-level + produit sur le code
Issu d'un audit de cohérence doc-vs-code. Corrige les claims périmés
sur le périmètre V1, la nomenclature des plans, les statuts de
facture et les sources typographiques.

CLAUDE.md
- IN/V1 : ajout Microsoft SSO + Stripe billing + mode démo (livrés)
- IN/V1 : « admin blog (à venir PR3) » → admin blog livré
- IN/V1 : 4 plans nommés concrètement (Standard B2B, Rapide, Patient,
  Ferme) au lieu de la mention vague « 4 plans fournis »
- Glossaire : « Check-in » → « Confirmation » (terme migré dans
  flow.md depuis ADR-008, alignement)
- Brand identité : typo Bricolage + Inter sont self-hosted via
  @fontsource-variable, pas Google Fonts via <link>
- Brand : lien `/brand-identity.html` → `/docs/brand-identity.html`
  (chemin réel) + retrait du caveat « or accent obsolète » (HTML
  sera nettoyé dans le batch brand)
- Bumpé la dernière maj 2026-05-07 → 2026-05-09

docs/produit.md
- §4.1 : ajout Microsoft SSO
- §4.3 : 4 plans réels (Standard B2B / Rapide / Patient / Ferme),
  variables Mustache réelles ({{client.name}}, {{amount}},
  {{dueDate}}), tons réels (amical | courtois | ferme |
  mise_en_demeure)
- §7 : trial commercial 30 jours (vs 14j historique). Note
  l'incohérence avec la « grâce 3 mois » dans billing.ts à fixer
  dans un PR séparé
- §8 : statut `relanced` → `in_relance` (constants/index.ts) +
  « check-in » → « confirmation »
- Header bumpé 2026-05-05 v0.1 → 2026-05-09 v0.2

docs/flow.md
- §6.1 : tons d'étapes corrigés (était amical|ferme|stricte —
  réel : amical|courtois|ferme|mise_en_demeure)
- Slug exemple : `standard-b2b` → `standard-30j` (réel)

docs/wireframes-mvp.html
- Compteur « 13 écrans » nuancé : c'était le scope low-fi initial,
  la SPA livrée en compte ~15 + onboarding multi-étapes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:06:59 +02:00
ordinarthur
06a3aaf468 feat(landing): step 04 — remerciement automatique au client
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 58s
Ajoute une 4e étape dans la section « Comment ça marche » qui
matérialise la fin heureuse du cycle : le client paye, Rubis envoie
automatiquement un mot court de remerciement (« Merci, paiement bien
reçu »).

Pourquoi c'est important côté pitch :
- Aligne le produit avec le principe brand « respectueux du client
  final » (cf. CLAUDE.md). On n'est pas qu'un outil de pression — on
  est aussi celui qui sait dire merci.
- Crée une attente positive de fin de cycle, qui s'enchaîne mieux
  vers le compteur de rubis (déplacé du step 03 vers 04 pour servir
  de récompense narrative à la boucle complète).

Modifs :
- En-tête : « Trois étapes → Quatre étapes » + ajustement du
  sous-titre.
- Step 03 : retitré « Vous validez. La machine fait le reste. »
  (le punchline « Et puis c'est tout » migre implicitement sur 04).
- Step 04 (nouveau) : email de remerciement + encart rubis.
- Nouveau ThankYouWidget : email card stylé Apple Mail (sender,
  sujet, corps, badge « Envoyé automatiquement ») en tokens rubis
  uniquement (pas de vert — interdit par brand).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 18:52:15 +02:00
ordinarthur
6993d80089 perf(landing): inline critical CSS + preload latin woff2
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 59s
Élimine ~130 ms du critical path LCP rapportés par l'audit Lighthouse :

- `build.inlineStylesheets: "always"` dans astro.config.mjs : la
  feuille `_astro/Layout.<hash>.css` (~42 KiB) n'est plus une requête
  séparée render-blocking, elle est inlinée dans le HTML. Coût : +42 KiB
  par page prerenderée (≈10 KiB gzippé sur la wire). Gain : 80 ms FCP.
  `"auto"` aurait été ignoré (Layout.css > 4 KiB du seuil interne).

- `<link rel="preload">` sur les 2 woff2 latin (Inter body + Bricolage
  Grotesque display) dans Layout.astro. Casse la chaîne HTML→CSS→fonts
  du network dependency tree (~50 ms gagnés). URLs résolues via Vite
  `?url` import → hashing préservé entre les builds.

On ne preload que latin-wght-normal — les autres subsets (latin-ext,
vietnamese, cyrillic, greek) sont chargés à la demande et ne valent
pas le poids upfront pour un trafic FR/latin.

Vérifié build : `<link rel="stylesheet">` disparu du HTML rendu, 1
seul `<style>` inline présent, 2 `<link rel="preload">` avec les bons
hashes d'asset.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 18:45:06 +02:00
ordinarthur
eda5436d12 fix(seo): smart title suffix + OG image par défaut 1200×630
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m1s
Audit SEO révélait deux pertes de valeur :

1. **Titres trop longs** (86 chars sur la home, 71 chars sur les articles).
   Google tronque à ~60 chars dans le SERP. Le suffixe automatique
   `— Rubis sur l'ongle` (20 chars) écrasait le message-clé.

   Layout.astro fait maintenant un suffix smart :
   * Si title <45 chars ET ne contient pas "Rubis" → suffix `— Rubis` (8 chars)
   * Sinon → titre tel quel (la brand est déjà couverte par og:site_name +
     JSON-LD publisher + le hostname rubis.pro visible dans la SERP).

   Résultat : home 64 chars, article 51 chars, "Mentions légales" → 24 chars
   avec suffix.

2. **Pas d'`og:image` sur les pages sans hero** (home, légal). Sur
   LinkedIn/X/Slack, aucune preview image — perte d'engagement énorme.

   Ajout d'un og-default.png 1200×630 (165 KB optimisé) dans
   apps/landing/public/, monté par fallback par Layout.astro quand la
   page ne fournit pas d'`ogImage` explicite. Twitter:card devient
   toujours `summary_large_image`.

   L'image a été générée via le nouvel outil HTML
   docs/marketing/assets/og-default.html (clone de la mécanique du
   linkedin-banner.html — clic sur "Télécharger PNG" et go).
   Compose : brand + tagline + mock card 124 rubis + CTA rubis.pro.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 18:14:28 +02:00
ordinarthur
b2dd991c58 fix(blog/admin): accept upload URLs (absolute + relative /uploads paths)
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m15s
L'upload renvoie maintenant une URL absolue construite depuis APP_URL
(`https://app.rubis.pro/api/v1/uploads/blog/{uuid}.{ext}`), pour que la
landing publique l'affiche directement en <img src> sans absolutize.

Le validator post (createPostValidator + updatePostValidator) accepte :
* Les URLs HTTPS absolues (image externe ou notre upload absolutisé)
* Les paths relatifs `/api/v1/uploads/...` (rétro-compat sécurité — si
  une URL relative arrive d'une autre source, on la laisse passer plutôt
  que 422 sur un champ qui résout côté client)

Bug initial : POST /api/v1/admin/uploads renvoyait `/api/v1/uploads/...`
(relatif), puis le PATCH /admin/posts/:id rejetait ce path en 422 car
`vine.string().url()` exige une URL absolue. Cause = double oubli (path
relatif côté upload + validator strict).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 17:54:06 +02:00
ordinarthur
52bc7507fb fix(blog/admin): expose contentMd dans PostTransformer + nullish guards
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 35s
Build & Deploy API / build-and-deploy (push) Successful in 1m27s
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>
2026-05-09 17:40:53 +02:00
ordinarthur
6dcae6956c feat(blog): admin CRUD + image upload + sidebar link
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m32s
Build & Deploy API / build-and-deploy (push) Successful in 2m20s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m20s
L'éditeur du blog (jusqu'ici limité au seeder) a maintenant une vraie
interface au-dessus de l'API.

Backend (apps/api) :
* Migration users.is_admin (boolean default false).
* Middleware admin (404 si user.is_admin=false après auth).
* Commande ace promote:admin --email=… [--revoke].
* AdminPostsController CRUD complet : list/show/store/update/publish/
  unpublish/destroy + suggest-slug. Au save, contentHtml + wordCount +
  readingTime sont re-calculés via blog_renderer. Au publish, durcit la
  validation SEO (titre ≤60, excerpt 120-160, hero+alt requis, ≥600 mots),
  flippe status='published' + publishedAt, ping Google+Bing pour le sitemap.
* BlogUploadsController :
  - POST /api/v1/admin/uploads (multipart, JPEG/PNG/WebP, max 4MB)
    → MinIO clé uploads/blog/{uuid}.{ext}
    → renvoie URL relative /api/v1/uploads/blog/{filename}
  - GET /api/v1/uploads/blog/:filename (public, cache immutable 1 an)
    → stream depuis MinIO, regex anti-traversal sur le nom.
* UserTransformer expose isAdmin (cf. shared/types/user).
* k3s/app/landing.yml : NodePort 30111 explicite (pour Traefik repo proxmox).

Frontend (apps/web) :
* Lib typée admin-blog (calls API, queryKeys, helpers URL).
* Route /admin/blog : liste filtrable avec status badge, ouverture
  publique, dépublier, supprimer, "+ Nouveau brouillon".
* Route /admin/blog/:id : éditeur 2-colonnes
  - Gauche : @uiw/react-md-editor (lazy import) avec preview live.
  - Droite : hero image (drag&drop + alt), excerpt avec compteur
    120-160, tags, aperçu Google snippet, validations bloquantes.
  - Autosave debounce 2s + bouton Publier qui sauve d'abord.
  - Hero image upload via MinIO (HeroImageUpload component).
* Sidebar : lien "Blog (admin)" si user.isAdmin.
* Gate côté client (beforeLoad redirect si non admin) + côté serveur
  (middleware admin) — defense in depth.

Note : les requirements de publish miroir backend ↔ frontend (cf.
PUBLISH_REQUIREMENTS dans validators/post.ts et VALIDATION_RULES dans
admin.blog_.\$id.tsx). À synchroniser si un seuil bouge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 17:25:34 +02:00
ordinarthur
77fdb6af48 feat: email de remerciement automatique après confirmation de paiement
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 38s
Build & Deploy API / build-and-deploy (push) Successful in 1m43s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m16s
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>
2026-05-09 16:41:26 +02:00
ordinarthur
2b34388723 fix(landing): copie le workspace + node_modules pruned en runner stage
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 48s
Le bundle Astro standalone (dist/server/entry.mjs) référence quand même
certaines deps externes (react, react-dom, lucide-react, sharp). En ne
copiant que dist/ vers le runner, le pod crashait au boot — Node ne
trouvait pas les modules → startupProbe failed → rollout K3s timeout.

Stratégie miroir de Dockerfile.api : on prune les devDeps après build,
puis on copie tout /repo dans /app. Les workspace symlinks (apps/landing
↔ packages/ui ↔ packages/shared) restent fonctionnels, les hoisted deps
(react, etc.) sont résolus depuis /app/node_modules.

CMD ajusté à `node ./dist/server/entry.mjs` (relative depuis le WORKDIR
/app/apps/landing) pour cohérence.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:27:45 +02:00
ordinarthur
566febca48 chore: sync pnpm-lock.yaml after removal of react-dom from apps/api
Some checks failed
Build & Deploy Web / build-and-deploy (push) Successful in 1m37s
Build & Deploy API / build-and-deploy (push) Successful in 2m22s
Build & Deploy Landing / build-and-deploy (push) Failing after 3m50s
Le commit précédent retirait `react-dom` + `@types/react-dom` de
apps/api/package.json (deps utilisées uniquement pour le SSR HTML
du blog, désormais dans apps/landing) sans régénérer le lockfile.
La CI deploy-api échouait avec ERR_PNPM_OUTDATED_LOCKFILE.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:18:28 +02:00
ordinarthur
e5530930b3 feat: refactor frontend en stack React unifiée (Astro + packages/ui)
Some checks failed
Build & Deploy API / build-and-deploy (push) Failing after 17s
Build & Deploy Web / build-and-deploy (push) Successful in 1m15s
Build & Deploy Landing / build-and-deploy (push) Failing after 3m43s
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>
2026-05-09 15:09:13 +02:00
ordinarthur
7141412174 feat(legal): infos éditeur réelles + OVH comme hébergeur
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 23s
2026-05-08 14:37:06 +02:00
ordinarthur
5127cd2c9e feat(landing): pages légales + CTAs trial 30 jours
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 19s
- CGV B2B SaaS (16 sections, conforme avec Stripe en place)
- Mentions légales allégées au strict minimum LCEN
- Politique de confidentialité resserrée :
  - retrait des détails infra (Proxmox, K3s, etc.)
  - sous-traitants par catégorie (Stripe / Resend / Mistral AI)
  - section sécurité standardisée
  - cookies simplifiés
- Période d'essai harmonisée à 30 jours partout (landing + CGV)
- Insistance sur l'hébergement et les données en France
2026-05-08 14:29:22 +02:00
ordinarthur
f59b11f836 chore(debug): endpoint /api/v1/_debug/sentry-test pour valider Sentry E2E
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m17s
Endpoint qui throw délibérément pour tester :
- Capture côté Sentry (handler.ts:report)
- Release tag (APP_VERSION = sha git)
- Sourcemaps désobfusquées
- Tags { url=pattern, method, status }
- User context (si auth présente)

Gardé par NODE_ENV !== 'production' OU DEBUG_SENTRY_TEST=true.
Sur prod, activer le temps du test via :

  kubectl -n rubis set env deploy/rubis-api DEBUG_SENTRY_TEST=true
  curl -i https://app.rubis.pro/api/v1/_debug/sentry-test
  # → 500, vérifier Sentry
  kubectl -n rubis set env deploy/rubis-api DEBUG_SENTRY_TEST-

À retirer (revert ce commit) une fois l'intégration Sentry validée.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:50:10 +02:00
ordinarthur
f33b2dd319 feat(observability): Sentry monitoring API + Web (ADR-024)
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m9s
Build & Deploy API / build-and-deploy (push) Successful in 2m2s
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>
2026-05-08 13:38:12 +02:00
ordinarthur
7c45ee4490 add plausible
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 29s
Build & Deploy Landing / build-and-deploy (push) Successful in 17s
Build & Deploy API / build-and-deploy (push) Successful in 1m20s
2026-05-08 13:08:07 +02:00
ordinarthur
05cfa598b2 chore(bruno): mises à jour test data dans la collection
- 00-Auth/02 Login : trailing whitespace
- 03-Clients/04 Create : test data variant (Martin2, siret/phone
  différents) pour éviter le conflit unique sur les exécutions
  successives
- 05-Invoices/04 Create : numéro F-2026-0047, dueDate au 2026-04-01
  (passée → déclenche l'envoi immédiat des RelanceTasks BullMQ pour
  tester le pipeline outbound)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 12:47:49 +02:00
ordinarthur
06dcf38fee feat(ui): DatePicker brandé Rubis (remplace input type=date)
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 20s
Le natif <input type="date"> affiche le calendrier moche du navigateur
(différent par client OS, pas d'alignement palette). On le remplace par
un composant Radix Popover + grille mensuelle Tailwind aux couleurs
Rubis.

Composant : apps/web/src/components/ui/DatePicker.tsx
- Trigger : bouton style Input (border line, focus rubis-glow,
  data-[state=open]:border-rubis pour hint visuel)
- Popover Radix : focus trap, Escape, click outside, animation
- Grille 7×6 (semaine commence lundi, locale FR via date-fns)
- Sélection : bg-rubis text-white + ombre rubis
- Today : ring-inset rubis-glow
- Hover : bg-cream
- Raccourci "Aujourd'hui" en footer

API alignée avec l'usage existant :
- value: string ISO | Date | null
- onChange(date: Date) — l'appelant fait .toISOString() comme avant

Usages migrés :
- ManualInvoiceDialog.tsx : Date d'émission
- factures_.import_.$batchId.tsx : Date d'échéance (avec préservation
  du className aria-invalid pour les low-confidence OCR)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:01:31 +02:00
ordinarthur
eb248c98b8 style(mail): fond crème edge-to-edge (au lieu d'un container blanc)
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m8s
Avant : body crème + container blanc avec border-radius + bordure → effet
"card flottante" qui laissait des marges crème en haut/bas et un fond
blanc pour le contenu. Sur iPhone Mail, cette structure créait un gap
mal rendu entre la frame Mail.app et le header rubis-deep.

Maintenant :
- bodyStyle.padding 24px 0 → 0 (rubis-deep colle au haut de la zone mail)
- containerStyle background blanc → crème (toute la zone email est crème,
  cohérente avec la palette)
- containerStyle borderRadius + border supprimés (edge-to-edge)
- invoiceCardStyle (checkin) + summaryCardStyle (relance) passent en
  blanc pour se détacher du nouveau fond crème
- Dark mode CSS : .rubis-container override aussi mis à crème

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:55:47 +02:00
ordinarthur
1bb0c7166b fix(mail): force light color-scheme pour empêcher l'auto-inversion iOS
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m7s
Sur iPhone Mail.app en dark mode, iOS inverse automatiquement les
couleurs de l'email : header rubis-deep (#771328) devenait rose
pâle, fond crème devenait noir, texte sombre devenait blanc.

Fix appliqué dans le _layout.tsx (donc impacte checkin + relance) :
- Ajoute meta `color-scheme: light only` + `supported-color-schemes`
  → signal aux clients mail (iOS Mail, Gmail mobile, Yahoo)
  qu'on ne souhaite PAS d'auto-dark-mode
- Ajoute style block avec :root color-scheme + overrides
  Outlook.com dark mode ([data-ogsc] / [data-ogsb])
- Ajoute className sur Body / Container / header / footer pour
  permettre le ciblage CSS dark-mode-resistant

Couvre : iOS Mail, Apple Mail macOS, Gmail mobile dark, Outlook.com.
Aucun impact sur les clients qui ne font pas d'inversion (Outlook
desktop, Thunderbird, etc.).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:42:57 +02:00
ordinarthur
d7148525d0 fix(k3s): mail from-address contact@rubis.pro + LANDING_URL
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 25s
- MAIL_FROM_ADDRESS: rubis@arthurbarre.frcontact@rubis.pro
  Les emails Rubis partent désormais du domaine principal,
  cohérent avec la marque et avec la boîte OVH MX Plan créée
  pour recevoir les replies clients.
- MAIL_FROM_NAME: "Rubis Sur l'Ongle" → "Rubis sur l'ongle"
  (alignement casse brand)
- LANDING_URL: ajoute la clé manquante (https://rubis.pro)
  qui n'était pas trackée dans le ConfigMap du repo. Le footer
  des emails utilisait l'ancienne valeur stale du cluster.

Pré-requis : domaine rubis.pro Verified dans Resend (DKIM + SPF).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:36:41 +02:00
ordinarthur
f86b07c444 copy(landing): CTAs pricing → app.rubis.pro + trial 30 jours
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 24s
- "Commencer l'essai 14 jours" → "Commencer l'essai 30 jours" et
  lien vers https://app.rubis.pro (au lieu de #waitlist obsolète)
- "Démarrer Free maintenant" → lien vers app.rubis.pro
- "Démarrer Business maintenant" → lien vers app.rubis.pro

30 jours = sweet spot SMB B2B (couvre 1 cycle de relance complet
J+3/J+10/J+20, conserve l'urgence sans tomber dans l'effet
d'ancrage du gratuit long).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:02:56 +02:00
ordinarthur
aebcb07b88 copy(landing): "huissier" → "commissaire de justice"
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 15s
Mise à jour terminologique : depuis le 1er juillet 2022 (réforme
ordonnance n° 2016-728), les huissiers de justice et commissaires-
priseurs judiciaires ont fusionné en une profession unifiée :
"commissaire de justice".

Garde le terme exact à jour dans le bloc "cadre légal" du landing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 21:52:46 +02:00
ordinarthur
1acb273c1d docs: email infra rubis.pro (Resend sortant + OVH MX entrant)
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 24s
Documentation post-migration du setup email :

- /docs/tech/backend.md §12.5 : architecture des 2 flux
  (Resend pour le sortant transactionnel via send.rubis.pro,
  OVH MX Plan pour l'entrant humain via @ rubis.pro)
- /CLAUDE.md : tableau récap email infra + maj domaine principal
  rubis.pro / app.rubis.pro, suppression de la question ouverte
  "domaine définitif" (résolue) et "endpoint waitlist" (remplacé
  par CTA app)
- /.claude/deploy-memory.md : section migration rubis.pro marquée
   avec checklist décommissionnement legacy
- /landing/confidentialite.html : remplace privacy@rubis.pro
  par contact@rubis.pro (alignement avec les boîtes OVH créées)

Adresses opérationnelles :
- contact@rubis.pro (général + RGPD)
- dev@rubis.pro (notifs techniques)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 21:48:35 +02:00
ordinarthur
1c5a58e09a chore(domain): migrate rubis.arthurbarre.fr → rubis.pro
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 26s
Build & Deploy Landing / build-and-deploy (push) Successful in 27s
Build & Deploy API / build-and-deploy (push) Successful in 1m18s
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>
2026-05-07 21:32:31 +02:00
ordinarthur
6e796a0980 feat(landing): CTAs vers app + pages mentions légales & RGPD
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 25s
- Remplace les formulaires waitlist par des CTA "Lancer Rubis"
  pointant vers app.rubis.pro (nav, hero, section finale)
- Met à jour la trust line ("3 mois gratuits puis Free 5 factures")
- Footer : ajoute liens Mentions légales / Confidentialité, casse
  "Rubis sur l'ongle" + lien Lancer Rubis
- Supprime le script de binding waitlist (plus utilisé)
- Migre les références au domaine vers rubis.pro

Nouvelles pages :
- mentions-legales.html : conformité LCEN (9 sections)
- confidentialite.html : politique RGPD (10 sections, sous-traitants
  Stripe/Resend/Mistral, droits utilisateur, durées de conservation)
- _legal-shell.css : shell graphique partagé (palette rubis,
  Bricolage Grotesque + Inter, header/footer brandés, TOC)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 21:24:42 +02:00
ordinarthur
ff8fe64be2 feat(mail): templates HTML React Email + brand "Rubis sur l'ongle"
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m1s
Build & Deploy API / build-and-deploy (push) Successful in 2m3s
Templates HTML stylés DA Rubis pour les 2 emails sortants — fini le
plain text moche.

apps/api/app/mails/
  ├── _brand.ts          : tokens couleur + spacing partagés
  ├── _layout.tsx        : squelette commun (header rubis-deep + footer)
  ├── checkin_email.tsx  : email envoyé À L'USER avec 2 boutons CTA
  │                        Oui (rubis primary) / Non (outlined)
  └── relance_email.tsx  : email envoyé AU CLIENT, body texte du plan
                           + card récap (numéro, montant, échéance,
                           badge retard rubis-deep)

Stack :
  - @react-email/components + @react-email/render
  - Tous les styles inline (compatible Gmail / Outlook / Apple Mail)
  - HTML + plain text en fallback (anti-spam, accessibility)

mail_dispatcher.ts :
  - sendRelanceEmail : .html(rendered) + .text(body)
  - sendCheckinEmail : .html(rendered) + .text(body)
  - daysLate calculé via clock.now (démo-aware)

send_test_email :
  - Nouveau flag --template=checkin (default) | relance | plain pour
    tester chaque rendu via Mailpit sans créer de vraie facture.

Brand & landing :
  - "Rubis Sur l'Ongle" → "Rubis sur l'ongle" partout (config, mail,
    PDF, Stripe appInfo)
  - Nouvelle env var LANDING_URL (default https://rubis.arthurbarre.fr)
  - Footer email rend "Rubis sur l'ongle" comme <a> rubis cliquable
    vers la landing — l'user qui reçoit le mail connaît la marque
    derrière l'envoi
  - .env.example mis à jour avec LANDING_URL pour les autres devs

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:10:27 +02:00
ordinarthur
87c6f49692 fix(mail): from-name = nom de l'org (pas "Rubis Sur l'Ongle")
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m8s
Le client final qui reçoit la relance voyait "From: Rubis Sur l'Ongle"
alors qu'il connaît "Arthur Barré" (le patron de la TPE qui utilise
Rubis). Confusion garantie côté client → relance perçue comme spam.

Fix : `sendRelanceEmail` utilise maintenant comme display name "From" :
  1. `organization.name` (en priorité — c'est le nom commercial connu
     du client)
  2. `user.fullName` (fallback si l'org n'a pas de nom posé)
  3. `MAIL_FROM_NAME` env (dernier recours, "Rubis Sur l'Ongle" en prod)

L'adresse technique reste sur notre domaine vérifié (relances@arthurbarre.fr
→ SPF/DKIM Resend OK), seul le display name change.

Le mail check-in (envoyé À l'user, pas au client) garde "Rubis Sur l'Ongle"
comme display — c'est nous qui le notifions, c'est cohérent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:45:57 +02:00
ordinarthur
ab75f1f979 fix(checkin): bump invoice.status pending → awaiting_user_confirmation
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m6s
Bug V1 documenté dans flow.md mais jamais corrigé : le job send_checkin_job
envoyait l'email + marquait la CheckinTask `sent`, mais ne touchait pas le
statut de la facture. Conséquence : l'user reçoit le mail check-in dans sa
boîte mais la modale in-app au refresh ne l'affiche pas (la modale liste
uniquement les `awaiting_user_confirmation` côté DB).

Fix : après l'envoi mail OK et le mark CheckinTask=sent, on bump
`Invoice.status = 'awaiting_user_confirmation'` SI elle est encore
en `pending`. Pas de bump si entre temps :
  - mark-paid (status=paid)
  - litigation/cancelled (transitions manuelles)
  - in_relance (impossible mais safe)

Doc flow.md mise à jour pour refléter le nouveau comportement (effets
de la transition pending → awaiting + déprécation de la note "TODO V1.5").

Pour les factures existantes en prod qui ont déjà reçu le mail mais
restent en `pending` (cas pré-fix) : backfill manuel via SQL :

  UPDATE invoices SET status = 'awaiting_user_confirmation'
  WHERE status = 'pending'
    AND id IN (
      SELECT invoice_id FROM checkin_tasks WHERE status = 'sent'
    );

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:42:52 +02:00
ordinarthur
023f08c261 feat(api): commande ace billing:scenario pour tester les états billing
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m7s
Force un état billing sur l'org d'un user pour tester rapidement chaque
comportement UI/enforcement sans passer par Stripe ni attendre 3 mois.

Usage :
  node ace billing:scenario --email <user> --scenario <name>

Scénarios :
  • status         : ne touche rien, affiche juste l'état courant
  • fresh          : reset signup neuf (free + grace 3 mois)
  • grace-expired  : free, grace terminée, ≤ 5 actives → OK
  • limit-reached  : free, grace terminée, force 5 actives → bloqué (402)
  • pro            : pro mensuel actif, fake IDs si pas de vrais
  • pro-cancelling : pro + cancel_at_period_end=true → bandeau ANNULÉ
  • pro-past-due   : pro + status=past_due → warning UI
  • business       : business mensuel actif

Sécurité : préserve les VRAIS Stripe IDs s'ils existent (= l'org a
déjà payé). Génère des fake `cus_test_FAKE_*` / `sub_test_FAKE_*`
seulement si NULL — ne pas écraser une vraie souscription.

Le command affiche un récap compact à chaque exécution :
  - plan / grace / Stripe IDs / status / cancel_at
  - factures actives vs limite
  - création autorisée ou non + raison

Pour tester un comportement côté UI :
  1. Lance le scénario
  2. Reload /parametres/abonnement et /factures
  3. Vérifie le rendu (bandeau cancel, blocage import, etc.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:36:06 +02:00
ordinarthur
3bad1451a9 docs(bruno): collection Billing + endpoints check-in in-app
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 19s
Nouveau dossier `09-Billing/` avec :
  - folder.bru (overview : plans + flows upgrade/cancel/reactivate)
  - 01 Get subscription : state du plan, caps, grace period, cancel flag
  - 02 Start checkout   : crée une Checkout Session Stripe (Pro/Business
                          × monthly/yearly)
  - 03 Open portal      : Customer Portal pour gérer CB/annulation
  - 04 Reactivate       : annule l'annulation programmée (sans paiement
                          immédiat) — gère le conflit Stripe
                          cancel_at vs cancel_at_period_end

Aussi documenté les endpoints in-app check-in qui manquaient dans Bruno :
  - 03 In-app pending           : liste des factures awaiting_user_confirmation
  - 04 In-app respond paid      : équivalent du lien email "C'est payé"
  - 05 In-app respond pending   : équivalent "Toujours en attente"

README mis à jour avec le parcours étendu (signup → … → billing).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:25:55 +02:00
ordinarthur
0f1a309be3 fix 2026-05-07 17:20:33 +02:00
ordinarthur
031b8cc062 fix(billing): détecte aussi cancel_at (Customer Portal) + reactivate sans conflit
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m7s
Bug : le Stripe Customer Portal n'utilise pas `cancel_at_period_end:true`
mais `cancel_at:<timestamp>` pour scheduler l'annulation. Notre webhook
ne lisait que le booléen → l'annulation via portail n'était pas remontée
côté DB, l'UI ne montrait jamais le bandeau "annulé".

Webhook handler :
  - Détecte l'annulation via EITHER `cancel_at_period_end` OR `cancel_at`
    et unifie en un seul booléen `cancelAtPeriodEnd` côté org.

Endpoint /reactivate :
  - Stripe REFUSE qu'on passe `cancel_at_period_end:false` ET `cancel_at:null`
    dans le même update ("Please pass in only one"). On retrieve d'abord
    la sub pour savoir laquelle des 2 mécaniques est active, puis on clear
    uniquement celle-là.

Logs enrichis : `cancelAtPeriodEnd` et `cancelAt` désormais loggés à
chaque `applySubscriptionToOrg` pour que le diagnostic soit immédiat.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:18:18 +02:00
ordinarthur
cb87bbc8d1 feat(billing): expose l'annulation programmée + bouton "Réactiver"
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 27s
Build & Deploy API / build-and-deploy (push) Successful in 1m14s
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>
2026-05-07 17:05:02 +02:00
ordinarthur
b1361de606 add stripe best practices for agent 2026-05-07 17:00:38 +02:00
ordinarthur
4dcd85f912 test(billing): unit tests backend (17) + frontend (7)
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 26s
Build & Deploy API / build-and-deploy (push) Successful in 1m14s
Backend (`apps/api/tests/unit/billing.spec.ts`) — 17 tests :
  - PLAN_CAPS sanity : Free 5 invoices/1 user, Pro illimité, Business 5 sièges
  - countActiveInvoices :
      • compte les 4 statuts actifs (pending, awaiting, in_relance, litigation)
      • exclut paid + cancelled
      • isolation par org (ne fuit pas entre orgs)
  - canCreateInvoices :
      • Free + grace period → autorisé même à 50+ actives
      • Free post-grace + 4 actives + delta=1 → autorisé (≤ limite)
      • Free post-grace + 5 actives + delta=1 → BLOQUÉ + bonne raison/limit/current
      • Free post-grace + 3 actives + delta=3 → BLOQUÉ (over par batch)
      • Pro + Business → toujours autorisé
      • paid n'occupe pas de slot (5 paid + delta=5 → autorisé)
  - getOrgSubscriptionState :
      • inGracePeriod=true quand date future
      • inGracePeriod=false quand date passée
      • Pro reflète subscription_status / billing_cycle / current_period_end
      • activeInvoicesCount inclut bien les 4 statuts

Frontend (`apps/web/src/lib/billing.test.tsx`) — 7 tests :
  - useSubscription : appelle /billing/subscription, retourne le state
  - useIsAtFreeLimit :
      • false en loading
      • false sur Pro avec 200 factures
      • false en grace period même si activeCount > limit (12)
      • true sur Free post-grace + activeCount = limit (5/5)
      • true sur Free post-grace + activeCount > limit (8/5)
      • false sur Free post-grace + activeCount < limit (4/5)

Setup :
  - vitest.config.ts : ajout de `env: {VITE_API_URL, ...}` pour stub
    les variables exigées par src/lib/env.ts au chargement (sinon plante
    au boot des tests).
  - Mock vi.spyOn(api, "get") pour éviter les vraies requêtes HTTP.
  - QueryClient avec retry:false pour fail-fast.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 16:43:40 +02:00
ordinarthur
fd24ef42a6 feat(billing): redesign page abonnement — layout asymétrique + identité Rubis
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 19s
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>
2026-05-07 16:34:46 +02:00
ordinarthur
1952265217 feat(billing): plans Free/Pro/Business + Stripe Checkout & Customer Portal
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m0s
Build & Deploy Landing / build-and-deploy (push) Successful in 31s
Build & Deploy API / build-and-deploy (push) Successful in 1m52s
Pricing V1 :
  - Free  : 5 factures actives, 1 user, 3 mois de grâce illimité au signup
  - Pro   : 19 €/mois ou 190 €/an, factures illimitées, 1 user
  - Business : 49 €/mois ou 490 €/an, illimité + 5 sièges (V2 multi-users)
              + reply-from-user-email (V2)

Backend :
  - Migration : plan, grace_period_ends_at, stripe_customer_id,
    stripe_subscription_id, subscription_status, billing_cycle,
    current_period_end sur `organizations`. Backfill grace_period auto.
  - `app/services/billing.ts` : PLAN_CAPS, countActiveInvoices,
    canCreateInvoices (enforce post-grace), getOrgSubscriptionState.
  - `app/services/stripe.ts` : client lazy + lookup_keys stables.
  - `app/controllers/billing_controller.ts` :
      • GET  /billing/subscription      → state pour l'UI
      • POST /billing/checkout          → crée une Checkout Session
      • POST /billing/portal            → Customer Portal Session
      • POST /billing/webhook (public)  → handle 4 events Stripe
        (checkout.completed, subscription.updated/deleted, invoice.payment_failed)
  - `commands/stripe_setup.ts` : `node ace stripe:setup` crée Products +
    Prices (idempotent via lookup_key).
  - Enforcement 402 `plan_limit_reached` sur :
      • POST /invoices (saisie manuelle)
      • POST /invoices/import-batch/:id/drafts/:draftId/validate (OCR)

Frontend :
  - `lib/billing.ts` : useSubscription, useStartCheckout, useOpenPortal,
    useIsAtFreeLimit.
  - `routes/_app/parametres_.abonnement.tsx` : page comparaison plans
    avec toggle mensuel/annuel, current plan + portail Stripe, CTA upgrade
    qui redirige vers Checkout hostée.
  - `routes/_app/parametres.tsx` : nouvelle section "Abonnement" qui
    affiche le plan courant + lien vers la page abonnement.
  - `components/billing/PlanLimitBanner.tsx` : banner sur /factures qui
    s'adapte selon période (grâce / approche / atteinte).
  - Toast dédié 402 sur la validation OCR avec action "Passer Pro".

Doc :
  - flow.md : nouvelle section §11 "Pricing & enforcement" qui couvre
    plans, grâce, webhook flow, Customer Portal, env vars.

Setup dev :
  1. STRIPE_SECRET_KEY (sk_test_...) dans apps/api/.env
  2. `stripe listen --forward-to localhost:3333/api/v1/billing/webhook`
     → copier whsec_... → STRIPE_WEBHOOK_SECRET
  3. `node ace stripe:setup` une fois pour créer Products+Prices
  4. Tester via /parametres/abonnement → checkout en mode test Stripe

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:03:28 +02:00
ordinarthur
d410ae014e docs: flow.md — cycle de vie facture, statuts, surfaces UI, check-in deep dive
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 22s
Nouvelle doc orientée comportement produit : explique précisément ce que
fait Rubis du point de vue user-lambda. Pour les arch/tech → architecture.md.
Pour la spec features → produit.md.

Sections :
  1. Modèle mental
  2. Glossaire (rubis, plan, étape, confirmation, mise en demeure, DSO, LME)
  3. Cycle de vie d'une facture (6 statuts + diagramme transitions ASCII +
     détails par transition avec effets en cascade)
  4. Surfaces UI où l'user agit (modale check-in, email check-in, fiche
     facture, slide-over démo) — avec différences mobile/desktop
  5. Mécanique de confirmation deep-dive (le coeur du produit)
  6. Plans de relance (structure, plans pré-fournis, wizard custom, vars)
  7. Mode démo (flag, fork point unique, horloge virtuelle)
  8. KPIs & calculs (rubis, encaissé, DSO, pipeline)
  9. Edge cases & règles
  10. Métriques produit à instrumenter
  11. Ce que Rubis ne fait PAS

CLAUDE.md mis à jour pour pointer vers cette doc dans la liste des
documents associés.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 14:26:59 +02:00