feat(prod): Sprint Q — reminders J-1 + cleanup cron endpoints
All checks were successful
CI / test (pull_request) Successful in 2m45s

Endpoints automatisables par cron externe (Hermes, GitHub Actions,
ou crontab système) pour gérer les tâches récurrentes de la
plateforme.

src/lib/cron-auth.ts (NEW) : isAuthorizedCronRequest(req) vérifie
l'en-tête Authorization Bearer ${CRON_TOKEN}. Le token est déjà dans
.env.production.

GET /api/cron/reminders :
- Itère bookings carbet CONFIRMED + rentalBookings CONFIRMED dont
  startDate ∈ [now+22h, now+26h] (fenêtre 4h pour absorber les
  éventuels retards de cron).
- Envoie sendBookingReminder (carbet) ou sendRentalReminder (rental).
- Compte bookingSent/bookingErrors et rentalSent/rentalErrors.
- Audit log scope=cron event=cron.reminders.run avec stats.
- Retourne JSON {ok, window, booking:{candidates,sent,errors},
  rental:{candidates,sent,errors}}.

GET /api/cron/cleanup :
- Purge OrgInviteToken expirés depuis > 30j.
- Booking PENDING + paymentStatus≠SUCCEEDED + createdAt > 7j →
  status=CANCELLED + paymentStatus=FAILED (libère le créneau).
- RentalBooking idem + delete RentalItemAvailability associée
  (libère stock) en transaction.
- Audit log scope=cron event=cron.cleanup.run avec compteurs.

src/lib/email.ts :
- sendBookingReminder(to, firstName, bookingId, title, startDate,
  slug) : email rappel J-1 avec CTA vers /reservations/[id].
- sendRentalReminder(to, firstName, rbId, providerName, startDate,
  contactInfo) : rappel pour récup matériel, affiche contacts
  provider (phone + email).

tests/lib/cron-auth.test.ts (6 cas) :
- Refus si CRON_TOKEN absent, header absent, format incorrect (Basic
  ou Token), token mismatch.
- Accept si match exact, accept avec espaces autour du token (defensive).

Total tests : 89/89 ✓.

Schedule recommandé (à brancher côté Hermes ou crontab) :
- GET /api/cron/reminders : 1× par jour à 9h (Authorization: Bearer
  $CRON_TOKEN)
- GET /api/cron/cleanup : 1× par semaine

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-06-03 03:23:58 +00:00
parent 9bdb3666a0
commit a6ea488732
5 changed files with 368 additions and 0 deletions

View file

