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;
}