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
124 lines
4.4 KiB
TypeScript
124 lines
4.4 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 })
|
||
}
|
||
|
||
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 })
|
||
}
|
||
}
|