src/app/api/mobile/explorer/feed/route.ts

route·app·16.5 KB · 502 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 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({