diff --git a/src/app/api/cron/cleanup/route.ts b/src/app/api/cron/cleanup/route.ts new file mode 100644 index 0000000..103315e --- /dev/null +++ b/src/app/api/cron/cleanup/route.ts @@ -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, + }); +} diff --git a/src/app/api/cron/reminders/route.ts b/src/app/api/cron/reminders/route.ts new file mode 100644 index 0000000..96e0573 --- /dev/null +++ b/src/app/api/cron/reminders/route.ts @@ -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 }, + }); +} diff --git a/src/lib/cron-auth.ts b/src/lib/cron-auth.ts new file mode 100644 index 0000000..dd034ba --- /dev/null +++ b/src/lib/cron-auth.ts @@ -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; +} diff --git a/src/lib/email.ts b/src/lib/email.ts index 7bb7779..47eb1d9 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -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 { + 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", + `

Bonjour ${firstName},

+

Votre séjour au carbet ${carbetTitle} commence ${dateFmt}.

+

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).

+

Voir ma réservation

+

Détails du carbet

`, + ), + }); +} + +export async function sendRentalReminder( + to: string, + firstName: string, + rentalBookingId: string, + providerName: string, + startDate: Date, + providerContact: { email: string | null; phone: string | null }, +): Promise { + 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", + `

Bonjour ${firstName},

+

Votre location matériel auprès de ${providerName} démarre ${dateFmt}.

+

Contactez le prestataire pour convenir du créneau et du lieu de remise.

+ ${contact ? `

${contact}

` : ""} +

Mes locations

+

Référence : ${rentalBookingId}

`, + ), + }); +} + export async function sendBookingRefunded( to: string, firstName: string, diff --git a/tests/lib/cron-auth.test.ts b/tests/lib/cron-auth.test.ts new file mode 100644 index 0000000..e31e2fa --- /dev/null +++ b/tests/lib/cron-auth.test.ts @@ -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); + }); +});