ArchitectureMarketplace — Architecture & Reference

Marketplace — Architecture & Reference

Source de vérité pour la marketplace BoostEcom (frontend, backend, data, SEO, monétisation, vendor flow). Mis à jour : 2026-09 (marketplace/3005).

Source de vérité pour la marketplace BoostEcom (frontend, backend, data, SEO, monétisation, vendor flow). Mis à jour : 2026-09 (marketplace/3005).

1. Vue d'ensemble

L'enum Prisma MarketplaceType déclare 13 types. 10 sont publics. Trois ne le sont pas :

  • CLI est retiré, et sa page de catégorie répond 308 vers /features/mcp ;
  • NEWSLETTER est retiré depuis marketplace/3005 (décision D8 du 2026-09-25) : il vendait le produit de /sponsor/newsletter et n'avait aucune annonce. Sa cible de redirection est /sponsor/newsletter ;
  • JOB est hors du public depuis marketplace/3005 (décision D7) : ses seules offres étaient les cinq de BoostEcom en JSON, qui contredisaient /careers, et c'est la page carrières qui fait foi. Il n'est pas retiré (pas de redirection) : une ligne JOB déjà en base garde sa page détail, en noindex, mais plus aucun vendeur n'en crée.

Le tableau ci-dessous est vérifié : src/test/marketplace-type-registry.test.ts échoue si une ligne manque, si une ligne cite un type non public, si l'ordre diffère de l'ordre éditorial, ou si une coche ne correspond plus à TYPE_CAPABILITIES. Il a annoncé « CLI : achat direct ✅, reviews ✅ » pendant des mois pour une catégorie que TYPE_COPY libelle « retired », et « JOB : hire ✅ apply » pour un type sans entrée de registre.

TypeSlug plurielAchat directHire flowApply flowReviews
APPapps✅——✅
THEMEthemes✅——✅
AGENCYagencies—✅—✅
FREELANCERfreelancers—✅—✅
STOREstores————
EXTENSIONextensions✅——✅
SKILLskills✅——✅
TOOLtools✅——✅
PROMPTprompts✅——✅
MCPmcp✅——✅

STORE ne coche rien ci-dessus et c'est normal : son flux est l'offer + escrow + APA, décrit en §3, et il n'a pas de reviews (acquisition one-shot).

Constantes canoniques : src/types/marketplace-constants.ts. Ne JAMAIS dupliquer TYPE_TO_PLURAL, PLURAL_TO_TYPE, TYPE_COPY ailleurs — ni, depuis marketplace/0645, TYPE_CAPABILITIES et les listes qui en dérivent (TYPE_ORDER, BUYABLE_TYPES, HIRE_TYPES, APPLY_TYPES, REVIEWABLE_TYPES, VENDOR_CREATABLE_TYPES).

1.1. Surface publique : ce qu'on montre, et pourquoi

Cette section existe parce que la question « qu'est-ce qui est public ? » avait neuf réponses qui ne concordaient pas, et qu'aucune n'était écrite.

Une catégorie est publique quand TYPE_CAPABILITIES[type].publicIndex est vrai. Elle est alors listée sur /marketplace — mais seulement si elle a au moins une ligne LIVE : resolveVisibleCounts ne lie jamais vers une catégorie qui rendrait zéro résultat.

Une catégorie mince n'entre pas dans l'index des moteurs. Deux verrous, posés ensemble et lisant la MÊME règle, isCategoryIndexable (counts.ts) : generateMetadata de /marketplace/[type] rend noindex, follow, et sitemap.ts n'émet pas son URL, tant que la catégorie n'a pas MIN_THIRD_PARTY_LISTINGS_TO_INDEX (3) annonces LIVE tierces (ownedByBoostecom: false). Une catégorie non publique n'est jamais indexée.

Deux étapes pour en arriver là. Avant, les douze catégories partaient à priorité 0.8 daily quoi qu'il arrive, donc nous poussions activement vers Google des pages « No jobs listed yet. ». Le premier correctif exigeait UNE ligne LIVE, et le catalogue que ce dépôt seed la fournissait tout seul : un placeholder BoostEcom par catégorie (thème, app, outil, prompt, skill jamais livrés), cinq offres d'emploi à nous, le fondateur et son agence. Chacune de ces catégories partait donc à Google portée par une annonce que nous avions écrite sur nous-mêmes (marketplace/3005). Les pages restent joignables et liées depuis /marketplace : seuls le noindex et le sitemap suivent le seuil.

/marketplace/hire suit la même règle par isHireIndexable : il est indexé dès que l'un de ses deux catalogues (HIRE_TYPES : agences, freelances) l'est. Tant qu'ils ne contiennent que le fondateur et son agence, il est en noindex et hors sitemap.

