Ship all P0/P1/P2 gaps + 11 AI features

P0 Critical (7):
- STOP/UNSUBSCRIBE keyword → CANCEL (PECR compliance)
- Rate limiting on pledge creation (10/IP/5min)
- Terms of Service + Privacy Policy pages
- WhatsApp onboarding gate (persistent dashboard banner)
- Demo account seeding (demo@pnpl.app)
- Footer legal links
- Basic accessibility (aria labels on donor flow)

P1 Within 2 Weeks (8):
- Pledge editing by staff (PATCH amount, name, email, phone, rail)
- Donor self-cancel page (/p/cancel) + API
- Donor 'My Pledges' lookup page (/p/my-pledges)
- Bulk QR code download (print-ready HTML)
- Public event progress bar (/e/[slug]/progress)
- Email-only donor handling (honest status + WhatsApp fallback)
- Email verification (format + disposable domain blocking)
- Organisations page rewrite (multi-campaign, not multi-org)

P2 Within First Month (10):
- Event cloning with QR sources
- Account deletion (GDPR Article 17)
- Daily digest cron via WhatsApp
- AI-6 Smart reminder timing (due date anchoring, cultural sensitivity)
- H1 Duplicate donor detection (email, phone, Jaro-Winkler name)
- H5 Bank CSV format presets (10 UK banks)
- H16 Partial payment matching (underpay, overpay, instalment)
- H10 Activity logging (audit trail for staff actions)
- AI nudge endpoint + AI column mapping + AI event setup wizard
- AI anomaly detection wired into daily digest

AI Features (11): smart reconciliation, social proof, auto column mapper,
daily digest, impact storyteller, smart timing, nudge composer, event wizard,
NLU concierge, anomaly detection, bank presets

22 new files, 15 modified files, 0 TypeScript errors, clean build.
This commit is contained in:
2026-03-04 20:10:34 +08:00
parent 59485579ec
commit fcfae1c1a4
36 changed files with 3405 additions and 46 deletions

View File

@@ -81,6 +81,61 @@ export async function POST(request: NextRequest) {
},
})
// AI-1: Smart match unmatched rows using AI fuzzy matching
const unmatchedRows = results.filter(r => r.confidence === "none" && r.bankRow.amount > 0)
if (unmatchedRows.length > 0 && unmatchedRows.length <= 30) {
try {
const { smartMatch } = await import("@/lib/ai")
const candidates = openPledges.map((p: { id: string; reference: string; amountPence: number }) => ({
ref: p.reference,
amount: p.amountPence,
donor: "", // We don't have donor name in the query above, but AI can match by amount + description
}))
for (const row of unmatchedRows) {
const aiResult = await smartMatch(
`${row.bankRow.description} ${row.bankRow.reference}`.trim(),
candidates
)
if (aiResult.matchedRef && aiResult.confidence >= 0.85) {
const pledgeInfo = pledgeMap.get(aiResult.matchedRef)
if (pledgeInfo) {
// Mark as AI match (partial confidence, needs review)
const idx = results.indexOf(row)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updated: any = {
...row,
pledgeId: pledgeInfo.id,
pledgeReference: aiResult.matchedRef,
confidence: "partial",
matchedAmount: row.bankRow.amount,
}
results[idx] = updated
}
}
}
} catch (err) {
console.error("[AI] Smart match failed:", err)
}
}
// Update import stats with AI matches
await prisma.import.update({
where: { id: importRecord.id },
data: {
matchedCount: results.filter(r => r.confidence === "exact").length,
unmatchedCount: results.filter(r => r.confidence === "none").length,
stats: {
totalRows: rows.length,
credits: rows.filter(r => r.amount > 0).length,
exactMatches: results.filter(r => r.confidence === "exact").length,
partialMatches: results.filter(r => r.confidence === "partial").length,
aiMatches: results.filter(r => r.confidence === "partial").length,
unmatched: results.filter(r => r.confidence === "none").length,
},
},
})
// Auto-confirm exact matches
const confirmed: string[] = []
for (const result of results) {