feat(rental): Sprint E — emails + plugin toggle + tests
Some checks failed
CI / test (pull_request) Failing after 1m10s

3 nouveaux templates email (best-effort, dry-run sans Resend) :
- sendRentalRequestedTenant : récap de demande au locataire (par RB)
- sendRentalRequestedProvider : nouvelle demande au prestataire
- sendRentalConfirmed : confirmation paiement reçu

Branchements :
- POST /api/rentals/checkout : envoie tenant + provider après création
  des RentalBooking (PENDING), catch global pour ne pas bloquer
- Webhook Stripe rental-bundle : envoie sendRentalConfirmed à chaque
  locataire après update CONFIRMED+SUCCEEDED

Plugin gear-rental :
- Ajout au registry (catégorie business)
- layout.tsx /materiel + /espace-prestataire avec requirePluginOr404
- requirePluginOr404 dans /panier et /mes-locations
- isPluginEnabled guard dans POST /api/rentals/checkout (404 si off)
- SiteHeader masque liens Matériel / Mes locations / Espace prestataire
  + CartBadge si plugin désactivé
- CompleteYourStay renvoie null si plugin désactivé
Décision admin → activable depuis /admin/plugins comme tous les autres.

Tests vitest (tests/lib/rentals.test.ts, 16 tests) :
- diffDays (mêmes dates, 1 nuit, 7 jours, négatif)
- parseCart (null/garbage/schéma invalide/valide/format date)
- serializeCart (updatedAt, roundtrip)
- commission formula (0%, 15%, arrondi centime)
- availability arithmetic (totalQty libre, soustractions, plancher 0)

53 tests pass total. Build OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-06-02 08:49:39 +00:00
parent 0723e50189
commit 5607a51980
13 changed files with 313 additions and 8 deletions

View file

@ -5,6 +5,11 @@ import { auth } from "@/auth";
import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
import { Prisma } from "@/generated/prisma/client";
import { recordAudit } from "@/lib/admin/audit";
import {
sendRentalRequestedProvider,
sendRentalRequestedTenant,
} from "@/lib/email";
import { isPluginEnabled } from "@/lib/plugins/server";
import { prisma } from "@/lib/prisma";
import { CART_COOKIE, EMPTY_CART, diffDays, parseCart } from "@/lib/rental-cart";
import {
@ -28,6 +33,9 @@ function parseDateOnly(s: string): Date {
}
export async function POST() {
if (!(await isPluginEnabled("gear-rental"))) {
return NextResponse.json({ error: "Service de location indisponible." }, { status: 404 });
}
const session = await auth();
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: "Connectez-vous pour finaliser." }, { status: 401 });
@ -241,6 +249,46 @@ export async function POST() {
},
});
// Emails best-effort : 1 mail au locataire (récap par prestataire) + 1 mail
// à chaque prestataire (sa demande). En cas d'échec d'envoi, on ne bloque pas.
try {
const fullBookings = await prisma.rentalBooking.findMany({
where: { id: { in: rentalBookingIds } },
include: {
provider: { select: { name: true, contactEmail: true } },
lines: { include: { item: { select: { name: true } } } },
},
});
const tenantName = session.user.name ?? session.user.email!;
for (const rb of fullBookings) {
const lineSummary = rb.lines.map((l) => ({ qty: l.qty, itemName: l.item.name }));
await sendRentalRequestedTenant(
session.user.email!,
tenantName,
rb.id,
rb.provider.name,
rb.startDate,
rb.endDate,
rb.amount.toString(),
rb.currency,
lineSummary,
);
if (rb.provider.contactEmail) {
await sendRentalRequestedProvider(
rb.provider.contactEmail,
rb.provider.name,
rb.id,
tenantName,
rb.startDate,
rb.endDate,
lineSummary,
);
}
}
} catch (e) {
console.error("[rental.checkout] email send failed:", e instanceof Error ? e.message : e);
}
// Vide le panier
jar.set(CART_COOKIE, JSON.stringify(EMPTY_CART), {
httpOnly: false,

View file

@ -8,6 +8,7 @@ import {
SubscriptionStatus,
} from "@/generated/prisma/enums";
import { refreshCarbetLastBookedAt } from "@/lib/carbet-last-booked";
import { sendRentalConfirmed } from "@/lib/email";
import { prisma } from "@/lib/prisma";
import { fromStripeTimestamp, getStripeClient } from "@/lib/stripe";
@ -64,6 +65,28 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
status: RentalBookingStatus.CONFIRMED,
},
});
try {
const rentals = await prisma.rentalBooking.findMany({
where: { id: { in: ids } },
include: {
provider: { select: { name: true } },
tenant: { select: { email: true, firstName: true } },
},
});
for (const rb of rentals) {
if (!rb.tenant.email) continue;
await sendRentalConfirmed(
rb.tenant.email,
rb.tenant.firstName ?? rb.tenant.email,
rb.id,
rb.provider.name,
rb.startDate,
rb.endDate,
);
}
} catch (e) {
console.error("[webhook.rental] email send failed:", e instanceof Error ? e.message : e);
}
return;
}

