ArchitectureRecherche globale

Recherche globale

Corrige docs/audits/2026-09-11-recherche-globale.md : cinq moteurs de recherche cloisonnes, aucune ressource de l'OS cherchable depuis le dashboard, un ⌘K reserve a /admin. Pilier : app-shell (backend + wiring) et…

Corrige docs/audits/2026-09-11-recherche-globale.md : cinq moteurs de recherche cloisonnes, aucune ressource de l'OS cherchable depuis le dashboard, un ⌘K reserve a /admin. Pilier : app-shell (backend + wiring) et design-system (le composant <GlobalCommandPalette>, sous src/components/**).

Vue d'ensemble

GET /api/search?q=...
        │
        ▼
resolveSearchActor(userId)        src/lib/search/actor.ts
        │  (orgs + role + permissions, isPlatformAdmin)
        ▼
runGlobalSearch(actor, q)          src/lib/search/orchestrator.ts
        │
        ├─ isAvailable(actor) ? ── non ─▶ provider jamais appele
        │
        ├─▶ provider.search()  (en parallele, timeout 2.5s chacun)
        │     stores · organizations · scans · audits · tasks ·
        │     conversations · members · marketplace · pages · docs
        │
        ▼
combineScore() + tri + slice(totalLimit)
        │
        ▼
{ query, hits: RankedHit[], degraded: string[] }

