Files
calvana/pledge-now-pay-later/src/app/api/whatsapp/webhook/route.ts
Omair Saleh 4f23f28873 production auth: signup, login, protected dashboard, landing page, WAHA QR fix
AUTH:
- NextAuth with credentials provider (bcrypt password hashing)
- /api/auth/signup: creates org + user in transaction
- /login, /signup pages with clean minimal UI
- Middleware protects all /dashboard/* routes → redirects to /login
- Session-based org resolution (no more hardcoded 'demo' headers)
- SessionProvider wraps entire app
- Dashboard header shows org name + sign out button

LANDING PAGE:
- Full marketing page at / with hero, problem, how-it-works, features, CTA
- 'Get Started Free' → /signup → auto-login → /dashboard/setup
- Clean responsive design, no auth required for public pages

WAHA QR FIX:
- WAHA CORE doesn't expose QR value via API or webhook
- Now uses /api/screenshot (full browser capture) with CSS crop to QR area
- Settings panel shows cropped screenshot with overflow:hidden
- Auto-polls every 5s, refresh button

MULTI-TENANT:
- getOrgId() tries session first, then header, then first-org fallback
- All dashboard APIs use session-based org
- Signup creates isolated org per charity
2026-03-03 05:37:04 +08:00

124 lines
4.4 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 })
}
const text = payload.body.trim().toUpperCase()
const fromPhone = payload.from.replace("@c.us", "")
// Only handle known commands
if (!["PAID", "HELP", "CANCEL", "STATUS"].includes(text)) {
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() },
})
await sendWhatsAppMessage(fromPhone,
`Your £${amount} pledge to ${pledge.event.name} has been cancelled. No worries — 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 })
}
}