Repository navigation
API
Statut : 🟡 Brouillon · Màj : 2026-10-20 · Tickets : #164 #306 #378 #383 #393 #397 #418 #499 #508 #510 #529 #531 #562
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.
- 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[]=cultureoumoods=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 400validation_error, avecdetails.issues; jamais un résultat non filtré (#25, D-066). Règle du code HTTP devalidation_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, avecdetails.reasonpremium_routepour alimenter E-17 ; D-023, D-094),route_removed(en 410 : parcours retiré, avecdetails.removedAtetdetails.near, voir Parcours retiré, D-093),account_required(en 403 : route qui demande un compte, appelée avec le jeton d'un compte anonyme, D-099).rate_limited(429) portedetails.reasonpending_routesetdetails.limitquand il s'agit de la limite de relecture (D-073, D-094) ; sansdetails.reason, c'est une limite de débit. - Dates ISO 8601 UTC ; coordonnées
{ lat, lng }. - Toutes les lectures publiques sont cachables (
Cache-Controlcourt, ETag), pour l'appelant anonyme, sauf celles qui portent des résultats temporaires de Mapbox, qu'il est interdit de conserver :/geocode(D-081) et la ficheGET /routes/:id, enno-store(D-088, D-093, voir En-têtes de cache et limites). - 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êteAuthorization, réponse anonyme,Cache-Control: public. Avec un en-têteAuthorization, réponseCache-Control: privateetVary: Authorization, jamais servie à un autre appelant. Un jeton présent mais invalide ou expiré répond 401unauthorized, 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. - Compte anonyme (D-099) : sans compte, l'app n'a un jeton qu'après sa première opération du store (achat ou restauration), faite sous un compte anonyme Firebase créé juste avant ; elle l'envoie sur les routes à authentification facultative, où il vaut celui d'un compte : c'est par lui que la fiche et la recherche reconnaissent un déblocage, un pass ou un abonnement achetés sans compte, avec la règle commune (
canAccessRoute) et une réponse privée. Le téléphone qui n'a rien acheté n'a pas de jeton et garde la réponse publique. Une route qui demande un compte (Compte, Favoris et programmation, Création, Lieux, signalements) répond 403account_requiredau jeton d'un compte anonyme. Un compte anonyme supprimé (sans achat, après 7 jours) répond 401 : l'app l'oublie et repart sans jeton.
| 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 route_removed, avec la date du retrait et la zone du départ. Cache-Control: no-store, 60 requêtes par minute et par IP. Contrat détaillé : Fiche parcours (D-093) |
| GET | /routes/:id/timeline |
Query startsAt= : timeline recalculée et conflits pour un autre départ que le départ conseillé, sur nos estimations, celles que la programmation enregistre, sans appel à Mapbox (D-093, qui amende D-088) ; voir 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 /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;nullpour 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,districtetneighborhooddu départ (« 3e », « Le Marais »),durationMinetdurationBucket,distanceM,rating(average,nullavant le premier avis, etcount). -
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).budgetBucketest dérivé de la même somme exacte, jamais de l'arrondi. -
isLocked:truequand la card d'un parcoursaccess = premiumest verrouillée pour l'appelant,falsesinon (toujoursfalsepour un parcours gratuit). L'app lit ce champ, jamaisaccessseul, 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, questepsles porte toutes ou non. -
steps[], dans l'ordre, pour les pastilles et la tooltip de la carte (E-04) :category(valeur deplaces.category),location({ lat, lng }),name(nom du lieu) etdurationMin(durée sur place, pour « Étape i/n · catégorie · durée »). Parcours gratuit : toutes les étapes. Parcoursaccess = 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,nameetdurationMinànull; les étapes 2 et suivantes ne quittent pas l'API, ni nom, ni lieu, ni coordonnées, ni photo. La tooltip Premium litstepCountetdurationMindu 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, anonyme compris (D-099), qui a droit au parcours : toutes les étapes, comme un parcours gratuit ; la carte affiche tracé, étapes et tooltips. - Droit au parcours : un compte, anonyme compris (D-099), dont
users.planvautpremiumet dontusers.plan_expires_atn'est pas passé (abonnement ou pass 7 jours), ou qui a une ligneroute_unlockspour ce parcours (#66) ; l'auteur du parcours (D-093). Règle calculée en un seul endroit du code (canAccessRoute), 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.
- Card verrouillée (
- 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 gagnesteps[].photoUrl(photo principale du lieu en vignette,nullsans 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 surcoverUrlquand 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, avecsort=recommendedseulement (absente avec les autres tris) :{ key: 'near_you', distanceM }, ou{ key }parmitop_rated,verified,fresh,good_timing,official;nullquand 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 quegood_timingetfresh(D-071). Règles : Filtres-et-Recherche › Recommandation (#63).
Statut : 🟢 Validé par Ilan le 2026-10-16 (#393, D-093) : brouillon du 2026-10-14, les onze recommandations retenues telles quelles (Choix tranchés, chacun avec ce qu'il écarte). Contrat à reporter 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.
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 jeton d'un compte anonyme (D-099) est un jeton comme un autre : l'acheteur sans compte lit en entier ce qu'il a débloqué. 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).
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), plusstatus(publishedouneeds_fixpour le public),description,photoUrls(couverture d'abord). -
Chiffres (issus de
computed, sans exposer l'objet brut) :durationMin,durationBucket,distanceM,walkingDistanceM,effort,budgetPerPersonEuretbudgetBucket,startAccess(station la plus proche du départ, D-050,nulltant 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 »),nullsinon. -
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 estGET /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 formeRouteTimelineque celle dePlannedRoute(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é » quandlastConfirmedAtest nul) ; « À ne pas manquer » et « En images » :highlightsetphotos, comme dans le snapshot d'une programmation (voir Favoris et programmation, #508, #529). - Étape verrouillée (
isLocked: true,LockedStepstrict) :id,position,category,durationMin,transitionseulement. 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,nullpour la dernière) :distanceM,durationMin,mode,estimated, etgeometry, à ajouter au schéma pour #154 : le tracé dans les rues en polyline encodée (précision 5, ce queapps/api/src/directions/mapbox.tsdemande déjà),nullsans tracé.
- Étape complète (
-
Pour l'utilisateur connecté :
myReport, date de son signalement du parcours s'il date de moins de 7 jours et n'est pas tranché,nullsinon (D-057) ; absent ounullsans 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 :
-
geometryde chaque trajet (D-088, #154) ; -
myReport(D-057) ; - « À prévoir » : lignes du créateur (
routes.tips) et suggestions calculées non masquées (computed, moinsroutes.tips_hidden), D-048, D-050, lues par E-06 ; - crédit des photos libres (
author,license,sourceUrl, D-052) :photoUrlsetplace.photoUrldeviennent des objets photo,nullpour une photo de créateur ; -
timezonede la ville (fuseau IANA, commePlannedRouteSnapshot) : 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.
- Query :
startsAtobligatoire, date et heure ISO 8601 avec décalage, même format quePOST /me/planned;tokenpour un parcours privé. Clé inconnue ou valeur invalide : 400validation_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, commePATCH /me/planned/:idsurstartsAt(D-038) : sans droit, on ne programme pas (Q7). - Réponse :
RouteTimelinepour ce départ, même forme quetimelinede la fiche et dePlannedRoute, horaires valables à la date demandée (horaires saisonniers, jour de fermeture), conflits compris. - Trajets : nos estimations, celles que
POST /me/plannedfige dans la programmation, pour que l'heure de fin vue dans E-06 soit celle de la sortie enregistrée ; aucun appel à Mapbox (Q6, D-093, qui amende D-088). - 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 dansRouteDetail.
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, dans la liste des Conventions ; il figure déjà dansapiErrorCodes(packages/shared/src/schemas/error.ts), etdetailsest validé côté app parRouteRemovedDetails, auquel s'ajoutenear. Corps uniqueApiErrord'apps/api/src/errors.ts(code,message,details), qui demande unapiCodeexplicite commecoherence_blocked, 410 n'ayant pas de code par défaut danscodeByStatus. -
removedAt: date du passage enunpublished. Aucune colonne ne la porte aujourd'hui :routes.unpublished_at(timestamptz), posée par l'écrivain unique du statut (moveRoute) à chaque passage enunpublished, 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 lirenear. - Texte de l'écran, neutre (Q2) : « Ce parcours a été retiré le 20 sept. », sans dire qui l'a retiré, un retrait pouvant venir de l'auteur, de l'équipe ou d'un lieu fermé ; planche parcours retiré, mise à jour le 2026-10-19 (D-099).
-
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) ; 429rate_limitedau-delà (Q8). -
GET /routes/:id/timeline, sans données Mapbox : comme/routes/search,Cache-Control: public(60 s, ETag) sansAuthorizationnitoken;private, no-cacheetVary: Authorizationavec un jeton Firebase ;private, no-storeavec untokende 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/:idne peut pas garder de résultat Mapbox ; il lit la fiche avec?trips=estimated, sans appel à Mapbox, réponse alors cachable comme/timeline(Q9).
Validés par Ilan le 2026-10-16, tels que recommandés dans le brouillon (D-093). Chaque choix porte la question, ce qui est retenu, puis ce qu'il écarte.
-
Zone dans la 410. La 410 porte-t-elle un point pour « Voir des parcours proches » ? Retenu : 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 »). -
Date et auteur du retrait. Retenu : 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(authorouteam), 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. -
Parcours déjà publié en cours de relecture. Un
needs_fixcorrigé par son auteur repassependinget sort du public jusqu'à la décision (choix 5 de la PR #420, à confirmer). Que voit celui qui l'ouvre d'un favori ? Retenu : 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 unneeds_fixpar une révision, comme un parcours publié, pour qu'il reste visible avec son bandeau pendant la relecture (#53). -
Lien privé d'un parcours soumis. Retenu : le jeton ouvre le parcours tant qu'il est
private,pendingouchanges_requestedsans 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 enprivate, qui casserait les liens déjà envoyés dès que l'auteur soumet. -
L'auteur d'un parcours Premium. Retenu : l'auteur a toujours droit à son propre parcours en entier, fiche et programmation, ce que
canAccessRoutene 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). -
Trajets exacts ou estimés, fiche et
/timeline. Retenu : 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é./timelinereste sur nos estimations, celles que la programmation enregistre, ce qui amende la ligne actuelle « sur les trajets exacts de Mapbox » (D-088). Écarte :/timelineexacte (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). -
Parcours Premium sans droit. Retenu : aucun appel à Mapbox (trajets estimés, aucun tracé), conflits vides sur les étapes verrouillées,
/timelineen 402premium_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. -
Cache et limite de la fiche. Retenu :
no-storesur la réponse 200 deGET /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). -
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. Retenu : 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. -
Avertissements dans la fiche. Retenu : aucun
warningsnierrorsde cohérence dans la fiche ; le lecteur voitstatus, 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. - Budget. Retenu : 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.
| 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, certification (critères avec valeur, seuil et état). Plus de compteurs du mois (D-023) ; plus de canCreateRoute, la création n'ayant pas de limite (D-094) |
| POST | /me/merge-anonymous |
{ anonymousIdToken } (D-099) : connexion à un compte qui existe déjà sur un téléphone qui a acheté sans compte ; l'app envoie le jeton du compte anonyme qu'elle avait, l'API le vérifie (signé par Firebase, fournisseur anonymous, compte anonyme connu) et déplace vers l'appelant ses déblocages (route_unlocks, sans doublon) et son droit Premium s'il finit plus tard ; le compte anonyme garde sa ligne, merged_into_id = l'appelant. 200 { unlocksMoved, planMoved } ; le même appel une seconde fois : 200 sans effet ; jeton invalide, expiré, non anonyme ou d'un compte déjà fusionné dans un autre : 403 forbidden, rien n'est déplacé |
| 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 |
| Méthode | Route | Description |
|---|---|---|
| GET | /me/favorites |
|
| PUT / DELETE | /me/favorites/:routeId |
Sans limite (D-023) |
| GET | /me/planned |
Mes programmations, { items, nextCursor }, cursor et limit (20 par défaut, 50 au plus). Query segment ou status, pas les deux (400 validation_error). segment=upcoming (À venir de E-08) : planned et in_progress, par départ croissant puis id, la prochaine sortie en tête ; segment=past (Passés) : completed, stopped et missed, par départ décroissant puis id, les plus récentes en premier ; status= (un seul statut) ou sans query : par départ croissant (D-095) |
| GET | /me/planned/:id |
Une programmation de l'appelant (PlannedRoute), lisible même quand le parcours est dépublié ou supprimé : le snapshot reste, routeId nul pour un parcours supprimé ; 404 pour la programmation d'un autre compte (D-095) |
| 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 (« Rétablir » une sortie annulée, « Reprogrammer » une sortie pas faite), in_progress (« C'est parti », « Relancer » un parcours arrêté), completed (« Terminer la sortie »), stopped (« Arrêter le parcours », depuis in_progress, sans note, D-029, D-033), cancelled (« Annuler la sortie ») ; missed n'est posé que par la tâche de 4:00 ; un passage non prévu répond 409 conflict (D-095). 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/arrived |
« J'y suis » (E-07, D-029) : pose l'arrivée (arrivedAt) une fois, un second appel garde la première heure (D-095). Corps facultatif { occurredAt } (D-102) |
| POST | /me/planned/:id/steps/:stepId/done |
« Étape suivante » : pose doneAt une fois ; une étape passée devient faite. Corps facultatif { occurredAt } (D-102) ; jusqu'à place_signals (#49), un corps { signal } répond 422 validation_error ; ensuite, corps facultatif { occurredAt?, signal?: { kind, reason?, comment? } } pour la vérification sur place (D-095) |
| POST / DELETE | /me/planned/:id/steps/:stepId/skip |
« Passer cette étape » (D-033) : POST pose skippedAt, l'étape ne compte pas comme faite ; 409 conflict sur une étape déjà faite ; corps facultatif { occurredAt } (D-102). DELETE : « Annuler » du toast, sans corps, retire skippedAt, l'étape retrouve son état d'avant (en route, ou sur place si arrivedAt est posé) (D-095) |
| GET | /me/planned/:id/maps-url |
{ stepId, url } : URL Google Maps vers la prochaine étape ni faite ni passée, construite côté serveur ; une URL par étape, jamais pour tout le parcours (D-032) ; 409 conflict quand il ne reste aucune étape. Dans le mode du trajet qui mène à l'étape (transition.mode de l'étape précédente, même passée), traduit par mappings.transitModes de tokens.json : walk → walking, bike → bicycling, metro → transit, car → driving ; à pied vers la première étape, qu'aucun trajet du parcours ne précède (D-052, D-095, #510). Hors connexion, l'app construit la même URL, par une seule fonction partagée |
Routes d'une étape du Go (arrived, done, skip) : réponse 200 PlannedRoute à jour ; 409 conflict hors d'un Go in_progress ; 404 not_found pour une étape inconnue ou la programmation d'un autre compte.
Heure d'une action du Go (D-102, décision prise par l'opérateur le 2026-10-20, à confirmer par Ilan, répond à #562) : les trois POST (arrived, done, skip) acceptent un corps vide (absent ou {}, toujours accepté, pour les versions de l'app qui n'envoient rien) ou { occurredAt }, date et heure ISO 8601 avec fuseau, l'heure où l'utilisateur a touché le bouton sur le téléphone. L'API retient occurredAt s'il est compris entre le lancement du Go (planned_routes.started_at, Modele-de-Donnees) et l'heure de réception ; sinon (avant le lancement, dans le futur, horloge du téléphone fausse) elle retient l'heure de réception, sans erreur. Toute autre clé, ou un occurredAt qui n'est pas une date ISO, répond 422 validation_error (et { signal } sur done jusqu'à #49, ci-dessus). L'app envoie occurredAt sur chaque action, et surtout sur une réponse gardée hors connexion (E-07, « Réponse gardée, envoyée au retour du réseau ») : « Fait à » montre alors l'heure de l'action, pas celle du retour du réseau. Raison : le point à surveiller de D-095, une étape faite à 10:22 dans le métro s'affichait « Fait à 10:58 » ; la borne garde l'heure plausible sans faire confiance à l'horloge du téléphone au-delà du Go. Écarté : l'heure de réception seule (règle de D-095, fausse hors connexion) ; occurredAt obligatoire (casse les versions de l'app déjà installées) ; un 422 pour un occurredAt hors borne (l'action est vraie, seule l'heure est douteuse : la refuser perdrait l'étape) ; l'horloge du téléphone sans borne (une heure future ou d'avant le départ fausserait la timeline et la durée de la sortie). Toutes les réponses de /me/planned : Cache-Control: no-store.
PlannedRoute (packages/shared/src/schemas/planned.ts) : id, routeId (nul une fois le parcours supprimé), status, startsAt, completedAt, closedBy, route (le snapshot), timeline (même forme RouteTimeline que la fiche), checklist ({ stepId, isChecked } des réservations), steps (progression du Go, une entrée par étape dans l'ordre, { stepId, arrivedAt, doneAt, skippedAt } : un autre téléphone du même compte voit « Vous y êtes » ou « Passée »), myRating, createdAt, updatedAt.
Snapshot (route, PlannedRouteSnapshot) : le parcours tel qu'il a été programmé, figé au POST et gardé sur le téléphone par E-07 pour la sortie hors connexion : id, title, access, isOfficial, timezone (fuseau IANA de la ville), steps. Chaque étape porte, en plus de son contenu et de son lieu (nom, catégorie, coordonnées, adresse, intérieur, horaires) :
-
highlights: « À ne pas manquer », 1 à 3 points, liste vide sans point (#508) ; -
photos: « En images », objets photo{ url, credit }, des URL lisibles hors connexion ;creditvaut{ author, license, sourceUrl }pour une photo libre,nullpour une photo de créateur (D-052) ; la photo du lieu en tête, puis les photos de l'étape dans leur ordre, 5 au plus en tout (#529, décision d'Ilan du 2026-10-16).
Une programmation enregistrée avant #508 relit ces deux champs vides. Mêmes champs et même règle sur les étapes complètes de la fiche (RouteDetailStep, voir Fiche parcours) ; une étape verrouillée (LockedStep) n'en porte aucun (D-014).
| Méthode | Route | Description |
|---|---|---|
| GET | /me/routes |
Mes parcours, tous statuts |
| POST | /routes |
Crée un brouillon, sans limite et sans contrôle préalable (révision du 2026-09-28, D-073, D-094) |
| 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 ; 429 rate_limited, details: { reason: 'pending_routes', limit }, quand l'auteur a déjà settings.max_pending_routes (3) autres parcours en relecture : une révision en attente d'un parcours publié compte pour un, un compte editor (publié sans relecture) n'est pas limité, et le parcours reste dans son statut (D-073, D-094) |
| 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 |
| 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 |
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 ; un moderator ne suspend et ne réactive qu'un compte user, un compte de l'équipe se suspend par un admin, et personne ne se suspend soi-même (D-097) |
| 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. Admin seul, compte user seulement : un compte de l'équipe est d'abord rétrogradé (PATCH du rôle), sinon 403 (D-098) |
| 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.
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 |
Produit
- Vision-Produit
- Perimetre-MVP
- Personas-et-Parcours-Utilisateurs
- Ecrans
- Filtres-et-Recherche
- Orchestration
- Creation-et-Moderation
- Admin-Web
- Compte-et-Monetisation
- Direction-Artistique
Technique
- Modele-de-Donnees
- Architecture-Technique
- Site-Web
- API
- Securite-et-RGPD
- Conditions-d-utilisation
- Politique-de-confidentialite
- Mentions-legales
- Analytics
Pilotage