feat(prod): Sprint Q — reminders J-1 + cleanup cron endpoints
All checks were successful
CI / test (pull_request) Successful in 2m45s
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:
parent
9bdb3666a0
commit
a6ea488732
5 changed files with 368 additions and 0 deletions
113
src/app/api/cron/cleanup/route.ts
Normal file
113
src/app/api/cron/cleanup/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
128
src/app/api/cron/reminders/route.ts
Normal file
128
src/app/api/cron/reminders/route.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue