Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

📚 Documentation CerOps

Ce dossier contient la documentation fonctionnelle et technique du projet CerOps.

Objectif

Centraliser la vision produit, les choix techniques et les règles métier afin de :

  • faciliter le développement
  • aligner l’équipe
  • préparer les livrables (école / pitch / démo)

Structure

La liste faisant foi est SUMMARY.md (sommaire mdBook). Aperçu :

  • parcelles.md → gestion des parcelles agricoles
  • agriculteurs.md → gestion des utilisateurs agriculteurs
  • pilotes-drone.md → gestion des pilotes de drone
  • marketplace.md → mise en relation agriculteurs ↔ pilotes
  • imagerie-parcellaire.md → traitement et exploitation des images
  • pricing.md → grille tarifaire
  • plan-actions.md → recommandations et suivi d’actions
  • mobile-offline-strategy.md → fonctionnement mobile + offline
  • api-contrats.md → contrats entre frontend et backend
  • rpg-import.md → import RPG
  • devops.md, release.md → infrastructure & déploiement
  • architecture.md, use-cases.md, politique-tests.md, business-plan.md, diagrams.md → pages stratégie
  • ADR/ → décisions d’architecture (ADR-001 à ADR-013)

Règles

  • 1 fichier = 1 domaine
  • Utiliser le template commun
  • Mettre à jour la doc en même temps que le code

Guide utilisateur

Bienvenue dans le dossier d’utilisation de CerOps, la plateforme de surveillance de parcelles agricoles par satellite et par drone.

À qui s’adresse ce guide

Vous êtes…ApplicationGuide
Agriculteur (web)https://cerops.jayllyz.frGuide agriculteur — web
Agriculteur (mobile)App Android com.cerops.mobileGuide agriculteur — mobile
Pilote de dronePortail pilotesGuide pilote de drone

Les questions fréquentes sont regroupées dans la FAQ.

Découvrir la plateforme avec un compte de démonstration

Des comptes de démonstration pré-remplis (exploitation, parcelles, analyses satellite) sont disponibles pour explorer la plateforme sans créer de compte :

ProfilIdentifiantConnexion
Agriculteuragri@cerops.frhttps://cerops.jayllyz.fr/login
Pilote de dronepilot@cerops.frhttps://cerops-pilots.jayllyz.fr/login

Mot de passe : Demo1234!

L’application mobile est publiée sur le Google Play Store en test interne (early access) : fiche Play Store · accès testeur.

Les comptes de démonstration sont réinitialisés périodiquement : ne stockez pas de données réelles dessus.

Ce que fait CerOps en un coup d’œil

  • Surveillance satellite automatique : chaque parcelle enregistrée est suivie par imagerie Sentinel-2 (passage tous les ~5 jours) — biomasse, azote, chlorophylle, stress hydrique — sans aucune action de votre part.
  • Parcelles sans saisie fastidieuse : importez vos parcelles depuis le Registre Parcellaire Graphique (RPG) officiel ou tracez-les sur la carte.
  • Missions drone à la demande : commandez un vol (surveillance, cartographie, inspection, épandage) réalisé par un pilote certifié ; le paiement n’est débité qu’à la livraison.
  • Plans d’actions : transformez les analyses en tâches terrain suivies, avec notifications.
  • Mobile hors-ligne : consultez vos parcelles et gérez vos actions en plein champ, même sans réseau.

Guide agriculteur — application web

Application : https://cerops.jayllyz.fr

1. Créer votre compte

  1. Rendez-vous sur la page d’accueil et lancez l’inscription (/onboarding).
  2. Étape 1 — Identité : renseignez prénom, nom, adresse e-mail, mot de passe (et sa confirmation), puis Continuer.
  3. Étape 2 — Exploitation : indiquez le nom de votre exploitation, puis Continuer.
  4. Étape 3 — Vos parcelles : ajoutez vos premières parcelles (voir ci-dessous) — cette étape peut être passée et complétée plus tard.
  5. Cliquez sur Commencer avec Cerops : vous arrivez sur votre tableau de bord.

Pour vous reconnecter : /login (e-mail + mot de passe). Mot de passe oublié : lien « Mot de passe oublié » (/forgot-password), un e-mail de réinitialisation vous est envoyé.

2. Le tableau de bord

Le tableau de bord (/dashboard) présente l’état de votre exploitation : parcelles suivies, dernières analyses satellite, alertes en cours, missions drone et tâches à faire. C’est votre point d’entrée quotidien.

3. Gérer vos parcelles

Menu Parcelles (/dashboard/plots).

Ajouter une parcelle

Deux méthodes (/dashboard/plots/new) :

  • Import RPG (recommandé) : recherchez vos parcelles dans le Registre Parcellaire Graphique officiel et revendiquez-les en quelques clics — géométrie et surface sont importées automatiquement.
  • Tracé manuel : dessinez le contour de la parcelle directement sur la carte ; la surface est calculée automatiquement.

Consulter une parcelle

La fiche parcelle (/dashboard/plots/<id>) affiche :

  • la carte de la parcelle et ses caractéristiques (surface, type de culture) ;
  • les cartes d’indices satellite : biomasse (NDVI), azote, chlorophylle, stress hydrique ;
  • l’historique temporel des indices, alimenté automatiquement à chaque passage Sentinel-2 (~5 jours) ;
  • les images et livrables issus des missions drone.

Si le ciel était couvert lors d’un passage satellite, la date est signalée comme inexploitable — c’est normal, la donnée reprend au passage suivant.

Les cultures (/dashboard/cultures/<id>) regroupent les parcelles d’une même culture pour une lecture agronomique consolidée.

4. Commander une mission drone

Menu Vols (/dashboard/flights).

  1. Nouveau vol (/dashboard/flights/new) : sélectionnez la ou les parcelles concernées.
  2. Choisissez le type de mission : surveillance, cartographie, inspection ou épandage.
  3. Ajoutez des options d’analyse si besoin (hydrométrie, azote…) — chaque option précise les capteurs requis.
  4. Définissez la fenêtre de réalisation (dates de début et de fin souhaitées).
  5. Validez : le prix est calculé automatiquement (base + surface × tarif/ha + options) et votre carte est pré-autorisée, pas débitée.

La mission est alors publiée sur la marketplace des pilotes certifiés. Vous suivez son avancement depuis la fiche vol (/dashboard/flights/<id>) : publiée → planifiée → en cours → livrée. Le paiement n’est capturé qu’à la livraison du livrable (orthophotos, cartes de chaleur, rapport).

5. Plans d’actions et tâches

Menu Tâches (/dashboard/tasks) : créez des plans d’actions à partir des analyses (ex. « apport d’azote zone nord ») et suivez les tâches associées (à faire / en cours / terminé), avec échéances et rattachement aux parcelles.

6. Rapports

Menu Rapports (/dashboard/reports) : consultez et exportez en PDF les rapports d’analyse de vos parcelles et missions.

7. Compte et abonnement

Menu Compte (/dashboard/account) : informations personnelles, exploitation, et gestion de l’abonnement (paiement sécurisé via Stripe — aucune donnée bancaire n’est stockée par CerOps).

Guide agriculteur — application mobile

Application Android CerOps (com.cerops.mobile), distribuée via le Google Play Store. Elle est pensée pour l’usage en plein champ, y compris sans réseau.

1. Installation et connexion

  1. Installez CerOps depuis le Play Store.
  2. Connectez-vous avec le compte créé sur le site web (e-mail + mot de passe). Vos parcelles et données sont automatiquement synchronisées.

2. Accueil

L’écran d’accueil résume l’état de vos parcelles : dernières analyses satellite, alertes, tâches en attente.

3. Parcelles sur carte

  • Visualisez toutes vos parcelles sur la carte, avec leur contour.
  • Touchez une parcelle pour ouvrir son détail : indices satellite (biomasse, azote, chlorophylle, stress hydrique) et leur évolution.
  • Renommez une parcelle directement depuis sa fiche.

4. Plan d’actions terrain

  • Créez et suivez vos tâches terrain depuis le mobile (création, changement de statut, échéances).
  • Recevez des notifications push pour les échéances et les alertes.
  • Les modifications faites hors-ligne sont synchronisées automatiquement au retour du réseau.

5. Commander un vol drone

Commandez une mission drone directement depuis l’application (mêmes étapes que sur le web : parcelles, type de mission, options, fenêtre de dates).

6. Assistant IA

Le chatbot agronome répond à vos questions en tenant compte du contexte de vos parcelles (« pourquoi le NDVI de la parcelle Nord baisse-t-il ? »). Les réponses s’affichent en streaming.

7. Diagnostic maladie par photo

Photographiez une plante suspecte : l’application propose un diagnostic de maladie et l’historise sur la parcelle.

8. Export vers vos machines agricoles

Exportez les prescriptions (ex. modulation d’azote) au format ISO-XML, directement importable dans les consoles de vos machines agricoles.

9. Mode hors-ligne

L’application fonctionne sans réseau :

Disponible hors-ligneDétail
ParcellesConsultation complète (métadonnées)
Tâches / actionsConsultation et modification (synchronisées à la reconnexion)
Fonds de carteZones téléchargeables à l’avance
Miniatures NDVIÀ la demande

Un indicateur signale les données modifiées localement en attente de synchronisation (needsSync). En cas de modification simultanée web + mobile, la version la plus récente est conservée.

Astuce : avant de partir dans une zone blanche, ouvrez la carte de vos parcelles pour télécharger les tuiles de la zone.

Guide pilote de drone

Portail dédié aux pilotes de drone certifiés (application web-pilots).

1. Créer votre compte pilote

  1. Depuis la page d’accueil du portail pilotes, cliquez sur Créer mon compte (/signup) : e-mail, mot de passe, confirmation.
  2. Complétez ensuite l’onboarding (/onboarding) : société, certifications, zone d’intervention (position géographique), et déclaration de votre matériel :
    • vos drones (nom, modèle) ;
    • vos capteurs (multispectral, thermique, RGB, LiDAR) — ils déterminent les missions auxquelles vous pouvez répondre.

Connexion ultérieure : /login. Mot de passe oublié : /forgot-password.

2. Tableau de bord

Le tableau de bord (/dashboard) affiche vos missions en cours, vos prochaines échéances et votre score pilote.

Score pilote : +1 par mission livrée, −2 par annulation. Un score élevé renforce votre visibilité auprès des agriculteurs — livrez ce que vous acceptez.

3. Trouver des missions

  • Carte (/map) : missions publiées autour de votre zone d’intervention.
  • Liste (/missions) : toutes les missions ouvertes, avec type (surveillance, cartographie, inspection, épandage), surface, fenêtre de réalisation, capteurs requis et rémunération.

Ouvrez une mission (/missions/<id>) pour voir le détail des parcelles et des options d’analyse demandées. Vous ne pouvez accepter une mission que si vos capteurs déclarés couvrent les capteurs requis.

4. Réaliser une mission

  1. Accepter la mission : elle passe en statut planifiée et apparaît dans Mes missions (/my-missions).
  2. Réalisez le vol dans la fenêtre convenue (statut en cours).
  3. Livrer : déposez les livrables (orthophotos, cartes de chaleur, rapports) et un commentaire depuis la fiche mission.
  4. À la validation de la livraison, le paiement pré-autorisé de l’agriculteur est capturé et votre rémunération créditée.

En cas d’empêchement, annulez au plus tôt : la mission repart sur la marketplace (votre score est pénalisé de −2).

5. Revenus

La page Revenus (/earnings) récapitule vos missions livrées et les montants associés.

6. Profil et matériel

La page Profil (/profile) permet de mettre à jour votre société, vos certifications, votre disponibilité, votre position et votre matériel (drones, capteurs).

FAQ — Questions fréquentes

Général

CerOps est-il gratuit ? La consultation satellite de base est incluse dans l’abonnement de votre exploitation (géré depuis la page Compte). Les missions drone sont facturées à la mission, selon la surface et les options d’analyse choisies.

Mes données sont-elles partagées ? Non. Vos parcelles et analyses ne sont visibles que par vous. Les pilotes ne voient que les informations nécessaires aux missions que vous publiez. Voir la politique de confidentialité (/privacy).

Satellite

À quelle fréquence mes parcelles sont-elles analysées ? À chaque passage exploitable du satellite Sentinel-2, soit environ tous les 5 jours. Aucune action n’est requise : l’analyse est automatique dès qu’une parcelle est enregistrée.

Pourquoi certaines dates n’ont-elles pas de données ? La couverture nuageuse rend certains passages inexploitables. Ces lacunes sont signalées dans l’historique ; les données reprennent au passage suivant.

Quels indices sont calculés ? Biomasse (NDVI), azote, chlorophylle et stress hydrique, avec historique temporel par parcelle.

Parcelles

Ma parcelle n’apparaît pas dans l’import RPG. Le RPG reflète les déclarations PAC de l’année du jeu de données. Vous pouvez toujours tracer la parcelle manuellement sur la carte — la surface est calculée automatiquement.

Puis-je modifier une parcelle après création ? Oui : renommage et gestion depuis la fiche parcelle (web et mobile).

Missions drone

Quand suis-je débité ? Jamais à la commande. Votre carte est pré-autorisée à la publication de la mission ; le montant n’est capturé qu’à la livraison des livrables. Une mission annulée est remboursée/libérée.

Qui réalise les vols ? Des pilotes de drone certifiés, inscrits sur la marketplace CerOps, équipés des capteurs requis par votre mission.

Que reçois-je à la livraison ? Les livrables du vol : orthophotos, cartes de chaleur, rapports d’analyse — consultables depuis la fiche vol et la page Rapports, et pour les prescriptions un fichier ISO-XML compatible avec vos machines.

Mobile

L’application fonctionne-t-elle sans réseau ? Oui : consultation des parcelles, création/modification des tâches et fonds de carte pré-téléchargés fonctionnent hors-ligne. Tout se synchronise au retour du réseau.

L’application est-elle disponible sur iOS ? L’application est développée avec Flutter (iOS + Android). La distribution actuelle se fait via le Google Play Store (Android).

Support

Un problème, une question ? Utilisez la page contact de l’application web (/contact).

🎯 [Nom du module]

Contexte

Décrire le rôle de ce module dans CerOps.

Objectif

Quel problème ce module résout ?

Utilisateurs concernés

  • Agriculteur
  • Pilote de drone
  • Admin
  • Autre ?

Fonctionnalités (User Stories)

  • En tant que …, je veux …, afin de …

Données manipulées

  • Entités
  • Champs importants
  • Relations

API / Interfaces

  • Endpoints concernés
  • Inputs / outputs

Écrans / UX

  • Pages / composants liés
  • Comportements attendus

Cas limites

  • Offline ?
  • Erreurs ?
  • Données manquantes ?

Critères d’acceptation

Dépendances

  • Backend
  • Mobile
  • Drone
  • Autres modules

MVP vs Post-MVP

MVP

Post-MVP

🎯 [Nom du module]

Contexte

Décrire le rôle de ce module dans CerOps.

Objectif

Quel problème ce module résout ?

Utilisateurs concernés

  • Agriculteur
  • Pilote de drone
  • Admin
  • Autre ?

Fonctionnalités (User Stories)

  • En tant que …, je veux …, afin de …

Données manipulées

  • Entités
  • Champs importants
  • Relations

API / Interfaces

  • Endpoints concernés
  • Inputs / outputs

Écrans / UX

  • Pages / composants liés
  • Comportements attendus

Cas limites

  • Offline ?
  • Erreurs ?
  • Données manquantes ?

Critères d’acceptation

Dépendances

  • Backend
  • Mobile
  • Drone
  • Autres modules

MVP vs Post-MVP

MVP

Post-MVP

🎯 [Nom du module]

Contexte

Décrire le rôle de ce module dans CerOps.

Objectif

Quel problème ce module résout ?

Utilisateurs concernés

  • Agriculteur
  • Pilote de drone
  • Admin
  • Autre ?

Fonctionnalités (User Stories)

  • En tant que …, je veux …, afin de …

Données manipulées

  • Entités
  • Champs importants
  • Relations

API / Interfaces

  • Endpoints concernés
  • Inputs / outputs

Écrans / UX

  • Pages / composants liés
  • Comportements attendus

Cas limites

  • Offline ?
  • Erreurs ?
  • Données manquantes ?

Critères d’acceptation

Dépendances

  • Backend
  • Mobile
  • Drone
  • Autres modules

MVP vs Post-MVP

MVP

Post-MVP

🎯 [Nom du module]

Contexte

Décrire le rôle de ce module dans CerOps.

Objectif

Quel problème ce module résout ?

Utilisateurs concernés

  • Agriculteur
  • Pilote de drone
  • Admin
  • Autre ?

Fonctionnalités (User Stories)

  • En tant que …, je veux …, afin de …

Données manipulées

  • Entités
  • Champs importants
  • Relations

API / Interfaces

  • Endpoints concernés
  • Inputs / outputs

Écrans / UX

  • Pages / composants liés
  • Comportements attendus

Cas limites

  • Offline ?
  • Erreurs ?
  • Données manquantes ?

Critères d’acceptation

Dépendances

  • Backend
  • Mobile
  • Drone
  • Autres modules

MVP vs Post-MVP

MVP

Post-MVP

Imagerie Parcellaire

Contexte

CerOps collecte des images satellite via l’API Copernicus (Sentinel-2) pour chaque parcelle agricole enregistrée. Ces images sont analysées pour produire des indices agronomiques (NDVI, stress hydrique, teneur en chlorophylle) qui permettent à l’agriculteur de suivre l’évolution de ses cultures dans le temps.

Objectif

Donner à l’agriculteur une vision temporelle de l’état de ses parcelles sans qu’il ait à commander une mission drone : les données satellite sont collectées automatiquement à chaque passage disponible (tous les 5 jours environ pour Sentinel-2), traitées en arrière-plan, et consultables depuis l’interface web.

Utilisateurs concernés

  • Agriculteur : consulte les cartes d’indices et l’historique de ses parcelles
  • Admin : supervise les acquisitions et relance les analyses en échec

Pipeline d’acquisition et d’analyse

Vue d’ensemble

Copernicus API
    │
    ▼ acquisition image brute
RustFS (S3)
    │
    ▼ message RabbitMQ
Worker d'analyse
    ├── calcule indices (NDVI, B04, B08…)
    ├── appende au cube Zarr de la parcelle (RustFS)
    └── met à jour les métadonnées en base

Acquisition

Les images brutes Sentinel-2 sont téléchargées depuis l’API Copernicus et stockées dans RustFS avant traitement. Ce stockage intermédiaire découple la récupération (réseau externe, latence variable, possible indisponibilité) du pipeline d’analyse.

Traitement asynchrone (RabbitMQ)

Chaque nouvelle image disponible publie un message dans RabbitMQ. Un worker consomme la queue, calcule les indices spectraux et met à jour le cube Zarr de la parcelle. Ce découplage permet de traiter les analyses sans bloquer l’API, et de scaler horizontalement en ajoutant des workers.

Stockage des données : cubes Zarr

C’est l’un des choix architecturaux les plus structurants du projet.

Le problème

Les données agronomiques sont intrinsèquement temporelles : chaque passage satellite produit une nouvelle tranche de données par parcelle. Accumulées sur une saison ou plusieurs années, elles forment une série temporelle volumineuse.

Stocker ça naïvement pose des problèmes :

  • En base de données (PostgreSQL) : une ligne par parcelle × date × indice crée des tables qui grossissent de façon incontrôlée, les requêtes “donne-moi le NDVI de cette parcelle sur 18 mois” deviennent coûteuses, et PostgreSQL n’est pas fait pour des tableaux numériques denses.
  • En fichiers individuels (GeoTIFF par date) : pour reconstituer une série temporelle, il faut lire N fichiers séquentiellement, ce qui est lent et peu pratique pour de l’analyse.

La solution : le cube Zarr

Zarr est un format de tableau N-dimensionnel chunké et compressé, conçu nativement pour les accès partiels sur stockage objet (S3).

Un cube Zarr par parcelle a trois dimensions :

cube[parcelle_id] → tableau 3D :
    ├── axe 0 : temps       (dates d'acquisition satellite)
    ├── axe 1 : x           (pixels longitude)
    └── axe 2 : y           (pixels latitude)
    ... par bande / indice (NDVI, B04, B08…)

Le cube est découpé en chunks (ex. 30 dates × 64px × 64px). Lire “le NDVI de janvier à mars” ne charge que les chunks correspondants, pas l’intégralité du cube. C’est le même principe qu’un index en base de données, mais pour des tableaux multidimensionnels.

Avantages concrets

BesoinApproche naïveZarr
Série temporelle d’une parcelleN requêtes SQL ou N fichiers1 lecture de chunks ciblés
Ajouter une nouvelle date d’acquisitionINSERT + gestion du schémaAppend d’un chunk sur l’axe temps
Analyse sur une fenêtre temporelleScan de table ou boucle fichiersSlice cube[t_start:t_end, :, :]
Stockage sur S3Peu adapté (fichiers plats)Natif, chaque chunk est un objet S3
Interopérabilité Python (xarray, numpy)Conversion nécessaireLecture directe

Flux de mise à jour

À chaque nouvelle acquisition Copernicus, le worker :

  1. Récupère l’image brute depuis RustFS
  2. Calcule les indices (NDVI, stress hydrique…)
  3. Appende la nouvelle tranche au cube Zarr de la parcelle dans RustFS
  4. Met à jour les métadonnées en base (dernière date disponible, couverture nuageuse)

La base de données ne contient jamais les valeurs pixel, uniquement les métadonnées. Les cubes Zarr sont la source de vérité pour toute l’analyse agronomique.

Cas limites

  • Couverture nuageuse : Sentinel-2 peut être inutilisable certains jours. La couverture nuageuse est stockée en métadonnée ; l’interface signale les lacunes à l’agriculteur.
  • Parcelle sans historique : à la création d’une parcelle, le cube Zarr est initialisé vide et se remplit au fil des acquisitions suivantes.
  • Échec du worker : RabbitMQ gère les relivraisons automatiques. Un message en échec répété passe en dead-letter queue pour traitement manuel.

Dépendances

  • RustFS : stockage des images brutes et des cubes Zarr
  • RabbitMQ : orchestration du pipeline d’analyse
  • Copernicus API : source des images Sentinel-2
  • Module Parcelles : les cubes sont indexés par parcelle_id

Pricing

Contexte

Le pricing actuel des missions est calcule cote serveur dans packages/api/src/routers/missions/pricing.ts. Le calcul est volontairement simple et deterministe :

  • une configuration globale de mission fournit les montants communs a toutes les missions ;
  • chaque option d’analyse peut ajouter un forfait fixe et/ou un prix variable par hectare ;
  • un minimum de mission peut relever le total final si le sous-total est trop bas.

L’objectif est de conserver le serveur comme source de verite du prix et de produire un detail exploitable pour l’UI, l’audit et les tests.

Donnees d’entree

Le calcul repose sur deux objets :

1. MissionPricingInput

  • totalSurface Surface totale de la mission.
  • analysisOptions Liste des options d’analyse selectionnees.

Chaque option d’analyse contient :

  • id
  • code
  • name
  • basePrice
  • pricePerHa

2. MissionPricingConfig

La configuration globale de pricing contient :

  • id
  • name
  • missionBasePrice Forfait applique une fois par mission.
  • missionPricePerHa Prix de base applique par hectare.
  • minimumMissionPrice Montant minimum facture pour la mission.

Algorithme actuel

La fonction calculateMissionPrice(input, pricingConfig) applique les regles suivantes dans cet ordre :

  1. Validation des donnees
  2. Ajout du forfait mission
  3. Ajout du prix mission par hectare
  4. Ajout des lignes de chaque option d’analyse
  5. Calcul du sous-total
  6. Ajout eventuel d’un ajustement de minimum
  7. Calcul du total final

1. Validation

Le calcul rejette :

  • une totalSurface negative ou non finie ;
  • un montant negatif ou non fini dans la configuration globale ;
  • un montant negatif ou non fini sur une option d’analyse.

null et undefined sont acceptes pour basePrice et pricePerHa sur les options. Ils sont traites comme 0.

2. Forfait mission

Si missionBasePrice > 0, une ligne est ajoutee :

  • quantity = 1
  • unitPrice = missionBasePrice
  • amount = missionBasePrice

3. Prix mission par hectare

Si missionPricePerHa > 0, une ligne est ajoutee :

  • quantity = totalSurface
  • unitPrice = missionPricePerHa
  • amount = missionPricePerHa * totalSurface

4. Options d’analyse

Pour chaque option :

  • si basePrice > 0, une ligne forfaitaire est ajoutee ;
  • si pricePerHa > 0, une ligne surfacique est ajoutee.

Autrement dit, la formule d’une option est :

optionTotal = (basePrice ?? 0) + (pricePerHa ?? 0) * totalSurface

5. Sous-total

Le sous-total correspond a la somme des amount de toutes les lignes generees avant application du minimum.

6. Minimum mission

Si minimumMissionPrice > subtotal, une ligne supplementaire est ajoutee pour combler l’ecart :

minimumAdjustment = minimumMissionPrice - subtotal

Cette ligne ne remplace pas les autres. Elle s’ajoute au detail pour rendre l’ajustement visible.

7. Arrondi

Chaque montant est arrondi a 2 decimales via roundMoney. Le total final est lui aussi recalcule a partir des lignes et arrondi a 2 decimales.

Structure de sortie

La fonction retourne :

  • totalPrice
  • lineItems

Chaque lineItem contient :

  • type
  • code
  • label
  • quantity
  • unitPrice
  • amount

Role de type et code

Aujourd’hui, type et code ne configurent pas le prix. Ils decrivent les lignes produites par le calcul.

type

type categorise la nature metier de la ligne :

  • mission_base forfait global de mission ;
  • mission_surface prix de base par hectare ;
  • mission_minimum_adjustment ajustement applique pour atteindre le minimum ;
  • analysis_option ligne issue d’une option d’analyse.

type est utile pour :

  • grouper ou afficher les lignes dans le front ;
  • reconnaitre les categories de cout dans les tests ;
  • garder une semantique stable cote API.

Mais type n’est pas lu comme une regle dynamique de pricing. Il est derive par le code, pas saisi en base pour piloter l’algorithme.

code

code identifie plus precisement la ligne.

Pour les lignes globales de mission, les codes sont statiques :

  • mission-base
  • mission-surface
  • mission-minimum-adjustment

Pour les options d’analyse, le code est derive du code de l’option :

  • ${analysisOption.code}:base
  • ${analysisOption.code}:surface

Exemples :

  • hydrometrie:base
  • hydrometrie:surface
  • azote:base

code sert donc a :

  • rattacher une ligne a une option precise ;
  • differencier la part forfaitaire et la part surfacique d’une meme option ;
  • produire un identifiant stable pour le recapitulatif ou d’eventuels traitements aval.

Comme type, code est aujourd’hui une sortie descriptive, pas un parametre de configuration du calcul.

Ce qui configure reellement le prix aujourd’hui

Les vraies entrees de configuration sont :

  • dans MissionPricingConfig missionBasePrice, missionPricePerHa, minimumMissionPrice ;
  • dans AnalysisOption basePrice, pricePerHa ;
  • dans l’entree de calcul totalSurface et la liste des options selectionnees.

En pratique :

  • changer missionBasePrice change la ligne mission_base ;
  • changer missionPricePerHa change la ligne mission_surface ;
  • changer minimumMissionPrice change l’eventuelle ligne mission_minimum_adjustment ;
  • changer analysisOption.basePrice ou analysisOption.pricePerHa change les lignes analysis_option correspondantes ;
  • changer analysisOption.code ne change pas la formule, mais change le code retourne dans les lignes ;
  • changer analysisOption.name ne change pas la formule, mais change le label retourne.

Exemple complet

Avec :

  • totalSurface = 12.5
  • missionBasePrice = 50
  • missionPricePerHa = 2
  • minimumMissionPrice = 0
  • option Hydrometrie basePrice = 120, pricePerHa = 4.5, code = hydrometrie
  • option Azote basePrice = 90, pricePerHa = 3.2, code = azote

Le detail produit est :

  • mission_base code mission-base montant 50
  • mission_surface code mission-surface montant 25
  • analysis_option code hydrometrie:base montant 120
  • analysis_option code hydrometrie:surface montant 56.25
  • analysis_option code azote:base montant 90
  • analysis_option code azote:surface montant 40

Total :

50 + 25 + 120 + 56.25 + 90 + 40 = 381.25

Cycle de vie de la configuration globale

L’administration du pricing global passe par packages/api/src/routers/pricing/router.ts.

Une configuration :

  • est creee en DRAFT ;
  • peut etre modifiee tant qu’elle reste en DRAFT ;
  • peut etre publiee ;
  • peut etre archivee.

Contraintes actuelles :

  • seule une configuration DRAFT peut etre modifiee ;
  • publier une configuration archive automatiquement toute configuration deja PUBLISHED ;
  • une configuration ARCHIVED ne peut plus etre re-archivee utilement.

Les schemas d’entree packages/api/src/schemas/pricing/schemas.ts imposent :

  • un name non vide, max 120 caracteres ;
  • des montants numeriques positifs ou nuls ;
  • des montants exprimes en EUR avec jusqu’a 2 decimales selon la convention documentee.

Limites du modele actuel

Le modele actuel fonctionne bien pour un pricing compose de :

  • forfait mission ;
  • prix mission par hectare ;
  • forfait par option ;
  • prix par hectare par option ;
  • minimum de mission.

En revanche, type et code ne permettent pas a eux seuls de rendre le pricing dynamique. Ils restent de simples marqueurs de sortie.

Si demain le projet veut rendre le modele tarifaire reellement configurable, il faudra introduire un moteur de regles versionne en base, avec des kind metier interpretes par le serveur, plutot que de transformer type ou code en champs libres pilotant le calcul.

🎯 [Nom du module]

Contexte

Décrire le rôle de ce module dans CerOps.

Objectif

Quel problème ce module résout ?

Utilisateurs concernés

  • Agriculteur
  • Pilote de drone
  • Admin
  • Autre ?

Fonctionnalités (User Stories)

  • En tant que …, je veux …, afin de …

Données manipulées

  • Entités
  • Champs importants
  • Relations

API / Interfaces

  • Endpoints concernés
  • Inputs / outputs

Écrans / UX

  • Pages / composants liés
  • Comportements attendus

Cas limites

  • Offline ?
  • Erreurs ?
  • Données manquantes ?

Critères d’acceptation

Dépendances

  • Backend
  • Mobile
  • Drone
  • Autres modules

MVP vs Post-MVP

MVP

Post-MVP

🎯 Stratégie Offline Mobile

Contexte

L’application mobile CerOps est utilisée en plein champ, souvent sans connexion réseau. Le mode offline permet de consulter et modifier les données sans internet, puis de synchroniser au retour de la connectivité.

Objectif

Permettre à l’utilisateur de travailler hors ligne sur ses parcelles et actions, avec synchronisation automatique à la reconnexion.

Utilisateurs concernés

  • Agriculteur
  • Pilote de drone

Fonctionnalités (User Stories)

  • En tant qu’agriculteur, je veux consulter mes parcelles et actions sans connexion.
  • En tant qu’agriculteur, je veux créer et modifier des actions hors ligne, afin qu’elles soient synchronisées à la reconnexion.
  • En tant qu’utilisateur, je veux télécharger une zone de carte à l’avance pour naviguer hors ligne.
  • En tant qu’utilisateur, je veux savoir quelles données ne sont pas encore synchronisées.

Données manipulées

Ce qui est mis en cache offline

DonnéeFaisabilitéStratégie
Parcelles (métadonnées)✅ OuiCache complet
Actions / tâches✅ OuiCache complet + mise à jour optimiste
Missions de vol (métadonnées)✅ OuiCache métadonnées uniquement
Tuiles de carte✅ Oui (par zone)Téléchargement à la demande (FMTC)
Imagerie NDVI⚠️ SélectifMiniatures uniquement, à la demande
Images brutes drone❌ NonServeur uniquement

Champs offline ajoutés à chaque entité

  • needsSync (bool) : modification locale en attente de synchronisation
  • lastModifiedAt (DateTime) : horodatage pour la résolution de conflits

Choix technologique

Base de données locale : Drift

Drift (SQLite ORM) est retenu pour les raisons suivantes :

  • Requêtes filtrées typées à la compilation (SQL)
  • Streaming natif compatible Riverpod (watch()StreamProvider)
  • Migrations de schéma versionnées — évolutions du modèle sans perte de données
  • Transactions atomiques pour la cohérence lors des synchronisations
  • Multi-plateforme : Android, iOS, Web, Desktop

Hive (clé-valeur) a été écarté : pas de migrations natives, pas de streaming filtré, scan mémoire O(n) pour les requêtes.

Détection de connectivité : connectivity_plus

Détecte les changements Wi-Fi / données mobiles. Note : connexion détectée ≠ internet disponible — tous les appels API restent protégés par timeout.

Cartes offline : flutter_map_tile_caching (FMTC)

Téléchargement de tuiles par zone géographique délimitée. Zoom 15–17, environ 15–30 Mo par zone de 5 km². Expiration à 30 jours.

Licence GPL — à vérifier avant intégration en production.

Résolution de conflits

Stratégie : Last-Write-Wins basée sur lastModifiedAt.

  • Modification locale plus récente → envoyée au serveur
  • Version serveur plus récente → écrase la version locale
  • En cas d’égalité → version serveur prime

Écrans / UX

  • Bannière discrète quand l’application est hors ligne
  • Indicateur visuel sur les éléments avec needsSync: true
  • Notification au retour de la connectivité : « X éléments synchronisés »
  • Écran de téléchargement de zone carte (accessible depuis les paramètres)

Cas limites

  • Premier démarrage sans connexion : impossible — le cache est vide, l’utilisateur doit se connecter une première fois.
  • Session expirée hors ligne : l’utilisateur travaille jusqu’à expiration du token, aucune re-authentification possible sans réseau.
  • Stockage insuffisant : erreur explicite, téléchargement annulé.
  • Entité référencée absente du cache : placeholder affiché, pas d’erreur fatale.
  • Évolution du schéma : migration Drift, aucune perte de données.

Critères d’acceptation

  • L’application démarre sans connexion et affiche les données du cache.
  • Les parcelles et actions sont lisibles hors ligne.
  • Une action créée hors ligne est sauvegardée localement et synchronisée à la reconnexion.
  • Le statut d’une action modifié hors ligne est mis à jour immédiatement (optimiste) et synchronisé à la reconnexion.
  • Les éléments non synchronisés sont visuellement distingués.
  • Une zone de carte peut être téléchargée et consultée hors ligne.
  • Une mise à jour de l’application ne perd pas les données locales.

Dépendances

Packages Flutter

PackageUsage
drift + drift_flutterBase de données locale SQLite
drift_dev + build_runnerGénération de code (dev uniquement)
connectivity_plusDétection de connectivité
flutter_map_tile_cachingCache de tuiles carte (GPL)

Backend

  • Aucun endpoint spécifique offline requis au MVP.
  • Chaque entité doit exposer updatedAt pour la résolution de conflits LWW.
  • Post-MVP : endpoint delta-sync (entités modifiées depuis un timestamp).

MVP vs Post-MVP

MVP

  • Cache Drift pour parcelles et actions (lecture + écriture hors ligne)
  • Synchronisation manuelle au retour de la connexion (pull-to-refresh / démarrage)
  • Mise à jour optimiste pour les actions
  • Indicateur visuel offline + éléments en attente de sync
  • Téléchargement de tuiles par zone (FMTC)
  • Résolution de conflits Last-Write-Wins

Post-MVP

  • Synchronisation automatique en arrière-plan (workmanager, toutes les 20 min)
  • Delta-sync depuis un timestamp
  • Cache miniatures NDVI à la demande
  • Gestion automatique de l’espace disque
  • Notification push pour déclencher une sync depuis le serveur

🎯 [Nom du module]

Contexte

Décrire le rôle de ce module dans CerOps.

Objectif

Quel problème ce module résout ?

Utilisateurs concernés

  • Agriculteur
  • Pilote de drone
  • Admin
  • Autre ?

Fonctionnalités (User Stories)

  • En tant que …, je veux …, afin de …

Données manipulées

  • Entités
  • Champs importants
  • Relations

API / Interfaces

  • Endpoints concernés
  • Inputs / outputs

Écrans / UX

  • Pages / composants liés
  • Comportements attendus

Cas limites

  • Offline ?
  • Erreurs ?
  • Données manquantes ?

Critères d’acceptation

Dépendances

  • Backend
  • Mobile
  • Drone
  • Autres modules

MVP vs Post-MVP

MVP

Post-MVP

Cerops Database

Tasks

erDiagram
"task" {
  String id PK
  String title
  String description "nullable"
  TaskStatus status
  TaskPriority priority
  String category "nullable"
  Int rank
  DateTime deadline "nullable"
  String userId FK
  String plotId FK "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"user" {
  String id PK
  String name
  String email UK
  Boolean emailVerified
  String image "nullable"
  AccountType accountType
  Boolean onboardingCompleted
  String stripeCustomerId "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"task" }|--|| "user" : user

task

Action item linked to a User and optionally a Plot.

Properties as follows:

  • id:
  • title:
  • description:
  • status:
  • priority:
  • category:
  • rank:
  • deadline:
  • userId: User id
  • plotId: Plot id
  • createdAt:
  • updatedAt:

Auth

erDiagram
"user" {
  String id PK
  String name
  String email UK
  Boolean emailVerified
  String image "nullable"
  AccountType accountType
  Boolean onboardingCompleted
  String stripeCustomerId "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"session" {
  String id PK
  DateTime expiresAt
  String token UK
  DateTime createdAt
  DateTime updatedAt
  String ipAddress "nullable"
  String userAgent "nullable"
  String userId FK
}
"account" {
  String id PK
  String accountId
  String providerId
  String userId FK
  String accessToken "nullable"
  String refreshToken "nullable"
  String idToken "nullable"
  DateTime accessTokenExpiresAt "nullable"
  DateTime refreshTokenExpiresAt "nullable"
  String scope "nullable"
  String password "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"verification" {
  String id PK
  String identifier
  String value
  DateTime expiresAt
  DateTime createdAt
  DateTime updatedAt
}
"subscription" {
  String id PK
  String plan
  String referenceId FK
  String stripeCustomerId
  String stripeSubscriptionId "nullable"
  String status
  DateTime periodStart "nullable"
  DateTime periodEnd "nullable"
  Boolean cancelAtPeriodEnd
  DateTime cancelAt "nullable"
  DateTime canceledAt "nullable"
  DateTime endedAt "nullable"
  Int seats "nullable"
  String billingInterval "nullable"
  String stripeScheduleId "nullable"
  DateTime trialStart "nullable"
  DateTime trialEnd "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"jwks" {
  String id PK
  String publicKey
  String privateKey
  DateTime createdAt
  DateTime expiresAt "nullable"
}
"oauthClient" {
  String id PK
  String clientId UK
  String clientSecret "nullable"
  Boolean disabled "nullable"
  Boolean skipConsent "nullable"
  Boolean enableEndSession "nullable"
  String subjectType "nullable"
  String scopes
  String userId FK "nullable"
  DateTime createdAt "nullable"
  DateTime updatedAt "nullable"
  String name "nullable"
  String uri "nullable"
  String icon "nullable"
  String contacts
  String tos "nullable"
  String policy "nullable"
  String softwareId "nullable"
  String softwareVersion "nullable"
  String softwareStatement "nullable"
  String redirectUris
  String postLogoutRedirectUris
  String tokenEndpointAuthMethod "nullable"
  String grantTypes
  String responseTypes
  Boolean public "nullable"
  String type "nullable"
  Boolean requirePKCE "nullable"
  String referenceId "nullable"
  Json metadata "nullable"
}
"oauthRefreshToken" {
  String id PK
  String token UK
  String clientId FK
  String sessionId FK "nullable"
  String userId FK
  String referenceId "nullable"
  DateTime expiresAt "nullable"
  DateTime createdAt "nullable"
  DateTime revoked "nullable"
  DateTime authTime "nullable"
  String scopes
}
"oauthAccessToken" {
  String id PK
  String token UK "nullable"
  String clientId FK
  String sessionId FK "nullable"
  String userId FK "nullable"
  String referenceId "nullable"
  String refreshId FK "nullable"
  DateTime expiresAt "nullable"
  DateTime createdAt "nullable"
  String scopes
}
"oauthConsent" {
  String id PK
  String clientId FK
  String userId FK "nullable"
  String referenceId "nullable"
  String scopes
  DateTime createdAt "nullable"
  DateTime updatedAt "nullable"
}
"platform_settings" {
  String id PK
  Boolean billingEnabled
  Boolean maintenanceMode
  Boolean registrationEnabled
  Boolean newMissionsEnabled
  Boolean cooperativesEnabled
  DateTime updatedAt
  String updatedById "nullable"
}
"session" }|--|| "user" : user
"account" }|--|| "user" : user
"subscription" }|--|| "user" : user
"oauthClient" }o--o| "user" : user
"oauthRefreshToken" }|--|| "oauthClient" : oauthclient
"oauthRefreshToken" }o--o| "session" : session
"oauthRefreshToken" }|--|| "user" : user
"oauthAccessToken" }|--|| "oauthClient" : oauthclient
"oauthAccessToken" }o--o| "session" : session
"oauthAccessToken" }o--o| "user" : user
"oauthAccessToken" }o--o| "oauthRefreshToken" : oauthrefreshtoken
"oauthConsent" }|--|| "oauthClient" : oauthclient
"oauthConsent" }o--o| "user" : user

