production: reminder cron, dashboard overhaul, shadcn components, setup wizard

- /api/cron/reminders: processes pending reminders every 15min, sends WhatsApp with email fallback
- /api/cron/overdue: marks overdue pledges daily (7d deferred, 14d immediate)
- /api/pledges: GET handler with filtering, search, pagination, sort by dueDate
- Dashboard overview: stats, collection progress bar, needs attention, upcoming payments
- Dashboard pledges: proper table with status tabs, search, actions, pagination
- New shadcn components: Table, Tabs, DropdownMenu, Progress
- Setup wizard: 4-step onboarding (org → bank → event → QR code)
- Settings API: PUT handler for org create/update
- Org resolver: single-tenant fallback to first org
- Cron jobs installed: reminders every 15min, overdue check at 6am
- Auto-generates installment dates when not provided
- HOSTNAME=0.0.0.0 in compose for multi-network binding
This commit is contained in:
2026-03-03 05:11:17 +08:00
parent 250221b530
commit c79b9bcabc
61 changed files with 3547 additions and 534 deletions

View File

@@ -0,0 +1,155 @@
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
if (channel === "whatsapp" && phone && whatsappReady) {
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 (exposed via webhook API for external tools like n8n/Zapier)
else if (channel === "email" && email) {
// 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",
})
}