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.
150 lines
5.6 KiB
TypeScript
150 lines
5.6 KiB
TypeScript
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 })
|
||
}
|
||
}
|