user

Platform user with role-based access. Relates to Session, Account, Plot, Task, Flight, and more.

Properties as follows:

  • id:
  • name:
  • email:
  • emailVerified:
  • image:
  • accountType:
  • onboardingCompleted:
  • stripeCustomerId:
  • createdAt:
  • updatedAt:

session

User session for Better-Auth. Each Session belongs to a User.

Properties as follows:

  • id:
  • expiresAt:
  • token:
  • createdAt:
  • updatedAt:
  • ipAddress:
  • userAgent:
  • userId: User id

account

OAuth / email-password account linked to a User.

Properties as follows:

  • id:
  • accountId:
  • providerId:
  • userId: User id
  • accessToken:
  • refreshToken:
  • idToken:
  • accessTokenExpiresAt:
  • refreshTokenExpiresAt:
  • scope:
  • password:
  • createdAt:
  • updatedAt:

verification

Email verification token.

Properties as follows:

  • id:
  • identifier:
  • value:
  • expiresAt:
  • createdAt:
  • updatedAt:

subscription

Stripe subscription for a User.

Properties as follows:

  • id:
  • plan:
  • referenceId: User id
  • stripeCustomerId:
  • stripeSubscriptionId:
  • status:
  • periodStart:
  • periodEnd:
  • cancelAtPeriodEnd:
  • cancelAt:
  • canceledAt:
  • endedAt:
  • seats:
  • billingInterval:
  • stripeScheduleId:
  • trialStart:
  • trialEnd:
  • createdAt:
  • updatedAt:

jwks

JSON Web Key Set for OAuth.

Properties as follows:

  • id:
  • publicKey:
  • privateKey:
  • createdAt:
  • expiresAt:

oauthClient

OAuth 2.0 client application owned by a User.

Properties as follows:

  • id:
  • clientId:
  • clientSecret:
  • disabled:
  • skipConsent:
  • enableEndSession:
  • subjectType:
  • scopes:
  • userId: User id
  • createdAt:
  • updatedAt:
  • name:
  • uri:
  • icon:
  • contacts:
  • tos:
  • policy:
  • softwareId:
  • softwareVersion:
  • softwareStatement:
  • redirectUris:
  • postLogoutRedirectUris:
  • tokenEndpointAuthMethod:
  • grantTypes:
  • responseTypes:
  • public:
  • type:
  • requirePKCE:
  • referenceId:
  • metadata:

oauthRefreshToken

OAuth 2.0 refresh token belonging to a User, Session, and OauthClient.

Properties as follows:

  • id:
  • token:
  • clientId: OauthClient id
  • sessionId: Session id
  • userId: User id
  • referenceId:
  • expiresAt:
  • createdAt:
  • revoked:
  • authTime:
  • scopes:

oauthAccessToken

OAuth 2.0 access token belonging to a User, Session, and OauthClient.

Properties as follows:

oauthConsent

OAuth 2.0 consent record for a User and OauthClient.

Properties as follows:

  • id:
  • clientId: OauthClient id
  • userId: User id
  • referenceId:
  • scopes:
  • createdAt:
  • updatedAt:

platform_settings

Global platform configuration singleton.

Properties as follows:

  • id:
  • billingEnabled:
  • maintenanceMode:
  • registrationEnabled:
  • newMissionsEnabled:
  • cooperativesEnabled:
  • updatedAt:
  • updatedById:

Cooperative

erDiagram
"cooperative" {
  String id PK
  String name
  String joinCode UK
  String createdById FK
  DateTime createdAt
  DateTime updatedAt
}
"user" {
  String id PK
  String name
  String email UK
  Boolean emailVerified
  String image "nullable"
  AccountType accountType
  Boolean onboardingCompleted
  String stripeCustomerId "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"cooperative" }|--|| "user" : createdBy

cooperative

Farmer cooperative group created by a User.

Properties as follows:

  • id:
  • name:
  • joinCode:
  • createdById: User id
  • createdAt:
  • updatedAt:

FieldObservation

erDiagram
"field_observation" {
  String id PK
  String userId FK
  String parcelId FK "nullable"
  String cropType "nullable"
  String photoOriginalObjectKey
  String photoThumbnailObjectKey "nullable"
  DateTime capturedAt
  DateTime uploadedAt "nullable"
  Float gpsLat "nullable"
  Float gpsLon "nullable"
  Float gpsAccuracyMeters "nullable"
  FieldObservationGpsAssociationStatus gpsAssociationStatus
  DateTime createdAt
  DateTime updatedAt
}
"plant_disease_diagnosis" {
  String id PK
  String observationId FK
  PlantDiseaseDiagnosisStatus status
  String modelName "nullable"
  String modelVersion "nullable"
  String predictedClass "nullable"
  String crop "nullable"
  String disease "nullable"
  String healthStatus "nullable"
  Float confidence "nullable"
  String severity "nullable"
  Boolean isConfident "nullable"
  Json topPredictions "nullable"
  Json recommendations "nullable"
  Int inferenceDurationMs "nullable"
  String errorCode "nullable"
  String errorMessage "nullable"
  Boolean retryable "nullable"
  String traceId "nullable"
  DateTime processingStartedAt "nullable"
  DateTime completedAt "nullable"
  DateTime failedAt "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"user" {
  String id PK
  String name
  String email UK
  Boolean emailVerified
  String image "nullable"
  AccountType accountType
  Boolean onboardingCompleted
  String stripeCustomerId "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"plot" {
  String id PK
  String name
  PlotSource source
  Float surface
  String userId FK "nullable"
  String rpgSourceKey UK "nullable"
  String rpgIdParcel "nullable"
  String rpgCultivationCode "nullable"
  String rpgGroupCode "nullable"
  String rpgCultureD1 "nullable"
  String rpgCultureD2 "nullable"
  String rpgCategory "nullable"
  Int rpgSourceYear "nullable"
  String rpgDatasetId FK "nullable"
  String rpgRegionCode "nullable"
  String rpgImportJobId FK "nullable"
  DateTime rpgImportedAt "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"field_observation" }|--|| "user" : user
"field_observation" }o--o| "plot" : parcel
"plant_disease_diagnosis" }o--|| "field_observation" : observation
"plot" }o--o| "user" : user

field_observation

In-field photo observation captured by a User on a Plot.

Properties as follows:

  • id:
  • userId: User id
  • parcelId: Plot id
  • cropType:
  • photoOriginalObjectKey:
  • photoThumbnailObjectKey:
  • capturedAt:
  • uploadedAt:
  • gpsLat:
  • gpsLon:
  • gpsAccuracyMeters:
  • gpsAssociationStatus:
  • createdAt:
  • updatedAt:

plant_disease_diagnosis

AI-powered disease diagnosis for a FieldObservation photo.

Properties as follows:

  • id:
  • observationId: FieldObservation id
  • status:
  • modelName:
  • modelVersion:
  • predictedClass:
  • crop:
  • disease:
  • healthStatus:
  • confidence:
  • severity:
  • isConfident:
  • topPredictions:
  • recommendations:
  • inferenceDurationMs:
  • errorCode:
  • errorMessage:
  • retryable:
  • traceId:
  • processingStartedAt:
  • completedAt:
  • failedAt:
  • createdAt:
  • updatedAt:

Flight

erDiagram
"flight" {
  String id PK
  DateTime scheduledAt
  FlightStatus status
  String plotId FK
  String pilotId FK
  DateTime createdAt
  DateTime updatedAt
}
"image" {
  String id PK
  String url
  String type "nullable"
  String flightId FK "nullable"
  String plotId FK
  DateTime createdAt
}
"user" {
  String id PK
  String name
  String email UK
  Boolean emailVerified
  String image "nullable"
  AccountType accountType
  Boolean onboardingCompleted
  String stripeCustomerId "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"plot" {
  String id PK
  String name
  PlotSource source
  Float surface
  String userId FK "nullable"
  String rpgSourceKey UK "nullable"
  String rpgIdParcel "nullable"
  String rpgCultivationCode "nullable"
  String rpgGroupCode "nullable"
  String rpgCultureD1 "nullable"
  String rpgCultureD2 "nullable"
  String rpgCategory "nullable"
  Int rpgSourceYear "nullable"
  String rpgDatasetId FK "nullable"
  String rpgRegionCode "nullable"
  String rpgImportJobId FK "nullable"
  DateTime rpgImportedAt "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"flight" }|--|| "plot" : plot
"flight" }|--|| "user" : pilot
"image" }|--o| "flight" : flight
"image" }|--|| "plot" : plot
"plot" }o--o| "user" : user

flight

Drone flight mission over a Plot flown by a User (pilot).

Properties as follows:

  • id:
  • scheduledAt:
  • status:
  • plotId: Plot id
  • pilotId: User id
  • createdAt:
  • updatedAt:

image

Image captured during a Flight over a Plot.

Properties as follows:

  • id:
  • url:
  • type:
  • flightId: Flight id
  • plotId: Plot id
  • createdAt:

Marketplace

erDiagram
"pilot_profile" {
  String id PK
  String userId FK,UK
  String companyName "nullable"
  Json interventionZone "nullable"
  Boolean available
  String certifications
  Int score
  Float latitude "nullable"
  Float longitude "nullable"
  String stripeConnectedAccountId "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"farmer_profile" {
  String id PK
  String userId FK,UK
  String farmName
  String address "nullable"
  String postalCode "nullable"
  String city "nullable"
  String agricultureType "nullable"
  Int parcelCount "nullable"
  Float totalSurface "nullable"
  String crops
  String cooperativeId FK "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"drone" {
  String id PK
  String pilotProfileId FK
  String name
  String model
  DateTime createdAt
}
"sensor" {
  String id PK
  String pilotProfileId FK
  String sensor_kind_code FK
  String name
  DateTime createdAt
}
"sensor_kind" {
  String code PK
  String name
  String description "nullable"
  Boolean active
  DateTime createdAt
  DateTime updatedAt
}
"analysis_option" {
  String id PK
  String code UK
  String name
  String description "nullable"
  Float basePrice "nullable"
  Float pricePerHa "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"mission_pricing_config" {
  String id PK
  String name
  MissionPricingConfigStatus status
  Float missionBasePrice
  Float missionPricePerHa
  Float minimumMissionPrice
  Float platformFeeRate
  DateTime publishedAt "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"mission_group" {
  String id PK
  DateTime createdAt
}
"mission" {
  String id PK
  String farmerId FK
  Json location
  Float totalSurface
  MissionType type
  DateTime windowStart
  DateTime windowEnd
  Float price
  String pricing_config_id FK "nullable"
  MissionStatus status
  DateTime publishedAt "nullable"
  String pilotProfileId FK "nullable"
  String groupId FK "nullable"
  String validationRejectionReason "nullable"
  String instructions "nullable"
  String product "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"mission_plot" {
  String missionId FK
  String plotId FK
}
"mission_required_sensor" {
  String missionId FK
  String sensor_kind_code FK
}
"analysis_option_sensor_type" {
  String analysis_option_id FK
  String sensor_kind_code FK
}
"mission_analysis" {
  String mission_id FK
  String analysis_option_id FK
}
"deliverable" {
  String id PK
  String missionId FK,UK
  String files
  String comment "nullable"
  DateTime createdAt
}
"payment" {
  String id PK
  String missionId FK,UK
  Float amount
  PaymentStatus status
  String stripePaymentId "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"user" {
  String id PK
  String name
  String email UK
  Boolean emailVerified
  String image "nullable"
  AccountType accountType
  Boolean onboardingCompleted
  String stripeCustomerId "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"cooperative" {
  String id PK
  String name
  String joinCode UK
  String createdById FK
  DateTime createdAt
  DateTime updatedAt
}
"plot" {
  String id PK
  String name
  PlotSource source
  Float surface
  String userId FK "nullable"
  String rpgSourceKey UK "nullable"
  String rpgIdParcel "nullable"
  String rpgCultivationCode "nullable"
  String rpgGroupCode "nullable"
  String rpgCultureD1 "nullable"
  String rpgCultureD2 "nullable"
  String rpgCategory "nullable"
  Int rpgSourceYear "nullable"
  String rpgDatasetId FK "nullable"
  String rpgRegionCode "nullable"
  String rpgImportJobId FK "nullable"
  DateTime rpgImportedAt "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"pilot_profile" |o--|| "user" : user
"farmer_profile" |o--|| "user" : user
"farmer_profile" }o--o| "cooperative" : cooperative
"drone" }|--|| "pilot_profile" : pilotProfile
"sensor" }|--|| "pilot_profile" : pilotProfile
"sensor" }|--|| "sensor_kind" : sensorKind
"mission" }|--|| "user" : farmer
"mission" }o--o| "mission_pricing_config" : pricingConfig
"mission" }o--o| "pilot_profile" : pilotProfile
"mission" }o--o| "mission_group" : group
"mission_plot" }|--|| "mission" : mission
"mission_plot" }|--|| "plot" : plot
"mission_required_sensor" }|--|| "mission" : mission
"mission_required_sensor" }|--|| "sensor_kind" : sensorKind
"analysis_option_sensor_type" }|--|| "analysis_option" : analysisOption
"analysis_option_sensor_type" }|--|| "sensor_kind" : sensorKind
"mission_analysis" }|--|| "mission" : mission
"mission_analysis" }|--|| "analysis_option" : analysisOption
"deliverable" |o--|| "mission" : mission
"payment" |o--|| "mission" : mission
"cooperative" }|--|| "user" : createdBy
"plot" }o--o| "user" : user

pilot_profile

Drone pilot profile and capabilities for a User.

Properties as follows:

  • id:
  • userId: User id
  • companyName:
  • interventionZone:
  • available:
  • certifications:
  • score:
  • latitude:
  • longitude:
  • stripeConnectedAccountId:
  • createdAt:
  • updatedAt:

farmer_profile

Farmer profile with farm details for a User, optionally in a Cooperative.

Properties as follows:

  • id:
  • userId: User id
  • farmName:
  • address:
  • postalCode:
  • city:
  • agricultureType:
  • parcelCount:
  • totalSurface:
  • crops:
  • cooperativeId: Cooperative id
  • createdAt:
  • updatedAt:

drone

Drone equipment owned by a PilotProfile.

Properties as follows:

  • id:
  • pilotProfileId: PilotProfile id
  • name:
  • model:
  • createdAt:

sensor

Sensor equipment owned by a PilotProfile, of a specific SensorKind.

Properties as follows:

sensor_kind

Sensor type / model catalog.

Properties as follows:

  • code:
  • name:
  • description:
  • active:
  • createdAt:
  • updatedAt:

analysis_option

Analysis product option for Missions.

Properties as follows:

  • id:
  • code:
  • name:
  • description:
  • basePrice:
  • pricePerHa:
  • createdAt:
  • updatedAt:

mission_pricing_config

Pricing template for Missions.

Properties as follows:

  • id:
  • name:
  • status:
  • missionBasePrice:
  • missionPricePerHa:
  • minimumMissionPrice:
  • platformFeeRate:
  • publishedAt:
  • createdAt:
  • updatedAt:

mission_group

Logical grouping of Missions (e.g. batch).

Properties as follows:

  • id:
  • createdAt:

mission

Farmer-to-pilot mission (spraying / inspection) from a User (farmer), optionally assigned to a PilotProfile.

Properties as follows:

  • id:
  • farmerId: User id
  • location:
  • totalSurface:
  • type:
  • windowStart:
  • windowEnd:
  • price:
  • pricing_config_id: MissionPricingConfig id
  • status:
  • publishedAt:
  • pilotProfileId: PilotProfile id
  • groupId: MissionGroup id
  • validationRejectionReason:
  • instructions:
  • product:
  • createdAt:
  • updatedAt:

mission_plot

Join table linking Missions to Plots.

Properties as follows:

mission_required_sensor

Required SensorKind for a Mission.

Properties as follows:

analysis_option_sensor_type

Compatible SensorKinds for an AnalysisOption.

Properties as follows:

mission_analysis

Selected AnalysisOption for a Mission.

Properties as follows:

deliverable

Mission deliverable files.

Properties as follows:

  • id:
  • missionId: Mission id
  • files:
  • comment:
  • createdAt:

payment

Mission payment via Stripe.

Properties as follows:

  • id:
  • missionId: Mission id
  • amount:
  • status:
  • stripePaymentId:
  • createdAt:
  • updatedAt:

Plot

erDiagram
"plot" {
  String id PK
  String name
  PlotSource source
  Float surface
  String userId FK "nullable"
  String rpgSourceKey UK "nullable"
  String rpgIdParcel "nullable"
  String rpgCultivationCode "nullable"
  String rpgGroupCode "nullable"
  String rpgCultureD1 "nullable"
  String rpgCultureD2 "nullable"
  String rpgCategory "nullable"
  Int rpgSourceYear "nullable"
  String rpgDatasetId FK "nullable"
  String rpgRegionCode "nullable"
  String rpgImportJobId FK "nullable"
  DateTime rpgImportedAt "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"culture" {
  String id PK
  String userId FK
  String name "nullable"
  String cultureType
  DateTime startedAt
  DateTime harvestedAt "nullable"
  DateTime createdAt
}
"culture_plot" {
  String cultureId FK
  String plotId FK
}
"user" {
  String id PK
  String name
  String email UK
  Boolean emailVerified
  String image "nullable"
  AccountType accountType
  Boolean onboardingCompleted
  String stripeCustomerId "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"plot" }o--o| "user" : user
"culture" }|--|| "user" : user
"culture_plot" }|--|| "culture" : culture
"culture_plot" }|--|| "plot" : plot

plot

Agricultural parcel / field owned by a User.

Properties as follows:

  • id:
  • name:
  • source:
  • surface:
  • userId: User id
  • rpgSourceKey:
  • rpgIdParcel:
  • rpgCultivationCode:
  • rpgGroupCode:
  • rpgCultureD1:
  • rpgCultureD2:
  • rpgCategory:
  • rpgSourceYear:
  • rpgDatasetId: RpgDataset id
  • rpgRegionCode:
  • rpgImportJobId: RpgImportJob id
  • rpgImportedAt:
  • createdAt:
  • updatedAt:

culture

Crop cycle on one or more Plots.

Properties as follows:

  • id:
  • userId: User id
  • name:
  • cultureType:
  • startedAt:
  • harvestedAt:
  • createdAt:

culture_plot

Join table linking Cultures to Plots.

Properties as follows:

Preview

erDiagram
"parcel_preview" {
  String id PK
  String renderKey UK
  String observationKey
  String parcelId
  String productId
  DateTime acquiredAt
  PreviewLayer layer
  String renderVersion
  PreviewStatus status
  String objectKey "nullable"
  String contentType "nullable"
  Int width "nullable"
  Int height "nullable"
  BigInt sizeBytes "nullable"
  String checksum "nullable"
  String errorCode "nullable"
  String errorMessage "nullable"
  Int attemptCount
  DateTime generatedAt "nullable"
  DateTime expiresAt "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"plot" {
  String id PK
  String name
  PlotSource source
  Float surface
  String userId FK "nullable"
  String rpgSourceKey UK "nullable"
  String rpgIdParcel "nullable"
  String rpgCultivationCode "nullable"
  String rpgGroupCode "nullable"
  String rpgCultureD1 "nullable"
  String rpgCultureD2 "nullable"
  String rpgCategory "nullable"
  Int rpgSourceYear "nullable"
  String rpgDatasetId FK "nullable"
  String rpgRegionCode "nullable"
  String rpgImportJobId FK "nullable"
  DateTime rpgImportedAt "nullable"
  DateTime createdAt
  DateTime updatedAt
}

parcel_preview

Generated map preview tile for a parcel.

Properties as follows:

  • id:
  • renderKey:
  • observationKey:
  • parcelId:
  • productId:
  • acquiredAt:
  • layer:
  • renderVersion:
  • status:
  • objectKey:
  • contentType:
  • width:
  • height:
  • sizeBytes:
  • checksum:
  • errorCode:
  • errorMessage:
  • attemptCount:
  • generatedAt:
  • expiresAt:
  • createdAt:
  • updatedAt:

Processing

erDiagram
"processing_cube" {
  String id PK
  String parcelId FK
  String geometryHash
  String zarrObjectKey
  String currentManifestObjectKey "nullable"
  Int revision
  String leaseToken UK "nullable"
  String leaseOwner "nullable"
  DateTime leaseAcquiredAt "nullable"
  DateTime leaseExpiresAt "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"processing_observation" {
  String id PK
  String observationKey UK
  String cubeId FK "nullable"
  String parcelId
  String productId FK
  DateTime acquiredAt
  ProcessingObservationOutcome outcome
  String skipReason "nullable"
  String manifestObjectKey "nullable"
  Int cubeRevision "nullable"
  Float validPixelRatio "nullable"
  Float invalidPixelPercent "nullable"
  String algorithmVersion
  Json metadata
  DateTime createdAt
  DateTime updatedAt
}
"parcel_index_analysis" {
  String id PK
  String observationKey FK
  String parcelId FK
  String productId FK
  DateTime acquiredAt
  String indexCode
  Float valueMean "nullable"
  Float historicalMean "nullable"
  Float historicalStd "nullable"
  Float zScore "nullable"
  Float trendSlope "nullable"
  Float affectedAreaPercent "nullable"
  Float innerAffectedAreaPercent "nullable"
  Float edgeAffectedAreaPercent "nullable"
  ParcelAnalysisSeverity severity
  Float confidence
  Json metadata
  DateTime createdAt
  DateTime updatedAt
}
"parcel_anomaly" {
  String id PK
  String anomalyKey UK
  String observationKey FK
  String parcelId FK
  String productId FK
  DateTime acquiredAt
  String indexCode
  ParcelAnomalyType type
  ParcelAnomalySpatialPattern spatialPattern "nullable"
  ParcelAnalysisSeverity severity
  Float confidence
  String description
  DateTime detectedAt
  Json metadata
}
"parcel_analysis_decision" {
  String id PK
  String observationKey FK,UK
  String parcelId FK
  String productId FK
  DateTime acquiredAt
  ParcelAnalysisDecisionStatus status
  ParcelAnalysisSeverity summarySeverity
  Float confidence
  String phenologyStage "nullable"
  String suppressionReason "nullable"
  Json hypotheses
  String explanation
  Json limitations
  Json metadata
  DateTime createdAt
  DateTime updatedAt
}
"parcel_recommended_action" {
  String id PK
  String actionKey UK
  String analysisDecisionId FK
  String actionCode
  String label
  ParcelRecommendedActionPriority priority
  Float confidence
  String reason
  ParcelRecommendedActionStatus status
  Json metadata
  DateTime createdAt
  DateTime updatedAt
}
"user" {
  String id PK
  String name
  String email UK
  Boolean emailVerified
  String image "nullable"
  AccountType accountType
  Boolean onboardingCompleted
  String stripeCustomerId "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"plot" {
  String id PK
  String name
  PlotSource source
  Float surface
  String userId FK "nullable"
  String rpgSourceKey UK "nullable"
  String rpgIdParcel "nullable"
  String rpgCultivationCode "nullable"
  String rpgGroupCode "nullable"
  String rpgCultureD1 "nullable"
  String rpgCultureD2 "nullable"
  String rpgCategory "nullable"
  Int rpgSourceYear "nullable"
  String rpgDatasetId FK "nullable"
  String rpgRegionCode "nullable"
  String rpgImportJobId FK "nullable"
  DateTime rpgImportedAt "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"satellite_product" {
  String id PK
  SatelliteProvider provider
  String providerProductId
  String satellite
  String productLevel
  String providerTileId "nullable"
  String tileId FK
  DateTime acquiredAt
  DateTime publishedAt "nullable"
  Float cloudCoverPercent "nullable"
  SatelliteProductStatus status
  String rawObjectKey "nullable"
  String checksum "nullable"
  BigInt sizeBytes "nullable"
  String errorCode "nullable"
  String errorMessage "nullable"
  Int attemptCount
  String claimToken UK "nullable"
  DateTime claimedAt "nullable"
  DateTime claimExpiresAt "nullable"
  Json metadata
  String ingestionRunId FK "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"processing_cube" }|--|| "plot" : parcel
"processing_observation" }o--o| "processing_cube" : cube
"processing_observation" }|--|| "satellite_product" : product
"parcel_index_analysis" }|--|| "processing_observation" : observation
"parcel_index_analysis" }|--|| "plot" : parcel
"parcel_index_analysis" }|--|| "satellite_product" : product
"parcel_anomaly" }|--|| "processing_observation" : observation
"parcel_anomaly" }|--|| "plot" : parcel
"parcel_anomaly" }|--|| "satellite_product" : product
"parcel_analysis_decision" ||--|| "processing_observation" : observation
"parcel_analysis_decision" }|--|| "plot" : parcel
"parcel_analysis_decision" }|--|| "satellite_product" : product
"parcel_recommended_action" }|--|| "parcel_analysis_decision" : analysisDecision
"plot" }o--o| "user" : user

processing_cube

Zarr-based processing cube for a Plot’s time series.

Properties as follows:

  • id:
  • parcelId: Plot id
  • geometryHash:
  • zarrObjectKey:
  • currentManifestObjectKey:
  • revision:
  • leaseToken:
  • leaseOwner:
  • leaseAcquiredAt:
  • leaseExpiresAt:
  • createdAt:
  • updatedAt:

processing_observation

Processing result linking a SatelliteProduct to a Plot.

Properties as follows:

  • id:
  • observationKey:
  • cubeId: ProcessingCube id
  • parcelId: Plot id
  • productId: SatelliteProduct id
  • acquiredAt:
  • outcome:
  • skipReason:
  • manifestObjectKey:
  • cubeRevision:
  • validPixelRatio:
  • invalidPixelPercent:
  • algorithmVersion:
  • metadata:
  • createdAt:
  • updatedAt:

parcel_index_analysis

Vegetation index analysis for a ProcessingObservation on a Plot.

Properties as follows:

  • id:
  • observationKey: ProcessingObservation id
  • parcelId: Plot id
  • productId: SatelliteProduct id
  • acquiredAt:
  • indexCode:
  • valueMean:
  • historicalMean:
  • historicalStd:
  • zScore:
  • trendSlope:
  • affectedAreaPercent:
  • innerAffectedAreaPercent:
  • edgeAffectedAreaPercent:
  • severity:
  • confidence:
  • metadata:
  • createdAt:
  • updatedAt:

parcel_anomaly

Detected anomaly in vegetation patterns on a Plot from a ProcessingObservation.

Properties as follows:

  • id:
  • anomalyKey:
  • observationKey: ProcessingObservation id
  • parcelId: Plot id
  • productId: SatelliteProduct id
  • acquiredAt:
  • indexCode:
  • type:
  • spatialPattern:
  • severity:
  • confidence:
  • description:
  • detectedAt:
  • metadata:

parcel_analysis_decision

Final analysis decision for a ProcessingObservation on a Plot.

Properties as follows:

  • id:
  • observationKey: ProcessingObservation id
  • parcelId: Plot id
  • productId: SatelliteProduct id
  • acquiredAt:
  • status:
  • summarySeverity:
  • confidence:
  • phenologyStage:
  • suppressionReason:
  • hypotheses:
  • explanation:
  • limitations:
  • metadata:
  • createdAt:
  • updatedAt:

Recommended action derived from a ParcelAnalysisDecision.

Properties as follows:

  • id:
  • actionKey:
  • analysisDecisionId: ParcelAnalysisDecision id
  • actionCode:
  • label:
  • priority:
  • confidence:
  • reason:
  • status:
  • metadata:
  • createdAt:
  • updatedAt:

RpgImport

erDiagram
"rpg_dataset" {
  String id PK
  Int sourceYear
  String label
  RpgDatasetStatus status
  DateTime activatedAt "nullable"
  DateTime archivedAt "nullable"
  Int plotCount
  DateTime createdAt
  DateTime updatedAt
}
"rpg_import_job" {
  String id PK
  String datasetId FK
  String sourceUrl
  Json sourceUrls "nullable"
  RpgImportJobMode mode
  RpgImportJobStatus status
  RpgImportScopeType scopeType
  String scopeCode
  String scopeLabel "nullable"
  String createdByUserId FK
  DateTime startedAt "nullable"
  DateTime finishedAt "nullable"
  DateTime activatedAt "nullable"
  DateTime cancelledAt "nullable"
  DateTime createdAt
  DateTime updatedAt
  Int sourceYear
  String sourceLabel "nullable"
  Int importedParcelCount
  Int insertedPlotCount
  Int updatedPlotCount
  Int skippedPlotCount
  Int downloadedPartCount
  Int expectedPartCount
  String errorMessage "nullable"
}
"rpg_import_log" {
  String id PK
  String jobId FK
  DateTime timestamp
  RpgImportLogLevel level
  String step
  String message
}
"user" {
  String id PK
  String name
  String email UK
  Boolean emailVerified
  String image "nullable"
  AccountType accountType
  Boolean onboardingCompleted
  String stripeCustomerId "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"rpg_import_job" }|--|| "rpg_dataset" : dataset
"rpg_import_job" }|--|| "user" : createdByUser
"rpg_import_log" }|--|| "rpg_import_job" : job

rpg_dataset

RPG (Registre Parcellaire Graphique) dataset for a given year.

Properties as follows:

  • id:
  • sourceYear:
  • label:
  • status:
  • activatedAt:
  • archivedAt:
  • plotCount:
  • createdAt:
  • updatedAt:

rpg_import_job

Import job for RPG parcel data, created by a User, for an RpgDataset.

Properties as follows:

  • id:
  • datasetId: RpgDataset id
  • sourceUrl:
  • sourceUrls:
  • mode:
  • status:
  • scopeType:
  • scopeCode:
  • scopeLabel:
  • createdByUserId: User id
  • startedAt:
  • finishedAt:
  • activatedAt:
  • cancelledAt:
  • createdAt:
  • updatedAt:
  • sourceYear:
  • sourceLabel:
  • importedParcelCount:
  • insertedPlotCount:
  • updatedPlotCount:
  • skippedPlotCount:
  • downloadedPartCount:
  • expectedPartCount:
  • errorMessage:

rpg_import_log

Processing log entry for an RpgImportJob.

Properties as follows:

  • id:
  • jobId: RpgImportJob id
  • timestamp:
  • level:
  • step:
  • message:

Satellite

erDiagram
"satellite_product" {
  String id PK
  SatelliteProvider provider
  String providerProductId
  String satellite
  String productLevel
  String providerTileId "nullable"
  String tileId FK
  DateTime acquiredAt
  DateTime publishedAt "nullable"
  Float cloudCoverPercent "nullable"
  SatelliteProductStatus status
  String rawObjectKey "nullable"
  String checksum "nullable"
  BigInt sizeBytes "nullable"
  String errorCode "nullable"
  String errorMessage "nullable"
  Int attemptCount
  String claimToken UK "nullable"
  DateTime claimedAt "nullable"
  DateTime claimExpiresAt "nullable"
  Json metadata
  String ingestionRunId FK "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"satellite_ingestion_run" {
  String id PK
  DateTime startedAt "nullable"
  DateTime finishedAt "nullable"
  SatelliteIngestionRunStatus status
  SatelliteIngestionRunTrigger trigger
  DateTime queryFrom
  DateTime queryTo
  String requestedTileId FK "nullable"
  String requestedProviderProductId "nullable"
  String requestedByUserId FK "nullable"
  Int productCount
  Int downloadedCount
  Int skippedCount
  Int failedCount
  Json metadata
  DateTime createdAt
  DateTime updatedAt
}
"sentinel2_tile" {
  String id PK
  Int sourceEpsg "nullable"
  Json properties
  DateTime createdAt
  DateTime updatedAt
}
"user" {
  String id PK
  String name
  String email UK
  Boolean emailVerified
  String image "nullable"
  AccountType accountType
  Boolean onboardingCompleted
  String stripeCustomerId "nullable"
  DateTime createdAt
  DateTime updatedAt
}
"satellite_product" }|--|| "sentinel2_tile" : tile
"satellite_product" }|--o| "satellite_ingestion_run" : ingestionRun
"satellite_ingestion_run" }o--o| "sentinel2_tile" : requestedTile
"satellite_ingestion_run" }o--o| "user" : requestedByUser

satellite_product

Satellite imagery product (Sentinel-2 scene) on a Sentinel2Tile.

Properties as follows:

  • id:
  • provider:
  • providerProductId:
  • satellite:
  • productLevel:
  • providerTileId:
  • tileId: Sentinel2Tile id
  • acquiredAt:
  • publishedAt:
  • cloudCoverPercent:
  • status:
  • rawObjectKey:
  • checksum:
  • sizeBytes:
  • errorCode:
  • errorMessage:
  • attemptCount:
  • claimToken:
  • claimedAt:
  • claimExpiresAt:
  • metadata:
  • ingestionRunId: SatelliteIngestionRun id
  • createdAt:
  • updatedAt:

satellite_ingestion_run

Ingestion run that discovers and downloads satellite products, optionally targeting a Sentinel2Tile.

Properties as follows:

  • id:
  • startedAt:
  • finishedAt:
  • status:
  • trigger:
  • queryFrom:
  • queryTo:
  • requestedTileId: Sentinel2Tile id
  • requestedProviderProductId:
  • requestedByUserId: User id
  • productCount:
  • downloadedCount:
  • skippedCount:
  • failedCount:
  • metadata:
  • createdAt:
  • updatedAt:

sentinel2_tile

Sentinel-2 tile grid reference containing SatelliteProducts.

Properties as follows:

  • id:
  • sourceEpsg:
  • properties:
  • createdAt:
  • updatedAt:

Import RPG

Les données géométriques des parcelles de la France entière sont disponibles via le RPG, maintenu par le Ministère de l’Agriculture.

Chaque année, une nouvelle version est publiée sous forme d’archives .7z découpées en plusieurs parties (ex. RPG_2024_Ile-de-France.7z.001, RPG_2024_Ile-de-France.7z.002, etc.).

Chaque archive contient un fichier GeoPackage (.gpkg) avec les données parcellaires d’une région spécifique.

Concepts métier

TermeSignification
DatasetCollection annuelle (ex. “RPG 2024”). Statuts : DRAFT → COMPLETE → ACTIVE → ARCHIVED.
JobImport d’une région dans un dataset (ex. “RPG 2024 — Ile-de-France”).
RégionUne des 13 régions administratives françaises, codées en dur dans regions.ts.
ActivationPassage de isActiveRpg = true sur les parcelles issues des jobs complétés. Un seul dataset ACTIVE à la fois.
Multipart 7zArchives découpées en .7z.001, .7z.002, etc. Téléchargées séquentiellement, extraites comme une seule archive.

Flux

Admin UI → API (démarrer import) → file pg-boss → Worker
            events.emit(RPG_IMPORT_DISPATCH)       ├─ Téléchargement des fichiers sources
                                                   ├─ Extraction 7z → recherche rpg_parcelles.gpkg
                                                   ├─ Lecture GeoPackage (SQLite via bun:sqlite)
                                                   └─ Upsert des parcelles par lots de 500 (SQL brut)

Machine à états du workflow d’import

                          ┌─────────────────────────────────────┐
                          │                                     ▼
PENDING → DOWNLOADING → READY_TO_EXTRACT → EXTRACTING → PARSING → TRANSFORMING → IMPORTING → COMPLETED → ACTIVATED
   │           │                │               │            │           │             │          │
   │           └───────────────────────────────────────────────────────────────────────► FAILED   │
   │                            │                                                          │      │
   └────────────────────────────┴────────────────────────────────────────────────────► CANCELLED  │
                                                                                                  ▼
                                                                                               CANCELLED

FAILED est relançable quand :

  • Le statut est READY_TO_EXTRACT, ou
  • Le statut est FAILED et toutes les parties sont téléchargées (downloadedPartCount >= expectedPartCount), ou
  • Le statut est PENDING (job bloqué avant le démarrage du pipeline)

Mode DIRECT : saute READY_TO_EXTRACT — après le téléchargement, le pipeline continue directement vers EXTRACTING.

Mode MULTIPART_7Z : se met en pause à READY_TO_EXTRACT et attend qu’un admin déclenche manuellement l’étape d’extraction.

Il y a 2 modes d’import : DIRECT et MULTIPART_7Z. Le mode est déterminé automatiquement par le nombre de parties dans l’archive. DIRECT est utilisé pour les imports avec une seule archive .7z, tandis que MULTIPART_7Z est utilisé pour les imports avec des archives découpées en plusieurs parties.

Modules clés

transitionRpgImportJob(jobId, to, extraFields?)

Point d’entrée unique pour tous les changements de statut d’un job. Récupère le statut courant, valide la transition contre la machine à états, écrit de manière atomique. failRpgImportJob et completeRpgImportJob passent par cette fonction.

JobWorkspace

Propriétaire de tous les chemins filesystem du répertoire de travail d’un job :

  • workspace.dir — racine ($RPG_IMPORT_WORKDIR/<jobId>/)
  • workspace.downloadsDir — parties d’archive téléchargées
  • workspace.extractDir — GeoPackage extrait
  • workspace.cleanup() — supprime tout le workspace
  • workspace.cleanupExtracted() — supprime uniquement les artefacts extraits (conserve les téléchargements pour une nouvelle tentative)

Créé une seule fois dans pipeline.ts au démarrage du job, transmis aux fonctions d’archive.

logImportStep(jobId, step, message, level?)

Fonction simple. Écrit une ligne RpgImportLog. Niveau par défaut : INFO. Appelée depuis le pipeline et le geopackage-importer — pas d’instanciation de classe ni d’injection.

flushImportChunk(rows)

Exécute un pipeline PostgreSQL à 8 CTEs :

  1. input — liaison des valeurs de ligne
  2. prepared — cast des types, décodage WKB hex → géométrie PostGIS (SRID 2154)
  3. normalizedST_MakeValid sur les géométries invalides → ST_CollectionExtract(3)ST_Multi
  4. deduplicatedGROUP BY rpgSourceKey, ST_UnaryUnion pour les géométries multi-parties
  5. annotated — marquage isEmpty, alreadyExists
  6. upsertedINSERT … ON CONFLICT (rpgSourceKey) DO UPDATE
  7. SELECT — retourne les compteurs : insertedRows, updatedRows, skippedRows, validRows, invalidRows

Retourne ImportChunkStats. Aucun effet de bord sur le statut du job.

Décisions d’architecture

  1. SQL brut pour l’upsert : la normalisation géométrique PostGIS + l’upsert ON CONFLICT ne peuvent pas être exprimés via Prisma. flushImportChunk utilise prisma.$queryRawUnsafe.
  2. Streaming via bun:sqlite : lecture SQLite directe depuis le .gpkg, découpée en lots de 500 lignes. Pendant qu’un lot est flushé en DB, le suivant est parsé (pipeline via chaîne de promesses pendingFlush).
  3. pg-boss pour la durabilité : les jobs survivent aux redémarrages serveur. localConcurrency: 1 garantit le traitement séquentiel — pas d’imports concurrents, pas de race condition sur isActiveRpg.
  4. Clé source déterministe : hash SHA-256 de (sourceYear, regionCode, id_parcel, code_cultu, code_group, culture_d1, culture_d2, cat_cult_p) — relancer le même import fait un upsert au lieu de dupliquer.
  5. Limites de sécurité au téléchargement : 6 Gio par fichier, 30 Gio total, timeout de 20 minutes. Liste blanche d’hôtes via la variable d’environnement RPG_IMPORT_ALLOWED_HOSTS.
  6. Conservation des téléchargements en cas d’échec : si l’extraction ou l’import échoue mais que les téléchargements sont complets, le workspace est conservé. La nouvelle tentative saute le téléchargement et reprend directement à l’extraction.

⚙️ DevOps & Infrastructure

Monorepo

CerOps est organisé en monorepo géré par Turborepo et Bun. Toutes les applications et packages partagés cohabitent dans un seul dépôt Git, ce qui garantit la cohérence des types TypeScript entre le backend et les frontends, et simplifie les déploiements.

Bun est utilisé à la fois comme runtime JavaScript, package manager et outil de compilation (le serveur Fastify est compilé en binaire natif via bun build --compile). Les versions des dépendances partagées sont centralisées dans le workspace catalog pour éviter toute dérive entre packages.

Application mobile Flutter

L’application mobile agriculteurs est développée en Flutter dans un dépôt séparé. Elle n’est pas intégrée au monorepo pour deux raisons principales : l’écosystème Flutter (Dart, pub.dev) est incompatible avec la chaîne d’outils Bun/Node, et les cycles de release mobiles (App Store / Play Store) suivent un rythme indépendant du déploiement web.

L’app communique avec le même backend via les mêmes endpoints ORPC.

Intégration Continue (GitHub Actions)

