src/app/api/mobile/reservations/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
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 703
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma/client";
import { getSessionFromRequest } from "@/lib/auth/session";
import type { Prisma } from "@prisma/client";
import { envoyerConfirmationReservation, envoyerNouvelleReservationAdmin } from "@/lib/email";
import { sendPushToTenantAdmin } from "@/lib/push";
import { initierPaiementRDV } from "@/lib/paydunya";
import { captureError } from "@/lib/sentry";
function dateLabelFR(d: Date): string {
return new Date(d).toLocaleString("fr-FR", {
weekday: "long", day: "numeric", month: "long",
hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "UTC",
});
}
const STATUTS_VALIDES = ["EN_ATTENTE", "CONFIRME", "ANNULE", "TERMINE", "NO_SHOW"] as const;
const MODES_PAIEMENT = ["CASH", "ORANGE_MONEY", "MOOV_MONEY", "VIREMENT", "PAYDUNYA"] as const;
const NOTE_CLIENT_MAX = 500;
const ICAL_BASE_URL = process.env.MAGIC_LINK_BASE_URL || "https://wari.pro";
// Pilier 2 V1 — durée réservation table par défaut 90 min (2 services possibles dans
// un créneau habituel 11h-13h ou 19h-21h). Personnalisable V1.5+.
const DUREE_TABLE_MS_DEFAULT = 90 * 60_000;
function parseDateInput(v: unknown): Date | null {
if (typeof v !== "string") return null;
const d = new Date(v);
return Number.isNaN(d.getTime()) ? null : d;
}
function calculerAcompte(
montantTotal: number | null,
typeAcompte: "AUCUN" | "POURCENTAGE" | "MONTANT_FIXE",
valeurAcompte: number | null
): number | null {
if (typeAcompte === "AUCUN" || valeurAcompte == null || montantTotal == null) return null;
if (typeAcompte === "POURCENTAGE") {
const v = (montantTotal * valeurAcompte) / 100;
return Math.round(v * 100) / 100;
}
return Math.min(valeurAcompte, montantTotal);
}
// ─── GET — Mes réservations (Bearer CLIENT) ────────────────────────────────────
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 { searchParams } = req.nextUrl;
const statut = searchParams.get("statut");
const type = searchParams.get("type") as "PRESTATION" | "TABLE" | "EVENEMENT" | null;
const enCours = searchParams.get("enCours") === "1";
const page = Math.max(1, parseInt(searchParams.get("page") || "1"));
const limit = Math.min(50, parseInt(searchParams.get("limit") || "20"));
const skip = (page - 1) * limit;
const where: Prisma.ReservationWhereInput = { clientId: session.userId };
if (statut && (STATUTS_VALIDES as readonly string[]).includes(statut)) {
where.statut = statut as (typeof STATUTS_VALIDES)[number];
}
if (type && ["PRESTATION", "TABLE", "EVENEMENT"].includes(type)) {
where.type = type;
}
if (enCours) {
where.statut = { in: ["EN_ATTENTE", "CONFIRME"] };
where.dateDebut = { gte: new Date() };
}
try {
const [reservations, total] = await Promise.all([
prisma.reservation.findMany({
where,
orderBy: { dateDebut: enCours ? "asc" : "desc" },
skip,
take: limit,
select: {
id: true,
icalToken: true,
type: true,
dateDebut: true,
dateFin: true,
nbCouverts: true,
statut: true,
montantTotal: true,
montantAcompte: true,
statutPaiement: true,
modePaiement: true,
noteClient: true,
createdAt: true,
tenantId: true,
prestation: {
select: {
id: true,
nom: true,
duree: true,
devise: true,
medias: { orderBy: { ordre: "asc" }, take: 1, select: { url: true } },
configReservation: { select: { delaiAnnulationHeures: true } },
},
},
tableRestaurant: { select: { id: true, numero: true, capacite: true } },
evenement: {
select: {
id: true,
titre: true,
lieu: true,
prixPlace: true,
devise: true,
medias: { orderBy: { ordre: "asc" }, take: 1, select: { url: true } },
},
},
},
}),
prisma.reservation.count({ where }),
]);
const tenantIds = [...new Set(reservations.map((r) => r.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({
reservations: reservations.map((r) => {
const configReservation = r.prestation?.configReservation ?? null;
const prestation = r.prestation
? {
id: r.prestation.id,
nom: r.prestation.nom,
duree: r.prestation.duree,
devise: r.prestation.devise,
medias: r.prestation.medias,
}
: null;
return {
...r,
prestation,
configReservation,
dateDebut: r.dateDebut.toISOString(),
dateFin: r.dateFin.toISOString(),
createdAt: r.createdAt.toISOString(),
tenant: tenantMap.get(r.tenantId) ?? null,
};
}),
total,
page,
totalPages: Math.ceil(total / limit),
});
} catch (error) {
captureError(error, {
route: "/api/mobile/reservations",
userId: session.userId,
role: session.role,
});
console.error("mobile/reservations GET error:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
// ─── POST — Créer une réservation (Bearer CLIENT) ──────────────────────────────
// Dispatch selon body.type (PRESTATION par défaut pour rétrocompat WP-221).
export async function POST(req: NextRequest) {
const session = await getSessionFromRequest(req);
if (!session || session.role !== "CLIENT") {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const body = await req.json().catch(() => ({}));
const type = body?.type as "PRESTATION" | "TABLE" | "EVENEMENT" | undefined;
if (type === "TABLE") return handlePostTable(body, session.userId);
if (type === "EVENEMENT") return handlePostEvenement(body, session.userId);
return handlePostPrestation(body, session.userId);
}
// ─── Handler PRESTATION (legacy WP-221 inchangé) ─────────────────────────────
async function handlePostPrestation(body: any, clientAccountId: string): Promise<NextResponse> {
const { prestationId, tenantId, modePaiement } = body ?? {};
const dateDebut = parseDateInput(body?.dateDebut);
const dateFin = parseDateInput(body?.dateFin);
if (typeof prestationId !== "string") {
return NextResponse.json({ error: "prestationId requis" }, { status: 400 });
}
if (typeof tenantId !== "string") {
return NextResponse.json({ error: "tenantId requis" }, { status: 400 });
}
if (!dateDebut || !dateFin) {
return NextResponse.json({ error: "dateDebut/dateFin invalides" }, { status: 400 });
}
if (dateFin.getTime() <= dateDebut.getTime()) {
return NextResponse.json({ error: "dateFin doit être > dateDebut" }, { status: 400 });
}
if (typeof modePaiement !== "string" || !(MODES_PAIEMENT as readonly string[]).includes(modePaiement)) {
return NextResponse.json({ error: "modePaiement invalide" }, { status: 400 });
}
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma/client";
import { getSessionFromRequest } from "@/lib/auth/session";
import type { Prisma } from "@prisma/client";
import { envoyerConfirmationReservation, envoyerNouvelleReservationAdmin } from "@/lib/email";
import { sendPushToTenantAdmin } from "@/lib/push";
import { initierPaiementRDV } from "@/lib/paydunya";
import { captureError } from "@/lib/sentry";
function dateLabelFR(d: Date): string {
return new Date(d).toLocaleString("fr-FR", {
weekday: "long", day: "numeric", month: "long",
hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "UTC",
});
}
const STATUTS_VALIDES = ["EN_ATTENTE", "CONFIRME", "ANNULE", "TERMINE", "NO_SHOW"] as const;
const MODES_PAIEMENT = ["CASH", "ORANGE_MONEY", "MOOV_MONEY", "VIREMENT", "PAYDUNYA"] as const;
const NOTE_CLIENT_MAX = 500;
const ICAL_BASE_URL = process.env.MAGIC_LINK_BASE_URL || "https://wari.pro";
// Pilier 2 V1 — durée réservation table par défaut 90 min (2 services possibles dans
// un créneau habituel 11h-13h ou 19h-21h). Personnalisable V1.5+.
const DUREE_TABLE_MS_DEFAULT = 90 * 60_000;
function parseDateInput(v: unknown): Date | null {
if (typeof v !== "string") return null;
const d = new Date(v);
return Number.isNaN(d.getTime()) ? null : d;
}
function calculerAcompte(
montantTotal: number | null,
typeAcompte: "AUCUN" | "POURCENTAGE" | "MONTANT_FIXE",
valeurAcompte: number | null
): number | null {
if (typeAcompte === "AUCUN" || valeurAcompte == null || montantTotal == null) return null;
if (typeAcompte === "POURCENTAGE") {
const v = (montantTotal * valeurAcompte) / 100;
return Math.round(v * 100) / 100;
}
return Math.min(valeurAcompte, montantTotal);
}
// ─── GET — Mes réservations (Bearer CLIENT) ────────────────────────────────────
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 { searchParams } = req.nextUrl;
const statut = searchParams.get("statut");
const type = searchParams.get("type") as "PRESTATION" | "TABLE" | "EVENEMENT" | null;
const enCours = searchParams.get("enCours") === "1";
const page = Math.max(1, parseInt(searchParams.get("page") || "1"));
const limit = Math.min(50, parseInt(searchParams.get("limit") || "20"));
const skip = (page - 1) * limit;
const where: Prisma.ReservationWhereInput = { clientId: session.userId };
if (statut && (STATUTS_VALIDES as readonly string[]).includes(statut)) {
where.statut = statut as (typeof STATUTS_VALIDES)[number];
}
if (type && ["PRESTATION", "TABLE", "EVENEMENT"].includes(type)) {
where.type = type;
}
if (enCours) {
where.statut = { in: ["EN_ATTENTE", "CONFIRME"] };
where.dateDebut = { gte: new Date() };
}
try {
const [reservations, total] = await Promise.all([
prisma.reservation.findMany({
where,
orderBy: { dateDebut: enCours ? "asc" : "desc" },
skip,
take: limit,
select: {
id: true,
icalToken: true,
type: true,
dateDebut: true,
dateFin: true,
nbCouverts: true,
statut: true,
montantTotal: true,
montantAcompte: true,
statutPaiement: true,
modePaiement: true,
noteClient: true,
createdAt: true,
tenantId: true,
prestation: {
select: {
id: true,
nom: true,
duree: true,
devise: true,
medias: { orderBy: { ordre: "asc" }, take: 1, select: { url: true } },
configReservation: { select: { delaiAnnulationHeures: true } },
},
},
tableRestaurant: { select: { id: true, numero: true, capacite: true } },
evenement: {
select: {
id: true,
titre: true,
lieu: true,
prixPlace: true,
devise: true,
medias: { orderBy: { ordre: "asc" }, take: 1, select: { url: true } },
},
},
},
}),
prisma.reservation.count({ where }),
]);
const tenantIds = [...new Set(reservations.map((r) => r.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({
reservations: reservations.map((r) => {
const configReservation = r.prestation?.configReservation ?? null;
const prestation = r.prestation
? {
id: r.prestation.id,
nom: r.prestation.nom,
duree: r.prestation.duree,
devise: r.prestation.devise,
medias: r.prestation.medias,
}
: null;
return {
...r,
prestation,
configReservation,
dateDebut: r.dateDebut.toISOString(),
dateFin: r.dateFin.toISOString(),
createdAt: r.createdAt.toISOString(),
tenant: tenantMap.get(r.tenantId) ?? null,
};
}),
total,
page,
totalPages: Math.ceil(total / limit),
});
} catch (error) {
captureError(error, {
route: "/api/mobile/reservations",
userId: session.userId,
role: session.role,
});
console.error("mobile/reservations GET error:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
// ─── POST — Créer une réservation (Bearer CLIENT) ──────────────────────────────
// Dispatch selon body.type (PRESTATION par défaut pour rétrocompat WP-221).
export async function POST(req: NextRequest) {
const session = await getSessionFromRequest(req);
if (!session || session.role !== "CLIENT") {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const body = await req.json().catch(() => ({}));
const type = body?.type as "PRESTATION" | "TABLE" | "EVENEMENT" | undefined;
if (type === "TABLE") return handlePostTable(body, session.userId);
if (type === "EVENEMENT") return handlePostEvenement(body, session.userId);
return handlePostPrestation(body, session.userId);
}
// ─── Handler PRESTATION (legacy WP-221 inchangé) ─────────────────────────────
async function handlePostPrestation(body: any, clientAccountId: string): Promise<NextResponse> {
const { prestationId, tenantId, modePaiement } = body ?? {};
const dateDebut = parseDateInput(body?.dateDebut);
const dateFin = parseDateInput(body?.dateFin);
if (typeof prestationId !== "string") {
return NextResponse.json({ error: "prestationId requis" }, { status: 400 });
}
if (typeof tenantId !== "string") {
return NextResponse.json({ error: "tenantId requis" }, { status: 400 });
}
if (!dateDebut || !dateFin) {
return NextResponse.json({ error: "dateDebut/dateFin invalides" }, { status: 400 });
}
if (dateFin.getTime() <= dateDebut.getTime()) {
return NextResponse.json({ error: "dateFin doit être > dateDebut" }, { status: 400 });
}
if (typeof modePaiement !== "string" || !(MODES_PAIEMENT as readonly string[]).includes(modePaiement)) {
return NextResponse.json({ error: "modePaiement invalide" }, { status: 400 });
}