src/app/api/search/route.ts
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.
Concepts détectés — comprends la théorie
ORM Prisma
6 occurrencesCe fichier accède à la base de données via Prisma. Prisma est l'ORM utilisé côté backend pour les requêtes typées sur PostgreSQL.
Voir l'article général
Route API Next.js
3 occurrencesCe fichier est une route API Next.js (App Router). Voir le contrat API complet pour les conventions de réponse et d'auth.
Voir l'article général
JWT / Auth backend
1 occurrenceCe fichier touche au système d'authentification (JWT, session, getSessionFromRequest). Voir le contrat API pour la logique complète.
Voir l'article général
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,
};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,
};