src/app/api/mobile/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
8 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
4 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
2 exports
GETPOST
Code source· typescript· tronqué à 200 lignes sur 409
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma/client";
import { getSessionFromRequest } from "@/lib/auth/session";
import { sendPushToTenantAdmin, sendPushToClient } from "@/lib/push";
import { envoyerConfirmationCommande, envoyerNotificationAdmin } from "@/lib/email";
import {
MODES_PAIEMENT_COMMANDE,
type ModePaiementCommande,
} from "@/lib/ussd";
import { hookCommandeCompleteParrainage } from "@/lib/parrainage";
import { captureError } from "@/lib/sentry";
export async function GET(req: NextRequest) {
const session = await getSessionFromRequest(req);
if (!session || session.role !== "CLIENT") {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const account = await prisma.clientAccount.findUnique({
where: { id: session.userId },
select: { email: true, phone: true },
});
if (!account) return NextResponse.json({ error: "Compte introuvable" }, { status: 404 });
const identifiers = [account.email, account.phone].filter((v): v is string => !!v);
if (identifiers.length === 0) {
return NextResponse.json({ commandes: [], total: 0, page: 1, totalPages: 0 });
}
const clients = await prisma.client.findMany({
where: { email: { in: identifiers } },
select: { id: true },
});
const clientIds = clients.map((c) => c.id);
if (clientIds.length === 0) {
return NextResponse.json({ commandes: [], total: 0, page: 1, totalPages: 0 });
}
const { searchParams } = req.nextUrl;
const page = Math.max(1, parseInt(searchParams.get("page") || "1"));
const limit = Math.min(50, parseInt(searchParams.get("limit") || "20"));
const enCours = searchParams.get("enCours") === "1";
const skip = (page - 1) * limit;
const where: any = { clientId: { in: clientIds } };
if (enCours) {
where.statut = { in: ["EN_ATTENTE", "CONFIRME", "PRET_RETRAIT", "EN_LIVRAISON"] };
}
const [commandes, total] = await Promise.all([
prisma.commande.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: limit,
include: {
lignes: {
select: {
quantite: true,
produit: {
select: {
nom: true,
medias: { take: 1, orderBy: { ordre: "asc" }, select: { url: true } },
},
},
},
},
},
}),
prisma.commande.count({ where }),
]);
const tenantIds = [...new Set(commandes.map((c) => c.tenantId))];
const tenants = await prisma.tenant.findMany({
where: { id: { in: tenantIds } },
select: { id: true, subdomain: true, nom: true, logoUrl: true },
});
const tenantMap = new Map(tenants.map((t) => [t.id, t]));
return NextResponse.json({
commandes: commandes.map((c) => ({
id: c.id,
statut: c.statut,
total: c.total,
devise: c.devise,
createdAt: c.createdAt.toISOString(),
nbLignes: c.lignes.length,
premiereLigne: c.lignes[0]
? {
nom: c.lignes[0].produit.nom,
quantite: c.lignes[0].quantite,
imageUrl: c.lignes[0].produit.medias[0]?.url ?? null,
}
: null,
tenant: tenantMap.get(c.tenantId) ?? null,
})),
total,
page,
totalPages: Math.ceil(total / limit),
});
}
// ─── POST (création commande mobile) — WP-225 ────────────────────────────────
type LignePayload = {
produitId: string;
varianteId?: string | null;
quantite: number;
prix: number;
};
function isValidLigne(x: unknown): x is LignePayload {
if (!x || typeof x !== "object") return false;
const l = x as Record<string, unknown>;
return (
typeof l.produitId === "string"
&& typeof l.quantite === "number" && l.quantite > 0
&& typeof l.prix === "number" && l.prix >= 0
&& (l.varianteId === undefined || l.varianteId === null || typeof l.varianteId === "string")
);
}
export async function POST(req: NextRequest) {
try {
const session = await getSessionFromRequest(req);
if (!session || session.role !== "CLIENT") {
return NextResponse.json(
{ error: "Connexion requise pour passer commande" },
{ status: 401 },
);
}
const clientAccountId = session.userId;
const body = await req.json().catch(() => null);
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "Body invalide" }, { status: 400 });
}
const tenantId = typeof body.tenantId === "string" ? body.tenantId : null;
const lignesRaw = Array.isArray(body.lignes) ? body.lignes : null;
const modePaiement = typeof body.modePaiement === "string" ? body.modePaiement : null;
const refPaiement = typeof body.refPaiement === "string" && body.refPaiement.trim()
? body.refPaiement.trim().slice(0, 200)
: null;
const noteClient = typeof body.noteClient === "string" && body.noteClient.trim()
? body.noteClient.trim().slice(0, 1000)
: null;
const instructionsPaiement = typeof body.instructionsPaiement === "string" && body.instructionsPaiement.trim()
? body.instructionsPaiement.trim().slice(0, 2000)
: null;
if (!tenantId) {
return NextResponse.json({ error: "tenantId requis" }, { status: 400 });
}
if (!lignesRaw || lignesRaw.length === 0) {
return NextResponse.json({ error: "Au moins 1 ligne requise" }, { status: 400 });
}
if (!lignesRaw.every(isValidLigne)) {
return NextResponse.json({ error: "Format des lignes invalide" }, { status: 400 });
}
if (!modePaiement || !MODES_PAIEMENT_COMMANDE.includes(modePaiement as ModePaiementCommande)) {
return NextResponse.json({ error: "modePaiement invalide" }, { status: 400 });
}
const lignes = lignesRaw as LignePayload[];
const tenant = await prisma.tenant.findFirst({
where: { id: tenantId, actif: true, vitrineActive: true },
select: { id: true, nom: true },
});
if (!tenant) {
return NextResponse.json({ error: "Vitrine introuvable" }, { status: 404 });
}
const produitIds = [...new Set(lignes.map((l) => l.produitId))];
const produits = await prisma.produit.findMany({
where: { id: { in: produitIds }, tenantId, disponible: true },
select: { id: true, nom: true, stock: true, prix: true, disponibilite: true },
});
const produitsMap = new Map(produits.map((p) => [p.id, p]));
for (const l of lignes) {
const p = produitsMap.get(l.produitId);
if (!p) {
return NextResponse.json(
{ error: `Produit ${l.produitId} indisponible` },
{ status: 400 },
);
}
// BUG-121 fix : stock négatif possible avant ce check.
// Pas de vérif stock pour SUR_COMMANDE / A_PARTIR_DE / SUR_DEVIS — ces modes
// acceptent par construction des commandes au-delà du stock physique.
if (p.disponibilite === "IMMEDIATE" && p.stock < l.quantite) {
return NextResponse.json(
{
error: `Stock insuffisant pour "${p.nom}" — ${p.stock} disponible(s)`,
produitId: l.produitId,
stockDisponible: p.stock,
},
{ status: 409 },
);import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma/client";
import { getSessionFromRequest } from "@/lib/auth/session";
import { sendPushToTenantAdmin, sendPushToClient } from "@/lib/push";
import { envoyerConfirmationCommande, envoyerNotificationAdmin } from "@/lib/email";
import {
MODES_PAIEMENT_COMMANDE,
type ModePaiementCommande,
} from "@/lib/ussd";
import { hookCommandeCompleteParrainage } from "@/lib/parrainage";
import { captureError } from "@/lib/sentry";
export async function GET(req: NextRequest) {
const session = await getSessionFromRequest(req);
if (!session || session.role !== "CLIENT") {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const account = await prisma.clientAccount.findUnique({
where: { id: session.userId },
select: { email: true, phone: true },
});
if (!account) return NextResponse.json({ error: "Compte introuvable" }, { status: 404 });
const identifiers = [account.email, account.phone].filter((v): v is string => !!v);
if (identifiers.length === 0) {
return NextResponse.json({ commandes: [], total: 0, page: 1, totalPages: 0 });
}
const clients = await prisma.client.findMany({
where: { email: { in: identifiers } },
select: { id: true },
});
const clientIds = clients.map((c) => c.id);
if (clientIds.length === 0) {
return NextResponse.json({ commandes: [], total: 0, page: 1, totalPages: 0 });
}
const { searchParams } = req.nextUrl;
const page = Math.max(1, parseInt(searchParams.get("page") || "1"));
const limit = Math.min(50, parseInt(searchParams.get("limit") || "20"));
const enCours = searchParams.get("enCours") === "1";
const skip = (page - 1) * limit;
const where: any = { clientId: { in: clientIds } };
if (enCours) {
where.statut = { in: ["EN_ATTENTE", "CONFIRME", "PRET_RETRAIT", "EN_LIVRAISON"] };
}
const [commandes, total] = await Promise.all([
prisma.commande.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: limit,
include: {
lignes: {
select: {
quantite: true,
produit: {
select: {
nom: true,
medias: { take: 1, orderBy: { ordre: "asc" }, select: { url: true } },
},
},
},
},
},
}),
prisma.commande.count({ where }),
]);
const tenantIds = [...new Set(commandes.map((c) => c.tenantId))];
const tenants = await prisma.tenant.findMany({
where: { id: { in: tenantIds } },
select: { id: true, subdomain: true, nom: true, logoUrl: true },
});
const tenantMap = new Map(tenants.map((t) => [t.id, t]));
return NextResponse.json({
commandes: commandes.map((c) => ({
id: c.id,
statut: c.statut,
total: c.total,
devise: c.devise,
createdAt: c.createdAt.toISOString(),
nbLignes: c.lignes.length,
premiereLigne: c.lignes[0]
? {
nom: c.lignes[0].produit.nom,
quantite: c.lignes[0].quantite,
imageUrl: c.lignes[0].produit.medias[0]?.url ?? null,
}
: null,
tenant: tenantMap.get(c.tenantId) ?? null,
})),
total,
page,
totalPages: Math.ceil(total / limit),
});
}
// ─── POST (création commande mobile) — WP-225 ────────────────────────────────
type LignePayload = {
produitId: string;
varianteId?: string | null;
quantite: number;
prix: number;
};
function isValidLigne(x: unknown): x is LignePayload {
if (!x || typeof x !== "object") return false;
const l = x as Record<string, unknown>;
return (
typeof l.produitId === "string"
&& typeof l.quantite === "number" && l.quantite > 0
&& typeof l.prix === "number" && l.prix >= 0
&& (l.varianteId === undefined || l.varianteId === null || typeof l.varianteId === "string")
);
}
export async function POST(req: NextRequest) {
try {
const session = await getSessionFromRequest(req);
if (!session || session.role !== "CLIENT") {
return NextResponse.json(
{ error: "Connexion requise pour passer commande" },
{ status: 401 },
);
}
const clientAccountId = session.userId;
const body = await req.json().catch(() => null);
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "Body invalide" }, { status: 400 });
}
const tenantId = typeof body.tenantId === "string" ? body.tenantId : null;
const lignesRaw = Array.isArray(body.lignes) ? body.lignes : null;
const modePaiement = typeof body.modePaiement === "string" ? body.modePaiement : null;
const refPaiement = typeof body.refPaiement === "string" && body.refPaiement.trim()
? body.refPaiement.trim().slice(0, 200)
: null;
const noteClient = typeof body.noteClient === "string" && body.noteClient.trim()
? body.noteClient.trim().slice(0, 1000)
: null;
const instructionsPaiement = typeof body.instructionsPaiement === "string" && body.instructionsPaiement.trim()
? body.instructionsPaiement.trim().slice(0, 2000)
: null;
if (!tenantId) {
return NextResponse.json({ error: "tenantId requis" }, { status: 400 });
}
if (!lignesRaw || lignesRaw.length === 0) {
return NextResponse.json({ error: "Au moins 1 ligne requise" }, { status: 400 });
}
if (!lignesRaw.every(isValidLigne)) {
return NextResponse.json({ error: "Format des lignes invalide" }, { status: 400 });
}
if (!modePaiement || !MODES_PAIEMENT_COMMANDE.includes(modePaiement as ModePaiementCommande)) {
return NextResponse.json({ error: "modePaiement invalide" }, { status: 400 });
}
const lignes = lignesRaw as LignePayload[];
const tenant = await prisma.tenant.findFirst({
where: { id: tenantId, actif: true, vitrineActive: true },
select: { id: true, nom: true },
});
if (!tenant) {
return NextResponse.json({ error: "Vitrine introuvable" }, { status: 404 });
}
const produitIds = [...new Set(lignes.map((l) => l.produitId))];
const produits = await prisma.produit.findMany({
where: { id: { in: produitIds }, tenantId, disponible: true },
select: { id: true, nom: true, stock: true, prix: true, disponibilite: true },
});
const produitsMap = new Map(produits.map((p) => [p.id, p]));
for (const l of lignes) {
const p = produitsMap.get(l.produitId);
if (!p) {
return NextResponse.json(
{ error: `Produit ${l.produitId} indisponible` },
{ status: 400 },
);
}
// BUG-121 fix : stock négatif possible avant ce check.
// Pas de vérif stock pour SUR_COMMANDE / A_PARTIR_DE / SUR_DEVIS — ces modes
// acceptent par construction des commandes au-delà du stock physique.
if (p.disponibilite === "IMMEDIATE" && p.stock < l.quantite) {
return NextResponse.json(
{
error: `Stock insuffisant pour "${p.nom}" — ${p.stock} disponible(s)`,
produitId: l.produitId,
stockDisponible: p.stock,
},
{ status: 409 },
);