Skip to content
IlanAMG edited this page Oct 14, 2026 · 24 revisions

API

Statut : 🟡 Brouillon · Màj : 2026-10-14 · Tickets : #164 #306 #378 #383 #393 #397

Contrat du MVP. Préfixe /v1. Authentification par jeton Firebase quand indiqué. Les schémas Zod dans packages/shared font foi ; cette page en est le résumé lisible.

Conventions

  • Pagination par curseur : ?cursor=&limit= (max 50), réponse { items, nextCursor }.
  • Listes dans la query : clé répétée, avec ou sans crochets (moods[]=food&moods[]=culture ou moods=food&moods=culture) ; les deux notations se mélangent et les doublons sont retirés. Toute autre notation (moods[0]=) est une clé inconnue. Les queries sont strictes : une clé inconnue (mood=, moods[0]=) ou une valeur hors taxonomie (budgets[]=premium) répond 400 validation_error, avec details.issues ; jamais un résultat non filtré (#25, D-066). Règle du code HTTP de validation_error (D-071, #221) : 400 pour une query invalide (GET), 422 pour un corps invalide (POST, PATCH, PUT).
  • Erreurs : { code, message, details? }, codes HTTP standards. Codes métier stables : validation_error, unauthorized, forbidden, not_found, conflict, rate_limited, coherence_blocked, link_expired (en 410 : lien de confirmation expiré, déjà utilisé ou annulé, D-062), premium_required (en 402, avec details.reason pour alimenter E-17 : premium_route ou create_limit, et details.limit pour la création ; D-023) ; brouillon #393, à valider : route_removed (en 410 : parcours retiré, avec details.removedAt et details.near, voir Fiche parcours).

🟡 Brouillon #383 du 2026-10-14, à valider par Ilan, voir Compte-et-Monetisation › Fin de la limite de création : proposé, premium_required ne garde que premium_route (create_limit et details.limit de la création retirés) ; rate_limited porte details.reason (pending_routes) et details.limit pour la limite de relecture (D-073).

  • Dates ISO 8601 UTC ; coordonnées { lat, lng }.
  • Toutes les lectures publiques sont cachables (Cache-Control court, ETag), pour l'appelant anonyme.
  • Authentification facultative (D-075) : une route publique dont la réponse dépend du droit de l'appelant (aujourd'hui /routes/search) accepte un jeton Firebase sans l'exiger. Sans en-tête Authorization, réponse anonyme, Cache-Control: public. Avec un en-tête Authorization, réponse Cache-Control: private et Vary: Authorization, jamais servie à un autre appelant. Un jeton présent mais invalide ou expiré répond 401 unauthorized, comme sur une route authentifiée : l'API ne retombe pas en silence sur la réponse anonyme, pour que l'app rafraîchisse son jeton et réessaie.

Découverte (public)

