Couche API (HTTP)
Le frontend parle au backend RoadTrip via un client généré localement dans src/api/generated/ (client fetch embarqué + SDK + types, produits depuis le contrat OpenAPI 3.1 du backend). La couche src/api/ enveloppe ce module généré dans une API simple et stable pour le reste de l'application.
Génération du client — api.json → src/api/generated/
Le backend sert son contrat sur GET ${BACKEND_URL}/api/contract (voir contrat-api.md côté backend). Le frontend le récupère et génère son client avec @hey-api/openapi-ts (config openapi-ts.config.ts à la racine — mêmes plugins que l'ancien package : client fetch embarqué dans la sortie, aucune dépendance runtime) :
npm run api:fetch—curlle contrat versapi.json(racine).BACKEND_URLpar défauthttp://localhost:8080(backend docker compose) ; sur un environnement déployé, exigeAuthorization: Bearer $CONTRACT_ACCESS_TOKEN(jeton développeur statique, contrôle désactivé en env local) ;npm run api:generate— régénèresrc/api/generated/depuisapi.json;npm run api:update— les deux enchaînés.
api.json et la sortie générée sont versionnés : ils figent la version du contrat contre laquelle le frontend est construit (builds reproductibles, diff du contrat lisible en MR — info.version indique la version backend source). La régénération est un geste explicite du développeur — jamais en CI ni au build. Ne jamais éditer src/api/generated/ à la main.
Historique : jusqu'en juillet 2026 le client était consommé via le package npm
@spektrum/roadtrip-api-client, publié sur le registre interne à chaque tag backend. Le package est retiré ; le flux servi le remplace.
src/api/client.ts — configuration du client
Configure le client fetch singleton du module généré (effet de bord à l'import) :
baseUrlprovient deVITE_API_BASE_URL(repli/api) ;- en-tête
Accept: application/jsonpour forcer des réponses JSON (Laravel renvoie alors les erreurs de validation 422 en JSON plutôt qu'une redirection HTML).
URL de base & variables d'environnement
Le SDK généré appelle des chemins nus (/login, /user…) ; VITE_API_BASE_URL fournit le préfixe, /api compris. Les valeurs par défaut sont versionnées (.env* à la racine du frontend ; surcharges personnelles dans .env.local, gitignoré) :
| Fichier | Mode | VITE_API_BASE_URL | Rôle |
|---|---|---|---|
.env | tous | /api | défaut partagé (relatif) |
.env.development | dev | — (hérite /api) | définit VITE_DEV_API_PROXY_TARGET |
.env.production | build | https://…/api (domaine fixe) | domaine absolu de l'API (placeholder à remplir) |
- Dev —
VITE_API_BASE_URL=/apiest relatif et passe par le proxy Vite :vite.config.tsrelaie/api/*versVITE_DEV_API_PROXY_TARGET(http://localhost:8000par défaut, lephp artisan servedu backend), ce qui évite tout CORS./api+ chemin/login→http://localhost:8000/api/login. - Prod — le build est servi sur un autre domaine que l'API ;
.env.productionfixe l'URL absolue (préfixe/apicompris). Pas de proxy. - Images (dev) — le proxy Vite relaie aussi
/img/*vers l'imgproxy local « port-forwardé » (VITE_DEV_IMG_PROXY_TARGET, défauthttp://localhost:8081). Dev uniquement : en environnement déployé, ce routage relève du reverse proxy (hors de ce dépôt).
.env.example documente l'ensemble des variables.
Gère aussi le jeton d'accès (personal access token Sanctum), conservé en mémoire :
setAuthToken(token | null)/getAuthToken()— mémorise ou efface le jeton ;- un intercepteur de requête ajoute l'en-tête
Authorization: Bearer <jeton>quand un jeton est présent (attachAuthToken, exporté pour être testé isolément).
La persistance du jeton (localStorage, store) ne relève pas de cette couche.
src/main.ts importe ./api/client au démarrage pour garantir la configuration avant tout appel.
src/api/viewing-context.ts : en-tête X-Viewing-As
Le contrat déclare X-Viewing-As requis sur de nombreux endpoints : la propriété headers est non optionnelle dans les types générés, donc un appel qui l'omet ne compile pas. SUPER_ADMIN_VIEWING_AS fournit cette valeur en un seul endroit, consommé par codes.ts, organizations.ts, tours.ts et users.ts.
Ce n'est pas la valeur qui circule : attachViewingAs (./client) écrase l'en-tête avec la casquette active à chaque requête. Elle ne part réellement que si aucune casquette n'est définie (hatToViewingAs renvoie null après une déconnexion, ou au démarrage avant restauration de session). D'où super-admin plutôt qu'une chaîne vide : le backend documente qu'une valeur absente ou inconnue donne un 422. Certains endpoints (POST et DELETE /tour) typent d'ailleurs l'en-tête comme le littéral 'super-admin', et n'accepteraient rien d'autre.
La constante vit dans son propre module, et non dans client.ts : celui-ci configure le SDK au chargement, les specs le remplacent donc par un mock, et toute constante qui y résiderait deviendrait undefined dans ces tests.
src/api/auth.ts — authentification
Enveloppe les fonctions SDK (authLogin, authLogout, getUser) :
login({ email, password })— ajoute ledevice_namerequis (voir ci-dessous), renvoie{ token, user }et mémorise le jeton ;logout()— révoque le jeton côté serveur puis l'efface localement (même en cas d'échec, pour ne jamais laisser de session fantôme) ;getCurrentUser()— renvoie l'utilisateur authentifié courant.
Nom d'appareil — src/api/device-name.ts
Le contrat LoginRequest exige device_name : il nomme le jeton Sanctum émis. On vise un libellé lisible (reconnaissable dans une future liste « appareils connectés ») et unique par session, au format Web · <plateforme> · <suffixe> (p.ex. Web · Chrome on macOS · a1b2c3). getDeviceName() le génère une fois — plateforme déduite des UA Client Hints puis de navigator.userAgent, suffixe via crypto.randomUUID() — et le conserve en sessionStorage (rdt_device_name) : stable pendant la session, neuf à la suivante. Tronqué à 255 caractères (limite du contrat).
Erreurs — ApiError
Les échecs sont normalisés en ApiError (message, status, fieldErrors, code, reason). fieldErrors reprend la structure des erreurs de validation Laravel (422) : un tableau de messages par champ. code reprend le champ error du corps JSON quand le backend en émet un — p. ex. organization_not_approved (middleware ResolveViewingContext, organisation suspendue ou en attente) ou invalid_reset_token. reason reprend le champ reason de certains 422 : un code de rejet stable et lisible machine (p. ex. already_claimed sur le claim d'un code d'activation, already_offered sur le gift) sur lequel l'UI branche ses messages localisés — absent sur une simple erreur de validation de champ ou face à un backend plus ancien, où l'appelant retombe sur un message générique. Le message du serveur est conservé tel quel ; un message de repli neutre couvre les erreurs réseau.
src/api/organizations.ts — organisations
Enveloppe les fonctions SDK côté organisations. Forme légère OrganizationSummary (id, name, type, status — pending / approved / suspended) pour l'organisation par défaut ; forme enrichie OrganizationListItem (la même plus contactEmail, commission / commissionType et les comptes de codes activationCodesCount / activationCodesInStockCount) pour chaque ligne du listing :
listOrganizationsPage({ type?, page?, perPage? })— une page deGET /organizations(réservé super-admin), le filtretype(reseller/region) étant résolu par le backend — aucune re-filtration côté client, y compris des statuts. Chaque ligne (OrganizationListItem, camelCase,commissionconverti en nombre) porte les conditions commerciales et les comptes de codes, si bien queresellers.tsn'a plus besoin d'ungetOrganizationni de comptages par revendeur. Retour{ items, total, lastPage }; alimente les annuaires paginés (revendeurs, régions) ;listOrganizations({ type? })— agrège toutes les pages vialistOrganizationsPage(100 éléments par page, tout passe par le SDK). Alimente les consommateurs qui veulent l'ensemble : sélecteurs (invitation, génération de codes, tours), annuaire des prestataires et file des candidatures ;getDefaultOrganization()— renvoie l'organisation par défaut (« maison » Travelise) viaGET /organizations/default; lisible par tout utilisateur authentifié. Sert à présélectionner l'organisation dans le dialogue de génération de codes ;getOrganization(id)—GET /organization, forme complèteOrganizationDetails(description,membersen camelCase — le contact d'une candidature en est extrait — et la paire de commission,commissionconverti en nombre car les ressources l'envoient en chaîne). Sert aussi de sonde d'accès au tableau de bord (composables/useOrganizationAccess.ts) : sous casquette d'organisation, un403 organization_not_approvedsignale une organisation suspendue (voir Routage) ;createOrganization(payload)—POST /organizations: crée l'organisation avec son manager (managerEmail,managerFirstName/managerLastName, locale UI mappée vers l'enumpreferred_language). La paire de commission (commissionen nombre +commissionType) est optionnelle et voyage ensemble — omise, la base applique ses défauts ; même paire surupdateOrganization(omise = inchangée). Attention à deux écarts constatés à l'exécution (backend dev) : le prénom / nom du manager sont requis par la validation alors que le contrat les type optionnels, et l'organisation créée revient au statutpending(l'appelant doit la passer àapproveds'il la veut visible). Le backend notifie un compte actif ou envoie immédiatement l'invitation à une adresse inconnue ;setOrganizationStatus(id, "approved" | "suspended")—PATCH /organizations/{id}/status. L'approbation d'une organisationpendingdéclenche le premier email d'invitation de l'organisateur ; le retour àpendingest interdit par le contrat.
Le mapping locale UI → PreferredLanguage (fr → fr-ch, de → ge-ch, en → en-gb, repli fr-ch) vit dans src/api/preferred-language.ts, partagé avec les invitations (invitations.ts).
src/api/regions.ts (une région est une organisation de type region) ajoute à ses enveloppes fetchRegionCapacity(id, date?) (GET /organizations/{id}/capacity, super-admin, 404 hors régions) : le roll-up de capacité du jour (date optionnelle, défaut aujourd'hui Europe/Zurich). Toute capacité agrégée est une structure{ value, exactness } (exact / partial / unknown / unlimited), jamais un nombre nu ; le contrat type ces réponses exactement (structs, enum d'exactitude, saturations numériques), le mapping n'est donc qu'une projection camelCase. Retour RegionCapacity : capacité et saturation de la région plus, par tour actif, capacité, saturation (MAX des taux de remplissage des étapes datées) et unplaceableSteps (étapes sans day_number, invisibles au remplissage).
En-tête X-Viewing-As
Le contrat type l'en-tête X-Viewing-As comme requis sur les endpoints réservés (organisations, utilisateurs, codes). Les enveloppes le passent explicitement ("super-admin") pour satisfaire le typage ; à l'exécution, l'intercepteur de client.ts l'écrase de toute façon avec la casquette active (setViewingAs, synchronisé par le store de session).
src/api/codes.ts — codes d'activation
Enveloppe la liste, la génération et les statistiques des codes d'activation (réservé super-admin) :
fetchActivationCodes(params)— liste paginée et filtrée côté serveur deGET /activation-codes; renvoie{ codes, total }oùtotal(meta.total) reflète l'ensemble filtré. LesActivationCodeResourcesont projetés vers le modèle UIActivationCode(statut dérivé des drapeauxis_reimbursed/is_cancelled/is_used/is_expired/is_active/is_purchased) ;exportActivationCodesCsv(params)— export CSV de l'ensemble filtré (la pagination ne s'applique pas) via la fonction SDK typéeexportActivationCodes(GET /activation-codes/export,text/csven flux) ; renvoie unBlob(parseAs: "blob"— le contrat type le 200 enstring, d'où le cast). Consommé par le bouton « Exporter CSV » du gestionnaire de codes avec le helperdownloadBlob;generateActivationCodes(payload)/fetchActivationCodeStats()— fonctions SDK typées (storeActivationCodes,activationCodeStats) ;bulkCancelActivationCodes(ids)/bulkReimburseActivationCodes(ids)/bulkRenewActivationCodes(ids, newExpiration)— actions de cycle de vie, via la fonction SDK typéeupdateActivationCodeStatuses(PUT /activation-codes/status, transition en masse, un id ou plusieurs). Le renouvellement est une transitionstatus=purchased+new_expiration(ISO date-time, une seule date pour tous les ids) : aucun timestamp de cycle de vie n'est écrit, seulexpires_atest repoussé. La réponse est un succès partiel (HTTP 200) exposé tel quel commeBulkCodeActionResult:updated(ressources projetées vers le modèle UIActivationCode) etskipped({ id, reason }, raison du backend) ; seule une erreur transport/HTTP lève uneApiError. L'appelant substitue les codes mis à jour dans sa liste et présente les écartés (voir la section codes du tableau de bord superadmin).fetchActivationCodeBatches(params)/updateActivationCodeBatch(id, input)— lots de codes (les codes générés en un seul appel), via les fonctions SDK typéeslistActivationCodeBatches(GET /activation-codes/batches) etupdateActivationCodeBatch(PATCH /activation-codes/batches/{id}, super-admins uniquement). Le premier renvoie{ batches, total }où chaqueActivationCodeBatchResourceest projeté vers le modèle UIBatch(compteurscode_countscoercés en nombres, tous les champsstringdu contrat) ; il accepte les filtresorganisation_id(« all » écarté) etbatch_number. Le second modifie la note interne et l'expiration du lot et renvoie le lot rafraîchi (compteurs recalculés). Consommés par la vue Lots (voir la section codes du tableau de bord superadmin).
Le statut UI du modèle ActivationCode (components/superadmin/codes.ts) reprend 1:1 les sept statuts du backend et est dérivé des drapeaux de la ressource, par priorité décroissante : is_reimbursed → reimbursed (avant is_cancelled : un code remboursé porte aussi la sémantique d'annulation), is_cancelled → cancelled, is_active → active (avant is_used : used_at est posé dès l'activation, un road trip en cours a donc aussi is_used), is_used → used, is_expired → expired, is_purchased → purchased, sinon inStock. La ressource expose aussi purchased_at (→ purchasedAt), sur lequel s'appuie la garde canRenewCode.
La réémission d'un code n'existe pas au contrat.
Pourquoi le listing passe par le client générique (et pas le SDK)
Historique : le contrat ne typait pas les paramètres de requête du listing à l'époque de son écriture. Le contrat courant les type désormais — et expose l'union ActivationCodeStatus, dont ApiCodeStatus est maintenant un simple alias. Le listing passe encore par client.get({ url, query }) (l'intercepteur injecte X-Viewing-As comme partout) ; la bascule vers listAllActivationCodes typé reste à faire, en re-vérifiant la sérialisation ci-dessous. L'export CSV, plus récent, utilise déjà sa fonction SDK typée.
buildActivationCodesQuery / buildActivationCodesExportQuery — filtres UI → paramètres de requête
Helpers purs (exportés, testés isolément) qui traduisent l'état des filtres du gestionnaire de codes en paramètres du contrat backend. Ils partagent le même cœur de mapping (buildFilterQuery, privé) pour que listing et export ne divergent pas ; l'export s'en distingue par l'absence de pagination et par organisation_id[] en tableau là où le listing prend le scalaire organisation_id (différence du contrat).
- Statuts — multi-sélection : chaque statut coché est traduit 1:1 vers son statut de la vue backend
activation_codes_with_status(seule la graphie deinStockdiffère :in stocksur le fil) ; aucune case cochée → paramètre omis. (L'ancien statut UIcompleteda été supprimé : le backend ne le produit jamais.) La clé littérale"status[]"est indispensable : le sérialiseur hey-api (form/explode) n'encode pas le nom du paramètre et produitstatus[]=a&status[]=b, la seule forme que Laravel lit comme un tableau. - Expiration — les préréglages (
expired/soon/valid, seuil 30 jours) deviennent des bornesexpires_from/expires_toen ISO UTC complet (aucun élargissement fin-de-journée côté backend, codes sans expiration exclus par le<=SQL). - Organisation —
organisation_id(l'id, pas le nom) ;"all"omis. - Recherche —
search(ILIKE partiel surfull_codecôté backend), trim, omise si vide, tronquée à 100 caractères (validation backendmax:100). per_page: défaut 25, plafonné silencieusement à 100 par le backend.
src/api/version.ts — version backend (hors contrat)
getBackendVersion() lit GET /api/version — un simple contrôle de sanity (« quelle version du backend tourne ? », cf. config('app.version') côté backend). Cet endpoint ne fait pas partie du contrat OpenAPI : le SDK généré ne le couvre pas. L'appel passe donc par le client générique (client.get({ url: "/version" }), préfixé par baseUrl), pas par une fonction SDK typée. La forme de la réponse n'étant pas garantie, elle est analysée défensivement (objet { version } ou chaîne brute) ; une version manquante lève, l'appelant affichant alors un repli.
Consommé par AppSidebarFooter.vue (easter-egg de version, voir Environnements & versionnement), qui ne déclenche l'appel qu'à la révélation (chargement paresseux).
src/api/reseller-applications.ts — candidatures revendeur (projection des organisations)
Le backend modélise une candidature comme une organisation pending (le signup public POST /organizations/signup la crée ainsi). Ce module projette donc les organisations de type reseller en candidatures, sans endpoint dédié :
fetchResellerApplications(page, perPage)— liste complète vialistOrganizations({ type: "reseller" })(filtre type résolu par le backend), tri statut (pendingd'abord) puis nom, pagination côté client (la file mélange les statuts, ce que le listing paginé ne sait pas trier) ; le contact (nom complet + email) est résolu par ligne de la page viagetOrganization(N+1 borné parperPage, une ligne en échec dégrade en contact vide) — membremanagerde préférence, sinon le premier ;fetchResellerApplicationStats()— compteurs par statut sur l'ensemble (source du badge de la barre latérale) ;updateResellerApplicationStatus(id, statut, notes)— approbation →setOrganizationStatus(id, "approved")(déclenche l'email d'invitation de l'organisateur) ; rejet →"suspended". Le backend n'a pas de statutrejected:suspendedest affiché « rejeté », donc un revendeur désactivé de longue date apparaît aussi dans l'historique comme rejeté (compromis assumé, documenté dans le module) ; l'approuver le réactive. Le retour àpendingest refusé (interdit par le contrat) ;saveResellerApplicationNotes(id, notes)— les notes internes etreviewedAtn'existent pas au contrat : elles vivent dans un état local de session (perdu au rechargement). De même, type de point de vente, message et dates de soumission n'ont pas de source backend et s'affichent en tirets.
__resetResellerApplicationsLocalState() remet l'état local (tests). Le mock en mémoire (createApplicationReviewMock, src/api/application-review.ts) ne sert plus que les candidatures prestataires (partner-applications.ts), faute de type d'organisation prestataire au contrat.
src/api/images.ts : envoi d'images
uploadImage(file) enveloppe POST /images (fonction SDK storeImage, corps multipart/form-data, champ image). Le SDK généré porte déjà le formDataBodySerializer et force Content-Type: null pour laisser le navigateur poser lui-même la frontière multipart. Rien à configurer ici.
La réponse est réduite à { id, url, variants } : url est l'URL imgproxy signée de l'original (relative, /img/{signature}/plain/{uuid}.{ext}), servie par le proxy de dev Vite en local et par le nginx de l'hôte en déployé, et variants la carte de variantes signées (voir plus bas) — un aperçu est donc affichable dès la réponse du POST, sans relecture de resource. Voir la documentation backend stockage-et-images.md.
Le module exporte aussi les garde-fous qui répliquent la validation backend, pour transformer un futur 422 en message immédiat :
ACCEPTED_IMAGE_TYPES/IMAGE_ACCEPT_ATTRIBUTE: miroir demimes:jpg,jpeg,png,webp;MAX_IMAGE_SIZE_BYTES(10 485 760) : miroir demax:10240, Laravel comptant en unités de 1024 octets ;isAcceptedImageType()/isWithinImageSizeLimit().
Ces contrôles sont du confort, pas une sécurité : le backend re-vérifie en sniffant le contenu réel du fichier, un renommage ou un MIME client falsifié étant rejetés de toute façon.
Variantes dimensionnées. Les resources qui servent une image (UserResource.profile_picture, ActivityResource.image, la réponse de POST /images) répondent une carte de variantes signées{ thumb, medium, large, full } (200 / 600 / 1200 / 1920 px de large, redimensionnement imgproxy rs:fit:{largeur}:0 à la volée, plafonné à la largeur d'origine — les variantes au-delà retombent sur l'URL de l'original). users.profile_picture_id est une vraie référence vers la table images, exposée par la resource et renvoyée telle quelle par PUT /user (full-replace) ; activities.image_id fonctionne pareil sur POST /tour/activities et PUT /tour/activity/{id}.
pickImageVariant(variants, preferred = "thumb")choisit l'URL d'une taille d'affichage dans cette carte. Le frontend ne construit jamais une URL imgproxy (la signature HMAC couvre le chemin complet, options comprises) : il ne fait que choisir parmi celles reçues. Le type au contrat étant une signature d'index sans clés garanties, la fonction retombe sur la variante supérieure la plus proche (nette une fois réduite), puis les inférieures, puis n'importe quelle valeur ;nullpour une carte absente ou vide.IMAGE_VARIANTS/ImageVariantName: miroir de l'enum backendImageVariant, de la plus petite à la plus grande.
Consommé par ProfileView.vue : l'avatar (80 px CSS) rend la variante thumb (nette en 2×). Le fichier choisi n'est qu'un aperçu local (URL.createObjectURL) jusqu'au clic sur « Enregistrer », qui enchaîne uploadImage() puis updateProfile() avec l'id obtenu (profile_picture_id). L'aperçu local reste affiché jusqu'à la réponse du PUT, qui fournit la carte de variantes fraîche — un envoi réussi suivi d'un PUT en échec conserve donc l'aperçu, et le nouvel essai renvoie l'id déjà stocké sans re-téléverser. Un envoi en échec annule toute la sauvegarde et conserve le fichier sélectionné pour un nouvel essai.
src/api/tours.ts : itinéraires
fetchTours(filters), createTour(payload) et deleteTour(id) enveloppent GET /tour, POST /tour et DELETE /tour/{id}. Ces endpoints exigent l'en-tête X-Viewing-As ; la valeur passée ici est écrasée par l'intercepteur avec la casquette active (même motif que organizations.ts). Ils sont en outre réservés aux super-admins (StoreTourRequest::authorize() compare le contexte de visualisation à ViewingAs::SuperAdmin).
createTour envoie les champs acceptés par StoreTourRequest :
| Champ | Règle backend |
|---|---|
name | required, string, max:255 |
color | required, regex:/^#[0-9A-Fa-f]{6}$/ |
partner_organization_id | required, uuid, exists:organizations,id |
region_organization_id | nullable, uuid (organisation de type region) |
Le formulaire s'aligne : nom et partenaire sont obligatoires (bouton désactivé sinon), la région est optionnelle (null = non liée) et la palette n'expose que des hexadécimaux. status n'est pas accepté à la création : le tour naît pending et le statut se déplace ensuite via PUT /tour/{id}, ce que le formulaire annonce par superadmin.tours.createdPending plutôt que d'offrir un sélecteur trompeur. Un rejet (422) laisse le formulaire ouvert avec ses valeurs et affiche le message du backend.
La réassignation de région d'une ligne passe par updateTour(id, patch) (PUT /tour/{id}, champs sometimes) : regionOrganizationId absent du patch laisse le lien intact, null le rompt. duplicateTour reporte le lien sur la copie.
Le filtrage et la pagination sont côté serveur : search (correspondance partielle insensible à la casse sur le nom), status[], partner_organization_id, page, per_page — aucun filtre par région côté backend. Les clés absentes ne sont pas envoyées, une liste de statuts vide est omise. La réponse est paginée ({ data, meta }) et projetée en TourPage (tours, total, page, lastPage).
Limites relevées en interrogeant le backend local :
GET /tourne charge pas la relationsteps; seulGET /tour/{id}la renvoie. LestepCountdu modèle d'affichage vaut donc toujours 0 dans la liste. Il faudra un compteur côtéTourResource, ou un appel par tour, ce qui n'est pas raisonnable sur une liste.- Le filtre « Non attribué » n'a pas d'équivalent serveur (le backend filtre sur un id de partenaire) : ce cas seul est réduit localement sur la liste complète.
travellers reste à zéro : aucune colonne backend.
src/api/tour-steps.ts, providers.ts et activities.ts : étapes et lien prestataire
Trois modules coopèrent pour l'éditeur d'étapes (voir le tableau de bord super-admin) :
tour-steps.ts: CRUD des étapes +reorderSteps. Les heures sont normalisées enHH:MMdans les deux sens (le backend renvoieH:i:sen lecture mais refuse ce format en écriture). Chaque plan d'une étape est matérialisé par une activité : l'ordre deactivitiesest l'ordre des plans, le premier étant le plan A.createStep/updateStepacceptent unactivityIds?: string[]optionnel qui remplace en bloc cette liste dans le même appel ; clé omise = liens intacts,[]= liste vidée (les trois sémantiques ont été vérifiées contre le backend local). Lenameenvoyé est celui du plan A (plans[0]du payload) : l'étape n'a pas de nom propre côté formulaire. Au mapping, les activités imbriquées sont triées par leurpriority(« 1 = plan A ») quand le backend la fournit, plutôt que de se fier à l'ordre du tableau. Les réponses d'étapes portent leprovider_idde chaque activité mais paseffective_provider: nom et position du prestataire restentnullau mapping et sont enrichis en aval. L'image de l'activité (image_id+ carte de variantesimage), elle, arrive directement dans la réponse et est mappée telle quelle, comme la paire capacité (capacity, voyageurs par jour de départ,null= inconnue) /unlimited(l'activité ne sature jamais).providers.ts: annuaire géolocalisé.searchProviderstransmetlatitude/longitudequand elles sont fournies — le backend classe alors par proximité et chaque résultat portedistance_meters(mètres, projeté endistanceMeters,nullsans coordonnées).createProvider(POST /tour/provider) sert la création à la volée pendant la planification : le backend estampilleorigin: on_the_flyetstatus: draft.activities.ts:createActivity(providerId, name, imageId?, tagIds?, capacity?, unlimited?)(POST /tour/activity) — la pièce qui matérialise le lien Étape → Activité → Prestataire — plusupdateActivityImage(id, imageId),updateActivityTags(id, tagIds)etupdateActivityCapacity(id, capacity, unlimited)(PUT /tour/activity/{id},nullpour détacher l'image). Les activités sont des lignes partagées (la duplication de tour rattache les mêmes ids) : on n'en modifie jamais une existante à trois exceptions près — son image, ses tags et sa capacité, données propres à l'activité et hors de la sémantique du lien ; le contrat n'offre aucune suppression. Côté API,tagsremplace l'ensemble en bloc ([]vide tout) : l'appelant re-fusionne ce qu'il veut conserver, notamment les tagsthemeque le formulaire d'étape n'affiche pas. La capacité etunlimitedsont mutuellement exclusifs (422 sinon) : le patch envoie toujours les deux champs ensemble — passer une activité chiffrée en illimitée exige{capacity: null, unlimited: true}dans le même payload.activity-closures.ts: fermetures d'une activité (super-admin) — une fermeture est une plage de dates fermées (bornesY-m-dincluses, capacité effective à zéro).fetchActivityClosures(activityId)(GET /tour/activity/{activityId}/closures, tableau nu trié par début),createActivityClosure(activityId, { startsOn, endsOn, reason? })(201,ends_on ≥ starts_oncontrôlé par le backend) etdeleteActivityClosure(activityId, closureId)(204). CRUD immédiat, consommé parActivityClosuresField.vuedans le formulaire d'étape.
src/api/tags.ts : le registre d'étiquettes
Lecture seule du registre (GET /tags) qui alimente les autocomplétions du formulaire d'étape ; le CRUD super-admin du contrat (POST/PUT/DELETE /tags) est consommé par le module colocalisésrc/components/superadmin/tags-api.ts (section « Gestion des tags », voir le tableau de bord super-admin), qui importe le client généré, toApiError et SUPER_ADMIN_VIEWING_AS sans rien modifier sous src/api/. Quatre vocabulaires (TagType) : thing_to_bring, restriction, suitability et theme — ce dernier sans champ dans le formulaire.
fetchAllTags()récupère tout le registre en une page généreuse (per_page: 1000, même logique que les codes d'activation) sans filtre de type — un seul aller-retour pour les trois sélecteurs, qui filtrent côté client. Les pages restantes sont tout de même agrégées si le backend borneper_page.- Le type domaine
Tagporte les libellés en enregistrementnames: { fr, en, de }— le français est canonique, seul non-nul — ettagName(tag, locale)résout le libellé avec repli surfr. Les libellés viennent du backend, jamais des fichiers de locales. icon(slug Lucide) est mappé ; le rendu de slugs arbitraires passe par le catalogue paresseuxsrc/lib/lucide-catalog.ts(chunk asynchrone chargé viaimport()dynamique par la gestion des tags), jamais par un import statique de la carte d'icônes entière, qui casserait le tree-shaking du bundle principal.
Tests
Les specs (*.spec.ts) mockent le module généré (vi.mock("./generated")) pour isoler la logique d'enveloppe (corps de requête, mémorisation du jeton, normalisation des erreurs) sans serveur réel. client.spec.ts importe le vrai client pour tester l'intercepteur et la sérialisation des paramètres de requête.