View file

@ -1,5 +1,6 @@
import Link from "next/link";
import { isPluginEnabled } from "@/lib/plugins/server";
import { prisma } from "@/lib/prisma";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
@ -17,6 +18,7 @@ const EMOJI: Record<string, string> = {
};
export async function CompleteYourStay({ river, capacity }: Props) {
if (!(await isPluginEnabled("gear-rental"))) return null;
const providers = await prisma.rentalProvider.findMany({
where: {
active: true,

View file

@ -0,0 +1,6 @@
import { requirePluginOr404 } from "@/lib/plugins/guard";
export default async function ProviderLayout({ children }: { children: React.ReactNode }) {
await requirePluginOr404("gear-rental");
return <>{children}</>;
}

View file

@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { requirePluginOr404 } from "@/lib/plugins/guard";
import { getPublicRentalItem } from "@/lib/rentals-public";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
@ -23,6 +24,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
}
export default async function RentalItemDetailPage({ params }: PageProps) {
await requirePluginOr404("gear-rental");
const { itemId } = await params;
const item = await getPublicRentalItem(itemId);
if (!item) notFound();

View file

@ -0,0 +1,6 @@
import { requirePluginOr404 } from "@/lib/plugins/guard";
export default async function MaterielLayout({ children }: { children: React.ReactNode }) {
await requirePluginOr404("gear-rental");
return <>{children}</>;
}

View file

@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { RentalCategory } from "@/generated/prisma/enums";
import { requirePluginOr404 } from "@/lib/plugins/guard";
import { isRentalCategory } from "@/lib/rental-category-labels";
import {
listPublicProviders,
@ -29,6 +30,7 @@ type PageProps = {
};
export default async function MaterialPage({ searchParams }: PageProps) {
await requirePluginOr404("gear-rental");
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,

View file

@ -1,6 +1,7 @@
import Link from "next/link";
import { requireAuth } from "@/lib/authorization";
import { requirePluginOr404 } from "@/lib/plugins/guard";
import { prisma } from "@/lib/prisma";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
@ -27,6 +28,7 @@ const PAYMENT_LABEL: Record<string, string> = {
type SearchParams = Promise<{ payment?: string; ids?: string; ok?: string }>;
export default async function MyRentalsPage({ searchParams }: { searchParams: SearchParams }) {
await requirePluginOr404("gear-rental");
const session = await requireAuth();
const sp = await searchParams;

View file

@ -1,5 +1,6 @@
import Link from "next/link";
import { requirePluginOr404 } from "@/lib/plugins/guard";
import { prisma } from "@/lib/prisma";
import { readCartFromCookies } from "@/lib/rental-cart-server";
@ -10,6 +11,7 @@ export const dynamic = "force-dynamic";
export const metadata = { title: "Mon panier matériel" };
export default async function CartPage() {
await requirePluginOr404("gear-rental");
const cart = await readCartFromCookies();
// Charge les items du panier en bulk pour rendu

View file

@ -7,6 +7,7 @@ import Link from "next/link";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { isPluginEnabled } from "@/lib/plugins/server";
import { CartBadge } from "./CartBadge";
import { SignOutButton } from "./SignOutButton";
@ -17,6 +18,7 @@ export async function SiteHeader() {
const isAdmin = u?.role === UserRole.ADMIN;
const isOwner = u?.role === UserRole.OWNER || isAdmin;
const isRentalProvider = u?.role === UserRole.RENTAL_PROVIDER || isAdmin;
const rentalEnabled = await isPluginEnabled("gear-rental");
return (
<header className="sticky top-0 z-30 border-b border-zinc-200 bg-white/85 backdrop-blur supports-[backdrop-filter]:bg-white/70">
@ -35,13 +37,15 @@ export async function SiteHeader() {
<Link href="/carbets" className="hover:text-zinc-900">
Catalogue
</Link>
<Link href="/materiel" className="hover:text-zinc-900">
Matériel
</Link>
{rentalEnabled ? (
<Link href="/materiel" className="hover:text-zinc-900">
Matériel
</Link>
) : null}
</nav>
<div className="flex items-center gap-3 text-sm">
<CartBadge />
{rentalEnabled ? <CartBadge /> : null}
{u ? (
<>
<Link href="/mes-favoris" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
@ -50,9 +54,11 @@ export async function SiteHeader() {
<Link href="/mes-reservations" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Mes réservations
</Link>
<Link href="/mes-locations" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Mes locations
</Link>
{rentalEnabled ? (
<Link href="/mes-locations" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Mes locations
</Link>
) : null}
<Link href="/mon-compte" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Mon compte
</Link>
@ -61,7 +67,7 @@ export async function SiteHeader() {
Espace hôte
</Link>
) : null}
{isRentalProvider ? (
{isRentalProvider && rentalEnabled ? (
<Link href="/espace-prestataire" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Espace prestataire
</Link>

View file

@ -224,6 +224,99 @@ export async function sendPasswordReset(
});
}
type RentalLineSummary = { qty: number; itemName: string };
function renderLines(lines: RentalLineSummary[]): string {
return lines.map((l) => `<li>${l.qty}× ${l.itemName}</li>`).join("");
}
export async function sendRentalRequestedTenant(
to: string,
firstName: string,
rentalBookingId: string,
providerName: string,
startDate: Date,
endDate: Date,
amount: string,
currency: string,
lines: RentalLineSummary[],
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Demande de location matériel — ${providerName}`,
html: wrap(
"Votre demande de location est enregistrée",
`<p>Bonjour ${firstName},</p>
<p>Votre demande de location auprès de <strong>${providerName}</strong> est bien enregistrée :</p>
<ul>
<li>Du ${fmt(startDate)} au ${fmt(endDate)}</li>
<li>Montant : ${Number(amount).toFixed(2)} ${currency}</li>
</ul>
<p><strong>Matériel demandé :</strong></p>
<ul>${renderLines(lines)}</ul>
<p>Vous recevrez un nouvel email dès que le paiement sera validé et le prestataire confirmera la préparation du matériel.</p>
<p><a href="${SITE_URL}/mes-locations" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mes locations</a></p>
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
),
});
}
export async function sendRentalRequestedProvider(
to: string,
providerName: string,
rentalBookingId: string,
tenantName: string,
startDate: Date,
endDate: Date,
lines: RentalLineSummary[],
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Nouvelle demande de location — ${tenantName}`,
html: wrap(
"Nouvelle demande à préparer",
`<p>Bonjour ${providerName},</p>
<p><strong>${tenantName}</strong> vient de réserver du matériel :</p>
<ul>
<li>Du ${fmt(startDate)} au ${fmt(endDate)}</li>
</ul>
<p><strong>Matériel :</strong></p>
<ul>${renderLines(lines)}</ul>
<p>Préparez le matériel pour la remise. Vous recevrez une confirmation paiement une fois le règlement validé.</p>
<p><a href="${SITE_URL}/espace-prestataire/reservations" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mes réservations</a></p>
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
),
});
}
export async function sendRentalConfirmed(
to: string,
firstName: string,
rentalBookingId: string,
providerName: string,
startDate: Date,
endDate: Date,
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Location confirmée — ${providerName}`,
html: wrap(
"Votre location est confirmée",
`<p>Bonjour ${firstName},</p>
<p>Le paiement de votre location auprès de <strong>${providerName}</strong> du ${fmt(startDate)} au ${fmt(endDate)} est validé.</p>
<p>Le prestataire vous contactera pour organiser la remise du matériel sur place.</p>
<p><a href="${SITE_URL}/mes-locations" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma location</a></p>
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
),
});
}
export async function sendBookingRefunded(
to: string,
firstName: string,

View file

@ -109,6 +109,14 @@ export const PLUGINS: PluginDescriptor[] = [
category: "business",
version: "0.1.0",
},
{
key: "gear-rental",
name: "Location matériel (sous-marketplace)",
description:
"Catalogue matériel (hamac, moustiquaire, pirogue, kayak…) loué par System D et prestataires tiers. Inclut panier, checkout Stripe, espace prestataire, recommandations carbet. Si désactivé : /materiel, /espace-prestataire et /mes-locations renvoient 404; liens header masqués.",
category: "business",
version: "0.1.0",
},
// Contenus / i18n
{