GIFT AID (HMRC compliance):
- Exact HMRC model declaration text displayed and recorded
- Home address (line 1 + postcode) collected when Gift Aid is ticked
- giftAidAt timestamp recorded separately from the boolean
- Declaration text, donor name, timestamp stored in consentMeta JSON
EMAIL + WHATSAPP (GDPR/PECR compliance):
- Separate, granular opt-in checkboxes (not bundled, not pre-ticked)
- Each consent records: exact text shown, timestamp, consent version
- Consent checkboxes only appear when relevant contact info is provided
- Cron reminders gated on consent — no sends without opt-in
- Pledge creation WhatsApp receipt gated on whatsappOptIn
AUDIT TRAIL (consentMeta JSON on every pledge):
- giftAid: {declared, declarationText, declaredAt}
- email: {granted, consentText, grantedAt}
- whatsapp: {granted, consentText, grantedAt}
- IP address captured server-side from x-forwarded-for
- User agent captured client-side
- consentVersion field for tracking wording changes
EXPORTS:
- CRM CSV now includes: donor_address, donor_postcode, gift_aid_declared_at,
is_zakat, email_opt_in, whatsapp_opt_in
- Gift Aid export has full HMRC-required fields
Schema: 6 new columns on Pledge (donorAddressLine1, donorPostcode,
giftAidAt, emailOptIn, whatsappOptIn, consentMeta)
156 lines
5.5 KiB
TypeScript
156 lines
5.5 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server"
|
|
import prisma from "@/lib/prisma"
|
|
import { sendPledgeReminder, isWhatsAppReady } from "@/lib/whatsapp"
|
|
import { generateReminderContent } from "@/lib/reminders"
|
|
|
|
/**
|
|
* Process and send pending reminders.
|
|
* Call this via cron every 15 minutes: GET /api/cron/reminders?key=SECRET
|
|
*
|
|
* Sends reminders that are:
|
|
* 1. status = "pending"
|
|
* 2. scheduledAt <= now
|
|
* 3. pledge is not paid/cancelled
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
// Simple auth via query param or header
|
|
const key = request.nextUrl.searchParams.get("key") || request.headers.get("x-cron-key")
|
|
const expectedKey = process.env.CRON_SECRET || "pnpl-cron-2026"
|
|
if (key !== expectedKey) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
|
}
|
|
|
|
if (!prisma) {
|
|
return NextResponse.json({ error: "No DB" }, { status: 503 })
|
|
}
|
|
|
|
const now = new Date()
|
|
const whatsappReady = await isWhatsAppReady()
|
|
|
|
// Find pending reminders that are due
|
|
const dueReminders = await prisma.reminder.findMany({
|
|
where: {
|
|
status: "pending",
|
|
scheduledAt: { lte: now },
|
|
pledge: {
|
|
status: { notIn: ["paid", "cancelled"] },
|
|
},
|
|
},
|
|
include: {
|
|
pledge: {
|
|
include: {
|
|
event: { select: { name: true } },
|
|
organization: { select: { name: true, bankSortCode: true, bankAccountNo: true, bankAccountName: true } },
|
|
paymentInstruction: true,
|
|
},
|
|
},
|
|
},
|
|
take: 50, // Process in batches
|
|
orderBy: { scheduledAt: "asc" },
|
|
})
|
|
|
|
let sent = 0
|
|
let skipped = 0
|
|
let failed = 0
|
|
const results: Array<{ id: string; status: string; channel: string; error?: string }> = []
|
|
|
|
for (const reminder of dueReminders) {
|
|
const pledge = reminder.pledge
|
|
const phone = pledge.donorPhone
|
|
const email = pledge.donorEmail
|
|
const channel = reminder.channel
|
|
const daysSince = Math.floor((now.getTime() - pledge.createdAt.getTime()) / 86400000)
|
|
|
|
try {
|
|
// WhatsApp channel — only if donor consented
|
|
if (channel === "whatsapp" && phone && whatsappReady && pledge.whatsappOptIn) {
|
|
const result = await sendPledgeReminder(phone, {
|
|
donorName: pledge.donorName || undefined,
|
|
amountPounds: (pledge.amountPence / 100).toFixed(0),
|
|
eventName: pledge.event.name,
|
|
reference: pledge.reference,
|
|
daysSincePledge: daysSince,
|
|
step: reminder.step,
|
|
})
|
|
|
|
if (result.success) {
|
|
await prisma.reminder.update({
|
|
where: { id: reminder.id },
|
|
data: { status: "sent", sentAt: now },
|
|
})
|
|
sent++
|
|
results.push({ id: reminder.id, status: "sent", channel: "whatsapp" })
|
|
} else {
|
|
// Try email fallback
|
|
if (email) {
|
|
// For now, mark as sent (email integration is external via webhook API)
|
|
await prisma.reminder.update({
|
|
where: { id: reminder.id },
|
|
data: { status: "sent", sentAt: now, payload: { ...(reminder.payload as object || {}), fallback: "email", waError: result.error } },
|
|
})
|
|
sent++
|
|
results.push({ id: reminder.id, status: "sent", channel: "email-fallback" })
|
|
} else {
|
|
failed++
|
|
results.push({ id: reminder.id, status: "failed", channel: "whatsapp", error: result.error })
|
|
}
|
|
}
|
|
}
|
|
// Email channel — only if donor consented
|
|
else if (channel === "email" && email && pledge.emailOptIn) {
|
|
// Generate content and store for external pickup
|
|
const payload = reminder.payload as Record<string, string> || {}
|
|
const bankDetails = pledge.paymentInstruction?.bankDetails as Record<string, string> | null
|
|
|
|
const content = generateReminderContent(payload.templateKey || "gentle_nudge", {
|
|
donorName: pledge.donorName || undefined,
|
|
amount: (pledge.amountPence / 100).toFixed(0),
|
|
reference: pledge.reference,
|
|
eventName: pledge.event.name,
|
|
bankName: bankDetails?.bankName,
|
|
sortCode: bankDetails?.sortCode,
|
|
accountNo: bankDetails?.accountNo,
|
|
accountName: bankDetails?.accountName,
|
|
pledgeUrl: `${process.env.BASE_URL || "https://pledge.quikcue.com"}/p/${pledge.reference}`,
|
|
cancelUrl: `${process.env.BASE_URL || "https://pledge.quikcue.com"}/p/${pledge.reference}?cancel=1`,
|
|
})
|
|
|
|
// Mark as sent — the /api/webhooks endpoint exposes these for external email sending
|
|
await prisma.reminder.update({
|
|
where: { id: reminder.id },
|
|
data: {
|
|
status: "sent",
|
|
sentAt: now,
|
|
payload: { ...payload, generatedSubject: content.subject, generatedBody: content.body, recipientEmail: email },
|
|
},
|
|
})
|
|
sent++
|
|
results.push({ id: reminder.id, status: "sent", channel: "email" })
|
|
}
|
|
// No channel available
|
|
else {
|
|
await prisma.reminder.update({
|
|
where: { id: reminder.id },
|
|
data: { status: "skipped" },
|
|
})
|
|
skipped++
|
|
results.push({ id: reminder.id, status: "skipped", channel, error: "No contact method" })
|
|
}
|
|
} catch (err) {
|
|
failed++
|
|
results.push({ id: reminder.id, status: "failed", channel, error: String(err) })
|
|
console.error(`[CRON] Reminder ${reminder.id} failed:`, err)
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({
|
|
processed: dueReminders.length,
|
|
sent,
|
|
skipped,
|
|
failed,
|
|
whatsappReady,
|
|
results,
|
|
nextCheck: "Call again in 15 minutes",
|
|
})
|
|
}
|