Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions docs/onboarding/01-demarrage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# 01 — Démarrage local

Objectif : app qui tourne sur `http://localhost:3000` avec une DB, l'auth, et
(optionnellement) le LLM et Stripe.

## Prérequis

- **Node ≥ 20** (le repo tourne en pratique sur Node 24)
- **pnpm 10** (`packageManager` épinglé dans le `package.json` racine)
- **Docker** (pour le PostgreSQL local via `docker-compose.yml`)
- Pour le PDF : un **Chromium/Chrome** installé (dev macOS : Chrome suffit ; sinon variable `CHROMIUM_EXECUTABLE_PATH`)
- Optionnel : le **CLI Stripe** (`stripe`) pour tester le paiement en local

## Étapes

```bash
# 0. Dépendances (postinstall lance `nuxt prepare`)
pnpm install

# 1. Fichier d'environnement (voir la table ci-dessous)
cp apps/app/.env.example apps/app/.env
# → puis édite apps/app/.env avec tes vraies clés

# 2. PostgreSQL local (conteneur postgres:16 sur :5432)
pnpm db:up

# 3. Applique le schéma (crée toutes les tables)
pnpm --filter @cvo/app prisma:migrate:deploy

# 4. Lance l'app (front Vue + API Nitro dans le même process)
pnpm dev # http://localhost:3000
```

> `pnpm dev` lance `prisma generate && nuxt dev`. Nuxt charge automatiquement
> `apps/app/.env` en dev.

## Variables d'environnement (`apps/app/.env`)

Toutes lues **côté serveur uniquement** (jamais exposées au client).

| Variable | Rôle | Requis ? |
|---|---|---|
| `DATABASE_URL` | Connexion PostgreSQL (matche `docker-compose.yml`) | **Oui** |
| `BETTER_AUTH_SECRET` | Secret de signature des sessions/tokens (min 32 chars) | **Oui** (en prod : fail-fast si absent, voir [08](./08-securite.md)) |
| `APP_URL` | URL de base de l'app (magic-links, cookies) — **`https://…` en prod** | **Oui** |
| `ANTHROPIC_API_KEY` | Clé API Claude (analyse/score/génération). Sans elle, `/api/candidature/*` répond 502 | Oui pour le flux CV |
| `ANTHROPIC_BASE_URL` | Origine de l'API Anthropic (le code ajoute `/v1/messages`) | Non (défaut fourni) |
| `DISABLE_GENERATION_LIMIT` | `true` = désactive le gate de crédits à la génération (**dev only**) | Non |
| `STRIPE_SECRET_KEY` | Clé Stripe (test `sk_test_…`/`rk_test_…`) — checkout & webhook | Oui pour le billing |
| `STRIPE_WEBHOOK_SECRET` | Secret de signature du webhook (`whsec_…`) | Oui pour le billing |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` / `MAIL_FROM` | Envoi d'e-mail (magic-link). **Absent en dev → le lien est loggé dans la console** | Oui en **prod** |
| `CHROMIUM_EXECUTABLE_PATH` | Chemin du binaire Chromium pour le rendu PDF (Docker/CI) | Non en dev macOS |

## Se connecter en local (magic-link sans SMTP)

Sans `SMTP_HOST`, l'e-mail n'est pas envoyé : le lien magic-link est **écrit dans la
console du serveur dev**. Récupère-le dans les logs :

```
[DEV] Magic-link pour toi@example.com:
http://localhost:3000/api/auth/magic-link/verify?token=...&callbackURL=%2Fprofil
```

Colle l'URL dans le navigateur → tu es connecté. Le lien est **à usage unique** et
expire en **10 min**. Le sign-in est **rate-limité à 3/min** (voir [08](./08-securite.md)).

## Tester le paiement Stripe en local ⚠️ (piège vécu)

Les crédits ne sont accordés **que par le webhook** `checkout.session.completed`. En
local, Stripe ne peut pas joindre `localhost` seul : il faut le relais du CLI.

```bash
# 1. Crée les produits/prix des packs dans Stripe (idempotent)
pnpm --filter @cvo/app stripe:seed

