src/app/api/mobile/explorer/feed/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
4 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 502
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";
// Sprint Explorer Feed Algorithmique — 2026-05-18
// Endpoint cœur du nouvel Explorer mobile.
// Retourne un feed paginé de cards typées (VITRINE / PRODUIT / PRESTATION /
// COLLECTION / ARRIVAGE) mixées selon un mix pondéré déterministe :
// - 40% VITRINE · 30% PRODUIT · 15% PRESTATION · 10% COLLECTION · 5% ARRIVAGE
// Sur une page de 20 items : 8/6/3/2/1.
// Bonus : si Bearer CLIENT, boost des vitrines suivies en top.
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
const FEED_CACHE_HEADERS: Record<string, string> = {
"Cache-Control": "public, max-age=30, s-maxage=60, stale-while-revalidate=300",
Vary: "Authorization",
};
const TENANT_SUMMARY_SELECT = {
id: true,
subdomain: true,
nom: true,
description: true,
logoUrl: true,
banniere: true,
whatsapp: true,
ville: true,
pays: true,
themeCouleur: true,
couleurAccent: true,
modeVitrineSiteWeb: true,
siteWebUrl: true,
niveauAcces: true,
marketCode: true,
categorieWari: { select: { id: true, nom: true, emoji: true } },
configAcces: true,
modules: { where: { actif: true }, select: { nom: true } },
} as const;
type FeedType = "VITRINE" | "PRODUIT" | "PRESTATION" | "COLLECTION" | "ARRIVAGE" | "MIXTE";
function parseType(raw: string | null): FeedType {
const valid: FeedType[] = ["VITRINE", "PRODUIT", "PRESTATION", "COLLECTION", "ARRIVAGE", "MIXTE"];
if (raw && valid.includes(raw as FeedType)) return raw as FeedType;
return "MIXTE";
}
// Mix sur 20 items : 8 vitrines, 6 produits, 3 prestations, 2 collections, 1 arrivage
function quotas(limit: number) {
const ratio = limit / 20;
return {
vitrines: Math.round(8 * ratio),
produits: Math.round(6 * ratio),
prestations: Math.round(3 * ratio),
collections: Math.max(1, Math.round(2 * ratio)),
arrivages: Math.max(1, Math.round(1 * ratio)),
};
}
// V1 : on retourne TOUTES les collections actives quelle que soit la saison.
// La saisonnalité sert au tri/boost côté algorithmique (V2). Sans ça, sur 3
// collections seedées seulement 0-1 serait visible selon la date du jour.
function buildCollectionsWhere(_now: Date, marketCode: string | null): any {
const where: any = { actif: true };
if (marketCode) {
where.OR = [{ marketCodes: { isEmpty: true } }, { marketCodes: { has: marketCode } }];
}
return where;
}
// PRNG déterministe simple basé sur seed (page number). Permet un shuffle
// stable au refresh d'une même page mais varié entre pages.
function seededShuffle<T>(arr: T[], seed: number): T[] {
const a = arr.slice();
let s = seed * 9301 + 49297;
for (let i = a.length - 1; i > 0; i--) {
s = (s * 9301 + 49297) % 233280;
const j = Math.floor((s / 233280) * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function serializeTenant(t: {
configAcces: Parameters<typeof getConfigEffective>[0];
modules: { nom: string }[];
[key: string]: unknown;
}) {
const { modules, configAcces, ...rest } = t;
return {
...rest,
modulesActifs: modules.map((m) => m.nom),
configAcces: getConfigEffective(configAcces),
};
}
function serializeProduit(p: any) {
return {
id: p.id,
nom: p.nom,
description: p.description,
prix: p.prix,
ancienPrix: p.ancienPrix,
devise: p.devise,
stock: p.stock,
disponible: p.disponible,
statut: p.statut,
marque: p.marque,
tags: p.tags,
videoUrl: p.videoUrl,
medias: p.medias,
categories: p.categories.map((c: any) => c.categorie),
tenant: { ...p.tenant, configAcces: getConfigEffective(p.tenant.configAcces) },
};
}
function serializePrestation(p: any) {
return {
id: p.id,
nom: p.nom,
description: p.description,
prixMin: p.prixMin,
prixMax: p.prixMax,
devise: p.devise,
duree: p.duree,
modeContact: p.modeContact,
medias: p.medias,
tenant: { ...p.tenant, configAcces: getConfigEffective(p.tenant.configAcces) },
};
}
export async function GET(req: NextRequest) {
try {
const session = await getSessionFromRequest(req);
const clientId = session?.role === "CLIENT" ? session.userId : null;
const { searchParams } = req.nextUrl;
const page = Math.max(1, parseInt(searchParams.get("page") || "1"));
const limit = Math.min(MAX_LIMIT, Math.max(1, parseInt(searchParams.get("limit") || String(DEFAULT_LIMIT))));
const type = parseType(searchParams.get("type"));
const marketCode = searchParams.get("marketCode");
const ville = searchParams.get("ville");
const categorieId = searchParams.get("categorieId");
const filtre = filtreTenantsAccessibles(clientId);
// Filtre tenant base utilisé pour vitrines/produits/prestations
const tenantFilter: any = { actif: true, vitrineActive: true, ...filtre };
if (marketCode) tenantFilter.marketCode = marketCode;
if (ville) tenantFilter.ville = { contains: ville, mode: "insensitive" };
// 2026-05-18 — Filtre catégorie sur Accueil → Explorer.
// Match si tenant a la catégorie soit en single FK (`categorieWariId`),
// soit dans la relation M2M (`categoriesWari[]` via TenantCategorieWari).
// L'ancien filtre ne regardait que la single FK, qui est NULL pour la
// majorité des tenants seedés → 0 résultat systématique.
if (categorieId) {
tenantFilter.OR = [
{ categorieWariId: categorieId },
{ categoriesWari: { some: { categorieWariId: categorieId } } },
];
}
// ─── Filtre par type (single-type mode) ───────────────────────────────────
// Si type=VITRINE/PRODUIT/PRESTATION/COLLECTION/ARRIVAGE → on remplit
// uniquement avec ce type. Sinon (MIXTE), on applique le mix pondéré.
const skip = (page - 1) * limit;
if (type === "VITRINE") {
const [vitrines, total] = await Promise.all([
prisma.tenant.findMany({
where: tenantFilter,
skip,
take: limit,
orderBy: { createdAt: "desc" },
select: TENANT_SUMMARY_SELECT,
}),
prisma.tenant.count({ where: tenantFilter }),
]);
return NextResponse.json(
{
items: vitrines.map((v) => ({ type: "VITRINE" as const, item: serializeTenant(v as any) })),
page,
totalPages: Math.ceil(total / limit),
},
{ headers: FEED_CACHE_HEADERS },
);
}
if (type === "PRODUIT") {
const where = {
disponible: true,
tenant: tenantFilter,
statut: { in: ["DISPONIBLE", "SUR_COMMANDE"] as const },
};
const [produits, total] = await Promise.all([
prisma.produit.findMany({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";
// Sprint Explorer Feed Algorithmique — 2026-05-18
// Endpoint cœur du nouvel Explorer mobile.
// Retourne un feed paginé de cards typées (VITRINE / PRODUIT / PRESTATION /
// COLLECTION / ARRIVAGE) mixées selon un mix pondéré déterministe :
// - 40% VITRINE · 30% PRODUIT · 15% PRESTATION · 10% COLLECTION · 5% ARRIVAGE
// Sur une page de 20 items : 8/6/3/2/1.
// Bonus : si Bearer CLIENT, boost des vitrines suivies en top.
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
const FEED_CACHE_HEADERS: Record<string, string> = {
"Cache-Control": "public, max-age=30, s-maxage=60, stale-while-revalidate=300",
Vary: "Authorization",
};
const TENANT_SUMMARY_SELECT = {
id: true,
subdomain: true,
nom: true,
description: true,
logoUrl: true,
banniere: true,
whatsapp: true,
ville: true,
pays: true,
themeCouleur: true,
couleurAccent: true,
modeVitrineSiteWeb: true,
siteWebUrl: true,
niveauAcces: true,
marketCode: true,
categorieWari: { select: { id: true, nom: true, emoji: true } },
configAcces: true,
modules: { where: { actif: true }, select: { nom: true } },
} as const;
type FeedType = "VITRINE" | "PRODUIT" | "PRESTATION" | "COLLECTION" | "ARRIVAGE" | "MIXTE";
function parseType(raw: string | null): FeedType {
const valid: FeedType[] = ["VITRINE", "PRODUIT", "PRESTATION", "COLLECTION", "ARRIVAGE", "MIXTE"];
if (raw && valid.includes(raw as FeedType)) return raw as FeedType;
return "MIXTE";
}
// Mix sur 20 items : 8 vitrines, 6 produits, 3 prestations, 2 collections, 1 arrivage
function quotas(limit: number) {
const ratio = limit / 20;
return {
vitrines: Math.round(8 * ratio),
produits: Math.round(6 * ratio),
prestations: Math.round(3 * ratio),
collections: Math.max(1, Math.round(2 * ratio)),
arrivages: Math.max(1, Math.round(1 * ratio)),
};
}
// V1 : on retourne TOUTES les collections actives quelle que soit la saison.
// La saisonnalité sert au tri/boost côté algorithmique (V2). Sans ça, sur 3
// collections seedées seulement 0-1 serait visible selon la date du jour.
function buildCollectionsWhere(_now: Date, marketCode: string | null): any {
const where: any = { actif: true };
if (marketCode) {
where.OR = [{ marketCodes: { isEmpty: true } }, { marketCodes: { has: marketCode } }];
}
return where;
}
// PRNG déterministe simple basé sur seed (page number). Permet un shuffle
// stable au refresh d'une même page mais varié entre pages.
function seededShuffle<T>(arr: T[], seed: number): T[] {
const a = arr.slice();
let s = seed * 9301 + 49297;
for (let i = a.length - 1; i > 0; i--) {
s = (s * 9301 + 49297) % 233280;
const j = Math.floor((s / 233280) * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function serializeTenant(t: {
configAcces: Parameters<typeof getConfigEffective>[0];
modules: { nom: string }[];
[key: string]: unknown;
}) {
const { modules, configAcces, ...rest } = t;
return {
...rest,
modulesActifs: modules.map((m) => m.nom),
configAcces: getConfigEffective(configAcces),
};
}
function serializeProduit(p: any) {
return {
id: p.id,
nom: p.nom,
description: p.description,
prix: p.prix,
ancienPrix: p.ancienPrix,
devise: p.devise,
stock: p.stock,
disponible: p.disponible,
statut: p.statut,
marque: p.marque,
tags: p.tags,
videoUrl: p.videoUrl,
medias: p.medias,
categories: p.categories.map((c: any) => c.categorie),
tenant: { ...p.tenant, configAcces: getConfigEffective(p.tenant.configAcces) },
};
}
function serializePrestation(p: any) {
return {
id: p.id,
nom: p.nom,
description: p.description,
prixMin: p.prixMin,
prixMax: p.prixMax,
devise: p.devise,
duree: p.duree,
modeContact: p.modeContact,
medias: p.medias,
tenant: { ...p.tenant, configAcces: getConfigEffective(p.tenant.configAcces) },
};
}
export async function GET(req: NextRequest) {
try {
const session = await getSessionFromRequest(req);
const clientId = session?.role === "CLIENT" ? session.userId : null;
const { searchParams } = req.nextUrl;
const page = Math.max(1, parseInt(searchParams.get("page") || "1"));
const limit = Math.min(MAX_LIMIT, Math.max(1, parseInt(searchParams.get("limit") || String(DEFAULT_LIMIT))));
const type = parseType(searchParams.get("type"));
const marketCode = searchParams.get("marketCode");
const ville = searchParams.get("ville");
const categorieId = searchParams.get("categorieId");
const filtre = filtreTenantsAccessibles(clientId);
// Filtre tenant base utilisé pour vitrines/produits/prestations
const tenantFilter: any = { actif: true, vitrineActive: true, ...filtre };
if (marketCode) tenantFilter.marketCode = marketCode;
if (ville) tenantFilter.ville = { contains: ville, mode: "insensitive" };
// 2026-05-18 — Filtre catégorie sur Accueil → Explorer.
// Match si tenant a la catégorie soit en single FK (`categorieWariId`),
// soit dans la relation M2M (`categoriesWari[]` via TenantCategorieWari).
// L'ancien filtre ne regardait que la single FK, qui est NULL pour la
// majorité des tenants seedés → 0 résultat systématique.
if (categorieId) {
tenantFilter.OR = [
{ categorieWariId: categorieId },
{ categoriesWari: { some: { categorieWariId: categorieId } } },
];
}
// ─── Filtre par type (single-type mode) ───────────────────────────────────
// Si type=VITRINE/PRODUIT/PRESTATION/COLLECTION/ARRIVAGE → on remplit
// uniquement avec ce type. Sinon (MIXTE), on applique le mix pondéré.
const skip = (page - 1) * limit;
if (type === "VITRINE") {
const [vitrines, total] = await Promise.all([
prisma.tenant.findMany({
where: tenantFilter,
skip,
take: limit,
orderBy: { createdAt: "desc" },
select: TENANT_SUMMARY_SELECT,
}),
prisma.tenant.count({ where: tenantFilter }),
]);
return NextResponse.json(
{
items: vitrines.map((v) => ({ type: "VITRINE" as const, item: serializeTenant(v as any) })),
page,
totalPages: Math.ceil(total / limit),
},
{ headers: FEED_CACHE_HEADERS },
);
}
if (type === "PRODUIT") {
const where = {
disponible: true,
tenant: tenantFilter,
statut: { in: ["DISPONIBLE", "SUR_COMMANDE"] as const },
};
const [produits, total] = await Promise.all([
prisma.produit.findMany({