From 13c1b1dd17ee7c82c13de41f9a328538b1d7b8f4 Mon Sep 17 00:00:00 2001 From: Omair Saleh Date: Thu, 5 Mar 2026 02:12:45 +0800 Subject: [PATCH] Fix AI message generation: enforce variables, due date step, regenerate, style adoption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6 issues identified and fixed: 1. AI-generated messages now ENFORCE required variables per step. Step 0 (receipt) MUST have {{sort_code}}, {{account_no}}, {{bank_name}}, {{reference}}. All steps MUST have {{name}}, {{amount}}, {{reference}}. Validation runs post-generation. Retry on failure. Patch as last resort. 2. Due date message added (step 4). Schema had dueDate + reminderSentForDueDate but NO message for the day. Now: 'On the due date · if set' — appears between receipt and first reminder. Default template: 'Today is the day' with bank details for instant action. Cron checks Pledge.dueDate = today, fires step 4 template, sets flag. 3. Regenerate button per AI variant. Hover over any AI card → refresh icon → deletes old B, generates fresh one. Different psychological approach each time (AI picks from 8 strategies). 4. AI now adopts the user's messaging style. Prompt analyses variant A for: formality, emoji density, greeting style, cultural markers, sentence length, sign-off. AI matches all of these while changing only the psychological APPROACH. 5. Per-step required variables enforced with validateTemplate() + patchMissingVariables(). If AI strips bank details from a receipt, they get patched back in. 6. Cron now uses CUSTOM TEMPLATES from MessageTemplate table (not hardcoded). A/B variant selection by splitPercent. sentCount incremented for tracking. Falls back to hardcoded templates only if no custom template exists. Files changed: - src/lib/templates.ts — REQUIRED_VARIABLES, validateTemplate(), patchMissingVariables(), due date template (step 4), STEP_META reordered for display - src/app/api/automations/ai/route.ts — enforce variables with retry, style adoption prompt, extractJson() for robust parsing, step-specific rules - src/app/dashboard/automations/page.tsx — regenerate button, due date message in conversation, conditional display (amber timestamp for due date step) - src/app/api/automations/route.ts — backfill due date templates for existing orgs - src/app/api/cron/reminders/route.ts — due date job, custom template resolution, A/B variant selection, sentCount tracking --- .../src/app/api/automations/ai/route.ts | 246 ++++++++---- .../src/app/api/automations/route.ts | 21 + .../src/app/api/cron/reminders/route.ts | 360 ++++++++++++++---- .../src/app/dashboard/automations/page.tsx | 112 ++++-- pledge-now-pay-later/src/lib/templates.ts | 136 ++++++- 5 files changed, 698 insertions(+), 177 deletions(-) diff --git a/pledge-now-pay-later/src/app/api/automations/ai/route.ts b/pledge-now-pay-later/src/app/api/automations/ai/route.ts index 85140d3..9e37094 100644 --- a/pledge-now-pay-later/src/app/api/automations/ai/route.ts +++ b/pledge-now-pay-later/src/app/api/automations/ai/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server" import prisma from "@/lib/prisma" import { getUser } from "@/lib/session" +import { validateTemplate, patchMissingVariables, REQUIRED_VARIABLES } from "@/lib/templates" const OPENAI_KEY = process.env.OPENAI_API_KEY const GEMINI_KEY = process.env.GEMINI_API_KEY @@ -46,11 +47,26 @@ async function chat(messages: Array<{ role: string; content: string }>, maxToken } catch { return "" } } +/** + * Extract JSON from AI response — handles markdown code blocks, preamble, etc. + */ +function extractJson(raw: string): string { + // Try to find JSON inside ```json ... ``` blocks + const codeBlock = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/) + if (codeBlock) return codeBlock[1].trim() + + // Try to find a JSON object + const jsonMatch = raw.match(/\{[\s\S]*\}/) + if (jsonMatch) return jsonMatch[0] + + return raw.trim() +} + /** * POST /api/automations/ai * * Actions: - * - generate_variant: AI creates a challenger variant B + * - generate_variant: AI creates a challenger variant B (with validation + retry) * - rewrite: AI rewrites a template with a specific instruction * - check_winners: Evaluate all A/B tests and auto-promote winners */ @@ -79,10 +95,11 @@ export async function POST(request: NextRequest) { }) const stepLabels: Record = { - 0: "pledge receipt (instant confirmation with bank details)", - 1: "gentle reminder (day 2, donor hasn't paid yet)", - 2: "impact nudge (day 7, building urgency with purpose)", - 3: "final reminder (day 14, last message before marking overdue)", + 0: "pledge receipt (instant confirmation — MUST include full bank transfer details)", + 4: "due date reminder (sent on the day the donor said they'd pay — MUST include due date and bank details)", + 1: "gentle reminder (day 2, donor hasn't paid yet — friendly check-in)", + 2: "impact nudge (day 7, building urgency with purpose — why their money matters)", + 3: "final reminder (day 14, last message before marking overdue — respect + options)", } const channelRules: Record = { @@ -91,10 +108,37 @@ export async function POST(request: NextRequest) { sms: "SMS message. MUST be under 160 characters. No formatting. Be extremely concise. Every character counts.", } - const result = await chat([ - { - role: "system", - content: `You are an A/B testing expert for UK charity fundraising messages. You generate CHALLENGER variants that take a fundamentally different psychological approach. + // Build the required variables string for the prompt + const required = REQUIRED_VARIABLES[step] || ["name", "amount", "reference"] + const requiredStr = required.map(v => `{{${v}}}`).join(", ") + + // Analyse the user's messaging style from variant A + const styleAnalysis = ` +STYLE MATCHING — THIS IS CRITICAL: +Read the user's current message carefully. Match their: +- FORMALITY LEVEL (casual vs professional — if they say "Salaam" keep that, if "Dear" keep that) +- EMOJI USAGE (match their density — lots of emojis = you use lots, none = you use none) +- SENTENCE LENGTH (short punchy = you write short punchy, longer = you write longer) +- GREETING STYLE (Salaam, Hi, Hey, Dear — match what they use) +- SIGN-OFF STYLE (match their closing — if they use org name, you use org name) +- CULTURAL MARKERS (if they reference Islamic giving, mosque, Zakat — keep that energy) +- PERSONALITY (warm, direct, formal, friendly — match it exactly) + +You're writing for the SAME organisation. The message should feel like it came from the SAME person, just trying a different psychological angle.` + + // Try up to 2 times — first attempt + 1 retry if validation fails + let parsed: { body: string; subject?: string; name?: string; strategy?: string; reasoning?: string } | null = null + let lastError = "" + + for (let attempt = 0; attempt < 2; attempt++) { + const retryHint = attempt > 0 + ? `\n\nIMPORTANT: Your previous attempt was rejected because it was MISSING these required variables: ${lastError}. You MUST include them this time. Every {{variable}} listed below MUST appear in your output.` + : "" + + const result = await chat([ + { + role: "system", + content: `You are an A/B testing expert for UK charity fundraising messages. You generate CHALLENGER variants that take a fundamentally different psychological approach. Your job: given an existing message (variant A), create a variant B that tests a DIFFERENT strategy. Don't just rephrase — change the APPROACH. @@ -108,13 +152,24 @@ Strategies to try (pick ONE that's different from variant A): - LOSS FRAMING: "Your pledge is at risk of being marked unfulfilled" - COMMUNITY: "Join 23 others who've completed their pledge this week" +${styleAnalysis} + +REQUIRED VARIABLES — YOUR MESSAGE MUST CONTAIN ALL OF THESE: +${requiredStr} + +These are template placeholders that get filled in with real data at send time. If you leave any out, the message is BROKEN and the donor won't know how to pay. + +${step === 0 ? `STEP 0 IS THE RECEIPT. The donor just pledged. They NEED the bank details to actually transfer the money. You MUST include a bank details section with {{sort_code}}, {{account_no}}, {{bank_name}}, and {{reference}}. Without this, the donor CANNOT PAY.` : ""} +${step === 4 ? `STEP 4 IS THE DUE DATE REMINDER. Sent on the exact day the donor said they'd pay. You MUST include {{due_date}} so they know today is the day. Include bank details ({{sort_code}}, {{account_no}}, {{bank_name}}) so they can pay right now without looking anything up.` : ""} + +All available variables: {{name}}, {{amount}}, {{event}}, {{reference}}, {{bank_name}}, {{sort_code}}, {{account_no}}, {{org_name}}, {{days}}, {{due_date}}, {{cancel_url}}, {{pledge_url}} + Rules: -- Use the SAME template variables: {{name}}, {{amount}}, {{event}}, {{reference}}, {{bank_name}}, {{sort_code}}, {{account_no}}, {{org_name}}, {{days}}, {{cancel_url}}, {{pledge_url}} - ${channelRules[channel] || "Keep appropriate for the channel."} -- UK English. Warm but professional. Charity context: ${org?.orgType || "charity"}. +- UK English. Charity context: ${org?.orgType || "charity"}. - Org name: ${org?.name || "the charity"} -Return ONLY valid JSON: +Return ONLY valid JSON (no markdown, no \`\`\` blocks): { "body": "the new message template with {{variables}}", "subject": "email subject line with {{variables}} (null if not email)", @@ -122,77 +177,112 @@ Return ONLY valid JSON: "strategy": "which strategy you used (e.g. 'social_proof')", "reasoning": "1-2 sentences explaining WHY this approach might outperform variant A" }` - }, - { - role: "user", - content: `Step: ${stepLabels[step] || `step ${step}`} + }, + { + role: "user", + content: `Step: ${stepLabels[step] || `step ${step}`} Channel: ${channel} Current variant A: --- ${existing.body} --- +${retryHint} +Generate a challenger variant B using a DIFFERENT psychological approach. Remember: MUST include ${requiredStr}.` + } + ], 900) -Generate a challenger variant B using a DIFFERENT psychological approach.` + if (!result) { + return NextResponse.json({ error: "AI unavailable — no API key configured" }, { status: 503 }) } - ], 800) - if (!result) { - return NextResponse.json({ error: "AI unavailable — no API key configured" }, { status: 503 }) + try { + const cleaned = extractJson(result) + parsed = JSON.parse(cleaned) + + if (parsed && parsed.body) { + // Validate required variables + const validation = validateTemplate(step, parsed.body) + + if (!validation.valid) { + // Attempt to patch missing variables + parsed.body = patchMissingVariables(step, parsed.body) + const recheck = validateTemplate(step, parsed.body) + + if (!recheck.valid) { + // Still missing after patching — retry + lastError = recheck.missing.map(v => `{{${v}}}`).join(", ") + parsed = null + continue + } + } + // Validation passed + break + } + } catch { + lastError = "Invalid JSON response" + parsed = null + continue + } } - try { - const parsed = JSON.parse(result) - - // Save as variant B - await prisma.messageTemplate.upsert({ - where: { - organizationId_step_channel_variant: { - organizationId: orgId, step, channel, variant: "B", - }, - }, - update: { - body: parsed.body, - subject: parsed.subject || null, - name: parsed.name || "AI Challenger", - isActive: true, - splitPercent: 50, - }, - create: { - organizationId: orgId, step, channel, variant: "B", - body: parsed.body, - subject: parsed.subject || null, - name: parsed.name || "AI Challenger", - isActive: true, - splitPercent: 50, - }, - }) - - // Set variant A to 50% - await prisma.messageTemplate.updateMany({ - where: { organizationId: orgId, step, channel, variant: "A" }, - data: { splitPercent: 50 }, - }) - + if (!parsed || !parsed.body) { return NextResponse.json({ - ok: true, - variant: { - body: parsed.body, - subject: parsed.subject, - name: parsed.name, - strategy: parsed.strategy, - reasoning: parsed.reasoning, - }, - }) - } catch { - return NextResponse.json({ error: "AI returned invalid response" }, { status: 500 }) + error: `AI generated a message but it was missing required information (${lastError}). Try again.`, + }, { status: 422 }) } + + // Save as variant B + await prisma.messageTemplate.upsert({ + where: { + organizationId_step_channel_variant: { + organizationId: orgId, step, channel, variant: "B", + }, + }, + update: { + body: parsed.body, + subject: parsed.subject || null, + name: parsed.name || "AI Challenger", + isActive: true, + splitPercent: 50, + sentCount: 0, + convertedCount: 0, + }, + create: { + organizationId: orgId, step, channel, variant: "B", + body: parsed.body, + subject: parsed.subject || null, + name: parsed.name || "AI Challenger", + isActive: true, + splitPercent: 50, + }, + }) + + // Set variant A to 50% + await prisma.messageTemplate.updateMany({ + where: { organizationId: orgId, step, channel, variant: "A" }, + data: { splitPercent: 50 }, + }) + + return NextResponse.json({ + ok: true, + variant: { + body: parsed.body, + subject: parsed.subject, + name: parsed.name, + strategy: parsed.strategy, + reasoning: parsed.reasoning, + }, + }) } // ─── REWRITE ──────────────────────────────────── if (action === "rewrite") { - const { instruction, currentBody } = body + const { instruction, currentBody, step } = body const channel = body.channel + const required = REQUIRED_VARIABLES[step] || ["name", "amount", "reference"] + const requiredStr = required.map((v: string) => `{{${v}}}`).join(", ") + const channelHints: Record = { whatsapp: "WhatsApp formatting (*bold*, _italic_, `code`). Emojis OK. Conversational.", email: "Email body (plain text). Can include links like {{pledge_url}}.", @@ -202,8 +292,13 @@ Generate a challenger variant B using a DIFFERENT psychological approach.` const result = await chat([ { role: "system", - content: `You rewrite UK charity fundraising messages. Follow the instruction exactly. Keep ALL {{variable}} placeholders. ${channelHints[channel] || ""} -Return ONLY the rewritten message text — nothing else. No JSON, no explanation.` + content: `You rewrite UK charity fundraising messages. Follow the instruction exactly. + +CRITICAL: You MUST keep ALL these template variables: ${requiredStr} +These are placeholders filled with real data. If you remove any, the message breaks. + +${channelHints[channel] || ""} +Return ONLY the rewritten message text — nothing else. No JSON, no explanation, no markdown blocks.` }, { role: "user", @@ -214,7 +309,7 @@ Current message: ${currentBody} --- -Rewrite it following the instruction.` +Rewrite it following the instruction. MUST include: ${requiredStr}` } ], 600) @@ -222,7 +317,20 @@ Rewrite it following the instruction.` return NextResponse.json({ error: "AI unavailable" }, { status: 503 }) } - return NextResponse.json({ ok: true, body: result.trim() }) + let rewritten = result.trim() + .replace(/^```[\s\S]*?\n/, "") + .replace(/\n```$/, "") + .trim() + + // Validate and patch if needed + if (step !== undefined) { + const validation = validateTemplate(step, rewritten) + if (!validation.valid) { + rewritten = patchMissingVariables(step, rewritten) + } + } + + return NextResponse.json({ ok: true, body: rewritten }) } // ─── CHECK WINNERS ────────────────────────────── diff --git a/pledge-now-pay-later/src/app/api/automations/route.ts b/pledge-now-pay-later/src/app/api/automations/route.ts index ad9bd5d..7ad016e 100644 --- a/pledge-now-pay-later/src/app/api/automations/route.ts +++ b/pledge-now-pay-later/src/app/api/automations/route.ts @@ -38,6 +38,27 @@ export async function GET() { body: t.body, })), }) + } else { + // Backfill: seed due date templates (step 4) if they don't exist yet + const hasDueDateTemplate = await prisma.messageTemplate.count({ + where: { organizationId: orgId, step: 4 }, + }) + if (hasDueDateTemplate === 0) { + const dueDateDefaults = DEFAULT_TEMPLATES.filter(t => t.step === 4) + if (dueDateDefaults.length > 0) { + await prisma.messageTemplate.createMany({ + data: dueDateDefaults.map(t => ({ + organizationId: orgId, + step: t.step, + channel: t.channel, + variant: "A", + name: t.name, + subject: t.subject || null, + body: t.body, + })), + }) + } + } } // Seed config if none exists diff --git a/pledge-now-pay-later/src/app/api/cron/reminders/route.ts b/pledge-now-pay-later/src/app/api/cron/reminders/route.ts index 9651cb4..48ff50a 100644 --- a/pledge-now-pay-later/src/app/api/cron/reminders/route.ts +++ b/pledge-now-pay-later/src/app/api/cron/reminders/route.ts @@ -1,16 +1,16 @@ import { NextRequest, NextResponse } from "next/server" import prisma from "@/lib/prisma" -import { sendPledgeReminder, isWhatsAppReady } from "@/lib/whatsapp" +import { sendWhatsAppMessage, isWhatsAppReady } from "@/lib/whatsapp" import { generateReminderContent } from "@/lib/reminders" +import { resolveTemplate } from "@/lib/templates" /** - * Process and send pending reminders. + * Process and send pending reminders + due date messages. * 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 + * Two jobs: + * 1. Normal reminders (Reminder table, status=pending, scheduledAt <= now) + * 2. Due date messages (Pledge.dueDate = today, reminderSentForDueDate = false) */ export async function GET(request: NextRequest) { // Simple auth via query param or header @@ -26,8 +26,119 @@ export async function GET(request: NextRequest) { const now = new Date() const whatsappReady = await isWhatsAppReady() + const baseUrl = process.env.BASE_URL || "https://pledge.quikcue.com" - // Find pending reminders that are due + let sent = 0 + let skipped = 0 + let failed = 0 + let dueDateSent = 0 + const results: Array<{ id: string; status: string; channel: string; type?: string; error?: string }> = [] + + // ───────────────────────────────────────────────────────── + // JOB 1: DUE DATE MESSAGES (step 4) + // Find pledges where dueDate is today and we haven't sent the message yet + // ───────────────────────────────────────────────────────── + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const todayEnd = new Date(todayStart.getTime() + 86400000) + + const dueDatePledges = await prisma.pledge.findMany({ + where: { + dueDate: { gte: todayStart, lt: todayEnd }, + reminderSentForDueDate: false, + status: { notIn: ["paid", "cancelled"] }, + }, + include: { + event: { select: { name: true } }, + organization: { + select: { + id: true, name: true, bankSortCode: true, + bankAccountNo: true, bankAccountName: true, bankName: true, + }, + }, + paymentInstruction: true, + }, + take: 50, + }) + + for (const pledge of dueDatePledges) { + const phone = pledge.donorPhone + if (!phone || !whatsappReady || !pledge.whatsappOptIn) { + // Mark as sent to avoid retrying — no channel available + await prisma.pledge.update({ + where: { id: pledge.id }, + data: { reminderSentForDueDate: true }, + }) + skipped++ + results.push({ id: pledge.id, status: "skipped", channel: "whatsapp", type: "due_date", error: "No phone/WA" }) + continue + } + + try { + // Try to use org's custom due date template (step 4) + const customTemplate = await prisma.messageTemplate.findFirst({ + where: { organizationId: pledge.organizationId, step: 4, channel: "whatsapp", variant: "A" }, + }) + + const bankDetails = pledge.paymentInstruction?.bankDetails as Record | null + const dueFormatted = pledge.dueDate + ? pledge.dueDate.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" }) + : "today" + + const vars: Record = { + name: pledge.donorName?.split(" ")[0] || "there", + amount: (pledge.amountPence / 100).toFixed(0), + event: pledge.event.name, + reference: pledge.reference, + due_date: dueFormatted, + sort_code: bankDetails?.sortCode || pledge.organization.bankSortCode || "N/A", + account_no: bankDetails?.accountNo || pledge.organization.bankAccountNo || "N/A", + bank_name: bankDetails?.accountName || pledge.organization.bankAccountName || pledge.organization.name || "N/A", + org_name: pledge.organization.name || "Our charity", + days: String(Math.floor((now.getTime() - pledge.createdAt.getTime()) / 86400000)), + cancel_url: `${baseUrl}/p/cancel?ref=${pledge.reference}`, + pledge_url: `${baseUrl}/p/my-pledges`, + } + + let messageText: string + if (customTemplate) { + messageText = resolveTemplate(customTemplate.body, vars) + } else { + // Fallback hardcoded due date message + messageText = `Salaam ${vars.name} 👋\n\nJust a heads up — your *£${vars.amount}* pledge to *${vars.event}* is due today (${vars.due_date}).\n\nYour ref: \`${vars.reference}\`\n\n━━━━━━━━━━━━━━━━━━\nSort Code: \`${vars.sort_code}\`\nAccount: \`${vars.account_no}\`\nName: ${vars.bank_name}\nReference: \`${vars.reference}\`\n━━━━━━━━━━━━━━━━━━\n\nAlready sent it? Reply *PAID* 🙏\nNeed help? Reply *HELP*` + } + + const result = await sendWhatsAppMessage(phone, messageText) + + if (result.success) { + await prisma.pledge.update({ + where: { id: pledge.id }, + data: { reminderSentForDueDate: true }, + }) + + // Increment sentCount on the template for A/B tracking + if (customTemplate) { + await prisma.messageTemplate.update({ + where: { id: customTemplate.id }, + data: { sentCount: { increment: 1 } }, + }) + } + + dueDateSent++ + results.push({ id: pledge.id, status: "sent", channel: "whatsapp", type: "due_date" }) + } else { + failed++ + results.push({ id: pledge.id, status: "failed", channel: "whatsapp", type: "due_date", error: result.error }) + } + } catch (err) { + failed++ + results.push({ id: pledge.id, status: "failed", channel: "whatsapp", type: "due_date", error: String(err) }) + } + } + + // ───────────────────────────────────────────────────────── + // JOB 2: NORMAL REMINDERS (steps 0-3) + // Uses custom templates from MessageTemplate when available + // ───────────────────────────────────────────────────────── const dueReminders = await prisma.reminder.findMany({ where: { status: "pending", @@ -40,50 +151,95 @@ export async function GET(request: NextRequest) { pledge: { include: { event: { select: { name: true } }, - organization: { select: { name: true, bankSortCode: true, bankAccountNo: true, bankAccountName: true } }, + organization: { + select: { + id: true, name: true, bankSortCode: true, + bankAccountNo: true, bankAccountName: true, bankName: true, + }, + }, paymentInstruction: true, }, }, }, - take: 50, // Process in batches + take: 50, 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) + const bankDetails = pledge.paymentInstruction?.bankDetails as Record | null + + // Build template variables + const vars: Record = { + name: pledge.donorName?.split(" ")[0] || "there", + amount: (pledge.amountPence / 100).toFixed(0), + event: pledge.event.name, + reference: pledge.reference, + sort_code: bankDetails?.sortCode || pledge.organization.bankSortCode || "N/A", + account_no: bankDetails?.accountNo || pledge.organization.bankAccountNo || "N/A", + bank_name: bankDetails?.accountName || pledge.organization.bankAccountName || pledge.organization.name || "N/A", + org_name: pledge.organization.name || "Our charity", + days: String(daysSince), + due_date: pledge.dueDate + ? pledge.dueDate.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" }) + : "", + cancel_url: `${baseUrl}/p/cancel?ref=${pledge.reference}`, + pledge_url: `${baseUrl}/p/my-pledges`, + } try { - // WhatsApp channel — only if donor consented + // ── WhatsApp ── if (channel === "whatsapp" && phone && whatsappReady && pledge.whatsappOptIn) { - 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, + // Try to use org's custom template + A/B variant selection + const orgTemplates = await prisma.messageTemplate.findMany({ + where: { + organizationId: pledge.organizationId, + step: reminder.step, + channel: "whatsapp", + isActive: true, + }, }) - 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" }) + let selectedTemplate = orgTemplates.find(t => t.variant === "A") + let messageText: string + + // A/B variant selection based on splitPercent + if (orgTemplates.length > 1) { + const variantB = orgTemplates.find(t => t.variant === "B") + if (variantB && selectedTemplate) { + // Random selection weighted by splitPercent + const roll = Math.random() * 100 + if (roll < variantB.splitPercent) { + selectedTemplate = variantB + } + } + } + + if (selectedTemplate) { + messageText = resolveTemplate(selectedTemplate.body, vars) } else { - // Try email fallback - if (email) { - // For now, mark as sent (email integration is external via webhook API) + // Fallback to hardcoded templates + const { sendPledgeReminder } = await import("@/lib/whatsapp") + const result = await sendPledgeReminder(phone, { + donorName: pledge.donorName || undefined, + amountPounds: vars.amount, + 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 if (email) { await prisma.reminder.update({ where: { id: reminder.id }, data: { status: "sent", sentAt: now, payload: { ...(reminder.payload as object || {}), fallback: "email", waError: result.error } }, @@ -94,51 +250,111 @@ export async function GET(request: NextRequest) { failed++ results.push({ id: reminder.id, status: "failed", channel: "whatsapp", error: result.error }) } + continue + } + + const result = await sendWhatsAppMessage(phone, messageText) + + if (result.success) { + await prisma.reminder.update({ + where: { id: reminder.id }, + data: { status: "sent", sentAt: now }, + }) + + // Increment sentCount for A/B tracking + if (selectedTemplate) { + await prisma.messageTemplate.update({ + where: { id: selectedTemplate.id }, + data: { sentCount: { increment: 1 } }, + }) + } + + sent++ + results.push({ id: reminder.id, status: "sent", channel: "whatsapp" }) + } else if (email) { + 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 — only if donor consented + // ── Email ── else if (channel === "email" && email && pledge.emailOptIn) { - // Generate content and store for external pickup const payload = reminder.payload as Record || {} - const bankDetails = pledge.paymentInstruction?.bankDetails as Record | null - const cancelUrl = `${process.env.BASE_URL || "https://pledge.quikcue.com"}/p/cancel?ref=${pledge.reference}` + const cancelUrl = `${baseUrl}/p/cancel?ref=${pledge.reference}` - 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/my-pledges`, - cancelUrl, + // Try custom email template + const emailTemplate = await prisma.messageTemplate.findFirst({ + where: { + organizationId: pledge.organizationId, + step: reminder.step, + channel: "email", + isActive: true, + variant: "A", + }, }) - // Try WhatsApp as fallback if phone exists and WhatsApp is ready - if (phone && whatsappReady && pledge.whatsappOptIn) { - const waResult = await sendPledgeReminder(phone, { + let subject: string + let body: string + + if (emailTemplate) { + subject = emailTemplate.subject ? resolveTemplate(emailTemplate.subject, vars) : `Pledge reminder — £${vars.amount}` + body = resolveTemplate(emailTemplate.body, vars) + } else { + const content = generateReminderContent(payload.templateKey || "gentle_nudge", { donorName: pledge.donorName || undefined, - amountPounds: (pledge.amountPence / 100).toFixed(0), - eventName: pledge.event.name, + amount: vars.amount, reference: pledge.reference, - daysSincePledge: daysSince, - step: reminder.step, + eventName: pledge.event.name, + bankName: bankDetails?.bankName, + sortCode: bankDetails?.sortCode, + accountNo: bankDetails?.accountNo, + accountName: bankDetails?.accountName, + pledgeUrl: `${baseUrl}/p/my-pledges`, + cancelUrl, }) - if (waResult.success) { - await prisma.reminder.update({ - where: { id: reminder.id }, - data: { status: "sent", sentAt: now, payload: { ...payload, deliveredVia: "whatsapp-fallback" } }, - }) - sent++ - results.push({ id: reminder.id, status: "sent", channel: "whatsapp-fallback" }) - continue + subject = content.subject + body = content.body + } + + // Try WhatsApp as fallback if phone exists + if (phone && whatsappReady && pledge.whatsappOptIn) { + const waTemplate = await prisma.messageTemplate.findFirst({ + where: { + organizationId: pledge.organizationId, + step: reminder.step, + channel: "whatsapp", + isActive: true, + variant: "A", + }, + }) + if (waTemplate) { + const waText = resolveTemplate(waTemplate.body, vars) + const waResult = await sendWhatsAppMessage(phone, waText) + if (waResult.success) { + await prisma.reminder.update({ + where: { id: reminder.id }, + data: { status: "sent", sentAt: now, payload: { ...payload, deliveredVia: "whatsapp-fallback" } }, + }) + if (waTemplate) { + await prisma.messageTemplate.update({ + where: { id: waTemplate.id }, + data: { sentCount: { increment: 1 } }, + }) + } + sent++ + results.push({ id: reminder.id, status: "sent", channel: "whatsapp-fallback" }) + continue + } } } - // Mark as "pending_email" — honestly indicates content is ready but not yet delivered - // Staff can see these in dashboard and send manually, or external tools can pick up via webhook + // Queue email await prisma.reminder.update({ where: { id: reminder.id }, data: { @@ -146,15 +362,22 @@ export async function GET(request: NextRequest) { sentAt: now, payload: { ...payload, - generatedSubject: content.subject, - generatedBody: content.body, + generatedSubject: subject, + generatedBody: body, recipientEmail: email, cancelUrl, deliveredVia: "email-queued", - note: "Email content generated. Awaiting external delivery via /api/webhooks or manual send.", }, }, }) + + if (emailTemplate) { + await prisma.messageTemplate.update({ + where: { id: emailTemplate.id }, + data: { sentCount: { increment: 1 } }, + }) + } + sent++ results.push({ id: reminder.id, status: "sent", channel: "email-queued" }) } @@ -175,8 +398,9 @@ export async function GET(request: NextRequest) { } return NextResponse.json({ - processed: dueReminders.length, + processed: dueReminders.length + dueDatePledges.length, sent, + dueDateSent, skipped, failed, whatsappReady, diff --git a/pledge-now-pay-later/src/app/dashboard/automations/page.tsx b/pledge-now-pay-later/src/app/dashboard/automations/page.tsx index 1fbbc9b..a561ca0 100644 --- a/pledge-now-pay-later/src/app/dashboard/automations/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/automations/page.tsx @@ -4,26 +4,11 @@ import { useState, useEffect, useCallback, useRef } from "react" import Image from "next/image" import { Loader2, Check, Send, Sparkles, Trophy, CheckCheck, - ChevronDown, Clock, MessageCircle + ChevronDown, Clock, MessageCircle, RefreshCw, Calendar } from "lucide-react" import Link from "next/link" import { resolvePreview, STEP_META } from "@/lib/templates" -/** - * /dashboard/automations - * - * AI DOES THE WORK. PHOTOGRAPHY SETS THE CONTEXT. - * - * The AI hero uses the same image-panel + dark-panel split from - * the landing page. The image is `digital-03-notification-smile` — - * a young man at a bus stop smiling at his phone. It IS the product - * working. The moment a WhatsApp reminder lands and someone thinks - * "oh right, I need to do that." - * - * Once AI is running, the hero compacts down — the image served its - * purpose (motivation to start). Now the data takes over. - */ - interface Template { id: string; step: number; channel: string; variant: string name: string; subject: string | null; body: string @@ -53,6 +38,7 @@ export default function AutomationsPage() { const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(null) const [aiWorking, setAiWorking] = useState(false) + const [regenerating, setRegenerating] = useState(null) const [showTiming, setShowTiming] = useState(false) const editorRef = useRef(null) @@ -75,14 +61,17 @@ export default function AutomationsPage() { templates.find(t => t.step === step && t.channel === "whatsapp" && t.variant === variant) || templates.find(t => t.step === step && t.variant === variant) - const testsRunning = STEP_META.filter((_, i) => !!tpl(i, "B")).length - const stepsWithoutTest = STEP_META.filter((_, i) => !tpl(i, "B")).length + // Count A/B tests across the 5 message steps + const allSteps = STEP_META.map(m => m.step) + const testsRunning = allSteps.filter(s => !!tpl(s, "B")).length + const stepsWithoutTest = allSteps.filter(s => tpl(s, "A") && !tpl(s, "B")).length const neverOptimised = testsRunning === 0 && templates.every(t => t.variant === "A") const optimiseAll = async () => { setAiWorking(true) - for (let step = 0; step < 4; step++) { + for (const step of allSteps) { if (tpl(step, "B")) continue + if (!tpl(step, "A")) continue try { await fetch("/api/automations/ai", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -94,6 +83,25 @@ export default function AutomationsPage() { setAiWorking(false) } + /** Regenerate a single AI variant — replaces the existing B with a fresh attempt */ + const regenerateVariant = async (step: number) => { + setRegenerating(step) + try { + // Delete existing B first + await fetch("/api/automations", { + method: "DELETE", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step, channel: "whatsapp", variant: "B" }), + }) + // Generate a new one + await fetch("/api/automations/ai", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "generate_variant", step, channel: "whatsapp" }), + }) + } catch { /* */ } + await load() + setRegenerating(null) + } + const pickWinnersAndContinue = async () => { setAiWorking(true) try { @@ -154,7 +162,7 @@ export default function AutomationsPage() { What your donors receive

- 4 messages over {delays[3]} days. Click any to edit. + 5 messages — a receipt, a due date nudge, and 3 reminders. Click any to edit.

@@ -165,12 +173,7 @@ export default function AutomationsPage() { )} - {/* ━━ AI HERO ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - Same image-panel + dark-panel split from the landing page. - The photo is the PRODUCT WORKING — a real person receiving - a WhatsApp reminder and acting on it. - Once AI is running, the hero compacts. The image did its job. - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */} + {/* ━━ AI HERO ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */} {neverOptimised ? (
{/* Photo — the moment a reminder lands */} @@ -207,7 +210,6 @@ export default function AutomationsPage() {
) : testsRunning > 0 ? ( - /* ── Compact hero: tests running ── */
@@ -225,7 +227,6 @@ export default function AutomationsPage() { )}
) : ( - /* ── Compact hero: optimised ── */
@@ -241,10 +242,7 @@ export default function AutomationsPage() {
)} - {/* ━━ THE CONVERSATION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - The WhatsApp mockup uses rounded corners because it IS a - phone. Everything else follows brand rules (sharp edges). - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */} + {/* ━━ THE CONVERSATION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
@@ -264,15 +262,29 @@ export default function AutomationsPage() {
- {STEP_META.map((meta, step) => { + {STEP_META.map((meta) => { + const step = meta.step const a = tpl(step, "A") const b = tpl(step, "B") const isEditing = editing === step const justSaved = saved === step - const delay = delays[step] + const isRegenning = regenerating === step const previewA = a ? resolvePreview(a.body) : "" const previewB = b ? resolvePreview(b.body) : "" + // Due date step: show conditional label + const isConditional = meta.conditional + const hasDueDateTemplate = !!a + + // Timing labels + const timeLabel = step === 0 ? "Instantly" : + step === 4 ? "On the due date · if set" : + step === 1 ? `Day ${delays[1]} · if not paid` : + step === 2 ? `Day ${delays[2]} · if not paid` : + `Day ${delays[3]} · if not paid` + + const clockTime = step === 0 ? "09:41" : step === 4 ? "08:00" : step === 1 ? "10:15" : step === 2 ? "09:30" : "11:00" + const rateA = a && a.sentCount > 0 ? Math.round((a.convertedCount / a.sentCount) * 100) : 0 const rateB = b && b.sentCount > 0 ? Math.round((b.convertedCount / b.sentCount) * 100) : 0 const totalSent = (a?.sentCount || 0) + (b?.sentCount || 0) @@ -280,16 +292,21 @@ export default function AutomationsPage() { const hasEnoughData = (a?.sentCount || 0) >= MIN_SAMPLE && (b?.sentCount || 0) >= MIN_SAMPLE const winner = hasEnoughData ? (rateB > rateA ? "B" : rateA > rateB ? "A" : null) : null + // Don't render the due date step if no template exists yet + if (isConditional && !hasDueDateTemplate) return null + return (
{/* Timestamp */}
- - {step === 0 ? "Instantly" : `Day ${delay} · if not paid`} + + {isConditional && } + {timeLabel}
{isEditing ? ( + /* ── EDITING STATE ── */