Quatre workflows couvrent l’ensemble du cycle de qualité :

  • CI (ci.yaml) : build, tests unitaires et lint (Biome, knip, sherif), déclenché sur chaque PR et push sur main
  • E2E (e2e.yaml) : tests Playwright avec une base PostGIS éphémère, déclenché sur chaque PR
  • PR title (pr-title.yaml) : validation Conventional Commits
  • Docs (docs.yaml) : compilation mdBook, déclenché uniquement si docs/** est modifié

Le cache Turborepo est restauré entre les runs pour ne reconstruire que les packages affectés par la PR.

Conteneurisation (Docker)

Chaque application a son propre Dockerfile multi-stage : le pruning Turborepo extrait uniquement les fichiers nécessaires à l’app ciblée, puis le build produit une image minimale. Le serveur est distribué comme binaire compilé, les frontends Next.js en mode standalone.

Déploiement : Coolify

Coolify est le PaaS self-hosted qui orchestre tous les services. Aujourd’hui, en phase de développement, Coolify effectue lui-même les builds directement depuis le dépôt Git à chaque push sur main.

À terme, cette responsabilité sera transférée à GitHub Actions : un workflow dédié se chargera de builder et publier une image Docker taguée (ex. v1.2.0) sur le registry, et Coolify n’aura plus qu’à déployer cette image prébuilt sur l’environnement cible (production, pré-production, etc.) sans refaire de build. Cette séparation garantit que le même artefact immuable traverse tous les environnements.

Services applicatifs

Les trois applications (server, web-agri, web-pilots) sont chacune un service Coolify distinct pointant sur leur Dockerfile respectif.

Base de données

La base de données est un service PostGIS (PostgreSQL + extension géospatiale) provisionné par Coolify. L’extension PostGIS est utilisée pour les calculs géographiques liés aux zones agricoles et aux missions terrain.

Services annexes

Coolify héberge également :

  • OpenObserve : observabilité unifiée (logs, métriques, traces)
  • RustFS : stockage objet compatible S3 (voir ci-dessous)
  • RabbitMQ : broker de messages pour le pipeline d’analyse d’images (voir ci-dessous)
  • pgAdmin : interface d’administration PostgreSQL
  • mdBook : cette documentation

Architecture de déploiement (actuelle - full dev)

GitHub (push main)
    │
    ▼ webhook + build
Coolify
    ├── server        (API Fastify)
    ├── web-agri      (frontend agriculteurs)
    ├── web-pilots     (frontend pilotes)
    ├── postgis        (base de données)
    ├── openobserve    (observabilité)
    ├── rustfs         (stockage objet S3)
    ├── rabbitmq       (broker analyse images)
    ├── pgadmin        (admin BDD)
    └── mdbook         (documentation)

Architecture de déploiement (cible)

GitHub (tag vX.Y.Z)
    │
    ▼ GitHub Actions (build + push image)
Container Registry
    │
    ▼ deploy image
Coolify
    ├── prod       → image :v1.2.0
    ├── preprod    → image :v1.2.0-rc1
    └── ...

Stockage objet : RustFS (S3)

RustFS est un serveur de stockage objet compatible S3, auto-hébergé via Coolify. Il stocke les livrables de missions drone (orthophotos, rapports), les images satellite brutes Copernicus, et les cubes Zarr d’historique des parcelles. Voir Imagerie Parcellaire pour le détail du pipeline et des cubes Zarr.

Messagerie asynchrone : RabbitMQ

RabbitMQ orchestre le pipeline d’analyse d’imagerie satellite (également auto-hébergé via Coolify) : chaque nouvelle image disponible publie un message qu’un worker consomme pour calculer les indices et mettre à jour les cubes Zarr. Voir Imagerie Parcellaire pour le détail du pipeline.

Release & Déploiement

Ce chapitre détaille le processus de release et les différentes stratégies de déploiement selon l’environnement cible.

Processus de Release

Le processus de release est déclenché manuellement via GitHub Actions (workflow_dispatch) :

  1. Déclencheur : execution manuelle du workflow avec un input de version (patch, minor, major ou 1.2.3)
  2. Préparation : bump des versions dans les 3 package.json via bumpp
  3. Build : création des images Docker pour les 3 applications
  4. Publication : push des images taguées dans le registry Github en privé
  5. Commit & Tag : commit des changements de version + création du tag Git
  6. GitHub Release : création de la release GitHub avec le changelog généré par git-cliff

Le tag Git est créé après le build réussi, pas avant. Cela garantit que le tag pointe toujours vers du code qui a été buildé et testé.

Versionnement

Le projet suit Semantic Versioning :

  • MAJOR : changement breakant l’API
  • MINOR : nouvelle fonctionnalité backward-compatible
  • PATCH : correction de bug

Environnements

Développement

L’environnement de développement est déployé automatiquement via Coolify. À chaque push sur la branche main, Coolify détecte le changement et lance un build direct depuis le dépôt Git.

GitHub (push main)
    │
    ▼ webhook
Coolify dev
    ├── server    (build auto)
    ├── web-agri  (build auto)
    └── web-pilots (build auto)

Cet environnement sert aux tests d’intégration et au développement quotidien.

Production

La production n’est déployée qu’à partir de tags Git validés. L’image Docker est prébuildée par le workflow de release, puis déployée par Coolify.

Application Mobile (Play Store)

L’application Flutter (dépôt Cerops/mobile) suit un cycle de release indépendant du monorepo web.

Le déploiement sur le Play Store est automatisé via GitHub Actions (release.yml) :

  1. Build : compilation Flutter en mode release (.aab)
  2. Upload : envoi sur la track interne Google Play via r0adkll/upload-google-play
- name: Upload to Google Play (internal)
  uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
  with:
    serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
    packageName: com.cerops.mobile
    releaseFiles: build/app/outputs/bundle/release/*.aab
    track: internal

L’identifiant de l’application sur le Play Store est com.cerops.mobile. La promotion de la track interne vers production se fait manuellement depuis la Google Play Console.

Keep a Changelog

Le projet utilise git-cliff pour maintenir un historique clair des changements. Le changelog est généré automatiquement lors de la création de la release GitHub, en se basant sur les commits depuis la dernière release.

Architecture logicielle — Cerops

Vue d’ensemble

Cerops est une plateforme mettant en relation des agriculteurs et des pilotes de drone certifiés pour la surveillance et l’analyse de parcelles agricoles.


Type d’application

ApplicationTypeTechnologie
web-agriApplication web riche (SPA/SSR)Next.js 16 + React 19
web-pilotsApplication web riche (SPA/SSR)Next.js 16 + React 19
mobileApplication mobile nativeFlutter (iOS + Android)
serverServeur APIFastify + oRPC

Gestion de projet

Approche Agile : TDD, Conventional Commits, versioning sémantique (changelog via git-cliff), CI/CD GitHub Actions sur chaque PR.

La roadmap est organisée en milestones, avec des issues détaillées dans GitHub. Les décisions d’architecture sont formalisées dans des ADR (Architecture Decision Records) pour assurer la traçabilité.


Architecture retenue : Monolithe modulaire en monorepo

Justification (ADR-002)

L’équipe est restreinte et le périmètre fonctionnel bien défini. Une architecture microservices aurait apporté une complexité opérationnelle (service discovery, monitoring distribué) inadaptée. Le monolithe modulaire permet un développement rapide avec une séparation claire des responsabilités via les routers oRPC par domaine.

Structure monorepo (ADR-001)

cerops/
├── apps/
│   ├── web-agri/     # Frontend agriculteur (port 3001)
│   ├── web-pilots/   # Frontend pilote (port 3002)
│   └── server/       # API Fastify (port 3000)
└── packages/
    ├── api/          # Routers oRPC + logique métier
    ├── auth/         # Better-Auth
    ├── db/           # Prisma + schéma PostgreSQL
    ├── env/          # Validation des variables d'environnement
    └── web-shared/   # Utilitaires partagés entre les deux frontends

Géré par Turborepo pour le cache de build et l’exécution parallèle des tâches. La dépendance mobile/ (Flutter) est volontairement séparée du monorepo TypeScript (ADR-009).


Choix technologiques justifiés

Serveur : Fastify + oRPC (ADR-005, ADR-006)

Fastify pour ses performances et son écosystème de plugins. oRPC assure la sécurité de type de bout en bout (serveur → client) sans génération de code. Exposition OpenAPI automatique via @orpc/openapi.

Frontend web : Next.js (ADR-004)

Deux applications distinctes par audience (agriculteurs / pilotes) pour éviter toute fuite de logique entre rôles. App Router Next.js pour le SSR natif et les Server Components (appels oRPC sans overhead réseau côté serveur).

Mobile : Flutter (ADR-009)

Performances proches du natif, accès caméra/GPS, et mode offline robuste via Drift (SQLite embarqué). Une seule codebase iOS + Android. Choisi face à React Native (offline plus complexe) et PWA (accès natif limité).

Base de données : PostgreSQL + PostGIS + Prisma (ADR-007, ADR-008)

PostgreSQL pour les transactions ACID et PostGIS pour les requêtes géospatiales (parcelles, viewport map). Prisma comme ORM pour la type-safety et les migrations versionées.

Authentification : Better-Auth (ADR-011)

Sessions httpOnly cookies, bearer tokens pour les clients API, CAPTCHA Cloudflare Turnstile, plugin Stripe pour les abonnements agriculteurs.

Runtime : Bun (ADR-003)

Remplacement de Node.js pour des performances d’installation et d’exécution supérieures. Compatible avec l’écosystème npm.

Paiements : Stripe (ADR-012)

Pré-autorisation à la création de mission, capture à la livraison du livrable.


Schéma de communication

Browser / Mobile App
        │
        │  HTTPS (cookies httpOnly / Bearer token)
        ▼
┌──────────────────┐
│  Fastify Server  │  port 3000
│  ┌────────────┐  │
│  │ oRPC Layer │  │  /rpc/*
│  └────────────┘  │
│  ┌────────────┐  │
│  │ Better-Auth│  │  /api/auth/*
│  └────────────┘  │
│  ┌────────────┐  │
│  │  OpenAPI   │  │  /docs
│  └────────────┘  │
└──────┬───────────┘
       │
       ├── PostgreSQL + PostGIS (port 5432)
       ├── Ollama LLM (port 11434)
       ├── S3-compatible (fichiers livrables)
       └── Stripe API (paiements)

Use Cases — Cerops

Extraits des fichiers de tests (packages/api/test/) qui constituent la spécification exécutable du système.


Acteurs

ActeurDescription
AgriculteurPublie des missions, gère ses parcelles et plans d’action
PiloteAccepte et exécute des missions de drone
AdminGère les datasets RPG, la tarification, les options d’analyse
SystèmeImporte les parcelles RPG, calcule les prix, gère le score pilote

UC-01 : Gestion des parcelles

Acteur principal : Agriculteur

#Cas d’utilisationPréconditionRésultat attendu
1.1Consulter ses parcellesAuthentifiéListe des parcelles ordonnée par date de modification
1.2Créer une parcelle manuellementAuthentifié, géométrie valide (< 500 ha)Parcelle créée avec surface calculée depuis la géométrie
1.3Créer une parcelle avec type de cultureAuthentifiéParcelle créée avec cultureType
1.4Modifier une parcelleAuthentifié, propriétaireNom et/ou type de culture mis à jour
1.5Supprimer une parcelleAuthentifié, propriétaireParcelle supprimée
1.6Accéder à une parcelle RPG activeAuthentifiéAccès autorisé même sans ownership
1.7Revendiquer des parcelles RPGAuthentifié, parcelles non encore revendiquéesParcelles rattachées à l’agriculteur
1.8Consulter la carte (viewport)Authentifié, zoom suffisantGeoJSON des parcelles dans le viewport

Cas d’erreur notables :

  • Parcelle > 500 ha → rejetée
  • Parcelle qui chevauche une parcelle existante de l’utilisateur → rejetée
  • Accès à la parcelle d’un autre utilisateur → FORBIDDEN
  • Parcelle RPG inactive → NOT_FOUND

UC-02 : Marketplace des missions

Acteurs : Agriculteur (publication), Pilote (acceptation/livraison)

#Cas d’utilisationActeurRésultat attendu
2.1Estimer le prix d’une missionAgriculteurPrix total + détail des lignes (base + ha + options)
2.2Créer une missionAgriculteurMission PUBLISHED, prix calculé automatiquement, config de tarification associée
2.3Lister les missions disponiblesPiloteMissions PUBLISHED, filtrées par compatibilité capteurs, prix min/max, distance
2.4Accepter une missionPilote compatibleMission passe à SCHEDULED, pilote assigné
2.5Annuler une mission (pilote)Pilote assignéMission repasse à PUBLISHED, score pilote -2
2.6Annuler une mission (agriculteur)Agriculteur propriétaireMission CANCELLED, score pilote non affecté
2.7Livrer un livrablePilote assigné (mission SCHEDULED ou IN_PROGRESS)Mission DELIVERED, score pilote +1, fichiers stockés
2.8Consulter ses missions (agriculteur)AgriculteurListe de ses missions avec statut, pilote assigné, livrable
2.9Consulter ses missions (pilote)PiloteMissions groupées par date d’acceptation

Cas d’erreur notables :

  • Prix fourni par le client → rejeté (prix calculé côté serveur uniquement)
  • Parcelles appartenant à un autre agriculteur → rejeté
  • Aucun config de tarification publiée → rejeté
  • Deuxième pilote tente d’accepter → CONFLICT
  • Pilote déjà à 5 missions SCHEDULEDFORBIDDEN
  • Pilote sans capteurs requis → FORBIDDEN
  • Score pilote plancher à -20 (ne peut pas descendre plus bas)

UC-03 : Plans d’action

Acteur principal : Agriculteur

#Cas d’utilisationRésultat attendu
3.1Créer un plan d’actionPlan créé, titre unique par utilisateur
3.2Créer une tâche dans un planTâche créée avec statut todo par défaut
3.3Associer une tâche à une parcelleTâche liée à une parcelle de l’utilisateur
3.4Modifier le statut d’une tâcheStatut mis à jour (todoin_progresscompleted)
3.5Réordonner les tâchesrank mis à jour
3.6Définir une échéancedeadline enregistrée
3.7Supprimer un plan d’actionPlan et toutes ses tâches supprimés (cascade)

Cas d’erreur notables :

  • Accès au plan d’action d’un autre utilisateur → FORBIDDEN
  • Association d’une tâche à la parcelle d’un autre utilisateur → FORBIDDEN

UC-04 : Profil pilote

Acteur principal : Pilote

#Cas d’utilisationRésultat attendu
4.1Consulter son profilProfil auto-créé si inexistant, avec liste de drones et capteurs
4.2Mettre à jour son profilChamps scalaires mis à jour
4.3Mettre à jour sa flotte de dronesListe de drones remplacée intégralement
4.4Mettre à jour ses capteursListe de capteurs remplacée intégralement

UC-05 : Import RPG (Admin)

Acteur principal : Admin

#Cas d’utilisationRésultat attendu
5.1Créer un dataset RPGDataset DRAFT créé
5.2Lancer un import par régionJob créé, pipeline asynchrone déclenché (téléchargement → extraction → parsing → import)
5.3Activer un datasetParcelles RPG rendues visibles aux agriculteurs
5.4Archiver un datasetParcelles masquées

UC-06 : Assistant IA agronomique

Acteur principal : Agriculteur

#Cas d’utilisationRésultat attendu
6.1Poser une question agronomiqueRéponse de l’assistant
6.2Soumettre une photo de cultureAnalyse visuelle : diagnostic, causes probables, actions recommandées

Politique de tests : Cerops


Principes

Les tests sont la spécification exécutable du système. Chaque procédure métier est couverte avant ou simultanément à son implémentation (TDD). Un test qui passe en CI est la définition de “fonctionne”.


Pourquoi les tests d’intégration sont faciles

oRPC expose les procédures métier comme des fonctions TypeScript ordinaires. Les tests les appellent directement, sans serveur HTTP à démarrer, sans mock réseau, sans sérialisation/désérialisation à traverser.

La base de données est une instance PostgreSQL + PostGIS réelle, éphémère par session de test, identique à celle de production (même schéma, mêmes types, mêmes contraintes). Ce que le test valide est exactement ce qui tourne en prod.

// Appel direct de la procédure, pas de fetch, pas de mock
const mission = await caller.missions.createMission({ plotIds, type, windowStart, windowEnd })

Chaque cas d’erreur (FORBIDDEN, UNAUTHORIZED, NOT_FOUND, CONFLICT) est testé explicitement, pas implicitement via des règles métier imprécises.


Structure

Un fichier de test par domaine métier dans packages/api/test/. La liste des fichiers reflète la liste des routers oRPC. Si un domaine n’a pas de fichier de test, il n’est pas couvert.

Tests E2E

Framework : Playwright · Localisation : packages/e2e/tests/

Flux d’authentification couverts. Les parcours utilisateur complets (web-agri, web-pilots) ne sont pas encore couverts.


Conventions de nommage

  • describe : nom de la procédure oRPC (createMission, listPlots, etc.)
  • test : phrase décrivant le comportement attendu, commençant par un verbe ou throws
    • Cas nominal : "creates mission with derived price and pricing config"
    • Cas d’erreur : "throws FORBIDDEN when called by a pilot"
    • Cas limite : "score floor: score cannot go below -20 after many cancels"

Ce qui n’est pas encore couvert

  • Tests E2E des parcours utilisateur complets (web-agri, web-pilots) : uniquement auth pour l’instant
  • Tests de charge / performance
  • Tests de la couche mobile Flutter

CerOps — Business Plan 2026

Le ciel au service de l’agriculture Imagerie satellite & drone · Analyse agronomique par IA


Problématiques

ProblèmeDescription
Données fragmentéesMétéo, analyses de sol, images satellite, conseils coop : tout est éparpillé sur des outils différents. La vision d’ensemble manque au moment de décider.
Précision insuffisanteLes images satellite disponibles restent limitées (précision > 1 m) pour détecter les anomalies précocement ou intervenir à l’échelle de la parcelle.
Coût des erreursUn dosage ou un timing de traitement incorrect se paye cher : marge rognée, risques agronomiques, non-conformité réglementaire. Prix de l’eau et engrais en hausse.
Absence d’outil simple et directement actionnableLes agriculteurs n’ont pas accès à un outil simple, intégré et directement actionnable, transformant des données complexes en recommandations concrètes.

349 600 exploitations agricoles en France (Agreste ESEA 2023).


Solution & Avantages Concurrentiels

Notre Solution

  • Analyse d’images satellite & drone + IA
  • Heat maps de biomasse et de stress hydrique (NDVI…)
  • Suggestions de traitement localisées par niveau de risque
  • Export de fichiers ISO-XML pour machines agricoles compatibles
  • Module RSE : estimation de l’impact environnemental & reporting
  • Application mobile utilisable depuis les champs
  • Notification directement accessible sur téléphone

Avantages Clés

AvantageDétail
Analyse en < 20 minDes images brutes à une décision en moins de 20 minutes
ISO-XML natifPrescription directement exploitable par la machine
Module RSE intégréPreuves environnementales mobilisables
Sans expertise numériqueInterface pensée pour l’agriculteur

État d’avancement : l’analyse satellite, les heat maps NDVI, les abonnements Stripe et l’application mobile sont opérationnels. L’export ISO-XML et le module RSE sont en cours de développement, livraison prévue avant le lancement commercial (début 2027). Le « < 20 min » est l’objectif de SLA du pipeline, mesuré en conditions de test.


Étude de Marché — Agriculture de précision, France & Europe

MarchéTailleDescription
TAM — Total Addressable Market11,5 Md€Marché mondial de l’agriculture de précision (2024). Croissance ~13 %/an, portée par la réduction des intrants et les exigences RSE.
SAM — Serviceable Available Market500 M€France + Europe de l’Ouest. Exploitations céréalières > 50 ha, viticulteurs, maraîchers et CUMA (~120 000 exploitations potentielles).
SOM — Serviceable Obtainable Market4 – 8 M€Plafond atteignable à 5 ans : 1–2 % du SAM (1 200–2 400 exploitations à 150–300 €/mois). Objectif du plan à 36 mois : 770 exploitations clientes, ~2 M€ de CA, soit ~0,6 % des exploitations du SAM — la moitié du bas de la fourchette SOM.

Sources : MarketsandMarkets 2024, Chambres d’Agriculture France, Agreste 2023


Description de l’Offre

Parcours utilisateur actuel (sans CerOps)

  1. Observation terrain — L’agriculteur constate visuellement un problème (jaunissement, verse, stress) sans pouvoir le quantifier ni le localiser précisément.
  2. Collecte de données — Il interroge plusieurs sources (appli météo, coopérative, bulletin phytosanitaire) sans agrégation possible. Temps moyen : 2–3h par décision.
  3. Décision à l’aveugle — En l’absence de carte de prescription, il traite souvent en plein (dose uniforme), perdant en moyenne 10 à 20 % d’intrants inutilement (USDA ERS ; EDIS/UFL).
  4. Avec CerOps — Il commande son vol et 20 min après le passage du pilote, il reçoit une heat map, des zones prioritaires et un fichier ISO-XML prêt pour sa machine.

Pipeline de traitement

Import image      →   Analyse IA      →   Heat Maps         →   Prescription ISO-XML   →   Recommandation
Satellite / Drone     Traitement          NDVI, stress,         Fichier machine             Optimisation agronomique
                      < 20 min            biomasse                                          & impact environnemental

Livrables inclus dans chaque analyse

  • Rapport PDF avec recommandations agronomiques localisées et impact environnemental (RSE)
  • Dashboard web interactif : suivi, historique, archivage
  • Fichier ISO-XML prêt à être utilisé dans les machines agricoles
  • Estimation des économies d’intrants (objectif : -10 % / parcelle)

Contraintes

Réglementaires

  • RGPD : données parcellaires et coordonnées GPS considérées comme données personnelles
  • Réglementation drone (DGAC) pour l’acquisition d’images aériennes
  • Directives phytosanitaires : les recommandations doivent rester des « aides à la décision » et non des prescriptions certifiées

Techniques

  • Dépendance à la couverture nuageuse pour les images satellite (solution : drone en complément)
  • Résolution satellite commerciale : 30–50 cm (Pléiades Neo/1A) — 10 m minimum (Sentinel-2 Copernicus)
  • Compatibilité ISO-XML limitée aux machines récentes ; besoin d’accompagnement pour le parc vieillissant

Approvisionnement / Partenariats

  • Accès aux images : partenariat avec Airbus (Pléiades / SPOT) ou Maxar/Planet
  • Partenariat coopératives et CUMA pour mutualiser les coûts d’abonnement
  • Interopérabilité avec les logiciels de gestion parcellaire existants (Smag, ISAGRI…)

Objectifs & Stratégie Commerciale

Objectifs SMART à 12, 24 et 36 mois

Tous les chiffres ci-dessous sont dérivés du plan de ventes (section « Objectifs de ventes sur 3 ans ») et du compte de résultat prévisionnel.

HorizonObjectifs
12 mois50 exploitations inscrites (plan Free inclus), dont 24 payantes (17 Starter, 7 Pro) · MRR fin d’année : 4 650 € · CA année 1 : 60 800 € · Taux d’adoption des suggestions IA > 60 % · Lancement MVP
24 mois200 exploitations clientes actives (140 Starter, 60 Pro) · MRR : 39 000 € — ARR abonnements : 468 000 € · CA année 2 : 528 200 € · 1 partenariat coopérative ou CUMA signé (accès à 20–50 exploitations) · Module drone opérationnel
36 mois770 exploitations actives (540 Starter, 230 Pro) · MRR : 150 000 € — ARR abonnements : 1,8 M€ · CA année 3 : ~2 M€ · Expansion Europe : Belgique, Pays-Bas, Espagne · Intégration ISO-XML V2 pour 90 % du parc compatible

Modèle tarifaire

PlanPrixContenu
Free0 €/moisCarte satellite de base, 1 analyse / mois
Starter150 €/moisJusqu’à 5 analyses/mois · Dashboard web + accès vol drone
Pro300 €/moisAnalyses illimitées · ISO-XML · Rapport RSE
CUMA / CoopSur devisLicences multi-exploitations · API intégration

Canaux d’acquisition

CanalDescription
Coopératives & CUMACanal prioritaire : 1 accord = accès immédiat à 20–50 exploitations. Démonstrations terrain.
Prospection directeCiblage exploitations > 50 ha via bases Agreste, salons (SIMA, Innov-Agri, Sitevi).
Content MarketingBlog agronomique, webinaires, études de cas ROI. SEO sur mots-clés agriculture de précision.
Ads cibléesFacebook (très utilisé en agricole), Google Ads (recherche métier).

Analyse Concurrentielle

Positionnement de CerOps face aux acteurs existants

CritèreSmag/ISAGRIJohn Deere Ops CenterxarvioFieldViewEarthDaily AgroCerOps
Analyse satellitePartielleOuiOuiOuiOuiOui
< 20 min recommandationsNonNonNonNonNonOui
Export ISO-XML machineNon (PAC-XML)Oui (flotte JD)NonOuiNonOui
Module RSE intégréNonNonPartielNonNonOui
Indépendant marque/chimieOuiNon (JD)Non (BASF)Non (Bayer)OuiOui
Prix indicatif / an~399 €FreemiumSur devis*~1 999 € packSur devis1 800–3 600 €

* xarvio (BASF) : gratuit ou inclus avec l’achat de produits phyto BASF — Sources : aladin.farm ; climatefieldview.fr/tarifs ; ptxtrimble.com ; xarvio.com


Business Plan Financier

1. Grille tarifaire & marges unitaires

ProduitPrix de venteCoût unitaireMarge
Abonnement Starter (0–80 Ha)150 €/mois1 €/mois149 €/mois
Abonnement Pro (80–150 Ha)300 €/mois2 €/mois298 €/mois
Vol de drone500 €380 €120 €
Module RSE6 €/Ha/an0,20 €/Ha/an5,80 €/Ha/an
Module Assurance12 €/Ha/an0,50 €/Ha/an11,50 €/Ha/an

2. Objectifs de ventes sur 3 ans

ProduitAnnée 1Année 2Année 3CA An 3
Abonnement Starter (0–80 Ha)17 clients140 clients540 clients972 000 €
Abonnement Pro (80–150 Ha)7 clients60 clients230 clients828 000 €
Vol de drone10 vols70 vols240 vols120 000 €
Module RSE1 400 Ha4 800 Ha28 800 €
Module Assurance1 400 Ha4 800 Ha57 600 €
Total CA60 800 €528 200 €2 006 400 €2 006 400 €

3. Investissements initiaux (CAPEX)

PosteMontant
Logo + Identité visuelle2 000 €
Honoraires (avocat, comptable) pour création de société2 000 €
Traitement informatique N-11 000 €
Total CAPEX5 000 €

4. Coûts opérationnels sur 3 ans (OPEX)

Poste202720282029
Ressources Humaines
CEO0 €12 000 €40 000 €
CTO0 €12 000 €40 000 €
Consultant RSE10 000 €20 000 €
Consultant Assurance10 000 €20 000 €
Consultant Développeur20 000 €40 000 €
Consultant IA10 000 €40 000 €
Administratif
Comptable2 400 €2 400 €2 400 €
Opérations
Infrastructure Cloud1 100 €1 100 €1 100 €
Infrastructure Cloud IA200 €200 €200 €
Divers5 000 €10 000 €20 000 €
Marketing20 000 €30 000 €40 000 €
Coûts annuels28 700 €117 700 €263 700 €
Coûts mensuels2 392 €9 808 €21 975 €

5. Compte de résultat prévisionnel

202720282029
VENTES
Abonnement Starter30 600 €252 000 €972 000 €
Abonnement Pro25 200 €216 000 €828 000 €
Vol de drone5 000 €35 000 €120 000 €
Module RSE8 400 €28 800 €
Module Assurance16 800 €57 600 €
Total des ventes60 800 €528 200 €2 006 400 €
CHARGES VARIABLES
Abonnement Starter204 €1 680 €6 480 €
Abonnement Pro168 €1 440 €5 520 €
Vol de drone3 800 €26 600 €91 200 €
Module RSE0 €280 €960 €
Module Assurance0 €700 €2 400 €
CHARGES FIXES (OPEX)
CEO0 €12 000 €40 000 €
CTO0 €12 000 €40 000 €
Consultant RSE10 000 €20 000 €
Consultant Assurance10 000 €20 000 €
Consultant Développeur20 000 €40 000 €
Consultant IA10 000 €40 000 €
Comptable2 400 €2 400 €2 400 €
Infrastructure Cloud1 100 €1 100 €1 100 €
Infrastructure Cloud IA200 €200 €200 €
Divers5 000 €10 000 €20 000 €
Marketing20 000 €30 000 €40 000 €
CAPEX amorti1 667 €1 667 €1 667 €
Total des charges34 539 €150 067 €371 927 €
RÉSULTAT NET+26 261 €+378 133 €+1 634 473 €
Rentabilité43,2 %71,6 %81,5 %

6. Scénario prudent (sensibilité année 1)

Le compte de résultat ci-dessus suppose que les clients de l’année 1 sont facturés sur 12 mois. En réalité, l’acquisition est progressive : en moyennant à 6 mois de facturation pour les clients acquis en cours d’année :

Année 1 — planAnnée 1 — prudent
CA60 800 €~32 900 €
Charges34 539 €~34 400 €
Résultat net+26 261 €≈ −1 500 €

Le point mort est atteint en cours d’année 2 dans les deux scénarios. Ce scénario prudent justifie la trésorerie de sécurité du plan de financement.


7. Plan de financement

Le modèle atteint l’équilibre entre l’année 1 (scénario plan) et le début de l’année 2 (scénario prudent). Le besoin de financement couvre le CAPEX initial et une trésorerie de sécurité — aucun emprunt bancaire n’est nécessaire.

PosteMontant
CAPEX initial (logo, honoraires, infra N-1)5 000 €
Trésorerie de sécurité (couvre le scénario prudent + BFR)17 000 €
Besoin total22 000 €
SourceApport
Fondateurs20 000 €
CTO (associé)2 000 €
Total couvert22 000 €

8. Hypothèses de churn & CAC

Churn. Les jalons de §2/SMART (24 / 200 / 770 exploitations payantes) sont des actifs nets (après départs), pas des ventes brutes. Hypothèse retenue : 15 % de churn annuel, cohérent avec un abonnement B2B agricole sans engagement long et un produit encore jeune. Volume d’acquisition brut nécessaire pour tenir ces jalons nets :

Actifs début périodePerdus (15 %)Actifs conservésJalon net (§2)Nouvelles ventes brutes requises
Année 224420200180
Année 320030170770600

Le churn ne modifie pas le CA (déjà exprimé sur les actifs nets, cf. §5), mais implique un effort commercial ~1,3 à 1,5 fois supérieur à un scénario sans départs — d’où le poids du canal CUMA (Canaux d’acquisition) pour absorber ce volume sans faire exploser le CAC.

CAC. Budget marketing annuel (§4 OPEX) rapporté aux nouvelles ventes brutes ci-dessus (24 en année 1, sans churn la première année) :

Budget marketingNouvelles ventes brutesCAC moyen
Année 120 000 €24~833 €/client
Année 230 000 €180~167 €/client
Année 340 000 €600~67 €/client

Le CAC blended baisse fortement car le mix d’acquisition bascule vers le canal CUMA (1 accord = accès immédiat à 20-50 exploitations, coût marginal quasi nul par exploitation), au détriment de la prospection directe et des ads. Avec une marge moyenne pondérée Starter/Pro d’environ 194 €/mois (§1, mix 70/30), le CAC le plus élevé (année 1, 833 €) est remboursé en moins de 5 mois ; les années suivantes, en moins d’un mois.

Sensibilité. À 25 % de churn annuel au lieu de 15 %, les ventes brutes requises en année 3 passent à ~620 au lieu de 600 (+3 %) pour tenir le même jalon net de 770 actifs — impact limité car il porte sur le stock, pas sur le CA. Le risque principal n’est donc pas le CAC (qui resterait du même ordre de grandeur) mais la capacité opérationnelle à signer et onboarder ce volume additionnel via le canal CUMA.


Analyse SWOT

Forces

  • Analyse en < 20 min
  • Export ISO-XML natif — passage direct de l’analyse à l’action
  • Module RSE différenciant, en ligne avec les exigences filières
  • Stack technique moderne : Next.js, Flutter, Fastify, IA scalable

Faiblesses

  • Dépendance à la couverture nuageuse pour les images satellite
  • Équipe réduite : risque de bottleneck R&D et commercial
  • Pas encore de revenus récurrents validés à grande échelle
  • Notoriété quasi-nulle face aux acteurs établis (Smag, Trimble)

Opportunités

  • Pression réglementaire RSE et PAC 2023–2027 → besoin de traçabilité
  • Standardisation ISO-XML accélérée par les constructeurs agricoles
  • Forte pression sur les intrants : marché prêt à payer pour +10 % d’économies
  • Segment CUMA : effet levier massif (1 contrat = n exploitations)

Menaces

  • Entrée de grands groupes (BASF, Bayer) avec budgets R&D élevés
  • Accessibilité croissante de Sentinel-2 (gratuit) : banalisation de la donnée
  • Résistance au changement : digitalisation lente dans les exploitations traditionnelles
  • Dépendance fournisseurs d’images satellite (pouvoir de négociation limité)

Annexe — Source de vérité & versions

Ce document est la seule version de référence du business plan (v2 — 5 juillet 2026). Toute présentation, tableur ou slide doit être régénéré à partir des chiffres de ce document.

Documents remplacés

DocumentStatutProblème
busines/business_plan_cerops_finance_*.xlsxObsolèteModélisé avec une tarification à 20–30 €/mois qui n’a jamais été celle du produit (CA an 3 : 418 800 € ; besoin : 35 939 €). À régénérer depuis ce document ou à archiver.
busines/business_plan.pdfObsolèteAssemblage cassé (pages dupliquées, numérotation mixte 11/14 pages) et P&L basé sur l’ancien xlsx alors que la slide pricing affiche 150/300 €.
busines/presentation_business.pdfÀ mettre à jourDeck de référence pour la forme, mais 3 slides à refaire (voir ci-dessous).

Slides du deck à régénérer

SlideAncien contenu (erroné)Nouveau contenu (ce document)
Objectifs SMART50 / 350 / 1 200 fermes · MRR 12 500 € · ARR 1,05 M€ / 3,6 M€ — ne correspondait à aucun P&L24 (50 inscrites) / 200 / 770 fermes payantes · MRR 4 650 / 39 000 / 150 000 € · CA 60,8 k / 528,2 k / 2 006,4 k€
Compte de résultatCA 11 600 / 115 400 / 418 800 € · résultats −22 939 / −14 667 / +86 873 € (prix 20–30 €)CA 60 800 / 528 200 / 2 006 400 € · résultats +26 261 / +378 133 / +1 634 473 € (prudent an 1 : ~32,9 k€ / ≈ −1,5 k€)
Plan de financementBesoin 35 939 € (lignes sommant à 38 273 € ; « surplus +3 192 € » alors que 55 000 − 35 939 = 19 061 €) · emprunt bancaire 13 939 €Besoin 22 000 € = CAPEX 5 000 € + trésorerie 17 000 € · apports fondateurs 20 000 € + CTO 2 000 € · sans emprunt
PricingInchangée (déjà 0 / 150 / 300 € / sur devis, alignée sur le produit)

Principes de cohérence

  1. Une seule grille tarifaire : celle du produit en production (Free / Starter 150 € / Pro 300 € / CUMA sur devis — cf. apps/web-agri landing et packages/api entitlements).
  2. Les objectifs SMART se déduisent du plan de ventes, jamais l’inverse.
  3. Toute promesse produit du deck (ISO-XML, RSE, < 20 min) doit refléter l’état d’avancement réel (cf. encart « État d’avancement »).

Diagrammes

Modèle Conceptuel de Données (MCD)

Généré depuis packages/db/prisma/schema/.

erDiagram
    User {
        string id PK
        string name
        string email
        enum accountType
        boolean onboardingCompleted
    }
    Plot {
        uuid id PK
        string name
        enum source
        float surface
        geometry geom
        string cultureType
        string userId FK
        boolean isActiveRpg
    }
    ActionPlan {
        string id PK
        string title
        string userId FK
    }
    Task {
        string id PK
        string title
        enum status
        int rank
        datetime deadline
        string actionPlanId FK
        string plotId FK
    }
    PilotProfile {
        string id PK
        string userId FK
        boolean available
        int score
    }
    Drone {
        string id PK
        string name
        string model
        string pilotProfileId FK
    }
    Sensor {
        string id PK
        string name
        string sensorKindCode FK
        string pilotProfileId FK
    }
    SensorKind {
        string code PK
        string name
        boolean active
    }
    Mission {
        string id PK
        string farmerId FK
        enum type
        enum status
        float totalSurface
        float price
        datetime windowStart
        datetime windowEnd
        string pilotProfileId FK
    }
    AnalysisOption {
        string id PK
        string code
        string name
        float basePrice
        float pricePerHa
    }
    Deliverable {
        string id PK
        string missionId FK
        string[] files
    }
    Payment {
        string id PK
        string missionId FK
        float amount
        enum status
    }
    Flight {
        string id PK
        datetime scheduledAt
        enum status
        string plotId FK
        string pilotId FK
    }

    User ||--o{ Plot : "possède"
    User ||--o{ ActionPlan : "crée"
    User ||--o| PilotProfile : "est"
    User ||--o{ Mission : "publie"
    ActionPlan ||--o{ Task : "contient"
    Task }o--o| Plot : "liée à"
    PilotProfile ||--o{ Drone : "possède"
    PilotProfile ||--o{ Sensor : "équipé de"
    Sensor }o--|| SensorKind : "de type"
    PilotProfile ||--o{ Mission : "accepte"
    Mission }o--o{ Plot : "couvre"
    Mission }o--o{ SensorKind : "requiert"
    Mission }o--o{ AnalysisOption : "inclut"
    Mission ||--o| Deliverable : "livre"
    Mission ||--o| Payment : "facturée"
    Plot ||--o{ Flight : "survol"

Architecture Logicielle

Monolithe modulaire · Monorepo Turborepo · 3 apps + 6 packages partagés.

graph TB
    subgraph Clients
        WA["web-agri\nNext.js · :3001\nAgriculteurs"]
        WP["web-pilots\nNext.js · :3002\nPilotes"]
        MOB["Mobile\nFlutter · iOS & Android"]
    end

    subgraph Server["Serveur Fastify · :3000"]
        ORPC["oRPC /rpc/*"]
        AUTH["Better-Auth /api/auth/*"]

        subgraph Routers["packages/api"]
            direction LR
            R1["plots"] --- R2["missions"]
            R2 --- R3["actions"]
            R3 --- R4["ai"]
            R4 --- R5["pilots"]
            R5 --- R6["weather"]
        end
    end

    subgraph Infra["Infrastructure"]
        PG[("PostgreSQL\n+ PostGIS")]
        OLLAMA["Ollama LLM\n:11434"]
        S3["S3 Storage"]
        STRIPE["Stripe"]
    end

    WA -->|"HTTPS + cookies"| ORPC
    WP -->|"HTTPS + cookies"| ORPC
    MOB -->|"HTTPS + Bearer"| ORPC
    ORPC --> Routers
    AUTH --> PG
    Routers --> PG
    R4 --> OLLAMA
    R2 --> S3
    R2 --> STRIPE

Diagramme de SéquenceCycle de vie d’une mission

sequenceDiagram
    actor Agri as Agriculteur
    participant API as API Fastify/oRPC
    participant DB as PostgreSQL
    participant S3 as S3 Storage
    actor Pilote as Pilote

    rect rgb(232, 245, 237)
        Note over Agri,DB: Création de mission
        Agri->>API: createMission(plotIds, type, window, analysisOptionIds)
        API->>DB: vérifier ownership des parcelles
        DB-->>API: plots OK
        API->>DB: lire pricingConfig (PUBLISHED)
        DB-->>API: config OK
        API->>API: prix = max(min, base + surface×€/ha + Σ options)
        API->>DB: INSERT mission (PUBLISHED)
        DB-->>API: OK
        API-->>Agri: mission { status: PUBLISHED, price }
    end

    rect rgb(220, 240, 228)
        Note over Pilote,DB: Acceptation
        Pilote->>API: acceptMission(missionId)
        API->>DB: vérifier capteurs compatibles
        DB-->>API: compatible
        API->>DB: COUNT missions SCHEDULED < 5
        DB-->>API: OK
        API->>DB: UPDATE status → SCHEDULED
        API-->>Pilote: mission { status: SCHEDULED }
    end

    rect rgb(208, 235, 218)
        Note over Pilote,S3: Livraison
        Pilote->>API: uploadDeliverable(missionId, files[])
        API->>S3: upload fichiers
        S3-->>API: URLs
        API->>DB: INSERT deliverable
        API->>DB: UPDATE status → DELIVERED
        API->>DB: pilotProfile.score + 1
        API-->>Pilote: deliverable OK
        API-->>Agri: notification DELIVERED
    end

Diagramme de Use Cases

graph LR
    AGRI(["Agriculteur"])
    PILOTE(["Pilote"])
    ADMIN(["Admin"])

    subgraph Parcelles
        UC1["Créer / modifier parcelle"]
        UC2["Revendiquer parcelles RPG"]
        UC3["Consulter la carte"]
    end

    subgraph Marketplace
        UC4["Créer une mission"]
        UC5["Consulter les missions"]
        UC6["Accepter une mission"]
        UC7["Livrer un livrable"]
    end

    subgraph Actions["Plans d'Action"]
        UC8["Gérer plans d'action"]
        UC9["Gérer les tâches"]
    end

    subgraph IA["Assistant IA"]
        UC10["Question agronomique"]
        UC11["Analyser une photo"]
    end

    subgraph Administration
        UC12["Import données RPG"]
        UC13["Configurer tarification"]
    end

    AGRI --> UC1
    AGRI --> UC2
    AGRI --> UC3
    AGRI --> UC4
    AGRI --> UC8
    AGRI --> UC9
    AGRI --> UC10
    AGRI --> UC11
    PILOTE --> UC5
    PILOTE --> UC6
    PILOTE --> UC7
    ADMIN --> UC12
    ADMIN --> UC13

Énumérations du modèle de données

EnumValeurs
AccountTypefarmer, buyer, cooperative, pilot, admin
PlotSourcemanual, rpg
FlightStatusplanned, in_progress, completed, cancelled
TaskStatustodo, in_progress, completed
MissionTypeSURVEILLANCE, MAPPING, INSPECTION, SPRAYING
MissionStatusPUBLISHED, SCHEDULED, IN_PROGRESS, DELIVERED, CANCELLED
PaymentStatusPRE_AUTHORIZED, CAPTURED, REFUNDED
MissionPricingConfigStatusDRAFT, PUBLISHED, ARCHIVED
RpgDatasetStatusDRAFT, COMPLETE, ACTIVE, ARCHIVED
RpgImportJobStatusPENDING, DOWNLOADING, EXTRACTING, PARSING, IMPORTING, COMPLETED, ACTIVATED, CANCELLED, FAILED

Points clés du modèle

  • Plot stocke la géométrie en MultiPolygon SRID 2154 (Lambert 93) via PostGIS. La surface est calculée automatiquement depuis la géométrie.
  • Mission regroupe des parcelles via la table de jonction MissionPlot et peut appartenir à un MissionGroup pour les missions groupées.
  • Score pilote : PilotProfile.score — +1 par livraison, -2 par annulation, plancher à -20.
  • Parcelles RPG : importées depuis le Registre Parcellaire Graphique. Une parcelle active (isActiveRpg=true) est visible par tous les agriculteurs jusqu’à ce qu’elle soit revendiquée.
  • Tarification mission : prix = max(minimumMissionPrice, basePrice + surface × pricePerHa + Σ analysisOptions)

ADR-001 - Architecture monorepo avec Turborepo et Bun workspaces

  • Statut : Accepté
  • Date : 2026-04-05

Contexte

Le projet Cerops comprend plusieurs applications (web-agri, web-pilots, server) et packages partagés (api, auth, db, env, config). Ces composants sont fortement couplés : un changement de schéma de base de données impacte simultanément le backend et les deux frontends. Il est donc nécessaire de choisir une stratégie d’organisation du code source qui minimise la friction entre équipes et garantit la cohérence des types partagés.

Décision

Nous organisons le projet sous forme de monorepo géré avec Turborepo et Bun workspaces.

Alternatives considérées

  • Polyrepo : un dépôt Git par application/package — versioning indépendant mais synchronisation complexe entre dépôts, gestion fastidieuse des versions des packages partagés
  • Monorepo sans outil de build : organisation simple mais pas de cache ni de pipeline de build optimisé
  • Nx : alternative à Turborepo, plus complet mais plus complexe à configurer pour une petite équipe

Conséquences

  • Partage de code facilité entre les packages (api, auth, db) et les applications
  • Refactoring atomique : un seul commit peut modifier frontend, backend et packages partagés
  • Cache de build et exécution parallèle des tâches via Turborepo
  • Configuration unifiée (TypeScript, Biome, tests) à la racine du dépôt
  • Catalog Bun workspaces pour centraliser les versions des dépendances et éviter les dérives
  • Dépôt potentiellement plus lourd à cloner pour un contributeur ne travaillant que sur une partie
  • Nécessite une bonne discipline de gestion des dépendances (outils sherif, knip en place)
  • En cas de croissance de l’équipe, les conflits de merge peuvent augmenter sur les fichiers partagés

ADR-002 - Architecture monolithe modulaire

  • Statut : Accepté
  • Date : 2026-04-05

Contexte

Le projet est développé sur une durée courte avec une équipe restreinte. Le périmètre fonctionnel est bien défini (gestion de parcelles, missions de drone, marketplace) et ne nécessite pas de distribution complexe des services. Le choix architectural doit permettre un développement rapide tout en maintenant une bonne séparation des responsabilités.

Décision

Nous choisissons une architecture monolithique modulaire : un seul serveur Fastify avec une organisation interne par domaines métier via les packages oRPC (packages/api).

Alternatives considérées

  • Microservices : indépendance de déploiement par service, mais complexité opérationnelle (service discovery, communication inter-services, monitoring distribué) inadaptée à la taille du projet
  • Architecture serverless : coûts maîtrisés à faible charge, mais cold starts et difficulté de débogage local
  • Monolithe non structuré : développement encore plus rapide initialement, mais dette technique rapide et difficultés de maintenabilité

Conséquences

  • Développement et débogage plus rapides (un seul process à lancer)
  • Déploiement simplifié (un seul artefact serveur)
  • Moins de complexité opérationnelle
  • Bonne séparation des responsabilités via les routers oRPC par domaine
  • Moins scalable horizontalement qu’une architecture microservices — acceptable pour le volume actuel
  • Un bug critique dans un module peut affecter l’ensemble du serveur
  • Migration vers des microservices rendue plus difficile si le besoin émerge, bien qu’atténuée par la modularité interne

ADR-003 - Bun comme runtime JavaScript

  • Statut : Accepté
  • Date : 2026-04-05

Contexte

Le projet nécessite un runtime JavaScript pour exécuter le serveur Fastify et les scripts du monorepo. L’équipe cherche à maximiser la rapidité de démarrage, la performance des scripts de build et la simplicité de la chaîne d’outils (un seul outil pour runtime, package manager et bundler).

Décision

Nous utilisons Bun comme runtime JavaScript, package manager et bundler pour l’ensemble du monorepo.

Alternatives considérées

  • Node.js + npm/pnpm : écosystème mature, large communauté, tooling bien documenté
  • Node.js + pnpm workspaces : meilleure gestion des monorepos que npm, mais performances inférieures à Bun
  • Deno : sécurité by default, support natif TypeScript, mais compatibilité limitée avec l’écosystème npm

Conséquences

  • Démarrage du serveur et exécution des scripts significativement plus rapides qu’avec Node.js
  • Compatibilité native TypeScript sans étape de transpilation séparée
  • Package manager intégré avec support des workspaces et catalog
  • Compilation du serveur en binaire natif possible via bun build --compile
  • Runtime encore jeune : certaines APIs Node.js ne sont pas encore totalement compatibles
  • Communauté et documentation moins matures que Node.js
  • Certains packages npm peuvent présenter des comportements inattendus sous Bun
  • L’équipe doit rester vigilante aux breaking changes entre versions de Bun

ADR-004 - Next.js pour les applications web (architecture multi-app)

  • Statut : Accepté
  • Date : 2026-04-05

Contexte

L’application web doit servir deux audiences aux besoins très différents :

  • Agriculteurs (web-agri) : consultation de parcelles, suivi d’actions, accès à la marketplace
  • Pilotes de drone (web-pilots) : gestion de missions, suivi de vol, interface opérationnelle

Les rôles, les parcours utilisateurs et les interfaces sont suffisamment distincts pour justifier une séparation applicative. L’équipe a de l’expérience en React et cherche une solution avec SSR et bon support TypeScript.

Décision

Nous utilisons Next.js avec TypeScript pour les deux frontends web, organisés en deux applications distinctes dans le monorepo : web-agri (port 3001) et web-pilots (port 3002), partageant les packages api, auth et env.

Alternatives considérées

  • Une seule application Next.js avec routing par rôle : moins de duplication de configuration, mais couplage plus fort entre les deux surfaces et risque de fuite de code entre rôles
  • Angular : framework complet avec opinions fortes, mais courbe d’apprentissage plus raide et moins adapté à l’écosystème oRPC/TanStack
  • React sans framework (Vite + React Router) : plus léger, mais sans SSR natif ni App Router
  • Vue.js / Nuxt : écosystème plus léger, mais moins maîtrisé par l’équipe

Conséquences

  • SSR natif via App Router pour de meilleures performances initiales et le SEO
  • Architecture dual-client oRPC : appels directs en Server Components (zéro overhead réseau), hooks TanStack Query en Client Components
  • Séparation claire des surfaces par audience, sans risque de fuite de logique ou d’UI entre rôles
  • Duplication de la configuration Next.js entre les deux apps (atténuée par packages/config)
  • Deux processus de build distincts à maintenir
  • Dépendance forte à l’écosystème Next.js / Vercel pour les évolutions du framework

ADR-005 - Fastify pour le serveur backend

  • Statut : Accepté
  • Date : 2026-04-05

Contexte

Le backend doit exposer une API performante, typée, avec un bon support TypeScript et Bun. L’équipe cherche un framework léger permettant une mise en place rapide sans imposer une structure trop rigide.

Décision

Nous utilisons Fastify comme framework HTTP pour le serveur backend.

Alternatives considérées

  • Express.js : très répandu et bien documenté, mais moins performant, peu opiniated sur TypeScript, pas de support natif des schémas de validation
  • NestJS : structure imposée, DI intégrée, adapté aux grandes équipes, mais sur-ingénierie pour ce projet et compatibilité Bun non garantie
  • Hono : très léger, excellente compatibilité Bun, mais écosystème de plugins moins riche que Fastify
  • Spring Boot : inadapté à l’écosystème TypeScript du projet

Conséquences

  • Performances élevées (l’un des frameworks Node.js/Bun les plus rapides)
  • Validation intégrée via JSON Schema (complémentaire à Zod via oRPC)
  • Système de plugins (@fastify/cors) pour étendre les fonctionnalités
  • Bonne compatibilité avec Bun
  • Moins de structure imposée que NestJS : la discipline d’organisation repose sur l’équipe
  • Certains plugins Fastify peuvent ne pas être totalement compatibles avec Bun
  • Communauté plus petite qu’Express

ADR-006 - oRPC pour la couche de communication API

  • Statut : Accepté
  • Date : 2026-04-05

Contexte

Le projet nécessite une communication typée de bout en bout entre les frontends Next.js et le serveur Fastify. L’application est interne, sans contrainte d’exposition publique de l’API à des tiers. L’équipe veut réduire le boilerplate lié à la définition, la validation et la consommation des endpoints.

Décision

Nous utilisons oRPC pour la communication front-back, avec génération automatique d’une documentation OpenAPI via @orpc/openapi.

Alternatives considérées

  • API REST classique : standard universel, facilement consommable par des tiers, mais nécessite de la duplication de types et de la validation des deux côtés
  • GraphQL : flexibilité de requêtage, mais complexité de setup (schema, resolvers, codegen) disproportionnée pour ce projet
  • tRPC : concurrent direct d’oRPC, bon écosystème, mais oRPC offre une meilleure intégration OpenAPI et un support natif Zod v4

Conséquences

  • Type safety de bout en bout : le contrat API est défini une seule fois dans packages/api et partagé entre server et clients
  • Réduction du boilerplate de validation (Zod schemas réutilisés côté serveur et client)
  • Documentation OpenAPI auto-générée exposée sur /docs
  • Architecture dual-client : appels directs en Server Components (sans HTTP), hooks TanStack Query en Client Components
  • Couplage plus fort entre frontend et backend qu’une API REST découplée
  • Moins standard qu’une API REST : intégration difficile pour des clients externes ou des outils tiers
  • Dépendance à un écosystème plus jeune qu’Express+REST ou GraphQL

ADR-007 - PostgreSQL comme base de données relationnelle

  • Statut : Accepté
  • Date : 2026-04-05

Contexte

L’application manipule des données fortement relationnelles :

  • utilisateurs, rôles (agriculteurs, pilotes)
  • parcelles agricoles et leurs métadonnées géographiques
  • missions de drone et plans de vol
  • actions et tâches sur les parcelles
  • transactions de la marketplace

Elle nécessite des transactions fiables et une modélisation stricte des relations entre entités.

Décision

Nous utilisons PostgreSQL comme base de données principale.

Alternatives considérées

  • MongoDB : flexibilité du schéma utile pour des données non structurées, mais inadapté aux relations complexes et aux transactions multi-documents
  • MySQL : bonne alternative relationnelle, mais fonctionnalités avancées (types JSON, window functions, extensions géospatiales) moins riches que PostgreSQL
  • SQLite : parfait pour le développement local, mais inadapté à la production multi-utilisateurs et à la concurrence d’écriture
  • PlanetScale / Turso : bases managées intéressantes, mais complexité supplémentaire et coût

Conséquences

  • Forte cohérence des données et support complet des transactions ACID
  • Excellente modélisation des relations entre entités métier
  • Support des types avancés (JSONB, tableaux, UUID) utiles pour les métadonnées de parcelles
  • Extension PostGIS disponible si des requêtes géospatiales avancées s’avèrent nécessaires
  • Nécessite une modélisation stricte du schéma en amont
  • Requiert une instance PostgreSQL dédiée (Docker en développement, service managé en production)
  • Montée en charge horizontale plus complexe que des bases NoSQL distribuées

ADR-008 - Prisma comme ORM

  • Statut : Accepté
  • Date : 2026-04-05

Contexte

Le projet nécessite un accès base de données simple, typé et rapide à mettre en œuvre. Le schéma est centralisé dans packages/db et partagé entre le serveur et les packages internes. L’équipe veut éviter d’écrire du SQL brut tout en maintenant un contrôle sur les migrations.

Décision

Nous utilisons Prisma comme ORM, avec l’adaptateur @prisma/adapter-pg pour la connexion PostgreSQL et la génération de types ESM compatible Bun.

Alternatives considérées

  • TypeORM : mature, décorateurs TypeScript, mais configuration complexe et compatibilité Bun incertaine
  • Drizzle ORM : très léger, performant, SQL-like, bonne compatibilité Bun — alternative sérieuse mais adoption moins répandue dans l’équipe
  • Kysely : query builder typé, contrôle total sur le SQL, mais plus verbeux et sans gestion de migrations intégrée
  • Requêtes SQL brutes : contrôle maximal, mais perte du typage automatique et maintenance plus lourde

Conséquences

  • Schéma lisible et déclaratif dans les fichiers .prisma
  • Types TypeScript auto-générés à partir du schéma (zéro dérive entre base et code)
  • Migrations versionnées via prisma migrate pour la production
  • Prisma Studio pour explorer et modifier les données en développement
  • Abstraction qui peut limiter certaines optimisations SQL avancées (requêtes complexes nécessitent $queryRaw)
  • Génération du client requise après chaque modification de schéma (bun db:generate)
  • Performances légèrement inférieures à un query builder bas niveau pour des requêtes massives
  • Le fichier de schéma multi-fichiers (schema.prisma + auth.prisma) nécessite Prisma >= 6

ADR-009 - Flutter pour l’application mobile

  • Statut : Accepté
  • Date : 2026-04-05

Contexte

L’application mobile est utilisée par les agriculteurs et les pilotes de drone en plein champ, souvent dans des zones à faible connectivité. Les besoins incluent : consultation de parcelles, suivi de missions, affichage de cartes, mode offline, et accès caméra pour la prise de photos. Le choix mobile est un point de divergence majeur avec d’autres projets de l’équipe qui utilisent une PWA.

Décision

Nous choisissons de développer l’application mobile en Flutter (Dart), ciblant iOS et Android à partir d’une base de code unique.

Alternatives considérées

  • Progressive Web App (PWA) : une seule base de code web+mobile, mais accès limité aux APIs natives (GPS précis, caméra avancée, stockage local robuste), performances inférieures sur mobile, et fonctionnement offline moins fiable en conditions terrain
  • React Native : partage de code possible avec le frontend web (React), mais performances et accès natif inférieurs à Flutter, et gestion du mode offline plus complexe
  • Application native iOS/Android séparée : performances et intégration native maximales, mais double base de code, double maintenance, double coût de développement
  • Capacitor (Ionic) : proche du PWA avec accès natif, mais performances UI insuffisantes pour des cartes et interactions terrain

Conséquences

  • Performances proches du natif grâce au moteur de rendu Skia/Impeller de Flutter
  • Accès natif à la caméra, au GPS, au stockage sécurisé et à la connectivité réseau
  • Mode offline robuste via Drift (SQLite embarqué) — voir ADR-010
  • Un seul codebase pour iOS et Android
  • Langage Dart distinct du reste du stack TypeScript : la base de code mobile est totalement séparée
  • Les développeurs web du projet ne peuvent pas contribuer directement à la partie mobile sans apprendre Dart/Flutter
  • Taille de l’APK/IPA plus importante qu’une app native pure
  • Déploiement via les stores (App Store, Google Play) : processus de validation et de mise à jour plus lent qu’une PWA

ADR-010 - Drift pour la persistance locale sur mobile

  • Statut : Accepté
  • Date : 2026-04-05

Contexte

L’application mobile est utilisée en plein champ, dans des zones sans couverture réseau fiable. Les agriculteurs et pilotes de drone doivent pouvoir consulter leurs parcelles et créer des actions hors ligne, puis synchroniser les données à la reconnexion. Un mécanisme de persistance locale est donc indispensable sur le mobile Flutter.

Décision

Nous utilisons Drift (anciennement Moor) comme ORM SQLite pour la persistance locale sur mobile.

Alternatives considérées

  • Hive : base de données clé-valeur NoSQL légère pour Flutter, rapide pour de la lecture simple, mais pas adaptée aux relations et aux requêtes complexes
  • Isar : base orientée objet pour Flutter, très performante, mais sans support des transactions relationnelles
  • sqflite : accès SQLite bas niveau, contrôle total, mais verbeux et sans typage automatique
  • SharedPreferences : uniquement pour des données simples de type clé-valeur, inadapté à un cache structuré

Conséquences

  • Schéma SQLite typé et déclaratif en Dart, cohérent avec l’approche Prisma côté serveur
  • Support des relations entre tables, des transactions et des requêtes complexes
  • Génération de code via drift_dev et build_runner pour les types et les requêtes
  • Stratégie de synchronisation par flag needsSync : les entités modifiées hors ligne sont marquées et synchronisées à la reconnexion
  • La synchronisation bidirectionnelle (conflits) doit être gérée manuellement — logique non triviale
  • Nécessite une étape de génération de code (build_runner) après chaque modification de schéma
  • Deux schémas à maintenir en cohérence : Prisma (serveur) et Drift (mobile) — risque de dérive si les entités évoluent

ADR-011 - Better-Auth pour l’authentification

  • Statut : Accepté
  • Date : 2026-04-05

Contexte

L’application nécessite une gestion de l’authentification pour les utilisateurs web (agriculteurs, pilotes) et mobiles (Flutter). Le système doit gérer les sessions, les rôles, et s’intégrer avec Stripe pour les abonnements. L’équipe cherche une solution clé-en-main, typée TypeScript, sans dépendre d’un service tiers payant.

Décision

Nous utilisons Better-Auth comme bibliothèque d’authentification, avec l’adaptateur Prisma (@better-auth/prisma-adapter) et le plugin Stripe (@better-auth/stripe).

Alternatives considérées

  • Auth.js (NextAuth) : très répandu dans l’écosystème Next.js, mais couplé au framework Next.js et difficile à partager avec un serveur Fastify indépendant
  • Clerk : solution SaaS complète avec UI intégrée, mais coût mensuel récurrent et dépendance à un service tiers externe
  • Lucia : bibliothèque minimaliste et flexible, bonne alternative, mais nécessite plus de code custom pour les fonctionnalités avancées
  • Auth custom : contrôle total, mais implémentation de la sécurité (hachage, sessions, CSRF) sujette aux erreurs

Conséquences

  • Authentification sessions-based via cookies httpOnly — pas de gestion de JWT côté client
  • Schéma de base de données généré et géré automatiquement via auth.prisma
  • Client Flutter via better_auth_client (package communautaire) — moins mature que le SDK web
  • Plugin Stripe intégré pour la gestion des abonnements sans code custom
  • Documentation OpenAPI auto-générée sur /api/auth/reference
  • Dépendance à une bibliothèque relativement jeune : risque de breaking changes lors des mises à jour majeures
  • Le package Flutter better_auth_client est un package communautaire non officiel — sa maintenance n’est pas garantie
  • Moins de composants UI préconstruits que Clerk : les formulaires de connexion sont à implémenter manuellement

ADR-012 - Stripe pour la gestion des paiements

  • Statut : Accepté
  • Date : 2026-04-05

Contexte

Cerops inclut une marketplace où des services et produits agricoles peuvent être échangés. La plateforme doit gérer des abonnements et/ou des transactions entre utilisateurs. Le traitement des paiements est un domaine sensible qui nécessite conformité PCI-DSS, gestion des remboursements et des litiges.

Décision

Nous utilisons Stripe comme solution de paiement, intégré via le plugin @better-auth/stripe côté serveur.

Alternatives considérées

  • PayPal : notoriété internationale, mais API moins développeur-friendly et intégration plus complexe
  • Mollie : bonne alternative européenne, APIs modernes, mais écosystème de plugins moins riche
  • Paiement custom : contrôle total, mais développement long, conformité PCI-DSS à gérer manuellement — risque de sécurité majeur
  • LemonSqueezy : solution SaaS tout-en-un pour les abonnements, mais moins flexible pour une marketplace B2B

Conséquences

  • Conformité PCI-DSS déléguée à Stripe : les données de carte ne transitent jamais par nos serveurs
  • Gestion des abonnements, des remboursements et des webhooks via l’API Stripe
  • Intégration simplifiée via @better-auth/stripe qui synchronise les abonnements avec les sessions utilisateurs
  • Stripe Elements / Stripe.js pour les formulaires de paiement côté client
  • Commission Stripe sur chaque transaction (1.4% + 0.25€ pour les cartes européennes) — à intégrer dans la politique tarifaire
  • Dépendance forte à un service tiers : une panne Stripe impacte directement les paiements de la plateforme
  • Les paiements sont en euros par défaut — la gestion multi-devises nécessiterait une configuration supplémentaire
  • Les webhooks Stripe nécessitent un endpoint public accessible — à prévoir dans l’infrastructure de déploiement

ADR-013 - Authentification M2M OAuth pour les services internes

  • Statut : Accepté
  • Date : 2026-06-04

Contexte

Cerops doit permettre à des services non interactifs, comme apps/sentinel, d’appeler l’API interne sans session utilisateur. Ces appels ne doivent pas réutiliser les cookies Better-Auth des applications web, ni dépendre d’un token statique partagé entre services.

Le besoin initial est l’accès de Sentinel aux procédures internes liées aux images satellite. Le mécanisme doit rester extensible à d’autres services internes avec des permissions distinctes.

Décision

Nous utilisons le grant OAuth 2 client_credentials fourni par @better-auth/oauth-provider. Chaque service machine est provisionné comme client OAuth confidentiel avec token_endpoint_auth_method = client_secret_basic.

Le serveur d’autorisation est exposé par Better-Auth sous /api/auth. Le endpoint de token utilisé par les machines est :

POST /api/auth/oauth2/token

Les clients demandent un token avec :

grant_type=client_credentials
resource=<CEROPS_API_AUDIENCE>
scope=<scopes demandes>

L’audience protégée par défaut est http://localhost:3000/rpc. En production, CEROPS_API_AUDIENCE doit correspondre à l’URL publique de l’API RPC.

Provisioning et rotation

Les clients machine sont créés par le script d’administration :

bun run oauth:provision:prod -- --name=sentinel-prod --scopes=satellite:read,satellite:write --admin-session-token=<admin_session_token>

Le script appelle auth.api.adminCreateOAuthClient(...) et stocke les scopes autorisés dans les métadonnées du client. Le client_secret est affiché une seule fois et doit être placé dans le gestionnaire de secrets de l’environnement cible.

La rotation d’urgence utilise :

bun run oauth:rotate:prod -- --client-id=<client_id> --admin-session-token=<admin_session_token>

Cette rotation invalide immédiatement l’ancien secret client. Les JWT déjà émis restent valides jusqu’à leur expiration. Pour une rotation sans interruption, il faut provisionner un second client, déployer ses identifiants, vérifier le renouvellement des JWT, puis désactiver l’ancien client.

Scopes de service

Les scopes M2M supportés sont centralisés dans packages/auth/src/service-scopes.ts :

  • satellite:read
  • processing:write
  • analysis:read
  • analysis:write

Un client ne peut demander que les scopes présents dans ses métadonnées allowedScopes. La fonction assertClientScopesAllowed(...) bloque une demande de token si le client demande un scope non autorisé.

Les procédures ORPC internes déclarent ensuite leurs scopes requis via createServiceProcedure(...). Par exemple, internal.auth.checkM2M exige satellite:read.

Validation côté API

Le contexte API extrait le header HTTP :

Authorization: Bearer <access_token>

Les procédures de service utilisent createServiceProcedure(requiredScopes). Ce middleware :

  • vérifie le JWT avec les clés JWKS exposées par Better-Auth sous /api/auth/jwks ;
  • impose l’issuer BETTER_AUTH_URL + /api/auth ;
  • impose l’audience CEROPS_API_AUDIENCE ;
  • extrait les scopes depuis la claim scope ;
  • rejette la requête en UNAUTHORIZED si le token est absent ou invalide ;
  • rejette la requête en FORBIDDEN si un scope requis manque ;
  • expose context.service avec clientId, subject et scopes.

Les procédures M2M ne dépendent pas de protectedProcedure et ne nécessitent donc pas de session utilisateur. Elles doivent passer par createServiceProcedure(...) pour éviter de mélanger authentification utilisateur et authentification machine.

Fonctionnement du client Sentinel

apps/sentinel utilise CeropsOAuthClient. Au démarrage, Sentinel attend quelques secondes puis appelle :

POST /rpc/internal/auth/checkM2M

Cette sonde échoue le processus si l’authentification M2M n’est pas opérationnelle.

Le client Sentinel :

  • demande un token à /api/auth/oauth2/token avec client_secret_basic ;
  • demande actuellement le scope satellite:read ;
  • met le JWT en cache mémoire ;
  • renouvelle le token avant expiration avec une marge de 60 secondes et un jitter de 15 secondes ;
  • déduplique les renouvellements concurrents ;
  • invalide le cache et retente une fois en cas de réponse 401 ;
  • retente les réponses 429 avec backoff exponentiel borné.

Les variables requises côté Sentinel sont :

  • CEROPS_API_URL
  • CEROPS_API_AUDIENCE
  • CEROPS_OAUTH_CLIENT_ID
  • CEROPS_OAUTH_CLIENT_SECRET

Métadonnées OAuth

Le serveur expose les métadonnées OAuth nécessaires à la découverte :

  • authorization server metadata : /.well-known/oauth-authorization-server/api/auth
  • protected resource metadata pour l’audience RPC
  • JWKS : /api/auth/jwks

Le chemin legacy Better-Auth /api/auth/token est désactivé. Les clients machine doivent utiliser /api/auth/oauth2/token.

Conséquences

  • Les secrets machine sont propres à chaque environnement et ne sont pas codés en dur.
  • Les permissions sont exprimées en scopes, ce qui permet de restreindre un service à son besoin réel.
  • La révocation d’un secret client stoppe les nouveaux tokens, mais pas les JWT déjà émis.
  • Les appels M2M sont indépendants des sessions utilisateur et des cookies httpOnly.
  • L’ajout d’un nouveau service interne nécessite un client OAuth dédié et des scopes explicites.
  • Les erreurs opérationnelles à surveiller sont les 401, 403 et 429 sur les routes internes.

Alternatives considérées

  • Token statique interne : simple à implémenter, mais rotation difficile, absence de scopes fins et risque élevé en cas de fuite.
  • Session utilisateur technique : réutilise l’auth web, mais mélange identité humaine et identité machine, et dépend des cookies.
  • mTLS uniquement : forte garantie d’identité transport, mais plus complexe à opérer et insuffisant pour exprimer des permissions applicatives.
  • Clés API par service : opérationnellement simple, mais moins standard qu’OAuth et moins intégré à Better-Auth/JWKS.