Dans les deux verrous, null (la base n'a pas répondu) n'est pas zéro : un incident transitoire ne doit pas désindexer le catalogue.

Soumettre une annonce — deux chemins, tous deux réservés aux plans payants.

Décision du 2026-09-25 (marketplace/2993) : vendre est une capacité des plans payants, acheter reste ouvert à tous. Les deux routes appellent canSellOnMarketplace (seller-eligibility.ts) : au moins une organisation du membre dont l'entitlement est déverrouillé (resolveEntitlement, donc un plan offert compte, le cache Organization.plan jamais). Refus = 402 seller-plan-required. La soumission anonyme du wizard /sell n'existe donc plus : 401.

CheminQuiTypesCoût
/sell → wizard registre → POST /api/marketplace/listingsmembre d'une org payantestore uniquement3 % au closing
/sell/dashboard/new → POST /api/vendor/marketplace/listingsmembre d'une org payanteles 10 publics (VENDOR_CREATABLE_TYPES)review admin
/sponsorpublictouspayant (placement)

Le CTA des pages catégorie envoie sur /sponsor pour tout type autre que STORE : c'est assumé, les placements non-store sont sponsorisés. Le libellé le dit maintenant (« Sponsor a placement ») ; il annonçait « Submit a listing » / « Submit your work » en menant sur une page de tarifs.

Portée réelle du registre de soumission. listing-types.ts décrit 11 types, mais le seul composant qui le lit — <SellStoreWizard /> — est monté à un seul endroit, src/app/(marketing)/sell/page.tsx, avec type="store" écrit en littéral et sans sélecteur de type. Dix des onze entrées sont donc de la configuration que rien ne rend aujourd'hui. Ce n'est pas un bug : c'est la conséquence de la règle ci-dessus. Ne pas lire ces dix entrées comme une fonctionnalité vivante.

2. Routes & surfaces

Public (marketing)

/marketplace                       # Index — TYPE_ORDER + trust strip + featured rail + recent rail
/marketplace/[type]                # Liste par type + pagination (PAS de filtres, cf. ci-dessous)
/marketplace/[type]/[slug]         # Detail listing (privacy-aware, JSON-LD par type, §6).
                                   # 308 vers listingPathFor(card.type, slug) quand le
                                   # type de l'URL n'est pas celui de l'annonce
/marketplace/hire                  # Funnel agency/freelancer hire
/marketplace/systems               # 308 vers /features/systems (+ chaque slug vers /features/systems/<slug>) :
                                   #   un System n'est pas une annonce (growth-web/3018, redirections permanentes)

Aucune n'est en ISR, quoi qu'en dise son export const revalidate. Le layout RACINE lit la requête deux fois — headers() pour le nonce CSP et cookies() via getLocale() — et la doc Next 16 est formelle : un nonce CSP désactive l'optimisation statique et l'ISR pour TOUTES les pages de l'application. Les trois pages rendent donc à chaque requête : zéro hit CDN, une salve Postgres par visiteur anonyme et par passage de crawler. Les valeurs revalidate restent déclarées pour le jour où le layout cessera de lire la requête (backlog/app-shell/0361, arbitrage chiffré dans 0365) ; la page détail, elle, ne redeviendra pas cacheable — elle lit headers() elle-même pour le compteur de vues (marketplace/0440).

Corollaire : les revalidatePath() des actions admin purgent un cache qui n'existe pas. Ils sont inoffensifs et restent en place pour la même raison.

Cette section annonçait « Toutes en ISR » et des « filtres » sur la page liste. Il n'y a pas de filtres : aucun composant ListingFilters n'existe dans le dépôt (cf. §11). Le moteur de recherche et de filtrage vit dans le Hub de la home, pas dans les pages marketing.

API publique

EndpointMéthodeAuthRate-limit
/api/marketplace/listingsPOSTopt10/min IP
/api/marketplace/listings/[id]/savePOSTuser50/min user
/api/marketplace/listings/[id]/reviewsPOSTuser60/24h user
/api/marketplace/listings/[id]/reviewsDELETEauteur ou admin30/h user
/api/marketplace/deals/[id]/advancePOSTpartie du deal20/h user
/api/marketplace/listings/[id]/checkoutPOSTuser10/24h user (ONE_TIME + SUBSCRIPTION)
/api/marketplace/offersPOSTopt20/24h IP
/api/marketplace/offers/[id]/verifyPOSTopt10/h IP+offerId
/api/marketplace/leadsPOSTopt30/24h IP + dedup 60s
/api/marketplace/recommendationsGET—30/min IP + cursor
/api/marketplace/searchGET—60/min IP — full-text tsvector
/api/marketplace/statsGET—60/min IP
/api/marketplace/sponsor-stateGET—60/min IP

API admin

EndpointMéthodeRate-limitAudit log
/api/admin/marketplace/listingsPOST10/min✅
/api/admin/marketplace/listings/[id]PATCH50/min✅ diff
/api/admin/marketplace/listings/[id]DELETE20/min✅

Vendor dashboard

Les listings vendeur ne sont plus org-scoped : MarketplaceListing n'a pas d'orgId, les deux surfaces lisaient les mêmes lignes via listSellerListings(user.id), et /[orgSlug]/~/listings* a ete fusionne dans /sell/dashboard* par marketplace/0145 (la redirection est retiree de next.config.mjs depuis le 2026-09-26 : l'ancienne URL rend 404). Cette section a décrit les routes org-scoped comme vivantes jusqu'en septembre 2026.

/sell/dashboard                    # Mes listings + stats agrégées + bulk actions
/sell/dashboard/new                # Create form (rhf+zod, save draft OR submit for review)
/sell/dashboard/analytics          # Analytics (totals all-time + KPIs 30j + top 5)
/sell/dashboard/[id]               # Detail / offres en attente / deals actifs
/sell/dashboard/[id]/edit          # Form complet edit (rhf+zod, markdown preview, image upload)
/sell/dashboard/[id]/analytics     # Analytics par listing (fenêtre 7/30/90j)
/sell/deals/[id]                   # Deal detail vendeur
/[orgSlug]/~/deals                 # Index deals (seller + buyer side) : toujours org-scoped
/[orgSlug]/~/deals/[id]            # Deal pipeline + checklist + messages

Corrige le 2026-09-05. Ce bloc documentait le dashboard vendeur sous /[orgSlug]/~/listings*. Ce prefixe n'existe plus : marketplace/0145 l'a consolide sur /sell/dashboard* parce que les deux surfaces lisaient les MEMES lignes via listSellerListings(user.id) et que MarketplaceListing n'a pas d'orgId — le [orgSlug] gatait sur l'appartenance a l'org puis ne scopait rien. next.config.mjs porte les deux redirections, et src/test/vendor-listings-consolidation.test.ts refuse que la route revienne.

Vendor API CRUD

EndpointMéthodeRate-limit
/api/vendor/marketplace/listingsGET(auth)
/api/vendor/marketplace/listingsPOST5/h
/api/vendor/marketplace/listings/[id]PATCH30/h
/api/vendor/marketplace/listings/[id]DELETE5/h
/api/vendor/marketplace/listings/[id]/submitPOST10/h (DRAFT → PENDING_REVIEW)
/api/vendor/marketplace/offers/[id]POST30/h (accept/reject)
/api/vendor/marketplace/deals/[id]/messagesPOST60/h
/api/vendor/marketplace/deals/[id]/advancePOST20/h
/api/vendor/marketplace/deals/[id]/signPOST30/h (nda/loi/apa)
/api/vendor/marketplace/uploadPOST20/h (image upload Blob)
/api/vendor/marketplace/listings/[id]/preview-tokenPOST10/h (rotate ou revoke)
/api/vendor/marketplace/listings/bulkPOST10/h (pause/unpause/delete jusqu'à 50 IDs)
/api/vendor/marketplace/deals/[id]/cancelPOST5/h

Tous owner-gated via sellerId === user.id ou seller/buyer === user.id (refuse 403 sinon). Audit log org-scoped sur toutes les mutations.

V4 — Stripe Connect + Disputes

EndpointMéthodeAuthRate-limit
/api/vendor/stripe/connect/onboardPOSTuser5/h
/api/vendor/stripe/connect/statusGETuser10/min (refresh=1)
/api/vendor/stripe/connect/dashboardPOSTuser20/h
/api/marketplace/disputesPOSTbuyer5/h
/api/marketplace/disputesGETbuyer—
/api/marketplace/disputes/[id]GETparty (buyer/seller/admin)—
/api/marketplace/disputes/[id]PATCHbuyer— (withdraw)
/api/vendor/marketplace/disputes/[id]/respondPOSTseller10/h
/api/admin/marketplace/disputes/[id]/resolvePOSTadmin50/h + audit log

Routes UI V4 :

# Buyer (account-scoped, no org needed)
/account/settings/payouts                # Stripe Connect onboarding + status + dashboard CTA
/account/disputes                        # Index disputes raised by me
/account/disputes/[id]                   # Unified thread (multi-role render via isAdmin/raisedBy/sellerId)

# Vendor (org-scoped)
/[orgSlug]/~/disputes                    # Disputes against my listings (seller side)

# Admin
/admin/content/marketplace-disputes      # Queue admin (priority sort)

Admin

/admin/content/marketplace               # Queue modération + decisions
/admin/content/marketplace/new           # Create listing direct
/admin/content/marketplace-disputes      # V4 — Dispute mediation queue

3. Modèle de données

Source : prisma/schema.prisma

Modèles marketplace

  • MarketplaceListing — la listing publique. Compteurs dénormalisés (savesCount, viewsCount, offersCount, reviewsAvg, reviewsCount).
  • VerifiedRevenue — MRR / ARR / last30d en cents, token OAuth chiffré.
  • Offer — offre acheteur. Unique constraint sur (listingId, buyerEmail, status) empêche les doublons PENDING. Deux champs de texte libre écrits par le VENDEUR, et ils ne sont pas interchangeables : counterMessage accompagne counterAmount sur une contre-offre, rejectionReason porte le motif d'un refus. Ils ont partagé une seule colonne jusqu'à marketplace/0442 — le motif s'écrivait dans counterMessage, personne ne le relisait, et le seul bouton « refuser » du produit ne l'envoyait pas. Ils se lisent exclusivement via services/marketplace/offer-messages.ts : la règle « quel statut possède ce texte » y est écrite une fois, au lieu d'être redérivée par surface.
  • DealThread — pipeline UNDER_OFFER → LOI_SIGNED → DILIGENCE → APA_SIGNED → ESCROW_FUNDED → TRANSFERRING → CLOSED.
  • DealMessage — chat seller × buyer.
  • Order — checkout Stripe (apps/themes/extensions/skills/prompts/MCP/tool/CLI).
  • Review — verifiedBuyer (Order PAID OU DealThread CLOSED). Sur un type achetable (BUYABLE_TYPES : APP, THEME, EXTENSION, SKILL, PROMPT, MCP, TOOL, CLI) ce n'est pas un badge mais une condition : sans achat, 403. Les types de prestation (AGENCY, FREELANCER) restent ouverts, leur travail se contracte hors plateforme et il n'existe aucun Order à pointer. DELETE retire la review de son auteur, ou n'importe laquelle pour un admin (jamais le vendeur), et recalcule l'agrégat dans la même transaction.
  • SavedListing — favorites.

BuyerProfile (KYC + track record, design V1) a été retiré du schéma en juin 2026 : jamais écrit ni lu par aucun code path, et le volet KYC est couvert par KycVerification (V5.1).

Machine à états du deal (qui avance quoi)

DealThread n'a pas un seul conducteur. Le service advanceDealStage refuse une transition dont l'appelant n'est pas la partie désignée, et refuse une transition dont la preuve n'est pas déjà sur la ligne :

DepuisVersQuiExige
UNDER_OFFERLOI_SIGNEDvendeurloiSignedAt
LOI_SIGNEDDILIGENCEvendeur—
DILIGENCEAPA_SIGNEDvendeurapaSignedAt
APA_SIGNEDESCROW_FUNDEDvendeur OU acheteur—
ESCROW_FUNDEDTRANSFERRINGvendeur—
TRANSFERRINGCLOSEDacheteur—

Deux règles derrière ce tableau :

  1. Une transition lit un fait, elle ne l'écrit pas. loiSignedAt et apaSignedAt viennent de la signature (markLegalDocSigned, signSignature), pas du clic « étape suivante ». escrowReleasedAt n'est plus écrit nulle part par la FSM : de l'argent qui sort d'un escrow est un évènement de paiement, et aucun n'existe dans ce dépôt.
  2. La partie qui subit la conséquence exécute la transition. La clôture passe la listing en SOLD et crédite le track record public du vendeur (totalDealsClosed sur /sellers/[id]) : elle appartient à l'acheteur, via POST /api/marketplace/deals/[id]/advance. Le vendeur qui la demande reçoit 409 buyer-confirms-this-stage. Le financement de l'escrow reste ouvert aux deux : il ne fait qu'OUVRIR la fenêtre de dispute de 14 jours (disputes.ts teste escrowFundedAt || closedAt), donc un vendeur qui le déclare ne peut jamais enfermer un acheteur.

Avant marketplace/0323, les six transitions étaient toutes au vendeur et chacune estampait le fait qu'elle nommait : six clics suffisaient à produire un LOI signé, un APA signé, un escrow financé, un escrow libéré, un deal clos et une listing vendue, sans signature, sans paiement et sans acheteur.

escrowFundedAt reste une déclaration, pas une preuve : aucun prestataire d'escrow n'est branché (DealThread.escrowId n'est écrit par aucun chemin). Le jour où il l'est, c'est le requires de cette table qui gagne un test sur escrowId.

Les deux écrans de deal (/account/deals/[id], /[orgSlug]/~/deals/[id]) proposent désormais chaque transition à la partie qui la détient : l'acheteur y confirme la réception et clôture, sur POST /api/marketplace/deals/[id]/advance. Ils lisent nextDealStage() côté serveur et passent la réponse au composant client (deal-stage-policy.ts, sous _components/ de la route org), plutôt que d'en garder une copie. Le libellé de la clôture est une confirmation de réception, pas un « suivant » : c'est cette déclaration qui rend la vente publique, et elle passe par une confirmation explicite. Entre marketplace/0323 et app-shell/0389 la capacité serveur existait sans aucune surface pour l'appeler, et les deals s'arrêtaient en TRANSFERRING.

Le document qu'une transition exige est proposé au stade qu'il faut QUITTER (LOI à UNDER_OFFER, APA à DILIGENCE), et non au stade déjà atteint : proposer le LOI une étape trop tard, c'était rendre impossible depuis cet écran la seule preuve que la transition demande. Le NDA n'y figure pas — aucune transition ne l'exige — et reste signable sur la même page via SignaturesPanel, qui est le chemin bilatéral et tracé (LegalSignature).

La seule condition KYC du pipeline

apaSignedAt est le fait qui fait sortir un deal de DILIGENCE, et deux chemins l'écrivent : signSignature (V5.1) et markLegalDocSigned (V2, click-through). Les deux refusent la signature de l'APA par l'ACHETEUR quand agreedAmount dépasse KYC_REQUIRED_ABOVE_CENTS ($10 000) et que son KycVerification n'est pas VERIFIED — code kyc-required, rendu 409 par les deux routes.

Et la TRANSITION DILIGENCE → APA_SIGNED pose la même condition, quel que soit qui a signé. C'est le vrai point d'application depuis marketplace/0603 : apaSignedAt est UNE colonne partagée par les deux parties, la signature exempte volontairement le vendeur, et le seul bouton « signer l'APA » que le produit rende est le sien (DOC_REQUIRED_TO_LEAVE propose le document à la partie qui pilote l'étape). Un vendeur qui signait en premier stampait donc la colonne et faisait avancer un deal à sept chiffres en APA_SIGNED puis ESCROW_FUNDED sans que l'identité de l'acheteur ait jamais été vérifiée — exactement le contournement que l'ADR 0011 nommait en écartant les autres étapes. advanceDealStage lit buyerId + agreedAmount sur la ligne et interroge isKycVerified(buyerId) dans le requires de l'étape (devenu asynchrone pour ça) : le deal reste en DILIGENCE, les deux routes advance rendent kyc-required en 409, et le remède reste la KycBanner de /account/deals/[id], qui lit le même dealRequiresBuyerKyc.

Ce n'est pas un revirement de l'ADR 0011 : la ligne qu'il écartait visait APA_SIGNED → ESCROW_FUNDED, ouverte aux DEUX parties (by: "either"), où une condition sur le KYC acheteur bloquerait aussi le vendeur. DILIGENCE → APA_SIGNED est pilotée par le vendeur seul et conditionnée sur l'acheteur : personne n'y est bloqué par la vérification d'un autre.

Le vendeur n'est pas gaté ici : Stripe Connect vérifie déjà son identité et cron/marketplace-payouts ne libère aucun escrow vers un compte qui n'est pas ENABLED. Le NDA et la LOI ne le sont pas non plus. L'acte de signer l'APA lui reste ouvert sans KYC (legal-doc-click-through.test.ts) ; ce qui attend l'acheteur, c'est l'état du deal.

Avant marketplace/0441, ce seuil n'existait que comme littéral dans la condition JSX qui affiche KycBanner sur /account/deals/[id], et isKycVerified n'avait aucun appelant : la bannière promettait une garantie que rien ne tenait. Le raisonnement complet, et les six étapes candidates écartées, sont dans docs/decisions/0011-la-porte-kyc-du-marketplace.md.

Que deux chemins écrivent la même colonne reste un défaut à part entière (marketplace/0443) : la porte est posée sur les deux — et sur la transition qui les lit — plutôt que d'en dépendre.

Gardes : src/services/marketplace/deal-stage-kyc.test.ts (la transition), legal-signatures.test.ts + legal-doc-click-through.test.ts (les deux signatures, exemption vendeur comprise).

Modèles V4

  • StripeConnectAccount — Stripe Connect (Express) account du seller. Mirror des flags chargesEnabled / payoutsEnabled / detailsSubmitted + currentlyDue / pastDue requirements. Status enum : PENDING → RESTRICTED → ENABLED / REJECTED. Synced via webhook account.updated. Lié à User (1:1) + orgId nullable.
  • Dispute — buyer-raised dispute sur un Order (orderId) OR DealThread (dealId), exclusif. REFUND_FULL / REFUND_PARTIAL ne s'appliquent qu'aux disputes d'Order : sur une dispute de deal, adminResolveDispute répond escrow-refund-manual (409). refundOrder inverse un PaymentIntent Stripe porté par un Order ; un deal n'en a pas, et l'e-mail de résolution rend refundAmount tel quel. Une décision REFUND_* sur un deal annonçait donc à l'acheteur un remboursement que rien ne versait et que rien ne traçait. L'admin résout NO_ACTION avec le règlement hors plateforme dans notes, ce qui laisse un AdminAuditLog qui dit ce qui s'est réellement passé. Workflow : OPEN → AWAITING_SELLER → AWAITING_ADMIN → RESOLVED_REFUND / RESOLVED_PARTIAL / RESOLVED_NO_ACTION / WITHDRAWN. Evidence URLs (buyerEvidence / sellerEvidence, max 5 chacun). Admin resolution déclenche refundOrder si REFUND_FULL/PARTIAL → stripeRefundId capturé. sellerDeadline (createdAt + 7j) drive l'auto-escalation cron.
  • ListingEvent — timeline horodatée fine-grained (VIEW / SAVE / UNSAVE / OFFER_CREATED / OFFER_ACCEPTED / DEAL_STARTED / DEAL_CLOSED / CHECKOUT_STARTED / ORDER_PAID / REVIEW_POSTED / CONTACT_CLICK / EXTERNAL_CLICK). VIEW dedup par visitorHash (sha256(ip + ua + day)). Foundation pour ML recos v2. Cette liste est celle de l'enum, pas celle des lignes écrites : OFFER_ACCEPTED, OFFER_REJECTED, DEAL_STARTED et DEAL_CLOSED n'ont toujours aucun producteur (marketplace/0464). VIEW en avait un depuis marketplace/0440 seulement, cf. § « Compteur de vues » ci-dessous.

Indexes composites (depuis cette PR)

MarketplaceListing  @@index([type, status, publishedAt(sort: Desc)])
MarketplaceListing  @@index([sellerId, status, publishedAt(sort: Desc)])
DealThread          @@index([sellerId, status, createdAt(sort: Desc)])
DealThread          @@index([buyerId, status, createdAt(sort: Desc)])
Review              @@index([listingId, createdAt(sort: Desc)])
Review              @@index([listingId, verifiedBuyer, createdAt(sort: Desc)])
Order               @@index([buyerId, status, createdAt(sort: Desc)])
Order               @@index([listingId, status])
Offer               @@unique([listingId, buyerEmail, status])
Offer               @@index([listingId, status, createdAt(sort: Desc)])
Offer               @@index([expiresAt, status])
VerifiedRevenue     @@index([source, refreshFailedAt])

# V4 — Stripe Connect / disputes / timeline
StripeConnectAccount @@unique([userId])
StripeConnectAccount @@unique([stripeAccountId])
StripeConnectAccount @@index([orgId])
StripeConnectAccount @@index([status])
Dispute              @@unique([orderId])
Dispute              @@unique([dealId])
Dispute              @@index([raisedById, createdAt(sort: Desc)])
Dispute              @@index([status, createdAt(sort: Desc)])
Dispute              @@index([sellerDeadline, status])
ListingEvent         @@index([listingId, createdAt(sort: Desc)])
ListingEvent         @@index([listingId, kind, createdAt(sort: Desc)])
ListingEvent         @@index([kind, createdAt(sort: Desc)])
ListingEvent         @@index([userId, createdAt(sort: Desc)])
ListingEvent         @@index([visitorHash, createdAt(sort: Desc)])

4. Migration JSON → DB

État : en cours (les JSON files sous content/marketplace/ restent seed pour les listings curated par BoostEcom).

# push le schema (créé/maj les tables marketplace)
pnpm db:push

Le seed des listings depuis content/marketplace/*.json est 100% automatique : aucun script manuel. Le sync tourne au cold-start Node-lambda (src/services/marketplace/sync-from-content.ts, piloté par src/instrumentation.ts), idempotent (upsert par slug + sweep des orphelins).

Supprimer un fichier JSON dépublie son annonce. Le sync écrit ownedByBoostecom: true sur chaque ligne qu'il crée, et il est le seul à le faire : c'est le marqueur. Au cold-start suivant, toute ligne ownedByBoostecom: true, status: LIVE dont le slug n'est plus dans le dossier passe ARCHIVED. Une ligne d'un vendeur n'est jamais touchée. marketplace/3005 a retiré ainsi le thème, l'app, l'outil, le prompt et le skill placeholders, les cinq offres d'emploi, et l'ancien slug boostecom-extension (l'annonce s'appelle boostecom-spy).

L'ensemble épargné par le sweep est celui des fichiers LUS sur disque, pas celui des upserts RÉUSSIS. C'était le second jusqu'à marketplace/3005 : un timeout de pool sur un seul upsert faisait de cette annonce un orphelin, archivé, puis gardé archivé à chaque déploiement par la règle du statut tenu par un admin ci-dessous.

Le JSON est la source de vérité du contenu, pas du cycle de vie : l'upsert écrit status à la création, et à la mise à jour seulement si la ligne n'est pas PAUSED / ARCHIVED / SOLD / REJECTED. Jusqu'à marketplace/0323 le même objet servait de create ET d'update, donc chaque cold start (donc chaque déploiement) republiait ce qu'un admin avait mis en pause ou archivé, en silence : l'AdminAuditLog disait pause, la ligne disait LIVE. Conséquence assumée : re-déposer un fichier JSON supprimé ne ressuscite pas une listing archivée, c'est un PATCH admin qui le fait, délibérément. Un force-sync admin est dispo via POST /api/admin/marketplace/sync.

Le service src/services/marketplace/unified.ts fait automatiquement DB-first → JSON-fallback avec cache Redis 5min pour les hot paths. Log structuré (logger.info / warn) sur chaque fallback pour monitorer la migration.

Le repli JSON ne sert que ce que la base ne connaît pas. Un slug qui a une ligne en base, quel que soit son statut (ARCHIVED, SOLD, PAUSED, DRAFT…), n'est jamais servi depuis le JSON : lookupListingBySlug répond visible, hidden ou missing, et seul missing (ou une base qui ne répond pas) déclenche le repli. Avant marketplace/3005, « pas de ligne LIVE » valait « pas en base » : une annonce archivée par un admin, ou balayée par le sync, revenait par le JSON avec un 200 et un canonical, et le sitemap la soumettait. listUnified applique la même règle à ses grilles.

4.5. Search interne (tsvector)

Postgres native full-text search via to_tsvector + ts_rank avec pondération :

  • A : title
  • B : tagline
  • C : description
  • D : tags (array joined)

Service : src/services/marketplace/search.ts

websearch_to_tsquery parse la query user naturellement (quotes pour phrases, OR/AND, parenthèses). Cache Redis 5min sur (q, type, limit).

L'index GIN n'est pas créé automatiquement, mais le script existe depuis la V3 : pnpm db:marketplace:fts-index (scripts/marketplace-fts-index.mjs) pose l'index d'expression pondéré A/B/C/D qui matche search.ts, plus un index partiel WHERE status='LIVE', le tout idempotent (CREATE INDEX IF NOT EXISTS). À lancer quand le volume dépasse ~10k listings ; en dessous le seq scan suffit. Cette section a dit « pas d'index GIN, à ajouter via migration SQL » jusqu'en septembre 2026.

5. Sécurité

Rate limiting

Toutes les routes mutantes ont un rate limit Upstash Redis (avec fallback in-memory en dev). Voir src/lib/security/rate-limit.ts.

Audit log admin

Toute mutation admin (create/update/delete listing) écrit dans AdminAuditLog via logAdminAction().

Audit log vendeur / acheteur

Une mutation marketplace non-admin passe par recordMarketplaceAudit(). Le helper existe pour une raison précise : treize routes recopiaient les mêmes huit lignes, dont douze sans orderBy, donc l'org sous laquelle l'historique d'un vendeur multi-org atterrissait changeait d'un appel à l'autre. La résolution est maintenant totale (joinedAt puis orgId), la même que lib/security/session-auth.ts.

Ce n'est pas l'org correcte, seulement une org stable : MarketplaceListing et DealThread ne portent pas d'orgId (le scope vendeur est l'utilisateur, cf. §9) et AuditLog.orgId est NOT NULL. Rendre la colonne nullable, ou ouvrir une table d'audit vendeur, est un changement de schéma qui appartient à data-platform ; ce fichier est alors le seul endroit à changer.

Idempotence checkout

Le checkout Stripe réutilise un Order PENDING existant avec stripeSessionId non-expiré (fenêtre 30 min) au lieu de créer un nouveau row. Stripe idempotencyKey scoped sur orderId empêche également les sessions dupliquées côté Stripe.

Devise d'une offre

POST /api/marketplace/offers accepte encore un champ currency, mais ne le croit jamais : l'offre est persistée dans listing.currency, et un body qui en nomme une autre reçoit 400. Un Offer est un entier de cents plus une devise, acceptOffer recopie l'entier dans DealThread.agreedAmount, et toute la suite (e-mails, pipeline, Dispute.refundAmount, analytics vendeur) l'affiche dans la devise de la listing : une offre en JPY se lisait donc comme une offre sérieuse en USD.

Trace d'une lead

POST /api/marketplace/leads écrit un ListingEvent CONTACT_CLICK avant l'envoi Resend, et répond 502 si l'envoi échoue. Il n'y a pas de table MarketplaceLead : cette ligne de timeline est la seule trace durable d'une lead. Avant, un échec d'envoi produisait un { ok: true } et un console.error contenant l'adresse du prospect, seule copie de la demande, dans une rétention de logs que rien ici ne contrôle. Le hold de dédup 60 s est relâché sur le 502, sinon la nouvelle tentative repartait en { ok: true, deduped: true }.

Dedup offers/leads

  • Offers : @@unique([listingId, buyerEmail, status]) côté DB + pre-check findFirst retournant 409.
  • Leads : hash SHA-256 court (16 chars) sur (listingId, email, message) stocké en Redis 60s → renvoie { ok: true, deduped: true } si match.

6. SEO

Structured Data JSON-LD (detail page)

  • WebPage + BreadcrumbList (toujours ; ProfilePage pour un freelance)

  • PAS d'Article. Cette ligne disait « + Article (toujours) » bien après son retrait. Une annonce était typée Product ET Article sur la même URL — même titre, même description, sans auteur, avec un datePublished qui n'existait que pour les items JSON. Deux types primaires contradictoires pour une page, c'est un tirage au sort sur l'entité qu'un moteur de réponse garde ; celle qui compte est le Product (prix, disponibilité, note).

  • un type schema.org par sorte d'annonce (marketplace/3005, listing-json-ld.ts, table exhaustive LISTING_SCHEMA_KIND) :

    TypesEntité
    APP, THEME, EXTENSION, MCP, TOOL, SKILLSoftwareApplication, le vendeur en publisher
    PROMPTCreativeWork
    AGENCYOrganization
    FREELANCERla page devient ProfilePage, mainEntity: Person
    STOREProduct sans brand, seulement avec un prix demandé
    JOB, NEWSLETTER, CLIaucune (aucun n'est public)

    Jusque-là, toute annonce était un Product de marque « BoostEcom » : une app tierce, le profil du fondateur, une agence, des offres d'emploi, des boutiques vendues par d'autres, et la plupart sans offre ni note (un Product invalide pour Google).

  • aucun Product sans offre ni note, et aucun vendeur par défaut : BoostEcom n'est nommé que sur nos annonces (ownedByBoostecom), un vendeur tiers seulement quand la page le nomme aussi (ligne « Seller » de la fiche, son nom public de /sellers/[id]). productJsonLd n'a plus de brand par défaut ;

  • offers suit le prix que la page affiche (listingOffer) : prix demandé pour une boutique, ONE_TIME / SUBSCRIPTION / FREE (prix 0) sinon, rien pour un prix négociable ;

  • aggregateRating quand reviewsCount > 0 ;

  • mode anonyme : ni image, ni site, ni vendeur, et aucune entité hors boutique (on ne publie pas une Person nommée « annonce privée »).

Liens sortants

  • le bouton « visit » d'une annonce (listingVisitRel, outbound-rel.ts) : sponsored si l'annonce a payé sa visibilité (featured ou un tier au-dessus de STARTER), nofollow pour tout autre tiers, rien de plus pour nos propres annonces. noopener noreferrer toujours ;
  • les emplacements pub (AdSlotCard, AdSlotsMobileBanner) portent AD_SLOT_REL = sponsored noopener noreferrer ;
  • le markdown écrit par un membre (ProseMarkdown : descriptions, reviews, forum) : ugc nofollow noopener noreferrer pour un hôte externe, rien pour nos propres URLs absolues.

Canonical

Une annonce a UNE URL, listingPathFor(card.type, slug) : le canonical, le sitemap, les ItemList des pages catégorie et vendeur. La recherche en base se fait par slug seul, donc /marketplace/<autre type>/<slug> rendait l'annonce avec un canonical sur le mauvais chemin ; il répond maintenant 308 vers le bon, en gardant un éventuel ?preview=.

Sitemap

src/app/sitemap.ts inclut les listings LIVE des catégories publiques (priorité 0.55-0.75 selon featured / verified), à leur URL canonique, plus une entrée par catégorie indexable au sens de isCategoryIndexable (0.8 daily) et /marketplace/hire quand isHireIndexable. Un fichier JSON n'est soumis que si aucune ligne de même slug n'existe en base (ou si la base ne répond pas) : une annonce archivée ou vendue ne revient plus par le catalogue. Un profil /sellers/[id] n'est soumis que pour un vendeur qui a une annonce LIVE non anonyme, et la page répond 404 sinon. Les slugs de catégorie sont dérivés de TYPE_ORDER : ce fichier portait sa propre liste de douze slugs écrite à la main, neuvième copie d'un mapping dont marketplace-constants.ts est la seule maison.

Pages hors index

  • catégorie mince ou non publique → noindex, follow (§1.1) ;
  • /marketplace/hire tant que ses deux catalogues sont minces (§1.1) ;
  • annonce d'une catégorie non publique (JOB) → noindex, follow ;
  • pages démo du hub (card.demo) → noindex, nofollow, nocache, hors sitemap, hors listes de catégorie, et sans aucun JSON-LD : publier un Product + AggregateRating sur des chiffres de démonstration laisserait un crawler mettre en cache des notes fictives.

Robots

src/app/robots.ts — /marketplace/** autorisé pour 24 crawlers AI (GPTBot, ClaudeBot, anthropic-ai, PerplexityBot, Cohere, Mistral, etc.).

LLM crawl

src/app/llms.txt/route.ts liste la marketplace en featured section.

7. Monétisation

ModeImplementation
Take fee 3%BOOSTECOM_MARKETPLACE_FEE_RATE dans types/marketplace
Stripe Taxautomatic_tax: { enabled: true } sur Checkout Session
ONE_TIME✅ supporté (Stripe Checkout mode: payment)
SUBSCRIPTION✅ supporté (Stripe Checkout mode: subscription, monthly) — chaque mois payé produit un Order, cf. §7.1
Featured slotDrapeau featured: true + tier GROWTH/SCALE ; expiry quotidienne via cron marketplace-maintenance
Sponsor/sponsor (les trois types HOME_ROTATION/FEATURED/NEWSLETTER, un seul <SponsorWizard> parametre par type, marketplace/0623) + /api/marketplace/sponsor-state + POST /api/sponsor/checkout (un chemin statique, discrimination sur le body) : l'occupation et le rang « founding » se comptent sur SponsorPlacement, via readSponsorMarketState (src/services/sponsor/placements.ts), une seule fois pour les deux surfaces qui affichent un prix — l'endpoint sponsor-state et le wizard lui-meme. L'ancienne page /checkout/sponsor (qui comptait autrefois un modele sponsor qui n'a jamais existe, derriere un as unknown as — corrige par app-shell/0390) est retiree : le wizard poste directement a /api/sponsor/checkout et redirige vers Stripe, et depuis growth-web/3018 ce n'est plus une page mais une regle 308 de next.config.mjs vers /sponsor. src/test/prisma-cast-names-a-real-model.test.ts refuse tout cast de prisma vers un modele que le schema ne declare pas
Diffusion (marketplace/2995) : le paiement RESERVE, la validation PUBLIE. SponsorPlacement.approvedAt est posee par « Valider et publier » dans /admin/marketplace/sponsors (badge sponsorsToReview) ; aucune surface ne rend une ligne sans elle. Rails : GET /api/sponsor/rotation (services/sponsor/live-rotation.ts) alimente AdSlotsProvider. Newsletter : marketplace/3028 ; featured : marketplace/3029
Affiliate30% du net sur 12 factures payees (src/config/affiliate.ts)
Stripe Connect✅ livre (V4) — onboarding Express, payout vendeur en escrow apres la fenetre de dispute 14j via le cron marketplace-payouts

7.1. Le cycle recurrent d'une listing SUBSCRIPTION

Le checkout d'une listing SUBSCRIPTION cree un abonnement Stripe, donc Stripe preleve l'acheteur tous les mois. Jusqu'a marketplace/0460, un seul de ces mois atteignait le vendeur :

  • checkout.session.completed basculait l'Order PENDING en PAID ;
  • cron/marketplace-payouts selectionne des lignes Order, donc il payait ce mois-la et rien d'autre ;
  • les factures de renouvellement ne resolvaient aucune Subscription locale, et handleInvoicePaid les laissait « fall through untouched ».

La plateforme encaissait donc 100 % des mois 2..n, sans Order, sans evenement, et sans que /sell/dashboard puisse le montrer.

La regle aujourd'hui : un mois paye = un Order PAID.

FaitOu
La regle (qui, combien, quand)services/marketplace/subscription-renewals.ts
Le cablagehandleInvoicePaid dans services/webhooks.ts — une delegation, retour anticipe
Ce qui identifie la factureinvoice.parent.subscription_details.metadata (snapshot fige par Stripe de subscription_data.metadata pose au checkout ; le shape pre-Basil, a la racine, est lu aussi)
IdempotenceOrder.stripeInvoiceId, unique en Postgres. StripeEvent ne dedupe que par event.id, et Stripe rejoue une livraison pendant trois jours : une seconde ligne ici serait un second virement reel au vendeur
Le montant partagetotal_excluding_tax plafonne a amount_paid. La TVA collectee pour un Etat n'est pas du revenu et n'est jamais partagee ; subtotal_excluding_tax ignore les remises au niveau facture
Mois 1billing_reason: "subscription_create" ne cree rien : il estampille sa facture sur l'Order du checkout, pour qu'un rejeu tardif tombe sur l'index unique au lieu de miner un renouvellement
L'escrowInchange. Chaque Order mensuel ouvre sa propre fenetre de dispute de 14 jours a partir de son paidAt, et le cron libere le net du mois quand elle se ferme

Aucun cron ni webhook nouveau. invoice.paid etait deja souscrit et cron/marketplace-payouts selectionne deja sur Order : le compte de crons de vercel.json (garde par pnpm docs:claims) ne bouge pas.

Ce qui manque encore : l'acheteur n'a aucune surface pour resilier son abonnement marketplace (le portail Stripe de ~/billing porte le client Stripe de l'organisation, pas celui que le Checkout cree pour l'acheteur). Suivi par marketplace/0462.

8. Email notifications

Toutes via Resend (cf. src/services/email/marketplace-*.ts).

EventTemplateService
Offer OTP (buyer)offer-otp.tsxmarketplace-offers.ts
Offer received (seller)offer-received.tsxmarketplace-offers.ts
Lead contact (seller)marketplace-lead.tsxmarketplace-leads.ts
Listing approved (seller)marketplace-decision.tsxmarketplace-decisions.ts (depuis cette PR)
Listing rejected (seller)marketplace-decision.tsxmarketplace-decisions.ts (depuis cette PR)

9. Vendor / Seller dashboard

Route : /sell/dashboard

Permissions : un vendeur voit ses propres listings, via listSellerListings(user.id). Le scope est l'UTILISATEUR, pas l'org : MarketplaceListing ne porte pas d'orgId, ce qui est precisement pourquoi la variante /[orgSlug]/~/listings a ete retiree (marketplace/0145).

Pages :

  • /sell/dashboard — index + stats agrégées + table + bulk actions
  • /sell/dashboard/new — create (draft ou submit for review)
  • /sell/dashboard/analytics — KPIs 30j + top performers
  • /sell/dashboard/[id] — detail + offres + deals
  • /sell/dashboard/[id]/edit — edition complete (rhf + zod, preview markdown, upload image, editeur de screenshots)
  • /sell/dashboard/[id]/analytics — analytics par listing (7/30/90j)

Corrige le 2026-09-05. Cette section disait « CRUD complet : 🚧 lecture seule pour l'instant ». L'edition complete est livree, avec sa route, son formulaire et son editeur de screenshots — la ligne decrivait l'etat d'avant. Un doc qui annonce comme a faire quelque chose qui est fait n'est pas seulement perime : il fait rouvrir le chantier.

Compteur de vues (marketplace/0440)

trackListingView existait depuis la V4, correcte, sans aucun appelant : MarketplaceListing.viewsCount valait 0 sur chaque ligne, ListingEvent ne contenait aucun VIEW, et les deux écrans d'analytics vendeur affichaient un zéro qui se lit comme une mesure. Le rail « les acheteurs ont aussi vu » n'avait rien à corréler.

Elle est appelée depuis src/app/(marketing)/marketplace/[type]/[slug]/page.tsx, via after(() => …) : le travail part une fois la réponse committée, il n'entre jamais en concurrence avec le rendu pour une connexion du pool.

Qui compte, et qui ne compte pas (view-tracking.ts) :

RefusPourquoi
user-agent de bot ou viderobots.txt invite GPTBot, ClaudeBot, PerplexityBot et Google-Extended sur le catalogue exprès. Un balayage de crawler offrirait quelques milliers de « vues » par semaine à une annonce sponsorisée
sec-fetch-dest ≠ documentune sous-ressource n'est pas une navigation (même règle que proxy.ts)
item démo, ?preview=, vendeur lui-mêmevirtuel, brouillon privé, ou ses propres statistiques
entrée JSON du cataloguepas de ligne à incrémenter, pas de listingId pour la FK
même visiteur, même annonce, même jourdédup par visitorHash

Le visitorHash dérive de l'IP de confiance (dernier hop de x-forwarded-for, jamais la position 0) : un client qui fait tourner l'en-tête ne peut pas gonfler le compteur.

viewsCount compte désormais des visiteurs distincts par jour, pas des chargements de page, et c'est la même quantité que COUNT(ListingEvent WHERE kind = 'VIEW'). Avant, l'incrément précédait l'écriture de l'évènement et seul l'évènement était dédupliqué : garder F5 enfoncé laissait une ligne de timeline et N « vues », donc les deux nombres que la page analytics imprime côte à côte décrivaient des choses différentes et le gonflable était celui que le vendeur lit comme de la demande.

Conséquence assumée : cette page lit headers(), donc elle ne redeviendra pas cacheable le jour où app-shell/0361 libère le layout racine. Un compteur de vues et une page en cache s'excluent.

10. Cache

SurfaceCache
listUnified()Upstash Redis 5 min anonymement, viewer flags joints
listLiveCountsByType()aucun, un groupBy ; dédupliqué par requête via cache()
/marketplace indexaucun — revalidate = 3600 est inerte (§2)
/marketplace/[type]aucun — revalidate = 300 est inerte (§2)
/marketplace/[type]/[slug]aucun, et ce ne changera pas : la page lit headers() (compteur de vues)
/api/marketplace/statss-maxage=300, swr=3600
/api/marketplace/recommendationss-maxage=600, swr=3600 + 30/min RL
/api/marketplace/sponsor-states-maxage=300, swr=3600

11. Composants partagés

src/components/shared/marketplace/ — 27 composants (ls *.tsx, hors tests) : ListingCard, ListingRail, BuyListingButton, ContactListingButton, MakeOfferButton, SaveListingButton, WriteReviewButton, FollowButton, StoreMetricsHero, TrustStrip, InstallSnippet, ScreenshotsGallery, AdminNotesPanel, SellStoreWizard, RegistryFields, SignaturesPanel, RaiseDisputeDialog, KycBanner, …

Cette ligne annonçait « 19 composants » et en nommait un, ListingFilters, qui n'existe pas — aucun fichier, aucun import. C'est de là que venait la mention de « filtres » sur la page liste en §2 : la doc citait un composant fantôme, puis décrivait la fonctionnalité qu'il aurait portée.

TrustStrip mérite une note : les logos qu'il affiche sous « Verified by » sont une affirmation factuelle sur ce que la plateforme peut prouver, pas un bandeau partenaires. La barre d'admission est écrite dans le fichier — un vendeur doit pouvoir connecter la source dans revenue-connect ET VerifiedRevenue.source doit l'accepter, ce qui laisse Shopify et Stripe.

Stack conforme : shadcn/ui, lucide-react, sonner, next-intl. Anti-patterns interdits : framer-motion, react-hot-toast, modal custom.

12. Tests

Il n'y a pas de répertoire tests/ dans ce repo : les tests vivent à côté du code qu'ils couvrent, en *.test.ts. Cette section a cité trois chemins inexistants jusqu'en septembre 2026.

13. Roadmap (haut niveau)

  • ✅ V1 : DB schema + services + queue admin + emails + audit log
  • ✅ V1.1 (cette PR) : indexes composites + cache + vendor dashboard skeleton + decision emails + SEO Product/AggregateRating + idempotence checkout + dedup offers/leads
  • ✅ V2 : CRUD vendor complet (forms react-hook-form + zod)
  • ✅ V3 : Stripe Connect (payout vendeur, versé en escrow par cron)
  • ✅ V4 : disputes + refund automation + ListingEvent
  • ✅ V5.1 : KYC pipeline + scaffold e-signature NDA/LOI/APA. POST /api/marketplace/kyc/start n'accepte que STRIPE_IDENTITY : c'est le seul provider branché. PERSONA / ONFIDO existent dans l'enum mais n'ont aucun client, et startVerification les refuse (kyc-provider-not-wired) au lieu de créer une ligne IN_REVIEW que personne ne peut faire avancer ; MANUAL est une décision admin, pas une demande utilisateur. Un second start sur une vérification déjà ouverte renvoie de nouveau une URL hébergée (retrieve, puis nouvelle session si le lien de 48 h est périmé) au lieu de null. Le verdict provider n'arrive toujours que par un pull admin : le webhook identity.verification_session.* est un item billing. Depuis marketplace/0441 ce pipeline garde une porte : l'APA signé par l'acheteur au-dessus de $10 000 (§3, « La seule condition KYC du pipeline »), et depuis marketplace/0603 la sortie de DILIGENCE elle-même, que l'APA ait été signé par l'acheteur ou par le vendeur. Il a été installé et n'a rien conditionné pendant toute la V5.1.
  • ✅ V5.2 (marketplace/0645) : une seule source de vérité pour les types et leurs capacités (TYPE_CAPABILITIES), surface publique explicite (§1.1), CLI retiré pour de bon, catégories vides hors index, pages catégorie traduites dans les six locales, et le tableau de la §1 vérifié par un test.
  • 🚧 Reste : Meilisearch (le tsvector suffit aujourd'hui), embeddings sémantiques pour les recos, DocuSign/HelloSign live, mobile. Pas de filtres sur les pages catégorie : le besoin est servi par le Hub de la home, et c'est un choix, pas un oubli (cf. §2 et §11).

Voir docs/architecture/marketplace-roadmap.md pour le détail.