Docs menu

Environnement de développement local#

Prérequis : Node ≥ 20, pnpm 9, Docker.

Démarrage#

bash
cp .env.example .env     # puis renseigner AUTH_SECRET
pnpm setup               # install + base + migrations + référentiels

pnpm setup enchaîne pnpm install, pnpm db:up, pnpm db:migrate et pnpm db:seed.

Puis, selon la surface :

bash
pnpm dev:web             # Next.js — http://localhost:3001
pnpm dev:mobile          # Expo — QR code, simulateur ou device

Base 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).

CommandeEffet
pnpm db:upDémarre le conteneur et attend qu'il soit sain
pnpm db:downArrête le conteneur (les données sont conservées)
pnpm db:resetDétruit le volume, recrée, migre et réamorce
pnpm db:generateGénère une migration à partir du schéma
pnpm db:migrateApplique les migrations en attente
pnpm db:seed(Ré)amorce les référentiels — idempotent
pnpm db:studioOuvre 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, puis pnpm db:migrate. Les migrations générées sont versionnées.
  • db:migrate est conditionnel : sans DATABASE_URL il 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 :

PointPourquoi
Un (1) établissement par entrepriseindex unique establishments_one_per_company_keyAnnexe 1A. Supprimer cet index au mois 6 ouvre le multi-établissements — aucune autre reprise
SIREN sur l'entreprise, SIRET sur l'établissementcompanies.registration_number / establishments.siretLe 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_centsC'est le montant opposable, celui présenté au prestataire — pas un calcul d'affichage
Preuve de la transparenceapplications.amount_disclosed_at / disclosed_total_cents« le prestataire ne peut accepter sans que ce montant lui ait été présenté »
Fenêtre de contestationtimesheets.dispute_window_ends_at24 h ; à expiration sans contestation, le règlement est déclenché
Journal des ordres de règlementpayment_ordersL'engagement de JAIKIN porte sur la transmission, pas sur le virement. Cette table est la preuve
Rayon et plafond paramétrablesplatform_settings, missions.radius_km, missions.application_capLeur réglage au vu des volumes réels est prévu au mois 3
Montants en centimespartoutAucun flottant sur de la monnaie
Coordonnées bancairespsp_account_refRé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.

ModuleRôle
password.tsHachage scrypt (N=16384), vérification à temps constant, normalisation NFKC
tokens.tsJetons opaques de 256 bits ; seul le condensat SHA-256 est stocké
sessions.tsCréation, validation, prolongation glissante, révocation
accounts.tsCréation de compte, vérification des identifiants, normalisation d'email
password-reset.tsJeton à usage unique, durée de vie 1 h
principal.tsCloisonnement 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 sessions ne 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 : createPasswordResetToken retourne null sans 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 / providerId du principal résolu côté serveur.

Règle de cloisonnement#

ts
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 autre

Un 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édureGarantit
publicProcedureRien — inscription, connexion, mot de passe oublié
protectedProcedurectx.user non nul
companyProcedurectx.principal typé CompanyPrincipal (companyId, establishmentId)
providerProcedurectx.principal typé ProviderPrincipal (providerId)
adminProcedureCompte 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 :

ts
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#

ClientVéhiculePourquoi
WebCookie HttpOnly, SameSite=LaxHors de portée du JavaScript de la page
MobileAuthorization: BearerPas 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#

FichierUsage
apps/web/src/lib/api.tsNavigateur — s'authentifie par cookie
apps/web/src/lib/api-server.tsComposants serveur — appel direct, lectures seulement
apps/mobile/src/lib/api.tsExpo — jeton lu à chaque requête
apps/mobile/src/lib/session-store.tsKeychain (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.ts tient la place et répond checked: 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 sur establishments : 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_pilote si pilot.free_period_enabled. Le montant vient de platform_settingsaucun 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" et nextSteps.createIndependentStatus, soit l'orientation vers un parcours externe. L'assistance intégrée est en phase 2 (mois 7).
  • Le compte reste incomplet tant 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 :

  1. mission.detail enregistre ce qui a été présenté dans mission_disclosures (taux horaire, durée, montant total).
  2. application.submit refuse 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 UPDATE sur 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.
  • listForMission renvoie NOT_FOUND pour la mission d'une autre entreprise : un FORBIDDEN révélerait son existence.

Interface web#

bash
pnpm seed:dev      # 3 comptes de démonstration
pnpm dev:web       # http://localhost:3001/connexion
ÉcranÉtat
/connexionRéel — formulaire + connexion rapide (dev uniquement)
/mon-compteRéel — session et déconnexion
/etablissement/missionsRéel — liste des missions publiées
/etablissement/missions/publierRé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:dev refuse de s'exécuter si NODE_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_pct vaut null, les heures sont validées mais l'ordre de règlement n'est pas établi : settleDue remonte 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#

bash
pnpm test                        # tout le dépôt
pnpm --filter @staff/api test    # un paquet

176 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#

bash
pnpm build         # web (Next) + mobile (expo export)
pnpm typecheck
pnpm test

Branches : 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 adressescompany.createProfile attend 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-query viendra 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_KEY est vide dans .env.example.
  • Circuit de facturation — arrêté par le Client à la session de cadrage (JAI-110). documents.payload reste 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.
Last updated Aug 13, 2026Powered by GitDoc — CleverAI