Audit de sécurité et fonctionnel : correctifs appliqués, quiz rejouable (D-018)
Sécurité : réclamation de foyer et consentement de rétractation déplacés dans le webhook Stripe uniquement (jamais plus au rendu de page) ; mot de passe Postgres régénéré et réseau dédié (fokan-net, base retirée de ai-net) ; limitation de débit sur les routes publiques ; plafonds sur les invitations ; jeton .ics dédié et révocable ; en-têtes de sécurité. Fonctionnel : le mail « garder le lien » part réellement ; verrou en mémoire sur l'enrichissement d'un foyer (course quiz/pg-boss) ; rattrapage Crit'Air étendu au passage hebdomadaire ; webhook Stripe robustifié (past_due/trialing ne coupent plus l'accès) ; champ P.2 proposé aussi sur la voie déclarative du quiz (marque/modèle), pas seulement via le champ K. Nouveau : un abonné peut rejouer son quiz pour mettre à jour son foyer (garde-fou d'un rejeu par mois) — voir D-018 dans docs/decisions.md pour le compromis retenu sur l'état des échéances. Exploitation : CI GitHub Actions, ESLint, migrations Drizzle versionnées (base existante baselinée, nouvelle migration jouée), healthcheck /api/health. Code mort retiré. Voir docs/audit-2026-07-28.md § 0 pour le détail complet, correctif par correctif, et ce qui reste délibérément différé (sauvegardes, sourcing des intervalles de courroie). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
d38478d092
commit
84c971b05b
56 changed files with 12881 additions and 319 deletions
35
.github/workflows/ci.yml
vendored
Normal file
35
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
# Priorité absolue (§ 27 du cadrage) : le moteur de réconciliation en premier.
|
||||
- name: Tests (vitest)
|
||||
run: npm test
|
||||
|
||||
- name: Validation de la base de connaissance
|
||||
run: npm run knowledge:check
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
# fokan — règles de travail
|
||||
|
||||
Projet « Veille du foyer » (marque sœur de Kankwa, éditée par MIAW). La source de vérité complète est [docs/cadrage-projet-fokan.md](docs/cadrage-projet-fokan.md) — en cas de doute sur une décision produit ou technique, la réponse y est probablement déjà, validée.
|
||||
Projet « Veille du foyer » (marque sœur de Kankwa, éditée par MIAW). La source de vérité complète est [docs/cadrage-projet-fokan.md](docs/cadrage-projet-fokan.md) — en cas de doute sur une décision produit ou technique, la réponse y est probablement déjà, validée. Les divergences postérieures sont tranchées et datées dans [docs/decisions.md](docs/decisions.md), qui prime sur le cadrage.
|
||||
|
||||
Documents de référence par pilier : [maison](docs/pilier-maison.md) · [véhicules](docs/pilier-vehicules.md) · [papiers](docs/pilier-papiers.md) · [animaux](docs/pilier-animaux.md) · [contrats](docs/pilier-contrats.md) (transverse, pas un pilier). État connu des défauts et chantiers restants : [docs/audit-2026-07-28.md](docs/audit-2026-07-28.md).
|
||||
|
||||
## Constitution produit (arbitre de toute feature)
|
||||
|
||||
|
|
|
|||
17
Dockerfile
17
Dockerfile
|
|
@ -1,15 +1,26 @@
|
|||
FROM node:22-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY package*.json .npmrc ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Dépendances de production seules, pour le script de migration (audit E2) — le build standalone
|
||||
# ne trace que ce que Next.js atteint depuis les pages/routes, pas les scripts hors app.
|
||||
FROM node:22-bookworm-slim AS deps
|
||||
WORKDIR /app
|
||||
COPY package*.json .npmrc ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:22-bookworm-slim
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/knowledge ./knowledge
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
COPY --from=builder /app/scripts/migrate.ts ./scripts/migrate.ts
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
# Migrations versionnées jouées avant le serveur (§ 27) — remplace le `drizzle-kit push` manuel.
|
||||
CMD ["sh", "-c", "node --experimental-strip-types scripts/migrate.ts && node server.js"]
|
||||
|
|
|
|||
77
README.md
77
README.md
|
|
@ -8,24 +8,75 @@ Marque sœur de [Kankwa](../kankwa), éditée par MIAW. Nom de code : **fokan**
|
|||
|
||||
## L'essentiel
|
||||
|
||||
- **Quiz de 2 min + détections (plaque, adresse)** → le calendrier complet du foyer (30-50 échéances), sans saisie.
|
||||
- **Le mail est le moteur** : rappels rares, au bon moment, avec l'action incluse. Réaction en 10 s ou ignorance sans conséquence.
|
||||
- **Freemium** : gratuit = le savoir instantané (quiz + hub figé + export .ics) ; payant (~4-5 €/mois par foyer) = la garde permanente + le registre.
|
||||
- **La barrière** : la base de connaissance des échéances françaises + le pipeline de compréhension de documents. Le produit, c'est le savoir.
|
||||
- **Un quiz de 2 minutes** (9 écrans) plus des détections gratuites — adresse (BAN, DPE ADEME, Géorisques, ZFE, arrêtés) et carte grise (champ K → référentiel RDW) — produisent le calendrier complet du foyer, 30 à 50 échéances, sans saisie obligatoire.
|
||||
- **Le mail est le moteur** : rappels rares, au bon moment, avec l'action incluse. Réaction en 10 s, ou ignorance sans conséquence — le glissement est dans le design.
|
||||
- **Révélation graduée** : gratuit = l'inventaire (total, rythme de l'année, 2-3 échéances en clair). Payant = **19,99 €/an par foyer** — full reveal, veille active, rappels, flux ics vivant, catalogue de guides, enrichissement continu.
|
||||
- **La barrière** : la base de connaissance des échéances françaises et le pipeline de compréhension de documents. Le produit, c'est le savoir ; le code n'est que son véhicule.
|
||||
|
||||
## Où en est le produit
|
||||
|
||||
| Brique | État |
|
||||
|---|---|
|
||||
| Base de connaissance | **66 fiches** bicéphales (moteur + page SEO), validées par schéma |
|
||||
| Moteur de réconciliation | Livré, pur, 288 tests verts |
|
||||
| Quiz + écran de fabrication en flux | Livré |
|
||||
| Pilier maison | Phases 0, 1 et 2 livrées (Géorisques, ZFE, veille CatNat/VigiEau) |
|
||||
| Pilier véhicules | Identification, rappels constructeur, Crit'Air/ZFE livrés ; **distribution livrée côté moteur, en cours de sourcing côté donnée** |
|
||||
| Piliers papiers et animaux | Fiches livrées ; **aucun enrichissement externe** |
|
||||
| Comptes, paiement, notifications | better-auth, Stripe (clés de test), scheduler quotidien — livrés |
|
||||
| Contrats | 8 types déclarables au quiz, 8 fiches |
|
||||
| Rejeu du quiz | Livré (28/07/2026) — un abonné met à jour ses déclarations, garde-fou d'un rejeu par mois |
|
||||
| CI, lint, migrations versionnées | Livrés (28/07/2026) — cf. `.github/workflows/ci.yml`, `drizzle/` |
|
||||
| Sauvegardes | **Absentes** — seul risque que le cadrage qualifie d'inacceptable (§ 24.2), différé à la demande du porteur de projet |
|
||||
|
||||
## Documents
|
||||
|
||||
- [docs/cadrage-projet-fokan.md](docs/cadrage-projet-fokan.md) — cadrage produit + architecture technique de référence, validé point par point le 22 juillet 2026. **Source de vérité du projet.**
|
||||
**Source de vérité**
|
||||
- [docs/cadrage-projet-fokan.md](docs/cadrage-projet-fokan.md) — cadrage produit + architecture technique de référence, validé point par point le 22 juillet 2026.
|
||||
- [docs/decisions.md](docs/decisions.md) — journal des décisions post-cadrage (D-001 → D-017). Toute divergence avec le cadrage y est tranchée et datée.
|
||||
- [CLAUDE.md](CLAUDE.md) — règles de travail sur ce dépôt (constitution produit, invariants d'architecture, stack).
|
||||
- [docs/phase-0/etude-api-plaque.md](docs/phase-0/etude-api-plaque.md) — étude des fournisseurs API plaque + sources open data adresse.
|
||||
- [docs/phase-0/quiz.md](docs/phase-0/quiz.md) — les 8 écrans du quiz, réponses et inférences.
|
||||
- [docs/phase-0/landing.md](docs/phase-0/landing.md) — spec de la landing, prix affiché, hub figé, mesure de l'intention.
|
||||
|
||||
**Audit**
|
||||
- [docs/audit-2026-07-28.md](docs/audit-2026-07-28.md) — revue complète : code, connaissance, exploitation. **À lire avant d'ouvrir un chantier** — le § 8 propose un ordre de traitement.
|
||||
|
||||
**Par pilier**
|
||||
- [docs/pilier-maison.md](docs/pilier-maison.md) — ce qu'une adresse française permet de savoir gratuitement. Le plus abouti.
|
||||
- [docs/pilier-vehicules.md](docs/pilier-vehicules.md) — ce qu'une carte grise permet de savoir, et ce qu'on a renoncé à en tirer.
|
||||
- [docs/pilier-papiers.md](docs/pilier-papiers.md) — la valeur n'est pas la date d'expiration, c'est ce qu'il y a devant.
|
||||
- [docs/pilier-animaux.md](docs/pilier-animaux.md) — deux questions, six échéances, zéro dépendance externe.
|
||||
- [docs/pilier-contrats.md](docs/pilier-contrats.md) — chantier transverse, pas un pilier (§ 5.6).
|
||||
|
||||
**Phase 0** *(historique — voir les avertissements en tête de chaque fichier)*
|
||||
- [docs/phase-0/quiz.md](docs/phase-0/quiz.md) — les 9 écrans, réponses et inférences. **Réaligné sur le code le 28/07/2026.**
|
||||
- [docs/phase-0/landing.md](docs/phase-0/landing.md) — spec de la landing. Dépassée sur le prix, l'ics et le hub figé.
|
||||
- [docs/phase-0/etude-api-plaque.md](docs/phase-0/etude-api-plaque.md) — **étude close** : D-002 annulée par D-004, aucune API plaque n'est utilisée.
|
||||
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # next dev -H 0.0.0.0
|
||||
npm test # vitest — priorité absolue : le moteur de réconciliation
|
||||
npm run typecheck
|
||||
npm run lint # eslint-config-next, épinglé sur la version exacte de next
|
||||
npm run knowledge:check # valide les 66 fiches YAML (Zod, IDs uniques, slugs, pages présentes)
|
||||
npm run db:generate # génère une migration Drizzle depuis src/db/schema.ts
|
||||
npm run db:migrate # joue les migrations en attente — appelé automatiquement au démarrage du conteneur
|
||||
npm run db:push # ⚠️ raccourci dev uniquement — ne jamais l'utiliser en production (audit E2)
|
||||
npm run reconcile:all # rejoue la réconciliation sur tous les foyers
|
||||
npm run vehicules:sync # référentiel RDW (~2 h)
|
||||
npm run distribution:queue # file de sourcing des codes moteur, les plus utiles d'abord
|
||||
npm run distribution:seed # amorçage du référentiel de distribution, idempotent
|
||||
npm run rappels:simuler -- <householdId> # ce qui partirait aujourd'hui pour un foyer, sans envoyer
|
||||
```
|
||||
|
||||
Le projet tourne en conteneur Docker (`docker-compose.yml`) : `fokan-db` (Postgres 16) et `fokan-app`, exposée sur `127.0.0.1:3100`. Les crons vivent dans le même processus que Next.js (`src/instrumentation.ts`, pg-boss) : veille des arrêtés à 3 h, réconciliation à 4 h, rappels à 8 h, référentiel véhicules le dimanche à 0h30 — heure de Paris.
|
||||
|
||||
## Roadmap
|
||||
|
||||
1. **Phase 0** — test de désirabilité : landing + quiz réel + hub figé + prix affiché. Mesure de l'étoile polaire (conversion quiz → abonné).
|
||||
2. **MVP** — la veille seule : rappels mail, partage conjoint, récap mensuel, ~30 pages SEO.
|
||||
3. **Phase 2** — pipeline de compréhension de documents (mail entrant) + bannette.
|
||||
4. **Phase 3** — scan photo, multiplicateurs (foyer des parents, bien locatif), affiliation.
|
||||
1. ✅ **Phase 0** — landing, quiz réel, hub à révélation graduée, prix affiché.
|
||||
2. **MVP — la veille seule** *(en cours)* : les rappels mail, la CI et les migrations sont livrés ; restent le sourcing des intervalles de distribution et **les sauvegardes** (seul risque que le cadrage qualifie d'inacceptable, § 24.2 — différé, pas oublié).
|
||||
3. **Phase 2** — pipeline de compréhension de documents (mail entrant) + bannette ; les contrats se renseignent par transfert de PDF.
|
||||
4. **Phase 3** — scan photo, multiplicateurs (foyer des parents, bien locatif), affiliation activée.
|
||||
|
||||
Prochains chantiers, dans l'ordre : ① Phase 0 (questions du quiz + landing + étude API plaque) · ② Base de connaissance v1 (~45 fiches) · ③ Squelette technique (monorepo, Drizzle, moteur de réconciliation + tests) · ④ Nom et identité.
|
||||
**Prochains chantiers, dans l'ordre** : ① les correctifs de l'audit (§ 8) · ② le sourcing des intervalles de courroie, seul travail qui fasse parler le pilier véhicules · ③ le calendrier scolaire, un adapter qui sert les piliers papiers et animaux · ④ nom et identité.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ services:
|
|||
container_name: fokan-db
|
||||
environment:
|
||||
POSTGRES_USER: fokan
|
||||
POSTGRES_PASSWORD: fokan
|
||||
# Injecté depuis .env, jamais en clair ici (audit S7). docker compose lit .env du
|
||||
# répertoire du projet pour la substitution ${...}, indépendamment de env_file: ci-dessous.
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: fokan
|
||||
volumes:
|
||||
- /srv/user-data/fokan:/var/lib/postgresql/data
|
||||
|
|
@ -14,7 +16,9 @@ services:
|
|||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ai-net
|
||||
# Réseau dédié uniquement : la base n'a plus besoin d'être joignable par les ~25 autres
|
||||
# conteneurs de ai-net (audit S7). Seul `app` la joint, sur fokan-net.
|
||||
- fokan-net
|
||||
|
||||
app:
|
||||
build: .
|
||||
|
|
@ -23,12 +27,25 @@ services:
|
|||
db:
|
||||
condition: service_healthy
|
||||
env_file: .env
|
||||
environment:
|
||||
# Sans lui, le serveur standalone Next.js ne bind que sur l'IP du conteneur — jamais sur
|
||||
# 127.0.0.1, ce qui fait échouer le healthcheck ci-dessous (constaté au déploiement du
|
||||
# 28/07/2026, audit E4). 0.0.0.0 couvre les deux.
|
||||
HOSTNAME: "0.0.0.0"
|
||||
ports:
|
||||
- "127.0.0.1:3100:3000"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ai-net
|
||||
- fokan-net # pour joindre fokan-db
|
||||
- ai-net # pour rester joignable par Caddy (reverse proxy), qui vit sur ai-net
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/api/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
networks:
|
||||
fokan-net:
|
||||
ai-net:
|
||||
external: true
|
||||
|
|
|
|||
343
docs/audit-2026-07-28.md
Normal file
343
docs/audit-2026-07-28.md
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
# Audit complet — 28 juillet 2026
|
||||
|
||||
Revue exhaustive du dépôt : code, base de connaissance, documents, exploitation.
|
||||
|
||||
> **Mise à jour du 28/07/2026 (même jour, seconde passe)** : la quasi-totalité des correctifs proposés ci-dessous ont été implémentés, vérifiés (`typecheck`, `vitest`, `knowledge:check`, `eslint`, `next build`) et **déployés** sur le conteneur `fokan-app` (rebuild + migration + redémarrage). Voir la [§ 0](#0-état-des-correctifs--28072026) pour le détail, item par item. Le corps du document ci-dessous est conservé tel quel : c'est le diagnostic d'origine, qui garde sa valeur de référence.
|
||||
|
||||
**Méthode** : lecture intégrale des 14 243 lignes de `src/`, des 66 fiches YAML, des scripts et des documents ; exécution réelle de `tsc --noEmit`, `vitest run` et `knowledge:check` ; recoupement systématique des attributs du DSL avec le code qui les pose ; recherche des exports sans consommateur ; revue de la surface publique HTTP.
|
||||
|
||||
**Ce qui est sain, et vaut d'être dit** :
|
||||
|
||||
| Vérification | Résultat |
|
||||
|---|---|
|
||||
| `tsc --noEmit` | ✅ aucune erreur |
|
||||
| `vitest run` | ✅ 288 tests, 20 fichiers, 0 échec |
|
||||
| `npm run knowledge:check` | ✅ 66 fiches valides (animaux 6, papiers 12, maison 36, véhicules 12) |
|
||||
| Fiches sans page SEO | ✅ aucune — la bicéphalie du § 11.2 tient |
|
||||
| Fiches sans contenu mail | ✅ aucune |
|
||||
| Secrets dans le dépôt ou l'historique git | ✅ aucun ; `.env` n'a jamais été suivi |
|
||||
| Fichiers orphelins (jamais importés) | ✅ aucun |
|
||||
| Invariants d'architecture § 19.3 | ✅ tenus — le moteur (`reconcile.ts`, `windows.ts`, `applicability.ts`, `sujets.ts`, `notifications.ts`, `fabrication.ts`) est pur, sans I/O, testé ; aucun code moteur ni aucune règle ZFE n'est gravé dans une fiche |
|
||||
|
||||
Le cœur du produit est en bon état. Les défauts ci-dessous sont concentrés sur **la surface HTTP publique**, **l'exploitation** (CI, migrations, sauvegardes) et **deux promesses non tenues** au foyer.
|
||||
|
||||
---
|
||||
|
||||
## 0. État des correctifs — 28/07/2026
|
||||
|
||||
Traités **dans l'ordre du § 8**, puis le reste. Trois précisions du porteur de projet ont infléchi l'implémentation par rapport à ce que ce document proposait initialement — elles sont notées.
|
||||
|
||||
| # | Sujet | Statut | Ce qui a été fait |
|
||||
|---|---|---|---|
|
||||
| F1 | Mail « garder le lien » | ✅ Corrigé | `src/emails/garder-lien.tsx` + envoi réel dans `POST /api/interet`, via les identifiants SMTP déjà présents en `.env` |
|
||||
| E3 | Sauvegardes | ⏸️ Différé | Non traité à la demande du porteur de projet (« on verra plus tard ») — reste le seul risque que le cadrage qualifie d'inacceptable (§ 24.2) |
|
||||
| E1 | CI | ✅ Corrigé | `.github/workflows/ci.yml` — `npm ci` → `typecheck` → `lint` → `vitest` → `knowledge:check` → `build` |
|
||||
| E2 | Migrations versionnées | ✅ Corrigé | `drizzle/0000_...sql` (baseline, marquée appliquée sans exécution — la base vivante existait déjà) + `drizzle/0001_...sql` (nouvelles colonnes) ; `npm run db:generate` / `db:migrate` / `db:baseline` ; jouées automatiquement au démarrage du conteneur (`Dockerfile`) |
|
||||
| S3 | Réclamation de foyer | ✅ Corrigé — **option b** | La réclamation ne dépend plus du rendu de `/foyer/[id]` : `src/lib/membership.ts` exige désormais `subscriptionStatus: "active"` en plus de « zéro membre », et le webhook Stripe (`checkout.session.completed`) inscrit directement le payeur quand une session existait déjà avant paiement |
|
||||
| S5 | Consentement de rétractation | ✅ Corrigé | Horodaté uniquement dans le webhook Stripe, avec `stripeCheckoutSessionId` comme preuve croisée (nouvelle colonne) ; `POST /api/checkout` ne fait plus l'écriture prématurée |
|
||||
| S1 | Limitation de débit | ✅ Corrigé | `src/lib/rate-limit.ts` (limiteur en mémoire, sans Redis) appliqué à `/api/quiz`, `/api/adresse`, `/api/vehicule/taxonomie`, `/api/interet`, `/api/event` |
|
||||
| S2 | Plafonds `/api/invitations` | ✅ Corrigé | Plafond par utilisateur/jour, plafond par adresse invitée (tous foyers confondus), refus si invitation pendante |
|
||||
| F3 | Sourcing des intervalles de courroie | ⏸️ Hors code | Confirmé : ce n'est pas un correctif de code (cf. corps du document) |
|
||||
| F2 / § 3 | Voie déclarative du quiz (P.2) | ✅ Corrigé | `motorisationsParModele` (nouvelle fonction, `vehicules-recherche.ts`) + mode `?marque=&modele=` sur `/api/vehicule/taxonomie` + bornage par année dans `codesEnLice` + nommage du moteur par puissance choisie + les 3 défauts mineurs du § 3.4 (reset `motorisations`, perte silencieuse du champ K, vignette par véhicule) |
|
||||
| — | Rejeu du quiz par un abonné | ✅ Ajouté (F8, précisé par le porteur de projet) | Le quiz reste la référence unique pour mettre à jour un foyer : `src/engine/quiz-rejeu.ts`, `GET/POST` étendus, garde-fou d'un rejeu par mois, entrée dans le hub (`/quiz?rejouer=<id>`), assets singleton (foyer, logement) mis à jour en place, assets multiples (véhicules, personnes, animaux, contrats) remplacés — voir la réserve dans le corps du § F8 |
|
||||
| §4 | Code mort | ✅ Corrigé | `Card`/`Badge`/`BadgeTone`/`PilierDot` (primitives.tsx), `Knowledge` (schema.ts), `LIBELLE_CRITAIR` (critair.ts) retirés ; `simulerRappelFoyer` gardé et exposé via `npm run rappels:simuler` |
|
||||
| S4 | Jeton `.ics` dédié | ✅ Corrigé | Colonne `icsToken`, route `GET /calendrier/[token]`, génération à la demande via `GET /api/foyer/[id]/ics` (authentifié, membre, abonné) ; l'ancienne route répond 404 |
|
||||
| S6 | En-têtes de sécurité | ✅ Corrigé | `headers()` dans `next.config.ts` (`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `HSTS`) ; les deux liens externes existants avaient déjà `rel="noopener noreferrer"` |
|
||||
| S7 | Mot de passe Postgres + réseau | ✅ Corrigé | Mot de passe généré (32 caractères), rotation appliquée à chaud sur la base vivante (`ALTER USER`), `POSTGRES_PASSWORD` déplacé dans `.env`, réseau `fokan-net` dédié créé (la base n'est plus sur `ai-net`, l'app y reste pour rester joignable par Caddy) |
|
||||
| F5 | Course sur les attributs d'asset | ✅ Corrigé — **verrou en mémoire, pas Postgres** | `src/lib/mutex.ts` : la course se joue entre deux appelants du même processus Next.js (POST /api/quiz et le worker pg-boss) — un verrou en mémoire suffit et évite de tenir une connexion Postgres ouverte pendant des appels réseau lents |
|
||||
| F6 | `rattraperCritairFoyer` incomplet | ✅ Corrigé | Appelé aussi depuis l'enrichissement hebdomadaire (`enrichissement-georisques.ts`), qui tourne pour tous les foyers |
|
||||
| F7 | Fragilités webhook Stripe | ✅ Corrigé | Lecture défensive de `items.data[0]` ; `past_due`/`trialing` gardent l'accès actif, seuls `canceled`/`unpaid` le coupent |
|
||||
| F8 | Copie trompeuse écran contrats | ✅ Corrigé | Texte aligné sur D-007 + le rejeu du quiz (voir ci-dessus) |
|
||||
| F9 | Funnel silencieux | ✅ Corrigé | Le `catch` de `/api/event` journalise désormais l'erreur |
|
||||
| E4 | Monitoring / healthcheck | ✅ Corrigé (partiellement) | `GET /api/health` + `healthcheck` Docker sur `fokan-app` (la base en avait déjà un) ; la lecture des heartbeats `events.name = cron_*` par une supervision externe reste hors périmètre |
|
||||
| E5 | CI/lint/Dockerfile | ✅ Corrigé | ESLint (`eslint.config.mjs`, `eslint-config-next@15.5.21` — épinglé sur la version exacte de Next, `npm run lint`) ; `Dockerfile` passé à `npm ci` (et un bug latent démasqué par ce changement — `.npmrc` n'était jamais copié dans l'image — corrigé au passage) ; le risque `dangerouslySetInnerHTML` sur `marked.parse()` n'a pas été traité (contenu interne, validé en CI, risque jugé nul aujourd'hui) |
|
||||
| §6 | Rangement des contrats dans les piliers | ➡️ Tranché | Le porteur de projet a choisi de garder les contrats dans le pilier Papiers — aucune fiche déplacée |
|
||||
|
||||
**Notes sur les trois précisions du porteur de projet :**
|
||||
- **F4** (`demenagement_recent` mort) : confirmé différé — noté dans `pilier-vehicules.md` et `pilier-animaux.md` comme un chantier futur, aucun code touché.
|
||||
- **F8** : au lieu de rouvrir la déclaration uniquement dans le récap annuel (proposition initiale de ce document), le porteur de projet a demandé une fonctionnalité plus large — le rejeu complet du quiz par un abonné, avec garde-fou mensuel. C'est ce qui a été implémenté ; voir la réserve ci-dessous sur le remplacement des assets multiples.
|
||||
- **S3** : l'option (b) a été retenue telle que proposée.
|
||||
|
||||
**Une réserve à connaître sur le rejeu du quiz (F8)** : les assets à instance unique (foyer, logement) sont mis à jour en place, donc leurs échéances gardent leur état (muet, responsable, historique). Les assets à instances multiples (véhicules, personnes, animaux, contrats) sont en revanche **remplacés entièrement** à chaque rejeu — il n'existe pas de correspondance fiable entre « l'ancienne deuxième voiture » et « la nouvelle deuxième voiture » d'une liste rejouée sans identifiant stable, et deviner aurait pu produire une correspondance fausse en silence. Ce choix est assumé et annoncé à l'écran avant validation, mais mérite d'être revu si l'usage réel montre que la perte d'état (mute, responsable) sur ces échéances-là gêne les foyers qui rejouent.
|
||||
|
||||
**Dette technique introduite, à surveiller** : le remplacement complet des assets multiples au rejeu (F8) est un compromis de rapidité d'implémentation, pas une contrainte définitive. Une évolution future pourrait apparier les véhicules par `reception_numero`, les contrats par `type`, etc., pour préserver leur état à la marge — non fait ici par prudence (une correspondance approximative aurait été pire qu'un remplacement franc, cf. le raisonnement de D-004 § 3 appliqué ailleurs dans le code).
|
||||
|
||||
---
|
||||
|
||||
## 1. Sécurité
|
||||
|
||||
### S1 — Aucune limitation de débit, sur aucune route · **élevé**
|
||||
|
||||
Les cinq routes publiques n'ont ni authentification, ni plafond, ni détection d'abus :
|
||||
|
||||
| Route | Ce qu'un appel coûte |
|
||||
|---|---|
|
||||
| `POST /api/quiz` | 1 foyer + n assets + réconciliation + **4 appels Géorisques + 1 CatNat + 1 VigiEau** + 1 job pg-boss, avec une attente serveur jusqu'à 10 s |
|
||||
| `GET /api/adresse` | 1 à 2 appels sortants (BAN, ADEME) |
|
||||
| `GET /api/vehicule/taxonomie` | requêtes `LIKE '%…%'` et agrégats sur un référentiel de 2,9 M de lignes |
|
||||
| `POST /api/interet` | insertion libre d'une adresse mail |
|
||||
| `POST /api/event` | insertion d'un JSON non borné, `householdId` arbitraire |
|
||||
|
||||
Le risque n'est pas le vol de données, c'est **la disponibilité et la réputation** : un script trivial fait de nous un client abusif de Géorisques, de la BAN et de l'ADEME — des API d'État sans quota documenté, dont le code reconnaît lui-même la fragilité (`ENTRE_FOYERS_MS = 1000` dans le passage hebdomadaire). Se faire fermer la porte de Géorisques coûterait tout le pilier maison.
|
||||
|
||||
**Correctif proposé** — un limiteur par IP, sans Redis (§ 22) : une table `rate_limits (cle, fenetre, compteur)` en Postgres ou un LRU en mémoire du process suffit à cette échelle. Plafonds distincts : `/api/quiz` très strict (quelques créations par IP et par heure), les routes de lecture plus larges. Et un plafond de taille de corps sur les deux routes d'écriture.
|
||||
|
||||
### S2 — `/api/invitations` permet d'envoyer un lien magique à n'importe qui, sans limite · **élevé**
|
||||
|
||||
Un membre authentifié peut déclencher `signInMagicLink` vers **n'importe quelle adresse**, autant de fois qu'il le veut. C'est un vecteur d'e-mail bombing depuis notre relais d'envoi — or le § 26 fait de la réputation d'envoi une infrastructure vitale : « un rappel en spam = la promesse rompue ». Un seul abus suffit à la détruire.
|
||||
|
||||
**Correctif proposé** — plafond par utilisateur et par jour, plafond par adresse invitée, et refus si une invitation non consommée et non expirée existe déjà pour ce couple (foyer, adresse).
|
||||
|
||||
### S3 — Un GET sur `/foyer/[id]` peut faire changer le foyer de propriétaire · **élevé**
|
||||
|
||||
`resolveMembership` (`src/lib/membership.ts:32`) est appelé au rendu de la page (`src/app/foyer/[id]/page.tsx:122`) et **écrit en base** : le premier visiteur authentifié d'un foyer sans membre en devient membre, définitivement.
|
||||
|
||||
Scénario réel, sans malveillance : quelqu'un fait le quiz, partage son lien (« regarde, 34 échéances »), le destinataire a un compte fokan et ouvre le lien — il devient propriétaire du foyer. L'auteur du quiz ne pourra plus jamais le réclamer, et rien ne le lui dira. Un préchargement de lien, un aperçu de messagerie ou un crawler porteur d'une session produisent le même effet.
|
||||
|
||||
**Correctif proposé** — la réclamation ne doit pas être un effet de bord du rendu. Deux options : (a) un geste explicite (`POST /api/foyer/[id]/reclamer`, bouton « c'est mon foyer »), (b) la réclamation reste automatique mais **uniquement** dans le webhook Stripe, où elle est déjà déclenchée (D-015) et où le paiement prouve l'intention. L'option (b) est la moins coûteuse et suffit au parcours réel.
|
||||
|
||||
### S4 — Le flux `.ics` et le hub n'ont pas la même porte · **moyen**
|
||||
|
||||
Depuis D-017, le hub payant exige `isPaid && isMember` (`page.tsx:130`). `GET /foyer/[id]/calendrier.ics` n'exige que `isPaid` : **l'UUID du foyer suffit à télécharger tout le calendrier** d'un foyer abonné — titres, dates, enjeux, adresse comprise dans les libellés d'assets.
|
||||
|
||||
Ce n'est pas un oubli sans raison : un client d'agenda ne sait pas s'authentifier, un flux webcal a besoin d'une URL-capacité. Mais alors la capacité doit être un jeton dédié et révocable, pas l'identifiant du foyer — qui circule dans chaque lien de partage, chaque mail, chaque historique de navigateur.
|
||||
|
||||
**Correctif proposé** — colonne `ics_token` sur `households` (aléatoire, régénérable), URL `/calendrier/<token>.ics`, et 404 sur l'ancienne forme.
|
||||
|
||||
### S5 — Le consentement de rétractation est horodaté au mauvais moment, par n'importe qui · **moyen**
|
||||
|
||||
`POST /api/checkout` n'est pas authentifiée et écrit `retractationWaivedAt` sur n'importe quel foyer dont on connaît l'identifiant, **avant** toute redirection vers Stripe. Deux conséquences :
|
||||
|
||||
1. un tiers peut horodater un consentement qui n'est pas le sien ;
|
||||
2. le consentement est enregistré même si le paiement est abandonné.
|
||||
|
||||
La preuve légale exigée au § 10.7 perd donc précisément ce qui en fait une preuve : elle n'établit plus que la personne qui a payé est celle qui a coché.
|
||||
|
||||
**Correctif proposé** — conserver le refus serveur si la case n'est pas cochée (c'est le bon garde-fou), mais **horodater dans le webhook `checkout.session.completed`**, en enregistrant l'identifiant de session Stripe comme preuve croisée. Le consentement devient alors indissociable du paiement.
|
||||
|
||||
### S6 — Aucun en-tête de sécurité · **moyen**
|
||||
|
||||
`next.config.ts` n'expose pas de `headers()`. Ni CSP, ni HSTS, ni `X-Content-Type-Options`, ni `X-Frame-Options`, ni `Referrer-Policy`. Si Caddy ne les pose pas en amont (à vérifier sur le serveur), deux conséquences concrètes : le hub est encadrable dans une iframe tierce, et **l'URL du foyer part en `Referer` vers chaque lien d'action sortant** — c'est-à-dire vers les futurs partenaires d'affiliation (§ 10.4). L'URL du foyer est une capacité : elle ne doit fuir nulle part.
|
||||
|
||||
**Correctif proposé** — un bloc `headers()` global, `Referrer-Policy: strict-origin-when-cross-origin` au minimum, et `rel="noreferrer"` sur tous les liens d'action des fiches.
|
||||
|
||||
### S7 — Postgres avec un mot de passe trivial, sur un réseau partagé · **moyen**
|
||||
|
||||
`docker-compose.yml` fixe `POSTGRES_USER: fokan` / `POSTGRES_PASSWORD: fokan` en clair, et rattache le service au réseau externe **`ai-net`**, partagé avec d'autres conteneurs de l'hôte. Le port n'est pas publié, ce qui est bien — mais tout conteneur de `ai-net` peut se connecter à la base avec un identifiant qui se devine du premier coup, et y lire l'intégralité des foyers.
|
||||
|
||||
**Correctif proposé** — mot de passe généré, injecté depuis `.env` (jamais en clair dans le compose), et un réseau `fokan-net` dédié ; `ai-net` n'est nécessaire que si un autre service doit joindre l'app, ce qui n'est pas le cas aujourd'hui.
|
||||
|
||||
---
|
||||
|
||||
## 2. Fonctionnel
|
||||
|
||||
### F1 — On promet au foyer un mail qui ne partira jamais · **élevé**
|
||||
|
||||
`garder-lien.tsx:65` affiche, après validation : *« C'est noté — le lien de ce foyer part sur {email}. Tu peux fermer cette page. »*
|
||||
|
||||
`POST /api/interet` insère une ligne dans `leads` et **n'envoie aucun mail**. `sendMail` n'est appelé que depuis `lib/auth.ts` (liens magiques) et `jobs/notifications.ts` (rappels). Le foyer ferme la page en confiance, et le lien n'arrive pas.
|
||||
|
||||
C'est le défaut le plus grave du lot, non par sa complexité mais par sa nature : c'est le seul geste de confiance qu'on demande au visiteur (D-005 : l'adresse se demande *après* le service rendu), et il n'est pas honoré. Le foyer perd son hub et n'en saura jamais la raison.
|
||||
|
||||
**Correctif proposé** — un React Email « voici le lien de ton foyer », envoyé depuis la route avec la dégradation gracieuse habituelle de `sendMail`. Tant qu'il n'existe pas, corriger la copie (« c'est noté »), sans promettre d'envoi.
|
||||
|
||||
### F2 — La chaîne véhicule décroche dès que le foyer n'a pas sa carte grise · **élevé**
|
||||
|
||||
C'est le point soulevé, et il est exact. Détail en [§ 3](#3-le-quiz-face-à-létat-réel-des-développements).
|
||||
|
||||
### F3 — Aucun foyer ne peut aujourd'hui recevoir une date de courroie · **élevé (produit, pas défaut)**
|
||||
|
||||
`courroieWindow` (`windows.ts:136`) refuse de dater quand `distribution_confiance_intervalle === "a_verifier"` — règle juste, née d'un vrai faux positif. Or, dans le référentiel amorcé, **un seul moteur porte un intervalle** (`K9K U8`), et sa confiance d'intervalle est justement `a_verifier`, parce que Renault publie une fourchette de marque et non une préconisation par moteur.
|
||||
|
||||
Conséquence : `couvertureDistribution().peutNotifier` vaut 0. La machine complète — ingestion, identification, unanimité, fenêtre, fiche de prise en charge, scheduler — fonctionne et ne produit, pour l'instant, **que des libellés informatifs**. C'est documenté en toutes lettres (`pilier-vehicules.md` § 7.2), et c'est le seul travail qui fasse basculer le pilier du registre « information » à celui de « rappel ».
|
||||
|
||||
**Ce n'est pas un correctif de code, c'est du sourcing** : quelques intervalles officiels bien choisis (§ 7.4 du document pilier) suffisent.
|
||||
|
||||
### F4 — La fiche `carte-grise-changement-adresse` ne s'applique à personne · **moyen**
|
||||
|
||||
`demenagement_recent` n'est posé nulle part dans l'application — vérifié par recoupement automatique de tous les attributs du DSL contre le code qui les écrit. C'est le **seul** attribut mort sur 29. Déjà relevé (`pilier-maison.md` § 10.6, `pilier-vehicules.md` § 8.4) et toujours vrai.
|
||||
|
||||
La page SEO existe et se trouve ; l'échéance, elle, n'existe pour aucun foyer, alors que le délai est d'un mois et la sanction de 135 €.
|
||||
|
||||
**Correctif proposé** — ne pas le demander au quiz (ce serait une question mémoire, § 4.2). L'inférer : le jour où le hub permet de corriger l'adresse du logement, ce changement pose `demenagement_recent: true` sur les véhicules du foyer, avec une fenêtre d'un mois. En attendant, assumer et documenter que cette fiche est « SEO seule ».
|
||||
|
||||
### F5 — Course sur les attributs d'asset à la fin du quiz · **moyen**
|
||||
|
||||
`POST /api/quiz:118` lance `Promise.race([enrichirRisquesFoyer(id), timeout 10 s])`. Quand le plafond tombe, l'enrichissement en cours **continue de tourner**, et la route poste **en plus** un job pg-boss qui relance `enrichirRisquesFoyer` sur le même foyer. Deux exécutions concurrentes font alors le lire-modifier-écrire décrit — et redouté — dans les commentaires du fichier : la plus lente écrase les attributs de l'autre.
|
||||
|
||||
L'effet est réparé au passage hebdomadaire, donc invisible ; mais il se produit précisément quand un tiers est lent, c'est-à-dire au pire moment.
|
||||
|
||||
**Correctif proposé** — un verrou consultatif Postgres autour de l'enrichissement d'un foyer (`pg_advisory_xact_lock(hashtext(household_id))`), qui rend la question sans objet quel que soit le nombre d'appelants.
|
||||
|
||||
### F6 — `rattraperCritairFoyer` ne tourne qu'au quiz · **faible**
|
||||
|
||||
Il n'est appelé que depuis `POST /api/quiz`. Un véhicule non classé au quiz (année inconnue, modèle absent du catalogue) et dont le foyer n'a pas donné d'adresse ne sera **jamais** reclassé, même quand le référentiel RDW s'enrichit — `enrichirZfeFoyer`, qui recalcule aussi Crit'Air, exige une adresse géolocalisée.
|
||||
|
||||
**Correctif proposé** — l'appeler également depuis `enrichirRisquesFoyer`, qui tourne pour tous les foyers chaque semaine, adresse ou non.
|
||||
|
||||
### F7 — Webhook Stripe : deux fragilités · **faible**
|
||||
|
||||
1. `subscription.items.data[0].current_period_end` (`webhook/route.ts:33` et `:80`) lève si `items` est vide. L'exception rend un 500, et Stripe rejoue l'événement en boucle pendant des jours.
|
||||
2. `customer.subscription.updated` mappe tout ce qui n'est ni `active` ni `canceled` vers `"expired"` — donc `past_due` (impayé temporaire, que Stripe retente pendant plusieurs jours) et `trialing` **coupent la veille immédiatement**. Un foyer qui paie voit son calendrier se reverrouiller sur un incident de carte.
|
||||
|
||||
**Correctif proposé** — lecture défensive de `items.data[0]`, et conserver `active` sur `past_due` et `trialing` ; ne basculer qu'à `unpaid` et `canceled`.
|
||||
|
||||
### F8 — L'écran contrats promet un rattrapage qui n'existe plus · **faible**
|
||||
|
||||
Le texte d'aide de l'écran 7 dit : *« Seulement ceux que tu as déjà. Les autres, **on te les proposera au bon moment** — rien n'est obligatoire ici. »* (`quiz-client.tsx:1149`).
|
||||
|
||||
C'est faux depuis **D-007**, qui a retiré l'opt-in post-quiz : rien ne sera jamais proposé. Le foyer qui passe son tour en confiance perd la déclaration pour de bon.
|
||||
|
||||
**Correctif proposé** — corriger la copie, et regarder si le récap annuel n'est pas le bon endroit pour rouvrir la déclaration sans redite (cf. [pilier-contrats.md § 3.1](pilier-contrats.md)). La seconde partie révise D-007 et demande une décision, pas seulement du code.
|
||||
|
||||
### F9 — Le funnel peut être silencieusement vide · **faible**
|
||||
|
||||
`POST /api/event` avale toute exception dans un `catch {}` muet. L'intention est juste (« le funnel ne doit jamais casser le parcours ») mais la conséquence l'est moins : si les insertions échouent, **l'étoile polaire du § 13.1 mesure zéro sans que personne ne le sache**.
|
||||
|
||||
**Correctif proposé** — garder le `catch`, y journaliser l'erreur.
|
||||
|
||||
---
|
||||
|
||||
## 3. Le quiz face à l'état réel des développements
|
||||
|
||||
La question posée était : *la recherche par marque/modèle ne relie pas au code moteur — ne devrait-on pas demander la puissance P.2 pour faire le lien ?* La réponse est oui, et le trou est plus précisément un trou de **câblage**, pas de conception.
|
||||
|
||||
### 3.1 Ce qui se passe aujourd'hui, voie par voie
|
||||
|
||||
| Voie d'entrée | Ce que le foyer donne | Ce que le moteur en tire |
|
||||
|---|---|---|
|
||||
| **Champ K** (+ D.2 si connus) | le numéro de réception | réception exacte → énergie → **P.2 proposé en liste fermée** → 1 à 3 codes moteur → distribution affirmée si unanimité |
|
||||
| **Marque / modèle** | « Peugeot 308 » + énergie + année tapée | modèle canonique → **tous** les moteurs jamais homologués sur ce modèle → unanimité impossible → **silence** |
|
||||
| **Rien** | — | rien, et c'est conforme (§ 4.3) |
|
||||
|
||||
Le champ P.2 n'est affiché que si `decl.pick.reception` existe (`quiz-client.tsx:1045`), et `puissanceRetenue()` rend `undefined` sans réception (`quiz-client.tsx:276`). **La voie déclarative ne peut donc structurellement jamais fournir P.2.**
|
||||
|
||||
Or `codesEnLice` (`distribution-moteurs.ts:219`) sait parfaitement s'en servir sans réception : le filtre `puissance_kw_max::numeric = puissance` s'applique après le filtre marque/modèle exactement comme après le filtre réception. **La donnée est disponible, la requête sait la lire, seule l'interface ne la demande pas.**
|
||||
|
||||
### 3.2 Pourquoi ça compte plus que ça n'en a l'air
|
||||
|
||||
Le champ K n'est connu de personne de tête : il faut sortir la carte grise. La voie marque/modèle est donc, selon toute vraisemblance, **la voie majoritaire** — et c'est celle qui ne produit rien sur la seule échéance véhicule qu'aucun tableau de bord n'affiche, celle qui justifie D-017 à elle seule.
|
||||
|
||||
Aggravant, sur la même voie : `parLibelle` rend toujours `annee: null`. L'année tapée par le foyer sert bien à dater la courroie (`repereVehicule`, `windows.ts:78`) mais **ne restreint jamais les moteurs en lice** — alors que `vehicules_reference.dateReception` le permettrait. Sur un modèle vendu quinze ans, c'est ce qui sépare « douze moteurs candidats » de « trois ».
|
||||
|
||||
### 3.3 Correctif proposé — trois pas, du plus rentable au plus coûteux
|
||||
|
||||
1. **Étendre `/api/vehicule/taxonomie`** d'un mode `?marque=&modele=&energie=` qui renvoie la même carte `motorisations` que la voie champ K, agrégée sur toutes les réceptions du couple. Le composant P.2 existe déjà : la condition `decl.pick.reception && …` devient `motorisationChoisie && …`.
|
||||
2. **Filtrer par l'année déclarée** dans `codesEnLice` (borne sur `dateReception`, avec une marge de ± 2 ans pour absorber l'écart entre homologation d'un type et mise en circulation d'un exemplaire).
|
||||
3. **Nommer la motorisation plutôt que ses kW** quand le référentiel sait le faire (« 1.5 dCi — 4 cylindres, 1 461 cm³ ») : plus lisible qu'un nombre, et toujours une liste fermée (§ 4.1).
|
||||
|
||||
**À mesurer avant d'implémenter, pas après** : sur la voie déclarative, une même valeur de P.2 peut désigner deux moteurs différents sur deux réceptions différentes du même modèle. La règle d'unanimité d'`accorderDistribution` protège de l'erreur — elle produira du silence, jamais du faux — mais elle peut aussi rendre l'ajout inutile. La mesure à refaire est exactement celle de D-017 (les 15 modèles dominants), appliquée cette fois au couple marque/modèle/énergie/P.2/année. **Si le gain n'est pas là, la question ne se pose pas** : une question de plus au quiz qui ne débloque rien est une régression au regard de la règle d'or.
|
||||
|
||||
### 3.4 Trois défauts mineurs relevés sur le même écran
|
||||
|
||||
- **`pickVehicule` ne remet pas `motorisations` à zéro.** Sans conséquence aujourd'hui (l'affichage et `puissanceRetenue()` sont tous deux gardés par `decl.pick.reception`), mais le point 1 ci-dessus lève cette garde : ça deviendrait un bug le jour même, en proposant les motorisations de la voiture précédente.
|
||||
- **Le champ K trouvé peut se perdre en silence.** Quand l'identification réussit, l'écran affiche aussi la recherche marque/modèle, pré-remplie. Y taper un caractère appelle `searchVehicule`, qui remet `pick` à `null` — l'identification exacte est perdue sans que rien ne le signale, et le véhicule retombe en `provenance: "declaratif"`.
|
||||
- **La vignette Crit'Air est une réponse globale.** Le chip écrit `vignette_critair` sur **tous** les véhicules du foyer (`quiz-client.tsx:906`). Un foyer à deux voitures dont une seule a sa vignette ne peut pas le dire, et recevra la fiche pour les deux ou pour aucune.
|
||||
|
||||
---
|
||||
|
||||
## 4. Code mort
|
||||
|
||||
Aucun fichier orphelin. Quatre symboles réellement morts, et une série d'exports inutilement publics.
|
||||
|
||||
**Morts — à retirer :**
|
||||
|
||||
| Fichier | Symbole | Note |
|
||||
|---|---|---|
|
||||
| `src/components/ui/primitives.tsx` | `Card`, `Badge`, `BadgeTone`, `PilierDot` | jamais importés ; le reste du fichier (`Band`, `Container`, `Eyebrow`, `SectionTitle`, `Lede`, `IconTile`) sert |
|
||||
| `src/knowledge/schema.ts` | `Knowledge` (const + type) | vestige ; le loader travaille sur `DeadlineTemplate[]` |
|
||||
| `src/knowledge/critair.ts` | `LIBELLE_CRITAIR` | aucun consommateur, y compris dans les tests |
|
||||
| `src/jobs/notifications.ts` | `simulerRappelFoyer` | outil de mise au point sans appelant ni script npm — soit lui donner un `npm run rappels:simuler`, soit le retirer |
|
||||
|
||||
**Exports publics sans consommateur externe** (utilisés seulement dans leur propre fichier) : `isApplicable`, `buildDesiredState`, `envoyerRappelFoyer`, `estEnergie`, `RAYON_ALERTE_KM`, `DELAI_DECLARATION_JOURS`, `CHAUFFAGE_CONNU`. Sans gravité — mais chacun est une promesse de stabilité qu'on ne s'est pas engagé à tenir.
|
||||
|
||||
**Faux positifs écartés** : `register` (contrat d'instrumentation Next.js), `contentType` (métadonnée d'image Next.js), `account` / `verification` (tables better-auth, consommées par `import * as schema`), et l'ensemble des types exportés consommés par inférence.
|
||||
|
||||
---
|
||||
|
||||
## 5. Exploitation — les écarts au cadrage
|
||||
|
||||
Ce sont les manques les plus lourds, parce qu'ils ne se voient pas tant que rien ne casse.
|
||||
|
||||
### E1 — Pas de CI · **élevé**
|
||||
|
||||
`.github/` n'existe pas. Le § 27 fait des tests du moteur de réconciliation et de la validation Zod des fiches **la priorité absolue** de la CI ; aujourd'hui rien ne les exécute automatiquement. La garantie « une fiche invalide casse le build, jamais la prod » repose entièrement sur la discipline de lancer les commandes à la main.
|
||||
|
||||
**Correctif proposé** — un workflow unique : `npm ci` → `typecheck` → `vitest run` → `knowledge:check`, sur push et sur PR.
|
||||
|
||||
### E2 — Pas de migrations · **élevé**
|
||||
|
||||
Seul `drizzle-kit push` est câblé (`npm run db:push`), et aucun dossier `drizzle/` n'est versionné. Le § 27 prévoit « migrations Drizzle jouées au déploiement ». `push` compare le schéma au vivant et applique le diff : sur une base de production, **il peut supprimer une colonne sans avertissement**. Le schéma actuel a déjà connu des retraits de colonnes (`carrosserie`, `cylindree`) — la mécanique est donc réellement sollicitée.
|
||||
|
||||
**Correctif proposé** — `drizzle-kit generate`, dossier `drizzle/` versionné, `migrate` au démarrage du conteneur.
|
||||
|
||||
### E3 — Pas de sauvegarde · **élevé**
|
||||
|
||||
Le § 24.2 appelle la sauvegarde chiffrée quotidienne vers un stockage objet français « **LA condition** » de l'hébergement local, et pose la restauration testée comme non négociable. Rien dans le dépôt ne la met en œuvre : `docker-compose.yml` monte `/srv/user-data/fokan` et s'arrête là.
|
||||
|
||||
C'est le seul risque que le cadrage qualifie d'inacceptable, et c'est le seul des cinq mitigations du § 24.2 qui ne soit pas commencé.
|
||||
|
||||
**Correctif proposé** — un service `pg_dump` quotidien, chiffré (`age` ou `gpg`), poussé vers Scaleway Object Storage, plus une procédure de restauration écrite et exécutée une fois pour de vrai.
|
||||
|
||||
### E4 — Monitoring à moitié fait · **moyen**
|
||||
|
||||
Les heartbeats existent (`events.name = cron_*`, écrits par les quatre files) — c'est la moitié difficile. Personne ne les lit : ni supervision externe, ni alerte, ni `healthcheck` sur le conteneur `app` (la base en a un, l'app non). Le scénario nommé au § 24.2 point 3 — « scheduler planté depuis 5 jours » — reste entièrement ouvert.
|
||||
|
||||
### E5 — Divers · **faible**
|
||||
|
||||
- Pas de lint : ni script, ni configuration ESLint, alors que le § 27 le cite.
|
||||
- `Dockerfile` : `npm install` au lieu de `npm ci` — le build ne reproduit pas le lockfile.
|
||||
- `marked.parse()` est rendu via `dangerouslySetInnerHTML` sans assainissement. Le contenu est le nôtre et validé en CI, donc ce n'est pas une faille aujourd'hui ; ça le deviendrait le jour où une page de connaissance serait rédigée par quelqu'un d'autre.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cohérence de la base de connaissance
|
||||
|
||||
- **29 attributs** référencés par le DSL, **28 réellement posés** par le code. Le seul orphelin est `demenagement_recent` (cf. F4).
|
||||
- **8 stratégies nommées** déclarées au schéma, **8 utilisées**, aucune orpheline dans un sens ni dans l'autre.
|
||||
- **8 types de contrats** dans `CONTRACT_TYPES`, **8 fiches `*-echeance`** correspondantes. Aucun contrat déclarable sans fiche, aucune fiche sans contrat déclarable.
|
||||
- **Une dérive de rangement à arbitrer** : les cinq fiches de `contrats.yaml` (emprunteur, mutuelle, box, mobile, salle de sport) sont classées `pilier: papiers`. Le § 5.6 dit qu'elles sont « rattachées au foyer » — mais il n'existe pas de pilier « foyer », les piliers sont quatre et gravés. Le choix est donc pragmatique et défendable ; il fait simplement du pilier Papiers un fourre-tout dont l'intitulé ne prépare pas le foyer à y trouver son forfait mobile. À trancher : assumer et le dire dans la copie du hub, ou déplacer ce qui peut l'être (le forfait mobile et la box ne sont pas des papiers).
|
||||
|
||||
---
|
||||
|
||||
## 7. Ce que cet audit ne couvre pas
|
||||
|
||||
- Le contenu **factuel** des 66 fiches (règles, fréquences, montants) n'a pas été revérifié source par source : chacune porte son `source:` et a été sourcée à sa rédaction. Une revue de fraîcheur annuelle reste à planifier — c'est le risque n° 7 du cadrage (coût éditorial permanent).
|
||||
- La **délivrabilité réelle** (SPF/DKIM/DMARC, réputation) : le domaine fokan n'existe pas encore, le montage est décidé (D-014) mais non exécuté.
|
||||
- La **configuration Caddy** de l'hôte, hors dépôt — ce qui laisse S6 partiellement indéterminé.
|
||||
|
||||
---
|
||||
|
||||
## 8. Ordre de traitement proposé
|
||||
|
||||
| # | Sujet | Pourquoi d'abord |
|
||||
|---|---|---|
|
||||
| 1 | **F1** — le mail de « garder le lien » | Une promesse non tenue à un foyer, sur le seul geste de confiance qu'on lui demande |
|
||||
| 2 | **E3** — sauvegardes | Le seul risque que le cadrage qualifie d'inacceptable |
|
||||
| 3 | **E1 + E2** — CI et migrations | Tout le reste devient plus sûr à changer |
|
||||
| 4 | **S3 + S5** — réclamation de foyer, consentement | Deux effets de bord au mauvais endroit, corrigés ensemble dans le webhook |
|
||||
| 5 | **S1 + S2** — limitation de débit | Protège l'accès aux API d'État et la réputation d'envoi |
|
||||
| 6 | **F3** — sourcing des intervalles de courroie | Ce qui fait exister la promesse du pilier véhicules |
|
||||
| 7 | **F2 / § 3** — la voie déclarative du quiz | À mesurer d'abord, implémenter ensuite |
|
||||
| 8 | **S4, S6, S7, F5–F9, § 4** | Le reste, par lots |
|
||||
|
||||
---
|
||||
|
||||
## 9. Documents produits ou mis à jour avec cet audit
|
||||
|
||||
**Créés** — les trois piliers qui n'avaient pas de document de référence, alors que maison et véhicules en avaient un :
|
||||
|
||||
- [pilier-papiers.md](pilier-papiers.md) — inclut le constat que **cinq des sept fiches du pilier ont une fenêtre libre, donc ne déclenchent jamais de mail**, et le seul 5e pilier sérieusement candidat (Enfance & scolarité).
|
||||
- [pilier-animaux.md](pilier-animaux.md) — le seul pilier sans aucune dépendance externe ; enrichissements identifiés (I-CAD, chiens catégorisés, voyage/rage).
|
||||
- [pilier-contrats.md](pilier-contrats.md) — **explicitement pas un pilier** (§ 5.6) ; socle juridique des fenêtres de résiliation, trou ouvert par D-007, état de l'affiliation.
|
||||
|
||||
**Un enrichissement ressort deux fois** : le **calendrier scolaire** (data.education.gouv.fr, source vérifiée, non testée) sert le passeport enfant, la garde des animaux, le voyage avec un animal et toute la saisonnalité « avant les vacances ». Un adapter, deux piliers.
|
||||
|
||||
**Mis à jour** :
|
||||
|
||||
- [phase-0/quiz.md](phase-0/quiz.md) — réécrit. Il décrivait encore la saisie de plaque, l'API SIV, le prix mensuel et l'ics gratuit ; l'ordre même des écrans était faux (animaux et contrats inversés).
|
||||
- [phase-0/landing.md](phase-0/landing.md) et [phase-0/etude-api-plaque.md](phase-0/etude-api-plaque.md) — avertissements en tête, contenu conservé pour la trace du raisonnement.
|
||||
- [pilier-vehicules.md](pilier-vehicules.md) — § 7.1 (le référentiel ne se limite plus à la famille EB), § 7.2 (`peutNotifier = 0`, dit sans détour), § 8 (quatre points ouverts ajoutés).
|
||||
- [../README.md](../README.md) — prix, périmètre gratuit, état réel par brique, index des documents.
|
||||
|
||||
**Non modifiés volontairement** : le cadrage et le journal des décisions. Plusieurs constats de cet audit appellent des **décisions** (rouvrir la déclaration des contrats, arbitrer le rangement dans les piliers, un éventuel 5e pilier, `echeance_mois` en `connue` ou `estimee`) — elles se prennent, elles ne se constatent pas.
|
||||
|
|
@ -2,6 +2,42 @@
|
|||
|
||||
Le [cadrage](cadrage-projet-fokan.md) reste le document de référence ; ce journal trace les décisions qui le révisent ou le précisent.
|
||||
|
||||
## D-018 — 28 juillet 2026 · Le quiz devient rejouable ; la réclamation d'un foyer ne dépend plus que du paiement
|
||||
|
||||
**Contexte** : un [audit complet](audit-2026-07-28.md) du dépôt (code, base de connaissance, exploitation) a relevé neuf défauts de sécurité, neuf défauts fonctionnels et cinq écarts d'exploitation. La quasi-totalité a été corrigée le jour même ; ce qui suit ne retient que ce qui **révise ou précise le cadrage** — le détail technique complet reste dans le document d'audit, qui n'est pas dupliqué ici.
|
||||
|
||||
### 1. Le quiz devient la référence unique pour mettre à jour un foyer
|
||||
|
||||
Jusqu'ici, un foyer se déclarait une fois pour toutes : rien ne permettait de corriger un véhicule vendu, un animal arrivé, un contrat oublié, sans repasser par un nouveau quiz — donc un nouveau foyer, une nouvelle veille, un doublon. **Un abonné peut désormais rejouer son quiz** (`/quiz?rejouer=<id>`), réponses précédentes préremplies et modifiables, avec un **garde-fou d'un rejeu par mois** pour qu'un rejeu répété ne devienne pas un vecteur d'abus.
|
||||
|
||||
Ce n'est pas neutre pour l'invariant n° 3 (état utilisateur inviolable) : un compromis a dû être tranché. Les assets à **instance unique** (foyer, logement) sont mis à jour **en place**, même `id` — leurs échéances, qui couvrent la quasi-totalité du pilier maison, gardent leur état (muet, responsable, historique). Les assets à **instances multiples** (véhicules, personnes, animaux, contrats) sont **remplacés entièrement** : il n'existe aucune correspondance fiable entre « l'ancienne deuxième voiture » et « la nouvelle deuxième voiture » d'une liste rejouée sans identifiant stable, et deviner aurait produit une correspondance fausse en silence — le même raisonnement qui gouverne déjà `accorderDistribution` (D-017 § 5 : « aucune correspondance approchée »). Un remplacement franc, annoncé à l'écran avant validation, a été préféré à une correspondance devinée. **Point à réexaminer si l'usage montre que la perte d'état sur ces échéances-là gêne les foyers qui rejouent** — une correspondance par clé naturelle (`reception_numero` pour un véhicule, `type` pour un contrat) resterait possible plus tard, non faite ici par prudence.
|
||||
|
||||
Comble au passage le trou ouvert par D-007 (l'opt-in post-quiz des contrats retiré « en connaissance de cause ») : un contrat devenu pertinent après le premier quiz (nouveau véhicule, nouvel animal) se déclare de nouveau via un rejeu, sans réintroduire le rattrapage post-quiz que D-007 avait explicitement fermé.
|
||||
|
||||
### 2. La réclamation d'un foyer ne se produit plus qu'au paiement
|
||||
|
||||
Un défaut de sécurité réel a été corrigé : `resolveMembership` faisait du premier visiteur authentifié d'un foyer **sans membre** son propriétaire, y compris pour un foyer **jamais payé** — un lien de quiz partagé avant paiement suffisait à en perdre la propriété, définitivement, sans que l'auteur ne le sache. La condition exige désormais aussi `subscriptionStatus: "active"`. La réclamation elle-même se fait dans le webhook Stripe (`checkout.session.completed`) quand une session existait déjà avant le paiement — jamais plus comme effet de bord d'un simple rendu de page.
|
||||
|
||||
Le consentement de renonciation au droit de rétractation (§ 10.7) est, pour la même raison, désormais horodaté **uniquement** dans ce même webhook, avec l'identifiant de la session Stripe comme preuve croisée — jamais avant, jamais par une requête non authentifiée.
|
||||
|
||||
### 3. Le flux .ics devient une capacité, pas un UUID
|
||||
|
||||
Le flux `.ics` vivant (§ 10.2, avantage payant) répondait à quiconque connaissait l'UUID du foyer — un identifiant qui circule dans chaque lien de partage, chaque mail. Il est désormais servi par un jeton dédié et révocable (`GET /calendrier/[token]`, généré à la demande par un membre abonné authentifié), l'ancienne route répond 404.
|
||||
|
||||
### 4. Le rangement des contrats dans les piliers est tranché
|
||||
|
||||
Le § 5.6 laissait ouvert le rattachement des cinq contrats sans pilier naturel (emprunteur, mutuelle, box, mobile, salle de sport), rangés par défaut dans Papiers. **Décision : ils y restent.** Aucune fiche déplacée, aucun changement de copie du hub pour l'instant.
|
||||
|
||||
### 5. Les migrations remplacent le `push` manuel
|
||||
|
||||
Le § 27 prévoyait des « migrations Drizzle jouées au déploiement » ; seul `drizzle-kit push` (qui compare le schéma au vivant et peut supprimer une colonne sans avertissement) était câblé. La base vivante — créée par `push`, jamais par une migration — a été **baselinée** (migration `0000` marquée appliquée sans être rejouée, son SQL décrivant un schéma qui existait déjà), puis une vraie migration `0001` (les colonnes ajoutées ci-dessus) a été générée et jouée. `npm run db:migrate` tourne désormais automatiquement au démarrage du conteneur ; `db:push` reste un raccourci de développement, plus une pratique de déploiement.
|
||||
|
||||
### Ce qui reste ouvert, sciemment
|
||||
|
||||
- **Les sauvegardes** (§ 24.2, seul risque que le cadrage qualifie d'inacceptable) — différées à la demande du porteur de projet, pas oubliées.
|
||||
- **`demenagement_recent`** — le seul attribut mort du DSL (relevé aussi en D-004/pilier véhicules) — reste sans code qui le pose ; débloqué le jour où l'adresse du logement devient modifiable dans le hub.
|
||||
- **Le sourcing des intervalles de courroie** (D-017 § 9) — confirmé comme un travail éditorial, pas un correctif de code.
|
||||
|
||||
## D-017 — 26 juillet 2026 · L'entretien véhicule se réduit à la distribution, et change de clé : le code moteur (annule le référentiel d'entretien de D-004)
|
||||
|
||||
**Contexte** : le point laissé en suspens depuis D-004 était « où trouver les intervalles d'entretien constructeur ». La table `vehicules_entretien` existait, validée et testée, avec un importateur — et **zéro ligne, zéro appelant**. L'audit devait trouver la source manquante. Il a d'abord trouvé autre chose.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
# Phase 0 — Étude API plaque & sources de détection
|
||||
|
||||
> **Décision [D-002](../decisions.md) (22/07/2026) : API plaque payante self-service (~5 c/req), 1 plaque offerte par quiz, cache + limite IP.** Vérifié : aucune API gratuite ne peut exister — la réutilisation du SIV exige une [licence ministérielle avec redevance](https://mobile.interieur.gouv.fr/Repertoire-des-informations-publiques/La-reutilisation-des-donnees-du-systeme-d-immatriculation-des-vehicules), et HistoVec (gratuit) exige nom + numéro de formule de la carte grise. La date CT reste indisponible en self-service → fenêtre calculée depuis la 1re immatriculation. Contacts AAA Data/Autorigin (CT réelle) suspendus, réactivables.
|
||||
> ## ⛔ Étude close — D-002 a été **annulée** par [D-004](../decisions.md) le 25/07/2026
|
||||
>
|
||||
> **Aucune API plaque n'est utilisée, et aucune ne le sera.** Le champ K de la carte grise (numéro de réception communautaire), recopié par le foyer, identifie le véhicule dans le référentiel [RDW Open Data](https://opendata.rdw.nl) — 2,9 M de configurations, domaine public, **zéro coût variable, zéro licence, zéro contact commercial**. Depuis [D-017](../decisions.md), la même source livre en plus le **code moteur**, c'est-à-dire la distribution.
|
||||
>
|
||||
> Ce que cela change par rapport à l'arbitrage du § 10.6 du cadrage : la question « la plaque doit-elle être gratuite ou payante ? » **n'a plus d'objet**. Il n'y a plus de coût variable à arbitrer, donc plus de CAC caché, et l'identification du véhicule est offerte à tous.
|
||||
>
|
||||
> **Ce document est conservé pour la trace du raisonnement** : il établit qu'aucune API plaque gratuite ne peut exister (la réutilisation du SIV exige une licence ministérielle avec redevance) et que la date réelle du contrôle technique n'est pas disponible en self-service. Ces deux constats restent vrais, et ce sont eux qui ont conduit à D-004. Voir [pilier-vehicules.md](../pilier-vehicules.md) pour l'état réel du pilier.
|
||||
>
|
||||
> Décision d'origine, conservée telle quelle ci-dessous :
|
||||
|
||||
> ~~**Décision [D-002](../decisions.md) (22/07/2026) : API plaque payante self-service (~5 c/req), 1 plaque offerte par quiz, cache + limite IP.**~~ Vérifié : aucune API gratuite ne peut exister — la réutilisation du SIV exige une [licence ministérielle avec redevance](https://mobile.interieur.gouv.fr/Repertoire-des-informations-publiques/La-reutilisation-des-donnees-du-systeme-d-immatriculation-des-vehicules), et HistoVec (gratuit) exige nom + numéro de formule de la carte grise. La date CT reste indisponible en self-service → fenêtre calculée depuis la 1re immatriculation. Contacts AAA Data/Autorigin (CT réelle) suspendus, réactivables.
|
||||
|
||||
*État au 22 juillet 2026 — recherche préliminaire web ; les points marqués 📞 nécessitent un contact commercial pour être tranchés.*
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,23 @@
|
|||
|
||||
*Objectif unique : mesurer « les gens paieront-ils ? » pour un coût quasi nul. Tout ce qui ne sert pas cette mesure est hors périmètre.*
|
||||
|
||||
> ## ⚠️ Document historique — dépassé sur quatre points, conservé pour la trace du raisonnement
|
||||
>
|
||||
> Relu à l'occasion de l'[audit du 28 juillet 2026](../audit-2026-07-28.md). Ce qu'il décrit **n'est plus ce qui est en production** :
|
||||
>
|
||||
> | Ce document dit | Ce qui est vrai aujourd'hui | Depuis |
|
||||
> |---|---|---|
|
||||
> | « 4,90 €/mois », variante à 3,90 € | **19,99 €/an**, palier unique, case de renonciation obligatoire au paiement | réécriture du § 10 (D-003) |
|
||||
> | Export .ics gratuit, « objet généreux et viral » | **Retiré du gratuit.** Le flux ics vivant est un avantage payant | § 10.2 |
|
||||
> | Hub figé où « tout est visible », deux boutons persistants | **Révélation graduée** : total, timeline, 2-3 échéances en clair, le reste réduit à un compte par pilier | D-005 |
|
||||
> | Pas d'encaissement en Phase 0 ; dépôt d'email comme proxy d'intention | **Stripe Checkout est branché**, en clés de test. Et plus aucune adresse mail n'est demandée avant contrepartie | D-005, D-015 |
|
||||
> | Adapter plaque self-service, « 1 plaque/quiz, cache, limite IP » | **Aucune API payante.** Le champ K de la carte grise et le référentiel RDW l'ont remplacée | D-004, D-017 |
|
||||
> | « Pas encore : comptes, paiement, moteur de réconciliation complet, mails récurrents » | **Tout cela existe** : better-auth, Stripe, moteur de réconciliation testé, scheduler de notifications quotidien | D-013, D-015 |
|
||||
>
|
||||
> Ce qui reste juste et n'a pas bougé : la structure de la landing (§ 1), l'anti-promesse, le choix d'une analytique sobre auto-hébergée, l'amorçage SEO, et **les quatre questions du § 5** — elles restent les bonnes.
|
||||
>
|
||||
> **Non fait à ce jour** : l'analytique sobre (§ 4) n'existe pas ; seule la table `events` mesure le funnel.
|
||||
|
||||
## 1. La landing
|
||||
|
||||
**Structure (une page, sobre, ton majordome) :**
|
||||
|
|
|
|||
|
|
@ -1,16 +1,28 @@
|
|||
# Phase 0 — Les questions exactes du quiz
|
||||
# Le quiz — les questions exactes, telles qu'elles sont
|
||||
|
||||
*Budget : ≤ 2 minutes, 9 écrans max (~~8~~, révisé par D-006 — cf. docs/decisions.md : les contrats rejoignent le quiz). Chaque question est factuelle (on sait sans chercher), « je ne sais pas » est toujours une réponse valide, aucune question mémoire, aucune saisie obligatoire. Chaque réponse déclenche des inférences documentées — le quiz est la matérialisation du DSL d'applicabilité (§ 20.1).*
|
||||
*Budget : ≤ 2 minutes, 9 écrans. Chaque question est factuelle (on sait sans chercher), « je ne sais pas » est toujours une réponse valide, aucune question mémoire, aucune saisie obligatoire. Chaque réponse déclenche des inférences documentées — le quiz est la matérialisation du DSL d'applicabilité (§ 20.1).*
|
||||
|
||||
> **Révision du 28 juillet 2026.** Ce document décrivait encore le parcours de juillet 2026 : saisie de plaque, API SIV payante, « 1 plaque offerte », prix mensuel, export .ics gratuit. Tout cela a été annulé — la plaque par **D-004** (le champ K de la carte grise la remplace), le prix par la réécriture du **§ 10** (19,99 €/an), le mur e-mail par **D-005**, l'entretien courant par **D-017**. Le document a été réaligné sur le code (`src/app/quiz/quiz-client.tsx`, `src/engine/quiz-to-assets.ts`) à l'occasion de l'[audit complet](../audit-2026-07-28.md). Les écarts restants sont signalés ⚠️.
|
||||
|
||||
**L'ordre réel des écrans** (`TOTAL_SCREENS = 9`) : adresse → logement → chauffage → équipements → véhicules → composition du foyer → papiers & CESU → contrats → animaux.
|
||||
|
||||
---
|
||||
|
||||
## Écran 0 — L'adresse (optionnel, avant tout)
|
||||
|
||||
> **« Ton adresse ? On pré-remplit ce qu'on peut avec les données publiques. »**
|
||||
> Champ autocomplété (API Adresse/BAN) · bouton « Passer » toujours visible.
|
||||
> **« On commence par ton adresse ? »**
|
||||
> Champ autocomplété (API Adresse/BAN) · « Passer » toujours visible.
|
||||
|
||||
- Si fournie → appel DPE/BDNB → l'écran 1 arrive **pré-rempli** avec bandeau : *« D'après les données publiques : maison de 1974, chauffage gaz — c'est toujours ça ? »* Tout est corrigeable en un tap.
|
||||
- Inférences directes (état réel, révisé le 26/07/2026) : département → **Loi Montagne** (pneus hiver, via `loi_montagne` calculé dans l'adapter adresse) et **zone de déclaration des revenus** (01-19 / 20-54 / 55+, stratégie `declaration_revenus` — l'échéance ne change pas, sa date si). ~~ZFE/Crit'Air~~ **rouvert et livré par D-011 (26/07/2026)** : le classement Crit'Air se déduit de la motorisation et de l'année, et se croise avec les zones à faibles émissions dans un rayon de 20 km autour du logement — aucune règle ZFE n'étant écrite chez nous, tout venant de la base nationale. ~~règlement sanitaire départemental (ramonage)~~ **non personnalisable** : le décret de 2023 pose un plancher de 12 mois que les arrêtés préfectoraux peuvent relever, mais ces arrêtés n'existent dans aucun jeu national (§ 2.7) — la fiche `ramonage` v3 énonce le plancher et renvoie à la préfecture, comme pour le PPRT.
|
||||
- Coordonnées (lat/lon/citycode) → **relevé Géorisques** pendant l'écran de fabrication (Phase 1 du pilier maison).
|
||||
- DPE → **datation du logement** (année exacte sur 57 % des diagnostics, tranche sur 100 %), reportée sans confirmation et résolue en `epoque_construction` : c'est la seule caractéristique du diagnostic qu'aucun chantier ne peut démentir (D-010).
|
||||
Si elle est fournie, elle est de très loin la réponse la plus rentable du quiz — elle alimente quatre chaînes distinctes :
|
||||
|
||||
- **Département** → Loi Montagne (`loi_montagne`, calculé dans l'adapter adresse) et **zone de déclaration des revenus** (01-19 / 20-54 / 55 et au-delà, stratégie `declaration_revenus` — l'échéance ne change pas, sa date si).
|
||||
- **Coordonnées** (`lat` / `lon` / `citycode` / `banId`, tous conservés jusqu'à l'asset) → relevé Géorisques, croisement ZFE, veille CatNat et VigiEau, pendant l'écran de fabrication.
|
||||
- **DPE de l'ADEME** (jointure par `banId`) → pré-remplissage du type de logement et du chauffage, **soumis à confirmation** ; et **datation du logement**, reportée **sans** confirmation — c'est la seule caractéristique du diagnostic qu'aucun chantier ne peut démentir (D-010).
|
||||
- Aucune règle ZFE n'est écrite chez nous : tout vient de la Base Nationale des ZFE (D-011).
|
||||
|
||||
**Ce qui n'est pas personnalisable, et pourquoi** : le règlement sanitaire départemental (ramonage). Le décret de 2023 pose un plancher de 12 mois que les arrêtés préfectoraux peuvent relever, mais ces arrêtés n'existent dans aucun jeu national — la fiche `ramonage` énonce le plancher et renvoie à la préfecture, comme pour le PPRT.
|
||||
|
||||
---
|
||||
|
||||
## Volet Logement
|
||||
|
||||
|
|
@ -19,90 +31,131 @@
|
|||
> **« Ton logement, c'est plutôt : »**
|
||||
> [Maison] [Appartement] · [Propriétaire] [Locataire]
|
||||
|
||||
Inférences : propriétaire → taxe foncière, déclaration des biens, AG de copro (si appart) ; locataire → révision IRL du loyer, assurance habitation (obligatoire) ; maison → toiture, gouttières ; appartement + propriétaire → AG de copropriété.
|
||||
Inférences : propriétaire → taxe foncière, déclaration des biens, AG de copro (si appartement) ; locataire → révision IRL, décence énergétique ; maison → gouttières, haies ; appartement + propriétaire → AG de copropriété.
|
||||
|
||||
**Question conditionnelle — l'époque de construction (ajoutée par D-010)** : `[Avant 1949] [Entre 1949 et 1997] [Après 1997] [Je ne sais pas]`. Elle **n'apparaît que si le DPE n'a pas daté le logement** (pas d'adresse, aucun diagnostic à l'adresse, ou tranche ADEME à cheval sur un pivot) — le quiz ne redemande jamais ce qu'il sait déjà. Trois réponses et pas dix : ce sont les deux seuls pivots réglementaires, le 1er janvier 1949 (constat plomb) et le 1er juillet 1997 (état d'amiante). Inférences, propriétaire uniquement : `plomb-crep`, `amiante-etat`.
|
||||
**Question conditionnelle — l'époque de construction (D-010)** : `[Avant 1949] [Entre 1949 et 1997] [Après 1997] [Je ne sais pas]`. Elle **n'apparaît que si le DPE n'a pas daté le logement** (pas d'adresse, aucun diagnostic, ou tranche ADEME à cheval sur un pivot). Trois réponses et pas dix : ce sont les deux seuls pivots réglementaires, le 1er janvier 1949 (constat plomb) et le 1er juillet 1997 (état d'amiante). Inférences, propriétaire uniquement : `plomb-crep`, `amiante-etat`.
|
||||
|
||||
**Écran de confirmation DPE** : quand un diagnostic est trouvé, l'étiquette et sa date de fin de validité ne sont **jamais** adoptées sans un tap explicite (`src/knowledge/dpe-confirmation.ts`), avec un bouton Annuler. Un DPE peut décrire un logement qui n'existe plus.
|
||||
|
||||
### Écran 2 — Le chauffage
|
||||
|
||||
> **« Tu te chauffes comment ? »** *(plusieurs réponses possibles)*
|
||||
> [Gaz] [Fioul] [Électrique] [Pompe à chaleur / clim] [Bois / poêle / cheminée] [Je ne sais pas]
|
||||
|
||||
Inférences : gaz/fioul → entretien annuel obligatoire de la chaudière ; PAC/clim → entretien bisannuel ; bois/cheminée → ramonage annuel ; électrique seul → rien (et c'est très bien). « Je ne sais pas » → rappel doux à la première fenêtre saisonnière (septembre) : « au fait, tu te chauffes comment ? ».
|
||||
Inférences : gaz/fioul → entretien annuel obligatoire ; PAC/clim → entretien bisannuel ; bois/cheminée → ramonage ; électrique seul → rien, et c'est très bien. « Je ne sais pas » est filtré à la construction de l'asset — il ne déclenche rien plutôt que de déclencher tout.
|
||||
|
||||
La pré-sélection issue du DPE s'appuie sur `src/knowledge/dpe-chauffage.ts` : une correspondance exacte des 155 valeurs de générateur et 15 valeurs d'énergie principale publiées par l'ADEME, et non une détection par sous-chaîne. C'est elle qui distingue une PAC d'un radiateur électrique — les deux affichent « Électricité » côté énergie principale.
|
||||
|
||||
### Écran 3 — Les équipements *(multi-sélection, tout est optionnel)*
|
||||
|
||||
> **« Coche ce que tu as : »**
|
||||
> [Jardin / haies] [Piscine] [Fosse septique] [Adoucisseur d'eau] [Chauffe-eau électrique] [Rien de tout ça]
|
||||
> [Jardin / haies] [Piscine] [Fosse septique] [Adoucisseur d'eau] [Chauffe-eau électrique]
|
||||
|
||||
Inférences : jardin → taille/élagage (périodes légales de voisinage) ; piscine → hivernage + remise en route ; fosse → vidange ~4 ans ; adoucisseur → sel/entretien ; chauffe-eau → détartrage. Les détecteurs de fumée et la VMC ne sont **pas demandés** : inférés pour tout logement (obligation universelle).
|
||||
Inférences : jardin → taille des haies ; piscine → sécurité, hivernage, remise en route ; fosse → vidange et contrôle SPANC ; adoucisseur → entretien ; chauffe-eau → détartrage. **Les détecteurs de fumée et la VMC ne sont pas demandés** : inférés pour tout logement (obligation universelle).
|
||||
|
||||
## Volet Véhicules *(décision D-002 : API plaque payante, 1 offerte par quiz)*
|
||||
---
|
||||
|
||||
### Écran 4 — La plaque (l'effet waouh)
|
||||
## Volet Véhicules *(D-004 puis D-017 — plus aucune API payante)*
|
||||
|
||||
> **« Une voiture au foyer ? Tape sa plaque, on s'occupe du reste. »**
|
||||
> Champ plaque · [Ajouter un 2e véhicule] · [Pas de voiture] · [Je préfère ne pas la donner]
|
||||
### Écran 4 — La carte grise, pas la plaque
|
||||
|
||||
- Plaque fournie → API : *« Peugeot 308 de 2019, essence — contrôle technique à prévoir avant mars 2027. Courroie de distribution préconisée vers 120 000 km, pneus hiver obligatoires dans ton département du 1er nov au 31 mars, Crit'Air 1. »* Confirmable/corrigeable en un tap.
|
||||
- La date CT est une **fenêtre calculée** depuis la date de 1re immatriculation retournée par l'API (exacte pour les < 4 ans : 1er CT au 4e anniversaire ; estimée ensuite : cycles de 2 ans). Affinage optionnel plus tard, jamais bloquant : « la date exacte est sur ta vignette pare-brise — dis-la-moi un jour et je serai au jour près ».
|
||||
- Inférences : CT, révisions constructeur, courroie (par modèle), Crit'Air/ZFE, pneus hiver (département Loi Montagne), batterie avant l'hiver.
|
||||
- **Repli déclaratif** (plaque refusée) : marque/modèle autocomplétés + année + carburant → mêmes échéances en fenêtres estimées.
|
||||
- Anti-abus : 1 plaque par quiz, cache par plaque, limite IP (§ 7.3).
|
||||
- Option discrète en fin d'écran : [Moto/scooter] [Remorque] (mêmes mécaniques, quiz allégé).
|
||||
> **« Une voiture au foyer ? »**
|
||||
> *Un seul champ de ta carte grise suffit : on remplit le reste. Pas de carte grise sous la main ? La recherche par modèle marche aussi.*
|
||||
|
||||
**Voie 1 — le champ K** (numéro de réception communautaire, ex. `e2*2001/116*0362*00`). Recopié, il identifie exactement le véhicule dans le référentiel RDW (2,9 M de configurations, domaine public, zéro coût variable) : marque, modèle, année de type, énergies homologuées, norme Euro. Le numéro est persisté **tel que le référentiel l'écrit**, pas tel qu'il a été tapé.
|
||||
|
||||
**Le champ P.2** (puissance nette maximale en kW) est ensuite proposé — jamais saisi librement : le référentiel connaît les puissances réellement homologuées pour cette réception, on les présente en liste fermée. Une saisie libre laissait passer la confusion kW / chevaux, qui produit un nombre plausible n'existant sur aucune version et fait taire le produit sans cause visible.
|
||||
|
||||
Trois cas, et dans deux d'entre eux **on ne demande rien** : une seule puissance homologuée (déduite), un seul moteur en lice (déduit), plusieurs moteurs (question posée). Mesuré à D-017 : le couple champ K + énergie désigne déjà un moteur unique 39 % du temps ; le P.2 fait passer l'identification du moteur de 38 % à 86 %.
|
||||
|
||||
**Voie 2 — marque/modèle**, ouverte à la demande ou d'office si le champ K est inconnu. Autocomplétion sur le catalogue agrégé, jamais de saisie libre. L'énergie proposée est restreinte à celles réellement commercialisées pour le modèle. L'année de mise en circulation est alors demandée (elle ne l'est pas sur la voie 1, où le référentiel la fournit).
|
||||
|
||||
> ⚠️ **Écart connu, documenté à l'[audit § 3](../audit-2026-07-28.md#3-le-quiz-face-à-létat-réel-des-développements)** : le champ P.2 n'est proposé **que** sur la voie 1. Un foyer passé par marque/modèle laisse donc tous les moteurs du modèle en lice, la règle d'unanimité ne tranche jamais, et rien n'est affirmé sur la distribution. Ce n'est pas une limite de conception — `codesEnLice` sait filtrer sur la puissance sans réception — c'est un trou de câblage. Correctif proposé et mesure préalable dans l'audit.
|
||||
|
||||
**La vignette Crit'Air** est la seule question du sujet : le classement, lui, se déduit de la motorisation et de l'année (`src/knowledge/critair.ts`). Non renseignée = on ne sait pas, et la fiche s'affiche quand même — commander une vignette qu'on a déjà coûte moins cher que rater une amende.
|
||||
> ⚠️ Cette réponse est appliquée à **tous** les véhicules du foyer, pas véhicule par véhicule (audit § 3.4).
|
||||
|
||||
**Ce qui a été retiré, et ne reviendra pas** : la saisie de plaque, l'API agrégateur SIV, le quota « 1 plaque offerte », le cache par plaque et la limite IP associée. Aucun de ces mécanismes n'existe plus. La date réelle du contrôle technique n'est de toute façon pas disponible en self-service — la fenêtre est calculée depuis la première immatriculation (stratégie `ct_vehicule`).
|
||||
|
||||
**⚠️ Non implémenté** : l'option [Moto/scooter] [Remorque] annoncée au § 5.3 du cadrage.
|
||||
|
||||
---
|
||||
|
||||
## Volet Foyer
|
||||
|
||||
### Écran 5 — Qui vit ici ?
|
||||
|
||||
> **« Le foyer, c'est : »**
|
||||
> Adultes : [1] [2] [3+] · Enfants : [aucun] ou tranches d'âge cochables [0-5] [6-11] [12-15] [16-17]
|
||||
> Adultes : [1] [2] [3 ou plus] · Enfants par tranche : [0-5] [6-11] [12-15] [16-17]
|
||||
> *Pas de prénoms, pas de dates de naissance — on n'en a pas besoin pour monter la garde.*
|
||||
|
||||
Inférences : par personne → CNI/passeport (dont **passeport enfant : 5 ans seulement**, calé sur les tranches) ; 15-17 ans → recensement citoyen + JDC ; enfants → rappels calés sur vacances scolaires (garde animaux, passeports avant l'été). Pas de prénoms demandés (ajoutables plus tard dans le hub — jamais exigés).
|
||||
Inférences : par adulte → CNI, passeport, permis (si `papiers_anciens` le permet) ; par enfant → passeport enfant, **5 ans seulement** ; tranche 16-17 → recensement citoyen et JDC.
|
||||
|
||||
### Écran 6 — Les animaux
|
||||
### Écran 6 — Les papiers (la seule question « floue » assumée)
|
||||
|
||||
> **« Des animaux ? »**
|
||||
> [Chien] [Chat] [Autre] [Aucun] · si oui : âge approximatif [jeune] [adulte] [senior] [je ne sais pas]
|
||||
|
||||
Inférences : vaccins annuels, antiparasitaires (saison avril-oct), vermifuge, bilan senior (si senior), garde avant vacances scolaires.
|
||||
|
||||
## Volet Papiers *(§ 5.6, révisé par D-006 — les contrats rejoignent ce volet, juste après le passeport)*
|
||||
|
||||
### Écran 7 — Les papiers (la seule question « floue » assumée)
|
||||
|
||||
> **« Tes papiers d'identité (CNI, passeport) ont plus de 10 ans ? »**
|
||||
> **« Tes papiers d'identité ont plus de 10 ans ? »**
|
||||
> [Oui, certains] [Non, refaits récemment] [Aucune idée]
|
||||
> **bonus, un tap** : « Tu emploies quelqu'un à domicile ? » [Oui] [Non] → sous-module CESU
|
||||
|
||||
- « Aucune idée » est **parfaitement servi** : rappel doux unique (« un jour, jette un œil à la date de ta CNI — on la surveillera pour toi ») + proposition de photo-extraction (phase 2, image jamais conservée).
|
||||
- Question bonus (un seul tap, optionnelle) : **« Tu emploies quelqu'un à domicile (ménage, garde, jardin) ? »** [Oui] [Non] → sous-module CESU (déclarations, attestation fiscale, crédit d'impôt 50 %).
|
||||
« Aucune idée » est traité comme « oui » : le seul biais acceptable ici, parce que vérifier une date qu'on connaît coûte dix secondes, et découvrir une CNI périmée à l'aéroport coûte le voyage.
|
||||
|
||||
### Écran 8 — Des contrats à associer, tant qu'on y est ?
|
||||
### Écran 7 — Les contrats *(§ 5.6, révisé par D-006 puis D-007)*
|
||||
|
||||
> **« Des contrats à associer, tant qu'on y est ? »**
|
||||
> Une carte par type de contrat **pertinent pour ce foyer** (filtrage sur ce que le quiz sait déjà à cet écran) : [Assurance habitation] [Assurance auto — si véhicule déclaré] [Assurance animale — si animal déclaré] [Assurance emprunteur — si propriétaire] [Mutuelle santé] [Box internet] [Forfait mobile] [Salle de sport]. Sélectionner une carte révèle un seul champ optionnel : « Échéance approximative ? » (mois, « je ne sais pas » par défaut). Le nom de l'assureur n'est jamais demandé — il ne sert aucune inférence, seule l'échéance en déclenche une (cohérent avec le reste du quiz : rien qui ne sert pas un rappel).
|
||||
|
||||
- Rien n'est obligatoire : ignorer l'écran entier est un parcours valide, exactement comme les autres.
|
||||
- **C'est le seul moment de déclaration (révisé par D-007)** : l'opt-in post-quiz du hub a été retiré — un contrat ignoré ici, ou une assurance devenue pertinente après coup (nouveau véhicule, nouvel animal), n'est plus déclarable du tout. La redite perçue par l'utilisateur (« on vient déjà de me poser la question ») a pesé plus lourd que la couverture perdue.
|
||||
- Un contrat déclaré ici devient un asset `contrat` (`provenance: "quiz"`) et déclenche sa fiche `*-echeance` — testé par simulation (session du 25/07/2026).
|
||||
Une carte par type de contrat **pertinent pour ce foyer** (filtrage sur ce que le quiz sait déjà à cet écran) : assurance habitation, auto (si véhicule), animale (si animal), emprunteur (si propriétaire), mutuelle, box, mobile, salle de sport. Sélectionner une carte révèle un seul champ optionnel : « Échéance approximative ? » (un mois, « je ne sais pas » par défaut).
|
||||
|
||||
## Écran de fabrication — où le travail a lieu (révisé par D-009)
|
||||
**L'assureur n'est jamais demandé** — il ne sert aucune inférence, seule l'échéance en déclenche une.
|
||||
|
||||
**C'est le seul moment de déclaration (D-007)** : l'opt-in post-quiz du hub a été retiré. Un contrat ignoré ici, ou une assurance devenue pertinente après coup, n'est plus déclarable du tout.
|
||||
|
||||
> ⚠️ **Contradiction de copy à corriger** : le texte d'aide de l'écran dit encore *« Les autres, on te les proposera au bon moment »*. C'est faux depuis D-007 — rien ne sera proposé. Voir [pilier-contrats.md § 3.1](../pilier-contrats.md) pour la sortie envisagée (rouvrir la déclaration dans le récap annuel).
|
||||
|
||||
### Écran 8 — Les animaux *(dernier écran)*
|
||||
|
||||
> **« Des animaux ? »**
|
||||
> [+ Un chien] [+ Un chat] [+ Autre animal] · puis âge du dernier ajouté : [jeune] [adulte] [senior] [Je ne sais pas]
|
||||
> **[Voir les échéances de mon foyer]** — *« Aucune adresse mail demandée. Le résultat s'affiche directement. »*
|
||||
|
||||
Inférences : vaccins annuels, antiparasitaires (avril-octobre), vermifuge trimestriel, bilan si senior, garde avant les vacances. Voir [pilier-animaux.md](../pilier-animaux.md).
|
||||
|
||||
---
|
||||
|
||||
## Écran de fabrication — où le travail a lieu (D-009)
|
||||
|
||||
> **« On monte ta veille… »**
|
||||
> Une étape par traitement réellement effectué, cochée quand il aboutit, avec ce qu'il a trouvé.
|
||||
|
||||
Le plan des étapes dépend des réponses : pas d'adresse, pas de relevé de risques et pas d'attente ; pas de véhicule, pas d'identification. Les traitements réels sont la création des assets, la réconciliation (croisement avec les 64 fiches), l'identification du véhicule dans le référentiel RDW avec ses campagnes de rappel, le relevé Géorisques de l'adresse, et **la veille événementielle** — arrêtés de catastrophe naturelle et restrictions d'eau en vigueur (D-012). Les deux derniers s'enchaînent dans le même passage, le seul qui fasse réellement attendre (~1 s en régime normal, ~~plafonné à 8 s~~ **plafond relevé à 10 s le 26/07/2026**, la veille ajoutant deux appels séquentiels — séquentiels parce que les deux enrichissements écrivent sur la même ligne `assets`).
|
||||
Le plan des étapes est calculé **par le serveur, à partir des réponses**, et envoyé en première ligne d'un flux NDJSON (`planFabrication`, `src/engine/fabrication.ts`) : pas d'adresse, pas de relevé de risques et pas d'attente ; pas de véhicule, pas d'identification. Aucun délai n'est simulé ; seule la cadence d'affichage est bornée, pour que ce qui est acquis reste lisible.
|
||||
|
||||
Chaque étape annonce un **constat**, jamais un geste ni une date : « zone de débroussaillement obligatoire », « PEUGEOT 308 — 20 campagnes de rappel recensées », « restrictions d'eau en vigueur — niveau alerte renforcée ». Quand la veille ne trouve rien, elle le dit (« aucun arrêté en cours à ton adresse ») : c'est déjà une information, et c'est le cas le plus fréquent. Le mode d'emploi reste derrière l'abonnement (D-005 : révéler l'inventaire, jamais le mode d'emploi). Aucun délai n'est simulé ; seule la cadence d'affichage est bornée à 420 ms par étape, pour que ce qui est acquis reste lisible.
|
||||
Les traitements réels : création des assets · réconciliation (croisement avec les **66** fiches) · identification des véhicules dans le référentiel RDW, de leurs **moteurs** et de leurs campagnes de rappel · relevé Géorisques · veille événementielle (arrêtés CatNat, restrictions d'eau) · croisement ZFE. Les trois derniers partagent le même passage, le seul qui fasse réellement attendre — **plafond 10 s**, au-delà duquel une file pg-boss prend le relais et le foyer se complète au rechargement suivant.
|
||||
|
||||
## Écran final — La révélation
|
||||
Chaque étape annonce un **constat**, jamais un geste ni une date : « zone de débroussaillement obligatoire », « PEUGEOT 308 — 20 campagnes de rappel recensées », « courroie de distribution qui tourne dans l'huile », « aucun arrêté en cours à ton adresse ». Le mode d'emploi reste derrière l'abonnement (D-005 : révéler l'inventaire, jamais le mode d'emploi).
|
||||
|
||||
> **« ✓ 34 échéances sous contrôle. Tu n'as plus besoin d'y penser. »**
|
||||
> Les 3 prochaines affichées avec leur pourquoi · les 4 piliers avec leurs comptes · deux boutons :
|
||||
> **[Activer la veille — 4,90 €/mois]** · [Télécharger mon calendrier (.ics gratuit)]
|
||||
---
|
||||
|
||||
**Ce qui n'est jamais dans le quiz** : les dates de dernier entretien (questions mémoire interdites) ; le prénom du conjoint (l'invitation vient à la première délégation). ~~Les contrats (proposés après, au moment pertinent, contre promesse monétaire — § 5.6)~~ **Les contrats ont un écran dédié depuis D-006 ; seul ce qui n'y est pas déclaré reste proposé après coup.**
|
||||
## Écran final — la révélation graduée (D-005, § 10.2)
|
||||
|
||||
**Comptage** : 9 écrans (~~8~~, D-006), ~12 taps pour le parcours médian hors contrats, 1 seule saisie clavier réellement utile (la plaque ; l'adresse est autocomplétée). Budget 2 minutes tenu pour un foyer qui ignore l'écran contrats ; au-delà, le coût dépend du nombre de contrats déclarés, pas d'un minimum imposé.
|
||||
**Aucune adresse mail n'est demandée**, ni avant ni pendant. Le hub s'affiche directement.
|
||||
|
||||
**Instrumentation Phase 0** (l'étoile polaire se mesure ici) : taux de complétion par écran (où abandonne-t-on ?), taux d'adresses fournies, taux de plaques fournies vs repli déclaratif, distribution du nombre d'échéances générées, clics sur chacun des deux boutons finaux.
|
||||
Ce qui est **montré à tous** : le total (« 34 échéances sous contrôle »), la répartition par pilier, la **timeline 12 mois** en pastilles anonymes, et **2-3 échéances entièrement révélées** — la seule brèche volontaire dans « révéler l'inventaire, jamais le mode d'emploi », parce que sans elle tout ce que voit le visiteur reste abstrait.
|
||||
|
||||
Ce qui est **verrouillé** : le reste du calendrier, réduit à un **compte par pilier**. Pas les titres — un titre est déjà le savoir.
|
||||
|
||||
Le geste de paiement : **19,99 €/an**, avec la case de renonciation au droit de rétractation obligatoire (§ 10.7, art. L221-28 du Code de la consommation), refusée côté serveur si elle n'est pas cochée. **Plus d'export .ics gratuit** : le flux vivant est un avantage payant.
|
||||
|
||||
L'adresse mail est proposée **après**, une fois le calendrier parcouru, et refusable — pour retrouver un foyer dont l'URL est un UUID que personne ne mémorisera.
|
||||
> ⚠️ **Cette promesse n'est pas tenue aujourd'hui** : `POST /api/interet` enregistre l'adresse et n'envoie aucun mail (audit F1).
|
||||
|
||||
**Ce qui n'est jamais dans le quiz** : les dates de dernier entretien (questions mémoire interdites), le prénom du conjoint, l'adresse mail, la plaque d'immatriculation.
|
||||
|
||||
---
|
||||
|
||||
## Comptage et instrumentation
|
||||
|
||||
**9 écrans**, une seule saisie clavier réellement utile (le champ K ; l'adresse et le modèle sont autocomplétés, la puissance est choisie en pastilles). Budget 2 minutes tenu pour un foyer qui ignore l'écran contrats.
|
||||
|
||||
**Un brouillon en `sessionStorage`** protège le parcours d'un rafraîchissement accidentel.
|
||||
|
||||
**Instrumentation** (l'étoile polaire se mesure ici, § 13.1) : `quiz_start`, `quiz_screen` par écran, `cta_activate`. Taux de complétion par écran, taux d'adresses fournies, **taux de champs K fournis vs repli déclaratif** (la mesure qui décidera de l'arbitrage de l'audit § 3), distribution du nombre d'échéances générées.
|
||||
> ⚠️ Ces événements sont insérés par `POST /api/event`, dont le `catch` est muet : si les insertions échouent, la mesure vaut zéro sans que rien ne le dise (audit F8).
|
||||
|
|
|
|||
129
docs/pilier-animaux.md
Normal file
129
docs/pilier-animaux.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# Pilier Animaux — ce qu'on sait d'un animal sans jamais rien demander
|
||||
|
||||
Document de référence du pilier animaux : ce que deux réponses de quiz permettent de déduire, ce qui reste inaccessible, et dans quel ordre enrichir. Le [cadrage](cadrage-projet-fokan.md) reste la source de vérité produit (§ 5.5) ; ce document le décline sur un pilier.
|
||||
|
||||
**Statut** : rédigé le 28 juillet 2026, à l'occasion de l'[audit complet](audit-2026-07-28.md). Les faits réglementaires cités ont été revérifiés aux sources officielles à cette date ; les items marqués ❓ n'ont **pas** été testés techniquement et ne doivent pas être considérés comme acquis.
|
||||
|
||||
---
|
||||
|
||||
## État d'avancement (28 juillet 2026)
|
||||
|
||||
**Phase 0 : livrée.** Six fiches, toutes bicéphales, toutes déclenchées par simulation.
|
||||
|
||||
| Fiche | Applicabilité | Fenêtre |
|
||||
|---|---|---|
|
||||
| `vaccins-rappel` | tout animal | annuelle, apprise à l'usage |
|
||||
| `antiparasitaires-saison` | chien, chat | mensuelle, saison avril → octobre |
|
||||
| `vermifuge` | chien, chat | trimestrielle |
|
||||
| `bilan-veterinaire-senior` | `age: senior` | annuelle |
|
||||
| `assurance-animale-echeance` | contrat `assurance_animale` | mois déclaré, ou annuelle |
|
||||
| `garde-vacances` | tout animal | saison mai-juin, `clore` après |
|
||||
|
||||
**Phase 1 (enrichissement) : non commencée.** Aucun adapter, aucune source externe, aucun attribut dérivé. C'est le seul pilier dans ce cas — maison en a quatre (Géorisques, ADEME, CatNat, VigiEau), véhicules en a deux (RDW, BNZFE), papiers en a un (la zone de déclaration, déduite du département).
|
||||
|
||||
---
|
||||
|
||||
## 1. Ce que le quiz demande, et ce qu'il en tire
|
||||
|
||||
Deux questions, un écran (§ 5.5, écran 6) :
|
||||
|
||||
> **« Des animaux ? »** [Chien] [Chat] [Autre] [Aucun] · **âge approximatif** [jeune] [adulte] [senior] [je ne sais pas]
|
||||
|
||||
C'est volontairement le pilier le plus pauvre en saisie du produit, et le meilleur rapport valeur/question : deux taps produisent quatre à six échéances, sans une seule question mémoire.
|
||||
|
||||
**Ce qui se déduit** : `espece` conditionne antiparasitaires et vermifuge (un lapin ou un furet n'a pas le même protocole, et on préfère se taire) ; `age: senior` ouvre le bilan annuel ; la présence d'un animal, quel qu'il soit, ouvre vaccins et garde.
|
||||
|
||||
**Ce qui n'est pas demandé, et pourquoi** : le nom (aucune inférence), la race (aucune inférence sauf pour les chiens catégorisés, cf. § 3.2), le poids, le sexe, la date de naissance exacte (question mémoire). Le nombre d'animaux est déduit du nombre d'entrées.
|
||||
|
||||
---
|
||||
|
||||
## 2. Les trois niveaux de vérité, appliqués aux animaux
|
||||
|
||||
Même grille que le pilier maison, et elle range tout :
|
||||
|
||||
1. **Le fait légal universel** — vrai pour tout animal en France, sans aucune donnée : identification obligatoire, règles de voyage, catégorisation. Se lit directement dans le moteur, sans confirmation.
|
||||
2. **Le fait déclaré** — espèce et âge, corrigeables, jamais imposés.
|
||||
3. **Le fait détenu ailleurs et inaccessible** — le carnet de vaccination, les dates réelles de traitement, le numéro I-CAD. Aucune API ne les donne, et c'est structurel (cf. § 4).
|
||||
|
||||
Le pilier vit presque entièrement au niveau 1 et 2. C'est ce qui le rend robuste : **rien à synchroniser, rien qui puisse tomber en panne.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Ce qui manque et qui est directement gagnable
|
||||
|
||||
Classé par valeur décroissante. Aucun ne demande d'API — ce sont des fiches et des inférences.
|
||||
|
||||
### 3.1 L'identification I-CAD et sa mise à jour · valeur haute, coût nul
|
||||
|
||||
L'identification (puce ou tatouage) et l'inscription au Fichier national d'identification des carnivores domestiques sont **obligatoires** : avant 4 mois pour un chien, avant 7 mois pour un chat, pour les chiens, chats et furets ([agriculture.gouv.fr](https://agriculture.gouv.fr/lidentification-des-animaux-de-compagnie-une-obligation-legale-qui-les-protege), [arrêté du 9 novembre 2023](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000048418470)).
|
||||
|
||||
L'échéance dormante n'est pas l'identification elle-même — elle est faite une fois — mais **la mise à jour des coordonnées**. I-CAD le dit sans détour : en cas de déménagement, de changement de téléphone ou de mail, il faut mettre à jour la fiche, y compris pour une résidence temporaire pendant les vacances ([i-cad.fr](https://www.i-cad.fr/articles/mise_a_jour_coordonnees)). Une puce à jour est la seule chose qui ramène un animal perdu ; une puce périmée ne sert à rien, et personne n'y pense.
|
||||
|
||||
C'est **exactement** la promesse fokan : gratuit, dormant, à conséquence réelle, et personne ne monte la garde dessus.
|
||||
|
||||
**Fiche à écrire** : `icad-coordonnees`. Applicabilité : tout animal, conditionnée à un attribut `demenagement_recent` sur le foyer — **le même attribut qui manque au pilier véhicules** (cf. audit F4). Les deux fiches se débloquent d'un seul geste le jour où l'adresse devient modifiable dans le hub.
|
||||
|
||||
### 3.2 Les chiens de catégorie · valeur haute, une question de quiz
|
||||
|
||||
Un chien de catégorie 1 ou 2 impose un **permis de détention** délivré par le maire, subordonné à une **assurance responsabilité civile**, à une **évaluation comportementale** par un vétérinaire de la liste départementale entre 8 mois et 1 an, et à une **vaccination antirabique en cours de validité** ; la stérilisation est requise en catégorie 1. Sans permis : 3 mois d'emprisonnement et 3 750 € d'amende ; sans assurance : jusqu'à 450 € ([service-public.gouv.fr F1839](https://www.service-public.fr/particuliers/vosdroits/F1839), [loi n° 2008-582](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000019060485), [agriculture.gouv.fr](https://agriculture.gouv.fr/les-chiens-de-categorie-1-et-2-dits-chiens-dangereux)).
|
||||
|
||||
Trois échéances datables en découlent, dont deux à sanction pénale : l'évaluation comportementale (fenêtre 8-12 mois, si l'animal est jeune), le renouvellement de l'antirabique (annuel, et la seule qui conditionne le permis), et le permis lui-même en cas de déménagement (il est délivré par la commune de résidence).
|
||||
|
||||
**Coût produit** : une question conditionnelle sur l'écran animaux, posée uniquement si `espece: chien` — « ton chien est-il de catégorie 1 ou 2 ? » avec « je ne sais pas » servi. C'est factuel (un propriétaire de catégorisé le sait) et ce n'est pas une question mémoire.
|
||||
|
||||
**Réserve honnête** : c'est une population minoritaire. La valeur individuelle est très forte, la couverture faible. À arbitrer contre § 3.3, qui touche tout le monde.
|
||||
|
||||
### 3.3 Le voyage : rage, passeport européen, délai de 21 jours · valeur haute, coût nul
|
||||
|
||||
Déjà énoncé dans le corps du mail de `vaccins-rappel`, mais **pas datable** aujourd'hui : le vaccin antirabique n'est valide que **21 jours après la primo-injection**, et le passeport européen est exigé pour franchir une frontière (règlement UE 576/2013). Un foyer qui décide en juin de partir en Espagne en juillet découvre le délai trop tard.
|
||||
|
||||
**Ce qui manque** : le déclencheur. Il est déjà à moitié là — la fiche `garde-vacances` se cale sur une saison codée en dur `[5, 6]`. Le vrai déclencheur est le calendrier scolaire (cf. § 4.1).
|
||||
|
||||
**Fiche à écrire** : `voyage-animal-rage`, saison calée sur les vacances de la zone, `clore` après.
|
||||
|
||||
### 3.4 Le certificat d'engagement et de connaissance · valeur moyenne
|
||||
|
||||
Depuis la loi du 30 novembre 2021, l'acquisition d'un animal de compagnie impose la signature d'un certificat d'engagement et de connaissance, avec un délai de réflexion de 7 jours avant la cession. ❓ **À vérifier avant rédaction** : c'est une obligation **de cession**, donc un événement ponctuel à l'acquisition — elle ne produit pas d'échéance récurrente. Elle a sa place comme page SEO (bon trafic de recherche) mais probablement pas comme fiche moteur. À trancher.
|
||||
|
||||
---
|
||||
|
||||
## 4. Les enrichissements par source externe
|
||||
|
||||
### 4.1 Le calendrier scolaire · ✅ source vérifiée, non testée techniquement
|
||||
|
||||
L'Éducation nationale publie le calendrier scolaire en open data, avec une API : jeu `fr-en-calendrier-scolaire` sur data.education.gouv.fr, exposé aussi comme [API calendrier scolaire](https://www.data.gouv.fr/dataservices/api-calendrier-scolaire) et [jeu de données](https://www.data.gouv.fr/datasets/le-calendrier-scolaire) sur data.gouv.fr. Zones A, B, C plus les onze territoires ultramarins, dates de début et de fin, disponible aussi en iCal.
|
||||
|
||||
**Ce que ça change** : `garde-vacances` se cale aujourd'hui sur `saison: [5, 6]` — une approximation nationale d'un calendrier qui est régional. Avec la zone (déduite du département, déjà collecté), la fenêtre devient exacte : « les gardes se réservent maintenant » part huit semaines avant **les** vacances du foyer, pas avant celles d'une zone moyenne.
|
||||
|
||||
**Portée bien au-delà des animaux** : c'est le déclencheur du passeport enfant (pilier papiers), du voyage avec l'animal (§ 3.3), et de toute la saisonnalité « avant les vacances » du produit. **C'est l'enrichissement à plus fort effet de levier des trois piliers non-maison** — une seule table, un seul cron, trois piliers servis.
|
||||
|
||||
**Coût estimé** : un adapter, une table `vacances_scolaires` rafraîchie au passage hebdomadaire, une correspondance département → zone (table statique, ~101 lignes, en git). L'ordre de grandeur est celui de l'adapter VigiEau.
|
||||
|
||||
### 4.2 I-CAD, carnet de vaccination, historique vétérinaire · ❌ inaccessible, et ce n'est pas un retard
|
||||
|
||||
Aucune API publique. I-CAD est un fichier consultable par le détenteur via un espace authentifié, pas une source de données ouverte ; les carnets de vaccination sont papier ou dans des logiciels vétérinaires propriétaires.
|
||||
|
||||
**Conséquence assumée** : les dates réelles ne viendront jamais d'une source. Elles viendront de l'usage (« déjà fait » en un tap, § 4.3 du cadrage) ou, en phase 2, de la bannette — une facture de vétérinaire transférée porte la date et l'acte.
|
||||
|
||||
C'est une bonne nouvelle mal déguisée : le pilier animaux est le seul qui n'ait **aucune dépendance externe à surveiller**.
|
||||
|
||||
### 4.3 Ce qu'on écarte explicitement
|
||||
|
||||
| Piste | Pourquoi non |
|
||||
|---|---|
|
||||
| Suivi de poids, de nourriture, de promenades | To-do list du quotidien — refus § 2.3, et sous les 2 semaines de la frontière temporelle |
|
||||
| Rappel de rendez-vous vétérinaire pris | On ne connaît pas les rendez-vous, et les connaître supposerait une saisie récurrente |
|
||||
| Alertes épizooties locales (grippe aviaire, leishmaniose) | Données à la maille département via les DDPP, mais sans jeu national stable ❓ ; et le geste attendu est diffus |
|
||||
| Assurance santé animale : comparaison de garanties | Frontière § 2.3 — on signale l'échéance et on lie, on ne conseille pas |
|
||||
| Chevaux (fichier SIRE, vaccination grippe équine) | Population trop étroite pour l'effort ; à réexaminer si les données d'usage le contredisent |
|
||||
|
||||
---
|
||||
|
||||
## 5. Ce qui reste ouvert
|
||||
|
||||
1. **Le calendrier scolaire** (§ 4.1) — le plus rentable, et il sert trois piliers. À faire avant tout le reste de ce document.
|
||||
2. **`demenagement_recent`** — le même attribut manquant débloque `icad-coordonnees` ici et `carte-grise-changement-adresse` côté véhicules (audit F4). Un seul geste, deux fiches. **Report confirmé le 28/07/2026** : traité plus tard, à dessein — pas un oubli.
|
||||
3. **Chiens catégorisés** (§ 3.2) — forte valeur individuelle, faible couverture. À arbitrer.
|
||||
4. **Le certificat d'engagement** (§ 3.4) — page SEO probable, fiche moteur probablement non. À trancher.
|
||||
5. **Antiparasitaires : une saison qui s'allonge.** La fiche fixe avril-octobre. Le décalage climatique le rend progressivement faux vers le sud, et la zone climatique du DPE est déjà récupérée en Phase 0 du pilier maison (`zone_climatique`, testée, valeur `H2c` sur l'adresse d'essai). Un croisement possible, non chiffré.
|
||||
6. **Une question à trancher sur la formulation d'`age`** — « jeune / adulte / senior » ne dit pas la même chose pour un chihuahua et un dogue allemand, dont l'entrée en sénior diffère de trois ans. Aujourd'hui c'est le foyer qui tranche, ce qui est le bon défaut ; la race le trancherait mieux, au prix d'une question de plus.
|
||||
127
docs/pilier-contrats.md
Normal file
127
docs/pilier-contrats.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# Contrats — un chantier transverse, pas un pilier
|
||||
|
||||
Document de référence des contrats. **Ce n'est pas un pilier** : le § 5.6 du cadrage l'interdit explicitement — « pas de pilier Contrats dans l'interface, chaque contrat vit dans le pilier qu'il sert ». Ce document existe parce que les contrats traversent les quatre piliers, portent le gisement d'affiliation (§ 10.4), et sont la seule saisie que le produit s'autorise à demander.
|
||||
|
||||
**Statut** : rédigé le 28 juillet 2026, à l'occasion de l'[audit complet](audit-2026-07-28.md). Les faits juridiques cités portent leur source ; les items marqués ❓ n'ont pas été vérifiés.
|
||||
|
||||
---
|
||||
|
||||
## État d'avancement (28 juillet 2026)
|
||||
|
||||
**Livré, et fermé par décision.** Huit types de contrats, huit fiches, un écran de quiz dédié.
|
||||
|
||||
| Type déclarable | Fiche | Pilier de rattachement |
|
||||
|---|---|---|
|
||||
| `assurance_habitation` | `assurance-habitation-echeance` | maison |
|
||||
| `assurance_auto` | `assurance-auto-echeance` | véhicules |
|
||||
| `assurance_animale` | `assurance-animale-echeance` | animaux |
|
||||
| `assurance_emprunteur` | `assurance-emprunteur-echeance` | papiers |
|
||||
| `mutuelle` | `mutuelle-sante-echeance` | papiers |
|
||||
| `box` | `box-internet-echeance` | papiers |
|
||||
| `mobile` | `forfait-mobile-echeance` | papiers |
|
||||
| `salle_de_sport` | `salle-de-sport-echeance` | papiers |
|
||||
|
||||
Correspondance vérifiée à l'audit : aucun type déclarable sans fiche, aucune fiche sans type déclarable.
|
||||
|
||||
**Trois décisions successives ont façonné ce chantier** ([decisions.md](decisions.md)) :
|
||||
|
||||
- **D-005** — le mur e-mail post-quiz est retiré ; le contrat n'est plus une monnaie d'échange.
|
||||
- **D-006** — les contrats rejoignent le quiz, dans un écran dédié à la suite des papiers, filtré par pertinence.
|
||||
- **D-007** — l'opt-in post-quiz est retiré. **C'est aujourd'hui le seul moment de déclaration.** Un contrat ignoré à l'écran 8, ou devenu pertinent après coup (nouveau véhicule, nouvel animal), n'est plus déclarable du tout. La redite perçue a pesé plus lourd que la couverture perdue.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ce qu'on demande, et ce qu'on refuse de demander
|
||||
|
||||
Un seul champ par contrat sélectionné : **« Échéance approximative ? »** (un mois, « je ne sais pas » servi par défaut).
|
||||
|
||||
**L'assureur n'est pas demandé.** C'est la règle qui tient tout l'écran : il ne sert aucune inférence, et le quiz ne demande jamais ce qui ne déclenche pas une échéance. C'est aussi ce qui rend l'écran tenable en dix secondes.
|
||||
|
||||
**Conséquence à assumer** : sans l'assureur, on ne peut ni pré-remplir une lettre de résiliation, ni personnaliser un lien d'affiliation, ni détecter un doublon. La détection par transfert de PDF (phase 2) est ce qui rattrape cette limite — pas une question de plus.
|
||||
|
||||
**C'est la seule exception à « aucune saisie obligatoire » du § 4.2**, et elle reste facultative dans les faits : ignorer l'écran entier est un parcours valide.
|
||||
|
||||
---
|
||||
|
||||
## 2. Le socle juridique — pourquoi une échéance de contrat est une vraie échéance
|
||||
|
||||
Ce n'est pas du confort déguisé : le droit français crée des **fenêtres** que presque personne ne connaît, et une fenêtre manquée coûte une année pleine au tarif non renégocié.
|
||||
|
||||
| Règle | Ce qu'elle ouvre | Source |
|
||||
|---|---|---|
|
||||
| **Résiliation infra-annuelle** (dite loi Hamon) | Auto, habitation, affinitaire : résiliation à tout moment après 1 an de contrat, sans frais | Code des assurances art. L113-15-2 ; [economie.gouv.fr](https://www.economie.gouv.fr/particuliers/gerer-mon-argent/emprunter-et-sassurer/assurance-habitation-auto-complementaire-sante-comment-resilier-son-contrat) |
|
||||
| **Obligation d'information de l'échéance** (dite loi Chatel) | L'assureur doit rappeler la date de résiliation ; à défaut, le contrat devient résiliable | Code des assurances art. L113-15-1 |
|
||||
| **Loi Lemoine** | Assurance emprunteur : résiliation **à tout moment** dès la signature de l'offre de prêt | [Loi n° 2022-270 du 28 février 2022](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000045268729) ; [economie.gouv.fr](https://www.economie.gouv.fr/particuliers/emprunter-et-sassurer/achat-immobilier-pouvez-vous-changer-dassurance-emprunteur) |
|
||||
| **Résiliation « en 3 clics »** | Depuis le 1er juin 2023, tout contrat souscriptible en ligne doit être résiliable en quelques clics — **même s'il a été souscrit en agence ou par téléphone** | [service-public.gouv.fr A16455](https://www.service-public.gouv.fr/particuliers/actualites/A16455) et [A16599](https://www.service-public.gouv.fr/particuliers/actualites/A16599) |
|
||||
| **Complémentaire santé** | Résiliation infra-annuelle également ouverte après 1 an | [economie.gouv.fr](https://www.economie.gouv.fr/particuliers/gerer-mon-argent/emprunter-et-sassurer/assurance-habitation-auto-complementaire-sante-comment-resilier-son-contrat) |
|
||||
| ❓ **Services financiers à distance** | De nouvelles règles de protection s'appliquent **à compter du 19 juin 2026** aux souscriptions à distance de services financiers, assurance comprise | signalé par [economie.gouv.fr](https://www.economie.gouv.fr/particuliers/gerer-mon-argent/emprunter-et-sassurer/assurance-ce-quil-faut-savoir-lorsque-vous-souscrivez-un-contrat) — **à instruire, portée exacte non vérifiée** |
|
||||
|
||||
**L'assurance emprunteur reste le premier gisement** (§ 10.4), pour une raison arithmétique : c'est le seul contrat où l'économie se compte en milliers d'euros sur la durée d'un prêt, et le seul résiliable à tout moment — donc le seul dont l'échéance n'est pas une contrainte mais un prétexte.
|
||||
|
||||
---
|
||||
|
||||
## 3. Ce qui manque
|
||||
|
||||
### 3.1 Le trou ouvert par D-007 · comblé le 28/07/2026 par le rejeu du quiz
|
||||
|
||||
> **Mise à jour du 28/07/2026** : plutôt que de rouvrir la déclaration uniquement dans le récap annuel (la piste envisagée ci-dessous, conservée pour la trace du raisonnement), le porteur de projet a préféré une réponse plus générale — **le quiz reste la référence unique pour mettre à jour les déclarations d'un foyer**. Un abonné peut désormais le rejouer à la demande (`/quiz?rejouer=<id>`, garde-fou d'un rejeu par mois pour éviter tout abus), ses réponses précédentes préremplies et modifiables. Un contrat oublié, ou devenu pertinent après coup, se déclare donc de nouveau à l'écran 7 lors d'un rejeu — le trou de D-007 est comblé sans usage ponctuel du récap annuel. Implémentation : `src/engine/quiz-rejeu.ts`, `POST /api/quiz` (paramètre `householdId`), `GET /api/foyer/[id]/quiz` (préremplissage). Voir l'[audit § 0](audit-2026-07-28.md#0-état-des-correctifs--28072026) pour la réserve sur les assets remplacés (véhicules, animaux, contrats perdent leur état d'échéance à chaque rejeu).
|
||||
|
||||
D-007 a fermé le rattrapage post-quiz **en connaissance de cause**. Le prix est explicite : un foyer qui achète une voiture six mois après son quiz ne pouvait plus déclarer son assurance auto. Jamais — jusqu'au rejeu ci-dessus.
|
||||
|
||||
C'était le bon arbitrage face à une redite perçue. Mais deux choses ont changé depuis :
|
||||
|
||||
1. le hub payant existe et se visite (§ 10.2) ;
|
||||
2. le récap annuel est prévu comme organe de rétention (§ 9.4, § 10.2).
|
||||
|
||||
**Le récap annuel est le bon endroit** pour rouvrir la déclaration : une fois par an, au moment où le foyer regarde déjà son année, sans redite possible puisque douze mois ont passé. Ce n'est ni un opt-in post-quiz ni un formulaire à tenir — c'est une ligne dans un document qu'il ouvre de toute façon.
|
||||
|
||||
**À trancher**, et cela demande une décision de cadrage, pas seulement du code.
|
||||
|
||||
### 3.2 Le rangement dans les piliers · tranché le 28/07/2026
|
||||
|
||||
Cinq fiches sur huit sont classées `pilier: papiers` faute de pilier « foyer » (§ 5.6 : « rattachés au foyer »). Le pilier Papiers devient un fourre-tout dont l'intitulé ne prépare personne à y trouver son forfait mobile.
|
||||
|
||||
Trois sorties étaient possibles :
|
||||
|
||||
- **assumer** et renommer dans la copie du hub (« Papiers & contrats »), sans toucher aux fiches ;
|
||||
- **redistribuer** ce qui a un rattachement naturel : la box internet est un équipement du logement (→ maison), la salle de sport et la mutuelle n'en ont aucun ;
|
||||
- **ne rien faire** et attendre les données d'usage.
|
||||
|
||||
**Décision du porteur de projet (28/07/2026)** : les contrats restent dans le pilier Papiers. Aucune fiche déplacée, aucun changement de copie demandé pour l'instant.
|
||||
|
||||
La première est la moins coûteuse et ne casse aucun identifiant stable (invariant n° 1).
|
||||
|
||||
### 3.3 La fenêtre est un mois, pas une date · limite structurelle
|
||||
|
||||
`computeWindow` traite `echeance_mois` avec `confidence: "connue"` (`src/engine/windows.ts:312`). C'est généreux : le foyer a donné un mois approximatif, pas une date. La conséquence n'est pas cosmétique — `connue` donne 21 jours d'avance au scheduler et, pour un enjeu `legal+assurance`, ouvre le passe-droit hors budget (`horsBudget`, `src/engine/notifications.ts:120`).
|
||||
|
||||
Concrètement, aucune fiche de contrat n'est classée `legal+assurance` aujourd'hui, donc le passe-droit ne se déclenche pas. Mais la marche est là, et elle se franchirait par une simple modification de fiche.
|
||||
|
||||
**À arbitrer** : soit `echeance_mois` produit une fenêtre `estimee` (ce qu'elle est réellement), soit on documente pourquoi `connue` est le bon choix ici. La première réponse semble plus juste — le foyer a lui-même répondu « approximativement ».
|
||||
|
||||
### 3.4 La détection par transfert de PDF · phase 2, inchangé
|
||||
|
||||
Le § 5.6 le pose : « en phase 2, transférer le PDF du contrat = le renseigner ». C'est ce qui rend l'assureur connaissable sans l'avoir demandé, donc ce qui débloque la personnalisation d'affiliation et la lettre de résiliation pré-remplie. Rien à faire avant la bannette.
|
||||
|
||||
---
|
||||
|
||||
## 4. Affiliation — l'état des lieux
|
||||
|
||||
Le § 10.4 fixe trois règles non négociables : uniquement dans les rappels où l'action sert le foyer, toujours affichée (« lien partenaire — c'est ce qui finance la veille »), proportionnelle à la confiance donnée.
|
||||
|
||||
**Aujourd'hui** : les huit fiches portent des `actions` avec des `slot_affiliation` nommés (`assurance_animale`, `chauffagiste`, `veterinaire`…), et ce sont des **liens neutres**, comme prévu « dès le jour 1 ». Aucun contrat commercial n'existe, aucun lien commissionné n'est posé. La bascule est triviale par construction : les slots sont dans les fiches, pas dans le code.
|
||||
|
||||
**Ce qui manque avant de basculer** : la mention obligatoire dans la copie du mail (elle n'est écrite nulle part), et la structure juridique — c'est une décision ouverte du § 17, sans échéance.
|
||||
|
||||
**Un point de vigilance qui n'est écrit nulle part** : le § 5 de l'audit relève que les liens sortants fuiteraient l'URL du foyer en `Referer` faute d'en-tête `Referrer-Policy`. Sur des liens partenaires, une URL-capacité qui fuit vers un tiers commercial n'est pas un détail de configuration.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ce qui reste ouvert
|
||||
|
||||
1. ✅ **Rouvrir la déclaration** (§ 3.1) — fait le 28/07/2026, via le rejeu du quiz plutôt que le récap annuel.
|
||||
2. **Le rangement dans les piliers** (§ 3.2) — tranché le 28/07/2026 : les contrats restent dans le pilier Papiers, aucune fiche déplacée.
|
||||
3. **`echeance_mois` : `connue` ou `estimee` ?** (§ 3.3) — une ligne de code, une vraie question de fond. Toujours ouvert.
|
||||
4. **La mention d'affiliation dans la copy des mails** (§ 4) — à écrire avant toute bascule commissionnée. Toujours ouvert.
|
||||
5. ✅ **`Referrer-Policy` sur les liens d'action** (§ 4) — fait le 28/07/2026 (`next.config.ts`, audit S6).
|
||||
6. ❓ **Les règles du 19 juin 2026 sur les services financiers à distance** (§ 2) — portée à instruire ; elles peuvent créer de nouvelles fenêtres à surveiller.
|
||||
155
docs/pilier-papiers.md
Normal file
155
docs/pilier-papiers.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# Pilier Papiers — la valeur n'est pas la date d'expiration, c'est ce qu'il y a devant
|
||||
|
||||
Document de référence du pilier papiers : ce qu'une composition de foyer permet de déduire, pourquoi la date d'expiration est la partie facile, et dans quel ordre enrichir. Le [cadrage](cadrage-projet-fokan.md) reste la source de vérité produit (§ 5.4) ; ce document le décline sur un pilier.
|
||||
|
||||
**Statut** : rédigé le 28 juillet 2026, à l'occasion de l'[audit complet](audit-2026-07-28.md). Les faits réglementaires cités portent leur source ; les items marqués ❓ n'ont **pas** été testés techniquement.
|
||||
|
||||
---
|
||||
|
||||
## État d'avancement (28 juillet 2026)
|
||||
|
||||
**Phase 0 : livrée.** Douze fiches — sept propres au pilier, cinq contrats rattachés au foyer (cf. § 6).
|
||||
|
||||
| Fiche | Applicabilité | Fenêtre |
|
||||
|---|---|---|
|
||||
| `cni-validite` | adulte, `papiers_anciens ∈ {oui, nsp}` | libre, apprise à l'usage |
|
||||
| `passeport-adulte` | idem | libre |
|
||||
| `permis-conduire-validite` | idem | libre |
|
||||
| `passeport-enfant` | tout enfant | **stratégie `passeport_enfant`** — mars, avant l'été |
|
||||
| `recensement-jdc` | `tranche: 16-17` | une fois |
|
||||
| `declaration-revenus` | le foyer | **stratégie `declaration_revenus`** — mai ou juin selon la zone |
|
||||
| `cesu-attestation-fiscale` | foyer, `cesu: true` | mars-avril |
|
||||
| `assurance-emprunteur-echeance`, `mutuelle-sante-echeance`, `box-internet-echeance`, `forfait-mobile-echeance`, `salle-de-sport-echeance` | contrat du type correspondant | mois déclaré, ou annuelle |
|
||||
|
||||
**Phase 1 (enrichissement) : commencée, une seule inférence.** `zoneDeclarationRevenus` (`src/engine/windows.ts:204`) déduit du département la zone de déclaration (01-19, 20-54, 55 et au-delà), avec le cas Corse traité à part parce que `Number("2A")` vaut `NaN`. C'est la seule personnalisation du pilier — et elle a corrigé un vrai défaut : la fiche annonçait « avril » à tout le monde, c'est-à-dire le mois de l'**ouverture** du service et non celui de la date limite, soit un à deux mois d'avance sur la vraie échéance.
|
||||
|
||||
---
|
||||
|
||||
## 1. La thèse du pilier
|
||||
|
||||
Le cadrage la pose en une ligne (§ 5.4) et elle mérite d'être tenue partout : **ce n'est pas la date d'expiration qui compte, c'est `date − délai d'obtention réel − règle des 6 mois exigée par certains pays`.**
|
||||
|
||||
Une date d'expiration, tout le monde peut la lire sur son propre document. Ce que personne ne sait, c'est :
|
||||
|
||||
- qu'un passeport enfant vaut **5 ans** et non 10 ;
|
||||
- que beaucoup de pays exigent **6 mois de validité restante à l'entrée**, ce qui avance de facto l'expiration de six mois ;
|
||||
- que le délai réel entre le rendez-vous en mairie et le titre en main se compte en **semaines à mois** selon la saison et la commune ;
|
||||
- que la prolongation automatique de 5 ans de certaines CNI (décret 2013-1188) n'est **écrite nulle part sur la carte**, et que plusieurs pays la refusent.
|
||||
|
||||
C'est là qu'est le savoir expert, et c'est là que le produit doit être exact. Trois des quatre points ci-dessus sont déjà dans les fiches.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ce que le quiz demande, et ce qu'il en tire
|
||||
|
||||
Deux écrans (§ 5.4, écrans 5 et 7) :
|
||||
|
||||
> **« Le foyer, c'est : »** adultes 1-6 · enfants par tranches [0-5] [6-11] [12-15] [16-17]
|
||||
> **« Tes papiers d'identité ont plus de 10 ans ? »** [Oui, certains] [Non, refaits récemment] [Aucune idée]
|
||||
> **bonus** : « Tu emploies quelqu'un à domicile ? » → sous-module CESU
|
||||
|
||||
**Une seule question floue dans tout le produit, et elle est assumée.** « Aucune idée » est servi comme « oui » — le seul biais acceptable ici, parce que vérifier une date qu'on connaît déjà coûte dix secondes, tandis que découvrir une CNI périmée à l'aéroport coûte le voyage.
|
||||
|
||||
**Ce qui n'est pas demandé** : les prénoms (aucune inférence), les dates d'expiration (question mémoire — elles s'apprennent à l'usage ou, en phase 2, par photo dont l'image n'est jamais conservée, § 8.2).
|
||||
|
||||
---
|
||||
|
||||
## 3. Le trou principal : les fenêtres libres
|
||||
|
||||
Cinq des sept fiches propres au pilier ont une fenêtre `libre` (`start: null`). Conséquence directe, lue dans le scheduler : `AVANCE_JOURS.libre === null`, donc **`estDu` rend toujours `false`** (`src/engine/notifications.ts:108`). Une fenêtre libre ne déclenche jamais aucun mail.
|
||||
|
||||
C'est le bon comportement — « sans date, il n'y a pas de bon moment, et fabriquer un moment serait une to-do list déguisée » — mais il faut le regarder en face : **aujourd'hui, le pilier papiers ne parle presque jamais.** Il affiche, il ne prévient pas. Seules `passeport-enfant` (mars, par stratégie), `declaration-revenus` (mai/juin) et `cesu-attestation-fiscale` (mars-avril) sont datées.
|
||||
|
||||
Deux sorties, et elles ne s'excluent pas :
|
||||
|
||||
1. **Apprendre la date** — c'est le chemin prévu (§ 4.3 du cadrage : « la donnée se construit par l'usage »). Il suppose un geste qui n'existe pas encore dans le hub : « dis-moi la date d'expiration, je m'occupe du reste ». C'est une saisie, mais unique, facultative, à récompense immédiate et évidente. Elle passe la règle d'or.
|
||||
2. **Caler une vérification saisonnière** — ce que fait déjà `passeport-enfant` : ne pas connaître la date n'empêche pas de dire « regarde tes papiers maintenant, pas en juin ». Applicable à `cni-validite`, `passeport-adulte` et `permis-conduire-validite`, avec la même stratégie.
|
||||
|
||||
**La seconde est immédiate et ne coûte rien.** C'est le correctif le plus rentable du pilier.
|
||||
|
||||
---
|
||||
|
||||
## 4. Les enrichissements identifiés
|
||||
|
||||
### 4.1 Le calendrier scolaire · ✅ source vérifiée, non testée
|
||||
|
||||
Même source et même bénéfice que pour les animaux — voir [pilier-animaux.md § 4.1](pilier-animaux.md). Jeu `fr-en-calendrier-scolaire`, [API calendrier scolaire](https://www.data.gouv.fr/dataservices/api-calendrier-scolaire), zones A/B/C plus l'outre-mer.
|
||||
|
||||
**Ce que ça change ici** : `passeport-enfant` déclenche aujourd'hui en mars, en dur, « avant les vacances d'été ». Avec la zone du foyer, la fenêtre se cale sur ses vraies vacances — et, plus important, elle devient exacte pour les départs hors été (février, Toussaint), qui sont ceux qu'on rate.
|
||||
|
||||
**C'est le même chantier que pour les animaux.** Fait une fois, il sert deux piliers et améliore la saisonnalité de tout le produit.
|
||||
|
||||
### 4.2 Les délais réels de rendez-vous en mairie · ❓ à tester
|
||||
|
||||
L'ANTS publie un [délai de rendez-vous en mairie](https://rendezvouspasseport.ants.gouv.fr/delai) pour les demandes de CNI et de passeport, calculé sur les communes raccordées à sa plateforme (~80 % des communes habilitées, [ANTS](https://passeport.ants.gouv.fr/services/acces-facilite-aux-rendez-vous-en-ligne-en-mairie)).
|
||||
|
||||
C'est exactement la donnée qui manque à la thèse du § 1 : aujourd'hui les fiches disent « 1 à 3 mois selon la saison », une plage nationale. Avec le délai réel de la commune du foyer, `date − délai` devient un calcul et non une formule.
|
||||
|
||||
**❓ Non vérifié** : l'existence d'un point d'accès programmable stable et d'une licence de réutilisation. C'est le premier test à faire, et il est court. Si ce n'est qu'une page web, la piste s'arrête là — on n'ira pas gratter un site de l'État.
|
||||
|
||||
### 4.3 Le calendrier fiscal · ✅ déjà partiellement fait, à finir
|
||||
|
||||
La zone de déclaration est déduite (§ État d'avancement). Ce qui reste :
|
||||
|
||||
- **Les dates exactes de l'année en cours.** Le code cale le **mois** (« mai » ou « juin ») et pas le jour, délibérément : le calendrier est refixé chaque année par la DGFiP, et graver « le 4 juin » vieillirait au premier changement. Correct — mais un jeu de données annuel, ou une simple constante révisée chaque année en git avec la fiche, permettrait de passer la confiance de `estimee` à `connue`, donc de gagner les 21 jours d'avance du scheduler sur une échéance à majoration de 10 %.
|
||||
- **La déclaration papier**, dont la date limite est plus précoce (le 19 mai en 2026, contre le 4 juin pour la zone 3). Aujourd'hui invisible. Elle concerne une population âgée, exactement celle du multiplicateur « foyer des parents » (§ 5.7).
|
||||
- **La modulation du prélèvement à la source** (§ 5.4 du cadrage), pas encore couverte du tout. Échéance dormante réelle : un changement de situation non signalé produit une régularisation brutale l'année suivante. ❓ La règle de déclenchement reste à concevoir — elle suppose de connaître un changement de revenus, ce qu'on ne saura pas. Probablement une fiche calendaire annuelle plutôt qu'une inférence.
|
||||
|
||||
### 4.4 Ce qu'on écarte explicitement
|
||||
|
||||
| Piste | Pourquoi non |
|
||||
|---|---|
|
||||
| Lecture des documents d'identité par API | N'existe pas, et n'existera pas — c'est le rôle de la photo-extraction en phase 3, image jamais conservée (§ 8.2) |
|
||||
| Suivi de dossier ANTS (« où en est mon passeport ? ») | Réactif, pas proactif — et ça suppose un numéro de dossier, donc une saisie récurrente |
|
||||
| Rappels de rendez-vous pris | Sous les 2 semaines de la frontière temporelle (§ 5.1) |
|
||||
| Titres de séjour | **Non écarté — voir § 5.2.** C'est la piste la plus sérieuse du pilier, pas un refus |
|
||||
| Aide juridictionnelle, dossiers sociaux, MDPH | Frontière santé et social (§ 2.3) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Extensions candidates
|
||||
|
||||
### 5.1 Vérification saisonnière des titres d'identité adultes · coût minimal, effet immédiat
|
||||
|
||||
Généraliser la stratégie `passeport_enfant` à `cni-validite`, `passeport-adulte` et `permis-conduire-validite` : une vérification calée avant les vacances, plutôt qu'une fenêtre libre qui ne parle jamais (§ 3). Une ligne de YAML par fiche une fois la stratégie généralisée.
|
||||
|
||||
### 5.2 Le titre de séjour · valeur très haute, à instruire
|
||||
|
||||
Un titre de séjour se renouvelle dans une fenêtre stricte (généralement 2 à 4 mois avant l'expiration selon le titre), les délais de préfecture sont longs et notoirement variables, et **la sanction d'un retard n'est pas une amende, c'est une rupture de droits** — travail, allocations, parfois séjour. C'est probablement l'échéance dormante à la plus forte conséquence individuelle de tout le produit.
|
||||
|
||||
Elle passe la constitution : factuelle, prévisible à bien plus de deux semaines, aucune question mémoire (le titre porte sa date), aucune donnée sensible au sens du RGPD dès lors qu'on ne stocke qu'une date et un type.
|
||||
|
||||
❓ **À instruire avant de s'engager** : la diversité des titres et de leurs règles de renouvellement est grande, et se tromper sur cette échéance-là coûte plus cher que sur n'importe quelle autre. Il faut une source officielle par type de titre avant d'écrire une seule fiche, et probablement se limiter d'abord aux titres pluriannuels les plus répandus.
|
||||
|
||||
### 5.3 Le pilier « Enfance & scolarité » — le seul 5e pilier sérieusement candidat
|
||||
|
||||
Inscriptions (crèche, école, collège, périscolaire), bourses de collège et de lycée, Parcoursup, assurance scolaire. Ces dates sont **nationales, calendaires, à sanction réelle** (une bourse non demandée dans la fenêtre est perdue pour l'année ; un dossier Parcoursup hors délai coûte une année), et **entièrement inférables** depuis les tranches d'âge déjà collectées au quiz.
|
||||
|
||||
Le test universel passe : ça marche parfaitement pour quelqu'un qui a oublié que le service existe. Aucun refus du § 2.3 n'est touché — ce n'est ni de la santé, ni du social, ni du conseil financier, ni du quotidien.
|
||||
|
||||
**Deux réserves, à trancher avant tout travail :**
|
||||
|
||||
1. **Est-ce un pilier ou une extension de Papiers ?** L'asset_type existe déjà (`personne`, `role: enfant`, `tranche`), donc techniquement rien à créer — un pilier est de la configuration (invariant n° 7). Mais un foyer ne cherchera pas « inscription au collège » dans un onglet nommé « Papiers ». C'est un arbitrage de nommage, pas d'architecture, et il touche les quatre piliers gravés du § 5.1 : **le modifier demande une décision de cadrage explicite.**
|
||||
2. **La bourse est à la limite.** Signaler la fenêtre de demande est de la veille ; estimer l'éligibilité serait du conseil. La ligne à tenir est la première.
|
||||
|
||||
---
|
||||
|
||||
## 6. La dérive de rangement des contrats · à arbitrer
|
||||
|
||||
Cinq fiches de contrats (`assurance-emprunteur`, `mutuelle`, `box`, `mobile`, `salle-de-sport`) sont classées `pilier: papiers`. Le § 5.6 du cadrage dit qu'elles sont « rattachées au foyer » — mais il n'existe pas de pilier « foyer », les piliers sont quatre et gravés.
|
||||
|
||||
Le choix est donc pragmatique et défendable. Il fait néanmoins du pilier Papiers un fourre-tout dont l'intitulé ne prépare personne à y trouver son forfait mobile. Deux sorties : assumer et le dire dans la copie du hub (« Papiers & contrats du foyer »), ou déplacer ce qui peut l'être — une box internet est un équipement du logement bien plus qu'un papier.
|
||||
|
||||
Voir [pilier-contrats.md](pilier-contrats.md).
|
||||
|
||||
---
|
||||
|
||||
## 7. Ce qui reste ouvert
|
||||
|
||||
1. **Vérification saisonnière des titres adultes** (§ 5.1) — le meilleur rapport effet/effort du pilier, immédiat.
|
||||
2. **Le calendrier scolaire** (§ 4.1) — partagé avec le pilier animaux, à faire une fois pour les deux.
|
||||
3. **Tester la source de délais ANTS** (§ 4.2) — court, et il décide de la suite.
|
||||
4. **Le titre de séjour** (§ 5.2) — la plus forte valeur individuelle du produit, à instruire sérieusement avant d'écrire quoi que ce soit.
|
||||
5. **Le pilier Enfance & scolarité** (§ 5.3) — demande une décision de cadrage, pas seulement du travail.
|
||||
6. **Le rangement des contrats** (§ 6).
|
||||
7. **Dates fiscales exactes et déclaration papier** (§ 4.3).
|
||||
|
|
@ -12,7 +12,9 @@ Document de référence du pilier véhicules : ce qu'on sait identifier gratuite
|
|||
|
||||
**Crit'Air et ZFE : livrés.** Classement déduit, croisement avec la Base Nationale des ZFE, trois fiches (D-011).
|
||||
|
||||
**Distribution : livrée côté moteur, en cours de sourcing côté donnée** (D-017). La chaîne complète fonctionne — ingestion des codes moteur, identification, règle de silence, fenêtre datée, fiche de prise en charge. Ce qui manque est le référentiel lui-même : seule la famille EB (PureTech) est documentée, et sans intervalle.
|
||||
**Distribution : livrée côté moteur, en cours de sourcing côté donnée** (D-017). La chaîne complète fonctionne — ingestion des codes moteur, identification, règle de silence, fenêtre datée, fiche de prise en charge. Ce qui manque est le référentiel lui-même : 34 codes documentés (les 8 moteurs dominants du parc français + les 26 orthographes de la famille EB), et **aucun intervalle exploitable** — donc aucune date, pour personne. Voir § 7.
|
||||
|
||||
**Un trou de câblage relevé le 28/07/2026** ([audit § 3](audit-2026-07-28.md)) : le champ **P.2** n'est proposé que sur la voie « champ K ». Un foyer passé par la recherche marque/modèle laisse tous les moteurs du modèle en lice, l'unanimité ne tranche jamais, et rien n'est affirmé sur sa distribution — alors que `codesEnLice` sait parfaitement filtrer sur la puissance sans réception. Comme le champ K suppose d'avoir sa carte grise sous la main, c'est probablement la voie majoritaire qui est muette.
|
||||
|
||||
**Entretien courant : abandonné, et c'est une décision, pas un retard.** Voir § 2.
|
||||
|
||||
|
|
@ -263,22 +265,39 @@ L'enrichissement distribution a été placé hors du passage Géorisques après
|
|||
|
||||
### 7.1 Ce qui est en base
|
||||
|
||||
```bash
|
||||
npm run distribution:queue
|
||||
# Référentiel : { moteurs: 26, courroies: 26, humides: 26, aVerifier: 0 }
|
||||
```
|
||||
*Mis à jour le 28 juillet 2026, à l'occasion de l'[audit complet](audit-2026-07-28.md) — la version précédente ne décrivait que la famille EB.*
|
||||
|
||||
26 orthographes de code pour la famille EB, **dérivées de la source** (cylindrée 1 199 cm³ / 3 cylindres, restreintes à la nomenclature PSA) et non tapées à la main :
|
||||
`scripts/seed-distribution.ts` écrit deux ensembles :
|
||||
|
||||
**a. Les huit moteurs les plus répandus du parc français**, sourcés un par un, avec la confiance qui reflète honnêtement la source :
|
||||
|
||||
| Code | Famille | Type | Intervalle | Confiance type / intervalle |
|
||||
|---|---|---|---|---|
|
||||
| `YH01` | 1.5 BlueHDi | courroie **humide** | — | `a_verifier` / `a_verifier` |
|
||||
| `H5F F4` | 1.2 TCe | chaîne | *(sans objet)* | `moyenne` / — |
|
||||
| `H5H E4` | 1.3 TCe | chaîne | *(sans objet)* | `moyenne` / — |
|
||||
| `K9K U8` | 1.5 Blue dCi | courroie | 60-120 mois, 60-160 000 km | `moyenne` / **`a_verifier`** |
|
||||
| `5F01` | 1.6 essence (EP6) | chaîne | *(sans objet)* | `a_verifier` / — |
|
||||
| `9H05`, `9H06` | 1.6 HDi | courroie | — | `a_verifier` / — |
|
||||
| `BH02` | 1.6 BlueHDi | courroie | — | `a_verifier` / — |
|
||||
|
||||
**b. 26 orthographes de code pour la famille EB (PureTech)**, campagne `eb2_puretech`, courroie humide — **dérivées de la source** (cylindrée 1 199 cm³ / 3 cylindres, restreintes à la nomenclature PSA) et non tapées à la main :
|
||||
|
||||
`EB2ADT, EB2ADT (HN05), EB2ADTS, EB2ADTS (HN05), EB2DT, EB2DTM, EB2DTS, EB2F, EB2FA, EB2FA (HM05), EB2F LPG, HM01, HM02, HM03, HM05, HN0, HN01, HN02, HN03, HN04, HN05, HN06, HN07, HN09, HNZ, ZM01`
|
||||
|
||||
Le litre 3 cylindres est un carrefour — Toyota `1KR`, Volkswagen `CHZ`/`DLA`, Opel `B10XE`, Renault `H4D`, Hyundai `G3LC` — d'où le code exact `ZM01` plutôt qu'un préfixe pour le 1.0 PureTech.
|
||||
|
||||
**Trois niveaux de source, et la colonne `confiance` les dit** : `haute` = document constructeur daté et versionné (un seul, le DV5) ; `moyenne` = page officielle du constructeur raisonnant par modèle et se déclarant non exhaustive (renault.fr) ; `a_verifier` = aucune source officielle publique — c'est le cas de **tout le diesel PSA**, ni Peugeot ni Citroën ne publiant courroie-ou-chaîne par code moteur. Aucune valeur ne vient de la presse, d'un forum ou d'un site spécialisé.
|
||||
|
||||
### 7.2 Ce qui manque, et pourquoi c'est assumé
|
||||
|
||||
**Aucun intervalle de remplacement n'est écrit.** Le document officiel ne le donne pas, et les sources secondaires se contredisent (175 000 contre 180 000 km). La contrainte de schéma qui l'aurait exigé a été retirée : « courroie, intervalle non documenté » est déjà une information utile, une borne inventée pour satisfaire un schéma serait le faux intervalle que D-004 proscrit.
|
||||
**Un seul intervalle de remplacement est écrit, et il ne date rien.** Le `K9K U8` porte la fourchette officielle de Renault (« 5 à 10 ans ou 60 000 à 160 000 km selon les modèles ») — mais c'est une fourchette **de marque**, pas une préconisation par moteur, d'où `confiance_intervalle: a_verifier`. Et `courroieWindow` (`src/engine/windows.ts:136`) refuse de dater sur un intervalle `a_verifier` : c'est la règle née d'un vrai faux positif, qui annonçait à un foyer une courroie dépassée depuis deux ans.
|
||||
|
||||
**Conséquence directe** : la machinerie de datation est en place et testée, mais tant qu'aucun intervalle n'est sourcé, la courroie ne se date pour personne.
|
||||
**Conséquence directe, à regarder en face** : `couvertureDistribution().peutNotifier` vaut **zéro**. La machinerie complète — ingestion des codes moteur, identification, règle d'unanimité, fenêtre datée, fiche de prise en charge, scheduler — est en place et testée, et **la courroie ne se date aujourd'hui pour personne**. Le pilier informe, il ne rappelle pas encore.
|
||||
|
||||
C'est le seul travail qui change cela, et il ne demande pas une ligne de code : quelques intervalles officiels bien choisis (§ 7.4).
|
||||
|
||||
Le raisonnement sur l'absence d'intervalle reste entier : la contrainte de schéma qui l'aurait exigé a été retirée volontairement. « Courroie, intervalle non documenté » est déjà une information utile — elle déclenche la fiche de courroie humide et elle empêche de dire « chaîne » à tort. Une borne inventée pour satisfaire un schéma serait le faux intervalle que D-004 proscrit.
|
||||
|
||||
### 7.3 La méthode : sourcer à la demande, dans le bon ordre
|
||||
|
||||
|
|
@ -344,8 +363,12 @@ Pour chacun : type de distribution, borne en mois, borne en km, source citable,
|
|||
|
||||
## 8. Ce qui reste ouvert
|
||||
|
||||
1. **Sourcer les intervalles** (§ 7.4) — le seul travail qui débloque la fenêtre datée.
|
||||
2. **Demander la date de 1re immatriculation** (champ B) — un champ, et le CT devient exact tandis que la courroie s'ancre sur une vraie date.
|
||||
3. **Implémenter `comportement_fait`** (§ 4.4) — sujet transverse à tout le produit, pas au seul pilier véhicules.
|
||||
4. **`carte-grise-changement-adresse` est une condition morte** : rien ne pose jamais `demenagement_recent` sur un asset véhicule. Constaté lors de la validation du pilier maison, toujours vrai.
|
||||
5. **Ford EcoBoost / EcoBlue** — la courroie humide n'est pas que chez Stellantis, et rien n'est encore sourcé côté Ford.
|
||||
1. **Sourcer les intervalles** (§ 7.4) — le seul travail qui débloque la fenêtre datée. Aujourd'hui `peutNotifier = 0` : le pilier informe, il ne rappelle pas.
|
||||
2. **Ouvrir le champ P.2 à la voie déclarative** ([audit § 3](audit-2026-07-28.md)) — trois pas proposés, et **une mesure préalable** : combien de couples marque/modèle/énergie/P.2/année tombent sur un moteur unique ? Si le gain n'est pas là, la question ne doit pas être posée. C'est la même mesure que celle de D-017, appliquée à l'autre voie.
|
||||
3. **Demander la date de 1re immatriculation** (champ B) — un champ, et le CT devient exact tandis que la courroie s'ancre sur une vraie date au lieu du 1er juillet de l'année déclarée.
|
||||
4. **Filtrer les moteurs en lice par l'année** — `codesEnLice` ne borne jamais sur `dateReception`. Sur un modèle vendu quinze ans, c'est ce qui sépare « douze moteurs candidats » de « trois ».
|
||||
5. **Implémenter `comportement_fait`** (§ 4.4) — sujet transverse à tout le produit, pas au seul pilier véhicules.
|
||||
6. **`carte-grise-changement-adresse` est une condition morte** : rien ne pose jamais `demenagement_recent` sur un asset véhicule. Constaté lors de la validation du pilier maison, confirmé par recoupement automatique à l'audit — c'est le **seul** attribut mort sur les 29 du DSL. Il se débloque en même temps que `icad-coordonnees` côté animaux, le jour où l'adresse devient modifiable dans le hub. **Report confirmé le 28/07/2026** : traité plus tard, à dessein — pas un oubli.
|
||||
7. **Ford EcoBoost / EcoBlue** — la courroie humide n'est pas que chez Stellantis, et rien n'est encore sourcé côté Ford.
|
||||
8. **La vignette Crit'Air est une réponse globale au quiz**, appliquée à tous les véhicules du foyer. Un foyer à deux voitures dont une seule a sa vignette ne peut pas le dire.
|
||||
9. **Deux-roues et remorques** — annoncés au § 5.3 du cadrage comme option du quiz, jamais implémentés. Le contrôle technique moto est une échéance légale récente, dormante par excellence ; ❓ la couverture du référentiel RDW pour la catégorie L reste à vérifier (le code ne filtre aujourd'hui que sur M1).
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { defineConfig } from "drizzle-kit";
|
|||
|
||||
export default defineConfig({
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: { url: process.env.DATABASE_URL! },
|
||||
});
|
||||
|
|
|
|||
385
drizzle/0000_left_shen.sql
Normal file
385
drizzle/0000_left_shen.sql
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
CREATE TABLE "account" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"account_id" text NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"access_token" text,
|
||||
"refresh_token" text,
|
||||
"id_token" text,
|
||||
"access_token_expires_at" timestamp,
|
||||
"refresh_token_expires_at" timestamp,
|
||||
"scope" text,
|
||||
"password" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "arretes_commune" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"code_insee" text NOT NULL,
|
||||
"code_national" text NOT NULL,
|
||||
"libelle_risque" text NOT NULL,
|
||||
"date_debut_evt" text,
|
||||
"date_fin_evt" text,
|
||||
"date_publication_jo" text NOT NULL,
|
||||
"synced_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "assets" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"asset_type" text NOT NULL,
|
||||
"label" text NOT NULL,
|
||||
"attributes" jsonb NOT NULL,
|
||||
"provenance" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "deadlines" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"template_id" text NOT NULL,
|
||||
"template_version" integer NOT NULL,
|
||||
"asset_id" uuid NOT NULL,
|
||||
"sujet" text DEFAULT '' NOT NULL,
|
||||
"sujet_label" text,
|
||||
"fenetre_start" timestamp,
|
||||
"fenetre_label" text NOT NULL,
|
||||
"confiance" text NOT NULL,
|
||||
"responsable" text,
|
||||
"muted" boolean DEFAULT false NOT NULL,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid,
|
||||
"name" text NOT NULL,
|
||||
"props" jsonb,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "households" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"quiz_answers" jsonb NOT NULL,
|
||||
"subscription_status" text DEFAULT 'free' NOT NULL,
|
||||
"subscription_period_end" timestamp,
|
||||
"subscription_payer_id" text,
|
||||
"stripe_customer_id" text,
|
||||
"stripe_subscription_id" text,
|
||||
"retractation_waived_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "invitations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"invited_by" text,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"used_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "invitations_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "leads" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid,
|
||||
"email" text NOT NULL,
|
||||
"source" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "memberships" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "moteurs_distribution" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"motorcode_norm" text NOT NULL,
|
||||
"motorcode" text NOT NULL,
|
||||
"famille" text,
|
||||
"type" text NOT NULL,
|
||||
"remplacement_mois" integer,
|
||||
"remplacement_km" integer,
|
||||
"remplacement_mois_max" integer,
|
||||
"remplacement_km_max" integer,
|
||||
"campagne" text,
|
||||
"humide" boolean DEFAULT false NOT NULL,
|
||||
"source" text NOT NULL,
|
||||
"confiance_type" text DEFAULT 'a_verifier' NOT NULL,
|
||||
"confiance_intervalle" text DEFAULT 'a_verifier' NOT NULL,
|
||||
"revu_par" text,
|
||||
"revu_le" timestamp,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notifications_log" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"occurrence_id" uuid NOT NULL,
|
||||
"deadline_id" uuid NOT NULL,
|
||||
"canal" text DEFAULT 'mail' NOT NULL,
|
||||
"envoi_id" uuid NOT NULL,
|
||||
"fenetre_notifiee" timestamp,
|
||||
"statut" text NOT NULL,
|
||||
"hors_budget" boolean DEFAULT false NOT NULL,
|
||||
"message_id" text,
|
||||
"erreur" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "occurrences" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"deadline_id" uuid NOT NULL,
|
||||
"fenetre_due" timestamp,
|
||||
"statut" text NOT NULL,
|
||||
"source_date" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "parc_immatricule" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"reception_numero" text NOT NULL,
|
||||
"variante" text NOT NULL,
|
||||
"version" text NOT NULL,
|
||||
"vehicules" integer DEFAULT 0 NOT NULL,
|
||||
"synced_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "rappels" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"reference" text NOT NULL,
|
||||
"date_publication" text,
|
||||
"producteur" text,
|
||||
"categorie" text,
|
||||
"gravite" text,
|
||||
"dangers" jsonb,
|
||||
"defaut_source" text,
|
||||
"reparation_source" text,
|
||||
"nb_vehicules" integer,
|
||||
"info_url" text,
|
||||
"info_tel" text,
|
||||
"synced_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "rappels_modeles" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"reference" text NOT NULL,
|
||||
"marque" text NOT NULL,
|
||||
"modele" text NOT NULL,
|
||||
"marque_norm" text NOT NULL,
|
||||
"modele_norm" text NOT NULL,
|
||||
"synced_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "session" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"user_id" text NOT NULL,
|
||||
CONSTRAINT "session_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"email_verified" boolean DEFAULT false NOT NULL,
|
||||
"image" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "user_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "vehicules_modeles" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"marque" text NOT NULL,
|
||||
"modele" text NOT NULL,
|
||||
"marque_norm" text NOT NULL,
|
||||
"modele_norm" text NOT NULL,
|
||||
"energies" jsonb NOT NULL,
|
||||
"configurations" integer DEFAULT 0 NOT NULL,
|
||||
"annee_min" integer,
|
||||
"annee_max" integer,
|
||||
"synced_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "vehicules_motorisations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"reception_numero" text NOT NULL,
|
||||
"variante" text NOT NULL,
|
||||
"version" text NOT NULL,
|
||||
"revision" integer DEFAULT 0 NOT NULL,
|
||||
"seq_motorisation" integer DEFAULT 1 NOT NULL,
|
||||
"motorcode" text,
|
||||
"motorcode_norm" text,
|
||||
"reception_moteur" text,
|
||||
"cylindree" integer,
|
||||
"nb_cylindres" integer,
|
||||
"electrique" text,
|
||||
"synced_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "vehicules_reference" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"reception_numero" text NOT NULL,
|
||||
"variante" text NOT NULL,
|
||||
"version" text NOT NULL,
|
||||
"revision" integer DEFAULT 0 NOT NULL,
|
||||
"reception_pays" text,
|
||||
"date_reception" text,
|
||||
"marque_code" text,
|
||||
"marque" text,
|
||||
"modele" text,
|
||||
"type_fabricant" text,
|
||||
"categorie" text,
|
||||
"nb_roues" integer,
|
||||
"conduite" text,
|
||||
"nb_portes_min" integer,
|
||||
"nb_portes_max" integer,
|
||||
"nb_places_min" integer,
|
||||
"nb_places_max" integer,
|
||||
"energie_code" text,
|
||||
"energies_codes" jsonb,
|
||||
"energie" text,
|
||||
"seq_motorisation" integer,
|
||||
"seq_energie" integer,
|
||||
"puissance_kw_min" text,
|
||||
"puissance_kw_max" text,
|
||||
"regime_puissance_min" integer,
|
||||
"regime_puissance_max" integer,
|
||||
"max_biocarburant" text,
|
||||
"puissance_elec_min" text,
|
||||
"puissance_elec_max" text,
|
||||
"autonomie_elec_km" integer,
|
||||
"conso_elec" text,
|
||||
"norme_emission" text,
|
||||
"reglement_emission" text,
|
||||
"conso_nedc_min" text,
|
||||
"conso_nedc_max" text,
|
||||
"co2_nedc_min" text,
|
||||
"co2_nedc_max" text,
|
||||
"co2_urbain_min" text,
|
||||
"co2_urbain_max" text,
|
||||
"co2_route_min" text,
|
||||
"co2_route_max" text,
|
||||
"conso_wltp_min" text,
|
||||
"conso_wltp_max" text,
|
||||
"co2_wltp_min" text,
|
||||
"co2_wltp_max" text,
|
||||
"em_co" text,
|
||||
"em_hc" text,
|
||||
"em_nox" text,
|
||||
"em_hc_nox" text,
|
||||
"em_particules" text,
|
||||
"em_nb_particules" text,
|
||||
"em_co_wltp" text,
|
||||
"em_nox_wltp" text,
|
||||
"em_particules_wltp" text,
|
||||
"em_nb_particules_wltp" text,
|
||||
"niveau_sonore_min" text,
|
||||
"niveau_sonore_max" text,
|
||||
"niveau_sonore_roulant" text,
|
||||
"regime_sonore_min" integer,
|
||||
"regime_sonore_max" integer,
|
||||
"longueur_min" integer,
|
||||
"longueur_max" integer,
|
||||
"largeur_min" integer,
|
||||
"largeur_max" integer,
|
||||
"hauteur_min" integer,
|
||||
"hauteur_max" integer,
|
||||
"empattement_min" integer,
|
||||
"empattement_max" integer,
|
||||
"masse_ordre_marche_min" integer,
|
||||
"masse_ordre_marche_max" integer,
|
||||
"masse_max_min" integer,
|
||||
"masse_max_max" integer,
|
||||
"vitesse_max_min" text,
|
||||
"vitesse_max_max" text,
|
||||
"vitesse_constr_min" text,
|
||||
"vitesse_constr_max" text,
|
||||
"resistance_f0_min" text,
|
||||
"resistance_f0_max" text,
|
||||
"resistance_f1_min" text,
|
||||
"resistance_f1_max" text,
|
||||
"resistance_f2_min" text,
|
||||
"resistance_f2_max" text,
|
||||
"brut" jsonb,
|
||||
"synced_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "verification" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"identifier" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "zfe_zones" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"zfe_id" text NOT NULL,
|
||||
"nom" text,
|
||||
"vp_critair" text,
|
||||
"vp_horaires" text,
|
||||
"date_debut" text,
|
||||
"date_fin" text,
|
||||
"url_arrete" text,
|
||||
"url_info" text,
|
||||
"centre_lat" text,
|
||||
"centre_lon" text,
|
||||
"rayon_km" text,
|
||||
"contour" jsonb,
|
||||
"synced_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "assets" ADD CONSTRAINT "assets_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "deadlines" ADD CONSTRAINT "deadlines_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "deadlines" ADD CONSTRAINT "deadlines_asset_id_assets_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."assets"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "households" ADD CONSTRAINT "households_subscription_payer_id_user_id_fk" FOREIGN KEY ("subscription_payer_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_invited_by_user_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD CONSTRAINT "leads_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "memberships" ADD CONSTRAINT "memberships_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "memberships" ADD CONSTRAINT "memberships_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notifications_log" ADD CONSTRAINT "notifications_log_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notifications_log" ADD CONSTRAINT "notifications_log_occurrence_id_occurrences_id_fk" FOREIGN KEY ("occurrence_id") REFERENCES "public"."occurrences"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notifications_log" ADD CONSTRAINT "notifications_log_deadline_id_deadlines_id_fk" FOREIGN KEY ("deadline_id") REFERENCES "public"."deadlines"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "occurrences" ADD CONSTRAINT "occurrences_deadline_id_deadlines_id_fk" FOREIGN KEY ("deadline_id") REFERENCES "public"."deadlines"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "arretes_commune_cle_idx" ON "arretes_commune" USING btree ("code_insee","code_national");--> statement-breakpoint
|
||||
CREATE INDEX "arretes_commune_jo_idx" ON "arretes_commune" USING btree ("date_publication_jo");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "moteurs_distribution_cle_idx" ON "moteurs_distribution" USING btree ("motorcode_norm");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "notifications_log_cle_idx" ON "notifications_log" USING btree ("occurrence_id","canal","fenetre_notifiee");--> statement-breakpoint
|
||||
CREATE INDEX "notifications_log_foyer_idx" ON "notifications_log" USING btree ("household_id","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "parc_immatricule_cle_idx" ON "parc_immatricule" USING btree ("reception_numero","variante","version");--> statement-breakpoint
|
||||
CREATE INDEX "parc_immatricule_reception_idx" ON "parc_immatricule" USING btree ("reception_numero");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "rappels_reference_idx" ON "rappels" USING btree ("reference");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "rappels_modeles_cle_idx" ON "rappels_modeles" USING btree ("reference","marque","modele");--> statement-breakpoint
|
||||
CREATE INDEX "rappels_modeles_vehicule_idx" ON "rappels_modeles" USING btree ("marque_norm","modele_norm");--> statement-breakpoint
|
||||
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "vehicules_modeles_cle_idx" ON "vehicules_modeles" USING btree ("marque","modele");--> statement-breakpoint
|
||||
CREATE INDEX "vehicules_modeles_modele_idx" ON "vehicules_modeles" USING btree ("modele");--> statement-breakpoint
|
||||
CREATE INDEX "vehicules_modeles_norm_idx" ON "vehicules_modeles" USING btree ("marque_norm","modele_norm");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "vehicules_motorisations_cle_idx" ON "vehicules_motorisations" USING btree ("reception_numero","variante","version","revision","seq_motorisation");--> statement-breakpoint
|
||||
CREATE INDEX "vehicules_motorisations_reception_idx" ON "vehicules_motorisations" USING btree ("reception_numero");--> statement-breakpoint
|
||||
CREATE INDEX "vehicules_motorisations_code_idx" ON "vehicules_motorisations" USING btree ("motorcode_norm");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "vehicules_reference_cle_idx" ON "vehicules_reference" USING btree ("reception_numero","variante","version","revision");--> statement-breakpoint
|
||||
CREATE INDEX "vehicules_reference_reception_idx" ON "vehicules_reference" USING btree ("reception_numero");--> statement-breakpoint
|
||||
CREATE INDEX "vehicules_reference_modele_idx" ON "vehicules_reference" USING btree ("marque","modele");--> statement-breakpoint
|
||||
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "zfe_zones_zfe_id_idx" ON "zfe_zones" USING btree ("zfe_id");
|
||||
4
drizzle/0001_overconfident_baron_strucker.sql
Normal file
4
drizzle/0001_overconfident_baron_strucker.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
ALTER TABLE "households" ADD COLUMN "stripe_checkout_session_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "households" ADD COLUMN "ics_token" text;--> statement-breakpoint
|
||||
ALTER TABLE "households" ADD COLUMN "quiz_rejoue_le" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "households" ADD CONSTRAINT "households_ics_token_unique" UNIQUE("ics_token");
|
||||
2732
drizzle/meta/0000_snapshot.json
Normal file
2732
drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2758
drizzle/meta/0001_snapshot.json
Normal file
2758
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
20
drizzle/meta/_journal.json
Normal file
20
drizzle/meta/_journal.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1785229940172,
|
||||
"tag": "0000_left_shen",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1785239419014,
|
||||
"tag": "0001_overconfident_baron_strucker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
21
eslint.config.mjs
Normal file
21
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const compat = new FlatCompat({ baseDirectory: __dirname });
|
||||
|
||||
/**
|
||||
* Config ESLint (audit E5, 28/07/2026) — absente jusqu'ici, alors que le § 27 du cadrage la cite
|
||||
* dans la CI. `eslint-config-next` pointé sur `15.5.21`, la version exacte de `next` installée.
|
||||
*/
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
// `next-env.d.ts` est régénéré par Next.js à chaque build — jamais à éditer ni à lint.
|
||||
ignores: [".next/**", "drizzle/**", "node_modules/**", "next-env.d.ts"],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
|
|
@ -3,6 +3,26 @@ import type { NextConfig } from "next";
|
|||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["pg", "yaml", "pg-boss"],
|
||||
/**
|
||||
* En-têtes de sécurité (audit S6, 28/07/2026) — absents jusqu'ici. Deux conséquences concrètes
|
||||
* relevées : le hub encadrable dans une iframe tierce, et l'URL du foyer (une capacité) partant
|
||||
* en `Referer` vers chaque lien d'action sortant — c'est-à-dire vers les futurs partenaires
|
||||
* d'affiliation (§ 10.4). `rel="noreferrer"` sur ces liens (déjà en place ou ajouté au cas par
|
||||
* cas) complète cette politique, il ne la remplace pas : elle doit tenir même sur un lien oublié.
|
||||
*/
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
4638
package-lock.json
generated
4638
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,12 +7,17 @@
|
|||
"build": "next build",
|
||||
"start": "next start -H 0.0.0.0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx scripts/migrate.ts",
|
||||
"db:baseline": "tsx scripts/baseline-migrations.ts",
|
||||
"knowledge:check": "tsx scripts/validate-knowledge.ts",
|
||||
"reconcile:all": "tsx scripts/reconcile-all.ts",
|
||||
"vehicules:sync": "tsx scripts/sync-vehicules-rdw.ts",
|
||||
"distribution:seed": "tsx scripts/seed-distribution.ts",
|
||||
"distribution:queue": "tsx scripts/distribution-a-sourcer.ts",
|
||||
"rappels:simuler": "tsx scripts/simuler-rappel.ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -33,6 +38,7 @@
|
|||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.6",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
|
|
@ -40,6 +46,8 @@
|
|||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"drizzle-kit": "^0.31.0",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^15.5.21",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
|
|
|
|||
56
scripts/baseline-migrations.ts
Normal file
56
scripts/baseline-migrations.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { readMigrationFiles } from "drizzle-orm/migrator";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { Pool } from "pg";
|
||||
|
||||
/**
|
||||
* Baselining à usage unique (audit E2, 28/07/2026) : la base vivante a été créée par
|
||||
* `drizzle-kit push`, jamais par une migration versionnée. La migration `0000` générée décrit
|
||||
* donc un schéma qui EXISTE DÉJÀ — la rejouer casserait sur les `CREATE TABLE` existants.
|
||||
*
|
||||
* On marque cette migration comme appliquée (même hash, même horodatage que `readMigrationFiles`
|
||||
* calculerait au moment de `migrate()`), sans exécuter son SQL. Toute migration suivante,
|
||||
* générée après une vraie évolution du schéma, sera jouée normalement par `npm run db:migrate`.
|
||||
*
|
||||
* Ne s'exécute qu'une fois, à la main, jamais au démarrage du conteneur.
|
||||
*/
|
||||
async function main() {
|
||||
const migrations = readMigrationFiles({ migrationsFolder: "./drizzle" });
|
||||
if (migrations.length !== 1) {
|
||||
throw new Error(
|
||||
`Attendu exactement 1 migration à baseliner (la 0000 initiale), trouvé ${migrations.length}. ` +
|
||||
"Ce script n'est prévu que pour l'amorçage — pas pour rattraper des migrations ultérieures.",
|
||||
);
|
||||
}
|
||||
const [migration] = migrations;
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const db = drizzle(pool);
|
||||
|
||||
await db.execute(sql`CREATE SCHEMA IF NOT EXISTS drizzle`);
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at bigint
|
||||
)
|
||||
`);
|
||||
const existing = await db.execute(
|
||||
sql`SELECT id FROM drizzle.__drizzle_migrations WHERE hash = ${migration.hash}`,
|
||||
);
|
||||
if (existing.rows.length) {
|
||||
console.log("[baseline] déjà marquée — rien à faire");
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
await db.execute(
|
||||
sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (${migration.hash}, ${migration.folderMillis})`,
|
||||
);
|
||||
console.log("[baseline] migration 0000 marquée comme appliquée, sans exécution de son SQL");
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("[baseline] échec :", e);
|
||||
process.exit(1);
|
||||
});
|
||||
26
scripts/migrate.ts
Normal file
26
scripts/migrate.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import { Pool } from "pg";
|
||||
|
||||
/**
|
||||
* Joue les migrations versionnées (audit E2, 28/07/2026) — remplace `drizzle-kit push`,
|
||||
* qui compare le schéma au vivant et peut supprimer une colonne sans avertissement.
|
||||
*
|
||||
* Appelé au démarrage du conteneur (Dockerfile), avant `node server.js` : § 27 du cadrage
|
||||
* prévoit « migrations Drizzle jouées au déploiement ». Idempotent — un redémarrage sans
|
||||
* migration nouvelle ne fait rien.
|
||||
*
|
||||
* Volontairement sans l'alias `@/db` : ce script tourne dans l'image finale via
|
||||
* `node --experimental-strip-types`, hors du bundler Next.js qui résout cet alias.
|
||||
*/
|
||||
async function main() {
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
await migrate(drizzle(pool), { migrationsFolder: "./drizzle" });
|
||||
await pool.end();
|
||||
console.log("[migrate] migrations à jour");
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("[migrate] échec :", e);
|
||||
process.exit(1);
|
||||
});
|
||||
24
scripts/simuler-rappel.ts
Normal file
24
scripts/simuler-rappel.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { simulerRappelFoyer } from "@/jobs/notifications";
|
||||
|
||||
/**
|
||||
* Mise au point du scheduler de notifications (§ 20.5) : ce qui partirait AUJOURD'HUI pour un
|
||||
* foyer, sans rien envoyer ni journaliser. Corrige le code mort relevé à l'audit (§ 4) —
|
||||
* `simulerRappelFoyer` n'avait jusqu'ici aucun appelant.
|
||||
*
|
||||
* Usage : npm run rappels:simuler -- <householdId>
|
||||
*/
|
||||
async function main() {
|
||||
const householdId = process.argv[2];
|
||||
if (!householdId) {
|
||||
console.error("Usage : npm run rappels:simuler -- <householdId>");
|
||||
process.exit(1);
|
||||
}
|
||||
const resultat = await simulerRappelFoyer(householdId);
|
||||
console.log(JSON.stringify(resultat, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("[simuler-rappel] échec :", e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { searchAdresse, suggestLogement } from "@/adapters/adresse";
|
||||
import { autoriser, ipDe, PLAFONDS } from "@/lib/rate-limit";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// Protège la BAN et l'ADEME, des API d'État sans quota documenté (audit S1).
|
||||
if (!autoriser(`adresse:${ipDe(req)}`, PLAFONDS.adresse.limite, PLAFONDS.adresse.fenetreMs)) {
|
||||
return NextResponse.json({ error: "trop de requêtes, réessaie dans un instant" }, { status: 429 });
|
||||
}
|
||||
const q = req.nextUrl.searchParams.get("q") ?? "";
|
||||
if (q.length < 5) return NextResponse.json({ suggestions: [] });
|
||||
const withLogement = req.nextUrl.searchParams.get("logement") === "1";
|
||||
|
|
|
|||
|
|
@ -39,12 +39,16 @@ export async function POST(req: NextRequest) {
|
|||
const [household] = await database.select({ id: householdsTable.id }).from(householdsTable).where(eq(householdsTable.id, householdId));
|
||||
if (!household) return NextResponse.json({ error: "foyer introuvable" }, { status: 404 });
|
||||
|
||||
// Consentement horodaté avant toute redirection vers Stripe — pas seulement dans les intentions.
|
||||
await database.update(householdsTable).set({ retractationWaivedAt: new Date() }).where(eq(householdsTable.id, householdId));
|
||||
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.g0tch.myds.me";
|
||||
|
||||
/**
|
||||
* Le consentement N'EST PLUS horodaté ici (audit S5) : cette route n'est pas authentifiée, et
|
||||
* un tiers connaissant seulement l'UUID du foyer pouvait faire enregistrer une renonciation qui
|
||||
* n'était pas la sienne, y compris sans jamais payer. Refuser sans la case reste la bonne garde
|
||||
* (ci-dessus), mais la PREUVE — l'horodatage et l'identifiant de session Stripe — n'est écrite
|
||||
* que dans le webhook `checkout.session.completed`, au moment où le paiement est confirmé.
|
||||
*/
|
||||
const checkoutSession = await client.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
line_items: [{ price: process.env.STRIPE_PRICE_ANNUAL, quantity: 1 }],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, events } from "@/db";
|
||||
import { autoriser, ipDe, PLAFONDS } from "@/lib/rate-limit";
|
||||
|
||||
const Body = z.object({
|
||||
name: z.string().max(50),
|
||||
|
|
@ -9,12 +10,19 @@ const Body = z.object({
|
|||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Insertion libre d'un JSON, householdId arbitraire (audit S1) — un plafond large, cet
|
||||
// endpoint est appelé à chaque écran de quiz.
|
||||
if (!autoriser(`event:${ipDe(req)}`, PLAFONDS.event.limite, PLAFONDS.event.fenetreMs)) {
|
||||
return NextResponse.json({ ok: false }, { status: 429 });
|
||||
}
|
||||
const parsed = Body.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ ok: false }, { status: 400 });
|
||||
try {
|
||||
await db().insert(events).values(parsed.data);
|
||||
} catch {
|
||||
// Le funnel ne doit jamais casser le parcours.
|
||||
} catch (e) {
|
||||
// Le funnel ne doit jamais casser le parcours (catch conservé) — mais un échec silencieux
|
||||
// faisait mesurer zéro à l'étoile polaire (§ 13.1) sans que personne ne le sache (audit F9).
|
||||
console.error("[event] insertion impossible :", String(e));
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
|
|
|||
45
src/app/api/foyer/[id]/ics/route.ts
Normal file
45
src/app/api/foyer/[id]/ics/route.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { db, households as householdsTable, memberships as membershipsTable } from "@/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getSubscription } from "@/lib/subscription";
|
||||
|
||||
/**
|
||||
* Génère (à la demande, une seule fois) ou renvoie le jeton du flux .ics d'un foyer (audit S4).
|
||||
* Réservé aux membres d'un foyer abonné — jamais accessible par le seul UUID du foyer.
|
||||
*/
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return NextResponse.json({ error: "connexion requise" }, { status: 401 });
|
||||
|
||||
const database = db();
|
||||
const [membership] = await database
|
||||
.select({ id: membershipsTable.id })
|
||||
.from(membershipsTable)
|
||||
.where(and(eq(membershipsTable.householdId, id), eq(membershipsTable.userId, session.user.id)));
|
||||
if (!membership) return NextResponse.json({ error: "pas membre de ce foyer" }, { status: 403 });
|
||||
|
||||
const subscription = await getSubscription(id);
|
||||
if (!subscription.isActive) {
|
||||
return NextResponse.json({ error: "réservé aux foyers abonnés" }, { status: 403 });
|
||||
}
|
||||
|
||||
const [household] = await database
|
||||
.select({ icsToken: householdsTable.icsToken })
|
||||
.from(householdsTable)
|
||||
.where(eq(householdsTable.id, id));
|
||||
if (!household) return NextResponse.json({ error: "foyer introuvable" }, { status: 404 });
|
||||
|
||||
let token = household.icsToken;
|
||||
if (!token) {
|
||||
token = randomBytes(24).toString("hex");
|
||||
await database.update(householdsTable).set({ icsToken: token }).where(eq(householdsTable.id, id));
|
||||
}
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.fr";
|
||||
return NextResponse.json({ url: `${base}/calendrier/${token}` });
|
||||
}
|
||||
43
src/app/api/foyer/[id]/quiz/route.ts
Normal file
43
src/app/api/foyer/[id]/quiz/route.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { db, households as householdsTable, memberships as membershipsTable } from "@/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getSubscription } from "@/lib/subscription";
|
||||
import { peutRejouerQuiz, prochainRejouePossible } from "@/engine/quiz-rejeu";
|
||||
|
||||
/**
|
||||
* Réponses précédentes d'un foyer, pour préremplir un rejeu du quiz (audit F8) — réservé aux
|
||||
* membres d'un foyer abonné, jamais accessible par le seul UUID du foyer (même porte que S3/S4).
|
||||
*/
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return NextResponse.json({ error: "connexion requise" }, { status: 401 });
|
||||
|
||||
const database = db();
|
||||
const [membership] = await database
|
||||
.select({ id: membershipsTable.id })
|
||||
.from(membershipsTable)
|
||||
.where(and(eq(membershipsTable.householdId, id), eq(membershipsTable.userId, session.user.id)));
|
||||
if (!membership) return NextResponse.json({ error: "pas membre de ce foyer" }, { status: 403 });
|
||||
|
||||
const subscription = await getSubscription(id);
|
||||
if (!subscription.isActive) {
|
||||
return NextResponse.json({ error: "réservé aux foyers abonnés" }, { status: 403 });
|
||||
}
|
||||
|
||||
const [household] = await database
|
||||
.select({ quizAnswers: householdsTable.quizAnswers, quizRejoueLe: householdsTable.quizRejoueLe })
|
||||
.from(householdsTable)
|
||||
.where(eq(householdsTable.id, id));
|
||||
if (!household) return NextResponse.json({ error: "foyer introuvable" }, { status: 404 });
|
||||
|
||||
const quizRejoueLe = household.quizRejoueLe ? new Date(household.quizRejoueLe) : null;
|
||||
return NextResponse.json({
|
||||
quizAnswers: household.quizAnswers,
|
||||
peutRejouer: peutRejouerQuiz(quizRejoueLe),
|
||||
prochainRejouePossible: prochainRejouePossible(quizRejoueLe)?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
18
src/app/api/health/route.ts
Normal file
18
src/app/api/health/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Vérification de vie pour le `healthcheck` Docker (audit E4, 28/07/2026) — la base a le sien
|
||||
* (`pg_isready`), l'app n'en avait aucun. Une requête triviale suffit : elle confirme que le
|
||||
* process répond ET que le pool Postgres est joignable, sans rien coûter au foyer qui passe.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await db().execute(sql`select 1`);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error("[health] échec :", String(e));
|
||||
return NextResponse.json({ ok: false }, { status: 503 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, leads } from "@/db";
|
||||
import { sendMail } from "@/adapters/mail";
|
||||
import { renderGarderLienEmail } from "@/emails/garder-lien";
|
||||
import { buildHub } from "@/engine/hub";
|
||||
import { autoriser, ipDe, PLAFONDS } from "@/lib/rate-limit";
|
||||
|
||||
const Body = z.object({
|
||||
email: z.string().email(),
|
||||
|
|
@ -8,13 +12,32 @@ const Body = z.object({
|
|||
source: z.string().default("activer_veille"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Dépôt d'une adresse mail « d'intérêt » (§ 10.1) — et, quand un foyer est fourni, envoi
|
||||
* effectif du lien (corrige F1 de l'audit du 28/07/2026 : la promesse de garder-lien.tsx
|
||||
* n'était jamais honorée). Dégradation gracieuse habituelle de `sendMail` : sans SMTP configuré,
|
||||
* l'envoi est journalisé, jamais bloquant — le dépôt en base reste le geste qui compte.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
// Insertion libre d'une adresse mail, et désormais un envoi réel derrière (audit S1).
|
||||
if (!autoriser(`interet:${ipDe(req)}`, PLAFONDS.interet.limite, PLAFONDS.interet.fenetreMs)) {
|
||||
return NextResponse.json({ error: "trop de tentatives, réessaie plus tard" }, { status: 429 });
|
||||
}
|
||||
const parsed = Body.safeParse(await req.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: "email invalide" }, { status: 400 });
|
||||
await db().insert(leads).values({
|
||||
email: parsed.data.email,
|
||||
householdId: parsed.data.householdId,
|
||||
source: parsed.data.source,
|
||||
});
|
||||
const { email, householdId, source } = parsed.data;
|
||||
|
||||
await db().insert(leads).values({ email, householdId, source });
|
||||
|
||||
if (householdId) {
|
||||
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.fr";
|
||||
const hub = await buildHub(householdId).catch(() => null);
|
||||
const html = await renderGarderLienEmail({ url: `${base}/foyer/${householdId}`, total: hub?.total ?? 0 });
|
||||
await sendMail({ to: email, subject: "Le lien de ton foyer fokan", html }).catch((e) => {
|
||||
// Le dépôt en base a déjà eu lieu : un échec d'envoi ne doit jamais le remettre en cause.
|
||||
console.error("[interet] envoi du lien impossible :", String(e));
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, gte, isNull, sql } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, memberships as membershipsTable, invitations as invitationsTable } from "@/db";
|
||||
import { autoriser, PLAFONDS } from "@/lib/rate-limit";
|
||||
|
||||
const Body = z.object({
|
||||
householdId: z.string().uuid(),
|
||||
|
|
@ -11,15 +12,30 @@ const Body = z.object({
|
|||
});
|
||||
|
||||
const SEPT_JOURS = 7 * 24 * 3600 * 1000;
|
||||
const VINGT_QUATRE_HEURES = 24 * 3600 * 1000;
|
||||
/** Plafond par adresse invitée, tous foyers et tous invitants confondus (audit S2). */
|
||||
const MAX_INVITATIONS_PAR_EMAIL_JOUR = 3;
|
||||
|
||||
/** Inviter un membre du foyer (§ 9.3) — réservé aux membres existants de CE foyer. */
|
||||
/**
|
||||
* Inviter un membre du foyer (§ 9.3) — réservé aux membres existants de CE foyer.
|
||||
*
|
||||
* Trois garde-fous ajoutés par l'audit S2 (28/07/2026) : un lien magique déclenché sans limite
|
||||
* est un vecteur d'e-mail bombing depuis notre relais d'envoi, dont le § 26 fait une
|
||||
* infrastructure vitale — un seul abus suffirait à en détruire la réputation.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "connexion requise" }, { status: 401 });
|
||||
|
||||
// 1. Plafond par utilisateur et par jour.
|
||||
if (!autoriser(`invitations:${session.user.id}`, PLAFONDS.invitationsParUtilisateur.limite, PLAFONDS.invitationsParUtilisateur.fenetreMs)) {
|
||||
return NextResponse.json({ error: "trop d'invitations envoyées aujourd'hui" }, { status: 429 });
|
||||
}
|
||||
|
||||
const parsed = Body.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "réponse invalide" }, { status: 400 });
|
||||
const { householdId, email } = parsed.data;
|
||||
const emailNorm = email.toLowerCase();
|
||||
|
||||
const database = db();
|
||||
const [membership] = await database
|
||||
|
|
@ -28,10 +44,38 @@ export async function POST(req: NextRequest) {
|
|||
.where(and(eq(membershipsTable.householdId, householdId), eq(membershipsTable.userId, session.user.id)));
|
||||
if (!membership) return NextResponse.json({ error: "pas membre de ce foyer" }, { status: 403 });
|
||||
|
||||
// 2. Refus si une invitation encore valable existe déjà pour ce couple (foyer, adresse) —
|
||||
// sans ce garde-fou, chaque nouvel appel renvoyait un lien magique de plus à la même adresse.
|
||||
const [pendante] = await database
|
||||
.select({ id: invitationsTable.id })
|
||||
.from(invitationsTable)
|
||||
.where(and(
|
||||
eq(invitationsTable.householdId, householdId),
|
||||
eq(invitationsTable.email, emailNorm),
|
||||
isNull(invitationsTable.usedAt),
|
||||
gte(invitationsTable.expiresAt, new Date()),
|
||||
));
|
||||
if (pendante) {
|
||||
return NextResponse.json({ error: "une invitation est déjà en cours pour cette adresse" }, { status: 409 });
|
||||
}
|
||||
|
||||
// 3. Plafond par adresse invitée, tous foyers confondus — protège une victime ciblée depuis
|
||||
// plusieurs comptes distincts, ce que le plafond n°1 (par utilisateur) ne couvre pas.
|
||||
const [{ count: recuesParEmail }] = await database
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(invitationsTable)
|
||||
.where(and(
|
||||
eq(invitationsTable.email, emailNorm),
|
||||
gte(invitationsTable.createdAt, new Date(Date.now() - VINGT_QUATRE_HEURES)),
|
||||
));
|
||||
if (recuesParEmail >= MAX_INVITATIONS_PAR_EMAIL_JOUR) {
|
||||
return NextResponse.json({ error: "trop d'invitations envoyées à cette adresse aujourd'hui" }, { status: 429 });
|
||||
}
|
||||
|
||||
const token = randomBytes(24).toString("hex");
|
||||
await database.insert(invitationsTable).values({
|
||||
householdId,
|
||||
email: email.toLowerCase(),
|
||||
email: emailNorm,
|
||||
token,
|
||||
invitedBy: session.user.id,
|
||||
expiresAt: new Date(Date.now() + SEPT_JOURS),
|
||||
|
|
@ -39,7 +83,7 @@ export async function POST(req: NextRequest) {
|
|||
|
||||
await auth.api.signInMagicLink({
|
||||
body: {
|
||||
email,
|
||||
email: emailNorm,
|
||||
callbackURL: `/foyer/${householdId}?invite=${token}`,
|
||||
metadata: { context: "invitation", invitedByEmail: session.user.email },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, households, assets } from "@/db";
|
||||
import { db, households, assets, memberships as membershipsTable } from "@/db";
|
||||
import { QuizAnswers, quizToAssets } from "@/engine/quiz-to-assets";
|
||||
import { runReconciliation } from "@/engine/reconcile-db";
|
||||
import { rejouerQuiz, peutRejouerQuiz, prochainRejouePossible } from "@/engine/quiz-rejeu";
|
||||
import { envoyerJob } from "@/jobs/boss";
|
||||
import { QUEUE_GEORISQUES, enrichirRisquesFoyer } from "@/jobs/enrichissement-georisques";
|
||||
import { apercuRappels, rappelsPourVehicule } from "@/knowledge/rappels";
|
||||
|
|
@ -12,6 +14,9 @@ import { planFabrication, type EvenementFabrication } from "@/engine/fabrication
|
|||
import { resumeZfeFoyer } from "@/jobs/enrichissement-zfe";
|
||||
import { loadTemplates } from "@/knowledge/loader";
|
||||
import type { Asset } from "@/knowledge/schema";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getSubscription } from "@/lib/subscription";
|
||||
import { autoriser, ipDe, PLAFONDS } from "@/lib/rate-limit";
|
||||
|
||||
/**
|
||||
* Plafond d'attente sur Géorisques. Au-delà, on rend la main et la file pg-boss finit le
|
||||
|
|
@ -39,13 +44,67 @@ const PLAFOND_RISQUES_MS = 10000;
|
|||
* Première ligne : le plan des étapes (le serveur connaît les réponses, donc sait lesquelles
|
||||
* auront lieu). Puis une ligne par étape franchie. Dernière ligne : l'identifiant du foyer.
|
||||
*/
|
||||
/**
|
||||
* Rejeu du quiz par un abonné (audit F8) : le quiz reste la référence unique pour mettre à jour
|
||||
* les déclarations d'un foyer, y compris après le premier passage. `householdId` dans le corps
|
||||
* de la requête distingue les deux cas — absent, un nouveau foyer se crée comme aujourd'hui ;
|
||||
* présent, ce foyer est mis à jour (cf. src/engine/quiz-rejeu.ts). Trois portes, dans cet ordre :
|
||||
* une session, l'appartenance à CE foyer, un abonnement actif — et un garde-fou d'un rejeu par
|
||||
* mois pour qu'un rejeu répété ne devienne pas un vecteur d'abus.
|
||||
*/
|
||||
async function autoriserRejeu(householdId: string, req: NextRequest): Promise<{ ok: true } | { ok: false; status: number; error: string }> {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return { ok: false, status: 401, error: "connexion requise" };
|
||||
|
||||
const database = db();
|
||||
const [membership] = await database
|
||||
.select({ id: membershipsTable.id })
|
||||
.from(membershipsTable)
|
||||
.where(and(eq(membershipsTable.householdId, householdId), eq(membershipsTable.userId, session.user.id)));
|
||||
if (!membership) return { ok: false, status: 403, error: "pas membre de ce foyer" };
|
||||
|
||||
const subscription = await getSubscription(householdId);
|
||||
if (!subscription.isActive) return { ok: false, status: 403, error: "réservé aux foyers abonnés" };
|
||||
|
||||
const [row] = await database
|
||||
.select({ quizRejoueLe: households.quizRejoueLe })
|
||||
.from(households)
|
||||
.where(eq(households.id, householdId));
|
||||
if (!row) return { ok: false, status: 404, error: "foyer introuvable" };
|
||||
const quizRejoueLe = row.quizRejoueLe ? new Date(row.quizRejoueLe) : null;
|
||||
if (!peutRejouerQuiz(quizRejoueLe)) {
|
||||
const prochain = prochainRejouePossible(quizRejoueLe);
|
||||
return {
|
||||
ok: false,
|
||||
status: 429,
|
||||
error: `un seul rejeu par mois — prochain possible le ${prochain?.toLocaleDateString("fr-FR") ?? "?"}`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const parsed = QuizAnswers.safeParse(await req.json());
|
||||
/**
|
||||
* Chaque foyer déclenche jusqu'à 6 appels vers des API d'État (Géorisques, CatNat, VigiEau)
|
||||
* pour un visiteur anonyme (audit S1) — un plafond strict, pas seulement une bonne pratique :
|
||||
* se faire fermer la porte de Géorisques coûterait tout le pilier maison.
|
||||
*/
|
||||
if (!autoriser(`quiz:${ipDe(req)}`, PLAFONDS.quiz.limite, PLAFONDS.quiz.fenetreMs)) {
|
||||
return NextResponse.json({ error: "trop de tentatives, réessaie dans une heure" }, { status: 429 });
|
||||
}
|
||||
const brut = await req.json().catch(() => null);
|
||||
const { householdId: rejouerId, ...answersBrut } = (brut && typeof brut === "object" ? brut : {}) as Record<string, unknown>;
|
||||
const parsed = QuizAnswers.safeParse(answersBrut);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "réponses invalides" }, { status: 400 });
|
||||
}
|
||||
const answers = parsed.data;
|
||||
|
||||
if (typeof rejouerId === "string") {
|
||||
const autorisation = await autoriserRejeu(rejouerId, req);
|
||||
if (!autorisation.ok) return NextResponse.json({ error: autorisation.error }, { status: autorisation.status });
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const flux = new ReadableStream({
|
||||
async start(controller) {
|
||||
|
|
@ -66,24 +125,31 @@ export async function POST(req: NextRequest) {
|
|||
try {
|
||||
emettre({ type: "plan", etapes: planFabrication(answers, loadTemplates().length) });
|
||||
|
||||
const [household] = await database
|
||||
.insert(households)
|
||||
.values({ quizAnswers: answers })
|
||||
.returning({ id: households.id });
|
||||
householdId = household.id;
|
||||
|
||||
const issusDuQuiz = quizToAssets(answers);
|
||||
const aCreer = issusDuQuiz.map((a) => ({
|
||||
householdId: household.id,
|
||||
assetType: a.type,
|
||||
label: a.label,
|
||||
attributes: a.attributes,
|
||||
// « carte_grise » se distingue du quiz : c'est une identification exacte dans le
|
||||
// référentiel (champ K), pas une saisie déclarative.
|
||||
provenance: a.type === "vehicule" && a.attributes.source === "carte_grise" ? "carte_grise" : "quiz",
|
||||
}));
|
||||
if (aCreer.length) await database.insert(assets).values(aCreer);
|
||||
emettre({ type: "fait", cle: "declare", detail: `${aCreer.length} éléments retenus` });
|
||||
|
||||
if (typeof rejouerId === "string") {
|
||||
householdId = rejouerId;
|
||||
await rejouerQuiz(rejouerId, answers);
|
||||
emettre({ type: "fait", cle: "declare", detail: `${issusDuQuiz.length} éléments retenus` });
|
||||
} else {
|
||||
const [household] = await database
|
||||
.insert(households)
|
||||
.values({ quizAnswers: answers })
|
||||
.returning({ id: households.id });
|
||||
householdId = household.id;
|
||||
|
||||
const aCreer = issusDuQuiz.map((a) => ({
|
||||
householdId: household.id,
|
||||
assetType: a.type,
|
||||
label: a.label,
|
||||
attributes: a.attributes,
|
||||
// « carte_grise » se distingue du quiz : c'est une identification exacte dans le
|
||||
// référentiel (champ K), pas une saisie déclarative.
|
||||
provenance: a.type === "vehicule" && a.attributes.source === "carte_grise" ? "carte_grise" : "quiz",
|
||||
}));
|
||||
if (aCreer.length) await database.insert(assets).values(aCreer);
|
||||
emettre({ type: "fait", cle: "declare", detail: `${aCreer.length} éléments retenus` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrichissements véhicule — avant la réconciliation, qui lit les attributs qu'ils posent.
|
||||
|
|
@ -96,27 +162,29 @@ export async function POST(req: NextRequest) {
|
|||
* La distribution est rejouée telle quelle par le passage hebdomadaire — elle est
|
||||
* idempotente, et la précision de l'aperçu vaut mieux qu'une requête économisée.
|
||||
*/
|
||||
const hhId = householdId!;
|
||||
|
||||
if (answers.vehicules.length) {
|
||||
await rattraperCritairFoyer(household.id);
|
||||
await enrichirDistributionFoyer(household.id).catch((e) => {
|
||||
await rattraperCritairFoyer(hhId);
|
||||
await enrichirDistributionFoyer(hhId).catch((e) => {
|
||||
console.error("[distribution] identification impossible, le foyer reste complet :", String(e));
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
// Mutation du foyer → réconciliation immédiate (§ 20.4, déclencheur "mutation d'un foyer").
|
||||
const plan = await runReconciliation(household.id);
|
||||
const plan = await runReconciliation(hhId);
|
||||
emettre({ type: "fait", cle: "calendrier", detail: `${plan.created} échéances` });
|
||||
|
||||
if (answers.vehicules.length) {
|
||||
emettre({ type: "fait", cle: "vehicules", detail: await detailVehicules(issusDuQuiz, household.id) });
|
||||
emettre({ type: "fait", cle: "vehicules", detail: await detailVehicules(issusDuQuiz, hhId) });
|
||||
}
|
||||
|
||||
if (answers.adresse?.lat != null) {
|
||||
// Course explicite : l'enrichissement continue de son côté même si le plafond tombe,
|
||||
// et la file prend le relais pour garantir que le foyer finit enrichi.
|
||||
const releve = await Promise.race([
|
||||
enrichirRisquesFoyer(household.id),
|
||||
enrichirRisquesFoyer(hhId),
|
||||
new Promise<null>((r) => setTimeout(() => r(null), PLAFOND_RISQUES_MS)),
|
||||
]).catch(() => null);
|
||||
|
||||
|
|
@ -139,11 +207,11 @@ export async function POST(req: NextRequest) {
|
|||
if (answers.vehicules.length) {
|
||||
// Le croisement ZFE a eu lieu dans le même passage : on nomme le constat, jamais
|
||||
// la conséquence (D-005 — l'inventaire, pas le mode d'emploi).
|
||||
const resume = await resumeZfeFoyer(household.id).catch(() => null);
|
||||
const resume = await resumeZfeFoyer(hhId).catch(() => null);
|
||||
if (resume) emettre({ type: "fait", cle: "zfe", detail: resume });
|
||||
}
|
||||
} else {
|
||||
await envoyerJob(QUEUE_GEORISQUES, { householdId: household.id });
|
||||
await envoyerJob(QUEUE_GEORISQUES, { householdId: hhId });
|
||||
// Les deux étapes partagent le même passage : si le plafond tombe, aucune des deux
|
||||
// n'a abouti, et laisser la seconde en suspens donnerait un écran qui n'aboutit jamais.
|
||||
emettre({ type: "fait", cle: "risques", detail: "on finit ça de notre côté" });
|
||||
|
|
@ -151,7 +219,7 @@ export async function POST(req: NextRequest) {
|
|||
}
|
||||
}
|
||||
|
||||
emettre({ type: "fin", id: household.id });
|
||||
emettre({ type: "fin", id: hhId });
|
||||
} catch (e) {
|
||||
console.error("[quiz] échec de fabrication :", e);
|
||||
// Le foyer créé n'est jamais abandonné pour un échec survenu après coup : mieux vaut un
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, households as householdsTable } from "@/db";
|
||||
import { db, households as householdsTable, memberships as membershipsTable } from "@/db";
|
||||
import { stripe } from "@/lib/stripe";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
|
|
@ -30,7 +30,10 @@ export async function POST(req: NextRequest) {
|
|||
if (!householdId || typeof session.subscription !== "string") break;
|
||||
|
||||
const subscription = await client.subscriptions.retrieve(session.subscription);
|
||||
const periodEnd = new Date(subscription.items.data[0].current_period_end * 1000);
|
||||
// Défensif (audit F7) : Stripe garantit `items.data` non vide pour un abonnement actif,
|
||||
// mais un 500 ici ferait rejouer l'événement en boucle pendant des jours plutôt qu'une fois.
|
||||
const item = subscription.items.data[0];
|
||||
const periodEnd = item ? new Date(item.current_period_end * 1000) : null;
|
||||
const payerUserId = session.metadata?.payerUserId || null;
|
||||
|
||||
await database
|
||||
|
|
@ -41,25 +44,45 @@ export async function POST(req: NextRequest) {
|
|||
subscriptionPayerId: payerUserId,
|
||||
stripeCustomerId: typeof session.customer === "string" ? session.customer : null,
|
||||
stripeSubscriptionId: subscription.id,
|
||||
/**
|
||||
* Horodaté ICI et non dans /api/checkout (audit S5) : avant ce point, la case pouvait
|
||||
* être cochée sans qu'aucun paiement n'ait lieu, et n'importe qui connaissant l'UUID du
|
||||
* foyer pouvait faire enregistrer un consentement qui n'était pas le sien. Le paiement
|
||||
* est la seule preuve qui vaille (§ 10.7) — `session.id` est la preuve croisée qui lie
|
||||
* ce consentement à CE paiement précis, jamais à une simple requête HTTP.
|
||||
*/
|
||||
retractationWaivedAt: new Date(),
|
||||
stripeCheckoutSessionId: session.id,
|
||||
})
|
||||
.where(eq(householdsTable.id, householdId));
|
||||
|
||||
/**
|
||||
* Sans ça, personne ne peut jamais devenir membre du foyer qu'il vient de payer.
|
||||
* La réclamation du foyer (audit S3, option b) : elle ne dépend plus jamais du rendu de
|
||||
* /foyer/[id]. Un GET anonyme sur cette page ne peut plus faire changer le foyer de
|
||||
* propriétaire — seul CE webhook, déclenché par un paiement réel, en décide.
|
||||
*
|
||||
* Le parcours n'a délibérément aucun champ e-mail avant le paiement (§ 10.1 : « un champ
|
||||
* de moins entre l'intention et la contrepartie ») — c'est Stripe Checkout qui le collecte.
|
||||
* `payerUserId` n'est donc renseigné QUE si quelqu'un était déjà connecté avant de payer ;
|
||||
* dans l'immense majorité des cas il est vide, et personne n'a de session à l'arrivée sur
|
||||
* `success_url`. `resolveMembership` (src/lib/membership.ts) sait déjà faire du premier
|
||||
* visiteur authentifié d'un foyer sans membre son premier membre — il ne manquait que la
|
||||
* connexion elle-même. On la déclenche ici avec l'adresse que Stripe vient de vérifier par
|
||||
* paiement, ce qui vaut largement une simple saisie de formulaire.
|
||||
*
|
||||
* Si un payeur était déjà connecté, il a déjà une session au moment où Stripe le renvoie
|
||||
* sur `success_url` : il se réclame tout seul à cet instant, un second mail serait superflu.
|
||||
* Cas 1 — une session existait déjà avant le paiement (`payerUserId` renseigné) : on sait
|
||||
* exactement qui payer est, on l'inscrit membre ici, tout de suite, sans ambiguïté.
|
||||
*/
|
||||
if (!payerUserId) {
|
||||
if (payerUserId) {
|
||||
const [dejaMembre] = await database
|
||||
.select({ id: membershipsTable.id })
|
||||
.from(membershipsTable)
|
||||
.where(and(eq(membershipsTable.householdId, householdId), eq(membershipsTable.userId, payerUserId)));
|
||||
if (!dejaMembre) {
|
||||
await database.insert(membershipsTable).values({ householdId, userId: payerUserId });
|
||||
}
|
||||
} else {
|
||||
/**
|
||||
* Cas 2 — personne n'était connecté avant le paiement (§ 10.1 : aucun champ e-mail avant
|
||||
* contrepartie, c'est Stripe Checkout qui le collecte). On ne peut pas créer le compte ni
|
||||
* la cotisation ici : better-auth ne crée l'utilisateur qu'à la vérification du lien
|
||||
* magique. Le lien envoyé mène donc chez nous, et c'est `resolveMembership`
|
||||
* (src/lib/membership.ts) qui inscrit le premier membre — mais seulement si le foyer est
|
||||
* déjà `subscriptionStatus: "active"` au moment de la visite, condition posée par CE
|
||||
* webhook juste au-dessus. Un foyer non payé ne peut donc plus jamais être réclamé par un
|
||||
* visiteur quelconque, quel que soit l'ordre d'exécution.
|
||||
*/
|
||||
const email = session.customer_details?.email;
|
||||
if (email) {
|
||||
await auth.api.signInMagicLink({
|
||||
|
|
@ -77,8 +100,20 @@ export async function POST(req: NextRequest) {
|
|||
|
||||
case "customer.subscription.updated": {
|
||||
const subscription = event.data.object;
|
||||
const periodEnd = new Date(subscription.items.data[0].current_period_end * 1000);
|
||||
const status = subscription.status === "active" ? "active" : subscription.status === "canceled" ? "canceled" : "expired";
|
||||
const item = subscription.items.data[0];
|
||||
const periodEnd = item ? new Date(item.current_period_end * 1000) : null;
|
||||
/**
|
||||
* `past_due` (impayé temporaire, retenté par Stripe pendant plusieurs jours) et `trialing`
|
||||
* ne doivent JAMAIS couper la veille d'un foyer qui a payé (audit F7) : seuls `canceled` et
|
||||
* `unpaid` (retries épuisés) referment l'accès. Tout le reste (`active`, `trialing`,
|
||||
* `past_due`, `incomplete`...) reste "active" côté produit. `unpaid` est distingué de
|
||||
* `canceled` (statut réservé à l'événement `subscription.deleted`, un choix délibéré du
|
||||
* foyer) — la nuance ne change rien à `getSubscription` (tout hors "active" bloque l'accès)
|
||||
* mais garde le signal pour le futur pilotage du churn (§ 13.2).
|
||||
*/
|
||||
const status = subscription.status === "canceled" ? "canceled"
|
||||
: subscription.status === "unpaid" ? "expired"
|
||||
: "active";
|
||||
await database
|
||||
.update(householdsTable)
|
||||
.set({ subscriptionStatus: status, subscriptionPeriodEnd: periodEnd })
|
||||
|
|
|
|||
|
|
@ -1,16 +1,29 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { identifierParReception, searchModeles } from "@/knowledge/vehicules-recherche";
|
||||
import { identifierParReception, motorisationsParModele, searchModeles } from "@/knowledge/vehicules-recherche";
|
||||
import type { Energie } from "@/knowledge/vehicules-cle";
|
||||
import { autoriser, ipDe, PLAFONDS } from "@/lib/rate-limit";
|
||||
|
||||
/** Les seules énergies que le quiz laisse choisir (§ 4.1, jamais de saisie libre). */
|
||||
const ENERGIES_QUIZ: Energie[] = ["essence", "diesel", "electrique", "hybride"];
|
||||
|
||||
/**
|
||||
* Identification véhicule du quiz — référentiel RDW, aucune saisie libre (§ 4.1), zéro API payante.
|
||||
*
|
||||
* ?q=<texte> → autocomplétion marque/modèle
|
||||
* ?q=<texte> → autocomplétion marque/modèle
|
||||
* ?k=<champ K>[&variante=&version=] → identification exacte par la carte grise (champs K et D.2)
|
||||
* ?marque=&modele= → motorisations (champ P.2) pour la voie déclarative
|
||||
* (audit § 3.3 point 1 : jusqu'ici réservées au champ K,
|
||||
* alors que la voie déclarative est vraisemblablement
|
||||
* majoritaire — quiconque n'a pas sa carte grise sous la main)
|
||||
*
|
||||
* Dégradation douce : une réception inconnue renvoie `vehicule: null` (repli déclaratif côté UI),
|
||||
* jamais une erreur bloquante — « je ne sais pas » est une réponse de première classe (§ 4.3).
|
||||
* Dégradation douce : une réception ou un modèle inconnu renvoie une carte vide (repli déclaratif
|
||||
* côté UI), jamais une erreur bloquante — « je ne sais pas » est une réponse de première classe (§ 4.3).
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
// Protège le référentiel (2,9 M de lignes, agrégats coûteux) d'un abus par script (audit S1).
|
||||
if (!autoriser(`vehicule-taxonomie:${ipDe(req)}`, PLAFONDS.vehiculeTaxonomie.limite, PLAFONDS.vehiculeTaxonomie.fenetreMs)) {
|
||||
return NextResponse.json({ error: "trop de requêtes, réessaie dans un instant" }, { status: 429 });
|
||||
}
|
||||
const champK = req.nextUrl.searchParams.get("k");
|
||||
if (champK) {
|
||||
const vehicule = await identifierParReception(champK, {
|
||||
|
|
@ -20,6 +33,16 @@ export async function GET(req: NextRequest) {
|
|||
return NextResponse.json({ vehicule });
|
||||
}
|
||||
|
||||
const marque = req.nextUrl.searchParams.get("marque");
|
||||
const modele = req.nextUrl.searchParams.get("modele");
|
||||
if (marque && modele) {
|
||||
const motorisations = await motorisationsParModele(marque, modele, ENERGIES_QUIZ).catch((e) => {
|
||||
console.error("[vehicules] motorisations indisponibles pour", marque, modele, ":", String(e));
|
||||
return {};
|
||||
});
|
||||
return NextResponse.json({ motorisations });
|
||||
}
|
||||
|
||||
const suggestions = await searchModeles(req.nextUrl.searchParams.get("q") ?? "").catch(() => []);
|
||||
return NextResponse.json({ suggestions });
|
||||
}
|
||||
|
|
|
|||
37
src/app/calendrier/[token]/route.ts
Normal file
37
src/app/calendrier/[token]/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { db, households as householdsTable } from "@/db";
|
||||
import { buildHub } from "@/engine/hub";
|
||||
import { hubToIcs } from "@/engine/ics";
|
||||
import { getSubscription } from "@/lib/subscription";
|
||||
|
||||
/**
|
||||
* Le flux .ics vivant (§ 10.2, audit S4) — servi par JETON, jamais par l'UUID du foyer.
|
||||
*
|
||||
* L'UUID circule dans chaque lien de partage, chaque mail, chaque historique de navigateur ;
|
||||
* un client d'agenda ne sait pas s'authentifier, donc le flux a besoin d'une URL-capacité —
|
||||
* mais cette capacité doit être un jeton dédié et révocable (POST /api/foyer/[id]/ics le
|
||||
* génère), pas l'identifiant du foyer. L'ancienne route (`/foyer/[id]/calendrier.ics`) a été
|
||||
* retirée : elle répond 404 partout ailleurs dans l'app.
|
||||
*/
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params;
|
||||
const [household] = await db()
|
||||
.select({ id: householdsTable.id })
|
||||
.from(householdsTable)
|
||||
.where(eq(householdsTable.icsToken, token));
|
||||
if (!household) return new Response("introuvable", { status: 404 });
|
||||
|
||||
const subscription = await getSubscription(household.id);
|
||||
if (!subscription.isActive) {
|
||||
return new Response("Le flux .ics est réservé aux abonnés — active la veille pour le débloquer.", { status: 403 });
|
||||
}
|
||||
|
||||
const ics = hubToIcs(await buildHub(household.id), household.id);
|
||||
|
||||
return new Response(ics, {
|
||||
headers: {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"Content-Disposition": 'attachment; filename="echeances-foyer.ics"',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { db, assets as assetsTable } from "@/db";
|
||||
import { buildHub } from "@/engine/hub";
|
||||
import { hubToIcs } from "@/engine/ics";
|
||||
import { getSubscription } from "@/lib/subscription";
|
||||
|
||||
/** Le flux ics vivant est un avantage payant (§ 10.2) — plus d'export gratuit. */
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const rows = await db().select({ id: assetsTable.id }).from(assetsTable).where(eq(assetsTable.householdId, id));
|
||||
if (!rows.length) return new Response("introuvable", { status: 404 });
|
||||
|
||||
const subscription = await getSubscription(id);
|
||||
if (!subscription.isActive) {
|
||||
return new Response("Le flux .ics est réservé aux abonnés — active la veille pour le débloquer.", { status: 403 });
|
||||
}
|
||||
|
||||
const ics = hubToIcs(await buildHub(id), id);
|
||||
|
||||
return new Response(ics, {
|
||||
headers: {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"Content-Disposition": 'attachment; filename="echeances-foyer.ics"',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -119,7 +119,7 @@ export default async function FoyerPage({
|
|||
const isPaid = subscription.isActive;
|
||||
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const members = await resolveMembership(id, session?.user ?? null, invite);
|
||||
const members = await resolveMembership(id, session?.user ?? null, isPaid, invite);
|
||||
const isMember = Boolean(session?.user.id) && members.some((m) => m.userId === session!.user.id);
|
||||
/**
|
||||
* Une fois abonné, le lien seul ne suffit plus à voir le détail (D-017) : contrairement à
|
||||
|
|
@ -326,6 +326,21 @@ export default async function FoyerPage({
|
|||
members={session?.user.id && members.some((m) => m.userId === session.user.id) ? members : []}
|
||||
/>
|
||||
{!unlocked && !adresseConnue && <GarderLien householdId={id} />}
|
||||
|
||||
{/*
|
||||
Rejeu du quiz (audit F8) : le quiz reste la référence unique pour mettre à jour les
|
||||
déclarations d'un foyer — réservé aux abonnés membres, garde-fou d'un rejeu par mois
|
||||
appliqué côté serveur (POST /api/quiz). Un lien simple suffit ici ; le refus éventuel
|
||||
(trop tôt, pas encore abonné) se dit sur l'écran du quiz lui-même.
|
||||
*/}
|
||||
{unlocked && (
|
||||
<p className="text-center text-sm text-muted">
|
||||
Une déclaration a changé (nouveau véhicule, déménagement, animal…) ?{" "}
|
||||
<Link href={`/quiz?rejouer=${id}`} className="font-medium text-accent underline underline-offset-2">
|
||||
Rejouer le quiz
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Suspense } from "react";
|
||||
import { QuizClient } from "./quiz-client";
|
||||
|
||||
/**
|
||||
|
|
@ -5,7 +6,14 @@ import { QuizClient } from "./quiz-client";
|
|||
* fiches suivies, pour l'écran de fabrication ; ce chiffre arrive désormais dans le plan émis
|
||||
* par POST /api/quiz, avec le reste des étapes — le compte vient de la même source que le travail
|
||||
* qu'il décrit, il ne peut plus diverger.
|
||||
*
|
||||
* Suspense requis par Next.js dès que `QuizClient` lit `useSearchParams()` (mode rejeu, audit
|
||||
* F8, `?rejouer=<id>`) — sans lui, le prérendu statique de la page échoue au build.
|
||||
*/
|
||||
export default function QuizPage() {
|
||||
return <QuizClient />;
|
||||
return (
|
||||
<Suspense>
|
||||
<QuizClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ArrowLeft, ArrowRight, Check, CreditCard, Search, Sparkles, X } from "lucide-react";
|
||||
import { Container } from "@/components/ui/primitives";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -11,7 +11,7 @@ import type { Pilier } from "@/knowledge/schema";
|
|||
import { resoudreConfirmationDpe, type DpeConfirmationReponse } from "@/knowledge/dpe-confirmation";
|
||||
import { epoqueConstruction, type EpoqueConstruction } from "@/knowledge/construction";
|
||||
import type { ChauffageConnu } from "@/knowledge/dpe-chauffage";
|
||||
import { CONTRACT_TYPES, relevantContractTypesForProfil } from "@/engine/contrats";
|
||||
import { relevantContractTypesForProfil } from "@/engine/contrats";
|
||||
import { QuizHud } from "./hud";
|
||||
import { Fabrication, type EtatEtape } from "./fabrication";
|
||||
import type { EvenementFabrication } from "@/engine/fabrication";
|
||||
|
|
@ -88,6 +88,20 @@ function libelleMoteur(m: { cylindree: number | null; cylindres: number | null }
|
|||
return m.cylindres ? `${litres} l, ${m.cylindres} cylindres` : `${litres} l`;
|
||||
}
|
||||
|
||||
/** Vignette Crit'Air commandée ou non — un tap suffit, « je ne sais pas » reste possible (§ 4.3). */
|
||||
function VignetteToggle({ value, onChange }: { value?: boolean; onChange: (v: boolean | undefined) => void }) {
|
||||
const label = value === true ? "Vignette commandée" : value === false ? "Vignette pas encore commandée" : "Vignette ?";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(value === undefined ? true : value === true ? false : undefined)}
|
||||
className="shrink-0 rounded-md border border-line px-2 py-0.5 text-xs whitespace-nowrap text-muted transition-colors hover:text-ink"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type Answers = {
|
||||
adresse: Adresse | null;
|
||||
logement: {
|
||||
|
|
@ -187,11 +201,12 @@ function Deduction({ children }: { children: React.ReactNode }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Retenu({ children, onRemove }: { children: React.ReactNode; onRemove?: () => void }) {
|
||||
function Retenu({ children, onRemove, after }: { children: React.ReactNode; onRemove?: () => void; after?: React.ReactNode }) {
|
||||
return (
|
||||
<p className="animate-fade mb-2.5 flex items-center gap-2.5 rounded-xl border border-line bg-surface-inset px-4 py-2.5 text-[0.925rem] text-ink-soft">
|
||||
<Check size={16} className="shrink-0 text-accent" strokeWidth={2.5} aria-hidden />
|
||||
<span className="flex-1">{children}</span>
|
||||
{after}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -237,6 +252,17 @@ function Actions({
|
|||
|
||||
export function QuizClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
/**
|
||||
* Rejeu du quiz par un abonné (audit F8) : `?rejouer=<householdId>` charge ses réponses
|
||||
* précédentes au lieu de partir d'un formulaire vide, et la soumission met à jour CE foyer
|
||||
* au lieu d'en créer un nouveau (POST /api/quiz, `householdId` dans le corps).
|
||||
*/
|
||||
const rejouerId = searchParams.get("rejouer");
|
||||
const [rejouerEtat, setRejouerEtat] = useState<"chargement" | "pret" | "refuse" | null>(
|
||||
rejouerId ? "chargement" : null,
|
||||
);
|
||||
const [rejouerErreur, setRejouerErreur] = useState<string | null>(null);
|
||||
const [screen, setScreen] = useState(0);
|
||||
const [answers, setAnswers] = useState<Answers>(ANSWERS_VIDES);
|
||||
const [logementChoisi, setLogementChoisi] = useState<{ type?: string; statut?: string }>({});
|
||||
|
|
@ -257,24 +283,36 @@ export function QuizClient() {
|
|||
const [vehiculeResults, setVehiculeResults] = useState<SuggestionVehicule[]>([]);
|
||||
const [energiesDisponibles, setEnergiesDisponibles] = useState<Vehicule["energie"][] | null>(null);
|
||||
/**
|
||||
* Ce que le champ P.2 peut valoir pour cette réception, énergie par énergie (D-017).
|
||||
* Renseigné par l'identification au champ K — sans elle il n'y a rien à proposer, et le
|
||||
* champ ne s'affiche pas du tout.
|
||||
* Ce que le champ P.2 peut valoir, énergie par énergie (D-017) — voie champ K comme voie
|
||||
* déclarative depuis l'audit du 28/07/2026 (§ 3.3 point 1) : les deux voies alimentent
|
||||
* désormais le même état, `chercherParChampK` comme `pickVehicule`.
|
||||
*/
|
||||
type Motorisation = { puissances: number[]; moteurs: number; cylindree: number | null; cylindres: number | null };
|
||||
type Motorisation = {
|
||||
puissances: number[];
|
||||
moteurs: number;
|
||||
cylindree: number | null;
|
||||
cylindres: number | null;
|
||||
/** Détail par valeur de P.2 — permet de nommer le moteur d'une puissance choisie (§ 3.3 point 3). */
|
||||
detailParPuissance?: Record<string, { moteurs: number; cylindree: number | null; cylindres: number | null }>;
|
||||
};
|
||||
type Motorisations = Record<string, Motorisation>;
|
||||
const [motorisations, setMotorisations] = useState<Motorisations>({});
|
||||
const motorisationChoisie = decl.energie ? motorisations[decl.energie] : undefined;
|
||||
/** Détail de la puissance actuellement choisie, quand il permet de nommer le moteur. */
|
||||
const detailPuissanceChoisie = motorisationChoisie?.detailParPuissance?.[decl.puissance];
|
||||
|
||||
/**
|
||||
* Puissance retenue pour ce véhicule — déduite quand elle est unique, choisie sinon.
|
||||
*
|
||||
* Ne dépend plus de la présence d'une réception (audit § 3.3 point 1) : la voie déclarative
|
||||
* propose désormais le même champ P.2 que la voie champ K.
|
||||
*
|
||||
* Calculée au moment de l'ajout plutôt que recopiée dans l'état à l'affichage : écrire dans
|
||||
* l'état pendant le rendu est un piège classique, et la valeur unique n'a de toute façon
|
||||
* besoin d'exister qu'ici.
|
||||
*/
|
||||
function puissanceRetenue(): number | undefined {
|
||||
if (!decl.pick?.reception || !motorisationChoisie) return undefined;
|
||||
if (!motorisationChoisie) return undefined;
|
||||
if (motorisationChoisie.puissances.length === 1) return motorisationChoisie.puissances[0];
|
||||
return decl.puissance ? Number(decl.puissance) : undefined;
|
||||
}
|
||||
|
|
@ -294,8 +332,13 @@ export function QuizClient() {
|
|||
|
||||
useEffect(() => { track("quiz_start"); }, []);
|
||||
|
||||
/** Un rafraîchissement accidentel ne doit jamais coûter les deux minutes déjà investies. */
|
||||
/**
|
||||
* Un rafraîchissement accidentel ne doit jamais coûter les deux minutes déjà investies.
|
||||
* Désactivé en mode rejeu (audit F8) : le brouillon d'un tout autre passage ne doit ni
|
||||
* écraser les réponses rechargées depuis le foyer, ni être écrasé par elles en retour.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (rejouerId) { restaure.current = true; return; }
|
||||
try {
|
||||
const brut = sessionStorage.getItem(BROUILLON);
|
||||
if (brut) {
|
||||
|
|
@ -306,14 +349,49 @@ export function QuizClient() {
|
|||
}
|
||||
} catch { /* brouillon illisible : on repart à zéro, sans le dire */ }
|
||||
restaure.current = true;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restaure.current) return;
|
||||
if (!restaure.current || rejouerId) return;
|
||||
try {
|
||||
sessionStorage.setItem(BROUILLON, JSON.stringify({ answers, screen, adresseQuery }));
|
||||
} catch { /* quota ou navigation privée : la persistance est un confort, pas une dépendance */ }
|
||||
}, [answers, screen, adresseQuery]);
|
||||
}, [answers, screen, adresseQuery, rejouerId]);
|
||||
|
||||
/** Charge les réponses précédentes du foyer à rejouer (audit F8) — remplace le formulaire vide. */
|
||||
useEffect(() => {
|
||||
if (!rejouerId) return;
|
||||
let annule = false;
|
||||
fetch(`/api/foyer/${rejouerId}/quiz`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.error ?? "impossible de charger tes réponses précédentes");
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data: { quizAnswers: Answers; peutRejouer: boolean; prochainRejouePossible: string | null }) => {
|
||||
if (annule) return;
|
||||
if (!data.peutRejouer) {
|
||||
const date = data.prochainRejouePossible
|
||||
? new Date(data.prochainRejouePossible).toLocaleDateString("fr-FR")
|
||||
: null;
|
||||
setRejouerErreur(`Un seul rejeu par mois — prochain possible${date ? ` le ${date}` : " bientôt"}.`);
|
||||
setRejouerEtat("refuse");
|
||||
return;
|
||||
}
|
||||
setAnswers({ ...ANSWERS_VIDES, ...data.quizAnswers });
|
||||
if (data.quizAnswers.adresse?.label) setAdresseQuery(data.quizAnswers.adresse.label);
|
||||
setRejouerEtat("pret");
|
||||
})
|
||||
.catch((e) => {
|
||||
if (annule) return;
|
||||
setRejouerErreur(String(e.message ?? e));
|
||||
setRejouerEtat("refuse");
|
||||
});
|
||||
return () => { annule = true; };
|
||||
}, [rejouerId]);
|
||||
|
||||
const goTo = useCallback((n: number) => {
|
||||
setScreen(n);
|
||||
|
|
@ -446,15 +524,37 @@ export function QuizClient() {
|
|||
}, 300);
|
||||
}
|
||||
|
||||
/** Jeton de la dernière requête de motorisations lancée — écarte une réponse tardive et périmée. */
|
||||
const motorisationsRequete = useRef(0);
|
||||
|
||||
function pickVehicule(s: SuggestionVehicule) {
|
||||
const energies = energiesValides(s.energies);
|
||||
// Une seule énergie possible pour ce modèle : on la pré-sélectionne. Sinon on laisse le
|
||||
// choix ouvert plutôt que de deviner — un modèle existe souvent en essence ET hybride,
|
||||
// présélectionner l'une à tort serait pire que ne rien présélectionner (§ 4.2).
|
||||
setDecl((d) => ({ ...d, pick: { marque: s.marque, modele: s.modele }, energie: energies.length === 1 ? energies[0] : undefined }));
|
||||
setDecl((d) => ({ ...d, puissance: "", pick: { marque: s.marque, modele: s.modele }, energie: energies.length === 1 ? energies[0] : undefined }));
|
||||
setEnergiesDisponibles(energies.length ? energies : null);
|
||||
setVehiculeQuery(`${s.marque} ${s.modele}`);
|
||||
setVehiculeResults([]);
|
||||
|
||||
/**
|
||||
* Motorisations remises à zéro puis rechargées pour CE modèle (audit § 3.4, défaut 1) :
|
||||
* sans la remise à zéro, un second véhicule choisi juste après le premier affichait encore
|
||||
* les motorisations de celui qu'on venait de quitter, le temps que la requête réponde.
|
||||
*
|
||||
* Et sans cette requête elle-même (audit § 3.3 point 1), la voie déclarative ne proposait
|
||||
* jamais le champ P.2 — trou de câblage : `codesEnLice` sait s'en servir sans réception,
|
||||
* seule l'interface ne le demandait pas.
|
||||
*/
|
||||
setMotorisations({});
|
||||
const requete = ++motorisationsRequete.current;
|
||||
fetch(`/api/vehicule/taxonomie?marque=${encodeURIComponent(s.marque)}&modele=${encodeURIComponent(s.modele)}`)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (requete !== motorisationsRequete.current) return; // une sélection plus récente a eu lieu entre-temps
|
||||
if (data?.motorisations) setMotorisations(data.motorisations);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -550,7 +650,8 @@ export function QuizClient() {
|
|||
const res = await fetch("/api/quiz", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(answers),
|
||||
// En mode rejeu (audit F8), `householdId` fait mettre à jour CE foyer au lieu d'en créer un.
|
||||
body: JSON.stringify(rejouerId ? { ...answers, householdId: rejouerId } : answers),
|
||||
});
|
||||
if (!res.ok || !res.body) throw new Error("fabrication indisponible");
|
||||
|
||||
|
|
@ -604,6 +705,28 @@ export function QuizClient() {
|
|||
|
||||
const toggle = (list: string[], v: string) => (list.includes(v) ? list.filter((x) => x !== v) : [...list, v]);
|
||||
|
||||
// Mode rejeu (audit F8) : chargement des réponses précédentes, ou refus (garde-fou mensuel,
|
||||
// droits insuffisants) — dans les deux cas, avant tout affichage du formulaire.
|
||||
if (rejouerEtat === "chargement") {
|
||||
return (
|
||||
<main>
|
||||
<Container width="narrow" className="py-16 text-center">
|
||||
<p className="text-muted">On recharge tes réponses précédentes…</p>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
if (rejouerEtat === "refuse") {
|
||||
return (
|
||||
<main>
|
||||
<Container width="narrow" className="py-16 text-center">
|
||||
<h1 className="text-title">Rejeu impossible</h1>
|
||||
<p className="mx-auto mt-4 max-w-md text-muted">{rejouerErreur}</p>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "calcul") {
|
||||
return (
|
||||
<main>
|
||||
|
|
@ -617,6 +740,13 @@ export function QuizClient() {
|
|||
return (
|
||||
<main className="pb-24">
|
||||
<Container width="narrow">
|
||||
{rejouerEtat === "pret" && (
|
||||
<p className="animate-fade mb-6 rounded-xl border border-accent-line bg-accent-soft px-4 py-3 text-sm text-accent-soft-ink">
|
||||
Tu modifies les réponses de ton foyer — tes véhicules, personnes, animaux et contrats
|
||||
actuels seront remplacés par ce que tu déclares ici, avec leurs échéances repartant à
|
||||
zéro. Ta maison garde son historique.
|
||||
</p>
|
||||
)}
|
||||
<QuizHud
|
||||
etape={screen}
|
||||
total={TOTAL_SCREENS}
|
||||
|
|
@ -880,10 +1010,28 @@ export function QuizClient() {
|
|||
titre="Une voiture au foyer ?"
|
||||
aide="Un seul champ de ta carte grise suffit : on remplit le reste. Pas de carte grise sous la main ? La recherche par modèle marche aussi."
|
||||
>
|
||||
{/*
|
||||
La classe Crit'Air, elle, se déduit — on ne demande jamais ce qu'on peut calculer.
|
||||
Reste la seule chose que la carte grise ne dit pas : la vignette a-t-elle été
|
||||
commandée ? Sans réponse, la proposition d'en commander une s'affiche quand même.
|
||||
|
||||
Réponse PAR véhicule et non globale au foyer (audit § 3.4, défaut 3) : un foyer à
|
||||
deux voitures dont une seule a sa vignette ne pouvait pas le dire, et recevait la
|
||||
fiche pour les deux ou pour aucune.
|
||||
*/}
|
||||
{answers.vehicules.map((v, i) => (
|
||||
<Retenu
|
||||
key={i}
|
||||
onRemove={() => setAnswers((s) => ({ ...s, vehicules: s.vehicules.filter((_, j) => j !== i) }))}
|
||||
after={
|
||||
<VignetteToggle
|
||||
value={v.vignette_critair}
|
||||
onChange={(vignette) => setAnswers((s) => ({
|
||||
...s,
|
||||
vehicules: s.vehicules.map((veh, j) => (j === i ? { ...veh, vignette_critair: vignette } : veh)),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{v.marque} {v.modele ?? ""} {v.annee ? `(${v.annee})` : ""}
|
||||
{v.source === "carte_grise" && (
|
||||
|
|
@ -892,35 +1040,6 @@ export function QuizClient() {
|
|||
</Retenu>
|
||||
))}
|
||||
|
||||
{/* La classe Crit'Air, elle, se déduit — on ne demande jamais ce qu'on peut calculer.
|
||||
Reste la seule chose que la carte grise ne dit pas : la vignette a-t-elle été
|
||||
commandée ? Sans réponse, la proposition d'en commander une s'affiche quand même. */}
|
||||
{answers.vehicules.length > 0 && (
|
||||
<ChipGroup legend="La vignette Crit'Air">
|
||||
{([[true, "Déjà commandée"], [false, "Pas encore"]] as const).map(([v, l]) => (
|
||||
<Chip
|
||||
key={l}
|
||||
on={answers.vehicules.every((veh) => veh.vignette_critair === v)}
|
||||
onClick={() => setAnswers((s) => ({
|
||||
...s,
|
||||
vehicules: s.vehicules.map((veh) => ({ ...veh, vignette_critair: v })),
|
||||
}))}
|
||||
>
|
||||
{l}
|
||||
</Chip>
|
||||
))}
|
||||
<Chip
|
||||
on={answers.vehicules.every((veh) => veh.vignette_critair === undefined)}
|
||||
onClick={() => setAnswers((s) => ({
|
||||
...s,
|
||||
vehicules: s.vehicules.map((veh) => ({ ...veh, vignette_critair: undefined })),
|
||||
}))}
|
||||
>
|
||||
Je ne sais pas
|
||||
</Chip>
|
||||
</ChipGroup>
|
||||
)}
|
||||
|
||||
<div className="surface-card mt-4 p-5">
|
||||
{/* Voie 1 — le champ K identifie exactement le véhicule, gratuitement (§ 7.3). */}
|
||||
<FieldLabel htmlFor="champ-k">
|
||||
|
|
@ -955,7 +1074,31 @@ export function QuizClient() {
|
|||
</Button>
|
||||
)}
|
||||
|
||||
{(rechercheModele || champKEtat === "trouve") && (
|
||||
{/*
|
||||
Une fois le champ K reconnu, la recherche marque/modèle ne se rouvre plus QUE
|
||||
sur un geste explicite (audit § 3.4, défaut 2) : avant, elle restait affichée,
|
||||
prérempli, et y taper un caractère remettait `pick` à `null` sans rien signaler
|
||||
— l'identification exacte se perdait en silence, remplacée par un repli
|
||||
déclaratif. Un lien explicite rend le renoncement visible.
|
||||
*/}
|
||||
{champKEtat === "trouve" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-3 -ml-3"
|
||||
onClick={() => {
|
||||
setChampK("");
|
||||
setChampKEtat(null);
|
||||
setDecl((d) => ({ ...d, pick: null, energie: undefined, puissance: "" }));
|
||||
setMotorisations({});
|
||||
setEnergiesDisponibles(null);
|
||||
setVehiculeQuery("");
|
||||
setRechercheModele(true);
|
||||
}}
|
||||
>
|
||||
Ce n'est pas la bonne voiture ?
|
||||
</Button>
|
||||
) : rechercheModele && (
|
||||
<div className="mt-5 border-t border-line pt-5">
|
||||
<FieldLabel htmlFor="modele">Marque et modèle</FieldLabel>
|
||||
<Input
|
||||
|
|
@ -1028,31 +1171,40 @@ export function QuizClient() {
|
|||
))}
|
||||
</ChipGroup>
|
||||
{!decl.energie && (energiesDisponibles?.length ?? 0) > 1 && (
|
||||
<Hint>Ta carte grise couvre plusieurs motorisations pour ce modèle — indique la bonne.</Hint>
|
||||
<Hint>Ce modèle existe en plusieurs motorisations — indique la bonne.</Hint>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Champ P.2 — jamais une saisie libre (§ 4.1). Le référentiel connaît les
|
||||
puissances réellement homologuées pour cette réception : on les propose.
|
||||
puissances réellement homologuées pour ce véhicule : on les propose.
|
||||
Une liste fermée supprime au passage une erreur qu'un champ libre rendait
|
||||
invisible — taper la puissance en chevaux (181 au lieu de 133) donne un
|
||||
nombre plausible qui n'existe sur aucune version, écarte TOUTES les
|
||||
motorisations, et fait taire le produit sans que personne sache pourquoi.
|
||||
|
||||
Proposé aussi bien pour la voie champ K que pour la voie déclarative
|
||||
marque/modèle (audit § 3.3 point 1, corrigé le 28/07/2026) : jusqu'ici,
|
||||
seule la voie champ K le proposait, alors que la voie déclarative — celle
|
||||
de qui n'a pas sa carte grise sous la main — en avait tout autant besoin.
|
||||
Le texte s'adapte selon que l'identification vient de la carte grise ou du
|
||||
référentiel seul, pour ne jamais prétendre à plus de certitude qu'on n'en a.
|
||||
|
||||
Trois cas, et dans deux d'entre eux on ne demande rien : mesuré, le couple
|
||||
champ K + énergie désigne déjà un seul moteur 39 % du temps (D-017).
|
||||
*/}
|
||||
{decl.pick.reception && decl.energie && motorisationChoisie && (
|
||||
{decl.energie && motorisationChoisie && (
|
||||
<div className="mt-4">
|
||||
{motorisationChoisie.puissances.length === 1 ? (
|
||||
<p className="flex items-center gap-2 text-sm text-muted">
|
||||
<Check size={14} className="text-accent shrink-0" strokeWidth={2.5} aria-hidden />
|
||||
Puissance {formaterKw(motorisationChoisie.puissances[0])}, identifiée depuis ta carte grise
|
||||
Puissance {formaterKw(motorisationChoisie.puissances[0])}
|
||||
{decl.pick.reception ? ", identifiée depuis ta carte grise" : " — la seule homologuée pour ce modèle"}
|
||||
</p>
|
||||
) : motorisationChoisie.moteurs === 1 ? (
|
||||
<p className="flex items-center gap-2 text-sm text-muted">
|
||||
<Check size={14} className="text-accent shrink-0" strokeWidth={2.5} aria-hidden />
|
||||
Moteur identifié{libelleMoteur(motorisationChoisie) ? ` : ${libelleMoteur(motorisationChoisie)}` : ""}, depuis ta carte grise
|
||||
Moteur identifié{libelleMoteur(motorisationChoisie) ? ` : ${libelleMoteur(motorisationChoisie)}` : ""}
|
||||
{decl.pick.reception ? ", depuis ta carte grise" : ""}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -1067,7 +1219,20 @@ export function QuizClient() {
|
|||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
<Hint>Ce nombre désigne le moteur — et c'est lui qui dit si ta voiture a une courroie de distribution, ce qu'aucun tableau de bord n'affiche.</Hint>
|
||||
{/*
|
||||
Nommer plutôt que compter les kW une fois la puissance choisie, quand
|
||||
elle désigne un seul moteur (audit § 3.3 point 3) — plus lisible qu'un
|
||||
nombre brut, et toujours la conséquence d'une liste fermée, jamais
|
||||
d'une saisie.
|
||||
*/}
|
||||
{decl.puissance && detailPuissanceChoisie?.moteurs === 1 && libelleMoteur(detailPuissanceChoisie) ? (
|
||||
<p className="mt-2 flex items-center gap-2 text-sm text-muted">
|
||||
<Check size={14} className="text-accent shrink-0" strokeWidth={2.5} aria-hidden />
|
||||
Moteur identifié : {libelleMoteur(detailPuissanceChoisie)}
|
||||
</p>
|
||||
) : (
|
||||
<Hint>Ce nombre désigne le moteur — et c'est lui qui dit si ta voiture a une courroie de distribution, ce qu'aucun tableau de bord n'affiche.</Hint>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1140,13 +1305,17 @@ export function QuizClient() {
|
|||
</Ecran>
|
||||
)}
|
||||
|
||||
{/* ── 7. Contrats (§ 5.6, révisé par D-006) ─────────────────────
|
||||
{/* ── 7. Contrats (§ 5.6, révisé par D-006 puis D-007) ──────────
|
||||
Seulement ceux pertinents pour ce foyer (auto sans voiture n'a aucun sens) —
|
||||
deux champs optionnels max, "je ne sais pas" accepté sur l'échéance. */}
|
||||
deux champs optionnels max, "je ne sais pas" accepté sur l'échéance.
|
||||
|
||||
Copie corrigée par l'audit F8 (28/07/2026) : elle promettait un rattrapage post-quiz
|
||||
retiré depuis D-007 — rien n'était plus jamais reproposé. Le seul rattrapage réel
|
||||
est désormais le rejeu du quiz, réservé aux abonnés (garde-fou d'un rejeu par mois). */}
|
||||
{screen === 7 && (
|
||||
<Ecran
|
||||
titre="Des contrats à associer, tant qu'on y est ?"
|
||||
aide="Seulement ceux que tu as déjà. Les autres, on te les proposera au bon moment — rien n'est obligatoire ici."
|
||||
aide="Seulement ceux que tu as déjà — rien n'est obligatoire ici. Une fois abonné, tu pourras revenir compléter cet écran en rejouant le quiz."
|
||||
>
|
||||
{relevantContractTypesForProfil({
|
||||
hasVehicule: answers.vehicules.length > 0,
|
||||
|
|
@ -1228,11 +1397,13 @@ export function QuizClient() {
|
|||
|
||||
<div className="mt-9">
|
||||
<Button size="lg" onClick={submit}>
|
||||
Voir les échéances de mon foyer
|
||||
{rejouerId ? "Mettre à jour mon foyer" : "Voir les échéances de mon foyer"}
|
||||
<ArrowRight size={17} aria-hidden />
|
||||
</Button>
|
||||
<p className="mt-3 text-sm text-muted">
|
||||
Aucune adresse mail demandée. Le résultat s'affiche directement.
|
||||
{rejouerId
|
||||
? "Tes véhicules, personnes, animaux et contrats sont remplacés par ce que tu viens de déclarer."
|
||||
: "Aucune adresse mail demandée. Le résultat s'affiche directement."}
|
||||
</p>
|
||||
</div>
|
||||
</Ecran>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ const BASE = process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.g0tch.myds.me";
|
|||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [{ userAgent: "*", allow: "/", disallow: ["/foyer/", "/api/"] }],
|
||||
// /calendrier/ : le flux .ics par jeton (audit S4) — une capacité, jamais à indexer.
|
||||
rules: [{ userAgent: "*", allow: "/", disallow: ["/foyer/", "/api/", "/calendrier/"] }],
|
||||
sitemap: `${BASE}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,6 @@ import { cn } from "@/lib/cn";
|
|||
|
||||
/* ── Surfaces ──────────────────────────────────────────────────────────── */
|
||||
|
||||
export function Card({
|
||||
className,
|
||||
as: As = "div",
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLElement> & { as?: "div" | "section" | "article" | "li" }) {
|
||||
return <As className={cn("surface-card p-5 sm:p-6", className)} {...rest} />;
|
||||
}
|
||||
|
||||
/** Bloc pleine largeur qui rompt la colonne de lecture — sert à rythmer les pages longues. */
|
||||
export function Band({
|
||||
className,
|
||||
|
|
@ -67,43 +59,6 @@ export function Lede({ className, ...rest }: React.HTMLAttributes<HTMLParagraphE
|
|||
|
||||
/* ── Signalétique ──────────────────────────────────────────────────────── */
|
||||
|
||||
export type BadgeTone = "neutral" | "accent" | "warn" | "locked";
|
||||
|
||||
export function Badge({
|
||||
tone = "neutral",
|
||||
className,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLSpanElement> & { tone?: BadgeTone }) {
|
||||
const tones: Record<BadgeTone, string> = {
|
||||
neutral: "bg-surface-inset text-muted border-line",
|
||||
accent: "bg-accent-soft text-accent-soft-ink border-accent-line",
|
||||
warn: "bg-warn-soft text-warn border-warn-line",
|
||||
locked: "bg-transparent text-faint border-line border-dashed",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5",
|
||||
"text-[0.7rem] font-semibold tracking-wide uppercase",
|
||||
tones[tone],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pastille colorée d'un pilier. La couleur est passée en variable, jamais en classe. */
|
||||
export function PilierDot({ color, className }: { color: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("inline-block size-2 shrink-0 rounded-full", className)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Cartouche d'icône teintée par le pilier — repère de tri sur le hub et les guides. */
|
||||
export function IconTile({
|
||||
color,
|
||||
|
|
|
|||
|
|
@ -66,8 +66,24 @@ export const households = pgTable("households", {
|
|||
subscriptionPayerId: text("subscription_payer_id").references(() => user.id),
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||
/** Preuve du consentement légal (§ 10.7) — jamais de paiement sans elle. */
|
||||
/**
|
||||
* Preuve croisée du consentement (audit S5, 28/07/2026) — l'identifiant de la session Stripe
|
||||
* qui a horodaté `retractationWaivedAt`. Sans elle, l'horodatage seul ne prouve pas qu'il est
|
||||
* lié à un paiement réel plutôt qu'à une requête HTTP quelconque.
|
||||
*/
|
||||
stripeCheckoutSessionId: text("stripe_checkout_session_id"),
|
||||
/** Preuve du consentement légal (§ 10.7) — jamais de paiement sans elle. Horodaté par le webhook Stripe uniquement (audit S5). */
|
||||
retractationWaivedAt: timestamp("retractation_waived_at"),
|
||||
/**
|
||||
* Jeton du flux .ics (audit S4) — capacité dédiée et révocable, distincte de l'UUID du foyer
|
||||
* qui circule dans chaque lien de partage. Généré à la première demande du flux.
|
||||
*/
|
||||
icsToken: text("ics_token").unique(),
|
||||
/**
|
||||
* Dernier rejeu du quiz par un abonné (F8, § foyer/quiz-rejoue) — garde-fou anti-abus : au
|
||||
* plus une fois par mois. `null` = jamais rejoué depuis la création.
|
||||
*/
|
||||
quizRejoueLe: timestamp("quiz_rejoue_le"),
|
||||
});
|
||||
|
||||
/** Membres égaux du foyer (§ 6.3) — pas de hiérarchie admin/invité. */
|
||||
|
|
|
|||
70
src/emails/garder-lien.tsx
Normal file
70
src/emails/garder-lien.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
render,
|
||||
} from "@react-email/components";
|
||||
|
||||
type Props = {
|
||||
url: string;
|
||||
/** Compte des échéances trouvées — rappelle ce que le lien redonne, sans rouvrir le hub ici. */
|
||||
total: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Le mail « je te renvoie ce lien » (garder-lien.tsx) — corrige F1 de l'audit du 28/07/2026 :
|
||||
* la route promettait cet envoi depuis D-005 sans jamais l'honorer.
|
||||
*
|
||||
* Pas d'authentification requise : l'URL est l'aperçu gratuit pré-paiement, volontairement
|
||||
* accessible à qui a le lien (§ 10.2) — c'est exactement ce que ce mail redonne.
|
||||
*/
|
||||
function GarderLienEmail({ url, total }: Props) {
|
||||
return (
|
||||
<Html lang="fr">
|
||||
<Head />
|
||||
<Preview>Le lien de ton foyer fokan</Preview>
|
||||
<Body style={{ backgroundColor: "#faf8f4", fontFamily: "sans-serif", padding: "2rem 0" }}>
|
||||
<Container style={{ backgroundColor: "#ffffff", borderRadius: 14, padding: "2rem", maxWidth: 480 }}>
|
||||
<Text style={{ fontSize: 12, textTransform: "uppercase", letterSpacing: 1, color: "#2e6650", fontWeight: 700 }}>
|
||||
fokan
|
||||
</Text>
|
||||
<Heading style={{ fontSize: 20, color: "#26302c" }}>Comme promis, voici ton lien</Heading>
|
||||
<Text style={{ color: "#6b7570", fontSize: 15, lineHeight: 1.6 }}>
|
||||
Ton foyer compte {total} échéance{total > 1 ? "s" : ""} sous contrôle. Garde ce mail —
|
||||
c'est le moyen le plus simple d'y revenir.
|
||||
</Text>
|
||||
<Section style={{ margin: "1.5rem 0" }}>
|
||||
<Link
|
||||
href={url}
|
||||
style={{
|
||||
background: "#2e6650",
|
||||
color: "#ffffff",
|
||||
padding: "0.85rem 1.5rem",
|
||||
borderRadius: 10,
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
display: "inline-block",
|
||||
}}
|
||||
>
|
||||
Retrouver mon foyer
|
||||
</Link>
|
||||
</Section>
|
||||
<Text style={{ color: "#6b7570", fontSize: 13 }}>
|
||||
Si tu n'es pas à l'origine de cette demande, ignore simplement ce mail —
|
||||
rien ne se passera.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderGarderLienEmail(props: Props): Promise<string> {
|
||||
return render(<GarderLienEmail {...props} />);
|
||||
}
|
||||
105
src/engine/quiz-rejeu.ts
Normal file
105
src/engine/quiz-rejeu.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
db,
|
||||
assets as assetsTable,
|
||||
deadlines as deadlinesTable,
|
||||
occurrences as occurrencesTable,
|
||||
notificationsLog,
|
||||
households as householdsTable,
|
||||
} from "@/db";
|
||||
import { quizToAssets, type QuizAnswers } from "./quiz-to-assets";
|
||||
import type { AssetType } from "@/knowledge/schema";
|
||||
|
||||
/** Le foyer, un logement : au plus un exemplaire — on les met à jour EN PLACE, jamais on ne les remplace. */
|
||||
const TYPES_SINGLETON: AssetType[] = ["foyer", "logement"];
|
||||
/** Véhicules, personnes, animaux, contrats : des listes, sans identité stable d'un rejeu à l'autre. */
|
||||
const TYPES_MULTI: AssetType[] = ["vehicule", "personne", "animal", "contrat"];
|
||||
|
||||
const TRENTE_JOURS_MS = 30 * 24 * 3600 * 1000;
|
||||
|
||||
/** Garde-fou anti-abus (audit F8) : au plus un rejeu par mois. */
|
||||
export function peutRejouerQuiz(quizRejoueLe: Date | null, maintenant = new Date()): boolean {
|
||||
return !quizRejoueLe || maintenant.getTime() - quizRejoueLe.getTime() >= TRENTE_JOURS_MS;
|
||||
}
|
||||
|
||||
/** Prochaine date à laquelle le rejeu redevient possible — `null` si déjà possible. */
|
||||
export function prochainRejouePossible(quizRejoueLe: Date | null): Date | null {
|
||||
return quizRejoueLe ? new Date(quizRejoueLe.getTime() + TRENTE_JOURS_MS) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejoue le quiz pour un foyer EXISTANT (audit F8, 28/07/2026) : le quiz reste la référence
|
||||
* unique pour mettre à jour ses déclarations, y compris après le premier passage — jamais un
|
||||
* second formulaire ad hoc dans le hub qui dupliquerait ses propres règles d'inférence.
|
||||
*
|
||||
* Deux traitements, selon que l'asset a une instance unique ou plusieurs par foyer :
|
||||
*
|
||||
* - **foyer, logement** (au plus un exemplaire) : mis à jour EN PLACE, même `id`. Les échéances
|
||||
* qui s'y rattachent — la quasi-totalité du pilier maison — gardent leur état utilisateur
|
||||
* (muet, responsable, historique des occurrences), conformément à l'invariant n° 3.
|
||||
* - **véhicule, personne, animal, contrat** (des listes) : REMPLACÉS entièrement. Il n'existe
|
||||
* aucune correspondance fiable entre « l'ancienne deuxième voiture » et « la nouvelle
|
||||
* deuxième voiture » d'une liste rejouée sans identifiant stable — deviner échouerait en
|
||||
* silence, ce que la fiche `distribution` et son exigence d'unanimité proscrivent déjà
|
||||
* ailleurs. Remplacer franchement plutôt que faire semblant de faire correspondre est le
|
||||
* compromis assumé de F8 : leurs échéances repartent de zéro (mute et responsable perdus).
|
||||
* C'est à l'écran de rejeu de le dire avant validation, pas à ce module de le cacher.
|
||||
*
|
||||
* Idempotent au sens où rejouer deux fois les MÊMES réponses ne change rien à l'état des assets
|
||||
* singleton (mise à jour, pas insertion) — mais recrée systématiquement les assets multiples,
|
||||
* ce qui est le prix du remplacement franc ci-dessus.
|
||||
*/
|
||||
export async function rejouerQuiz(householdId: string, answers: QuizAnswers): Promise<void> {
|
||||
const database = db();
|
||||
const nouveaux = quizToAssets(answers);
|
||||
const existants = await database.select().from(assetsTable).where(eq(assetsTable.householdId, householdId));
|
||||
|
||||
for (const type of TYPES_SINGLETON) {
|
||||
const nouveau = nouveaux.find((a) => a.type === type);
|
||||
if (!nouveau) continue;
|
||||
const existant = existants.find((a) => a.assetType === type);
|
||||
if (existant) {
|
||||
await database
|
||||
.update(assetsTable)
|
||||
.set({ label: nouveau.label, attributes: nouveau.attributes })
|
||||
.where(eq(assetsTable.id, existant.id));
|
||||
} else {
|
||||
await database.insert(assetsTable).values({
|
||||
householdId, assetType: nouveau.type, label: nouveau.label, attributes: nouveau.attributes, provenance: "quiz",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const aRemplacer = existants.filter((a) => TYPES_MULTI.includes(a.assetType as AssetType));
|
||||
if (aRemplacer.length) {
|
||||
const assetIds = aRemplacer.map((a) => a.id);
|
||||
const deadlineRows = await database
|
||||
.select({ id: deadlinesTable.id })
|
||||
.from(deadlinesTable)
|
||||
.where(inArray(deadlinesTable.assetId, assetIds));
|
||||
const deadlineIds = deadlineRows.map((d) => d.id);
|
||||
if (deadlineIds.length) {
|
||||
// Ordre imposé par les clés étrangères : l'outbox puis la timeline puis les échéances.
|
||||
await database.delete(notificationsLog).where(inArray(notificationsLog.deadlineId, deadlineIds));
|
||||
await database.delete(occurrencesTable).where(inArray(occurrencesTable.deadlineId, deadlineIds));
|
||||
await database.delete(deadlinesTable).where(inArray(deadlinesTable.id, deadlineIds));
|
||||
}
|
||||
await database.delete(assetsTable).where(inArray(assetsTable.id, assetIds));
|
||||
}
|
||||
|
||||
const aCreer = nouveaux.filter((a) => TYPES_MULTI.includes(a.type));
|
||||
if (aCreer.length) {
|
||||
await database.insert(assetsTable).values(aCreer.map((a) => ({
|
||||
householdId,
|
||||
assetType: a.type,
|
||||
label: a.label,
|
||||
attributes: a.attributes,
|
||||
provenance: a.type === "vehicule" && a.attributes.source === "carte_grise" ? "carte_grise" : "quiz",
|
||||
})));
|
||||
}
|
||||
|
||||
await database
|
||||
.update(householdsTable)
|
||||
.set({ quizAnswers: answers, quizRejoueLe: new Date() })
|
||||
.where(eq(householdsTable.id, householdId));
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import { runReconciliation } from "@/engine/reconcile-db";
|
|||
import { enrichirZfeFoyer } from "./enrichissement-zfe";
|
||||
import { enrichirDistributionFoyer } from "./enrichissement-distribution";
|
||||
import { enrichirVeilleFoyer, type ReleveVeille } from "./veille-arretes";
|
||||
import { rattraperCritairFoyer } from "./enrichissement-critair";
|
||||
import { avecVerrou } from "@/lib/mutex";
|
||||
|
||||
export const QUEUE_GEORISQUES = "enrichissement-georisques";
|
||||
|
||||
|
|
@ -23,8 +25,25 @@ export const QUEUE_GEORISQUES = "enrichissement-georisques";
|
|||
* Appelée à deux endroits, et c'est voulu : en direct par POST /api/quiz, qui l'attend le temps
|
||||
* d'un plafond court pour que l'aperçu montre les risques dès son premier rendu, et en file
|
||||
* pg-boss quand ce plafond a été dépassé. Les `faits` alimentent l'écran de fabrication.
|
||||
*
|
||||
* C'est justement cette double porte d'entrée qui ouvrait la course relevée par l'audit F5 :
|
||||
* si le plafond tombe côté quiz, la route poste un job en plus de laisser l'appel en cours
|
||||
* continuer — deux exécutions concurrentes du même lire-modifier-écrire sur `assets`. Le verrou
|
||||
* en mémoire ci-dessous (`src/lib/mutex.ts`) les sérialise, sans rien changer à l'appelant.
|
||||
*/
|
||||
export async function enrichirRisquesFoyer(
|
||||
export function enrichirRisquesFoyer(
|
||||
householdId: string,
|
||||
): Promise<{
|
||||
enrichis: number;
|
||||
faits: string[];
|
||||
zfe: { vehicules: number; restreints: number };
|
||||
distribution: { vehicules: number; identifies: number; courroies: number };
|
||||
veille: ReleveVeille | null;
|
||||
}> {
|
||||
return avecVerrou(`enrichissement-risques:${householdId}`, () => enrichirRisquesFoyerInterne(householdId));
|
||||
}
|
||||
|
||||
async function enrichirRisquesFoyerInterne(
|
||||
householdId: string,
|
||||
): Promise<{
|
||||
enrichis: number;
|
||||
|
|
@ -94,6 +113,17 @@ export async function enrichirRisquesFoyer(
|
|||
return { vehicules: 0, identifies: 0, courroies: 0 };
|
||||
});
|
||||
|
||||
/**
|
||||
* Rattrapage Crit'Air (audit F6) : jusqu'ici appelé uniquement depuis POST /api/quiz, donc
|
||||
* jamais pour un véhicule non classé au quiz (année inconnue, modèle absent du catalogue) d'un
|
||||
* foyer sans adresse — ni pour un foyer créé avant que l'attribut n'existe. Ce passage tourne
|
||||
* pour tous les foyers chaque semaine, adresse ou non : c'est le bon endroit pour rattraper ce
|
||||
* que le quiz n'a pas pu classer, à mesure que le référentiel RDW s'enrichit.
|
||||
*/
|
||||
await rattraperCritairFoyer(householdId).catch((e) => {
|
||||
console.error("[critair] rattrapage impossible, reste du foyer conservé :", String(e));
|
||||
});
|
||||
|
||||
if (enrichis || zfe.vehicules || distribution.identifies) await runReconciliation(householdId);
|
||||
// `veille` reste à part de `faits` : ce sont deux étapes distinctes de l'écran de fabrication,
|
||||
// et les mélanger reviendrait à annoncer un événement du mois sous le titre d'un état du lieu.
|
||||
|
|
|
|||
|
|
@ -20,17 +20,6 @@
|
|||
export const CLASSES_CRITAIR = ["0", "1", "2", "3", "4", "5", "non_classe"] as const;
|
||||
export type ClasseCritair = (typeof CLASSES_CRITAIR)[number];
|
||||
|
||||
/** Libellé lisible, tel qu'on l'écrit dans une fiche ou un mail. */
|
||||
export const LIBELLE_CRITAIR: Record<ClasseCritair, string> = {
|
||||
"0": "Crit'Air 0 (verte)",
|
||||
"1": "Crit'Air 1",
|
||||
"2": "Crit'Air 2",
|
||||
"3": "Crit'Air 3",
|
||||
"4": "Crit'Air 4",
|
||||
"5": "Crit'Air 5",
|
||||
non_classe: "non classé",
|
||||
};
|
||||
|
||||
/**
|
||||
* Le référentiel RDW publie la norme sous des formes très variées — « EURO 6 W », « EURO 5 J »,
|
||||
* « EURO VI D », « EURO III » — où la lettre finale désigne une sous-étape d'homologation qui
|
||||
|
|
|
|||
|
|
@ -202,6 +202,30 @@ async function codesEnLice(
|
|||
} else if (resolu) {
|
||||
filtres.push(eq(vehiculesReference.marque, resolu.marque));
|
||||
filtres.push(eq(vehiculesReference.modele, resolu.modele));
|
||||
/**
|
||||
* Bornage par l'année déclarée (audit § 3.3 point 2) — seulement sur cette voie : la voie
|
||||
* champ K restreint déjà à UNE réception précise, l'année n'y ajouterait rien. Sur un modèle
|
||||
* vendu quinze ans, c'est ce qui sépare « douze moteurs candidats » de « trois » : sans lui,
|
||||
* l'exigence d'unanimité d'`accorderDistribution` échoue presque toujours sur un long
|
||||
* millésime, faute d'accord entre des moteurs qui n'ont jamais coexisté.
|
||||
*
|
||||
* Marge de ±2 ans : le champ K date l'homologation d'un TYPE, pas la sortie d'un exemplaire
|
||||
* précis (D-004) — resserrer davantage risquerait d'écarter à tort la bonne motorisation.
|
||||
* Une ligne sans date connue n'est jamais exclue : l'absence d'information ne doit jamais
|
||||
* se travestir en exclusion (§ 4.3).
|
||||
*/
|
||||
const anneeDeclaree = typeof attributs.date_premiere_immat === "string"
|
||||
? Number(attributs.date_premiere_immat.slice(0, 4))
|
||||
: typeof attributs.annee === "number" ? attributs.annee : null;
|
||||
if (anneeDeclaree && Number.isInteger(anneeDeclaree)) {
|
||||
filtres.push(sql`(
|
||||
${vehiculesReference.dateReception} is null
|
||||
or (
|
||||
left(${vehiculesReference.dateReception}, 4) ~ '^[0-9]{4}$'
|
||||
and left(${vehiculesReference.dateReception}, 4)::int between ${anneeDeclaree - 2} and ${anneeDeclaree + 2}
|
||||
)
|
||||
)`);
|
||||
}
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,9 +84,6 @@ export const DeadlineTemplate = z.object({
|
|||
});
|
||||
export type DeadlineTemplate = z.infer<typeof DeadlineTemplate>;
|
||||
|
||||
export const Knowledge = z.object({ templates: z.array(DeadlineTemplate) });
|
||||
export type Knowledge = z.infer<typeof Knowledge>;
|
||||
|
||||
/** Un asset instancié (couche 2) — un pilier n'est pas une table, c'est un asset_type. */
|
||||
export type Asset = {
|
||||
type: z.infer<typeof AssetType>;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm";
|
||||
import { db, vehiculesModeles, vehiculesMotorisations, vehiculesReference } from "@/db";
|
||||
import { type Energie, energiesDe, normText, reparerLibelle } from "./vehicules-cle";
|
||||
|
||||
|
|
@ -56,6 +56,12 @@ export type MotorisationsEnergie = {
|
|||
/** Cylindrée et cylindres du moteur quand il est unique — de quoi le nommer lisiblement. */
|
||||
cylindree: number | null;
|
||||
cylindres: number | null;
|
||||
/**
|
||||
* Le même détail, mais PAR valeur de P.2 (clé = puissance en texte) — audit § 3.3 point 3 :
|
||||
* une fois une puissance choisie parmi plusieurs, on peut nommer le moteur qu'elle désigne
|
||||
* plutôt que de n'afficher qu'un nombre en kW, si cette puissance précise n'a qu'un moteur.
|
||||
*/
|
||||
detailParPuissance: Record<string, { moteurs: number; cylindree: number | null; cylindres: number | null }>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -225,43 +231,36 @@ export async function identifierParReception(
|
|||
}
|
||||
|
||||
/**
|
||||
* Ce que la réception permet de proposer pour le champ P.2, énergie par énergie.
|
||||
*
|
||||
* Une seule requête pour toutes les énergies : le quiz demande l'énergie APRÈS l'identification,
|
||||
* et faire un aller-retour de plus au moment du clic ferait clignoter l'écran pour rien.
|
||||
* Cœur partagé du calcul des motorisations proposables — par réception exacte (champ K) ou par
|
||||
* modèle canonique (voie déclarative, audit § 3.3 point 1). Groupé par (énergie, puissance) et
|
||||
* pas seulement par énergie : c'est ce qui permet de nommer le moteur d'une puissance précise
|
||||
* une fois choisie parmi plusieurs (§ 3.3 point 3), là où l'ancien regroupement ne savait nommer
|
||||
* que le cas où une SEULE puissance existait pour toute l'énergie.
|
||||
*
|
||||
* La motorisation purement électrique est écartée — pas de distribution, et la compter fausserait
|
||||
* le décompte de moteurs sur tous les hybrides. Le tri se fait sur l'absence de cylindres, jamais
|
||||
* sur l'indicateur électrique de la source, qui vaut « J » sur la ligne thermique d'un hybride.
|
||||
*/
|
||||
async function motorisationsParEnergie(
|
||||
reception: string,
|
||||
async function motorisationsCoeur(
|
||||
filtresBase: SQL[],
|
||||
energies: Energie[],
|
||||
tvv?: { variante?: string; version?: string },
|
||||
): Promise<Record<string, MotorisationsEnergie>> {
|
||||
if (!energies.length) return {};
|
||||
|
||||
const filtres = [
|
||||
eq(vehiculesReference.receptionNumero, reception),
|
||||
...filtresBase,
|
||||
inArray(vehiculesReference.energie, energies),
|
||||
sql`${vehiculesReference.puissanceKwMax} is not null and ${vehiculesReference.puissanceKwMax} <> ''`,
|
||||
sql`${vehiculesMotorisations.nbCylindres} is not null and ${vehiculesMotorisations.nbCylindres} > 0`,
|
||||
];
|
||||
if (tvv?.variante) filtres.push(eq(vehiculesReference.variante, tvv.variante.trim()));
|
||||
if (tvv?.version) filtres.push(eq(vehiculesReference.version, tvv.version.trim()));
|
||||
|
||||
const rows = await db()
|
||||
// Vue globale par énergie — mêmes agrégats qu'avant l'introduction du détail par puissance
|
||||
// (§ 3.3 point 3) : c'est elle qui dit si UNE SEULE puissance existe, et donc si le P.2 n'a
|
||||
// même pas besoin d'être demandé (39 % des cas, mesuré à D-017).
|
||||
const parEnergie = await db()
|
||||
.select({
|
||||
energie: vehiculesReference.energie,
|
||||
/**
|
||||
* `json_agg` et non `array_agg` : le pilote rend un `numeric[]` sous forme de CHAÎNE
|
||||
* (« {96.00,133.00} »), qu'il faudrait alors découper à la main. Le JSON, lui, arrive en
|
||||
* vrai tableau de nombres — et la conversion JSON supprime au passage les décimales qui
|
||||
* ne valent rien (96.00 → 96) tout en gardant celles qui comptent (121.40 → 121.4).
|
||||
*/
|
||||
puissances: sql<number[]>`coalesce(json_agg(distinct ${vehiculesReference.puissanceKwMax}::numeric), '[]'::json)`,
|
||||
moteurs: sql<number>`count(distinct ${vehiculesMotorisations.motorcodeNorm})::int`,
|
||||
// Un seul moteur : sa cylindrée le nomme pour un humain, là où « HN05 » ne dit rien.
|
||||
cylindree: sql<number | null>`case when count(distinct ${vehiculesMotorisations.motorcodeNorm}) = 1
|
||||
then max(${vehiculesMotorisations.cylindree}) end`,
|
||||
cylindres: sql<number | null>`case when count(distinct ${vehiculesMotorisations.motorcodeNorm}) = 1
|
||||
|
|
@ -277,18 +276,79 @@ async function motorisationsParEnergie(
|
|||
.where(and(...filtres))
|
||||
.groupBy(vehiculesReference.energie);
|
||||
|
||||
// Détail par puissance — permet de nommer le moteur d'UNE puissance précise une fois choisie
|
||||
// parmi plusieurs, là où la vue ci-dessus ne sait nommer que le cas « une seule puissance ».
|
||||
const parPuissance = await db()
|
||||
.select({
|
||||
energie: vehiculesReference.energie,
|
||||
puissance: sql<number>`${vehiculesReference.puissanceKwMax}::numeric`,
|
||||
moteurs: sql<number>`count(distinct ${vehiculesMotorisations.motorcodeNorm})::int`,
|
||||
cylindree: sql<number | null>`case when count(distinct ${vehiculesMotorisations.motorcodeNorm}) = 1
|
||||
then max(${vehiculesMotorisations.cylindree}) end`,
|
||||
cylindres: sql<number | null>`case when count(distinct ${vehiculesMotorisations.motorcodeNorm}) = 1
|
||||
then max(${vehiculesMotorisations.nbCylindres}) end`,
|
||||
})
|
||||
.from(vehiculesReference)
|
||||
.innerJoin(vehiculesMotorisations, and(
|
||||
eq(vehiculesMotorisations.receptionNumero, vehiculesReference.receptionNumero),
|
||||
eq(vehiculesMotorisations.variante, vehiculesReference.variante),
|
||||
eq(vehiculesMotorisations.version, vehiculesReference.version),
|
||||
eq(vehiculesMotorisations.revision, vehiculesReference.revision),
|
||||
))
|
||||
.where(and(...filtres))
|
||||
.groupBy(vehiculesReference.energie, sql`${vehiculesReference.puissanceKwMax}::numeric`);
|
||||
|
||||
const out: Record<string, MotorisationsEnergie> = {};
|
||||
for (const r of rows) {
|
||||
for (const r of parEnergie) {
|
||||
if (!r.energie) continue;
|
||||
out[r.energie] = {
|
||||
// Tri en JS et pas en SQL : `json_agg(distinct … order by …)` impose que l'expression du
|
||||
// tri soit celle du distinct, ce qui alourdit la requête pour trier au plus douze nombres.
|
||||
// `Number()` reste une ceinture de sécurité au cas où le pilote rendrait des chaînes.
|
||||
puissances: [...(r.puissances ?? [])].map(Number).filter(Number.isFinite).sort((a, b) => a - b),
|
||||
moteurs: r.moteurs,
|
||||
cylindree: r.cylindree,
|
||||
cylindres: r.cylindres,
|
||||
detailParPuissance: {},
|
||||
};
|
||||
}
|
||||
for (const r of parPuissance) {
|
||||
if (!r.energie || !Number.isFinite(r.puissance) || !out[r.energie]) continue;
|
||||
out[r.energie].detailParPuissance[String(r.puissance)] = {
|
||||
moteurs: r.moteurs, cylindree: r.cylindree, cylindres: r.cylindres,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce que la réception permet de proposer pour le champ P.2, énergie par énergie — voie champ K.
|
||||
* Une seule requête pour toutes les énergies : le quiz demande l'énergie APRÈS l'identification,
|
||||
* et faire un aller-retour de plus au moment du clic ferait clignoter l'écran pour rien.
|
||||
*/
|
||||
async function motorisationsParEnergie(
|
||||
reception: string,
|
||||
energies: Energie[],
|
||||
tvv?: { variante?: string; version?: string },
|
||||
): Promise<Record<string, MotorisationsEnergie>> {
|
||||
const filtres = [eq(vehiculesReference.receptionNumero, reception)];
|
||||
if (tvv?.variante) filtres.push(eq(vehiculesReference.variante, tvv.variante.trim()));
|
||||
if (tvv?.version) filtres.push(eq(vehiculesReference.version, tvv.version.trim()));
|
||||
return motorisationsCoeur(filtres, energies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce que le référentiel propose pour le champ P.2, agrégé sur TOUTES les réceptions d'un couple
|
||||
* marque/modèle — voie déclarative (audit § 3.3 point 1). Corrige le trou de câblage relevé le
|
||||
* 28/07/2026 : jusqu'ici, seule la voie champ K proposait P.2, alors que `codesEnLice` sait déjà
|
||||
* s'en servir sans réception. La voie déclarative, qui suppose de ne PAS avoir sa carte grise
|
||||
* sous la main, est vraisemblablement la voie majoritaire — c'était donc elle qui restait muette
|
||||
* sur la seule échéance véhicule qu'aucun tableau de bord n'affiche.
|
||||
*/
|
||||
export async function motorisationsParModele(
|
||||
marque: string,
|
||||
modele: string,
|
||||
energies: Energie[],
|
||||
): Promise<Record<string, MotorisationsEnergie>> {
|
||||
const filtres = [eq(vehiculesReference.marque, marque), eq(vehiculesReference.modele, modele)];
|
||||
return motorisationsCoeur(filtres, energies);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,20 @@ type SessionUser = { id: string; email: string };
|
|||
|
||||
/**
|
||||
* Réconcilie l'appartenance au foyer (§ 6.3, § 9.3) :
|
||||
* - premier visiteur authentifié d'un foyer sans membre → il le réclame (l'organisateur du quiz) ;
|
||||
* - premier visiteur authentifié d'un foyer sans membre **et déjà payé** → il le réclame ;
|
||||
* - visiteur authentifié avec un token d'invitation valide pour SON email → il rejoint.
|
||||
* Jamais d'auto-jointure arbitraire sur un foyer qui a déjà des membres sans invitation.
|
||||
*
|
||||
* `isPaid` est une condition ajoutée par l'audit S3 (28/07/2026) : sans elle, un simple GET
|
||||
* anonyme-puis-authentifié sur un foyer non payé (partagé avant paiement, préchargé par un
|
||||
* client mail, crawlé) en faisait le propriétaire, définitivement. Un foyer gratuit ne se
|
||||
* réclame donc plus jamais tout seul — seul le webhook Stripe (option b, § 3 de l'audit) fait
|
||||
* passer `subscriptionStatus` à "active", ce qui est la condition qui ouvre cette porte.
|
||||
*/
|
||||
export async function resolveMembership(
|
||||
householdId: string,
|
||||
sessionUser: SessionUser | null,
|
||||
isPaid: boolean,
|
||||
inviteToken?: string,
|
||||
): Promise<Member[]> {
|
||||
const database = db();
|
||||
|
|
@ -29,7 +36,7 @@ export async function resolveMembership(
|
|||
.from(membershipsTable)
|
||||
.where(eq(membershipsTable.householdId, householdId));
|
||||
|
||||
if (currentMembers.length === 0) {
|
||||
if (currentMembers.length === 0 && isPaid) {
|
||||
await database.insert(membershipsTable).values({ householdId, userId: sessionUser.id });
|
||||
} else if (inviteToken) {
|
||||
const [invitation] = await database
|
||||
|
|
|
|||
23
src/lib/mutex.ts
Normal file
23
src/lib/mutex.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Verrou en mémoire, par clé (audit F5, 28/07/2026) — sérialise les tâches qui partagent la
|
||||
* même clé, sans jamais bloquer les autres.
|
||||
*
|
||||
* Pas un verrou Postgres (`pg_advisory_lock`) : la course qu'on referme se joue entre deux
|
||||
* appelants du MÊME processus Next.js (POST /api/quiz et le worker pg-boss, tous deux dans
|
||||
* `src/instrumentation.ts`) — un verrou en mémoire du process suffit, et évite de tenir une
|
||||
* connexion Postgres ouverte pendant des appels réseau lents (Géorisques, CatNat, VigiEau).
|
||||
*/
|
||||
|
||||
const verrous = new Map<string, Promise<void>>();
|
||||
|
||||
/** Exécute `tache` après que toute tâche précédente sur la même `cle` soit terminée. */
|
||||
export function avecVerrou<T>(cle: string, tache: () => Promise<T>): Promise<T> {
|
||||
const precedente = verrous.get(cle) ?? Promise.resolve();
|
||||
const resultat = precedente.then(tache, tache);
|
||||
const suivi = resultat.then(() => {}, () => {});
|
||||
verrous.set(cle, suivi);
|
||||
suivi.finally(() => {
|
||||
if (verrous.get(cle) === suivi) verrous.delete(cle);
|
||||
});
|
||||
return resultat;
|
||||
}
|
||||
64
src/lib/rate-limit.ts
Normal file
64
src/lib/rate-limit.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Limiteur de débit en mémoire (audit S1, 28/07/2026) — sans Redis (§ 22 : la file pg-boss est
|
||||
* déjà la seule dépendance d'infra ajoutée). Suffisant à l'échelle du produit : une seule
|
||||
* instance du serveur, aucun besoin de partager l'état entre processus.
|
||||
*
|
||||
* Un redémarrage remet les compteurs à zéro — acceptable : le risque visé est l'abus en continu
|
||||
* (script qui martèle une route), pas la persistance d'un historique d'abus entre déploiements.
|
||||
*/
|
||||
|
||||
type Fenetre = { compteur: number; finFenetre: number };
|
||||
|
||||
const fenetres = new Map<string, Fenetre>();
|
||||
|
||||
/** Purge opportuniste : une entrée sur mille déclenche un nettoyage, pour ne jamais grossir sans fin. */
|
||||
let appels = 0;
|
||||
|
||||
function purgerExpirees(maintenant: number) {
|
||||
for (const [cle, f] of fenetres) {
|
||||
if (f.finFenetre <= maintenant) fenetres.delete(cle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Autorise ou refuse un appel pour `cle` (typiquement `route:ip`), au plus `limite` fois par
|
||||
* `fenetreMs`. Fenêtre fixe (pas glissante) : suffisant pour se protéger d'un abus, pas conçu
|
||||
* pour un lissage fin du trafic.
|
||||
*/
|
||||
export function autoriser(cle: string, limite: number, fenetreMs: number): boolean {
|
||||
const maintenant = Date.now();
|
||||
if (++appels % 1000 === 0) purgerExpirees(maintenant);
|
||||
|
||||
const existante = fenetres.get(cle);
|
||||
if (!existante || existante.finFenetre <= maintenant) {
|
||||
fenetres.set(cle, { compteur: 1, finFenetre: maintenant + fenetreMs });
|
||||
return true;
|
||||
}
|
||||
if (existante.compteur >= limite) return false;
|
||||
existante.compteur++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* IP du visiteur derrière le reverse proxy (Caddy, § 24.2) — jamais `req.ip`, absent de
|
||||
* `NextRequest` hors Vercel. Première adresse de `x-forwarded-for` ; à défaut, une clé stable
|
||||
* qui regroupe tout le trafic sans en-tête connu plutôt que de planter.
|
||||
*/
|
||||
export function ipDe(req: Request): string {
|
||||
const xff = req.headers.get("x-forwarded-for");
|
||||
if (xff) return xff.split(",")[0].trim();
|
||||
return req.headers.get("x-real-ip") ?? "inconnue";
|
||||
}
|
||||
|
||||
const MINUTE = 60_000;
|
||||
const HEURE = 60 * MINUTE;
|
||||
|
||||
/** Plafonds par route (audit S1) — strict pour ce qui écrit ou sollicite des API d'État, plus large pour la lecture. */
|
||||
export const PLAFONDS = {
|
||||
quiz: { limite: 5, fenetreMs: HEURE },
|
||||
adresse: { limite: 60, fenetreMs: MINUTE },
|
||||
vehiculeTaxonomie: { limite: 60, fenetreMs: MINUTE },
|
||||
interet: { limite: 10, fenetreMs: HEURE },
|
||||
event: { limite: 120, fenetreMs: MINUTE },
|
||||
invitationsParUtilisateur: { limite: 10, fenetreMs: 24 * HEURE },
|
||||
} as const;
|
||||
Loading…
Add table
Reference in a new issue