Routage d'étape (StepRouting)
En une phrase : la trace vivante du voyage — pour chaque étape d'un voyageur, le plan vers lequel ce voyageur a réellement été routé, d'abord itinéraire du jour, puis souvenir permanent.
Rôle métier
Le routage d'étape est la matérialisation du parcours individuel : le tour est un gabarit partagé, le routage est ce que ce voyageur vit, lui. La ligne naît de deux gestes possibles :
- la révélation — le voyageur affiche l'étape pour la première fois, « le système le route » vers le plan choisi par la politique par défaut (
ResolveStepPlanAction: l'alternative vivante de meilleure priorité) ; - le pré-routage super-admin — l'admin route des voyageurs (multi-sélection explicite, jamais de cohorte) vers une alternative avant la révélation : la ligne attend,
revealed_atNULL, et l'étape reste anonyme pour le voyageur.
« Révélée » n'est donc pas « la ligne existe » mais l'horodatage explicite revealed_at ; created_at n'est que la naissance de la ligne.
Elle est ensuite le support des déroutages :
- demande voyageur — le restaurant est complet, le voyageur demande un autre plan : la ligne est mise à jour instantanément, pas d'approbation (le voyageur a faim et voit la réalité mieux que nous) —
POST …/steps/{stepId}/next-plan, voir Demander un autre plan ; - re-routage super-admin — anticipation d'affluence : l'admin déplace des voyageurs vers d'autres alternatives via le même endpoint que le pré-routage (
PUT /api/tour/step/{stepId}/routings), qui met la ligne à jour en place sans toucherrevealed_at.
Le choix de l'alternative de repli (la plus proche, celle avec le plus de place connue…) est une politique produit, volontairement hors schéma : la ligne enregistre le plan choisi, jamais la règle qui l'a choisi. La politique vit dans une action unique (App\Actions\ResolveStepPlanAction), changeable sans migration.
Les instants d'une étape appartiennent au serveur
Le serveur calcule et impose les instants de chaque étape (App\Services\StepRevealSchedule, fuseau fixe Europe/Zurich) — le client ne dérive plus d'instants, il compare les instants reçus à son horloge. Une seule arithmétique de jour les produit tous :
revealable_at— quand l'étape devient révélable ;next_plan_requestable_at— quand un autre plan peut être demandé (l'instant de rendez-vous de l'étape) ;next_plan_requestable_until— quand cette possibilité se referme (le rendez-vous de l'étape suivante, plafonné par la fin du voyage ; c'est donc lestartsAt()d'une autre étape). Voir Demander un autre plan.
Règles communes :
- le jour 1 est la date calendaire locale du
used_atdu code ; - le jour de l'étape est départ + (
day_number− 1) ; le jour de révélation retranche en plusreveal_days_before, le rendez-vous jamais — voir le déjeuner de demain aujourd'hui ne déplace pas le déjeuner ; - l'heure lue est
reveal_timepour la révélation,start_timepour le rendez-vous, minuit si NULL dans les deux cas : une étape sans heure de rendez-vous est ouverte toute sa journée ; day_numberabsent → jour 1 : l'éditeur d'itinéraire traite le jour comme optionnel et les tours d'une seule journée ne le posent jamais — lereveal_timed'un créneau s'applique alors au jour du départ lui-même. C'est le calendrier qui est défaulté, jamais la donnée : leday_numberde l'étape reste NULL sur le fil ;- un instant antérieur au départ n'est pas rabattu : l'étape est révélable immédiatement (c'est le sens de
reveal_days_beforesur un jour 1) ; - jamais d'auto-révélation : l'éligibilité est imposée (422 sur un appel prématuré), la révélation n'a lieu que sur l'appel explicite du voyageur — plusieurs étapes peuvent être simultanément révélables.
Les endpoints
| Endpoint | Rôle |
|---|---|
GET /api/activation-codes/{id}/steps | La timeline du voyageur (« Mon Roadtrip ») : chaque étape du tour du voyage, en ordre de position — silhouette anonyme (id, position, revealable_at) tant que non révélée, identité (name, start_time, day_number, revealed_at) ensuite. TravellerStepResource, distincte de StepResource qui embarque les alternatives. Volontairement sans contenu d'activité : la liste reste légère, le détail se charge à la demande. |
GET /api/activation-codes/{id}/steps/{stepId} | L'écran de détail derrière une carte révélée : la même étape, plus le contenu du plan réellement suivi (TravellerStepDetailResource + TravellerActivityResource). Sûre et idempotente — ne révèle jamais rien : recharger l'URL ne crée ni ne touche aucune ligne de routage, et une étape non révélée revient en 200 avec exactement la silhouette anonyme de la timeline. |
POST /api/activation-codes/{id}/steps/{stepId}/reveal | Enregistre le premier affichage et renvoie le payload de détail (contenu compris — l'écran derrière le compte à rebours n'a pas besoin d'un second appel). Upsert unique et idempotent sur UNIQUE (activation_code_id, step_id) : à l'insertion, plan de la politique par défaut ; en conflit, le plan existant (pré-routage admin) est conservé — sauf s'il a été retiré, auquel cas la politique re-résout. |
POST /api/activation-codes/{id}/steps/{stepId}/next-plan | Le voyageur demande à être routé ailleurs — le restaurant est complet, le bateau est plein. Déplace la ligne vers l'alternative vivante suivante et renvoie le payload de détail du nouveau plan. Irréversible et non idempotent : chaque appel avance d'un rang. Ouvert entre l'instant de rendez-vous de l'étape et le démarrage de l'étape suivante (422 de part et d'autre). Voir Demander un autre plan. |
PUT /api/tour/step/{stepId}/routings | Super-admin : route une multi-sélection de codes vers une alternative vivante de l'étape — pré-routage (ligne née non révélée) comme re-routage (plan déplacé en place, revealed_at intouché). Tout-ou-rien sur l'éligibilité des codes (codes en voyage du tour de l'étape). |
Les quatre endpoints voyageur exigent le contexte traveler et la propriété du code (le code d'un autre est un 404). Cette garde est partagée (ResolvesTravellerJourney) : les quatre portes résolvent le code à l'identique. Sur le quand, en revanche, elle est volontairement asymétrique — voir ci-dessous.
Le voyage terminé reste lisible
| Endpoint | Voyage éligible |
|---|---|
GET …/steps, GET …/steps/{stepId} (lectures) | pre-active, active, et tout code dont le used_at est passé — voyage en cours ou terminé |
POST …/steps/{stepId}/reveal, POST …/steps/{stepId}/next-plan (écritures) | pre-active / active uniquement |
Les lectures s'ouvrent quand la fenêtre d'activation s'ouvre et ne se referment plus jamais. C'est la conséquence directe de la mémoire du voyage : la ligne de routage est immortelle, refuser un voyage terminé n'aurait protégé aucune surprise — cela aurait effacé le souvenir, et l'onglet « Passés » de l'espace voyageur n'aurait rien eu à montrer. Le pré-actif passe toujours exprès : reveal_days_before peut rendre une étape révélable avant le départ.
Les écritures se ferment avec le voyage, pour une seule et même raison : elles écrivent un routage maintenant, pour un jour déjà passé — la ligne prétendrait que le voyageur a été envoyé quelque part où il n'est jamais allé. La révélation exécuterait ResolveStepPlanAction sur un jour révolu ; la demande d'un autre plan réécrirait le souvenir d'un repas déjà pris. Une étape non révélée pendant le voyage reste donc scellée à jamais — la surprise ne se rejoue pas. Le détail (GET …/steps/{stepId}) la sert indéfiniment comme la silhouette anonyme qu'elle est restée.
Le prédicat de lecture est used_at au passé, jamais status === 'used', et ce choix porte deux garde-fous :
- un statut posé après coup —
cancelled,reimbursed, une demande de remboursement ouverte — gagne par priorité de cascade et masqueused; un geste administratif ne doit pas dé-vivre un voyage qui a eu lieu ; durationest nullable (les imports hérités n'en portent pas :ImportActivationCodesAction) et la brancheactivede la vue l'exige : un tel code passe directement depre-activeàusedet n'est doncactiveaucune seconde du voyage qu'il est pourtant en train de vivre. Adossée au statut, la garde enfermait ces voyageurs hors de leur propre roadtrip pendant toute sa durée.
Reste ouvert : la même nullité de
durationprive encore ces codes de la révélation (fermée aux seulspre-active/active). Ils voient leur timeline mais ne peuvent rien y révéler. Le correctif est à trancher côté donnée — rendredurationobligatoire à l'activation, ou lui donner une valeur par défaut — plutôt que d'élargir la garde d'écriture.
Demander un autre plan
Depuis l'écran de détail d'une étape révélée — jamais depuis la timeline, qui reste sans contenu — le voyageur peut demander à être envoyé ailleurs. Le geste est instantané (aucune approbation, aucun admin dans la boucle) et irréversible : la modale de confirmation est la responsabilité du client.
Le curseur, pas un « plan B » codé en dur
ResolveStepPlanAction::nextAfter() répond « l'alternative vivante suivante après celle que ce voyageur détient » :
SELECT id FROM activity_step
WHERE step_id = ? AND retired_at IS NULL AND priority > ?
ORDER BY priority LIMIT 1La priorité comparée est lue sur la ligne vivante, jamais sur un instantané stocké dans le routage : si un admin re-classe les alternatives entre deux demandes, le curseur bouge avec elles. C'est aussi toute la préparation du pré-routage super-admin à venir (« ces 5 voyageurs partent sur A, les 7 suivants sur B ») : un voyageur pré-routé en C a D pour suite, sans aucun cas particulier à écrire. La politique reste hors schéma, dans la même action unique que le choix initial — changeable sans migration.
Repli sur un plan retiré : une alternative retirée n'a plus de rang (le CHECK impose retiré ⇔ priority NULL), donc « après elle » n'a pas de sens. La politique par défaut re-résout alors depuis le haut — la meilleure alternative vivante — exactement comme le fait déjà la révélation face à un pré-routage retiré.
Une fenêtre, pas une porte : du rendez-vous à l'étape suivante
Le droit de demander est borné des deux côtés — « si l'étape 1 démarre à 8 h et l'étape 2 à 9 h, on ne peut demander un autre plan pour l'étape 1 qu'entre 8 h et 9 h » :
| Borne | Valeur |
|---|---|
| Ouverture (incluse) | L'instant de rendez-vous de l'étape (StepRevealSchedule::startsAt()) |
| Fermeture (exclue) | Le rendez-vous de l'étape à la position suivante, plafonné par la fin du voyage (used_at + duration, en heures) — le premier des deux |
La fenêtre est semi-ouverte : à l'instant de démarrage de l'étape suivante, celle d'avant est déjà close. C'est le sens exact d'« entre 8 et 9 », et c'est ce qui empêche deux étapes consécutives d'accepter une demande au même instant. Les deux bornes voyagent ensemble dans un même objet valeur (NextStepPlanWindow), jamais dérivées séparément.
L'ouverture est au rendez-vous, pas à la révélation : un plan B répond à une porte fermée, et la porte ne l'est qu'une fois qu'on est devant elle. reveal_days_before peut faire voir le déjeuner de demain aujourd'hui — il ne déplace pas le déjeuner.
Conséquence assumée : téléphoner à 11 h pour un déjeuner de midi est refusé jusqu'à midi. Arbitré ainsi en connaissance de cause ; l'assouplir (une fenêtre d'anticipation) ne coûterait qu'un changement dans
App\Services\NextStepPlan, sans migration ni changement de contrat.
Le plafond du voyage ne refuse jamais rien lui-même. Un voyage passé sa fin n'est plus active : la garde de journey de l'endpoint répond 403 avant que la fenêtre ne soit consultée. Le plafond sert donc à deux choses — borner ce qu'on annonce au client, et rendre une fenêtre vide quand le rendez-vous d'une étape tombe après la fin du voyage.
Conséquence connue : la fenêtre vide
La borne haute suit la position suivante, jamais « l'étape suivante dans le temps ». Or les positions ne sont pas contraintes de suivre l'ordre des jours, et une étape sans start_time démarre à minuit : une étape de position 2 peut donc se programmer avant celle de position 1. La fenêtre se ferme alors avant de s'ouvrir — elle est vide, et cette étape n'offre jamais d'autre plan.
C'est délibéré et non corrigé par un repli : l'ordre de l'itinéraire est celui de ses positions, et deviner un successeur chronologique dirait autre chose que l'écran que le voyageur a sous les yeux. Le choix a été fait en connaissance de cause, en sachant qu'il a la même forme que le bug du day_number NULL qui avait jadis verrouillé des tours entiers.
En revanche il est rendu observable, jamais silencieux : les deux instants sont servis tels quels, si bien qu'une fenêtre vide se lit directement sur le fil (next_plan_requestable_until ≤ next_plan_requestable_at) au lieu de se manifester par un bouton mystérieusement absent. Deux tests l'épinglent — un successeur programmé avant son prédécesseur, et un rendez-vous au-delà de la fin du voyage.
À surveiller : si des tours réels commencent à présenter des fenêtres vides, la correction est côté données (ré-ordonner les positions pour suivre les jours, ou poser les
start_timemanquants), pas côté règle.
Sur le fil : deux instants, jamais un rang
Le voyageur n'apprend jamais son rang — aucune lettre de plan ne circule, et l'id de l'alternative n'est jamais accepté en entrée : le serveur dérive « la suivante » lui-même. Le contrat se réduit à un champ, sur la ressource de détail uniquement (TravellerStepDetailResource) :
| Champ | Sens |
|---|---|
next_plan_requestable_at | Instant à partir duquel ce voyageur peut demander un autre plan (ISO-8601 avec décalage, borne incluse). NULL = pas de bouton. Présent seulement une fois l'étape révélée. |
next_plan_requestable_until | Instant où cette possibilité se referme (borne exclue) : démarrage de l'étape suivante, plafonné par la fin du voyage. NULL quand rien ne la borne. Même présence conditionnelle. |
Des instants nullables plutôt qu'un booléen doublé de dates : c'est la forme déjà retenue pour revealable_at — le serveur possède les instants, le client les compare à son horloge. Trois raisons se confondent volontairement dans le NULL, car le voyageur n'a pas à savoir laquelle s'applique : le voyage n'est pas en cours, l'étape n'est pas révélée, ou il ne reste plus d'alternative vivante après celle qu'il détient.
Une fenêtre simplement refermée n'est en revanche pas NULL : les deux instants continuent d'être servis, pour que le client distingue « trop tard » de « jamais » sans re-requêter. Les trois contrôleurs qui servent le détail (lecture, révélation, demande) estampillent la paire d'un seul geste (NextStepPlan::stampOn()), si bien qu'ils ne peuvent pas diverger et qu'une étape fraîchement révélée comme une étape fraîchement déroutée savent déjà s'il reste un plan derrière, et jusqu'à quand.
L'écriture
Un seul UPDATE sous verrou (lockForUpdate), dans une transaction qui porte aussi l'entrée d'audit : activity_step_id bouge, rien d'autre. La ligne garde son identité, revealed_at est intact (demander un autre plan ne re-révèle rien) et aucune seconde ligne ne naît — l'historique vit dans le journal d'audit, sous l'événement plan_requested, distinct de revealed / re_routed / routed pour que l'UI de routage super-admin à venir sache qui a déplacé qui.
Chaque garde est re-vérifiée sous le verrou (ligne existante, étape révélée, fenêtre ouverte, fenêtre pas encore refermée, alternative suivante disponible), chacune avec son propre 422 : NextStepPlan répond pour l'écran, l'action ne lui fait pas confiance.
L'action est délibérément non idempotente — « suivant » est par nature une avance, un double envoi avance deux fois. C'est la modale plus une garde pending côté client qui l'empêchent, jamais un jeton compare-and-swap : un tel jeton exigerait de mettre l'id de l'alternative sur le fil, ce qu'on refuse précisément.
Le contenu se lit par le routage, jamais par le catalogue
Le détail résout l'activité à travers la ligne de routage (step_routings.activity_step_id → activity_step → activities), et n'applique aucun visibleTo() ni aucun filtre « vivant » :
- un plan retiré depuis la révélation résout toujours, et son contenu est servi en entier ;
plan_retired: truele signale de façon purement informative — aucun traitement visuel n'est prescrit côté voyageur (le grisé est une convention du cockpit super-admin, pas de l'écran du voyageur : il y est allé, le retrait est une édition de catalogue postérieure qui ne doit rien changer à son souvenir) ; - une activité archivée reste visible pour le voyageur qui y est allé.
La règle : le voyage fait autorité sur le souvenir, pas l'état courant du catalogue. Une édition du catalogue ne doit jamais effacer l'écran d'un voyageur — précédent identique sur GET /tour/{id}/things-to-bring.
TravellerActivityResource est délibérément distincte d'ActivityResource : ni notes internes, ni statut de cycle de vie, ni capacité, ni artefacts de recherche, ni les canaux de réservation du prestataire (booking_email / booking_link — réserver en direct autour du produit est précisément ce qu'on n'expose pas). Le contenu servi : name, description, duration_minutes, physical_difficulty, la fourchette de prix, l'image (variantes signées + crédit Pexels, null — le cas courant — quand l'activité n'en a pas), les tags et le lieu (provider : adresse, coordonnées, téléphone, site).
Mémoire du voyage
Une fois écrite, la ligne est immortelle tant que le code existe : c'est elle qui répond à « c'était quoi, ce restaurant du jour 2 ? », même des années plus tard — et depuis que les lectures survivent au voyage (voir Le voyage terminé reste lisible), cette promesse est réellement servie par l'API, même si l'établissement a fermé (l'alternative retirée reste référencée et pleinement servie ; c'est le cockpit super-admin qui l'affiche grisée dans l'éditeur d'étape, l'écran du voyageur la présente comme n'importe quel autre souvenir). C'est aussi la couture prévue pour les évaluations à venir : une note par étape et par voyage s'accrochera à cette ligne — l'identité exacte « ce voyageur, cette étape, l'endroit où il est réellement allé ».
Cycle de vie
- Naissance à la révélation ou au pré-routage — écriture paresseuse au premier affichage, idempotente sous la contrainte d'unicité, ou écriture anticipée par l'admin. Les étapes ni révélées ni pré-routées n'ont pas de ligne : le gabarit reste librement modifiable pour tout ce qui n'a pas encore été vu.
- Mise à jour en place — les déroutages réécrivent la ligne ; l'historique des changements est porté par le journal d'audit, pas par des lignes multiples.
- Jamais supprimée — la ligne meurt avec son code, et avec rien d'autre.
Relations métier
| Relation | Sens métier |
|---|---|
| → Code d'activation | Le voyage concerné ; la trace fait partie de l'historique commercial et meurt avec le code (CASCADE) |
| → Étape | Le créneau du voyage ; on ne supprime pas une étape qui porte de l'histoire (RESTRICT) |
| → Alternative | Le plan réellement suivi ; on ne supprime pas une alternative référencée (RESTRICT) — on la retire |
| ← Journal d'audit | Chaque révélation, chaque demande de plan du voyageur et chaque (pré-)routage admin tracés, avec leur auteur — par des entrées explicites : les écritures sont des upserts ensemblistes qui contournent les événements Eloquent, le modèle n'est volontairement pas Auditable (voir journal d'audit) |
Règles métier
- Une ligne par étape et par voyage :
UNIQUE (activation_code_id, step_id)— c'est aussi la cible d'écriture idempotente de la révélation (upsert), et ce qui garantira « une évaluation par étape ». - Le plan appartient à l'étape : clé composite vers l'alternative (voir base de données) — impossible de router un voyageur vers le plan d'une autre étape.
- L'accès du voyageur passe par son code, pas par le catalogue : la visibilité
Tour::visibleTo()gouverne la navigation et le choix du tour à l'activation ; un toursuspendeden cours de voyage reste pleinement accessible à ses voyageurs en route. L'itinéraire complet (GET /tour/step, étapes embarquées deGET /tour/{id}) est réservé au super-admin — pour le voyageur, l'étape non révélée est anonyme sur le fil :name,start_timeetday_numbersont retenus jusqu'àrevealed_at. - La date à laquelle un voyageur atteint une étape est dérivée, jamais stockée :
used_atdu code +day_numberde l'étape. Les projections d'affluence (voyageurs routés par activité et par jour, contre la capacité indicative) sont des agrégations SQL, sans état supplémentaire. - Le cas « tous les plans sont complets » n'est pas traité à ce jour : une étape sans plan vivant répond 422 à la révélation, et un voyageur arrivé sur la dernière alternative n'a plus de bouton (
next_plan_requestable_atNULL) plutôt qu'une porte de sortie. La sortie prévue reste d'ajouter une alternative à l'étape puis de router vers elle — jamais d'assouplir la clé composite.

