Skip to content

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.jsonsrc/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:fetchcurl le contrat vers api.json (racine). BACKEND_URL par défaut http://localhost:8080 (backend docker compose) ; sur un environnement déployé, exige Authorization: Bearer $CONTRACT_ACCESS_TOKEN (jeton développeur statique, contrôle désactivé en env local) ;
  • npm run api:generate — régénère src/api/generated/ depuis api.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) :

  • baseUrl provient de VITE_API_BASE_URL (repli /api) ;
  • en-tête Accept: application/json pour 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é) :

FichierModeVITE_API_BASE_URLRôle
.envtous/apidéfaut partagé (relatif)
.env.developmentdev— (hérite /api)définit VITE_DEV_API_PROXY_TARGET
.env.productionbuildhttps://…/api (domaine fixe)domaine absolu de l'API (placeholder à remplir)
  • DevVITE_API_BASE_URL=/api est relatif et passe par le proxy Vite : vite.config.ts relaie /api/* vers VITE_DEV_API_PROXY_TARGET (http://localhost:8000 par défaut, le php artisan serve du backend), ce qui évite tout CORS. /api + chemin /loginhttp://localhost:8000/api/login.
  • Prod — le build est servi sur un autre domaine que l'API ; .env.production fixe l'URL absolue (préfixe /api compris). Pas de proxy.
  • Images (dev) — le proxy Vite relaie aussi /img/* vers l'imgproxy local « port-forwardé » (VITE_DEV_IMG_PROXY_TARGET, défaut http://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 le device_name requis (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, statuspending / 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 de GET /organizations (réservé super-admin), le filtre type (reseller / region) étant résolu par le backend — aucune re-filtration côté client, y compris des statuts. Chaque ligne (OrganizationListItem, camelCase, commission converti en nombre) porte les conditions commerciales et les comptes de codes, si bien que resellers.ts n'a plus besoin d'un getOrganization ni de comptages par revendeur. Retour { items, total, lastPage } ; alimente les annuaires paginés (revendeurs, régions) ;
  • listOrganizations({ type? }) — agrège toutes les pages via listOrganizationsPage (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) via GET /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ète OrganizationDetails (description, members en camelCase — le contact d'une candidature en est extrait — et la paire de commission, commission converti 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, un 403 organization_not_approved signale 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'enum preferred_language). La paire de commission (commission en nombre + commissionType) est optionnelle et voyage ensemble — omise, la base applique ses défauts ; même paire sur updateOrganization (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 statut pending (l'appelant doit la passer à approved s'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 organisation pending déclenche le premier email d'invitation de l'organisateur ; le retour à pending est 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 de GET /activation-codes ; renvoie { codes, total }total (meta.total) reflète l'ensemble filtré. Les ActivationCodeResource sont projetés vers le modèle UI ActivationCode (statut dérivé des drapeaux is_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ée exportActivationCodes (GET /activation-codes/export, text/csv en flux) ; renvoie un Blob (parseAs: "blob" — le contrat type le 200 en string, d'où le cast). Consommé par le bouton « Exporter CSV » du gestionnaire de codes avec le helper downloadBlob ;

  • 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ée updateActivationCodeStatuses (PUT /activation-codes/status, transition en masse, un id ou plusieurs). Le renouvellement est une transition status=purchased + new_expiration (ISO date-time, une seule date pour tous les ids) : aucun timestamp de cycle de vie n'est écrit, seul expires_at est repoussé. La réponse est un succès partiel (HTTP 200) exposé tel quel comme BulkCodeActionResult : updated (ressources projetées vers le modèle UI ActivationCode) et skipped ({ id, reason }, raison du backend) ; seule une erreur transport/HTTP lève une ApiError. 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ées listActivationCodeBatches (GET /activation-codes/batches) et updateActivationCodeBatch (PATCH /activation-codes/batches/{id}, super-admins uniquement). Le premier renvoie { batches, total } où chaque ActivationCodeBatchResource est projeté vers le modèle UI Batch (compteurs code_counts coercés en nombres, tous les champs string du contrat) ; il accepte les filtres organisation_id (« all » écarté) et batch_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_reimbursedreimbursed (avant is_cancelled : un code remboursé porte aussi la sémantique d'annulation), is_cancelledcancelled, is_activeactive (avant is_used : used_at est posé dès l'activation, un road trip en cours a donc aussi is_used), is_usedused, is_expiredexpired, is_purchasedpurchased, 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 de inStock diffère : in stock sur le fil) ; aucune case cochée → paramètre omis. (L'ancien statut UI completed a é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 produit status[]=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 bornes expires_from / expires_to en ISO UTC complet (aucun élargissement fin-de-journée côté backend, codes sans expiration exclus par le <= SQL).
  • Organisationorganisation_id (l'id, pas le nom) ; "all" omis.
  • Recherchesearch (ILIKE partiel sur full_code côté backend), trim, omise si vide, tronquée à 100 caractères (validation backend max: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 via listOrganizations({ type: "reseller" }) (filtre type résolu par le backend), tri statut (pending d'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 via getOrganization (N+1 borné par perPage, une ligne en échec dégrade en contact vide) — membre manager de 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 statut rejected : suspended est 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 à pending est refusé (interdit par le contrat) ;
  • saveResellerApplicationNotes(id, notes) — les notes internes et reviewedAt n'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 de mimes:jpg,jpeg,png,webp ;
  • MAX_IMAGE_SIZE_BYTES (10 485 760) : miroir de max: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 ; null pour une carte absente ou vide.
  • IMAGE_VARIANTS / ImageVariantName : miroir de l'enum backend ImageVariant, 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 :

ChampRègle backend
namerequired, string, max:255
colorrequired, regex:/^#[0-9A-Fa-f]{6}$/
partner_organization_idrequired, uuid, exists:organizations,id
region_organization_idnullable, 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 /tour ne charge pas la relation steps ; seul GET /tour/{id} la renvoie. Le stepCount du 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 en HH:MM dans les deux sens (le backend renvoie H:i:s en lecture mais refuse ce format en écriture). Chaque plan d'une étape est matérialisé par une activité : l'ordre de activities est l'ordre des plans, le premier étant le plan A. createStep / updateStep acceptent un activityIds?: 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). Le name envoyé 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 leur priority (« 1 = plan A ») quand le backend la fournit, plutôt que de se fier à l'ordre du tableau. Les réponses d'étapes portent le provider_id de chaque activité mais pas effective_provider : nom et position du prestataire restent null au mapping et sont enrichis en aval. L'image de l'activité (image_id + carte de variantes image), 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é. searchProviders transmet latitude/longitude quand elles sont fournies — le backend classe alors par proximité et chaque résultat porte distance_meters (mètres, projeté en distanceMeters, null sans coordonnées). createProvider (POST /tour/provider) sert la création à la volée pendant la planification : le backend estampille origin: on_the_fly et status: draft.
  • activities.ts : createActivity(providerId, name, imageId?, tagIds?, capacity?, unlimited?) (POST /tour/activity) — la pièce qui matérialise le lien Étape → Activité → Prestataire — plus updateActivityImage(id, imageId), updateActivityTags(id, tagIds) et updateActivityCapacity(id, capacity, unlimited) (PUT /tour/activity/{id}, null pour 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 tags theme que le formulaire d'étape n'affiche pas. La capacité et unlimited sont 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 (bornes Y-m-d incluses, 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_on contrôlé par le backend) et deleteActivityClosure(activityId, closureId) (204). CRUD immédiat, consommé par ActivityClosuresField.vue dans 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 borne per_page.
  • Le type domaine Tag porte les libellés en enregistrement names: { fr, en, de }le français est canonique, seul non-nul — et tagName(tag, locale) résout le libellé avec repli sur fr. 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 paresseux src/lib/lucide-catalog.ts (chunk asynchrone chargé via import() 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.

Contributors

No contributors

Changelog

No recent changes