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:
54
pledge-now-pay-later/src/app/api/cron/overdue/route.ts
Normal file
54
pledge-now-pay-later/src/app/api/cron/overdue/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import prisma from "@/lib/prisma"
|
||||
|
||||
/**
|
||||
* Mark overdue pledges.
|
||||
* Call via cron daily: GET /api/cron/overdue?key=SECRET
|
||||
*
|
||||
* A pledge is overdue if:
|
||||
* - status is "new" or "initiated"
|
||||
* - AND either:
|
||||
* - dueDate is set and is more than 7 days ago
|
||||
* - dueDate is null and createdAt is more than 14 days ago
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
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 sevenDaysAgo = new Date(now.getTime() - 7 * 86400000)
|
||||
const fourteenDaysAgo = new Date(now.getTime() - 14 * 86400000)
|
||||
|
||||
// Deferred pledges: 7 days past due date
|
||||
const overdueDeferred = await prisma.pledge.updateMany({
|
||||
where: {
|
||||
status: { in: ["new", "initiated"] },
|
||||
dueDate: { not: null, lt: sevenDaysAgo },
|
||||
},
|
||||
data: { status: "overdue" },
|
||||
})
|
||||
|
||||
// Immediate pledges: 14 days since creation
|
||||
const overdueImmediate = await prisma.pledge.updateMany({
|
||||
where: {
|
||||
status: { in: ["new", "initiated"] },
|
||||
dueDate: null,
|
||||
createdAt: { lt: fourteenDaysAgo },
|
||||
},
|
||||
data: { status: "overdue" },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
markedOverdue: overdueDeferred.count + overdueImmediate.count,
|
||||
deferred: overdueDeferred.count,
|
||||
immediate: overdueImmediate.count,
|
||||
timestamp: now.toISOString(),
|
||||
})
|
||||
}
|
||||
155
pledge-now-pay-later/src/app/api/cron/reminders/route.ts
Normal file
155
pledge-now-pay-later/src/app/api/cron/reminders/route.ts
Normal 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",
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,90 @@ import { generateReference } from "@/lib/reference"
|
||||
import { calculateReminderSchedule } from "@/lib/reminders"
|
||||
import { sendPledgeReceipt } from "@/lib/whatsapp"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
if (!prisma) return NextResponse.json({ pledges: [] })
|
||||
|
||||
const sp = request.nextUrl.searchParams
|
||||
const eventId = sp.get("eventId")
|
||||
const status = sp.get("status")
|
||||
const limit = parseInt(sp.get("limit") || "50")
|
||||
const offset = parseInt(sp.get("offset") || "0")
|
||||
const sort = sp.get("sort") || "createdAt"
|
||||
const dir = sp.get("dir") === "asc" ? "asc" as const : "desc" as const
|
||||
const dueSoon = sp.get("dueSoon") === "true" // pledges due in next 7 days
|
||||
const overdue = sp.get("overdue") === "true"
|
||||
const search = sp.get("search")
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const where: any = {}
|
||||
if (eventId) where.eventId = eventId
|
||||
if (status && status !== "all") where.status = status
|
||||
if (overdue) where.status = "overdue"
|
||||
if (dueSoon) {
|
||||
const now = new Date()
|
||||
const weekFromNow = new Date(now.getTime() + 7 * 86400000)
|
||||
where.dueDate = { gte: now, lte: weekFromNow }
|
||||
where.status = { in: ["new", "initiated"] }
|
||||
}
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ donorName: { contains: search, mode: "insensitive" } },
|
||||
{ donorEmail: { contains: search, mode: "insensitive" } },
|
||||
{ reference: { contains: search, mode: "insensitive" } },
|
||||
{ donorPhone: { contains: search } },
|
||||
]
|
||||
}
|
||||
|
||||
const orderBy = sort === "dueDate" ? { dueDate: dir } :
|
||||
sort === "amountPence" ? { amountPence: dir } :
|
||||
{ createdAt: dir }
|
||||
|
||||
const [pledges, total] = await Promise.all([
|
||||
prisma.pledge.findMany({
|
||||
where,
|
||||
include: {
|
||||
event: { select: { name: true } },
|
||||
qrSource: { select: { label: true, volunteerName: true } },
|
||||
},
|
||||
orderBy,
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
prisma.pledge.count({ where }),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
pledges: pledges.map(p => ({
|
||||
id: p.id,
|
||||
reference: p.reference,
|
||||
amountPence: p.amountPence,
|
||||
status: p.status,
|
||||
rail: p.rail,
|
||||
donorName: p.donorName,
|
||||
donorEmail: p.donorEmail,
|
||||
donorPhone: p.donorPhone,
|
||||
giftAid: p.giftAid,
|
||||
dueDate: p.dueDate,
|
||||
planId: p.planId,
|
||||
installmentNumber: p.installmentNumber,
|
||||
installmentTotal: p.installmentTotal,
|
||||
eventName: p.event.name,
|
||||
qrSourceLabel: p.qrSource?.label || null,
|
||||
volunteerName: p.qrSource?.volunteerName || null,
|
||||
createdAt: p.createdAt,
|
||||
paidAt: p.paidAt,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Pledges GET error:", error)
|
||||
return NextResponse.json({ pledges: [], total: 0, error: "Failed to load pledges" })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
@@ -36,7 +120,17 @@ export async function POST(request: NextRequest) {
|
||||
const org = event.organization
|
||||
|
||||
// --- INSTALLMENT MODE: create N linked pledges ---
|
||||
if (scheduleMode === "installments" && installmentCount && installmentCount > 1 && installmentDates?.length) {
|
||||
// Auto-generate dates if not provided (1st of each month starting next month)
|
||||
let resolvedDates = installmentDates
|
||||
if (scheduleMode === "installments" && installmentCount && installmentCount > 1 && !resolvedDates?.length) {
|
||||
resolvedDates = []
|
||||
const now = new Date()
|
||||
for (let i = 0; i < installmentCount; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() + 1 + i, 1)
|
||||
resolvedDates.push(d.toISOString().split("T")[0])
|
||||
}
|
||||
}
|
||||
if (scheduleMode === "installments" && installmentCount && installmentCount > 1 && resolvedDates?.length) {
|
||||
const perInstallment = Math.ceil(amountPence / installmentCount)
|
||||
const planId = `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
let firstRef = ""
|
||||
@@ -54,7 +148,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
if (i === 0) firstRef = ref
|
||||
|
||||
const installmentDue = new Date(installmentDates[i])
|
||||
const installmentDue = new Date(resolvedDates[i])
|
||||
|
||||
const p = await tx.pledge.create({
|
||||
data: {
|
||||
@@ -99,7 +193,7 @@ export async function POST(request: NextRequest) {
|
||||
const name = donorName?.split(" ")[0] || "there"
|
||||
const { sendWhatsAppMessage } = await import("@/lib/whatsapp")
|
||||
sendWhatsAppMessage(donorPhone,
|
||||
`🤲 *Pledge Confirmed!*\n\nThank you, ${name}!\n\n💷 *£${(amountPence / 100).toFixed(0)}* pledged to *${event.name}*\n📆 *${installmentCount} monthly payments* of *£${(perInstallment / 100).toFixed(0)}*\n\nFirst payment: ${new Date(installmentDates[0]).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}\n\nWe'll send you payment details before each due date.\n\nReply *STATUS* anytime to see your pledges.`
|
||||
`🤲 *Pledge Confirmed!*\n\nThank you, ${name}!\n\n💷 *£${(amountPence / 100).toFixed(0)}* pledged to *${event.name}*\n📆 *${installmentCount} monthly payments* of *£${(perInstallment / 100).toFixed(0)}*\n\nFirst payment: ${new Date(resolvedDates[0]).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}\n\nWe'll send you payment details before each due date.\n\nReply *STATUS* anytime to see your pledges.`
|
||||
).catch(err => console.error("[WAHA] Installment receipt failed:", err))
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,47 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Try to find existing org first
|
||||
const orgId = await resolveOrgId(request.headers.get("x-org-id") || "default")
|
||||
|
||||
if (orgId) {
|
||||
// Update existing
|
||||
const allowed = ["name", "charityNumber", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo"]
|
||||
const data: Record<string, string> = {}
|
||||
for (const key of allowed) {
|
||||
if (key in body && body[key] !== undefined) data[key] = body[key]
|
||||
}
|
||||
const org = await prisma.organization.update({ where: { id: orgId }, data })
|
||||
return NextResponse.json({ id: org.id, name: org.name, created: false })
|
||||
} else {
|
||||
// Create new org
|
||||
const slug = (body.name || "org").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "")
|
||||
const org = await prisma.organization.create({
|
||||
data: {
|
||||
name: body.name || "My Charity",
|
||||
slug: slug || "my-charity",
|
||||
country: "GB",
|
||||
bankName: body.bankName || "",
|
||||
bankSortCode: body.bankSortCode || "",
|
||||
bankAccountNo: body.bankAccountNo || "",
|
||||
bankAccountName: body.bankAccountName || body.name || "",
|
||||
refPrefix: slug.substring(0, 4).toUpperCase() || "PNPL",
|
||||
},
|
||||
})
|
||||
return NextResponse.json({ id: org.id, name: org.name, created: true })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Settings PUT error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
|
||||
|
||||
Reference in New Issue
Block a user