src/app/api/mobile/vitrine/commandes/[id]/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
7 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
GETPATCH
Code source· typescript· tronqué à 200 lignes sur 300
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma/client";
import { getSessionFromRequest } from "@/lib/auth/session";
import { envoyerPaiementValide } from "@/lib/email";
import { sendPushToClient } from "@/lib/push";
import { captureError } from "@/lib/sentry";
import type { Prisma } from "@prisma/client";
const STATUTS_VALIDES = ["EN_ATTENTE", "CONFIRME", "PRET_RETRAIT", "EN_LIVRAISON", "LIVRE", "ANNULE"] as const;
const STATUTS_PAIEMENT_VALIDES = ["EN_ATTENTE", "PAYE", "REFUSE", "REMBOURSE"] as const;
const TRANSITIONS_STATUT: Record<string, string[]> = {
EN_ATTENTE: ["CONFIRME", "ANNULE"],
CONFIRME: ["PRET_RETRAIT", "EN_LIVRAISON", "ANNULE"],
PRET_RETRAIT: ["LIVRE", "ANNULE"],
EN_LIVRAISON: ["LIVRE", "ANNULE"],
LIVRE: [],
ANNULE: [],
};
const NOTE_MAX = 500;
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSessionFromRequest(req);
if (!session || session.role !== "TENANT_ADMIN" || !session.tenantId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
try {
const commande = await prisma.commande.findFirst({
where: { id, tenantId: session.tenantId },
include: {
client: { select: { nom: true, email: true, telephone: true } },
lignes: {
include: {
produit: {
select: {
nom: true,
medias: { orderBy: { ordre: "asc" }, take: 1, select: { url: true } },
},
},
variante: { select: { attributs: true } },
},
},
},
});
// Note : modePaiement, refPaiement, instructionsPaiement sont déjà inclus
// par défaut dans `commande` (Prisma retourne tous les champs scalaires
// sans select explicite).
if (!commande) {
return NextResponse.json({ error: "Commande introuvable" }, { status: 404 });
}
return NextResponse.json({ commande });
} catch (error) {
captureError(error, {
route: "/api/mobile/vitrine/commandes/[id]",
tenantId: session.tenantId,
userId: session.userId,
role: session.role,
});
console.error("boutique/commandes/[id] error:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
// PATCH générique — accepte statut (avec validation transitions strict),
// statutPaiement (toggle libre) et noteInterne (max 500 chars)
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSessionFromRequest(req);
if (!session || session.role !== "TENANT_ADMIN" || !session.tenantId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
const body = await req.json().catch(() => ({}));
const existing = await prisma.commande.findFirst({
where: { id, tenantId: session.tenantId },
select: { id: true, statut: true, statutPaiement: true },
});
if (!existing) {
return NextResponse.json({ error: "Commande introuvable" }, { status: 404 });
}
const data: Prisma.CommandeUpdateInput = {};
if (typeof body.statut === "string") {
if (!STATUTS_VALIDES.includes(body.statut)) {
return NextResponse.json({ error: "Statut invalide" }, { status: 400 });
}
if (body.statut !== existing.statut) {
const transitions = TRANSITIONS_STATUT[existing.statut] ?? [];
if (!transitions.includes(body.statut)) {
return NextResponse.json(
{ error: `Transition ${existing.statut} → ${body.statut} non autorisée` },
{ status: 422 }
);
}
data.statut = body.statut;
}
}
if (typeof body.statutPaiement === "string") {
if (!STATUTS_PAIEMENT_VALIDES.includes(body.statutPaiement)) {
return NextResponse.json({ error: "Statut paiement invalide" }, { status: 400 });
}
data.statutPaiement = body.statutPaiement;
}
// M2 fix : permettre au gérant d'éditer la référence de paiement
// (cas usage : client envoie ref par WhatsApp après le checkout).
if ("refPaiement" in body) {
if (body.refPaiement === null || body.refPaiement === "") {
data.refPaiement = null;
} else if (typeof body.refPaiement === "string") {
data.refPaiement = body.refPaiement.trim().slice(0, 200) || null;
}
}
if ("noteInterne" in body) {
if (body.noteInterne === null || body.noteInterne === "") {
data.noteInterne = null;
} else if (typeof body.noteInterne === "string") {
const trimmed = body.noteInterne.trim();
if (trimmed.length > NOTE_MAX) {
return NextResponse.json({ error: `Note trop longue (max ${NOTE_MAX} caractères)` }, { status: 400 });
}
data.noteInterne = trimmed || null;
}
}
if (Object.keys(data).length === 0) {
return NextResponse.json({ error: "Aucune modification" }, { status: 400 });
}
const updated = await prisma.commande.update({
where: { id },
data,
select: {
id: true,
statut: true,
statutPaiement: true,
noteInterne: true,
modePaiement: true,
refPaiement: true,
updatedAt: true,
},
});
// Email + push client à la transition EN_ATTENTE → PAYE/REFUSE (WP-225 G + WP-227)
if (
typeof body.statutPaiement === "string"
&& existing.statutPaiement === "EN_ATTENTE"
&& (body.statutPaiement === "PAYE" || body.statutPaiement === "REFUSE")
) {
void (async () => {
try {
const [full, tenantInfo] = await Promise.all([
prisma.commande.findUnique({
where: { id },
select: {
id: true, total: true, devise: true, tenantId: true,
clientAccountId: true,
client: { select: { nom: true, email: true, telephone: true } },
},
}),
prisma.tenant.findUnique({
where: { id: session.tenantId! },
select: { nom: true, subdomain: true },
}),
]);
if (!full || !tenantInfo) return;
// Email best-effort
if (full.client.email && full.client.email.includes("@")) {
await envoyerPaiementValide({
clientEmail: full.client.email,
clientNom: full.client.nom ?? null,
tenantNom: tenantInfo.nom,
tenantSubdomain: tenantInfo.subdomain,
commandeId: full.id,
montant: full.total,
devise: full.devise,
statut: body.statutPaiement as "PAYE" | "REFUSE",
}).catch((e) => console.error("[commandes PATCH] paiement email failed:", e));
}
// Push client best-effort (WP-227) — lookup ClientAccount par email/phone
// M6 — utiliser la FK directe Commande.clientAccountId
// Fallback lookup OR pour les commandes pré-WP-227 sans clientAccountId.
let ca: { id: string } | null = null;
if (full.clientAccountId) {import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma/client";
import { getSessionFromRequest } from "@/lib/auth/session";
import { envoyerPaiementValide } from "@/lib/email";
import { sendPushToClient } from "@/lib/push";
import { captureError } from "@/lib/sentry";
import type { Prisma } from "@prisma/client";
const STATUTS_VALIDES = ["EN_ATTENTE", "CONFIRME", "PRET_RETRAIT", "EN_LIVRAISON", "LIVRE", "ANNULE"] as const;
const STATUTS_PAIEMENT_VALIDES = ["EN_ATTENTE", "PAYE", "REFUSE", "REMBOURSE"] as const;
const TRANSITIONS_STATUT: Record<string, string[]> = {
EN_ATTENTE: ["CONFIRME", "ANNULE"],
CONFIRME: ["PRET_RETRAIT", "EN_LIVRAISON", "ANNULE"],
PRET_RETRAIT: ["LIVRE", "ANNULE"],
EN_LIVRAISON: ["LIVRE", "ANNULE"],
LIVRE: [],
ANNULE: [],
};
const NOTE_MAX = 500;
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSessionFromRequest(req);
if (!session || session.role !== "TENANT_ADMIN" || !session.tenantId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
try {
const commande = await prisma.commande.findFirst({
where: { id, tenantId: session.tenantId },
include: {
client: { select: { nom: true, email: true, telephone: true } },
lignes: {
include: {
produit: {
select: {
nom: true,
medias: { orderBy: { ordre: "asc" }, take: 1, select: { url: true } },
},
},
variante: { select: { attributs: true } },
},
},
},
});
// Note : modePaiement, refPaiement, instructionsPaiement sont déjà inclus
// par défaut dans `commande` (Prisma retourne tous les champs scalaires
// sans select explicite).
if (!commande) {
return NextResponse.json({ error: "Commande introuvable" }, { status: 404 });
}
return NextResponse.json({ commande });
} catch (error) {
captureError(error, {
route: "/api/mobile/vitrine/commandes/[id]",
tenantId: session.tenantId,
userId: session.userId,
role: session.role,
});
console.error("boutique/commandes/[id] error:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
// PATCH générique — accepte statut (avec validation transitions strict),
// statutPaiement (toggle libre) et noteInterne (max 500 chars)
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSessionFromRequest(req);
if (!session || session.role !== "TENANT_ADMIN" || !session.tenantId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
const body = await req.json().catch(() => ({}));
const existing = await prisma.commande.findFirst({
where: { id, tenantId: session.tenantId },
select: { id: true, statut: true, statutPaiement: true },
});
if (!existing) {
return NextResponse.json({ error: "Commande introuvable" }, { status: 404 });
}
const data: Prisma.CommandeUpdateInput = {};
if (typeof body.statut === "string") {
if (!STATUTS_VALIDES.includes(body.statut)) {
return NextResponse.json({ error: "Statut invalide" }, { status: 400 });
}
if (body.statut !== existing.statut) {
const transitions = TRANSITIONS_STATUT[existing.statut] ?? [];
if (!transitions.includes(body.statut)) {
return NextResponse.json(
{ error: `Transition ${existing.statut} → ${body.statut} non autorisée` },
{ status: 422 }
);
}
data.statut = body.statut;
}
}
if (typeof body.statutPaiement === "string") {
if (!STATUTS_PAIEMENT_VALIDES.includes(body.statutPaiement)) {
return NextResponse.json({ error: "Statut paiement invalide" }, { status: 400 });
}
data.statutPaiement = body.statutPaiement;
}
// M2 fix : permettre au gérant d'éditer la référence de paiement
// (cas usage : client envoie ref par WhatsApp après le checkout).
if ("refPaiement" in body) {
if (body.refPaiement === null || body.refPaiement === "") {
data.refPaiement = null;
} else if (typeof body.refPaiement === "string") {
data.refPaiement = body.refPaiement.trim().slice(0, 200) || null;
}
}
if ("noteInterne" in body) {
if (body.noteInterne === null || body.noteInterne === "") {
data.noteInterne = null;
} else if (typeof body.noteInterne === "string") {
const trimmed = body.noteInterne.trim();
if (trimmed.length > NOTE_MAX) {
return NextResponse.json({ error: `Note trop longue (max ${NOTE_MAX} caractères)` }, { status: 400 });
}
data.noteInterne = trimmed || null;
}
}
if (Object.keys(data).length === 0) {
return NextResponse.json({ error: "Aucune modification" }, { status: 400 });
}
const updated = await prisma.commande.update({
where: { id },
data,
select: {
id: true,
statut: true,
statutPaiement: true,
noteInterne: true,
modePaiement: true,
refPaiement: true,
updatedAt: true,
},
});
// Email + push client à la transition EN_ATTENTE → PAYE/REFUSE (WP-225 G + WP-227)
if (
typeof body.statutPaiement === "string"
&& existing.statutPaiement === "EN_ATTENTE"
&& (body.statutPaiement === "PAYE" || body.statutPaiement === "REFUSE")
) {
void (async () => {
try {
const [full, tenantInfo] = await Promise.all([
prisma.commande.findUnique({
where: { id },
select: {
id: true, total: true, devise: true, tenantId: true,
clientAccountId: true,
client: { select: { nom: true, email: true, telephone: true } },
},
}),
prisma.tenant.findUnique({
where: { id: session.tenantId! },
select: { nom: true, subdomain: true },
}),
]);
if (!full || !tenantInfo) return;
// Email best-effort
if (full.client.email && full.client.email.includes("@")) {
await envoyerPaiementValide({
clientEmail: full.client.email,
clientNom: full.client.nom ?? null,
tenantNom: tenantInfo.nom,
tenantSubdomain: tenantInfo.subdomain,
commandeId: full.id,
montant: full.total,
devise: full.devise,
statut: body.statutPaiement as "PAYE" | "REFUSE",
}).catch((e) => console.error("[commandes PATCH] paiement email failed:", e));
}
// Push client best-effort (WP-227) — lookup ClientAccount par email/phone
// M6 — utiliser la FK directe Commande.clientAccountId
// Fallback lookup OR pour les commandes pré-WP-227 sans clientAccountId.
let ca: { id: string } | null = null;
if (full.clientAccountId) {