Méthode Route Description
GET /routes/search Parcours dans une zone avec filtres. Query : bbox=w,s,e,n, audiences[], moods[], conditions[], durations[], budgets[], transports[] (valeurs de la taxonomie), `sort=recommended
GET /users/:id/profile Profil public (D-023) : avatar, prénom, membre depuis, quartier, bio, certified et certification (critères avec valeur, seuil et état, pour la sheet d'explication), chiffres routesPublished, routesCompleted (sorties sur ses parcours, D-091), ratingAvg (chacun null s'il n'existe pas), verifiedPlaces (lieux distincts confirmés sur place ou après la sortie, null à zéro, D-056), anecdotes dans l'ordre (questionKey, libellé, réponse), parcours à la une dans l'ordre (cards), parcours publiés (cards ; query `sort=recent
GET /routes/:id/ratings Avis visibles, paginés
GET /routes/search/count Même query, réponse { count } pour le compteur des filtres. breakdown=all_but_one ajoute without : le nombre de parcours obtenu en retirant chaque filtre actif pris isolément, groupe par groupe et jamais valeur par valeur, pour les suggestions du zéro résultat de E-03 (D-010)
GET /routes/:id Fiche complète (étapes, lieux, horaires du jour, computed, vérification, lieux signalés ; pour l'utilisateur connecté, myReport : date et statut de son signalement en cours, D-057). ?token= pour un parcours privé. Trajets à pied, à vélo et en voiture : distances, durées et tracés exacts demandés à Mapbox Directions pour cette réponse, jamais enregistrés ; nos estimations si Mapbox ne répond pas (estimated, sans tracé, D-088). Parcours access = premium pour un appelant non abonné : étape 1 complète, étapes suivantes réduites à heure, catégorie, durée et trajet, sans lieu, coordonnées ni conseil, et pas de tracé (D-014). Parcours retiré : 410 avec la date du retrait. Contrat détaillé, en brouillon : Fiche parcours
GET /routes/:id/timeline Query startsAt= : timeline recalculée et conflits, sur les trajets exacts de Mapbox quand il répond, nos estimations sinon (D-088). Le brouillon #393 propose nos estimations seules (Q6) : Fiche parcours
GET /geocode Query q=, proxy Mapbox (Geocoding et Search Box pour les stations), interrogé dans l'emprise de l'Île-de-France ; une zone n'est gardée que si chaque mot tapé commence un mot de son nom. Sans cache (résultats temporaires de Mapbox, qu'il est interdit de conserver) : Cache-Control: no-store. Limité à 30 requêtes par minute et par IP. Le géocodage permanent, qui permettrait un cache de 24 h, est écarté pour l'instant, question ouverte (D-081, voir Recherche textuelle)
GET /weather/today Query city=paris : `{ condition: 'rainy'
GET /config Taxonomies, libellés, seuils publics (dont ceux de la certification), questions d'anecdote actives (clé, libellé, ordre), versions de l'app par plateforme : minVersion (sous elle, E-20 bloque) et recommendedVersion (sous elle, E-20 propose la mise à jour, D-063) ; versions en vigueur des textes légaux (legal.terms, legal.privacy)
GET /legal/:doc doc = terms ou privacy, `?locale=fr
POST /support/messages Formulaire « Nous écrire » (E-19, D-063), avec ou sans jeton : `{ topic: 'question'

Card de recherche

Card de /routes/search (RouteCard dans packages/shared), telle que livrée par #25, #161, #63 et #170 (D-066), révisée pour les parcours Premium par D-075 :

  • id, title, coverUrl (photo de couverture du parcours), isOfficial, author (id, firstName, avatarUrl ; null pour un parcours « Par Widoo », et quand l'auteur est supprimé ou son profil non public, D-025), access (free, premium), isVerified, moods (la première est l'ambiance principale), audiences, district et neighborhood du départ (« 3e », « Le Marais »), durationMin et durationBucket, distanceM, rating (average, null avant le premier avis, et count).
  • budgetPerPersonEur : somme exacte des coûts des étapes par personne, en euros, un seul nombre ; plus de fourchette (D-032, #170). L'app l'arrondit à l'affichage (« ≈ 25 € par pers. », « Gratuit » à zéro). budgetBucket est dérivé de la même somme exacte, jamais de l'arrondi.
  • isLocked : true quand la card d'un parcours access = premium est verrouillée pour l'appelant, false sinon (toujours false pour un parcours gratuit). L'app lit ce champ, jamais access seul, pour choisir l'affichage : une card Premium déverrouillée s'affiche comme un parcours gratuit, avec sa pastille Premium (D-075).
  • stepCount : nombre d'étapes du parcours, exact, que steps les porte toutes ou non.
  • steps[], dans l'ordre, pour les pastilles et la tooltip de la carte (E-04) : category (valeur de places.category), location ({ lat, lng }), name (nom du lieu) et durationMin (durée sur place, pour « Étape i/n · catégorie · durée »). Parcours gratuit : toutes les étapes. Parcours access = premium : la règle d'E-04 (« Abonné, le tap validé ne change pas ») se tient dans l'API (D-014, D-075).
    • Card verrouillée (isLocked = true), sans jeton ou pour un compte qui n'a pas droit au parcours : le départ seul, avec sa catégorie et ses coordonnées, name et durationMin à null ; les étapes 2 et suivantes ne quittent pas l'API, ni nom, ni lieu, ni coordonnées, ni photo. La tooltip Premium lit stepCount et durationMin du parcours (« 4 étapes en 7 h ») ; la carte se recadre sans tracé ni étapes.
    • Card déverrouillée (isLocked = false), avec le jeton valide d'un compte qui a droit au parcours : toutes les étapes, comme un parcours gratuit ; la carte affiche tracé, étapes et tooltips.
    • Droit au parcours : aujourd'hui, un compte dont users.plan vaut premium et dont users.plan_expires_at n'est pas passé. Le pass 7 jours (même colonnes) et le déblocage à l'unité (route_unlocks) entrent dans la même règle par #66, calculée en un seul endroit du code et partagée avec /routes/:id (#36).
    • Écarté : réduire la card pour tout le monde au nom du cache public (choix de #161, refusé le 2026-09-29) ; faire attendre l'abonné jusqu'à l'ouverture de la fiche.
  • Photo de la ligne d'étape de la tooltip : la maquette montre la photo du lieu de l'étape (E-04), que la card ne porte pas ; l'app affiche aujourd'hui coverUrl. Décision (D-066) : la card gagne steps[].photoUrl (photo principale du lieu en vignette, null sans photo ; présente seulement sur les étapes que la card porte, donc absente des étapes 2 et suivantes d'une card verrouillée, D-075), la tooltip retombant sur coverUrl quand elle est nulle ; nécessaire à la tooltip d'une étape touchée (#153), dont le lieu n'est pas celui de la couverture. Confirmé par Ilan le 2026-09-28 (D-071) ; photo principale d'un lieu : voir Modele-de-Donnees › media ; livré par #219.
  • reason : raison principale du classement, avec sort=recommended seulement (absente avec les autres tris) : { key: 'near_you', distanceM }, ou { key } parmi top_rated, verified, fresh, good_timing, official ; null quand aucune composante ne se dégage, et l'app n'affiche alors rien. Clés techniques ; les libellés sont dans l'app, qui n'affiche que good_timing et fresh (D-071). Règles : Filtres-et-Recherche › Recommandation (#63).

Fiche parcours (brouillon #393)

Statut : 🟡 Brouillon du 2026-10-14, à valider par Ilan, en réponse à #393. Rien ici n'est encore décidé : chaque choix ouvert est une question numérotée (Questions à trancher), avec la recommandation et ce qu'elle écarte. Après validation : entrée D-xxx au Design-Changelog, report par MANAGER dans #36.

Contrat de GET /routes/:id (la fiche, Ecrans › E-05) et de GET /routes/:id/timeline. Point de départ : le schéma RouteDetail de packages/shared/src/schemas/route-detail.ts, fixé par la PR #390 (#37) ; cette section en est le résumé lisible et liste ce que #36 doit y ajouter. L'endpoint tient ce schéma partagé, sans seconde définition.

Qui obtient quoi

Authentification facultative, comme /routes/search (voir Conventions, D-075) : la réponse dépend de l'appelant (droit Premium, myReport). Un jeton Firebase invalide ou expiré répond 401 unauthorized. Le droit au parcours entier se décide par canAccessRoute(route, viewer, now) (apps/api/src/access/route-access.ts), la règle unique déjà lue par la recherche et la programmation, sans seconde implémentation.

Statut du parcours Sans jeton de lien Avec ?token= valide (routes.share_token) Auteur connecté
published, needs_fix 200 200, jeton ignoré 200
private 404 not_found 200, avec sharedBy 200
pending, changes_requested, jamais publié 404 200, avec sharedBy (Q4) 200
pending, changes_requested, déjà publié (un needs_fix corrigé et resoumis, PR #420) 404 (Q3) 404 (Q3) 200
draft, rejected 404 404 404 : l'auteur les lit par GET /me/routes/:id
unpublished 410 route_removed 410 410 : l'auteur le rouvre par GET /me/routes/:id
inexistant, ou supprimé avec le compte de son auteur (D-025) 404 404 —
  • Identifiant mal formé : 400 validation_error (D-071). Jeton faux ou d'un autre parcours : 404, jamais 403, pour ne pas confirmer qu'un parcours privé existe.
  • Pas d'exception de rôle : un modérateur lit un parcours par GET /admin/routes/:id.
  • Auteur supprimé : un parcours publié anonymisé reste servi, author à null (« Membre Widoo », D-025).

Réponse 200 de GET /routes/:id

Query : token (lien privé) ; trips=estimated (nos estimations seules, sans appel à Mapbox ni tracé, pour le site, Q9) ; toute autre clé : 400 validation_error.

RouteDetail, tel que l'appelant a le droit de le voir :

  • Identité : champs de la card (id, title, coverUrl, isOfficial, author, access, isVerified, moods, audiences, conditions, transport, district, neighborhood, rating, stepCount), plus status (published ou needs_fix pour le public), description, photoUrls (couverture d'abord).
  • Chiffres (issus de computed, sans exposer l'objet brut) : durationMin, durationBucket, distanceM, walkingDistanceM, effort, budgetPerPersonEur et budgetBucket, startAccess (station la plus proche du départ, D-050, null tant qu'elle n'est pas calculée). Le budget est la somme exacte, comme sur la card ; l'app l'arrondit (« ≈ 25 € par pers. », D-032, D-066 ; Q11). Les paliers restent ceux enregistrés ; durée, distance, marche et effort sont ceux des trajets de la réponse (Q6).
  • Créateur : creator (routesPublished, isCertified, D-023) ; sharedBy (firstName) pour un parcours privé ouvert par son lien (« Partagé par Camille »), null sinon.
  • Avis : reviews, les trois derniers avis visibles, du plus récent au plus ancien (prénom, note, commentaire, date ; sans réponse) ; la liste complète est GET /routes/:id/ratings.
  • Timeline : timeline, celle du départ conseillé, aujourd'hui dans la ville (« Départ conseillé à 10:00 ») : routes.suggested_start, sinon le départ calculé (D-050). Même forme RouteTimeline que celle de PlannedRoute (GET /me/planned, PR #401) : { startsAt, endsAt, steps: [{ stepId, arrivesAt, leavesAt, conflicts }] }, une entrée par étape dans l'ordre des étapes, conflits d'Orchestration › Timeline (closed_at_arrival, closed_that_day, closes_during_step, hours_unknown).
  • Étapes : steps, toutes, dans l'ordre (steps.length = stepCount).
    • Étape complète (isLocked: false) : titre, description (le conseil du créateur), durée, coût, réservation (bookingRequired, bookingUrl, bookingProvider, bookingNote, bookingIsAffiliate, D-048, D-052), endsOn (étape éphémère, D-050), transitionNote, le lieu (name, category, location, address, isIndoor, photoUrl, verificationStatus, verifiedAt, hours) et la vérification { confirmations, lastConfirmedAt, reports: { count, lastAt } | null } (D-013, D-056 ; « Pas encore vérifié » quand lastConfirmedAt est nul).
    • Étape verrouillée (isLocked: true, LockedStep strict) : id, position, category, durationMin, transition seulement. Le schéma refuse tout autre champ : ni nom, ni lieu, ni coordonnées, ni conseil, ni photo ne quittent l'API (D-014).
    • Trajet vers l'étape suivante (transition, null pour la dernière) : distanceM, durationMin, mode, estimated, et geometry, à ajouter au schéma pour #154 : le tracé dans les rues en polyline encodée (précision 5, ce que apps/api/src/directions/mapbox.ts demande déjà), null sans tracé.
  • Pour l'utilisateur connecté : myReport, date de son signalement du parcours s'il date de moins de 7 jours et n'est pas tranché, null sinon (D-057) ; absent ou null sans jeton.

Trajets exacts, rien d'enregistré (D-088). À chaque GET /routes/:id d'un parcours que l'appelant voit en entier, l'API demande à Mapbox Directions les trajets à pied, à vélo et en voiture (profils walking, cycling, driving) pour cette réponse seulement : estimated: false, distance, durée et geometry de Mapbox. Rien n'est écrit en base (computed, steps.transition, direction_cache restent sur nos estimations), ni journalisé, ni mis en cache (voir En-têtes). Repli, jamais une erreur : Mapbox en échec, trop lent (4 s par appel, comme aujourd'hui) ou quota atteint, la réponse porte l'estimation enregistrée, estimated: true, geometry: null. Le métro n'a pas de profil Mapbox : toujours notre estimation, estimated: true, sans tracé (ligne droite dans l'app, #154). Piste de #36 : une requête par profil et par parcours, les étapes en points de passage (25 au plus), pour tenir la limite de 300 requêtes par minute.

Parcours Premium sans droit (isLocked: true, D-014, D-075) : étape 1 complète, étapes suivantes en LockedStep ; aucun tracé (geometry: null partout) ; aucun appel à Mapbox, trajets sur nos estimations (Q7) ; conflits de la timeline vides sur les étapes verrouillées, qui ne disent rien des horaires d'un lieu caché (Q7). La fiche reste lisible : photos, titre, chiffres, description, infos, créateur, avis.

Avertissements utiles au lecteur (Q10) : la fiche ne porte ni warnings ni errors de cohérence, réservés à l'éditeur (OwnRoute, PR #420) et à la modération ; « le score de cohérence n'est pas montré à l'utilisateur final » (Orchestration). Le lecteur voit ce qui le concerne par des champs déjà là : status = needs_fix (bandeau), les conflits de la timeline, la vérification et verificationStatus de chaque lieu (lieu signalé, stale, closed), endsOn d'une étape (« Une étape s'est terminée le 17 janvier »).

À ajouter au schéma de #390 par #36, décisions déjà prises que route-detail.ts ne porte pas encore :

  • geometry de chaque trajet (D-088, #154) ;
  • myReport (D-057) ;
  • « À prévoir » : lignes du créateur (routes.tips) et suggestions calculées non masquées (computed, moins routes.tips_hidden), D-048, D-050, lues par E-06 ;
  • crédit des photos libres (author, license, sourceUrl, D-052) : photoUrls et place.photoUrl deviennent des objets photo, null pour une photo de créateur ;
  • timezone de la ville (fuseau IANA, comme PlannedRouteSnapshot) : les horaires sont des heures locales, et « Ouvert aujourd'hui » se lit à l'heure de Paris, pas à celle du téléphone.

Hors de #36 : la traduction (route_translations, D-076), qui ajoutera sa langue d'origine et ses champs traduits avec son propre ticket ; l'état « déjà programmé » de la barre (D-035), lu sur GET /me/planned.

GET /routes/:id/timeline?startsAt=

  • Query : startsAt obligatoire, date et heure ISO 8601 avec décalage, même format que POST /me/planned ; token pour un parcours privé. Clé inconnue ou valeur invalide : 400 validation_error.
  • Mêmes règles d'accès que la fiche (404, 410, jeton). Parcours Premium sans droit : 402 premium_required, details.reason: premium_route, comme PATCH /me/planned/:id sur startsAt (D-038) : sans droit, on ne programme pas (Q7).
  • Réponse : RouteTimeline pour ce départ, même forme que timeline de la fiche et de PlannedRoute, horaires valables à la date demandée (horaires saisonniers, jour de fermeture), conflits compris.
  • Trajets : nos estimations, celles que POST /me/planned fige dans la programmation, pour que l'heure de fin vue dans E-06 soit celle de la sortie enregistrée ; aucun appel à Mapbox (Q6, qui amende la ligne du tableau ci-dessus).
  • Ce qui reste à /timeline : un autre départ que le départ conseillé (E-06 « Autre date », changement d'heure). La fiche n'a pas besoin de l'appeler à l'ouverture : sa timeline est dans RouteDetail.

Parcours retiré : la 410

Un parcours unpublished (retiré par son auteur, par l'équipe, ou dépublié parce qu'un de ses lieux a fermé définitivement) répond, à tout appelant et avec ou sans jeton :

HTTP/1.1 410 Gone
{
  "code": "route_removed",
  "message": "Route removed",
  "details": {
    "removedAt": "2026-09-20T08:12:00.000Z",
    "near": { "lat": 48.86, "lng": 2.35 }
  }
}
  • Code stable route_removed, à ajouter à la liste des Conventions ; il figure déjà dans apiErrorCodes (packages/shared/src/schemas/error.ts), et details est validé côté app par RouteRemovedDetails, auquel s'ajoute near. Corps unique ApiError d'apps/api/src/errors.ts (code, message, details), qui demande un apiCode explicite comme coherence_blocked, 410 n'ayant pas de code par défaut dans codeByStatus.
  • removedAt : date du passage en unpublished. Aucune colonne ne la porte aujourd'hui : routes.unpublished_at (timestamptz), posée par l'écrivain unique du statut (moveRoute) à chaque passage en unpublished, remise à null à la republication (Q2).
  • near : la zone du parcours, pour que « Voir des parcours proches » recentre l'accueil (E-05) : les coordonnées du départ arrondies à deux décimales (environ 1,1 km du nord au sud, 0,7 km d'est en ouest à Paris), jamais le point exact (Q1).
  • Rien d'autre du parcours retiré : ni titre, ni auteur, ni étapes, ni motif du retrait.
  • La 410 vaut tant que la ligne du parcours existe ; un parcours supprimé avec le compte de son auteur répond 404 (seuls les parcours publiés sont gardés, anonymisés, D-025). Le site (/r/:id, E-21) rend sur la 410 l'encart « Ce parcours n'est plus disponible », sans lire near.
  • Écart avec la planche parcours retiré : elle dit « Son créateur l'a retiré le 20 sept. », alors qu'un retrait peut venir de l'équipe ou d'un lieu fermé (Q2).

En-têtes de cache et limites

  • GET /routes/:id, réponse 200 : Cache-Control: no-store, sans ETag (Q8). Elle porte des résultats de Mapbox Directions, qu'il est interdit de conserver (D-088), comme /geocode (D-081) ; et elle dépend de l'appelant. L'app garde la fiche ouverte en mémoire pendant qu'on la lit, sans la persister.
  • Limite : 60 requêtes par minute et par IP sur GET /routes/:id, chaque ouverture coûtant une requête Mapbox par profil, voire par trajet (menace « abus des API tierces payantes », Securite-et-RGPD) ; 429 rate_limited au-delà (Q8).
  • GET /routes/:id/timeline, sans données Mapbox : comme /routes/search, Cache-Control: public (60 s, ETag) sans Authorization ni token ; private, no-cache et Vary: Authorization avec un jeton Firebase ; private, no-store avec un token de lien privé (règle de /r/:id, Site-Web).
  • Erreurs (400, 401, 402, 404, 410, 429) : jamais mises en cache, comme aujourd'hui (cacheHook).
  • Site widoo.app : son cache de 5 minutes sur /r/:id ne peut pas garder de résultat Mapbox ; il lit la fiche avec ?trips=estimated, sans appel à Mapbox, réponse alors cachable comme /timeline (Q9).

Questions à trancher (#393)

Pour Ilan. Chaque question porte la recommandation, puis ce qu'elle écarte.

  1. Zone dans la 410. La 410 porte-t-elle un point pour « Voir des parcours proches » ? Recommandé : oui, details.near, départ arrondi à 0,01° (environ 1 km), assez pour recentrer la carte de l'accueil sur le quartier, sans désigner un lieu. Écarte : le départ exact (il nommerait le premier lieu d'un parcours peut-être retiré pour « Lieu dangereux ou interdit d'accès ») ; l'arrondissement en texte (l'app devrait le géocoder par Mapbox, et « 3e » seul n'a pas de centre) ; l'emprise du parcours (elle trace sa forme) ; rien (l'accueil s'ouvrirait sur la position de l'utilisateur, alors qu'E-05, validée, dit « accueil centré sur sa zone »).
  2. Date et auteur du retrait. Recommandé : nouvelle colonne routes.unpublished_at, et un texte neutre sur la planche, « Ce parcours a été retiré le 20 sept. », sans dire qui l'a retiré. Écarte : details.removedBy (author ou team), qui dirait publiquement que l'équipe a retiré le parcours d'un créateur nommé ailleurs ; garder « Son créateur l'a retiré » pour tous les retraits (faux pour un retrait de l'équipe ou un lieu fermé) ; lire la date dans un journal d'actions, qui ne couvre pas les retraits de l'auteur.
  3. Parcours déjà publié en cours de relecture. Un needs_fix corrigé par son auteur repasse pending et sort du public jusqu'à la décision (choix 5 de la PR #420, à confirmer). Que voit celui qui l'ouvre d'un favori ? Recommandé : 404, comme un parcours introuvable, pour un état court et rare au MVP. Écarte : la 410 (elle annoncerait un retrait qui n'a pas eu lieu, avec une fausse date) ; un nouvel état « en cours de correction » (planche à faire). À reconsidérer : corriger un needs_fix par une révision, comme un parcours publié, pour qu'il reste visible avec son bandeau pendant la relecture (#53).
  4. Lien privé d'un parcours soumis. Recommandé : le jeton ouvre le parcours tant qu'il est private, pending ou changes_requested sans avoir été publié, comme le dit « Partageable : oui » de Creation-et-Moderation › Cycle de vie ; l'auteur le lit sans jeton. Écarte : un jeton valable seulement en private, qui casserait les liens déjà envoyés dès que l'auteur soumet.
  5. L'auteur d'un parcours Premium. Recommandé : l'auteur a toujours droit à son propre parcours en entier, fiche et programmation, ce que canAccessRoute ne dit pas aujourd'hui (règle à étendre par #36 ou #66). Écarte : verrouiller l'auteur comme un autre non-abonné (il ne verrait plus ses étapes dans la fiche).
  6. Trajets exacts ou estimés, fiche et /timeline. Recommandé : la fiche calcule durée, distance, marche, effort et sa timeline sur les trajets de la réponse (exacts quand Mapbox répond), pour que les heures s'additionnent avec les trajets affichés ; paliers et filtres restent sur l'enregistré. /timeline reste sur nos estimations, celles que la programmation enregistre, ce qui amende la ligne actuelle « sur les trajets exacts de Mapbox » (D-088). Écarte : /timeline exacte (l'heure de fin vue dans E-06 différerait de quelques minutes de la sortie enregistrée, et chaque changement de date coûterait des requêtes Mapbox) ; une fiche entièrement estimée (« 15 min à pied » affiché, 13 min entre deux heures).
  7. Parcours Premium sans droit. Recommandé : aucun appel à Mapbox (trajets estimés, aucun tracé), conflits vides sur les étapes verrouillées, /timeline en 402 premium_required. Écarte : interroger Mapbox pour des étapes que l'appelant ne voit pas (coût sans usage) ; des conflits qui laisseraient deviner les horaires d'un lieu caché ; une timeline à un départ libre pour un parcours qu'il ne peut pas programmer.
  8. Cache et limite de la fiche. Recommandé : no-store sur la réponse 200 de GET /routes/:id, et 60 requêtes par minute et par IP. Écarte : le cache public court de la convention « toutes les lectures publiques sont cachables » (il garderait les résultats Mapbox dans le CDN et l'app, D-088), qui devient une exception à écrire dans Conventions ; aucune limite (une boucle d'ouvertures épuiserait le quota Mapbox de 300 requêtes par minute pour tous).
  9. Le site /r/:id (E-21). Son rendu est mis en cache 5 minutes, et sa carte du parcours ne montre jamais le détail des étapes (E-21, D-068) : il n'a besoin d'aucun trajet. Recommandé : le site lit la fiche avec ?trips=estimated (nos estimations seules, aucun appel à Mapbox, réponse cachable 5 minutes comme /timeline). Écarte : un appel à Mapbox à chaque revalidation du site, pour des trajets qu'il n'affiche pas ; un résultat Mapbox gardé 5 minutes dans son cache (interdit par D-088) ; un endpoint de plus, propre au site.
  10. Avertissements dans la fiche. Recommandé : aucun warnings ni errors de cohérence dans la fiche ; le lecteur voit status, les conflits, la vérification et les dates de fin. Écarte : exposer les avertissements de l'éditeur (too_many_transitions, back_and_forth…), qui parlent au créateur et à la modération, pas à celui qui sort.
  11. Budget. Recommandé : somme exacte, comme sur la card (D-066), l'arrondi aux 5 € restant dans l'app (une seule fonction partagée, que le test « 23 € donne 25 € » de #36 vise). Écarte : un budget déjà arrondi par l'API, que demande encore le corps de #36 et qui ferait deux règles entre la card et la fiche.

Compte (auth)

Méthode Route Description
POST /auth/email-link Sans jeton (D-090, #397, v0.4) : { email, language }, language valant fr ou en ; l'API génère le lien de connexion par l'Admin SDK Firebase (generateSignInWithEmailLink, adresse de retour et réglages du lien fixés par l'API, jamais par l'app) et l'envoie par Brevo au modèle « Connexion » de E-22, dans la langue demandée. 202 sans corps, identique que l'adresse ait un compte ou non ; 422 validation_error ; 429 rate_limited moins de 30 s après l'envoi précédent à la même adresse, au-delà de 5 envois par heure pour l'adresse ou de 30 par heure pour l'IP. Ni l'adresse ni le lien dans les journaux. Jusqu'à la v0.4, l'app demande le lien à Firebase directement
GET /me Profil complet (bio, quartier, anecdotes, à la une), rôle, plan, préférences, canCreateRoute (faux pour un compte gratuit qui a déjà son parcours), certification (critères avec valeur, seuil et état). Plus de compteurs du mois (D-023)
PATCH /me Prénom ou pseudo, avatar, bio (150 caractères), quartier (facultatif), isPublic, locale (fr, en, ou null pour la langue du téléphone, D-062), notificationPrefs ({ reminders, nextDay, myRoutes }, booléens, D-062), anecdotes (liste ordonnée [{ questionKey, answer }], 3 au plus, question unique, réponse de 150 caractères) et featuredRouteIds (3 au plus, parcours publiés de l'appelant), enregistrés en une transaction par « Enregistrer » de Modifier le profil ; 422 validation_error sinon
POST /webhooks/revenuecat Webhook signé : met à jour plan et plan_expires_at
DELETE /me Suppression de compte, sans paramètre (D-025) : parcours publiés anonymisés (auteur « Membre Widoo »), tout le reste supprimé
POST /me/devices Enregistrement du jeton push, avec la langue du téléphone (locale) : langue des push quand users.locale est nul (D-062)
POST /me/email { newEmail } (D-062) : envoie à la nouvelle adresse un lien de confirmation, dans la langue du compte ; l'ancienne adresse reste active. Remplace une demande en attente. 409 conflict (details.reason email_in_use), 422 validation_error, 429 rate_limited moins de 30 s après l'envoi précédent
POST /me/email/confirm { token } (lien ouvert dans l'app) : l'adresse change dans Firebase Auth (Admin updateUser, emailVerified) et dans users.email ; la connexion par e-mail suit, Apple et Google ne changent pas. 410 link_expired
GET /me/export Export de vos données (E-19 › Confidentialité, D-063) : un JSON (profil, anecdotes, à la une, favoris, sorties programmées et passées, notes, parcours créés avec leurs étapes et les liens de leurs photos, préférences), Content-Disposition: attachment; filename=widoo-donnees-AAAA-MM-JJ.json ; préparé à la demande, non conservé ; 429 rate_limited au-delà de quelques demandes par heure
DELETE /me/email Annule le changement en attente (« Annuler » de E-19) ; le lien déjà envoyé ne marche plus

🟡 Brouillon #383 du 2026-10-14, à valider par Ilan, voir Compte-et-Monetisation › Fin de la limite de création : proposé, canCreateRoute est retiré de GET /me, sans remplaçant (jamais codé) : la création n'a plus de limite, la seule limite se dit à la soumission.

Favoris et programmation (auth)

Méthode Route Description
GET /me/favorites
PUT / DELETE /me/favorites/:routeId Sans limite (D-023)
GET /me/planned Query status=
POST /me/planned { routeId, startsAt } → programmation avec timeline et checklist. 402 premium_required (parcours Premium pour un non-abonné) ; sans limite sinon (D-023)
POST /me/planned/:id/rating { rating, comment? } ; exige un Go terminé ; crée ou met à jour route_ratings
PATCH /me/planned/:id startsAt, checklist, status (planned pour « Rétablir » ou « Reprogrammer », in_progress, completed, cancelled) ; changer startsAt recalcule la timeline et recale les rappels ; parcours Premium sans abonnement : 402 premium_required sur startsAt et in_progress (D-038). personalNote retiré (D-029)
POST /me/planned/:id/steps/:stepId/done Coche une étape (Go) ; corps facultatif { signal: { kind, reason?, comment? } } pour la vérification sur place
GET /me/planned/:id/maps-url URL Google Maps à pied jusqu'à une étape, construite côté serveur : une URL par étape, jamais pour tout le parcours (D-032)

Création (auth)

Méthode Route Description
GET /me/routes Mes parcours, tous statuts
POST /routes Crée un brouillon, sans limite (révision du 2026-09-28 ; l'ancienne limite create_limit est abandonnée)
PATCH /routes/:id Informations, ambiances, publics, conditions, transport
PUT /routes/:id/steps Remplace la liste ordonnée des étapes ; retourne le résultat d'orchestration (computed, warnings, errors)
POST /routes/:id/submit Passe en pending ; 422 coherence_blocked si règles bloquantes
POST /routes/:id/status private, draft, unpublished par l'auteur
POST /media/upload-url { ownerType, ownerId, contentType } → URL signée et mediaId
DELETE /media/:id

🟡 Brouillon #383 du 2026-10-14, à valider par Ilan, voir Compte-et-Monetisation › Fin de la limite de création : proposé, POST /routes/:id/submit documente aussi la 429 rate_limited (details: { reason: 'pending_routes', limit }) déjà codée (#66, #48) : au-delà de settings.max_pending_routes (3) parcours en relecture, une révision en attente comptant pour un, compte editor non limité (à confirmer, question 4).

Lieux (auth)

Méthode Route Description
GET /places/search Query q=, near=, category= : lieux Widoo existants
POST /places Création ; répond 409 avec le candidat si un lieu proche de même nom existe, sauf force=true
PATCH /places/:id Auteur du lieu tant qu'il n'est utilisé que par ses parcours ; sinon modérateur
POST /places/:id/signals `{ kind: 'confirm'
POST /routes/:id/reports { reason, stepId?, comment? } (D-056) ; stepId pour place_changed ; un signalement par utilisateur et par parcours sur 7 jours glissants (409 sinon) ; 403 sur ses propres parcours ; envoyé en différé par l'app hors connexion

Admin (rôles editor, moderator, admin)

Rôle exigé par section : matrice de Admin-Web › Rôles. Toute écriture est journalisée dans admin_actions (D-085). Jeton sans second facteur : 403 mfa_required (D-087). Une décision sur un parcours ou un signalement déjà tranché par un autre membre de l'équipe : 409 already_decided avec la décision prise.

Méthode Route Description
GET /admin/moderation File pending et changes_requested
GET /admin/routes/:id Fiche de modération (A-02) : parcours tel qu'il sera publié, avertissements de cohérence, différence avec la version publiée pour une révision, chiffres et historique de l'auteur, historique du parcours, position dans la file (« 1 sur 14 »)
GET /admin/counters Compteurs légers de la navigation (modération et ancienneté maximale, signalements, support, brouillons IA, traductions en échec), filtrés par rôle
POST /admin/routes/:id/publish
POST /admin/routes/:id/request-changes { message }
POST /admin/routes/:id/reject { reasonCode, message }
POST /admin/routes/:id/unpublish { reasonCode, message }
GET /admin/signals Groupés par lieu
POST /admin/places/:id/verify Remet verified, purge les signaux ouverts
POST /admin/places/:id/close { reason } ; passe closed, parcours concernés en needs_fix
POST /admin/signals/:id/reject { reason }
GET /admin/reports Signalements de parcours ouverts, avec l'étape pour place_changed (D-056)
POST /admin/reports/:id/resolve `{ outcome: 'accepted'
POST /admin/ratings/:id/hide { reason } ; masque un commentaire
POST /admin/users/:id/certification `{ override: 'none'
GET /admin/stats Compteurs du tableau de bord
POST /admin/places/import CSV, rôle admin
POST /admin/routes/:id/republish Republie un parcours dépublié
POST /admin/routes/:id/reassign Réassigne à l'équipe Widoo
GET /admin/ratings Avis avec commentaire, filtres note et parcours ; POST /admin/ratings/:id/unhide rétablit
PATCH /admin/places/:id Édition d'un lieu, dont partner_booking_url et partner_program_id ; POST /admin/places crée un lieu
GET /admin/users, /admin/users/:id Recherche et fiche (A-06)
PATCH /admin/users/:id/role Rôle admin seulement ; jamais sur soi-même
POST /admin/users/:id/suspend, /reactivate { reason } ; un compte suspendu reçoit 403 account_suspended partout
POST /admin/users/:id/gift { until? , routeId? } : Premium offert jusqu'à une date ou un parcours offert, plan_source = gift
GET /admin/users/:id/export Export RGPD, même contenu que celui de l'app
DELETE /admin/users/:id Même règle que la suppression dans l'app
POST, GET, PATCH, DELETE /admin/ai-drafts Génération, liste, éditeur assigné, suppression des brouillons IA (A-07, #247)
GET /admin/translations Traductions en échec ou corrigées ; POST /:id/retry, PATCH /:id (champ corrigé)
GET, POST, PATCH /admin/affiliate-programs Programmes d'affiliation (A-09)
GET /admin/bookable-steps Étapes réservables, origine du lien, clics sur 30 jours
GET, POST, PATCH /admin/b2b-clients Partenaires B2B ; PUT /:id/routes : 3 à 10 parcours dans l'ordre (A-10)
GET /admin/settings Réglages ; PATCH /admin/settings/:key (admin), validation par clé, effet sous une minute
GET /admin/actions Journal (admin)
GET /admin/support Messages « Nous écrire » ; POST /:id/reply (e-mail Brevo E-22), POST /:id/handled

Publics liés à l'admin : GET /go/:stepId enregistre un clic dans booking_clicks et redirige (302) vers le lien de réservation affiché (créateur d'abord, D-048) ; GET /public/partners/:slug sert la page partenaire B2B du site.

Interne (jeton de service, Cloud Scheduler)

Appelées par Cloud Scheduler avec Authorization: Bearer <INTERNAL_TOKEN>, jamais par un utilisateur ni par l'admin web : 401 sans le bon jeton (comparé en temps constant) ; routes absentes quand le jeton n'est pas configuré ; Cache-Control: no-store ; hors de la documentation publique de l'API (D-066).

Méthode Route Description
POST /internal/recommendation Recalcul nocturne des composantes stables du score de recommandation dans routes.recommendation (qualité, fiabilité, fraîcheur, officiel, moments ; voir Filtres-et-Recherche › Recommandation) ; réponse { updated } (#63)
POST /internal/certification Recalcul nocturne du badge Créateur certifié selon les seuils de settings
POST /internal/recompute Recalcul des parcours dont un lieu a changé
POST /internal/reminders Rappels J-1 et H-1 des parcours programmés
POST /internal/verification-decay Passage verified → stale après 90 jours

Clone this wiki locally