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 },
|
||||
});
|
||||
}
|
||||
18
src/lib/cron-auth.ts
Normal file
18
src/lib/cron-auth.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
47
tests/lib/cron-auth.test.ts
Normal file
47
tests/lib/cron-auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue