Files
calvana/pledge-now-pay-later/src/app/api/dashboard/route.ts
Omair Saleh 50d449e2b7 feat: conditional & match funding pledges — deeply integrated across entire product
- Schema: isConditional, conditionType, conditionText, conditionThreshold, conditionMet, conditionMetAt on Pledge
- Pledge form: 'This is a match pledge' toggle after amount selection
  - Two modes: threshold (if target is reached) and match (match funding)
  - Goal amount passed through from event
- Auto-trigger: when total raised hits threshold, conditional pledges unlock automatically
  - WhatsApp notification sent to donor when unlocked
  - Threshold check runs after every pledge creation AND every status change
- Cron: skips conditional pledges until conditionMet=true (no premature reminders)
- Dashboard Home: progress bar shows conditional segment (amber), stats grid adds Conditional column
- Dashboard Money: conditional/unlocked badge on pledge rows
- Dashboard Collect: hero shows conditional total in amber
- Dashboard Reports: financial summary shows conditional breakdown
- Donor 'My Pledges': conditional card with condition text + activation status
- Confirmation step: specialized messaging for match pledges
- CRM export: includes is_conditional, condition_type, condition_text, condition_met columns
- Status guide: conditional status explained in human language
2026-03-05 04:19:23 +08:00

179 lines
6.3 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { getOrgId } from "@/lib/session"
interface PledgeRow {
id: string
reference: string
amountPence: number
status: string
rail: string
donorName: string | null
donorEmail: string | null
donorPhone: string | null
giftAid: boolean
dueDate: Date | null
planId: string | null
installmentNumber: number | null
installmentTotal: number | null
createdAt: Date
paidAt: Date | null
event: { name: string }
qrSource: { label: string; volunteerName: string | null; tableName: string | null } | null
reminders: Array<{ step: number; status: string; scheduledAt: Date }>
}
interface AnalyticsRow {
eventType: string
_count: number
}
interface ReminderRow {
step: number
status: string
scheduledAt: Date
}
export async function GET(request: NextRequest) {
try {
if (!prisma) {
return NextResponse.json({
summary: {
totalPledges: 12,
totalPledgedPence: 2450000,
totalCollectedPence: 1820000,
collectionRate: 74,
overdueRate: 8,
},
byStatus: { paid: 8, pending: 2, overdue: 1, cancelled: 1 },
byRail: { bank_transfer: 10, card: 2 },
topSources: [
{ label: "Table 1 - Ahmed", count: 4, amount: 850000 },
{ label: "Table 2 - Fatima", count: 3, amount: 620000 },
],
funnel: { qr_scan: 45, pledge_started: 32, pledge_completed: 12 },
pledges: [],
})
}
const orgId = await getOrgId(request.headers.get("x-org-id"))
if (!orgId) {
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
}
const eventId = request.nextUrl.searchParams.get("eventId")
const where = {
organizationId: orgId,
...(eventId ? { eventId } : {}),
}
const [pledges, analytics] = await Promise.all([
prisma.pledge.findMany({
where,
include: {
event: { select: { name: true } },
qrSource: { select: { label: true, volunteerName: true, tableName: true } },
reminders: { select: { step: true, status: true, scheduledAt: true } },
},
orderBy: { createdAt: "desc" },
}),
prisma.analyticsEvent.groupBy({
by: ["eventType"],
where: eventId ? { eventId } : {},
_count: true,
}),
]) as [PledgeRow[], AnalyticsRow[]]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const confirmedPledges = pledges.filter((p: any) => !p.isConditional || p.conditionMet)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const conditionalPledges = pledges.filter((p: any) => p.isConditional && !p.conditionMet)
const totalPledged = confirmedPledges.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
const totalConditional = conditionalPledges.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
const totalCollected = pledges
.filter((p: PledgeRow) => p.status === "paid")
.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
const collectionRate = totalPledged > 0 ? totalCollected / totalPledged : 0
const overdueCount = pledges.filter((p: PledgeRow) => p.status === "overdue").length
const overdueRate = pledges.length > 0 ? overdueCount / pledges.length : 0
// Status breakdown
const byStatus: Record<string, number> = {}
pledges.forEach((p: PledgeRow) => {
byStatus[p.status] = (byStatus[p.status] || 0) + 1
})
// Rail breakdown
const byRail: Record<string, number> = {}
pledges.forEach((p: PledgeRow) => {
byRail[p.rail] = (byRail[p.rail] || 0) + 1
})
// Top QR sources
const qrStats: Record<string, { label: string; count: number; amount: number }> = {}
pledges.forEach((p: PledgeRow) => {
if (p.qrSource) {
const key = p.qrSource.label
if (!qrStats[key]) qrStats[key] = { label: key, count: 0, amount: 0 }
qrStats[key].count++
qrStats[key].amount += p.amountPence
}
})
// Funnel from analytics
const funnel = Object.fromEntries(analytics.map((a: AnalyticsRow) => [a.eventType, a._count]))
return NextResponse.json({
summary: {
totalPledges: pledges.length,
totalPledgedPence: totalPledged,
totalCollectedPence: totalCollected,
totalConditionalPence: totalConditional,
conditionalCount: conditionalPledges.length,
collectionRate: Math.round(collectionRate * 100),
overdueRate: Math.round(overdueRate * 100),
},
byStatus,
byRail,
topSources: Object.values(qrStats).sort((a: { amount: number }, b: { amount: number }) => b.amount - a.amount).slice(0, 10),
funnel,
pledges: pledges.map((p: PledgeRow) => ({
id: p.id,
reference: p.reference,
amountPence: p.amountPence,
status: p.status,
rail: p.rail,
donorName: p.donorName,
donorEmail: p.donorEmail,
donorPhone: p.donorPhone,
eventName: p.event.name,
source: p.qrSource?.label || null,
volunteerName: p.qrSource?.volunteerName || null,
giftAid: p.giftAid,
dueDate: p.dueDate,
planId: p.planId,
installmentNumber: p.installmentNumber,
installmentTotal: p.installmentTotal,
isDeferred: !!p.dueDate,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isConditional: (p as any).isConditional || false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
conditionText: (p as any).conditionText || null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
conditionMet: (p as any).conditionMet || false,
createdAt: p.createdAt,
paidAt: p.paidAt,
nextReminder: p.reminders
.filter((r: ReminderRow) => r.status === "pending")
.sort((a: ReminderRow, b: ReminderRow) => a.scheduledAt.getTime() - b.scheduledAt.getTime())[0]?.scheduledAt || null,
lastTouch: p.reminders
.filter((r: ReminderRow) => r.status === "sent")
.sort((a: ReminderRow, b: ReminderRow) => b.scheduledAt.getTime() - a.scheduledAt.getTime())[0]?.scheduledAt || null,
})),
})
} catch (error) {
console.error("Dashboard error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}