Docs menu
Environnement de développement local#
Prérequis : Node ≥ 20, pnpm 9, Docker.
Démarrage#
cp .env.example .env # puis renseigner AUTH_SECRET
pnpm setup # install + base + migrations + référentielspnpm setup enchaîne pnpm install, pnpm db:up, pnpm db:migrate et pnpm db:seed.
Puis, selon la surface :
pnpm dev:web # Next.js — http://localhost:3001
pnpm dev:mobile # Expo — QR code, simulateur ou deviceBase de données#
PostgreSQL 16 en conteneur, exposé sur le port 5433 (et non 5432, pour ne pas entrer en conflit avec une instance déjà installée sur la machine).
| Commande | Effet |
|---|---|
pnpm db:up | Démarre le conteneur et attend qu'il soit sain |
pnpm db:down | Arrête le conteneur (les données sont conservées) |
pnpm db:reset | Détruit le volume, recrée, migre et réamorce |
pnpm db:generate | Génère une migration à partir du schéma |
pnpm db:migrate | Applique les migrations en attente |
pnpm db:seed | (Ré)amorce les référentiels — idempotent |
pnpm db:studio | Ouvre Drizzle Studio |
Connexion directe : psql postgres://staff:staff@localhost:5433/staff
Règles#
- Drizzle ORM uniquement pour le schéma et les migrations — jamais de fichier SQL écrit à la main.
- Après toute modification de
packages/db/src/schema.ts:pnpm db:generate, puispnpm db:migrate. Les migrations générées sont versionnées. db:migrateest conditionnel : sansDATABASE_URLil sort en code 0 sans rien faire, de sorte qu'un build sans base (CI, Vercel) ne casse pas.- Le seed n'insère que des référentiels (régions, secteurs, types de postes, paramètres). Aucune donnée de démonstration : la base part vide côté comptes, missions et paiements.
Schéma#
packages/db/src/schema.ts — 21 tables couvrant le périmètre V1 de l'Annexe 1A,
du compte à l'ordre de règlement. Voir docs/contrat/PERIMETRE-V1.md.
Quelques choix qui portent une contrainte contractuelle :
| Point | Où | Pourquoi |
|---|---|---|
| Un (1) établissement par entreprise | index unique establishments_one_per_company_key | Annexe 1A. Supprimer cet index au mois 6 ouvre le multi-établissements — aucune autre reprise |
| SIREN sur l'entreprise, SIRET sur l'établissement | companies.registration_number / establishments.siret | Le mois 6 rattache « plusieurs points de vente à un même numéro d'identification d'entreprise » : confondre les deux imposerait une reprise du modèle |
| Montant total estimé persisté | missions.estimated_total_cents | C'est le montant opposable, celui présenté au prestataire — pas un calcul d'affichage |
| Preuve de la transparence | applications.amount_disclosed_at / disclosed_total_cents | « le prestataire ne peut accepter sans que ce montant lui ait été présenté » |
| Fenêtre de contestation | timesheets.dispute_window_ends_at | 24 h ; à expiration sans contestation, le règlement est déclenché |
| Journal des ordres de règlement | payment_orders | L'engagement de JAIKIN porte sur la transmission, pas sur le virement. Cette table est la preuve |
| Rayon et plafond paramétrables | platform_settings, missions.radius_km, missions.application_cap | Leur réglage au vu des volumes réels est prévu au mois 3 |
| Montants en centimes | partout | Aucun flottant sur de la monnaie |
| Coordonnées bancaires | psp_account_ref | Référence chez le prestataire de paiement — jamais d'IBAN en clair |
Deux paramètres sont volontairement sans valeur (NULL) :
billing.commission_rate_pct et billing.subscription_monthly_cents. Le contrat
ne fixe aucun barème — il est fourni par le Client (art. 6, ticket JAI-99).
Authentification — packages/auth#
Cœur d'authentification indépendant de tout framework : web et mobile partagent
le même mécanisme. Aucune dépendance native — scrypt et SHA-256 viennent de
node:crypto, donc le même code tourne en local, en CI et sur Vercel.
| Module | Rôle |
|---|---|
password.ts | Hachage scrypt (N=16384), vérification à temps constant, normalisation NFKC |
tokens.ts | Jetons opaques de 256 bits ; seul le condensat SHA-256 est stocké |
sessions.ts | Création, validation, prolongation glissante, révocation |
accounts.ts | Création de compte, vérification des identifiants, normalisation d'email |
password-reset.ts | Jeton à usage unique, durée de vie 1 h |
principal.ts | Cloisonnement des données — résolution du rattachement et gardes de rôle |
Ce qui est délibéré#
- Le mot de passe n'est jamais stocké en clair, les jetons non plus : la base
ne contient que des condensats. Une fuite de
sessionsne permet pas d'usurper. - Email inconnu et mot de passe faux renvoient le même message, et un condensat factice est tout de même calculé — sinon l'écart de temps de réponse permettrait d'énumérer les comptes.
- « Mot de passe oublié » ne dit jamais si l'adresse existe :
createPasswordResetTokenretournenullsans lever, et la couche HTTP doit répondre identiquement. - Une réinitialisation révoque toutes les sessions de l'utilisateur.
- L'unicité de l'email est portée par l'index, pas par une lecture préalable : deux inscriptions simultanées ne peuvent pas passer toutes les deux.
- Aucune requête métier ne filtre sur un identifiant fourni par le client.
Elle filtre sur le
companyId/providerIddu principal résolu côté serveur.
Règle de cloisonnement#
const user = await requireSession(db, token);
const principal = await resolvePrincipal(db, user);
const company = requireCompany(principal); // lève si ce n'est pas une entreprise
assertOwnsEstablishment(company, establishmentId); // lève si l'établissement est celui d'un autreUn compte dont le profil métier n'existe pas encore (inscription interrompue) n'a aucun principal : il peut s'authentifier, pas agir sur des données.
API — packages/api (tRPC)#
Montée sur apps/web à /api/trpc/[trpc], elle sert le web et le mobile.
Le typage descend du serveur vers les deux clients : renommer une procédure ou
changer un champ casse la compilation des applications, pas seulement de l'API.
L'authentification est le contexte#
Le contexte est résolu une fois par requête, avant toute procédure : lecture du jeton, validation de la session, résolution du principal. Les procédures n'ont plus qu'à déclarer le rattachement qu'elles exigent.
| Procédure | Garantit |
|---|---|
publicProcedure | Rien — inscription, connexion, mot de passe oublié |
protectedProcedure | ctx.user non nul |
companyProcedure | ctx.principal typé CompanyPrincipal (companyId, establishmentId) |
providerProcedure | ctx.principal typé ProviderPrincipal (providerId) |
adminProcedure | Compte d'administration |
L'autorisation tient au choix de la procédure, jamais à un test répété dans chaque route — c'est ce qui rend le cloisonnement difficile à oublier :
export const missionsRouter = router({
publier: companyProcedure // ⟵ le rattachement est déjà garanti ici
.input(schema)
.mutation(({ ctx, input }) => {
// ctx.principal.establishmentId vient du serveur, jamais du client
}),
});Transport du jeton#
| Client | Véhicule | Pourquoi |
|---|---|---|
| Web | Cookie HttpOnly, SameSite=Lax | Hors de portée du JavaScript de la page |
| Mobile | Authorization: Bearer | Pas de cookie exploitable ; jeton en Keychain/Keystore |
Les mutations qui ouvrent une session posent le cookie et renvoient le jeton : un seul chemin de code sert les deux clients.
Clients#
| Fichier | Usage |
|---|---|
apps/web/src/lib/api.ts | Navigateur — s'authentifie par cookie |
apps/web/src/lib/api-server.ts | Composants serveur — appel direct, lectures seulement |
apps/mobile/src/lib/api.ts | Expo — jeton lu à chaque requête |
apps/mobile/src/lib/session-store.ts | Keychain (iOS) / Keystore (Android) |
En développement mobile, localhost désigne le téléphone : définir
EXPO_PUBLIC_API_URL=http://<ip-machine>:3001/api/trpc.
Erreurs#
Les erreurs d'authentification sont traduites en codes HTTP corrects
(400 / 401 / 403) et portent un authCode stable pour l'affichage
côté client. Elles ne doivent jamais repartir en 500 : une faute de frappe
dans un mot de passe compterait alors comme incident serveur et noierait la
surveillance quotidienne prévue à l'article 3.3. Des tests verrouillent ce point.
Inscriptions — JAI-112 et JAI-113#
Deux temps, volontairement séparés : auth.register crée le compte ;
company.createProfile / provider.createProfile créent le profil métier.
Entre les deux, auth.me renvoie principal: null — c'est ce qui permet au
client de reprendre une inscription interrompue là où elle s'est arrêtée.
Numéros d'identification#
packages/api/src/validation/identifiers.ts vérifie les clés de contrôle
SIREN (9 chiffres) et SIRET (14), y compris la dérogation des établissements de
La Poste. C'est un contrôle hors ligne : une faute de frappe est signalée
immédiatement, sans appel réseau — ce qui sert directement l'objectif d'un
parcours prestataire en moins de cinq minutes.
⚠️ L'Annexe 1A demande la « vérification de l'existence » du numéro. La clé de contrôle ne prouve pas l'existence au registre : cela suppose un service tiers non retenu à ce jour et absent de l'Annexe 5.
validation/registry.tstient la place et répondchecked: false— l'API le dit explicitement au client plutôt que de laisser croire à une vérification qui n'a pas eu lieu.
Entreprise (JAI-112)#
- SIREN sur
companies, SIRET surestablishments: le SIRET doit relever du SIREN déclaré (ses 9 premiers chiffres). Sans ce contrôle, un compte pourrait rattacher l'établissement d'une autre entreprise. - Entreprise + établissement + abonnement sont créés dans une transaction : un compte à moitié inscrit serait inutilisable et invisible.
- Abonnement en
gratuit_pilotesipilot.free_period_enabled. Le montant vient deplatform_settings— aucun tarif n'est inventé tant que le Client n'a pas fourni son barème (JAI-99). - Un second appel renvoie
CONFLICT: « ouvre droit à un (1) établissement ».
Prestataire (JAI-113)#
- Moins de 18 ans refusé, avec le libellé exact du cahier des charges. Le jour même de l'anniversaire est accepté (« 18 ans révolus »).
- Sans numéro d'indépendant :
independentStatus: "a_creer"etnextSteps.createIndependentStatus, soit l'orientation vers un parcours externe. L'assistance intégrée est en phase 2 (mois 7). - Le compte reste
incomplettant que les coordonnées bancaires ne sont pas rattachées — elles supposent le prestataire de paiement (JAI-94). - Un type de poste hors des secteurs choisis est refusé : il ne serait jamais diffusé.
Missions — JAI-114 à JAI-117#
Cycle couvert : publication → diffusion → candidature → sélection.
Publication (JAI-114)#
- Le montant total estimé est calculé par le serveur et persisté
(
missions.estimated_total_cents). C'est le montant opposable : le recalculer à l'affichage ouvrirait un écart entre ce qui a été montré et ce qui a été convenu. - L'adresse reprend celle de l'établissement, et demeure modifiable.
- Rayon et plafond viennent de
platform_settings, surchargeables par mission. Le plafond est borné à 5–8 (Annexe 1A) ; le rayon est borné à 100 km — une mission de quelques heures n'a pas de sens à 300 km. - La réponse indique le nombre de prestataires réellement joignables, pas un chiffre théorique : le gérant doit pouvoir juger ses chances de recrutement.
Diffusion (JAI-115)#
Haversine calculé en SQL (geo.ts), sans extension PostgreSQL — earthdistance
imposerait une extension à installer sur l'hébergement du Client pour un gain nul
à l'échelle de 10 km. Filtrage sur le rayon propre à chaque mission, secteurs
du prestataire, et classement par distance croissante (exigence explicite).
Limite de périmètre : la V1 se limite au calcul de distance. Le suivi de position en temps réel est expressément exclu — ne rien ajouter qui y ressemble.
Transparence (JAI-117)#
C'est une règle bloquante, pas un affichage :
mission.detailenregistre ce qui a été présenté dansmission_disclosures(taux horaire, durée, montant total).application.submitrefuse la candidature sans cette trace, et refuse aussi si le montant a changé depuis, ou si le montant confirmé par le client ne correspond pas.
La candidature conserve la preuve (amount_disclosed_at, disclosed_total_cents) :
la règle est vérifiable après coup, pas seulement appliquée à l'écran.
Candidatures et sélection (JAI-116)#
- Plafond appliqué sous verrou de ligne (
SELECT … FOR UPDATEsur la mission). Sans lui, des candidatures simultanées le franchissent — vérifié : huit candidats entraient sur cinq places. - Retirer une candidature libère une place.
- La sélection retient un candidat, écarte les autres et passe la mission à
pourvue, qui disparaît alors du fil de diffusion. listForMissionrenvoieNOT_FOUNDpour la mission d'une autre entreprise : unFORBIDDENrévélerait son existence.
Interface web#
pnpm seed:dev # 3 comptes de démonstration
pnpm dev:web # http://localhost:3001/connexion| Écran | État |
|---|---|
/connexion | Réel — formulaire + connexion rapide (dev uniquement) |
/mon-compte | Réel — session et déconnexion |
/etablissement/missions | Réel — liste des missions publiées |
/etablissement/missions/publier | Réel — publication (JAI-114) |
/etablissement/missions/[id] | Réel — candidats et sélection (JAI-116) |
/etablissement/{vue-ensemble,publier,suivi,noter,factures} | Maquette — données mockées, à traiter avec JAI-118 |
/admin/* | Maquette — données mockées |
Connexion rapide en développement#
Le bloc de connexion rapide de /connexion n'existe qu'en développement.
La décision est prise dans le composant serveur : en production la liste des
comptes est vide, donc aucun identifiant n'est sérialisé dans la page. Une
seconde garde process.env.NODE_ENV côté client rend la branche prouvablement
morte, si bien que le balisage lui-même disparaît du bundle.
Vérifié sur un vrai next build && next start : ni les identifiants, ni le
libellé du bloc n'apparaissent dans .next/static ni dans la page rendue.
pnpm seed:devrefuse de s'exécuter siNODE_ENV=production: ces mots de passe sont publics dans le dépôt.
Gardes de session#
/etablissement/* et /admin/* sont protégés côté serveur, dans leur
layout : sans session du bon rôle, redirection vers /connexion avant tout
rendu. Masquer l'interface côté client aurait laissé les données transiter.
Ces gardes ne remplacent pas les contrôles de l'API — chaque procédure vérifie le rattachement du principal de son côté. C'est de la défense en profondeur.
Exécution des missions — JAI-120 à JAI-122#
Pointage par scan (JAI-120)#
L'entreprise obtient un code (timesheet.scanCode) qu'elle présente sur
site ; le prestataire scanne pour démarrer, puis pour terminer. Le décompte
horaire est automatique.
- La base ne conserve que le condensat du code : une fuite de table ne permet pas de pointer à distance.
- Régénérer un code révoque le précédent.
- Le double scan d'entrée, et la sortie sans entrée, sont refusés.
Adresse et itinéraire (JAI-121)#
L'adresse complète et le lien d'itinéraire ne sont révélés qu'au prestataire
retenu (timesheet.assignment). La fiche de diffusion s'en tient à la
commune — un prestataire non retenu obtient NOT_FOUND.
Validation et contestation (JAI-122)#
À la sortie, une fenêtre de contestation de 24 h s'ouvre
(timesheet.dispute_window_hours). L'entreprise valide, ou conteste dans le
délai. timesheet.settleDue traite les fenêtres échues — destinée à une tâche
planifiée.
⚠️ Aucune commission n'est inventée. Le barème est fourni par le Client (art. 6, JAI-99). Tant que
billing.commission_rate_pctvautnull, les heures sont validées mais l'ordre de règlement n'est pas établi :settleDueremonte le nombre de relevés en attente pour cette raison. Mieux vaut un règlement en attente qu'un prélèvement au hasard.
L'ordre de règlement est créé au statut a_transmettre : sa transmission au
prestataire de paiement relève de JAI-124, et c'est sur cette transmission —
non sur le virement — que porte l'engagement de JAIKIN.
Tests#
pnpm test # tout le dépôt
pnpm --filter @staff/api test # un paquet176 tests. Les suites d'intégration tournent contre PostgreSQL et sont
ignorées proprement si DATABASE_URL est absent.
Chaque paquet a sa propre base de test (
staff_test_auth,staff_test_api), créée et migrée automatiquement au démarrage de la suite. Sans cela, deux paquets testés en parallèle par turbo videraient les tables l'un de l'autre — des échecs qui n'apparaissent qu'en exécution groupée et disparaissent quand on relance le paquet seul. La base de développement n'est jamais touchée.
Vérifications avant de pousser#
pnpm build # web (Next) + mobile (expo export)
pnpm typecheck
pnpm testBranches : DEV et PRODUCTION uniquement. Le web se déploie via
l'intégration git Vercel, jamais par vercel deploy.
Ce qui n'est pas encore en place#
- Écrans — l'API d'authentification et d'inscription est complète et testée ; les formulaires et la reprise de session au démarrage de l'app restent à faire.
- Consultation du registre des entreprises — service tiers non retenu
(
validation/registry.ts). Seule la clé de contrôle est vérifiée. - Géocodage des adresses —
company.createProfileattend des coordonnées fournies par l'appelant. Aucun service de géocodage n'est retenu ; le mobile peut s'appuyer sur la position de l'appareil, le gérant étant sur place. - Suppression de compte — un utilisateur rattaché à une entreprise ne peut pas être supprimé tel quel (contrainte de clé étrangère, volontaire). Le droit à l'effacement du RGPD demandera un parcours explicite (art. 15, DPA JAI-111).
- Intégration React Query — le client actuel est le client tRPC nu, utilisable
partout. Le branchement
@trpc/tanstack-react-queryviendra avec les écrans qui en ont besoin. - Limitation du nombre de tentatives de connexion — nécessite un compteur partagé (l'exécution est sans état côté Vercel), donc un choix d'infrastructure.
- Envoi des emails — le jeton de réinitialisation est produit, son acheminement non (service tiers à la charge du Client, Annexe 5). En développement, le jeton est écrit dans la console.
- Prestataire de paiement — non choisi (JAI-94).
PSP_API_KEYest vide dans.env.example. - Circuit de facturation — arrêté par le Client à la session de cadrage (JAI-110).
documents.payloadreste volontairement libre tant que cet arbitrage n'est pas rendu. - Les apps consomment encore les données mockées de
apps/*/src/data; le branchement sur la base est le travail du Lot 1.