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 }