Files
calvana/pledge-now-pay-later/src/app/api/whatsapp/webhook/route.ts
Omair Saleh fcfae1c1a4 Ship all P0/P1/P2 gaps + 11 AI features
P0 Critical (7):
- STOP/UNSUBSCRIBE keyword → CANCEL (PECR compliance)
- Rate limiting on pledge creation (10/IP/5min)
- Terms of Service + Privacy Policy pages
- WhatsApp onboarding gate (persistent dashboard banner)
- Demo account seeding (demo@pnpl.app)
- Footer legal links
- Basic accessibility (aria labels on donor flow)

P1 Within 2 Weeks (8):
- Pledge editing by staff (PATCH amount, name, email, phone, rail)
- Donor self-cancel page (/p/cancel) + API
- Donor 'My Pledges' lookup page (/p/my-pledges)
- Bulk QR code download (print-ready HTML)
- Public event progress bar (/e/[slug]/progress)
- Email-only donor handling (honest status + WhatsApp fallback)
- Email verification (format + disposable domain blocking)
- Organisations page rewrite (multi-campaign, not multi-org)

P2 Within First Month (10):
- Event cloning with QR sources
- Account deletion (GDPR Article 17)
- Daily digest cron via WhatsApp
- AI-6 Smart reminder timing (due date anchoring, cultural sensitivity)
- H1 Duplicate donor detection (email, phone, Jaro-Winkler name)
- H5 Bank CSV format presets (10 UK banks)
- H16 Partial payment matching (underpay, overpay, instalment)
- H10 Activity logging (audit trail for staff actions)
- AI nudge endpoint + AI column mapping + AI event setup wizard
- AI anomaly detection wired into daily digest

AI Features (11): smart reconciliation, social proof, auto column mapper,
daily digest, impact storyteller, smart timing, nudge composer, event wizard,
NLU concierge, anomaly detection, bank presets

22 new files, 15 modified files, 0 TypeScript errors, clean build.
2026-03-04 20:10:34 +08:00

150 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { sendWhatsAppMessage } from "@/lib/whatsapp"
import { setQrValue } from "@/lib/qr-store"
/**
* WAHA webhook — receives incoming WhatsApp messages + session status events
* Handles: PAID, HELP, CANCEL commands from donors
* Also captures QR code value for the pairing flow
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { event, payload } = body
// Handle session.status events (for QR code capture)
if (event === "session.status") {
console.log("[WAHA] Session status:", JSON.stringify({ status: payload?.status, hasQr: !!payload?.qr, keys: Object.keys(payload || {}) }))
// WAHA sends QR in different formats depending on version
const qrVal = payload?.qr?.value || payload?.qr || payload?.qrCode
if (payload?.status === "SCAN_QR_CODE" && qrVal && typeof qrVal === "string") {
setQrValue(qrVal)
console.log("[WAHA] QR value stored, length:", qrVal.length)
}
return NextResponse.json({ ok: true })
}
// WAHA sends message events
if (!payload?.body || !payload?.from) {
return NextResponse.json({ ok: true })
}
let text = payload.body.trim().toUpperCase()
const fromPhone = payload.from.replace("@c.us", "")
// Alias STOP / UNSUBSCRIBE / OPT OUT → CANCEL (PECR compliance)
if (["STOP", "UNSUBSCRIBE", "OPT OUT", "OPTOUT"].includes(text)) {
text = "CANCEL"
}
// Only handle known commands — all others go to AI NLU (if enabled)
if (!["PAID", "HELP", "CANCEL", "STATUS"].includes(text)) {
// AI-10: Natural Language Understanding for non-keyword messages
try {
const { classifyDonorMessage } = await import("@/lib/ai")
const intent = await classifyDonorMessage(payload.body.trim(), fromPhone)
if (intent && intent.confidence >= 0.8 && ["PAID", "HELP", "CANCEL", "STATUS"].includes(intent.action)) {
text = intent.action
} else {
return NextResponse.json({ ok: true })
}
} catch {
return NextResponse.json({ ok: true })
}
}
if (!prisma) return NextResponse.json({ ok: true })
// Find pledges by this phone number
// Normalize: try with and without country code
const phoneVariants = [fromPhone]
if (fromPhone.startsWith("44")) {
phoneVariants.push("0" + fromPhone.slice(2))
phoneVariants.push("+" + fromPhone)
}
const pledges = await prisma.pledge.findMany({
where: {
donorPhone: { in: phoneVariants },
status: { in: ["new", "initiated"] },
},
include: {
event: { select: { name: true } },
paymentInstruction: true,
},
orderBy: { createdAt: "desc" },
take: 5,
})
if (pledges.length === 0) {
await sendWhatsAppMessage(fromPhone, `We couldn't find any pending pledges for this number. If you need help, please contact the charity directly.`)
return NextResponse.json({ ok: true })
}
const pledge = pledges[0] // Most recent
const amount = (pledge.amountPence / 100).toFixed(0)
switch (text) {
case "PAID": {
await prisma.pledge.update({
where: { id: pledge.id },
data: { status: "initiated", iPaidClickedAt: new Date() },
})
await sendWhatsAppMessage(fromPhone,
`✅ Thanks! We've noted that you've paid your *£${amount}* pledge to ${pledge.event.name}.\n\nWe'll confirm once the payment is matched. Ref: \`${pledge.reference}\``
)
break
}
case "HELP": {
const bankDetails = pledge.paymentInstruction?.bankDetails as Record<string, string> | null
if (bankDetails) {
await sendWhatsAppMessage(fromPhone,
`🏦 *Bank Details for your £${amount} pledge:*\n\nSort Code: \`${bankDetails.sortCode}\`\nAccount: \`${bankDetails.accountNo}\`\nName: ${bankDetails.accountName}\nReference: \`${pledge.reference}\`\n\n⚠ _Use the exact reference above_`
)
} else {
await sendWhatsAppMessage(fromPhone,
`Your pledge ref is \`${pledge.reference}\` for £${amount} to ${pledge.event.name}.\n\nContact the charity for payment details.`
)
}
break
}
case "CANCEL": {
await prisma.pledge.update({
where: { id: pledge.id },
data: { status: "cancelled", cancelledAt: new Date() },
})
// Revoke WhatsApp consent for ALL pledges from this number (PECR compliance)
await prisma.pledge.updateMany({
where: { donorPhone: { in: phoneVariants }, whatsappOptIn: true },
data: { whatsappOptIn: false },
})
// Skip all pending reminders for cancelled pledge
await prisma.reminder.updateMany({
where: { pledgeId: pledge.id, status: "pending" },
data: { status: "skipped" },
})
await sendWhatsAppMessage(fromPhone,
`Your £${amount} pledge to ${pledge.event.name} has been cancelled. You won't receive any more messages from us. Thank you for considering! 🙏`
)
break
}
case "STATUS": {
const statusText = pledges.map(p =>
`• £${(p.amountPence / 100).toFixed(0)}${p.event.name} (${p.status}) [${p.reference}]`
).join("\n")
await sendWhatsAppMessage(fromPhone, `📋 *Your pledges:*\n\n${statusText}`)
break
}
}
return NextResponse.json({ ok: true })
} catch (error) {
console.error("WhatsApp webhook error:", error)
return NextResponse.json({ ok: true })
}
}