From 800c6259768faa410a88d8fbeb821b4f8385b0f3 Mon Sep 17 00:00:00 2001 From: sowibrr Date: Sat, 14 Nov 2026 18:15:05 +0000 Subject: [PATCH] feat: contrat des missions freelance, types et maquettes (#175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le contrat missions.yaml : publier une mission (seule ou depuis une demande publiée), proposer, retenir, devis par jalons, démarrer, livrer, accepter (automatiquement après 72 h), déclarer un paiement, prolonger, annuler, trancher un désaccord, laisser un avis. Les types du front sont générés et les maquettes MSW couvrent la liste, la création, la publication et les propositions. Outils d'IA : Claude Code (Claude Opus 5.5). Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 2 +- apps/web/src/api/generated/missions.ts | 1476 ++++++++++++++++++++++++ apps/web/src/mocks/handlers.ts | 2 + apps/web/src/mocks/missions.test.ts | 55 + apps/web/src/mocks/missions.ts | 160 +++ contracts/missions.yaml | 929 +++++++++++++++ 6 files changed, 2623 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/api/generated/missions.ts create mode 100644 apps/web/src/mocks/missions.test.ts create mode 100644 apps/web/src/mocks/missions.ts create mode 100644 contracts/missions.yaml diff --git a/apps/web/package.json b/apps/web/package.json index c7d7295..6c6b9e3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,7 +14,7 @@ "preview": "vite preview", "test": "vitest run", "typecheck": "vue-tsc --noEmit", - "api:types": "openapi-typescript ../../contracts/socle.yaml -o src/api/generated/socle.ts && openapi-typescript ../../contracts/systalink.yaml -o src/api/generated/systalink.ts && openapi-typescript ../../contracts/vitrine.yaml -o src/api/generated/vitrine.ts && openapi-typescript ../../contracts/communautes.yaml -o src/api/generated/communautes.ts && openapi-typescript ../../contracts/profils.yaml -o src/api/generated/profils.ts && openapi-typescript ../../contracts/projets.yaml -o src/api/generated/projets.ts && openapi-typescript ../../contracts/cercles.yaml -o src/api/generated/cercles.ts && openapi-typescript ../../contracts/reseau.yaml -o src/api/generated/reseau.ts && openapi-typescript ../../contracts/demandes.yaml -o src/api/generated/demandes.ts", + "api:types": "openapi-typescript ../../contracts/socle.yaml -o src/api/generated/socle.ts && openapi-typescript ../../contracts/systalink.yaml -o src/api/generated/systalink.ts && openapi-typescript ../../contracts/vitrine.yaml -o src/api/generated/vitrine.ts && openapi-typescript ../../contracts/communautes.yaml -o src/api/generated/communautes.ts && openapi-typescript ../../contracts/profils.yaml -o src/api/generated/profils.ts && openapi-typescript ../../contracts/projets.yaml -o src/api/generated/projets.ts && openapi-typescript ../../contracts/cercles.yaml -o src/api/generated/cercles.ts && openapi-typescript ../../contracts/reseau.yaml -o src/api/generated/reseau.ts && openapi-typescript ../../contracts/demandes.yaml -o src/api/generated/demandes.ts && openapi-typescript ../../contracts/missions.yaml -o src/api/generated/missions.ts", "msw:init": "msw init public --save", "tokens": "node scripts/tokens-css.mjs" }, diff --git a/apps/web/src/api/generated/missions.ts b/apps/web/src/api/generated/missions.ts new file mode 100644 index 0000000..87c1fe2 --- /dev/null +++ b/apps/web/src/api/generated/missions.ts @@ -0,0 +1,1476 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/missions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Les missions publiques ouvertes aux propositions, ou les miennes + * @description `vue=publiques` (par défaut, sans connexion) : les missions `publique` à l'état `publiee`, la plus récente d'abord. + * `vue=miennes` (connexion requise) : celles dont je suis le client ou le développeur retenu, tous états confondus, + * et celles où je suis invité. + */ + get: operations["listerMissions"]; + put?: never; + /** + * Créer une mission (brouillon, invisible des autres) + * @description Avec `demandeId`, la mission reprend une demande **publiée** dont je suis l'auteur (`409 demande_non_publiee` + * sinon, `404` si elle n'est pas à moi). Les champs envoyés remplacent ceux de la demande. + */ + post: operations["creerMission"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + /** Une mission (le détail du travail seulement pour le client et le développeur retenu) */ + get: operations["lireMission"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Modifier l'annonce (client, à l'état brouillon ou publiee) */ + patch: operations["modifierMission"]; + trace?: never; + }; + "/api/missions/{missionId}/publier": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** Publier (client) — brouillon → publiee */ + post: operations["publierMission"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/fermer": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Fermer sans choisir personne (client) — publiee → fermee + * @description Les propositions en cours passent `non_retenue` et leurs auteurs sont prévenus. + */ + post: operations["fermerMission"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/invitations": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Inviter un développeur à proposer (client) + * @description Possible en `brouillon` ou `publiee`. Inviter deux fois la même personne ne change rien (`200`). + */ + post: operations["inviterDeveloppeur"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/propositions": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + /** Les propositions (toutes pour le client, la sienne seulement pour un développeur) */ + get: operations["listerPropositions"]; + put?: never; + /** Envoyer une proposition (développeur, mission publiee) */ + post: operations["proposer"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/propositions/{propositionId}/retenir": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + propositionId: components["parameters"]["PropositionId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Retenir une proposition (client) — son auteur peut envoyer un devis + * @description Une seule proposition retenue à la fois. En retenir une autre remet la précédente `envoyee`. + */ + post: operations["retenirProposition"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/propositions/{propositionId}/retirer": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + propositionId: components["parameters"]["PropositionId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** Retirer sa proposition (développeur, tant que la mission n'est pas attribuée) */ + post: operations["retirerProposition"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/devis": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Envoyer le devis (développeur dont la proposition est retenue) + * @description Un nouveau devis remplace le précédent tant qu'il n'est pas accepté. La somme des jalons doit être égale à + * `total` (`409 somme_jalons_invalide`). Tous les montants sont dans la même devise. + */ + post: operations["envoyerDevis"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/devis/{devisId}/accepter": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + devisId: components["parameters"]["DevisId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Accepter le devis (client) — publiee → attribuee + * @description Le devis vaut contrat. Les autres propositions passent `non_retenue` et leurs auteurs sont prévenus. + */ + post: operations["accepterDevis"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/demarrer": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Donner les informations de départ (client) — attribuee → en_cours + * @description Les échéances des jalons sont calculées à partir de ce moment (`delaiJours` du devis). + */ + post: operations["demarrerMission"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/jalons/{jalonId}/livrer": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + jalonId: components["parameters"]["JalonId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** Livrer un jalon (développeur) — en_cours → livre */ + post: operations["livrerJalon"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/jalons/{jalonId}/accepter": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + jalonId: components["parameters"]["JalonId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Accepter un jalon livré (client) — livre → accepte + * @description Le dernier jalon accepté fait passer la mission `terminee`. + */ + post: operations["accepterJalon"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/jalons/{jalonId}/demander-retouche": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + jalonId: components["parameters"]["JalonId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Demander une retouche (client) — livre → en_cours + * @description Dans la limite de `retouchesComprises` du devis, pour toute la mission (`409 retouches_epuisees`). + */ + post: operations["demanderRetouche"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/jalons/{jalonId}/declarer-paiement": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + jalonId: components["parameters"]["JalonId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Déclarer un paiement fait hors de Jamii (le client « paye », puis le développeur « recu ») + * @description `recu` avant `paye` : `409 etat_invalide`. Possible dès que le jalon est livré. + */ + post: operations["declarerPaiement"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/prolongations": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Demander plus de temps pour le jalon en cours (développeur) + * @description Une seule demande en attente à la fois (`409 etat_invalide`). + */ + post: operations["demanderProlongation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/prolongations/{prolongationId}/repondre": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + prolongationId: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** Accepter ou refuser une demande de prolongation (client) */ + post: operations["repondreProlongation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/annuler": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Demander ou confirmer l'annulation (client ou développeur retenu) + * @description - Si l'autre partie a déjà demandé l'annulation : la mission passe `annulee` (commun accord). + * - Sinon, **seul** et tout de suite, dans deux cas : le développeur, si les informations de départ n'ont pas été + * données 7 jours après l'attribution ; le client, si un jalon a plus de 7 jours de retard sur son échéance. + * - Sinon, la demande est enregistrée (`annulationDemandeePar`) et l'autre partie est prévenue. + * Après `livre` sur un jalon contesté, il faut passer par un désaccord (`409 annulation_non_permise`). + */ + post: operations["annulerMission"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/desaccord": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Ouvrir un désaccord (client ou développeur retenu) — en_cours → desaccord + * @description Les livraisons, retouches et acceptations automatiques sont gelées jusqu'à la décision. + */ + post: operations["ouvrirDesaccord"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/desaccord/trancher": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** Trancher un désaccord (modérateur) — desaccord → terminee ou annulee */ + post: operations["trancherDesaccord"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/missions/{missionId}/avis": { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** Laisser un avis sur l'autre partie (une fois, mission terminee) */ + post: operations["donnerAvis"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Montant: { + /** + * Format: int64 + * @description Unité mineure (en francs pour XOF et XAF). + */ + centimes: number; + /** @description ISO 4217. */ + devise: string; + }; + NouvelleMission: { + /** + * Format: uuid + * @description Ma demande publiée dont part la mission (`demandes.yaml`). + */ + demandeId?: string | null; + titre: string; + /** @description Le besoin ou le cahier des charges. Texte brut. */ + besoin: string; + competences: components["schemas"]["Competence"][]; + /** @description `null` : « à discuter ». */ + budget?: components["schemas"]["Montant"] | null; + /** Format: date */ + dateSouhaitee?: string | null; + /** @enum {string} */ + visibilite: "publique" | "invitation"; + ville?: string | null; + pays?: components["schemas"]["Pays"] | null; + /** @default true */ + aDistance: boolean; + }; + /** @description JSON Merge Patch. Seuls les champs envoyés changent. */ + ModificationMission: { + version: number; + titre?: string; + besoin?: string; + competences?: components["schemas"]["Competence"][]; + budget?: components["schemas"]["Montant"] | null; + /** Format: date */ + dateSouhaitee?: string | null; + /** @enum {string} */ + visibilite?: "publique" | "invitation"; + ville?: string | null; + pays?: components["schemas"]["Pays"] | null; + aDistance?: boolean; + }; + /** @enum {string} */ + StatutMission: "brouillon" | "publiee" | "fermee" | "attribuee" | "en_cours" | "terminee" | "annulee" | "desaccord"; + MissionResume: { + /** Format: uuid */ + id: string; + titre: string; + statut: components["schemas"]["StatutMission"]; + competences: components["schemas"]["Competence"][]; + budget: components["schemas"]["Montant"] | null; + ville?: string | null; + pays?: string | null; + aDistance?: boolean; + client: components["schemas"]["PersonneResume"]; + nombrePropositions: number; + /** Format: date-time */ + publieeLe: string | null; + }; + Mission: components["schemas"]["MissionResume"] & { + /** Format: uuid */ + demandeId?: string | null; + /** @description Texte brut, affiché échappé. */ + besoin: string; + /** Format: date */ + dateSouhaitee?: string | null; + /** @enum {string} */ + visibilite: "publique" | "invitation"; + version: number; + /** Format: date-time */ + creeLe: string; + /** @description Ce que la personne qui lit peut faire. Le front s'en sert pour afficher les boutons ; l'API revérifie tout. */ + moi: { + /** @enum {string} */ + role: "client" | "developpeur_retenu" | "invite" | "candidat" | "visiteur" | "moderateur"; + actions: ("modifier" | "publier" | "fermer" | "inviter" | "proposer" | "retenir" | "envoyer_devis" | "accepter_devis" | "demarrer" | "livrer" | "accepter_jalon" | "demander_retouche" | "declarer_paye" | "declarer_recu" | "demander_prolongation" | "repondre_prolongation" | "annuler" | "ouvrir_desaccord" | "trancher" | "donner_avis")[]; + }; + /** @description Seulement pour le client, le développeur retenu et un modérateur. Absent sinon. */ + travail?: components["schemas"]["Travail"] | null; + }; + Travail: { + developpeur: components["schemas"]["PersonneResume"]; + devis: components["schemas"]["Devis"]; + informationsDepart: string | null; + /** Format: date-time */ + demarreLe: string | null; + retouchesRestantes: number; + prolongationEnAttente?: components["schemas"]["Prolongation"] | null; + /** @enum {string|null} */ + annulationDemandeePar: "client" | "developpeur" | null; + desaccord: components["schemas"]["Desaccord"] | null; + avis?: components["schemas"]["Avis"][]; + }; + NouvelleProposition: { + /** @description Comment je m'y prendrais, ce que j'ai déjà fait de proche. */ + message: string; + prix: components["schemas"]["Montant"]; + delaiJours: number; + /** @description Réponses aux questions du client, dans l'ordre. */ + reponses?: string[]; + }; + Proposition: { + /** Format: uuid */ + id: string; + developpeur: components["schemas"]["PersonneResume"]; + message: string; + prix: components["schemas"]["Montant"]; + delaiJours: number; + reponses?: string[]; + /** @enum {string} */ + statut: "envoyee" | "retenue" | "acceptee" | "non_retenue" | "retiree"; + /** Format: date-time */ + envoyeeLe: string; + }; + PageMissions: components["schemas"]["Page"] & { + elements?: unknown; + }; + PagePropositions: components["schemas"]["Page"] & { + elements?: unknown; + }; + NouveauDevis: { + total: components["schemas"]["Montant"]; + retouchesComprises: number; + /** @description Ce qui est compris, ce qui ne l'est pas. */ + conditions?: string | null; + jalons: { + titre: string; + description?: string | null; + montant: components["schemas"]["Montant"]; + /** @description Compté depuis le démarrage de la mission. */ + delaiJours: number; + }[]; + }; + Devis: { + /** Format: uuid */ + id: string; + total: components["schemas"]["Montant"]; + retouchesComprises: number; + conditions?: string | null; + jalons: components["schemas"]["Jalon"][]; + /** Format: date-time */ + envoyeLe: string; + /** Format: date-time */ + accepteLe: string | null; + }; + Jalon: { + /** Format: uuid */ + id: string; + ordre: number; + titre: string; + description?: string | null; + montant: components["schemas"]["Montant"]; + delaiJours: number; + /** + * Format: date-time + * @description `null` tant que la mission n'a pas démarré. + */ + echeance: string | null; + /** @enum {string} */ + statut: "a_faire" | "en_cours" | "livre" | "accepte"; + livraisons: components["schemas"]["Livraison"][]; + /** + * Format: date-time + * @description Si livré : quand il sera accepté sans réponse du client (72 h après la livraison). + */ + acceptationAutomatiqueLe?: string | null; + /** Format: date-time */ + accepteLe: string | null; + acceptationAutomatique: boolean; + /** @enum {string} */ + paiement: "non_paye" | "declare_paye" | "declare_recu"; + }; + NouvelleLivraison: { + version: number; + message: string; + /** @description Lien de test, dépôt, fichiers. `https` uniquement. */ + liens?: string[]; + }; + Livraison: { + message: string; + liens: string[]; + /** Format: date-time */ + livreeLe: string; + /** @description La raison de la retouche demandée après cette livraison, s'il y en a une. */ + retouche?: string | null; + }; + Prolongation: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + jalonId: string; + jours: number; + raison: string; + /** Format: date-time */ + demandeeLe: string; + }; + Desaccord: { + /** @enum {string} */ + ouvertPar: "client" | "developpeur"; + raison: string; + /** Format: date-time */ + ouvertLe: string; + /** @enum {string|null} */ + issue: "terminee" | "annulee" | null; + explication: string | null; + /** Format: date-time */ + trancheLe: string | null; + }; + Avis: { + /** @enum {string} */ + auteur: "client" | "developpeur"; + note: number; + texte?: string | null; + /** Format: date-time */ + donneLe: string; + }; + /** @description En minuscules, sans espace inutile (`flutter`, `postgresql`, `ux-design`). */ + Competence: string; + /** @description Code ISO 3166-1 alpha-2 (`SN`, `CI`, `KE`). */ + Pays: string; + /** + * @description Base de toute liste paginée. Un contrat l'étend ainsi : + * `allOf: [{ $ref: './socle.yaml#/components/schemas/Page' }, { properties: { elements: { items: { $ref: … } } } }]` + */ + Page: { + elements: unknown[]; + /** @description `null` à la dernière page. */ + curseurSuivant: string | null; + }; + /** @description Ce que le projet montre d'une personne. Jamais son e-mail. */ + PersonneResume: { + /** Format: uuid */ + id: string; + nom: string; + /** Format: uri */ + avatarUrl?: string | null; + }; + ErreurChamp: { + /** @description Chemin du champ, par exemple `roles[0].titre`. */ + champ: string; + /** + * @example obligatoire + * @example trop_court + * @example format_invalide + */ + code: string; + }; + /** @description Problem Details (RFC 9457). Le front se base sur `code`, jamais sur `title`. */ + Erreur: { + /** + * Format: uri + * @example urn:jamii:erreur:validation + */ + type: string; + /** @description Résumé lisible, pour le débogage. */ + title: string; + status: number; + detail?: string; + /** @description Chemin de la requête. */ + instance?: string; + /** + * @example validation + * @example introuvable + */ + code: string; + /** @description Identifiant à retrouver dans les journaux. */ + traceId?: string; + erreurs?: components["schemas"]["ErreurChamp"][]; + }; + }; + responses: { + /** @description La mission à jour. */ + MissionAJour: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Mission"]; + }; + }; + /** @description Pas connecté ou session expirée (`code` = `non_connecte`). */ + NonConnecte: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Erreur"]; + }; + }; + /** @description Entrée invalide (`code` = `validation`, détail dans `erreurs`). */ + Invalide: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Erreur"]; + }; + }; + /** @description Ressource inexistante ou que l'utilisateur n'a pas le droit de voir (`code` = `introuvable`). */ + Introuvable: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Erreur"]; + }; + }; + /** @description Doublon, état incompatible ou `modification_concurrente`. */ + Conflit: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Erreur"]; + }; + }; + /** @description Connecté mais sans le droit nécessaire, ou jeton CSRF absent (`code` = `interdit`). */ + Interdit: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Erreur"]; + }; + }; + /** @description Limite atteinte (`code` = `trop_de_requetes`). */ + TropDeRequetes: { + headers: { + "Retry-After": components["headers"]["RetryAfter"]; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Erreur"]; + }; + }; + }; + parameters: { + MissionId: string; + PropositionId: string; + DevisId: string; + JalonId: string; + /** @description Valeur opaque reçue dans `curseurSuivant`. Absent pour la première page. */ + Curseur: string; + Limite: number; + }; + requestBodies: { + Version: { + content: { + "application/json": { + /** @description La `version` de la mission lue juste avant. */ + version: number; + }; + }; + }; + }; + headers: { + /** @description Nombre de secondes à attendre avant de réessayer. */ + RetryAfter: number; + }; + pathItems: never; +} +export type $defs = Record; +export interface operations { + listerMissions: { + parameters: { + query?: { + /** @description Valeur opaque reçue dans `curseurSuivant`. Absent pour la première page. */ + curseur?: components["parameters"]["Curseur"]; + limite?: components["parameters"]["Limite"]; + vue?: "publiques" | "miennes"; + competence?: components["schemas"]["Competence"]; + pays?: components["schemas"]["Pays"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Une page de missions. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageMissions"]; + }; + }; + 401: components["responses"]["NonConnecte"]; + }; + }; + creerMission: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NouvelleMission"]; + }; + }; + responses: { + /** @description Le brouillon. */ + 201: { + headers: { + Location?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Mission"]; + }; + }; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 404: components["responses"]["Introuvable"]; + /** @description `demande_non_publiee`. */ + 409: components["responses"]["Conflit"]; + }; + }; + lireMission: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description La mission. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Mission"]; + }; + }; + 404: components["responses"]["Introuvable"]; + }; + }; + modifierMission: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/merge-patch+json": components["schemas"]["ModificationMission"]; + }; + }; + responses: { + /** @description La mission à jour. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Mission"]; + }; + }; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + /** @description `etat_invalide`, `modification_concurrente`. */ + 409: components["responses"]["Conflit"]; + }; + }; + publierMission: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + requestBody: components["requestBodies"]["Version"]; + responses: { + 200: components["responses"]["MissionAJour"]; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + 409: components["responses"]["Conflit"]; + }; + }; + fermerMission: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + requestBody: components["requestBodies"]["Version"]; + responses: { + 200: components["responses"]["MissionAJour"]; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + 409: components["responses"]["Conflit"]; + }; + }; + inviterDeveloppeur: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: uuid */ + utilisateurId: string; + }; + }; + }; + responses: { + /** @description Invitation enregistrée (ou déjà présente). La personne est prévenue. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + /** @description `etat_invalide`, `propre_mission`. */ + 409: components["responses"]["Conflit"]; + }; + }; + listerPropositions: { + parameters: { + query?: { + /** @description Valeur opaque reçue dans `curseurSuivant`. Absent pour la première page. */ + curseur?: components["parameters"]["Curseur"]; + limite?: components["parameters"]["Limite"]; + }; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Une page de propositions. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PagePropositions"]; + }; + }; + 401: components["responses"]["NonConnecte"]; + 404: components["responses"]["Introuvable"]; + }; + }; + proposer: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NouvelleProposition"]; + }; + }; + responses: { + /** @description La proposition. Le client est prévenu. */ + 201: { + headers: { + Location?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Proposition"]; + }; + }; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 404: components["responses"]["Introuvable"]; + /** @description `etat_invalide`, `deja_candidat`, `propre_mission`. */ + 409: components["responses"]["Conflit"]; + 429: components["responses"]["TropDeRequetes"]; + }; + }; + retenirProposition: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + propositionId: components["parameters"]["PropositionId"]; + }; + cookie?: never; + }; + requestBody: components["requestBodies"]["Version"]; + responses: { + 200: components["responses"]["MissionAJour"]; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + 409: components["responses"]["Conflit"]; + }; + }; + retirerProposition: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + propositionId: components["parameters"]["PropositionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Proposition retirée. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["NonConnecte"]; + 404: components["responses"]["Introuvable"]; + /** @description `etat_invalide`. */ + 409: components["responses"]["Conflit"]; + }; + }; + envoyerDevis: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NouveauDevis"]; + }; + }; + responses: { + /** @description Le devis. Le client est prévenu. */ + 201: { + headers: { + Location?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Devis"]; + }; + }; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + /** @description `etat_invalide`, `somme_jalons_invalide`. */ + 409: components["responses"]["Conflit"]; + }; + }; + accepterDevis: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + devisId: components["parameters"]["DevisId"]; + }; + cookie?: never; + }; + requestBody: components["requestBodies"]["Version"]; + responses: { + 200: components["responses"]["MissionAJour"]; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + 409: components["responses"]["Conflit"]; + }; + }; + demarrerMission: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + version: number; + /** @description Accès, contenus, contacts utiles. Jamais de mot de passe : l'écran le rappelle. */ + informations: string; + }; + }; + }; + responses: { + 200: components["responses"]["MissionAJour"]; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + 409: components["responses"]["Conflit"]; + }; + }; + livrerJalon: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + jalonId: components["parameters"]["JalonId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NouvelleLivraison"]; + }; + }; + responses: { + 200: components["responses"]["MissionAJour"]; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + 409: components["responses"]["Conflit"]; + }; + }; + accepterJalon: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + jalonId: components["parameters"]["JalonId"]; + }; + cookie?: never; + }; + requestBody: components["requestBodies"]["Version"]; + responses: { + 200: components["responses"]["MissionAJour"]; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + 409: components["responses"]["Conflit"]; + }; + }; + demanderRetouche: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + jalonId: components["parameters"]["JalonId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + version: number; + raison: string; + }; + }; + }; + responses: { + 200: components["responses"]["MissionAJour"]; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + 409: components["responses"]["Conflit"]; + }; + }; + declarerPaiement: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + jalonId: components["parameters"]["JalonId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + version: number; + /** @enum {string} */ + sens: "paye" | "recu"; + }; + }; + }; + responses: { + 200: components["responses"]["MissionAJour"]; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + 409: components["responses"]["Conflit"]; + }; + }; + demanderProlongation: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + version: number; + /** Format: uuid */ + jalonId: string; + jours: number; + raison: string; + }; + }; + }; + responses: { + 200: components["responses"]["MissionAJour"]; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + 409: components["responses"]["Conflit"]; + }; + }; + repondreProlongation: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + prolongationId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + version: number; + accepte: boolean; + }; + }; + }; + responses: { + 200: components["responses"]["MissionAJour"]; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + 409: components["responses"]["Conflit"]; + }; + }; + annulerMission: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + version: number; + raison: string; + }; + }; + }; + responses: { + 200: components["responses"]["MissionAJour"]; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + 409: components["responses"]["Conflit"]; + }; + }; + ouvrirDesaccord: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + version: number; + raison: string; + }; + }; + }; + responses: { + 200: components["responses"]["MissionAJour"]; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + 409: components["responses"]["Conflit"]; + }; + }; + trancherDesaccord: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + version: number; + /** @enum {string} */ + issue: "terminee" | "annulee"; + /** @description Montrée aux deux parties. */ + explication: string; + }; + }; + }; + responses: { + 200: components["responses"]["MissionAJour"]; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + 409: components["responses"]["Conflit"]; + }; + }; + donnerAvis: { + parameters: { + query?: never; + header?: never; + path: { + missionId: components["parameters"]["MissionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + note: number; + texte?: string | null; + }; + }; + }; + responses: { + /** @description L'avis. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Avis"]; + }; + }; + 400: components["responses"]["Invalide"]; + 401: components["responses"]["NonConnecte"]; + 403: components["responses"]["Interdit"]; + 404: components["responses"]["Introuvable"]; + /** @description `etat_invalide`, `avis_deja_donne`. */ + 409: components["responses"]["Conflit"]; + }; + }; +} diff --git a/apps/web/src/mocks/handlers.ts b/apps/web/src/mocks/handlers.ts index 6f20820..2c9df65 100644 --- a/apps/web/src/mocks/handlers.ts +++ b/apps/web/src/mocks/handlers.ts @@ -5,6 +5,7 @@ import { handlersVitrine } from './vitrine' import { handlersCommunautes } from './communautes' import { handlersProfils } from './profils' import { handlersDemandes } from './demandes' +import { handlersMissions } from './missions' import { handlersCercles } from './cercles' import { handlersReseau } from './reseau' @@ -85,6 +86,7 @@ export const handlers = [ ...handlersCommunautes, ...handlersProfils, ...handlersDemandes, + ...handlersMissions, ...handlersCercles, ...handlersReseau, http.get('/api/me', () => diff --git a/apps/web/src/mocks/missions.test.ts b/apps/web/src/mocks/missions.test.ts new file mode 100644 index 0000000..ac93622 --- /dev/null +++ b/apps/web/src/mocks/missions.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { reinitialiserMissions, type Mission } from './missions' + +afterEach(() => reinitialiserMissions()) + +const json = (corps: unknown) => ({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(corps) }) +const nouvelle = { + titre: 'Site pour une association', + besoin: 'Une page pour présenter nos actions et recevoir des dons par Mobile Money.', + competences: ['vue'], + visibilite: 'publique', +} + +describe('maquettes des missions (contracts/missions.yaml)', () => { + it('liste les missions publiques publiées, sans les brouillons', async () => { + await fetch('/api/missions', json(nouvelle)) + const page = await (await fetch('/api/missions')).json() + expect(page.curseurSuivant).toBeNull() + expect(page.elements).toHaveLength(1) + expect(page.elements[0].statut).toBe('publiee') + }) + + it('crée un brouillon puis le publie avec la bonne version', async () => { + const reponse = await fetch('/api/missions', json(nouvelle)) + expect(reponse.status).toBe(201) + const brouillon: Mission = await reponse.json() + expect(brouillon.statut).toBe('brouillon') + + const perimee = await fetch(`/api/missions/${brouillon.id}/publier`, json({ version: brouillon.version + 1 })) + expect(perimee.status).toBe(409) + expect((await perimee.json()).code).toBe('modification_concurrente') + + const publiee: Mission = await (await fetch(`/api/missions/${brouillon.id}/publier`, json({ version: brouillon.version }))).json() + expect(publiee.statut).toBe('publiee') + expect(publiee.version).toBe(brouillon.version + 1) + }) + + it('refuse une mission sans besoin décrit', async () => { + const reponse = await fetch('/api/missions', json({ ...nouvelle, besoin: 'trop court' })) + expect(reponse.status).toBe(400) + }) + + it('ajoute une proposition et la compte', async () => { + const [mission] = (await (await fetch('/api/missions')).json()).elements + const proposition = { + message: 'Je fais ce genre de site depuis deux ans, avec le paiement Wave et Orange Money.', + prix: { centimes: 300000, devise: 'XOF' }, + delaiJours: 14, + } + expect((await fetch(`/api/missions/${mission.id}/propositions`, json(proposition))).status).toBe(201) + const page = await (await fetch(`/api/missions/${mission.id}/propositions`)).json() + expect(page.elements).toHaveLength(2) + expect((await (await fetch(`/api/missions/${mission.id}`)).json()).nombrePropositions).toBe(2) + }) +}) diff --git a/apps/web/src/mocks/missions.ts b/apps/web/src/mocks/missions.ts new file mode 100644 index 0000000..404c0a4 --- /dev/null +++ b/apps/web/src/mocks/missions.ts @@ -0,0 +1,160 @@ +import { http, HttpResponse } from 'msw' +import type { components } from '../api/generated/missions' + +type S = components['schemas'] +export type Mission = S['Mission'] +export type MissionResume = S['MissionResume'] +export type NouvelleMission = S['NouvelleMission'] +export type NouvelleProposition = S['NouvelleProposition'] +export type Proposition = S['Proposition'] + +// Données de démo (contracts/missions.yaml) : jamais de vraies données personnelles. +const AWA = { id: '6f1c2a4e-8b7d-4c3e-9a10-2b5d7e9f0a11', nom: 'Awa Diop', avatarUrl: null } +const KOFI = { id: '0b9f3c1d-2e4a-4f6b-8c7d-9e0a1b2c3d4e', nom: 'Kofi Mensah', avatarUrl: null } + +function erreur(status: number, code: string) { + return HttpResponse.json( + { type: `urn:jamii:erreur:${code}`, title: code, status, code }, + { status, headers: { 'Content-Type': 'application/problem+json' } }, + ) +} + +function missionDemo(): Mission { + return { + id: 'a3d1c6f0-5b2e-4c8a-9f17-3e6b0d4a2c91', + titre: 'Boutique en ligne pour un atelier de couture', + besoin: + "L'atelier veut montrer ses modèles avec photos et prix, recevoir les commandes sur WhatsApp et être payé par Mobile Money.", + statut: 'publiee', + competences: ['vue', 'spring', 'mobile-money'], + budget: { centimes: 450000, devise: 'XOF' }, + ville: 'Dakar', + pays: 'SN', + aDistance: true, + client: AWA, + nombrePropositions: 1, + publieeLe: '2026-11-14T10:00:00Z', + demandeId: null, + dateSouhaitee: '2026-12-15', + visibilite: 'publique', + version: 2, + creeLe: '2026-11-14T09:30:00Z', + moi: { role: 'client', actions: ['modifier', 'fermer', 'inviter', 'retenir'] }, + travail: null, + } +} + +function propositionDemo(): Proposition { + return { + id: 'c5e2a7b1-9d3f-4e6a-8b10-7f2c4d6e8a01', + developpeur: KOFI, + message: "J'ai déjà fait deux boutiques avec paiement Wave. Je commence par le catalogue, puis la commande WhatsApp.", + prix: { centimes: 400000, devise: 'XOF' }, + delaiJours: 21, + reponses: [], + statut: 'envoyee', + envoyeeLe: '2026-11-14T12:00:00Z', + } +} + +const missions = new Map() +const propositions = new Map() + +export function reinitialiserMissions() { + missions.clear() + propositions.clear() + const m = missionDemo() + missions.set(m.id, m) + propositions.set(m.id, [propositionDemo()]) +} +reinitialiserMissions() + +function resume({ id, titre, statut, competences, budget, ville, pays, aDistance, client, nombrePropositions, publieeLe }: Mission): MissionResume { + return { id, titre, statut, competences, budget, ville, pays, aDistance, client, nombrePropositions, publieeLe } +} + +export const handlersMissions = [ + http.get('/api/missions', ({ request }) => { + const vue = new URL(request.url).searchParams.get('vue') ?? 'publiques' + const liste = [...missions.values()].filter((m) => + vue === 'miennes' ? m.client.id === AWA.id : m.visibilite === 'publique' && m.statut === 'publiee', + ) + return HttpResponse.json({ elements: liste.map(resume), curseurSuivant: null }) + }), + + http.post('/api/missions', async ({ request }) => { + const corps = (await request.json()) as NouvelleMission + if (!corps.titre || corps.titre.trim().length < 3 || !corps.besoin || corps.besoin.trim().length < 20 || !corps.competences?.length) { + return erreur(400, 'validation') + } + const mission: Mission = { + ...missionDemo(), + id: crypto.randomUUID(), + titre: corps.titre, + besoin: corps.besoin, + competences: corps.competences, + budget: corps.budget ?? null, + dateSouhaitee: corps.dateSouhaitee ?? null, + visibilite: corps.visibilite, + ville: corps.ville ?? null, + pays: corps.pays ?? null, + aDistance: corps.aDistance ?? true, + demandeId: corps.demandeId ?? null, + statut: 'brouillon', + nombrePropositions: 0, + publieeLe: null, + version: 1, + creeLe: new Date().toISOString(), + moi: { role: 'client', actions: ['modifier', 'publier', 'inviter'] }, + } + missions.set(mission.id, mission) + propositions.set(mission.id, []) + return HttpResponse.json(mission, { status: 201, headers: { Location: `/api/missions/${mission.id}` } }) + }), + + http.get('/api/missions/:id', ({ params }) => { + const mission = missions.get(String(params.id)) + return mission ? HttpResponse.json(mission) : erreur(404, 'introuvable') + }), + + http.post('/api/missions/:id/publier', async ({ params, request }) => { + const mission = missions.get(String(params.id)) + if (!mission) return erreur(404, 'introuvable') + const { version } = (await request.json()) as { version: number } + if (version !== mission.version) return erreur(409, 'modification_concurrente') + if (mission.statut !== 'brouillon') return erreur(409, 'etat_invalide') + Object.assign(mission, { + statut: 'publiee', + publieeLe: new Date().toISOString(), + version: mission.version + 1, + moi: { role: 'client', actions: ['modifier', 'fermer', 'inviter', 'retenir'] }, + }) + return HttpResponse.json(mission) + }), + + http.get('/api/missions/:id/propositions', ({ params }) => { + const liste = propositions.get(String(params.id)) + return liste ? HttpResponse.json({ elements: liste, curseurSuivant: null }) : erreur(404, 'introuvable') + }), + + http.post('/api/missions/:id/propositions', async ({ params, request }) => { + const mission = missions.get(String(params.id)) + if (!mission) return erreur(404, 'introuvable') + if (mission.statut !== 'publiee') return erreur(409, 'etat_invalide') + const corps = (await request.json()) as NouvelleProposition + if (!corps.message || corps.message.trim().length < 30 || corps.delaiJours < 1) return erreur(400, 'validation') + const proposition: Proposition = { + id: crypto.randomUUID(), + developpeur: KOFI, + message: corps.message, + prix: corps.prix, + delaiJours: corps.delaiJours, + reponses: corps.reponses ?? [], + statut: 'envoyee', + envoyeeLe: new Date().toISOString(), + } + propositions.get(mission.id)?.push(proposition) + mission.nombrePropositions += 1 + return HttpResponse.json(proposition, { status: 201 }) + }), +] diff --git a/contracts/missions.yaml b/contracts/missions.yaml new file mode 100644 index 0000000..e534318 --- /dev/null +++ b/contracts/missions.yaml @@ -0,0 +1,929 @@ +openapi: 3.1.0 +info: + title: Jamii · Missions freelance + version: 0.1.0 + summary: Un client publie une mission, choisit un développeur sur devis, puis suit le travail jalon par jalon jusqu'aux avis. + description: | + Responsable : @sowibrr (module Freelance, #158). Proposition : `docs/propositions/0004-hackathons-et-missions.md`. + Suivi : #166, ce contrat : #175. Règles communes : `docs/api-conventions.md`. + + **Le parcours** + 1. Le client crée une mission (`brouillon`), seul ou **à partir de sa demande publiée** (`demandeId`, `demandes.yaml`), + puis la publie : `publiee`. Publier est gratuit. + 2. Les développeurs envoient une **proposition** (approche, prix, délai). Une mission `invitation` n'est visible et + ouverte qu'aux développeurs invités. + 3. Le client **retient** une proposition ; ce développeur envoie un **devis** (jalons, montants, retouches comprises). + Le devis accepté par les deux **vaut contrat** : la mission passe `attribuee`, les autres propositions `non_retenue`. + 4. Le client donne les **informations de départ** : `en_cours`. **Le délai de chaque jalon commence à ce moment.** + 5. Pour chaque jalon : le développeur **livre**, le client **accepte** ou **demande une retouche** (dans la limite du + devis). **Sans réponse 72 heures après la livraison, le jalon est accepté** (`accepteLe` + `acceptationAutomatique`). + 6. Le dernier jalon accepté **termine** la mission. Chacun peut alors laisser **un avis**. + 7. À tout moment après l'attribution : **annulation** (d'un commun accord, ou seul dans les cas prévus) ou + **désaccord**, tranché par un modérateur. + + **Le paiement n'est pas encaissé par Jamii (v1).** Chacun **déclare** : le client « payé », puis le développeur + « reçu ». L'écran le dit clairement. Un séquestre par paiement mobile pourra remplacer la déclaration sans changer ce + contrat (même champ `paiement`). + + **Qui voit quoi** + - Une mission `publique` publiée, fermée ou terminée : tout le monde, même sans être connecté (sans le devis, les + jalons ni les propositions). + - Une mission `invitation`, un brouillon, ou les détails du travail (devis, jalons, livraisons) : le client et le + développeur retenu. Les invités voient l'annonce. Pour tout autre utilisateur, **la mission n'existe pas** (`404`). + - Les propositions : le client les voit toutes ; un développeur ne voit **que la sienne**. + + **Limites contre les abus** (`429` avec `Retry-After`) : 5 missions publiées par jour et par compte, 20 propositions + envoyées par jour et par compte. Les propositions sont gratuites. + + **Montants** : toujours un entier en **unité mineure** de la devise (`centimes`) et une devise ISO 4217 (`XOF`, + `KES`, `NGN`, `GHS`, `EUR`…). Pour le franc CFA, qui n'a pas d'unité mineure, `centimes` vaut le montant en francs. + + **Modification concurrente** : toute action qui change l'état d'une mission ou d'un jalon envoie la `version` lue ; + si elle a changé entre-temps, `409 modification_concurrente`. + + **Codes `409`** : `etat_invalide` (action impossible dans l'état actuel), `modification_concurrente`, + `deja_candidat`, `propre_mission`, `demande_non_publiee`, `somme_jalons_invalide`, `retouches_epuisees`, + `annulation_non_permise`, `avis_deja_donne`. + + **Textes** : titres, besoins, propositions, livraisons et avis sont du texte brut, affiché échappé. + + **Données personnelles** : missions, propositions, devis, livraisons et avis font partie de l'export + (`GET /api/me/export`). À la suppression du compte, ses missions publiées sont fermées et ses avis anonymisés. + + **CSRF** : toute requête `POST` ou `PATCH` envoie l'en-tête `X-XSRF-TOKEN` (`socle.yaml`, schéma `csrf`). +servers: + - url: http://localhost:8080 + +security: + - session: [] + +tags: + - { name: missions, description: 'Publier une mission et la lire.' } + - { name: propositions, description: 'Candidater, retenir, fixer le devis.' } + - { name: suivi, description: 'Démarrer, livrer, accepter, payer, prolonger.' } + - { name: fin, description: 'Annuler, trancher un désaccord, laisser un avis.' } + +paths: + /api/missions: + get: + tags: [missions] + operationId: listerMissions + summary: Les missions publiques ouvertes aux propositions, ou les miennes + description: | + `vue=publiques` (par défaut, sans connexion) : les missions `publique` à l'état `publiee`, la plus récente d'abord. + `vue=miennes` (connexion requise) : celles dont je suis le client ou le développeur retenu, tous états confondus, + et celles où je suis invité. + security: [{}, { session: [] }] + parameters: + - $ref: './socle.yaml#/components/parameters/Curseur' + - $ref: './socle.yaml#/components/parameters/Limite' + - { name: vue, in: query, schema: { type: string, enum: [publiques, miennes], default: publiques } } + - { name: competence, in: query, schema: { $ref: './projets.yaml#/components/schemas/Competence' } } + - { name: pays, in: query, schema: { $ref: './profils.yaml#/components/schemas/Pays' } } + responses: + '200': + description: Une page de missions. + content: + application/json: + schema: { $ref: '#/components/schemas/PageMissions' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + post: + tags: [missions] + operationId: creerMission + summary: Créer une mission (brouillon, invisible des autres) + description: | + Avec `demandeId`, la mission reprend une demande **publiée** dont je suis l'auteur (`409 demande_non_publiee` + sinon, `404` si elle n'est pas à moi). Les champs envoyés remplacent ceux de la demande. + security: + - session: [] + csrf: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/NouvelleMission' } + responses: + '201': + description: Le brouillon. + headers: { Location: { schema: { type: string } } } + content: + application/json: + schema: { $ref: '#/components/schemas/Mission' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { description: '`demande_non_publiee`.', $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}: + parameters: [{ $ref: '#/components/parameters/MissionId' }] + get: + tags: [missions] + operationId: lireMission + summary: Une mission (le détail du travail seulement pour le client et le développeur retenu) + security: [{}, { session: [] }] + responses: + '200': + description: La mission. + content: + application/json: + schema: { $ref: '#/components/schemas/Mission' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + patch: + tags: [missions] + operationId: modifierMission + summary: Modifier l'annonce (client, à l'état brouillon ou publiee) + security: + - session: [] + csrf: [] + requestBody: + required: true + content: + application/merge-patch+json: + schema: { $ref: '#/components/schemas/ModificationMission' } + responses: + '200': + description: La mission à jour. + content: + application/json: + schema: { $ref: '#/components/schemas/Mission' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { description: '`etat_invalide`, `modification_concurrente`.', $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/publier: + parameters: [{ $ref: '#/components/parameters/MissionId' }] + post: + tags: [missions] + operationId: publierMission + summary: Publier (client) — brouillon → publiee + security: + - session: [] + csrf: [] + requestBody: { $ref: '#/components/requestBodies/Version' } + responses: + '200': { $ref: '#/components/responses/MissionAJour' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/fermer: + parameters: [{ $ref: '#/components/parameters/MissionId' }] + post: + tags: [missions] + operationId: fermerMission + summary: Fermer sans choisir personne (client) — publiee → fermee + description: Les propositions en cours passent `non_retenue` et leurs auteurs sont prévenus. + security: + - session: [] + csrf: [] + requestBody: { $ref: '#/components/requestBodies/Version' } + responses: + '200': { $ref: '#/components/responses/MissionAJour' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/invitations: + parameters: [{ $ref: '#/components/parameters/MissionId' }] + post: + tags: [missions] + operationId: inviterDeveloppeur + summary: Inviter un développeur à proposer (client) + description: Possible en `brouillon` ou `publiee`. Inviter deux fois la même personne ne change rien (`200`). + security: + - session: [] + csrf: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [utilisateurId] + properties: + utilisateurId: { type: string, format: uuid } + responses: + '200': { description: 'Invitation enregistrée (ou déjà présente). La personne est prévenue.' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { description: '`etat_invalide`, `propre_mission`.', $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/propositions: + parameters: [{ $ref: '#/components/parameters/MissionId' }] + get: + tags: [propositions] + operationId: listerPropositions + summary: Les propositions (toutes pour le client, la sienne seulement pour un développeur) + parameters: + - $ref: './socle.yaml#/components/parameters/Curseur' + - $ref: './socle.yaml#/components/parameters/Limite' + responses: + '200': + description: Une page de propositions. + content: + application/json: + schema: { $ref: '#/components/schemas/PagePropositions' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + post: + tags: [propositions] + operationId: proposer + summary: Envoyer une proposition (développeur, mission publiee) + security: + - session: [] + csrf: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/NouvelleProposition' } + responses: + '201': + description: La proposition. Le client est prévenu. + headers: { Location: { schema: { type: string } } } + content: + application/json: + schema: { $ref: '#/components/schemas/Proposition' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { description: '`etat_invalide`, `deja_candidat`, `propre_mission`.', $ref: './socle.yaml#/components/responses/Conflit' } + '429': { $ref: './socle.yaml#/components/responses/TropDeRequetes' } + + /api/missions/{missionId}/propositions/{propositionId}/retenir: + parameters: [{ $ref: '#/components/parameters/MissionId' }, { $ref: '#/components/parameters/PropositionId' }] + post: + tags: [propositions] + operationId: retenirProposition + summary: Retenir une proposition (client) — son auteur peut envoyer un devis + description: Une seule proposition retenue à la fois. En retenir une autre remet la précédente `envoyee`. + security: + - session: [] + csrf: [] + requestBody: { $ref: '#/components/requestBodies/Version' } + responses: + '200': { $ref: '#/components/responses/MissionAJour' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/propositions/{propositionId}/retirer: + parameters: [{ $ref: '#/components/parameters/MissionId' }, { $ref: '#/components/parameters/PropositionId' }] + post: + tags: [propositions] + operationId: retirerProposition + summary: Retirer sa proposition (développeur, tant que la mission n'est pas attribuée) + security: + - session: [] + csrf: [] + responses: + '204': { description: 'Proposition retirée.' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { description: '`etat_invalide`.', $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/devis: + parameters: [{ $ref: '#/components/parameters/MissionId' }] + post: + tags: [propositions] + operationId: envoyerDevis + summary: Envoyer le devis (développeur dont la proposition est retenue) + description: | + Un nouveau devis remplace le précédent tant qu'il n'est pas accepté. La somme des jalons doit être égale à + `total` (`409 somme_jalons_invalide`). Tous les montants sont dans la même devise. + security: + - session: [] + csrf: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/NouveauDevis' } + responses: + '201': + description: Le devis. Le client est prévenu. + headers: { Location: { schema: { type: string } } } + content: + application/json: + schema: { $ref: '#/components/schemas/Devis' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { description: '`etat_invalide`, `somme_jalons_invalide`.', $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/devis/{devisId}/accepter: + parameters: [{ $ref: '#/components/parameters/MissionId' }, { $ref: '#/components/parameters/DevisId' }] + post: + tags: [propositions] + operationId: accepterDevis + summary: Accepter le devis (client) — publiee → attribuee + description: Le devis vaut contrat. Les autres propositions passent `non_retenue` et leurs auteurs sont prévenus. + security: + - session: [] + csrf: [] + requestBody: { $ref: '#/components/requestBodies/Version' } + responses: + '200': { $ref: '#/components/responses/MissionAJour' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/demarrer: + parameters: [{ $ref: '#/components/parameters/MissionId' }] + post: + tags: [suivi] + operationId: demarrerMission + summary: Donner les informations de départ (client) — attribuee → en_cours + description: Les échéances des jalons sont calculées à partir de ce moment (`delaiJours` du devis). + security: + - session: [] + csrf: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [version, informations] + properties: + version: { type: integer } + informations: { type: string, minLength: 1, maxLength: 4000, description: 'Accès, contenus, contacts utiles. Jamais de mot de passe : l''écran le rappelle.' } + responses: + '200': { $ref: '#/components/responses/MissionAJour' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/jalons/{jalonId}/livrer: + parameters: [{ $ref: '#/components/parameters/MissionId' }, { $ref: '#/components/parameters/JalonId' }] + post: + tags: [suivi] + operationId: livrerJalon + summary: Livrer un jalon (développeur) — en_cours → livre + security: + - session: [] + csrf: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/NouvelleLivraison' } + responses: + '200': { $ref: '#/components/responses/MissionAJour' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/jalons/{jalonId}/accepter: + parameters: [{ $ref: '#/components/parameters/MissionId' }, { $ref: '#/components/parameters/JalonId' }] + post: + tags: [suivi] + operationId: accepterJalon + summary: Accepter un jalon livré (client) — livre → accepte + description: Le dernier jalon accepté fait passer la mission `terminee`. + security: + - session: [] + csrf: [] + requestBody: { $ref: '#/components/requestBodies/Version' } + responses: + '200': { $ref: '#/components/responses/MissionAJour' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/jalons/{jalonId}/demander-retouche: + parameters: [{ $ref: '#/components/parameters/MissionId' }, { $ref: '#/components/parameters/JalonId' }] + post: + tags: [suivi] + operationId: demanderRetouche + summary: Demander une retouche (client) — livre → en_cours + description: Dans la limite de `retouchesComprises` du devis, pour toute la mission (`409 retouches_epuisees`). + security: + - session: [] + csrf: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [version, raison] + properties: + version: { type: integer } + raison: { type: string, minLength: 10, maxLength: 2000 } + responses: + '200': { $ref: '#/components/responses/MissionAJour' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/jalons/{jalonId}/declarer-paiement: + parameters: [{ $ref: '#/components/parameters/MissionId' }, { $ref: '#/components/parameters/JalonId' }] + post: + tags: [suivi] + operationId: declarerPaiement + summary: Déclarer un paiement fait hors de Jamii (le client « paye », puis le développeur « recu ») + description: '`recu` avant `paye` : `409 etat_invalide`. Possible dès que le jalon est livré.' + security: + - session: [] + csrf: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [version, sens] + properties: + version: { type: integer } + sens: { type: string, enum: [paye, recu] } + responses: + '200': { $ref: '#/components/responses/MissionAJour' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/prolongations: + parameters: [{ $ref: '#/components/parameters/MissionId' }] + post: + tags: [suivi] + operationId: demanderProlongation + summary: Demander plus de temps pour le jalon en cours (développeur) + description: Une seule demande en attente à la fois (`409 etat_invalide`). + security: + - session: [] + csrf: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [version, jalonId, jours, raison] + properties: + version: { type: integer } + jalonId: { type: string, format: uuid } + jours: { type: integer, minimum: 1, maximum: 60 } + raison: { type: string, minLength: 10, maxLength: 1000 } + responses: + '200': { $ref: '#/components/responses/MissionAJour' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/prolongations/{prolongationId}/repondre: + parameters: + - $ref: '#/components/parameters/MissionId' + - { name: prolongationId, in: path, required: true, schema: { type: string, format: uuid } } + post: + tags: [suivi] + operationId: repondreProlongation + summary: Accepter ou refuser une demande de prolongation (client) + security: + - session: [] + csrf: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [version, accepte] + properties: + version: { type: integer } + accepte: { type: boolean } + responses: + '200': { $ref: '#/components/responses/MissionAJour' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/annuler: + parameters: [{ $ref: '#/components/parameters/MissionId' }] + post: + tags: [fin] + operationId: annulerMission + summary: Demander ou confirmer l'annulation (client ou développeur retenu) + description: | + - Si l'autre partie a déjà demandé l'annulation : la mission passe `annulee` (commun accord). + - Sinon, **seul** et tout de suite, dans deux cas : le développeur, si les informations de départ n'ont pas été + données 7 jours après l'attribution ; le client, si un jalon a plus de 7 jours de retard sur son échéance. + - Sinon, la demande est enregistrée (`annulationDemandeePar`) et l'autre partie est prévenue. + Après `livre` sur un jalon contesté, il faut passer par un désaccord (`409 annulation_non_permise`). + security: + - session: [] + csrf: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [version, raison] + properties: + version: { type: integer } + raison: { type: string, minLength: 10, maxLength: 1000 } + responses: + '200': { $ref: '#/components/responses/MissionAJour' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/desaccord: + parameters: [{ $ref: '#/components/parameters/MissionId' }] + post: + tags: [fin] + operationId: ouvrirDesaccord + summary: Ouvrir un désaccord (client ou développeur retenu) — en_cours → desaccord + description: Les livraisons, retouches et acceptations automatiques sont gelées jusqu'à la décision. + security: + - session: [] + csrf: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [version, raison] + properties: + version: { type: integer } + raison: { type: string, minLength: 20, maxLength: 4000 } + responses: + '200': { $ref: '#/components/responses/MissionAJour' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/desaccord/trancher: + parameters: [{ $ref: '#/components/parameters/MissionId' }] + post: + tags: [fin] + operationId: trancherDesaccord + summary: Trancher un désaccord (modérateur) — desaccord → terminee ou annulee + security: + - session: [] + csrf: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [version, issue, explication] + properties: + version: { type: integer } + issue: { type: string, enum: [terminee, annulee] } + explication: { type: string, minLength: 20, maxLength: 4000, description: 'Montrée aux deux parties.' } + responses: + '200': { $ref: '#/components/responses/MissionAJour' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { $ref: './socle.yaml#/components/responses/Conflit' } + + /api/missions/{missionId}/avis: + parameters: [{ $ref: '#/components/parameters/MissionId' }] + post: + tags: [fin] + operationId: donnerAvis + summary: Laisser un avis sur l'autre partie (une fois, mission terminee) + security: + - session: [] + csrf: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [note] + properties: + note: { type: integer, minimum: 1, maximum: 5 } + texte: { type: [string, 'null'], maxLength: 1000 } + responses: + '201': + description: L'avis. + content: + application/json: + schema: { $ref: '#/components/schemas/Avis' } + '400': { $ref: './socle.yaml#/components/responses/Invalide' } + '401': { $ref: './socle.yaml#/components/responses/NonConnecte' } + '403': { $ref: './socle.yaml#/components/responses/Interdit' } + '404': { $ref: './socle.yaml#/components/responses/Introuvable' } + '409': { description: '`etat_invalide`, `avis_deja_donne`.', $ref: './socle.yaml#/components/responses/Conflit' } + +components: + securitySchemes: + session: { $ref: './socle.yaml#/components/securitySchemes/session' } + csrf: { $ref: './socle.yaml#/components/securitySchemes/csrf' } + + parameters: + MissionId: { name: missionId, in: path, required: true, schema: { type: string, format: uuid } } + PropositionId: { name: propositionId, in: path, required: true, schema: { type: string, format: uuid } } + DevisId: { name: devisId, in: path, required: true, schema: { type: string, format: uuid } } + JalonId: { name: jalonId, in: path, required: true, schema: { type: string, format: uuid } } + + responses: + MissionAJour: + description: La mission à jour. + content: + application/json: + schema: { $ref: '#/components/schemas/Mission' } + + requestBodies: + Version: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [version] + properties: + version: { type: integer, description: 'La `version` de la mission lue juste avant.' } + + schemas: + Montant: + type: object + additionalProperties: false + required: [centimes, devise] + properties: + centimes: { type: integer, format: int64, minimum: 0, description: 'Unité mineure (en francs pour XOF et XAF).' } + devise: { type: string, pattern: '^[A-Z]{3}$', description: 'ISO 4217.' } + + NouvelleMission: + type: object + additionalProperties: false + required: [titre, besoin, competences, visibilite] + properties: + demandeId: { type: [string, 'null'], format: uuid, description: 'Ma demande publiée dont part la mission (`demandes.yaml`).' } + titre: { type: string, minLength: 3, maxLength: 120 } + besoin: { type: string, minLength: 20, maxLength: 8000, description: 'Le besoin ou le cahier des charges. Texte brut.' } + competences: + type: array + minItems: 1 + maxItems: 10 + uniqueItems: true + items: { $ref: './projets.yaml#/components/schemas/Competence' } + budget: { oneOf: [{ $ref: '#/components/schemas/Montant' }, { type: 'null' }], description: '`null` : « à discuter ».' } + dateSouhaitee: { type: [string, 'null'], format: date } + visibilite: { type: string, enum: [publique, invitation] } + ville: { type: [string, 'null'], maxLength: 80 } + pays: { oneOf: [{ $ref: './profils.yaml#/components/schemas/Pays' }, { type: 'null' }] } + aDistance: { type: boolean, default: true } + + ModificationMission: + type: object + additionalProperties: false + required: [version] + description: JSON Merge Patch. Seuls les champs envoyés changent. + properties: + version: { type: integer } + titre: { type: string, minLength: 3, maxLength: 120 } + besoin: { type: string, minLength: 20, maxLength: 8000 } + competences: { type: array, minItems: 1, maxItems: 10, uniqueItems: true, items: { $ref: './projets.yaml#/components/schemas/Competence' } } + budget: { oneOf: [{ $ref: '#/components/schemas/Montant' }, { type: 'null' }] } + dateSouhaitee: { type: [string, 'null'], format: date } + visibilite: { type: string, enum: [publique, invitation] } + ville: { type: [string, 'null'], maxLength: 80 } + pays: { oneOf: [{ $ref: './profils.yaml#/components/schemas/Pays' }, { type: 'null' }] } + aDistance: { type: boolean } + + StatutMission: + type: string + enum: [brouillon, publiee, fermee, attribuee, en_cours, terminee, annulee, desaccord] + + MissionResume: + type: object + required: [id, titre, statut, competences, budget, client, nombrePropositions, publieeLe] + properties: + id: { type: string, format: uuid } + titre: { type: string } + statut: { $ref: '#/components/schemas/StatutMission' } + competences: { type: array, items: { $ref: './projets.yaml#/components/schemas/Competence' } } + budget: { oneOf: [{ $ref: '#/components/schemas/Montant' }, { type: 'null' }] } + ville: { type: [string, 'null'] } + pays: { type: [string, 'null'] } + aDistance: { type: boolean } + client: { $ref: './projets.yaml#/components/schemas/PersonneResume' } + nombrePropositions: { type: integer, minimum: 0 } + publieeLe: { type: [string, 'null'], format: date-time } + + Mission: + allOf: + - $ref: '#/components/schemas/MissionResume' + - type: object + required: [besoin, visibilite, version, creeLe, moi] + properties: + demandeId: { type: [string, 'null'], format: uuid } + besoin: { type: string, description: 'Texte brut, affiché échappé.' } + dateSouhaitee: { type: [string, 'null'], format: date } + visibilite: { type: string, enum: [publique, invitation] } + version: { type: integer } + creeLe: { type: string, format: date-time } + moi: + type: object + description: Ce que la personne qui lit peut faire. Le front s'en sert pour afficher les boutons ; l'API revérifie tout. + required: [role, actions] + properties: + role: { type: string, enum: [client, developpeur_retenu, invite, candidat, visiteur, moderateur] } + actions: + type: array + items: + type: string + enum: [modifier, publier, fermer, inviter, proposer, retenir, envoyer_devis, accepter_devis, demarrer, livrer, accepter_jalon, demander_retouche, declarer_paye, declarer_recu, demander_prolongation, repondre_prolongation, annuler, ouvrir_desaccord, trancher, donner_avis] + travail: + description: Seulement pour le client, le développeur retenu et un modérateur. Absent sinon. + oneOf: [{ $ref: '#/components/schemas/Travail' }, { type: 'null' }] + + Travail: + type: object + required: [developpeur, devis, informationsDepart, demarreLe, retouchesRestantes, annulationDemandeePar, desaccord] + properties: + developpeur: { $ref: './projets.yaml#/components/schemas/PersonneResume' } + devis: { $ref: '#/components/schemas/Devis' } + informationsDepart: { type: [string, 'null'] } + demarreLe: { type: [string, 'null'], format: date-time } + retouchesRestantes: { type: integer, minimum: 0 } + prolongationEnAttente: { oneOf: [{ $ref: '#/components/schemas/Prolongation' }, { type: 'null' }] } + annulationDemandeePar: { type: [string, 'null'], enum: [client, developpeur, null] } + desaccord: { oneOf: [{ $ref: '#/components/schemas/Desaccord' }, { type: 'null' }] } + avis: { type: array, maxItems: 2, items: { $ref: '#/components/schemas/Avis' } } + + NouvelleProposition: + type: object + additionalProperties: false + required: [message, prix, delaiJours] + properties: + message: { type: string, minLength: 30, maxLength: 4000, description: 'Comment je m''y prendrais, ce que j''ai déjà fait de proche.' } + prix: { $ref: '#/components/schemas/Montant' } + delaiJours: { type: integer, minimum: 1, maximum: 365 } + reponses: { type: array, maxItems: 5, items: { type: string, maxLength: 1000 }, description: 'Réponses aux questions du client, dans l''ordre.' } + + Proposition: + type: object + required: [id, developpeur, message, prix, delaiJours, statut, envoyeeLe] + properties: + id: { type: string, format: uuid } + developpeur: { $ref: './projets.yaml#/components/schemas/PersonneResume' } + message: { type: string } + prix: { $ref: '#/components/schemas/Montant' } + delaiJours: { type: integer } + reponses: { type: array, items: { type: string } } + statut: { type: string, enum: [envoyee, retenue, acceptee, non_retenue, retiree] } + envoyeeLe: { type: string, format: date-time } + + PageMissions: + allOf: + - $ref: './socle.yaml#/components/schemas/Page' + - properties: + elements: { items: { $ref: '#/components/schemas/MissionResume' } } + + PagePropositions: + allOf: + - $ref: './socle.yaml#/components/schemas/Page' + - properties: + elements: { items: { $ref: '#/components/schemas/Proposition' } } + + NouveauDevis: + type: object + additionalProperties: false + required: [total, retouchesComprises, jalons] + properties: + total: { $ref: '#/components/schemas/Montant' } + retouchesComprises: { type: integer, minimum: 0, maximum: 10 } + conditions: { type: [string, 'null'], maxLength: 4000, description: 'Ce qui est compris, ce qui ne l''est pas.' } + jalons: + type: array + minItems: 1 + maxItems: 10 + items: + type: object + additionalProperties: false + required: [titre, montant, delaiJours] + properties: + titre: { type: string, minLength: 3, maxLength: 120 } + description: { type: [string, 'null'], maxLength: 2000 } + montant: { $ref: '#/components/schemas/Montant' } + delaiJours: { type: integer, minimum: 1, maximum: 365, description: 'Compté depuis le démarrage de la mission.' } + + Devis: + type: object + required: [id, total, retouchesComprises, jalons, envoyeLe, accepteLe] + properties: + id: { type: string, format: uuid } + total: { $ref: '#/components/schemas/Montant' } + retouchesComprises: { type: integer } + conditions: { type: [string, 'null'] } + jalons: { type: array, items: { $ref: '#/components/schemas/Jalon' } } + envoyeLe: { type: string, format: date-time } + accepteLe: { type: [string, 'null'], format: date-time } + + Jalon: + type: object + required: [id, ordre, titre, montant, delaiJours, echeance, statut, livraisons, accepteLe, acceptationAutomatique, paiement] + properties: + id: { type: string, format: uuid } + ordre: { type: integer, minimum: 1 } + titre: { type: string } + description: { type: [string, 'null'] } + montant: { $ref: '#/components/schemas/Montant' } + delaiJours: { type: integer } + echeance: { type: [string, 'null'], format: date-time, description: '`null` tant que la mission n''a pas démarré.' } + statut: { type: string, enum: [a_faire, en_cours, livre, accepte] } + livraisons: { type: array, items: { $ref: '#/components/schemas/Livraison' } } + acceptationAutomatiqueLe: { type: [string, 'null'], format: date-time, description: 'Si livré : quand il sera accepté sans réponse du client (72 h après la livraison).' } + accepteLe: { type: [string, 'null'], format: date-time } + acceptationAutomatique: { type: boolean } + paiement: { type: string, enum: [non_paye, declare_paye, declare_recu] } + + NouvelleLivraison: + type: object + additionalProperties: false + required: [version, message] + properties: + version: { type: integer } + message: { type: string, minLength: 10, maxLength: 4000 } + liens: { type: array, maxItems: 5, items: { type: string, format: uri, maxLength: 500 }, description: 'Lien de test, dépôt, fichiers. `https` uniquement.' } + + Livraison: + type: object + required: [message, liens, livreeLe] + properties: + message: { type: string } + liens: { type: array, items: { type: string, format: uri } } + livreeLe: { type: string, format: date-time } + retouche: { type: [string, 'null'], description: 'La raison de la retouche demandée après cette livraison, s''il y en a une.' } + + Prolongation: + type: object + required: [id, jalonId, jours, raison, demandeeLe] + properties: + id: { type: string, format: uuid } + jalonId: { type: string, format: uuid } + jours: { type: integer } + raison: { type: string } + demandeeLe: { type: string, format: date-time } + + Desaccord: + type: object + required: [ouvertPar, raison, ouvertLe, issue, explication, trancheLe] + properties: + ouvertPar: { type: string, enum: [client, developpeur] } + raison: { type: string } + ouvertLe: { type: string, format: date-time } + issue: { type: [string, 'null'], enum: [terminee, annulee, null] } + explication: { type: [string, 'null'] } + trancheLe: { type: [string, 'null'], format: date-time } + + Avis: + type: object + required: [auteur, note, donneLe] + properties: + auteur: { type: string, enum: [client, developpeur] } + note: { type: integer, minimum: 1, maximum: 5 } + texte: { type: [string, 'null'] } + donneLe: { type: string, format: date-time }