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>
614 lines
28 KiB
Markdown
614 lines
28 KiB
Markdown
# Architecture technique — Rubis Sur l'Ongle
|
||
|
||
> Version : 0.1 · Dernière maj : 2026-05-05
|
||
> Décisions de référence : ADR-014 (stack), ADR-015 (repo), ADR-016 (PG), ADR-017 (auth), ADR-018 (storage). Voir `/docs/decisions.md`.
|
||
|
||
Ce document est la source de vérité technique. Quand le code et ce fichier divergent, on tranche en discussion et on met à jour ici.
|
||
|
||
---
|
||
|
||
## 1. Vue d'ensemble
|
||
|
||
```
|
||
┌────────────────────────────┐
|
||
│ Internet (HTTPS) │
|
||
└─────────────┬──────────────┘
|
||
│
|
||
┌─────────────▼──────────────┐
|
||
│ Traefik (Proxmox gateway) │
|
||
│ Host(rubis.pro) │
|
||
│ Host(app.rubis.pro) │
|
||
└─────────────┬──────────────┘
|
||
│
|
||
┌──────────────────────────┼─────────────────────────┐
|
||
│ │ │
|
||
┌───────▼─────────┐ ┌────────▼─────────┐ ┌──────────▼────────┐
|
||
│ Pod: rubis- │ │ Pod: rubis-web │ │ Pod: rubis-api │
|
||
│ landing (Astro) │ │ (nginx + Vite) │ │ (AdonisJS Node) │
|
||
│ rubis.pro/* │ │ app.rubis.pro/* │ │ /api/v1/* + │
|
||
│ (SSG + SSR) │ │ (proxy /api → api)│ │ jobs BullMQ │
|
||
└───────┬─────────┘ └────────┬─────────┘ └──────────┬────────┘
|
||
│ SSR fetch JSON │ │
|
||
└─────────────►──────────────────────►────────────────┘
|
||
│
|
||
┌───────────────────────┼───────────────────────┐
|
||
│ │ │
|
||
┌────────▼─────────┐ ┌────────▼────────┐ ┌─────────▼────────┐
|
||
│ LXC: PostgreSQL │ │ LXC: MinIO │ │ Provider OCR │
|
||
│ (existant) │ │ (S3-compat) │ │ (à benchmarker) │
|
||
└──────────────────┘ └─────────────────┘ └──────────────────┘
|
||
```
|
||
|
||
**Composants** :
|
||
|
||
| Composant | Rôle | Hosting | Status |
|
||
|---|---|---|---|
|
||
| `apps/landing` | Landing + blog publics (Astro 6 SSR) | Pod Node K3s (`rubis-landing:4321`) | ✅ Déployé |
|
||
| `apps/web` | SPA React + TanStack Router/Query (SaaS) | nginx pod K3s (build statique + proxy `/api/*` → rubis-api) | ✅ Déployé |
|
||
| `apps/api` | API REST AdonisJS v7 — logique métier, jobs BullMQ inline, email, billing Stripe | Pod Node K3s (`rubis-api:3333`) | ✅ Déployé |
|
||
| `packages/shared` | Types TS, schemas Zod, constantes (statuts, tons, plans, etc.) | workspace local | ✅ |
|
||
| `packages/ui` | Design system partagé (tokens Tailwind v4 + composants TSX) | workspace local | ✅ |
|
||
| PostgreSQL | Base de données métier | LXC Proxmox existant (`10.10.10.3`) | ✅ En place |
|
||
| MinIO | Stockage PDF + uploads blog (S3-compat, bucket `rubis-prod-invoices`) | namespace K3s `minio` | ✅ En place |
|
||
| Redis | Backend BullMQ (queues: relances, checkins, payment-thanks) | Deployment K3s `rubis-redis` + PVC | ✅ Déployé |
|
||
| Provider OCR | Extraction texte des factures | Mistral (`OCR_PROVIDER=mistral`) | ✅ Choisi |
|
||
| Provider Email outbound | Resend (`relances@rubis.pro`, sub-domaine `send.rubis.pro`) | Externe (HTTPS) | ✅ En place |
|
||
| Provider Email inbound | OVH MX (`contact@rubis.pro`, `dev@rubis.pro`) | Externe (HTTPS/IMAP) | ✅ En place |
|
||
|
||
---
|
||
|
||
## 2. Repo layout (monorepo)
|
||
|
||
```
|
||
rubis/
|
||
├── apps/
|
||
│ ├── api/ # AdonisJS v7 backend
|
||
│ │ ├── app/ # Controllers, models, services
|
||
│ │ ├── config/ # Auth, database, mail, queue
|
||
│ │ ├── database/
|
||
│ │ │ ├── migrations/
|
||
│ │ │ └── seeders/
|
||
│ │ ├── start/ # Routes, kernel
|
||
│ │ ├── tests/
|
||
│ │ ├── ace.js # CLI Adonis
|
||
│ │ ├── package.json
|
||
│ │ └── tsconfig.json
|
||
│ ├── web/ # SPA React Vite (app.rubis.pro)
|
||
│ │ ├── src/
|
||
│ │ │ ├── routes/ # TanStack Router (file-based)
|
||
│ │ │ ├── components/ # composants app-spécifiques
|
||
│ │ │ ├── lib/ # api client, query keys, utils
|
||
│ │ │ └── main.tsx
|
||
│ │ ├── public/
|
||
│ │ ├── vite.config.ts
|
||
│ │ └── package.json
|
||
│ └── landing/ # Astro SSR (rubis.pro + /blog)
|
||
│ ├── src/
|
||
│ │ ├── pages/ # routes file-based (.astro + .ts endpoints)
|
||
│ │ ├── layouts/ # Layout.astro, LegalLayout.astro
|
||
│ │ ├── components/ # SiteHeader/Footer + sections + blog
|
||
│ │ ├── lib/api.ts # client API typé
|
||
│ │ └── styles/app.css
|
||
│ ├── public/ # favicons, manifest
|
||
│ ├── astro.config.mjs
|
||
│ └── package.json
|
||
├── packages/
|
||
│ ├── shared/ # Types TS + schemas Zod (api ↔ web)
|
||
│ │ └── src/{types,schemas,constants}
|
||
│ └── ui/ # Design system partagé (apps/web + apps/landing)
|
||
│ └── src/{components,lib,styles}
|
||
├── docs/
|
||
│ └── tech/architecture.md # (ce fichier)
|
||
├── k3s/
|
||
│ ├── namespace.yml
|
||
│ └── app/{api,web,landing,redis}.yml
|
||
├── Dockerfile.api # AdonisJS Node SSR
|
||
├── Dockerfile.web # nginx + bundle Vite
|
||
├── Dockerfile.landing # Astro Node SSR (standalone)
|
||
├── pnpm-workspace.yaml
|
||
└── CLAUDE.md
|
||
```
|
||
|
||
**Outils monorepo** :
|
||
- **pnpm workspaces** — léger, rapide, gestion native des liens symboliques entre packages
|
||
- **TypeScript project references** — résout les imports cross-package sans build préalable
|
||
- **Turborepo** *(optionnel, à voir au volume)* — cache + parallélisation des scripts
|
||
|
||
**Commandes racine** typiques :
|
||
```bash
|
||
pnpm install # installe tout
|
||
pnpm -F api dev # dev API
|
||
pnpm -F web dev # dev SPA
|
||
pnpm -F api migration:run # migrations DB
|
||
pnpm -F api test # tests API
|
||
pnpm build # build api + web pour prod
|
||
```
|
||
|
||
---
|
||
|
||
## 3. apps/api — AdonisJS v7
|
||
|
||
### Stack interne
|
||
- **AdonisJS v7** + **Lucid ORM** (PG)
|
||
- **`@adonisjs/auth`** — access tokens Bearer (stateless)
|
||
- **`@adonisjs/bouncer`** — autorisations par policy (admin/lecture/édition pour V2 multi-users)
|
||
- **`@adonisjs/mail`** — emails outbound (provider à choisir ADR-021)
|
||
- **`@adonisjs/queue`** ou **BullMQ** — jobs différés (relances programmées, OCR, check-ins)
|
||
- **`@adonisjs/limiter`** — rate limiting sur les routes publiques (login, signup)
|
||
- **Vine** (validateur natif Adonis 7) ou **Zod** côté API pour validation des payloads
|
||
|
||
### Conventions de routes
|
||
Toutes les routes API sous `/api/v1/`. Versioning explicite — V2 vivra côté `/api/v2/` sans casser V1.
|
||
|
||
```
|
||
POST /api/v1/auth/register
|
||
POST /api/v1/auth/login
|
||
POST /api/v1/auth/logout
|
||
POST /api/v1/auth/refresh
|
||
|
||
GET /api/v1/me
|
||
PATCH /api/v1/me
|
||
GET /api/v1/organizations/:id
|
||
|
||
GET /api/v1/invoices
|
||
POST /api/v1/invoices # create manual
|
||
POST /api/v1/invoices/upload # OCR pipeline
|
||
GET /api/v1/invoices/:id
|
||
PATCH /api/v1/invoices/:id
|
||
DELETE /api/v1/invoices/:id
|
||
POST /api/v1/invoices/:id/relance # relance manuelle
|
||
POST /api/v1/invoices/:id/mark-paid
|
||
|
||
GET /api/v1/plans
|
||
POST /api/v1/plans
|
||
PATCH /api/v1/plans/:id
|
||
DELETE /api/v1/plans/:id
|
||
|
||
GET /api/v1/clients
|
||
POST /api/v1/clients
|
||
PATCH /api/v1/clients/:id
|
||
|
||
GET /api/v1/dashboard/kpis
|
||
GET /api/v1/dashboard/activity
|
||
```
|
||
|
||
### Conventions de réponse
|
||
- JSON systématique
|
||
- Format succès : `{ data: ..., meta?: { ... } }`
|
||
- Format erreur : `{ errors: [{ code, message, field? }] }`
|
||
- Codes HTTP standards (200, 201, 204, 400, 401, 403, 404, 422, 500)
|
||
- Pagination cursor-based pour les listes (préférable à offset pour les flux modifiés en temps réel)
|
||
|
||
### Endpoints blog publics
|
||
|
||
L'API expose les articles du blog en **JSON** (consommés par `apps/landing` en SSR). L'API ne sert plus de HTML — le rendu HTML déménagé côté Astro.
|
||
|
||
| Route | Description |
|
||
|---|---|
|
||
| `GET /api/v1/posts` | Liste des articles publiés (summary, sans contentHtml) |
|
||
| `GET /api/v1/posts/:slug` | Article complet (contentHtml inclus) + 3 articles liés |
|
||
|
||
Cf. `apps/api/app/controllers/blog_controller.ts` + `apps/api/app/transformers/post_transformer.ts`.
|
||
|
||
**Pipeline de contenu** :
|
||
1. **Manuel V1** : `node ace seed:blog` insère/upserte les 3 articles fondateurs.
|
||
2. **Admin (PR3)** : page React `/admin/blog` sur `app.rubis.pro` (auth-gated) — édition markdown + preview + bouton publier. Le markdown est rendu en HTML via `app/services/blog_renderer.ts` (marked v15, GFM, IDs auto) au save, cache dans `posts.content_html`.
|
||
3. **Cron IA hebdo (PR4)** : `apps/api/app/jobs/weekly_blog_post.ts` génère un draft Sonnet 4.6 chaque lundi 06:00 Europe/Paris ; review humaine obligatoire avant publish.
|
||
|
||
---
|
||
|
||
## 4. apps/web — React + Vite
|
||
|
||
### Stack interne
|
||
- **React 19** + **Vite 6**
|
||
- **TanStack Router** — routing file-based (à privilégier), search params type-safe pour les filtres facture
|
||
- **TanStack Query** — cache + invalidation + optimistic updates pour le state serveur
|
||
- **TailwindCSS** *(à confirmer)* — utility-first, cohérent avec les couleurs de marque
|
||
- **Lucide React** pour les icônes
|
||
- **Bricolage Grotesque + Inter** via Google Fonts (cohérent landing)
|
||
|
||
### Versionnage SPA + toast de release
|
||
|
||
- `apps/web/src/version.ts` exporte `APP_VERSION` (semver string) et `CHANGELOG_URL` (`https://rubis.pro/changelog`).
|
||
- Bump manuel à chaque release, dans la même PR que l'ajout de l'entrée changelog côté `apps/landing`.
|
||
- `<VersionToast/>` (mount-once dans `__root.tsx`) compare cette constante à `localStorage["rubis:last-seen-version"]`. Toast persistant Sonner si différent. Cf. `apps/web/src/components/version-toast.tsx`.
|
||
- localStorage indisponible (private mode) → fail silent, pas de toast et pas de crash.
|
||
|
||
### Auth côté SPA (cf. ADR-017)
|
||
- **Access token** stocké en mémoire (variable de module / state Query) — pas localStorage pour éviter XSS
|
||
- **Refresh token** en cookie httpOnly + SameSite=Strict
|
||
- Au boot du SPA : appel `/auth/refresh` pour obtenir un nouvel access token (silent reauth)
|
||
- Si refresh échoue → redirect `/login`
|
||
|
||
### Organisation des routes
|
||
File-based via TanStack Router :
|
||
```
|
||
src/routes/
|
||
├── __root.tsx # Layout global + AuthGate
|
||
├── login.tsx
|
||
├── signup.tsx
|
||
├── _app/ # Routes protégées
|
||
│ ├── _app.tsx # Layout app (sidebar, header, brand)
|
||
│ ├── index.tsx # Dashboard
|
||
│ ├── factures.tsx # Liste factures
|
||
│ ├── factures.$id.tsx # Détail facture
|
||
│ ├── plans.tsx # Bibliothèque plans
|
||
│ ├── plans.$id.tsx # Éditeur plan
|
||
│ ├── clients.tsx
|
||
│ └── parametres.tsx
|
||
└── onboarding/
|
||
├── compte.tsx
|
||
├── entreprise.tsx
|
||
└── signature.tsx
|
||
```
|
||
|
||
---
|
||
|
||
## 4bis. apps/landing — Astro 6 (rubis.pro)
|
||
|
||
Landing publique + blog, en Astro avec rendu hybride statique/SSR. Image Docker `rubis-landing` (Node 22 standalone), service ClusterIP `rubis-landing:4321`.
|
||
|
||
### Stack interne
|
||
- **Astro 6** + adapter `@astrojs/node` (mode standalone)
|
||
- **React 19** via `@astrojs/react` pour les composants interactifs / sections complexes
|
||
- **Tailwind v4** via `@tailwindcss/vite`
|
||
- **`@rubis/ui`** — tokens + composants partagés avec `apps/web`
|
||
|
||
### Stratégie de rendu
|
||
|
||
`output: "server"` dans `astro.config.mjs` → SSR par défaut, override par page :
|
||
|
||
| Page | Mode | Raison |
|
||
|---|---|---|
|
||
| `/` (landing) | SSG (`prerender = true`) | HTML figé, LCP/CLS optimaux, copy change rarement |
|
||
| `/mentions-legales`, `/confidentialite`, `/cgv` | SSG | Pages légales, mises à jour planifiées |
|
||
| `/blog` | SSR | Doit refléter immédiatement les publications admin |
|
||
| `/blog/:slug` | SSR | Idem + 404 dynamique |
|
||
| `/blog/rss.xml`, `/sitemap.xml`, `/robots.txt` | SSR (endpoints `.ts`) | Liste à jour |
|
||
| `/changelog`, `/changelog/rss.xml` | SSG (`prerender = true`) | Contenu MD versionné, regénéré à chaque release |
|
||
|
||
**Cache HTTP** : pages SSR avec `Cache-Control: public, max-age=300, stale-while-revalidate=86400`. Absorbe les pics, et un publish admin propage en ≤5 min sans purge active.
|
||
|
||
### Structure
|
||
```
|
||
apps/landing/
|
||
├── astro.config.mjs # output: server, adapter Node, tailwindcss plugin
|
||
├── src/
|
||
│ ├── content.config.ts # collections Astro (changelog)
|
||
│ ├── content/changelog/ # 1 .md par version livrée (frontmatter Zod)
|
||
│ │ ├── 1.0.0.md ... 1.10.0.md
|
||
│ ├── pages/
|
||
│ │ ├── index.astro # / (SSG)
|
||
│ │ ├── mentions-legales.astro # SSG
|
||
│ │ ├── confidentialite.astro # SSG
|
||
│ │ ├── cgv.astro # SSG
|
||
│ │ ├── sitemap.xml.ts # SSR endpoint
|
||
│ │ ├── robots.txt.ts # SSR endpoint
|
||
│ │ ├── blog/
|
||
│ │ │ ├── index.astro # /blog (SSR)
|
||
│ │ │ ├── [slug].astro # /blog/:slug (SSR)
|
||
│ │ │ └── rss.xml.ts # SSR endpoint
|
||
│ │ └── changelog/
|
||
│ │ ├── index.astro # /changelog (SSG)
|
||
│ │ └── rss.xml.ts # SSG endpoint
|
||
│ ├── layouts/
|
||
│ │ ├── Layout.astro # SEO complet, Header + Footer
|
||
│ │ └── LegalLayout.astro # wrapper prose pour pages légales
|
||
│ ├── components/
|
||
│ │ ├── SiteHeader.tsx # nav publique
|
||
│ │ ├── SiteFooter.tsx
|
||
│ │ ├── sections/ # 10 sections de la landing
|
||
│ │ └── blog/PostCard.tsx
|
||
│ ├── lib/api.ts # client API typé (fetch /api/v1/posts)
|
||
│ ├── styles/app.css # @import @rubis/ui/styles + @source
|
||
│ └── public/ # favicons, manifest
|
||
```
|
||
|
||
### Mécanique du changelog (release workflow)
|
||
|
||
À chaque release, **deux fichiers doivent être modifiés en même temps**, dans le même commit :
|
||
|
||
1. `apps/web/src/version.ts` — bump de la constante `APP_VERSION` (semver, sans préfixe `v`)
|
||
2. `apps/landing/src/content/changelog/<APP_VERSION>.md` — nouvelle entrée Markdown avec frontmatter Zod-validé (`version`, `date`, `title`, `type`, `highlights[]`)
|
||
|
||
Le composant `<VersionToast/>` monté au root de la SPA (`apps/web/src/routes/__root.tsx`) compare `APP_VERSION` à `localStorage["rubis:last-seen-version"]` au mount :
|
||
|
||
- Clé absente (1re visite) → on enregistre `APP_VERSION` en silence, pas de toast
|
||
- Clé identique → rien
|
||
- Clé différente → toast Sonner persistant avec action `Voir les nouveautés ↗` qui ouvre `https://rubis.pro/changelog#<APP_VERSION>` dans un nouvel onglet. localStorage est mis à jour à l'affichage (donc l'user ne reverra plus le toast pour cette version même s'il ferme sans cliquer).
|
||
|
||
Pour automatiser le ritual des deux fichiers, le skill Claude Code `/push` (cf. `.claude/skills/push/SKILL.md`) bump la version, crée le `.md` correspondant, commit avec un message conventional, et push sur `gitea/main`.
|
||
|
||
### Connexion à l'API
|
||
|
||
En SSR, Astro fetch directement le service Adonis via le DNS K3s (interne, pas de TLS) — cf. `API_URL` dans le ConfigMap `rubis-landing-config` :
|
||
```
|
||
API_URL=http://rubis-api.rubis.svc.cluster.local:3333
|
||
```
|
||
|
||
En dev local : `API_URL=http://localhost:3333` dans `.env.development`.
|
||
|
||
---
|
||
|
||
## 5. packages/shared — types et schémas partagés
|
||
|
||
Le but : un client API typé fortement, sans duplication de définitions.
|
||
|
||
```ts
|
||
// packages/shared/src/types/invoice.ts
|
||
export type InvoiceStatus = 'pending' | 'awaiting_user_confirmation' | 'paid' | 'in_relance' | 'litigation' | 'cancelled'
|
||
|
||
export type Invoice = {
|
||
id: string
|
||
numero: string
|
||
clientId: string
|
||
amountTtc: number
|
||
dueDate: string // ISO
|
||
status: InvoiceStatus
|
||
planId: string | null
|
||
// ...
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// packages/shared/src/schemas/invoice.ts
|
||
import { z } from 'zod'
|
||
|
||
export const createInvoiceSchema = z.object({
|
||
numero: z.string().min(1),
|
||
clientId: z.string().uuid(),
|
||
amountTtc: z.number().positive(),
|
||
dueDate: z.string().datetime(),
|
||
// ...
|
||
})
|
||
|
||
export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>
|
||
```
|
||
|
||
**Avantage** : Adonis valide avec ce schéma, le SPA valide avec le même, le type est inféré une seule fois.
|
||
|
||
---
|
||
|
||
## 5bis. packages/ui — design system partagé
|
||
|
||
Source unique de vérité pour la marque visuelle, consommée par `apps/web` (et bientôt `apps/landing`).
|
||
|
||
```
|
||
packages/ui/
|
||
├── src/
|
||
│ ├── styles/
|
||
│ │ ├── tokens.css # @theme Tailwind v4 : palette, fonts, radius, shadows
|
||
│ │ └── base.css # @layer base + @utility (eyebrow, shadow-rubis…)
|
||
│ ├── components/
|
||
│ │ ├── Brand.tsx # Lockup ◆ + "Rubis sur l'ongle"
|
||
│ │ ├── Gem.tsx # SVG gem facetté direction A
|
||
│ │ ├── Button.tsx # Variants : primary/secondary/ghost/link/danger × sm/md/lg
|
||
│ │ ├── Card.tsx # Variants : default/flat/hero × paddings
|
||
│ │ ├── Chip.tsx # Pastille sélectionnable (filtres, choix tonalité)
|
||
│ │ ├── Eyebrow.tsx # Petit label majuscule rubis avec ◆ préfixe
|
||
│ │ └── EmptyState.tsx # Message centré + icon + cta
|
||
│ ├── lib/cn.ts # Helper Tailwind merge (clsx + tailwind-merge)
|
||
│ └── index.ts # Barrel : exports nommés uniquement
|
||
├── package.json # peerDeps : react, react-dom, lucide-react
|
||
└── tsconfig.json # extends ../../tsconfig.base.json
|
||
```
|
||
|
||
**Conventions** :
|
||
- **Pas de build step** : on exporte directement les `.ts/.tsx` (cohérent avec `packages/shared`). Vite/tsc résout les imports en mode source.
|
||
- **Tokens en CSS** : Tailwind v4 lit `@theme {}` et génère automatiquement les classes (`bg-rubis`, `font-display`, `rounded-card`…). Pour ajouter une couleur, c'est dans `tokens.css`, nulle part ailleurs.
|
||
- **Consommation** :
|
||
```css
|
||
/* dans le CSS racine de l'app */
|
||
@import "@rubis/ui/styles/tokens.css";
|
||
@import "@rubis/ui/styles/base.css";
|
||
@source "../../../packages/ui/src/**/*.{ts,tsx}"; /* scan des composants */
|
||
```
|
||
```ts
|
||
import { Button, Card, Brand } from "@rubis/ui";
|
||
```
|
||
- **`peerDeps`** : `react`, `react-dom`, `lucide-react`. Le consommateur fournit ces deps (évite la duplication de bundle).
|
||
- **Ce qui RESTE app-spécifique** (`apps/web/src/components/`) : layouts (`AppLayout`, `AppSidebar`), formulaires liés à TanStack Form, composants couplés à des hooks d'auth/router.
|
||
|
||
---
|
||
|
||
## 6. Flux de données critiques
|
||
|
||
### 6.1 Upload + OCR + création facture
|
||
|
||
```
|
||
SPA (drag & drop PDF)
|
||
│
|
||
│ POST /api/v1/invoices/upload (multipart)
|
||
▼
|
||
api: stocke le PDF dans MinIO (bucket rubis-invoices)
|
||
│
|
||
│ retourne { uploadId, status: 'processing' }
|
||
▼
|
||
api: enqueue job ProcessOcr(uploadId)
|
||
│
|
||
▼
|
||
worker: récupère le PDF depuis MinIO
|
||
│
|
||
│ appel HTTP vers OCR provider
|
||
▼
|
||
worker: parse les champs extraits, crée l'Invoice en DB (status: pending)
|
||
│
|
||
▼
|
||
SPA: poll ou WebSocket → reçoit l'Invoice prête à valider
|
||
```
|
||
|
||
**Points d'attention** : le job OCR doit être idempotent (même uploadId rejoué = pas de duplicate). Le SPA peut afficher un spinner pendant les 3-10 secondes d'OCR.
|
||
|
||
### 6.2 Programmation des relances
|
||
|
||
```
|
||
SPA: utilisateur clique "Valider" sur l'Invoice
|
||
│
|
||
│ PATCH /api/v1/invoices/:id (status: scheduled, planId: …)
|
||
▼
|
||
api: créé N RelanceTasks (une par étape du plan)
|
||
chaque RelanceTask a un sendAt (calculé d'après dueDate + offset étape)
|
||
│
|
||
▼
|
||
queue: tâches en attente
|
||
│
|
||
│ ┌─ avant chaque relance, créer aussi un CheckinTask (T-2j) ─┐
|
||
▼ ▼ ▼
|
||
worker @ sendAt: vérifie l'état de l'Invoice (toujours pending ?)
|
||
│
|
||
│ si invoice.status === 'pending' → envoie l'email
|
||
│ sinon → no-op (l'invoice a été marquée payée entre-temps)
|
||
```
|
||
|
||
### 6.3 Check-in email à l'utilisateur
|
||
|
||
```
|
||
worker @ checkinTask.sendAt:
|
||
│
|
||
│ génère un token signé (avec invoice.id + reply_action: 'paid' | 'not_paid')
|
||
▼
|
||
api: envoie un email à l'utilisateur (pas au client) avec 2 boutons
|
||
│
|
||
│ chaque bouton = lien GET /api/v1/checkin/:token (action embeddée)
|
||
▼
|
||
utilisateur clique "Oui, j'ai été payé"
|
||
│
|
||
▼
|
||
api: GET /checkin/:token → vérifie token, marque invoice.status = 'paid'
|
||
→ annule les RelanceTasks futures de cette invoice
|
||
→ redirect SPA avec confirmation
|
||
```
|
||
|
||
**Sécurité** : le token doit être signé (HMAC ou JWT court) et avoir une durée limitée (24h après émission). Pas d'auth Bearer requise pour ce endpoint car c'est un click depuis email.
|
||
|
||
### 6.4 Authentification Bearer
|
||
|
||
```
|
||
SPA: POST /api/v1/auth/login { email, password }
|
||
│
|
||
│ api valide credentials, crée AccessToken (TTL 30 min) + RefreshToken (TTL 30j httpOnly cookie)
|
||
▼
|
||
SPA reçoit { accessToken, user } — accessToken stocké en mémoire
|
||
│
|
||
│ chaque requête API : Authorization: Bearer <accessToken>
|
||
▼
|
||
30 min plus tard : 401 sur appel API
|
||
│
|
||
▼
|
||
SPA: POST /api/v1/auth/refresh (cookie httpOnly envoyé auto)
|
||
│
|
||
│ api valide refresh, émet nouvel accessToken
|
||
▼
|
||
SPA retry l'appel original avec nouveau token
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Topologie de déploiement
|
||
|
||
### Réseau Proxmox
|
||
|
||
| Resource | Type | Rôle |
|
||
|---|---|---|
|
||
| Cluster K3s | Pool VMs Proxmox | Orchestration des pods app |
|
||
| LXC `postgres` | LXC dédié | PostgreSQL — accessible aux pods K3s via réseau interne |
|
||
| LXC `minio` | LXC dédié | MinIO — accessible aux pods K3s via réseau interne |
|
||
| Traefik | Reverse proxy | TLS termination + routing par hostname |
|
||
|
||
### Pods K3s
|
||
|
||
```yaml
|
||
# Namespace: rubis (cf. k3s/app/*.yml)
|
||
- Deployment: rubis-api # AdonisJS Node, port 3333 (workers BullMQ inline, pas de pod séparé)
|
||
- Deployment: rubis-web # nginx, sert le bundle Vite + proxy /api → rubis-api:3333, port 80
|
||
- Deployment: rubis-landing # Astro 6 SSR Node, port 4321
|
||
- Deployment: rubis-redis # Redis 7 + PVC pour BullMQ
|
||
- Service: rubis-api # NodePort 30100 → 3333
|
||
- Service: rubis-web # NodePort 30110 → 80
|
||
- Service: rubis-landing # NodePort 30111 → 4321
|
||
- Service: rubis-redis # ClusterIP 6379
|
||
- ConfigMap: rubis-api-config + rubis-landing-config + rubis-web-config
|
||
- Secret: rubis-api-secrets # DB credentials, S3 credentials, OCR/Mistral API key, Resend key, Stripe keys
|
||
- Routing Traefik (config dynamique sur la VM gateway, repo proxmox) :
|
||
rubis.pro → 10.10.10.5:30111 (rubis-landing)
|
||
app.rubis.pro/* → 10.10.10.5:30110 (rubis-web — qui proxie /api/* vers rubis-api en interne K3s)
|
||
# Note : pas de hostname `api.rubis.pro` exposé. L'API est servie via app.rubis.pro/api/*.
|
||
```
|
||
|
||
**Ressources externes (LXC Proxmox, hors K3s)** : PostgreSQL (`PG_HOST=10.10.10.3` en clair dans `rubis-api-config`), MinIO (DNS `minio.minio.svc.cluster.local:9000` via le namespace MinIO du même cluster, bucket prod = `rubis-prod-invoices`). Pas de `Service ExternalName` créé, les URLs sont posées directement dans la ConfigMap.
|
||
|
||
### Pipeline CI Gitea
|
||
|
||
```
|
||
git push gitea main
|
||
↓
|
||
.gitea/workflows/build.yml
|
||
↓
|
||
build & push images :
|
||
- git.arthurbarre.fr/ordinarthur/rubis-api:<sha>
|
||
- git.arthurbarre.fr/ordinarthur/rubis-web:<sha>
|
||
↓
|
||
kubectl rollout (api + web)
|
||
↓
|
||
healthchecks readinessProbe → service public
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Conventions de code
|
||
|
||
| Domaine | Convention |
|
||
|---|---|
|
||
| Branches | `feat/<short-desc>`, `fix/<short-desc>`, `chore/<…>` |
|
||
| Commits | [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`) |
|
||
| TypeScript | `strict: true`, pas de `any` (sauf justifié + commenté), `noUncheckedIndexedAccess: true` |
|
||
| Linting | ESLint + Prettier, config partagée via `tsconfig.base.json` + `.eslintrc.cjs` racine |
|
||
| Tests | Japa (Adonis) pour API, Vitest pour shared/, Playwright pour E2E utilisateur (V2) |
|
||
| Migrations | Versionnées, jamais éditées rétroactivement (cf. principe ADR du log de décisions) |
|
||
| Secrets | Jamais en clair dans le repo. `.env.local` git-ignoré, secrets K3s pour la prod |
|
||
|
||
---
|
||
|
||
## 9. Sécurité & RGPD
|
||
|
||
- **Hébergement français** (Proxmox France) — conforme RGPD pour la cible TPE-PME
|
||
- **Chiffrement at-rest** : disque Proxmox chiffré (LUKS) — à confirmer côté infra
|
||
- **Chiffrement in-transit** : TLS partout (Traefik), connexions PG et MinIO en SSL interne
|
||
- **Rate limiting** : `@adonisjs/limiter` sur `/auth/*` (5 req/min par IP), routes OCR (10/h par utilisateur)
|
||
- **CORS** : whitelist stricte (`app.rubis.pro` uniquement) — refus des origines tierces
|
||
- **CSRF** : non-applicable car auth via Bearer header (pas cookie session). Les endpoints email check-in utilisent des tokens signés à TTL court.
|
||
- **Backups** :
|
||
- PG : dump quotidien dans MinIO (`rubis-backups/pg/<date>.dump`)
|
||
- MinIO : snapshot Proxmox du LXC quotidien
|
||
- Retention : 30 jours mini (à confirmer)
|
||
- **Suppression** : RGPD Article 17 — endpoint `DELETE /api/v1/me` qui purge data utilisateur + factures + pièces jointes
|
||
|
||
---
|
||
|
||
## 10. Décisions encore en attente
|
||
|
||
À trancher avant fin V1, par ordre de priorité :
|
||
|
||
| # | Sujet | Statut |
|
||
|---|---|---|
|
||
| 019 | **Domain model** (entités, relations, index) | ✅ tranché — modèles dans `apps/api/app/models/` |
|
||
| 020 | **Provider OCR** | ✅ tranché — Mistral (`OCR_PROVIDER=mistral`, cf. `.env.example` + `k3s/app/api.yml`). ADR à formaliser |
|
||
| 021 | **Provider email** | ✅ tranché — Resend pour le sortant, OVH MX pour l'entrant (cf. `docs/tech/backend.md` §12.5). ADR à formaliser |
|
||
| 022 | **Pricing exact** | ✅ tranché — Free 5 factures / Pro 19 € / Business 49 € (cf. `apps/api/app/services/billing.ts:34`) |
|
||
| 023 | **Endpoint waitlist** | ❌ obsolète — la landing pousse directement vers signup, pas de waitlist en prod |
|
||
| 024 | **Sentry pour error tracking** | ✅ tranché — voir `docs/decisions.md` ADR-024 |
|
||
|
||
---
|
||
|
||
## 11. Évolutions V2+ anticipées
|
||
|
||
- **Multi-utilisateurs** : tables `organizations` et `memberships` à prévoir dès la V1 (même si UI mono-user)
|
||
- **SMS** : provider Twilio/OVH abstrait derrière un service `MessageDispatcher` qui route email/sms selon plan + cadence
|
||
- **Intégration banking** : webhook entrant sur `/api/v1/banking/payment-confirmed` qui marque les invoices payées automatiquement (le check-in email V1 devient fallback)
|
||
- **Intégrations comptables** (Pennylane/Sage) : modèle d'événement abstrait `invoice.created` exportable en webhook sortant
|
||
- **API publique** : sous `/api/v1/public/*` avec abilities/scopes par token (lecture seule, écriture limitée)
|
||
|
||
---
|
||
|
||
*Maintenu par Arthur + Claude. Ce document est versionné — les changements significatifs passent par un ADR dans `/docs/decisions.md`.*
|