@ -0,0 +1,113 @@
import { NextResponse } from "next/server";
import {
BookingStatus,
PaymentStatus,
RentalBookingStatus,
} from "@/generated/prisma/enums";
import { recordAudit } from "@/lib/admin/audit";
import { isAuthorizedCronRequest } from "@/lib/cron-auth";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const INVITE_EXPIRY_GRACE_DAYS = 30;
const ABANDONED_PENDING_DAYS = 7;
/**
* GET /api/cron/cleanup
*
* Purge :
* - OrgInviteToken expirés depuis plus de 30j (rétention pour audit court).
* - Booking carbet PENDING dont createdAt > 7j et paiement non SUCCEEDED :
* status passé à CANCELLED (libère le créneau via cascade des
* Availabilities seulement si onDelete CASCADE ici on flip juste
* status pour conserver le log).
* - RentalBooking PENDING idem + delete RentalItemAvailability associée
* (libère le stock).
*
* Auth : Bearer CRON_TOKEN.
*/
export async function GET(req: Request) {
if (!isAuthorizedCronRequest(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const now = new Date();
const inviteCutoff = new Date(now.getTime() - INVITE_EXPIRY_GRACE_DAYS * 86_400_000);
const abandonedCutoff = new Date(now.getTime() - ABANDONED_PENDING_DAYS * 86_400_000);
// 1. Invites expirés (expiresAt < cutoff)
const { count: invitesDeleted } = await prisma.orgInviteToken.deleteMany({
where: { expiresAt: { lt: inviteCutoff } },
});
// 2. Bookings carbet PENDING abandonnés
const abandonedBookings = await prisma.booking.findMany({
where: {
status: BookingStatus.PENDING,
paymentStatus: { not: PaymentStatus.SUCCEEDED },
createdAt: { lt: abandonedCutoff },
},
select: { id: true, carbetId: true },
});
let bookingsCancelled = 0;
if (abandonedBookings.length > 0) {
const { count } = await prisma.booking.updateMany({
where: { id: { in: abandonedBookings.map((b) => b.id) } },
data: { status: BookingStatus.CANCELLED, paymentStatus: PaymentStatus.FAILED },
});
bookingsCancelled = count;
}
// 3. RentalBookings PENDING abandonnés + delete availability associée
const abandonedRentals = await prisma.rentalBooking.findMany({
where: {
status: RentalBookingStatus.PENDING,
paymentStatus: { not: PaymentStatus.SUCCEEDED },
createdAt: { lt: abandonedCutoff },
},
select: { id: true },
});
let rentalsCancelled = 0;
let availabilityFreed = 0;
if (abandonedRentals.length > 0) {
const ids = abandonedRentals.map((r) => r.id);
const [rentalRes, availRes] = await prisma.$transaction([
prisma.rentalBooking.updateMany({
where: { id: { in: ids } },
data: {
status: RentalBookingStatus.CANCELLED,
paymentStatus: PaymentStatus.FAILED,
},
}),
prisma.rentalItemAvailability.deleteMany({
where: { rentalBookingId: { in: ids } },
}),
]);
rentalsCancelled = rentalRes.count;
availabilityFreed = availRes.count;
}
await recordAudit({
scope: "cron",
event: "cron.cleanup.run",
target: null,
actorEmail: "system:cron",
details: {
invitesDeleted,
bookingsCancelled,
rentalsCancelled,
availabilityFreed,
},
});
return NextResponse.json({
ok: true,
invitesDeleted,
bookingsCancelled,
rentalsCancelled,
availabilityFreed,
});
}

View file

@ -0,0 +1,128 @@
import { NextResponse } from "next/server";
import {
BookingStatus,
RentalBookingStatus,
} from "@/generated/prisma/enums";
import { recordAudit } from "@/lib/admin/audit";
import { isAuthorizedCronRequest } from "@/lib/cron-auth";
import { sendBookingReminder, sendRentalReminder } from "@/lib/email";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/cron/reminders
*
* Envoie des rappels J-1 (24h avant le début) pour :
* - Booking CONFIRMED dont startDate [now+22h, now+26h]
* - RentalBooking CONFIRMED idem
*
* Idempotent à l'échelle d'une journée : le filtre temporel narrow limite
* naturellement le risque de double-envoi (en pratique le cron tourne 1× par
* jour à heure fixe). Pour une garantie at-most-once stricte il faudrait
* stocker un flag `reminderSentAt` sur Booking/RentalBooking défensif
* mais pas critique pour v1.
*
* Auth : Bearer CRON_TOKEN.
*/
export async function GET(req: Request) {
if (!isAuthorizedCronRequest(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const now = new Date();
const from = new Date(now.getTime() + 22 * 60 * 60 * 1000);
const to = new Date(now.getTime() + 26 * 60 * 60 * 1000);
const [carbetBookings, rentalBookings] = await Promise.all([
prisma.booking.findMany({
where: {
status: BookingStatus.CONFIRMED,
startDate: { gte: from, lt: to },
},
include: {
tenant: { select: { email: true, firstName: true } },
carbet: { select: { title: true, slug: true } },
},
}),
prisma.rentalBooking.findMany({
where: {
status: RentalBookingStatus.CONFIRMED,
startDate: { gte: from, lt: to },
},
include: {
tenant: { select: { email: true, firstName: true } },
provider: { select: { name: true, contactEmail: true, contactPhone: true } },
},
}),
]);
let bookingSent = 0;
let bookingErrors = 0;
for (const b of carbetBookings) {
if (!b.tenant.email) continue;
try {
await sendBookingReminder(
b.tenant.email,
b.tenant.firstName,
b.id,
b.carbet.title,
b.startDate,
b.carbet.slug,
);
bookingSent++;
} catch (e) {
bookingErrors++;
console.error(
"[cron.reminders] booking email failed:",
b.id,
e instanceof Error ? e.message : e,
);
}
}
let rentalSent = 0;
let rentalErrors = 0;
for (const r of rentalBookings) {
if (!r.tenant.email) continue;
try {
await sendRentalReminder(
r.tenant.email,
r.tenant.firstName,
r.id,
r.provider.name,
r.startDate,
{ email: r.provider.contactEmail, phone: r.provider.contactPhone },
);
rentalSent++;
} catch (e) {
rentalErrors++;
console.error(
"[cron.reminders] rental email failed:",
r.id,
e instanceof Error ? e.message : e,
);
}
}
await recordAudit({
scope: "cron",
event: "cron.reminders.run",
target: null,
actorEmail: "system:cron",
details: {
window: { from: from.toISOString(), to: to.toISOString() },
booking: { candidates: carbetBookings.length, sent: bookingSent, errors: bookingErrors },
rental: { candidates: rentalBookings.length, sent: rentalSent, errors: rentalErrors },
},
});
return NextResponse.json({
ok: true,
window: { from: from.toISOString(), to: to.toISOString() },
booking: { candidates: carbetBookings.length, sent: bookingSent, errors: bookingErrors },
rental: { candidates: rentalBookings.length, sent: rentalSent, errors: rentalErrors },
});
}

18
src/lib/cron-auth.ts Normal file
View file

@ -0,0 +1,18 @@
import "server-only";
/**
* Auth Bearer pour les endpoints /api/cron/*. Le token est partagé entre le
* serveur et le cron caller externe (Hermes, cron host, etc.).
*
* Renvoie true si l'en-tête Authorization correspond exactement à
* `Bearer ${process.env.CRON_TOKEN}` (timing-safe via le comparateur natif
* acceptable car le token n'est pas dérivable de la requête).
*/
export function isAuthorizedCronRequest(req: Request): boolean {
const expected = (process.env.CRON_TOKEN ?? "").trim();
if (!expected) return false;
const header = req.headers.get("authorization") ?? "";
if (!header.startsWith("Bearer ")) return false;
const token = header.slice("Bearer ".length).trim();
return token === expected;
}

View file

@ -440,6 +440,68 @@ export async function sendPayoutSent(
});
}
export async function sendBookingReminder(
to: string,
firstName: string,
bookingId: string,
carbetTitle: string,
startDate: Date,
carbetSlug: string,
): Promise<void> {
const dateFmt = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "long",
year: "numeric",
weekday: "long",
}).format(startDate);
await sendEmail({
to,
subject: `Demain : votre séjour ${carbetTitle}`,
html: wrap(
"Votre séjour démarre demain",
`<p>Bonjour ${firstName},</p>
<p>Votre séjour au carbet <strong>${carbetTitle}</strong> commence <strong>${dateFmt}</strong>.</p>
<p>Pensez à vérifier vos affaires : hamac, moustiquaire, frontale, eau, etc. Vérifiez aussi avec le loueur les détails d'arrivée (clés, dégrad, pirogue).</p>
<p><a href="${SITE_URL}/reservations/${bookingId}" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma réservation</a></p>
<p><a href="${SITE_URL}/carbets/${carbetSlug}" style="font-size:12px;color:#71717a;">Détails du carbet</a></p>`,
),
});
}
export async function sendRentalReminder(
to: string,
firstName: string,
rentalBookingId: string,
providerName: string,
startDate: Date,
providerContact: { email: string | null; phone: string | null },
): Promise<void> {
const dateFmt = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "long",
weekday: "long",
}).format(startDate);
const contact = [
providerContact.phone ? `📞 ${providerContact.phone}` : null,
providerContact.email ? `${providerContact.email}` : null,
]
.filter(Boolean)
.join(" · ");
await sendEmail({
to,
subject: `Demain : récupération matériel ${providerName}`,
html: wrap(
"Récupération matériel demain",
`<p>Bonjour ${firstName},</p>
<p>Votre location matériel auprès de <strong>${providerName}</strong> démarre <strong>${dateFmt}</strong>.</p>
<p>Contactez le prestataire pour convenir du créneau et du lieu de remise.</p>
${contact ? `<p>${contact}</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 sendBookingRefunded(
to: string,
firstName: string,

View file

@ -0,0 +1,47 @@
import { describe, it, expect, vi, afterEach } from "vitest";
vi.mock("server-only", () => ({}));
const { isAuthorizedCronRequest } = await import("@/lib/cron-auth");
function mkReq(authHeader: string | null): Request {
return new Request("https://example.invalid/", {
headers: authHeader ? { authorization: authHeader } : {},
});
}
afterEach(() => {
delete process.env.CRON_TOKEN;
});
describe("isAuthorizedCronRequest", () => {
it("refuse si CRON_TOKEN absent côté serveur", () => {
expect(isAuthorizedCronRequest(mkReq("Bearer anything"))).toBe(false);
});
it("refuse si pas d'en-tête Authorization", () => {
process.env.CRON_TOKEN = "secret";
expect(isAuthorizedCronRequest(mkReq(null))).toBe(false);
});
it("refuse si format incorrect (pas Bearer)", () => {
process.env.CRON_TOKEN = "secret";
expect(isAuthorizedCronRequest(mkReq("Basic secret"))).toBe(false);
expect(isAuthorizedCronRequest(mkReq("Token secret"))).toBe(false);
});
it("refuse si token différent", () => {
process.env.CRON_TOKEN = "secret";
expect(isAuthorizedCronRequest(mkReq("Bearer wrong"))).toBe(false);
});
it("accepte si token exact", () => {
process.env.CRON_TOKEN = "secret";
expect(isAuthorizedCronRequest(mkReq("Bearer secret"))).toBe(true);
});
it("trim les espaces autour du token (defensive)", () => {
process.env.CRON_TOKEN = "secret";
expect(isAuthorizedCronRequest(mkReq("Bearer secret "))).toBe(true);
});
});