src/app/api/mobile/restaurant/commandes/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
5 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
POST
Code source· typescript· tronqué à 200 lignes sur 319
import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma/client";
import { getSessionFromRequest } from "@/lib/auth/session";
import { getConfigEffective } from "@/lib/acces-vitrine";
import { sendPushToTenantAdmin } from "@/lib/push";
import {
estOuvertMaintenant,
getConfigRestaurantEffective,
type HorairesRestaurant,
} from "@/lib/restaurant";
import { invalidateCache } from "@/lib/cache";
import { captureError } from "@/lib/sentry";
const MODES_VALIDES = ["SUR_PLACE", "A_RECUPERER", "LIVRAISON"] as const;
type Mode = (typeof MODES_VALIDES)[number];
const MODES_PAIEMENT_VALIDES = [
"ORANGE_MONEY", "MOOV_MONEY", "WAVE", "AIRTEL_MONEY", "CASH", "VIREMENT",
] as const;
const MODE_TOGGLE: Record<Mode, "accepteSurPlace" | "accepteARecuperer" | "accepteLivraison"> = {
SUR_PLACE: "accepteSurPlace",
A_RECUPERER: "accepteARecuperer",
LIVRAISON: "accepteLivraison",
};
type LigneInput = {
platId: string;
quantite: number;
options?: Record<string, string>;
note?: string;
};
export async function POST(req: NextRequest) {
const session = await getSessionFromRequest(req);
const clientId = session?.role === "CLIENT" ? session.userId : null;
const body = await req.json().catch(() => null);
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "Body invalide" }, { status: 400 });
}
const b = body as Record<string, unknown>;
if (typeof b.tenantId !== "string" || !b.tenantId) {
return NextResponse.json({ error: "tenantId requis" }, { status: 400 });
}
if (typeof b.mode !== "string" || !(MODES_VALIDES as readonly string[]).includes(b.mode)) {
return NextResponse.json({ error: "mode invalide" }, { status: 400 });
}
const mode = b.mode as Mode;
if (!Array.isArray(b.lignes) || b.lignes.length === 0) {
return NextResponse.json({ error: "lignes requis (au moins 1)" }, { status: 400 });
}
const lignesInput: LigneInput[] = [];
for (const raw of b.lignes) {
if (!raw || typeof raw !== "object") {
return NextResponse.json({ error: "ligne invalide" }, { status: 400 });
}
const l = raw as Record<string, unknown>;
if (typeof l.platId !== "string" || !l.platId) {
return NextResponse.json({ error: "ligne.platId requis" }, { status: 400 });
}
if (typeof l.quantite !== "number" || !Number.isFinite(l.quantite) || l.quantite < 1) {
return NextResponse.json({ error: "ligne.quantite invalide" }, { status: 400 });
}
const options = (l.options && typeof l.options === "object" && !Array.isArray(l.options))
? (l.options as Record<string, string>)
: undefined;
lignesInput.push({
platId: l.platId,
quantite: Math.floor(l.quantite),
options,
note: typeof l.note === "string" ? l.note.slice(0, 200) : undefined,
});
}
// 1. Tenant actif + module restaurant + configAcces (commandeSansCompte)
const tenant = await prisma.tenant.findFirst({
where: { id: b.tenantId, actif: true, vitrineActive: true },
select: {
id: true,
modules: { where: { actif: true, nom: "restaurant" }, select: { nom: true } },
configAcces: true,
},
});
if (!tenant || tenant.modules.length === 0) {
return NextResponse.json({ error: "Restaurant indisponible" }, { status: 404 });
}
const accesEff = getConfigEffective(tenant.configAcces);
if (!clientId && !accesEff.commandeSansCompte) {
return NextResponse.json({ error: "Connexion requise pour commander" }, { status: 401 });
}
// 2. Mode accepté
const configDb = await prisma.configRestaurant.findUnique({
where: { tenantId: tenant.id },
});
const config = getConfigRestaurantEffective(configDb);
if (!config[MODE_TOGGLE[mode]]) {
return NextResponse.json({ error: `Mode ${mode} non accepté` }, { status: 400 });
}
// 3. Restaurant ouvert (sinon DEC-219 : message + 400)
if (!estOuvertMaintenant(config.horaires as HorairesRestaurant | null)) {
return NextResponse.json(
{ error: "Restaurant fermé", messageOuverture: config.messageFerme },
{ status: 400 },
);
}
// 4. Charger plats + options en une fois (1 query)
const platIds = Array.from(new Set(lignesInput.map((l) => l.platId)));
const plats = await prisma.plat.findMany({
where: { id: { in: platIds }, tenantId: tenant.id },
include: { options: true },
});
const platMap = new Map(plats.map((p) => [p.id, p]));
for (const l of lignesInput) {
const plat = platMap.get(l.platId);
if (!plat) return NextResponse.json({ error: `Plat ${l.platId} introuvable` }, { status: 400 });
if (!plat.disponible) return NextResponse.json({ error: `Plat "${plat.nom}" indisponible` }, { status: 400 });
if (plat.modesDisponibles.length > 0 && !plat.modesDisponibles.includes(mode)) {
return NextResponse.json(
{ error: `Plat "${plat.nom}" non disponible en mode ${mode}` },
{ status: 400 },
);
}
// Options obligatoires : chaque OptionPlat avec obligatoire=true doit être renseignée
for (const opt of plat.options) {
if (opt.obligatoire) {
const choisi = l.options?.[opt.id];
if (!choisi || !opt.choix.includes(choisi)) {
return NextResponse.json(
{ error: `Option "${opt.nom}" requise pour "${plat.nom}"` },
{ status: 400 },
);
}
}
// Choix non vide : doit être dans la liste des choix possibles
const choisi = l.options?.[opt.id];
if (choisi !== undefined && !opt.choix.includes(choisi)) {
return NextResponse.json(
{ error: `Choix invalide pour "${opt.nom}"` },
{ status: 400 },
);
}
}
}
// 5. Validation mode-specific + calcul total
let total = 0;
type LigneSnapshot = {
platId: string;
nom: string;
prix: number;
quantite: number;
options: { optionId: string; nom: string; choix: string; prixSupp: number }[];
note: string | null;
};
const snapshots: LigneSnapshot[] = [];
for (const l of lignesInput) {
const plat = platMap.get(l.platId)!;
const optionsSnap: LigneSnapshot["options"] = [];
let prixSuppLigne = 0;
for (const opt of plat.options) {
const choisi = l.options?.[opt.id];
if (choisi !== undefined) {
optionsSnap.push({ optionId: opt.id, nom: opt.nom, choix: choisi, prixSupp: opt.prixSupp });
prixSuppLigne += opt.prixSupp;
}
}
const prixUnitaire = plat.prix + prixSuppLigne;
total += prixUnitaire * l.quantite;
snapshots.push({
platId: plat.id,
nom: plat.nom,
prix: prixUnitaire,
quantite: l.quantite,
options: optionsSnap,
note: l.note ?? null,
});
}
// Validation montant min livraison
if (mode === "LIVRAISON" && config.montantMinLivraison > 0 && total < config.montantMinLivraison) {
return NextResponse.json(
{ error: `Montant minimum de livraison : ${config.montantMinLivraison} FCFA` },
{ status: 400 },
);
}
// Ajout frais de livraison
const fraisLivraison = mode === "LIVRAISON" ? config.fraisLivraison : 0;
total += fraisLivraison;
// 6. Validation table (SUR_PLACE) : tableId optionnel mais doit appartenir au tenant si fourni
let tableIdResolved: string | null = null;
let numeroTableResolved: string | null = null;
if (mode === "SUR_PLACE") {import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma/client";
import { getSessionFromRequest } from "@/lib/auth/session";
import { getConfigEffective } from "@/lib/acces-vitrine";
import { sendPushToTenantAdmin } from "@/lib/push";
import {
estOuvertMaintenant,
getConfigRestaurantEffective,
type HorairesRestaurant,
} from "@/lib/restaurant";
import { invalidateCache } from "@/lib/cache";
import { captureError } from "@/lib/sentry";
const MODES_VALIDES = ["SUR_PLACE", "A_RECUPERER", "LIVRAISON"] as const;
type Mode = (typeof MODES_VALIDES)[number];
const MODES_PAIEMENT_VALIDES = [
"ORANGE_MONEY", "MOOV_MONEY", "WAVE", "AIRTEL_MONEY", "CASH", "VIREMENT",
] as const;
const MODE_TOGGLE: Record<Mode, "accepteSurPlace" | "accepteARecuperer" | "accepteLivraison"> = {
SUR_PLACE: "accepteSurPlace",
A_RECUPERER: "accepteARecuperer",
LIVRAISON: "accepteLivraison",
};
type LigneInput = {
platId: string;
quantite: number;
options?: Record<string, string>;
note?: string;
};
export async function POST(req: NextRequest) {
const session = await getSessionFromRequest(req);
const clientId = session?.role === "CLIENT" ? session.userId : null;
const body = await req.json().catch(() => null);
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "Body invalide" }, { status: 400 });
}
const b = body as Record<string, unknown>;
if (typeof b.tenantId !== "string" || !b.tenantId) {
return NextResponse.json({ error: "tenantId requis" }, { status: 400 });
}
if (typeof b.mode !== "string" || !(MODES_VALIDES as readonly string[]).includes(b.mode)) {
return NextResponse.json({ error: "mode invalide" }, { status: 400 });
}
const mode = b.mode as Mode;
if (!Array.isArray(b.lignes) || b.lignes.length === 0) {
return NextResponse.json({ error: "lignes requis (au moins 1)" }, { status: 400 });
}
const lignesInput: LigneInput[] = [];
for (const raw of b.lignes) {
if (!raw || typeof raw !== "object") {
return NextResponse.json({ error: "ligne invalide" }, { status: 400 });
}
const l = raw as Record<string, unknown>;
if (typeof l.platId !== "string" || !l.platId) {
return NextResponse.json({ error: "ligne.platId requis" }, { status: 400 });
}
if (typeof l.quantite !== "number" || !Number.isFinite(l.quantite) || l.quantite < 1) {
return NextResponse.json({ error: "ligne.quantite invalide" }, { status: 400 });
}
const options = (l.options && typeof l.options === "object" && !Array.isArray(l.options))
? (l.options as Record<string, string>)
: undefined;
lignesInput.push({
platId: l.platId,
quantite: Math.floor(l.quantite),
options,
note: typeof l.note === "string" ? l.note.slice(0, 200) : undefined,
});
}
// 1. Tenant actif + module restaurant + configAcces (commandeSansCompte)
const tenant = await prisma.tenant.findFirst({
where: { id: b.tenantId, actif: true, vitrineActive: true },
select: {
id: true,
modules: { where: { actif: true, nom: "restaurant" }, select: { nom: true } },
configAcces: true,
},
});
if (!tenant || tenant.modules.length === 0) {
return NextResponse.json({ error: "Restaurant indisponible" }, { status: 404 });
}
const accesEff = getConfigEffective(tenant.configAcces);
if (!clientId && !accesEff.commandeSansCompte) {
return NextResponse.json({ error: "Connexion requise pour commander" }, { status: 401 });
}
// 2. Mode accepté
const configDb = await prisma.configRestaurant.findUnique({
where: { tenantId: tenant.id },
});
const config = getConfigRestaurantEffective(configDb);
if (!config[MODE_TOGGLE[mode]]) {
return NextResponse.json({ error: `Mode ${mode} non accepté` }, { status: 400 });
}
// 3. Restaurant ouvert (sinon DEC-219 : message + 400)
if (!estOuvertMaintenant(config.horaires as HorairesRestaurant | null)) {
return NextResponse.json(
{ error: "Restaurant fermé", messageOuverture: config.messageFerme },
{ status: 400 },
);
}
// 4. Charger plats + options en une fois (1 query)
const platIds = Array.from(new Set(lignesInput.map((l) => l.platId)));
const plats = await prisma.plat.findMany({
where: { id: { in: platIds }, tenantId: tenant.id },
include: { options: true },
});
const platMap = new Map(plats.map((p) => [p.id, p]));
for (const l of lignesInput) {
const plat = platMap.get(l.platId);
if (!plat) return NextResponse.json({ error: `Plat ${l.platId} introuvable` }, { status: 400 });
if (!plat.disponible) return NextResponse.json({ error: `Plat "${plat.nom}" indisponible` }, { status: 400 });
if (plat.modesDisponibles.length > 0 && !plat.modesDisponibles.includes(mode)) {
return NextResponse.json(
{ error: `Plat "${plat.nom}" non disponible en mode ${mode}` },
{ status: 400 },
);
}
// Options obligatoires : chaque OptionPlat avec obligatoire=true doit être renseignée
for (const opt of plat.options) {
if (opt.obligatoire) {
const choisi = l.options?.[opt.id];
if (!choisi || !opt.choix.includes(choisi)) {
return NextResponse.json(
{ error: `Option "${opt.nom}" requise pour "${plat.nom}"` },
{ status: 400 },
);
}
}
// Choix non vide : doit être dans la liste des choix possibles
const choisi = l.options?.[opt.id];
if (choisi !== undefined && !opt.choix.includes(choisi)) {
return NextResponse.json(
{ error: `Choix invalide pour "${opt.nom}"` },
{ status: 400 },
);
}
}
}
// 5. Validation mode-specific + calcul total
let total = 0;
type LigneSnapshot = {
platId: string;
nom: string;
prix: number;
quantite: number;
options: { optionId: string; nom: string; choix: string; prixSupp: number }[];
note: string | null;
};
const snapshots: LigneSnapshot[] = [];
for (const l of lignesInput) {
const plat = platMap.get(l.platId)!;
const optionsSnap: LigneSnapshot["options"] = [];
let prixSuppLigne = 0;
for (const opt of plat.options) {
const choisi = l.options?.[opt.id];
if (choisi !== undefined) {
optionsSnap.push({ optionId: opt.id, nom: opt.nom, choix: choisi, prixSupp: opt.prixSupp });
prixSuppLigne += opt.prixSupp;
}
}
const prixUnitaire = plat.prix + prixSuppLigne;
total += prixUnitaire * l.quantite;
snapshots.push({
platId: plat.id,
nom: plat.nom,
prix: prixUnitaire,
quantite: l.quantite,
options: optionsSnap,
note: l.note ?? null,
});
}
// Validation montant min livraison
if (mode === "LIVRAISON" && config.montantMinLivraison > 0 && total < config.montantMinLivraison) {
return NextResponse.json(
{ error: `Montant minimum de livraison : ${config.montantMinLivraison} FCFA` },
{ status: 400 },
);
}
// Ajout frais de livraison
const fraisLivraison = mode === "LIVRAISON" ? config.fraisLivraison : 0;
total += fraisLivraison;
// 6. Validation table (SUR_PLACE) : tableId optionnel mais doit appartenir au tenant si fourni
let tableIdResolved: string | null = null;
let numeroTableResolved: string | null = null;
if (mode === "SUR_PLACE") {