Le moteur ne remplace AUCUN des trois moteurs specialises existants — il les reutilise :

  • marketplace provider appelle searchListings() (src/services/marketplace/search.ts, Postgres FTS + cache KV) telle quelle.

  • pages provider lit ADMIN_ROUTES (src/config/admin-routes.ts), le MEME registre que consulte deja <AdminCommandPalette>.

  • docs provider appelle searchDocs() (src/lib/content/docs-search.ts), le MEME moteur que les outils MCP searchDocs / getDoc. C'est la regle de ce fichier appliquee une fois de plus : un agent et un humain qui posent la meme question au meme corpus doivent obtenir la meme reponse, et deux implementations divergent le jour ou l'une apprend a matcher un titre de section et pas l'autre.

    Ce provider cherche les TITRES DE SECTION, pas seulement les titres de page, et c'est la ou est sa valeur : « comment contester un prelevement » ne correspond a aucun titre de page du corpus, il correspond a une section de /docs/disputes. Les ancres viennent de collectHeadings, la fonction qui produit deja celles que la page rend, donc un hit peut pointer /docs/disputes#the-window et tomber sur le paragraphe. Une recherche qui ne lit que les titres repond « rien trouve » aux questions que les gens posent vraiment.

    Public et anonyme : le corpus est servi sans session sur /docs, donc le scoper par acteur cacherait a un utilisateur connecte ce qu'un inconnu peut lire.

  • La recherche vectorielle par boutique (services/knowledge/search.ts) reste hors de ce moteur : elle repond a une question differente (« que sait l'IA sur CETTE boutique »), pas « ou est la ressource X ».

Le contrat provider (src/lib/search/types.ts)

interface SearchProvider {
  id: SearchProviderId
  label: string        // interne, jamais rendu — voir i18n plus bas
  weight: number        // [0,1], importance relative de la ressource
  minQueryLength?: number
  isAvailable(actor): boolean            // gate GROSSIER
  search(query, actor, limit): Promise<SearchHit[]>  // scoping REEL
}

isAvailable() n'est jamais le controle d'acces. C'est un filtre de cout : un non-admin ne declenche jamais le provider pages (ADMIN uniquement), donc aucune requete n'est meme tentee. Le VRAI controle d'acces vit dans le WHERE de chaque search() — un non-admin qui appellerait storesProvider.search() directement obtiendrait quand meme un resultat scope a ses propres organisations, jamais la table entiere. Deux tests le prouvent explicitement contre un Prisma mocke : providers/stores.test.ts verifie que la clause where.orgId porte les orgs de l'acteur pour un non-admin et est absente pour un admin.

Ajouter une ressource cherchable = un fichier dans providers/ + une ligne dans registry.ts. Rien d'autre ne doit changer.

Deux exceptions a ce « rien d'autre », toutes deux tenues par un test plutot que par la memoire : l'id doit rejoindre l'union SearchProviderId (registry.test.ts compare les deux), et le libelle de groupe doit exister dans les six catalogues (patterns.navigation.globalSearch.groups.<id>) — sinon le loader rend un libelle derive de la cle, plausible et faux. Le typage exhaustif de PROVIDER_ICON dans global-command-palette-dialog.tsx attrape l'icone manquante a la compilation.

L'acteur (src/lib/search/actor.ts)

resolveSearchActor(userId) reproduit deliberement le raccourci « owner » de getOrgAccess() (lib/security/tenant-auth.ts) : une organisation POSSEDEE (Organization.ownerId === userId) donne role: "owner" meme sans ligne OrganizationMember. Un provider qui aurait requete OrganizationMember seul aurait silencieusement perdu toute organisation creee mais jamais rejointe comme membre — c'est-a-dire la totalite des organisations d'un proprietaire sur ce schema.

SearchActorOrg porte role + permissions (l'override JSON lu par readPermissionOverride), pas seulement orgId : un provider comme members reutilise hasAccessPermission() sans re-deriver l'acces a partir de zero.

Classement (src/lib/search/rank.ts)

score = textScore * 0.7 + providerWeight * 0.2 + recency * 0.1
  • textScore (scoreTextMatch) : exact > prefixe de titre > sous-chaine de titre > exact de sous-titre > prefixe/sous-chaine de sous-titre > chevauchement de tokens pour une requete multi-mots. Normalise sur [0,1], comparable entre TOUS les providers meme si aucun ne voit les lignes des autres.
  • recency (recencyScore) : decroissance exponentielle, demi-vie 30 jours. Une ressource sans horodatable (une page admin statique) recoit 0.5 — ni penalisee ni favorisee.
  • providerWeight : importance relative declaree par le provider (stores: 1, organizations: 0.9, audits: 0.6, scans: 0.55, tasks/members: 0.5, conversations: 0.45, marketplace: 0.4, pages: 0.35).

Le texte domine (0.7) deliberement : un provider « important » ne doit jamais enterrer une correspondance exacte d'un provider secondaire. Poids et recence ne departagent qu'entre hits deja comparables.

Un hit dont textScore <= 0 est ecarte par l'orchestrateur meme si le provider l'a renvoye : un provider est garant de la PORTEE (qui peut voir la ligne), jamais de la PERTINENCE — bug de provider ou filtre contains trop permissif ne remontent donc jamais en tete de liste avec un badge 0.

Echecs partiels (src/lib/search/orchestrator.ts)

Chaque provider tourne sous un timeout (2.5 s) et un try/catch. Un provider qui expire ou leve une exception est retire du resultat ET NOMME dans degraded: string[] — jamais absorbe silencieusement dans une liste plus courte. GET /api/search renvoie ce tableau tel quel, et <GlobalCommandPaletteDialog> l'affiche comme un bandeau d'avertissement au-dessus de la liste, distinct de l'etat « aucun resultat ».

GlobalSearchOptions.providers et .timeoutMs existent uniquement pour les tests (orchestrator.test.ts injecte des providers factices et un timeout de quelques millisecondes) — le code de production ne les passe jamais.

Route (src/app/api/search/route.ts)

GET /api/search?q=..., withSessionAuth (session NextAuth requise, CSRF + rate limit 60/min par utilisateur/route reutilises tels quels, aucune politique maison). Pas de requireOrg : un appelant sans organisation recoit quand meme les hits des providers actor-independants (marketplace).

UI (src/components/patterns/navigation/global-command-palette-dialog.tsx)

Ne possede pas son propre raccourci ⌘K. Une premiere version montait un declencheur+listener independant dans <SharedHeader> ; au meme moment, design-system/0605 livrait <WorkspaceCommandPalette> — l'autre agent de la meme reconciliation de flotte — qui revendique le MEME raccourci ⌘K sur les MEMES pages (partout hors /admin), monte au MEME endroit (center de <SharedHeader>, cf. shell-client.tsx). Deux listeners keydown sur ⌘K sur les memes pages auraient ouvert les deux dialogues a chaque frappe. Trouve et resolu au merge de origin/main (pas a la revue) : <WorkspaceCommandPalette> garde l'unique raccourci et son propre declencheur ; sa nouvelle entree « Search everything » (dashboard.commandPalette.searchEverywhere) ferme son dialogue et ouvre celui-ci a la place — deux dialogues freres dans le temps, jamais imbriques. Ce fichier reste donc la SEULE piece UI de ce document : cmdk, le fetch /api/search, le rendu des groupes, tous les etats. dynamic(..., { ssr: false }) + le meme useArmed (vu, jamais demonte), desormais porte par <WorkspaceCommandPalette> plutot que par un declencheur dedie.

<WorkspaceCommandPalette> reste par ailleurs une pure liste d'actions qui reutilisent une surface existante — jamais un fetch a elle (src/test/workspace-command-palette.test.ts le garde) : le round-trip reseau vit entierement ici, un saut plus loin.

Coexistence avec <AdminCommandPalette> : inchangee. <AdminCommandPalette>, montee par <AdminCategoryRail>, garde son propre listener sous /admin. Les routes admin restent NEANMOINS cherchables depuis n'importe quelle autre page via le provider pages, qui lit le meme ADMIN_ROUTES : un admin qui tape ⌘K depuis /acme/ma-boutique, tape « submissions » dans « Search everything », retrouve la route sans quitter sa page.

i18n

patterns.navigation.globalSearch (les six locales, messages/*.json) : libelle du declencheur, placeholder, etats (chargement/vide/erreur/ degrade), et les noms de groupe par providerId. Le provider pages reste volontairement en anglais : c'est le meme choix que <AdminCommandPalette> (outillage BoostEcom-interne, jamais expose a un client), pas un oubli.

Limite connue, assumee : aucune section de navigation ORG-scopee (Membres, Facturation, Parametres, Deals…) n'est indexee aujourd'hui. Ces libelles sont customer-facing et auraient exige soit un appel next-intl cote Route Handler — un pattern qu'aucune autre route src/app/api/** n'utilise dans ce depot — soit une liste anglaise en dur qui aurait contourne le contrat six-locales tenu partout ailleurs. Le choix a ete de ne PAS livrer un raccourci qui casse ce contrat plutot que de forcer le pattern sans le valider ailleurs d'abord. Un provider sections dedie, avec son propre registre traduit, est le suivi naturel — voir backlog/app-shell/.

Ajouter une ressource cherchable

  1. src/lib/search/providers/<nom>.ts — implementer SearchProvider. Copier stores.ts (le plus simple, scope org direct) ou audits.ts (fusion de deux modeles) selon le cas.
  2. Une ligne dans SEARCH_PROVIDERS (registry.ts).
  3. Une entree dans SearchProviderId (types.ts) — registry.test.ts echoue si le registre et le type union divergent dans un sens ou l'autre.
  4. Une entree groups.<id> dans patterns.navigation.globalSearch des SIX locales (script Python, jamais d'edition a la main — voir messages/CLAUDE.md), sauf pour une ressource strictement interne comme pages.
  5. Si le provider touche Prisma : un test avec vi.mock("@/lib/core/database") qui prouve la clause de scoping, sur le modele de providers/stores.test.ts.