# 2. Laisse tourner CE terminal pendant tout le dev billing :
stripe listen --api-key <TA_CLÉ_.env> --forward-to localhost:3000/api/billing/webhook
```

**Deux pièges qui font que « le paiement passe mais les crédits n'augmentent pas » :**
1. `stripe listen` **pas lancé** → l'événement n'atteint jamais le serveur.
2. `stripe listen` connecté à un **autre compte Stripe** que ta clé `.env` (utilise
`--api-key <clé .env>` pour forcer le bon compte) — sinon les events partent ailleurs.

Copie le `whsec_…` imprimé par `stripe listen` dans `STRIPE_WEBHOOK_SECRET`, puis
**redémarre Nuxt** (l'env n'est lu qu'au boot). Détails complets : [07 — Billing](./07-billing-credits.md).

## Scripts utiles

### Racine (`package.json`)
| Commande | Effet |
|---|---|
| `pnpm dev` | Lance l'app Nuxt (`apps/app`) |
| `pnpm build` | Build `@cvo/shared` puis l'app (Nuxt/Nitro) |
| `pnpm typecheck` | Build shared + `typecheck` récursif (vue-tsc + tsc strict) |
| `pnpm test` | Build shared + tests Vitest récursifs |
| `pnpm lint` | ESLint sur tout le repo |
| `pnpm format` / `pnpm format:write` | Prettier (check / write) |
| `pnpm db:up` / `pnpm db:down` | PostgreSQL local (Docker) |

### App (`apps/app`, via `pnpm --filter @cvo/app <script>`)
| Commande | Effet |
|---|---|
| `prisma:migrate:dev` | Crée une migration en dev (schéma modifié) |
| `prisma:migrate:deploy` | Applique les migrations existantes |
| `prisma:generate` | (Re)génère le client Prisma |
| `stripe:seed` | Crée les produits/prix Stripe des packs |
| `dev` / `build` / `start` / `preview` | Cycle de vie Nuxt |
| `typecheck` / `test` | Qualité (par package) |

## Gate qualité avant de merger

Le repo est **PR-only** (aucun push direct sur `main`, seul l'owner merge). Un script
rejoue les jobs CI en local :

```bash
bash scripts/pre-merge.sh # lint · typecheck · build · test
bash scripts/pre-merge.sh --health # + boot Nuxt + probe /api/health (Postgres requis)
```

> ℹ️ La CI GitHub-hosted peut être rouge pour une raison **d'infra** (facturation
> Actions au niveau du compte, pas de SMTP). On valide **en local**. Voir
> [`docs/ci-gate.md`](../ci-gate.md).

## Preuve de vie

```bash
curl -s http://localhost:3000/api/health
# {"status":"ok","db":"up","service":"cvo-app","timestamp":"…"}
```
130 changes: 130 additions & 0 deletions docs/onboarding/02-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# 02 — Architecture

## Le monorepo

```
cv-optimizer/
├─ apps/
│ └─ app/ # L'application Nuxt 3 full-stack
│ ├─ pages/ # Routes Vue (front)
│ ├─ components/ # Composants Vue (ui/, cv/, landing/)
│ ├─ composables/ # Logique front réutilisable (useAuth, useToast…)
│ ├─ layouts/ middleware/ # Layout global + garde de navigation (auth)
│ ├─ config/ i18n/ # Marque, pricing, textes FR
│ ├─ assets/css/ # Tokens de design Tailwind v4
│ ├─ utils/ # Utils front (photo-crop, health-label)
│ ├─ server/ # ⬅️ L'API (serveur Nitro) — voir plus bas
│ ├─ prisma/ # schema.prisma + migrations
│ ├─ scripts/ # stripe-seed.ts
│ └─ test/ # Tests unitaires Vitest (côté app)
├─ packages/
│ └─ shared/ # @cvo/shared : types & logique partagés client ↔ serveur
├─ docs/ # Documentation (dont ce dossier onboarding/)
├─ scripts/ # pre-merge.sh, mock-llm.mjs
└─ docker-compose.yml # PostgreSQL local
```

### `apps/app/server/` — l'API (Nitro)

C'est le cœur backend. Nitro mappe l'arborescence de fichiers sur des routes.

```
server/
├─ api/ # Endpoints HTTP (le nom du fichier = la route + la méthode)
│ ├─ auth/[...all].ts # tout /api/auth/** délégué à Better Auth
│ ├─ profile/… # CRUD profil candidat
│ ├─ candidature/… # analyze + generate (LLM)
│ ├─ candidatures/… # CRUD des candidatures persistées
│ ├─ cv/… # preview (HTML) + export-pdf
│ ├─ billing/… # checkout + webhook + summary (Stripe)
│ ├─ usage/current.get.ts # compteurs d'usage
│ ├─ health.get.ts # preuve de vie
│ └─ csp-report.post.ts # collecteur de violations CSP
├─ middleware/auth.ts # s'exécute sur CHAQUE requête : pose event.context.userId
├─ plugins/00.validate-env.ts # fail-fast au boot (secrets prod)
├─ routes/sitemap.xml.ts# routes non-/api (sitemap)
├─ services/ # logique métier LLM (offer-analysis, matching, match-report…)
└─ utils/ # briques serveur (prisma, auth, stripe, pdf, credits, cv-html…)
```

> **Convention Nitro** : `server/api/foo/bar.post.ts` → `POST /api/foo/bar`.
> `[id]` = paramètre dynamique (`getRouterParam(event, 'id')`).
> `[...all]` = catch-all (toutes les sous-routes).

### `packages/shared` (`@cvo/shared`)

Types TypeScript et **logique pure** partagés entre le front et le serveur : contrats
d'API, types du CV, garde-fou de provenance, quotas, pricing. Importé partout via
`@cvo/shared` (workspace pnpm). **Doit être buildé** (`tsc`) avant l'app — c'est fait
automatiquement par `pnpm build`/`typecheck`/`test`. Voir [03](./03-modele-de-donnees.md)
et [06](./06-pipeline-candidature.md).

## Principe directeur : tout le sensible est côté serveur

Le navigateur ne voit **jamais** : les clés API (Anthropic, Stripe), les prompts, les
données brutes d'un autre utilisateur. Le front appelle des endpoints `/api/*` ; le
serveur Nitro fait le travail (DB, LLM, Stripe, PDF) et ne renvoie que le strict
nécessaire. C'est aussi une exigence RGPD (voir [`docs/rgpd.md`](../rgpd.md)).

## Cycle de vie d'une requête API

```
Navigateur ──HTTP──▶ Nitro
│
├─ 1. server/middleware/auth.ts
│ résout la session Better Auth (cookie)
│ → pose event.context.userId (ou undefined)
│ ⚠️ NON bloquant : ne lève jamais 401 lui-même
│
├─ 2. nuxt-security (headers, CSP, size limiter)
│
└─ 3. le handler de l'endpoint
├─ requireUserId(event) ── si besoin d'auth → 401 si absent
├─ validation Zod du body
├─ Prisma (DB) / services LLM / Stripe / PDF
└─ réponse JSON (ou binaire pour le PDF)
```

### Le modèle d'authentification (à bien comprendre)

C'est un modèle **opt-in par route**, en deux temps :

1. **Producteur** — `server/middleware/auth.ts` tourne sur *toutes* les requêtes,
résout la session et **pose `event.context.userId`** (ou le laisse `undefined`).
Il **ne bloque jamais**.
2. **Consommateur** — chaque endpoint qui exige l'auth appelle
**`requireUserId(event)`** (`server/utils/session.ts`), qui lit
`event.context.userId` et **lève une 401** s'il est absent.

> ⚠️ **Conséquence pour toi** : un endpoint qui **oublie** `requireUserId` est
> **ouvert au public**. C'est déjà arrivé (l'export PDF, corrigé dans l'audit sécu).
> Réflexe : tout nouvel endpoint qui lit/écrit des données d'un user **commence** par
> `const userId = requireUserId(event)`.

### Le flux magic-link (Better Auth)

```
1. POST /api/auth/sign-in/magic-link { email } → envoie un lien (mail ou log dev)
2. GET /api/auth/magic-link/verify?token=… → valide, pose le cookie de session,
redirige vers callbackURL (ex. /profil)
3. Requêtes suivantes : le cookie `better-auth.session_token` authentifie l'utilisateur
```

Better Auth stocke ses données dans les tables `sessions` / `accounts` / `verifications`
(voir [03](./03-modele-de-donnees.md)). Côté front, le composable `useAuth()`
enveloppe le client Better Auth.

## Stack technique (récap)

| Couche | Techno | Où |
|---|---|---|
| Front | Vue 3 (SFC), Nuxt 3 | `apps/app/pages`, `components`, `composables` |
| Styles | Tailwind v4 (tokens `@theme`) | `apps/app/assets/css/main.css` |
| API | Nitro (Nuxt server) | `apps/app/server` |
| DB | PostgreSQL + Prisma | `apps/app/prisma`, `server/utils/prisma.ts` |
| Auth | Better Auth (magic-link) | `server/utils/auth.ts` |
| LLM | Claude (Anthropic) | `server/utils/anthropic.ts`, `server/services/*` |
| PDF | Chromium (playwright-core) | `server/utils/pdf.ts` |
| Paiement | Stripe Checkout | `server/utils/stripe.ts`, `server/api/billing/*` |
| Types partagés | `@cvo/shared` | `packages/shared` |
| Sécurité HTTP | nuxt-security (headers, CSP) | `apps/app/nuxt.config.ts` |
110 changes: 110 additions & 0 deletions docs/onboarding/03-modele-de-donnees.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# 03 — Modèle de données (Prisma / PostgreSQL)

Fichier : [`apps/app/prisma/schema.prisma`](../../apps/app/prisma/schema.prisma).
Client Prisma exposé en singleton par [`server/utils/prisma.ts`](../../apps/app/server/utils/prisma.ts).

> **Convention** : les modèles sont en `PascalCase` côté Prisma mais **mappés en
> `snake_case` en base** via `@@map` (ex. `Candidature` → table `candidatures`,
> `CreditLedger` → `credit_ledger`, `User` → `users`). ⚠️ En SQL direct (psql), utilise
> les noms **snake_case**, pas les noms Prisma.

## Deux invariants transverses

1. **Soft-delete RGPD** — la plupart des tables métier ont un `deletedAt DateTime?`.
Une ligne avec `deletedAt` non nul = **supprimée logiquement**, à exclure des
lectures. Le helper `NOT_DELETED` (`server/utils/profile-serialize.ts`) sert de
filtre standard (`{ deletedAt: null }`). La purge physique est un futur job de
rétention (voir `DataGovernanceEvent`).
2. **`userId` opaque sur les tables « additives »** — `Candidature`, `UsageEvent`,
`UsageCounter`, `CreditLedger` stockent un `userId` **sans FK Prisma** vers `users`.
C'était un choix pour que ces lots restent indépendants de l'ordre de merge. La
sécurité d'accès repose donc sur le **filtrage applicatif par `userId`** dans chaque
endpoint (voir [04](./04-backend-api.md)), pas sur une contrainte DB.

## Domaines du schéma

### 1. Profil candidat (la source de vérité du moteur)

```
User (1) ──1:1── Profile (1) ──1:n── Experience
├──1:n── Skill
├──1:n── Education
└──1:n── Language
```

| Modèle | Table | Rôle & champs notables |
|---|---|---|
| `User` | `users` | Compte. `email` unique, `emailVerified`, `name`, `image`, `stripeCustomerId` (unique, créé à la 1re intention d'achat), `deletedAt`. Relations : `profile`, `sessions`, `accounts`. |
| `Profile` | `profiles` | 1-1 avec User. **Identité factuelle** du CV : `fullName`, `email`, `phone`, `location`, `links[]`. `keySkills[]` = « compétences clés » (phrases verbe d'action). `headline`, `summary`. `baseCvDesign Json?` = thème « CV de base » capturé d'un PDF (style seul, **jamais le contenu**, le PDF n'est jamais stocké). |
| `Experience` | `experiences` | `title`, `company`, `startDate`/`endDate` (`Date`, endDate null = en cours), `description`, `skillsUsed[]`, `orderIndex` (ordre d'affichage). |
| `Skill` | `skills` | `label`, `level` (`SkillLevel` enum), `years?`, `orderIndex`. **Compétence RÉELLE déclarée** = source de vérité du moteur. |
| `Education` | `education` | `degree`, `school`, dates, `description`, `orderIndex`. |
| `Language` | `languages` | `label`, `level` (`LanguageLevel` = CEFR A1→C2 + `NATIVE`), `orderIndex`. |

> 🔑 **À retenir** : le contenu du CV généré **ne peut venir que de ces tables**. Le
> garde-fou de provenance (voir [06](./06-pipeline-candidature.md)) rejette tout élément
> de CV qui ne pointe pas vers un `id` réel du profil.

### 2. Candidature (CV persisté + suivi)

| Modèle | Table | Rôle & champs notables |
|---|---|---|
| `Candidature` | `candidatures` | Une candidature = une offre analysée + un CV généré éditable + un suivi. `userId` (opaque), `label` (dénormalisé = `offer.title`), `status` (`CandidatureStatus`: DRAFT/SUBMITTED/INTERVIEW/REJECTED/ACCEPTED), `offerSnapshot Json` (`AnalyzedOffer` figée), `matchScore Int` (dénormalisé, tri/badge), `matchReport Json`, `generatedCv Json` (`RenderableCv`, **copie de travail éditable**), `design Json?` (`CvDesign` par-candidature, null = fallback profil/défaut). Index `[userId, deletedAt]`. |

### 3. Metering (usage — base du billing freemium)

On mesure l'usage **dès le MVP**, même gratuit. **Jamais de contenu** — que des
compteurs et des volumes de tokens.

| Modèle | Table | Rôle |
|---|---|---|
| `UsageEvent` | `usage_events` | 1 ligne par action. `type` (`UsageEventType`: GENERATION/EXPORT_PDF/EXTRACTION), `period` (yyyymm dénormalisé), `tokensIn`/`tokensOut`, `billable`. |
| `UsageCounter` | `usage_counters` | Agrégat `(userId, period)` — incrémenté par upsert atomique à chaque événement. Unique `[userId, period]`. Sert au quota `export_pdf` (voir [`usage.ts`](#quotas)). |

### 4. Billing — le ledger de crédits ⭐

Modèle **grand livre append-only** : le **solde = somme des `delta`**. On n'écrit
**jamais** un solde directement — chaque octroi/consommation est **une ligne**. Ça
garantit l'audit et l'invariant « **jamais déficitaire** ».

| Modèle | Table | Champs |
|---|---|---|
| `CreditLedger` | `credit_ledger` | `userId`, `delta Int` (jamais 0 : +N octroi/achat, −1 génération), `reason` (`CreditReason`), `idempotencyKey String?` (unique), `packKey String?`, `createdAt`. |

**Les `reason` et leur idempotence :**

| reason | delta | idempotencyKey | Signification |
|---|---|---|---|
| `FREE_GRANT` | +N | `free:<userId>` | Crédits offerts (1 seule ligne par user) |
| `PURCHASE` | +N | id de session Stripe | 1 achat = 1 ligne (rejeu webhook sans effet) |
| `GENERATION` | −1 | `null` | Consommation à la génération d'un CV |
| `ADJUSTMENT` | ±N | `null` | Correction manuelle (SAV) |

> La contrainte `@@unique([idempotencyKey])` fait tout le travail d'idempotence :
> deux `PURCHASE` avec le même `sessionId` ⇒ le 2e est rejeté (Postgres autorise
> plusieurs `NULL` distincts, donc les GENERATION/ADJUSTMENT ne se gênent pas). Détails
> dans [07 — Billing](./07-billing-credits.md).

### 5. Better Auth (tables satellites)

Schéma imposé par Better Auth v1 (adaptateur Prisma). **Pas de soft-delete** (géré en
dur par Better Auth). Champs en `camelCase` imposés par l'adaptateur.

| Modèle | Table | Rôle |
|---|---|---|
| `Session` | `sessions` | Sessions actives (`token` unique, `expiresAt`, `ipAddress`, `userAgent`). |
| `Account` | `accounts` | Comptes liés (OAuth/credentials ; ici = magic-link). |
| `Verification` | `verifications` | Tokens de vérification — **le plugin magic-link y stocke ses tokens**. |

### 6. RGPD (placeholder structurel)

| Modèle | Table | Rôle |
|---|---|---|
| `DataGovernanceEvent` | `data_governance_event` | Trace des actions de gouvernance (`DataGovernanceAction`: RETENTION_APPLIED / ERASURE_REQUESTED / EXPORT_REQUESTED). `subjectRef` = référence opaque (ex. userId), pas de FK. **Placeholder** : la couche conformité réelle est cadrée par [`docs/rgpd.md`](../rgpd.md). |

## Migrations

- Dossier : `apps/app/prisma/migrations/`.
- Créer une migration en dev : `pnpm --filter @cvo/app prisma:migrate:dev`.
- Appliquer en CI/prod : `pnpm --filter @cvo/app prisma:migrate:deploy`.
- Après changement de schéma : `pnpm --filter @cvo/app prisma:generate` (fait aussi par `dev`/`build`).
Loading
Loading