src/app/api/search/route.ts

route·app·8.0 KB · 223 lignes· Voir l'itinéraire
Annotation non disponible

Lance npm run annotate (nécessite ANTHROPIC_API_KEY dans .env.local) pour générer une annotation française par Claude Haiku 4.5.

1 export

GET

Code source· typescript· tronqué à 200 lignes sur 223

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma/client";
import { filtreTenantsAccessibles, getConfigEffective } from "@/lib/acces-vitrine";
import { getSessionFromRequest } from "@/lib/auth/session";

// Pilier 1 — Recherche universelle cross-types.
// V1 : ILIKE simple, ranking par medias_count desc (favorise items avec photo).
// V2 : pg_trgm/full-text + scoring (extension déjà activée par migration).

const MIN_QUERY_LENGTH = 2;
const DEFAULT_LIMIT_PER_TYPE = 5;
const MAX_LIMIT_PER_TYPE = 30;

const VALID_TYPES = ["all", "produits", "prestations", "plats", "vitrines", "categories"] as const;
type SearchType = (typeof VALID_TYPES)[number];

const TENANT_NESTED_SELECT = {
  id: true,
  subdomain: true,
  nom: true,
  logoUrl: true,
  whatsapp: true,
  niveauAcces: true,
  configAcces: true,
} as const;

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const qRaw = (searchParams.get("q") ?? "").trim();
  const typeParam = (searchParams.get("type") ?? "all") as SearchType;
  const limitRaw = Number(searchParams.get("limit") ?? DEFAULT_LIMIT_PER_TYPE);
  const limit = Math.min(Math.max(1, Number.isFinite(limitRaw) ? Math.floor(limitRaw) : DEFAULT_LIMIT_PER_TYPE), MAX_LIMIT_PER_TYPE);
  const offsetRaw = Number(searchParams.get("offset") ?? 0);
  const offset = Math.max(0, Number.isFinite(offsetRaw) ? Math.floor(offsetRaw) : 0);

  if (qRaw.length < MIN_QUERY_LENGTH) {
    return NextResponse.json(
      { error: `Requête trop courte (min ${MIN_QUERY_LENGTH} caractères)` },
      { status: 400 },
    );
  }
  const type: SearchType = VALID_TYPES.includes(typeParam) ? typeParam : "all";

  // Pattern ILIKE — case-insensitive substring
  const q = qRaw;
  const like = `%${q}%`;

  // Filtre accès vitrine (Bearer optionnel pour clients connectés)
  const session = await getSessionFromRequest(req).catch(() => null);
  const clientId = session?.role === "CLIENT" ? session.userId : null;
  const tenantFiltre = filtreTenantsAccessibles(clientId);
  // pour Tenant.findMany direct
  const tenantWhereBase = { actif: true, vitrineActive: true, ...tenantFiltre };
  // pour relations imbriquées (Produit.tenant, Plat.tenant, etc.)
  const tenantNestedWhere = { actif: true, vitrineActive: true, ...tenantFiltre };

  const includeProduits = type === "all" || type === "produits";
  const includePrestations = type === "all" || type === "prestations";
  const includePlats = type === "all" || type === "plats";
  const includeVitrines = type === "all" || type === "vitrines";
  const includeCategories = type === "all" || type === "categories";

  try {
    const [produits, prestations, plats, vitrines, categoriesRoot] = await Promise.all([
      includeProduits
        ? prisma.produit.findMany({
            where: {
              disponible: true,
              tenant: tenantNestedWhere,
              statut: { in: ["DISPONIBLE", "SUR_COMMANDE"] },
              OR: [
                { nom: { contains: q, mode: "insensitive" } },
                { description: { contains: q, mode: "insensitive" } },
                { marque: { contains: q, mode: "insensitive" } },
                { tags: { has: q } },
              ],
            },
            take: limit,
            skip: offset,
            orderBy: [{ medias: { _count: "desc" } }, { createdAt: "desc" }],
            include: {
              medias: { orderBy: { ordre: "asc" }, take: 1, select: { url: true, type: true, ordre: true } },
              tenant: { select: TENANT_NESTED_SELECT },
            },
          })
        : Promise.resolve([]),
      includePrestations
        ? prisma.prestation.findMany({
            where: {
              disponible: true,
              tenant: tenantNestedWhere,
              OR: [
                { nom: { contains: q, mode: "insensitive" } },
                { description: { contains: q, mode: "insensitive" } },
              ],
            },
            take: limit,
            skip: offset,
            orderBy: [{ medias: { _count: "desc" } }, { ordre: "asc" }],
            include: {
              medias: { orderBy: { ordre: "asc" }, take: 1, select: { url: true, type: true, ordre: true } },
              tenant: { select: TENANT_NESTED_SELECT },
            },
          })
        : Promise.resolve([]),
      includePlats
        ? prisma.plat.findMany({
            where: {
              disponible: true,
              tenant: { ...tenantNestedWhere, modules: { some: { nom: "restaurant", actif: true } } },
              OR: [
                { nom: { contains: q, mode: "insensitive" } },
                { description: { contains: q, mode: "insensitive" } },
              ],
            },
            take: limit,
            skip: offset,
            orderBy: [{ medias: { _count: "desc" } }, { ordre: "asc" }],
            include: {
              medias: { orderBy: { ordre: "asc" }, take: 1, select: { url: true, type: true, ordre: true } },
              tenant: { select: TENANT_NESTED_SELECT },
            },
          })
        : Promise.resolve([]),
      includeVitrines
        ? prisma.tenant.findMany({
            where: {
              ...tenantWhereBase,
              OR: [
                { nom: { contains: q, mode: "insensitive" } },
                { description: { contains: q, mode: "insensitive" } },
                { ville: { contains: q, mode: "insensitive" } },
              ],
            },
            take: limit,
            skip: offset,
            orderBy: { createdAt: "desc" },
            select: {
              id: true,
              subdomain: true,
              nom: true,
              description: true,
              logoUrl: true,
              banniere: true,
              whatsapp: true,
              ville: true,
              pays: true,
              themeCouleur: true,
              couleurAccent: true,
              niveauAcces: true,
              categorieWari: { select: { id: true, nom: true, emoji: true } },
              configAcces: true,
            },
          })
        : Promise.resolve([]),
      includeCategories
        ? prisma.categorieWari.findMany({
            where: {
              OR: [
                { nom: { contains: q, mode: "insensitive" } },
                { slug: { contains: q, mode: "insensitive" } },
              ],
            },
            take: limit,
            skip: offset,
            orderBy: [{ tenantsWari: { _count: "desc" } }, { nom: "asc" }],
            select: { id: true, nom: true, slug: true, emoji: true, couleur: true, parentId: true },
          })
        : Promise.resolve([]),
    ]);

    // Tracker la query (best-effort, ne pas bloquer la réponse)
    const qNormalized = qRaw.toLowerCase();
    prisma.searchQuery
      .upsert({
        where: { q: qNormalized },
        create: { q: qNormalized },
        update: { count: { increment: 1 }, lastSeenAt: new Date() },
      })
      .catch(() => {});

    const results = {
      produits: produits.map((p) => ({
        ...p,
        tenant: { ...p.tenant, configAcces: getConfigEffective(p.tenant.configAcces) },
      })),
      prestations: prestations.map((p) => ({
        ...p,
        tenant: { ...p.tenant, configAcces: getConfigEffective(p.tenant.configAcces) },
      })),
      plats: plats.map((p) => ({
        ...p,
        tenant: { ...p.tenant, configAcces: getConfigEffective(p.tenant.configAcces) },
      })),
      vitrines: vitrines.map((v) => ({
        ...v,
        configAcces: getConfigEffective(v.configAcces),
      })),
      categories: categoriesRoot,
    };