Files
calvana/pledge-now-pay-later/prisma/schema.prisma
Omair Saleh 17b3e15fae Automations deep redesign: message design studio with WhatsApp-native preview
COMPLETE RETHINK — from monitoring dashboard to message design studio.

## The Big Idea
Aaisha doesn't need a dashboard that says 'is it working?'
She needs a studio where she can SEE what Ahmed sees on his phone,
EDIT the words, TEST different approaches, and DESIGN cross-channel
sequences. The WhatsApp phone mockup is the star.

## New: Phone Mockups (3 channels)
- WhatsApp: green bubbles, blue ticks, org avatar, chat wallpaper,
  full formatting (*bold*, _italic_, `code`, ━━━ dividers)
- Email: macOS mail client chrome, From header, subject line
- SMS: iOS Messages style, grey bubbles, contact avatar

## New: Template Editor
- Editable templates per step (receipt, day 2, 7, 14) per channel
- Live preview in phone mockup as you type
- Variable insertion chips: {{name}}, {{amount}}, {{reference}}, etc.
- Subject line editor for email channel
- Character count + SMS segment counter

## New: A/B Testing
- Create Variant B of any step/channel message
- 50/50 split traffic automatically
- Track sent count + conversion rate (paid after receiving)
- Side-by-side stats: 'A: 33% paid, B: 54% paid ★'
- Delete variant to revert to single message

## New: Channel Strategy Matrix
- 3 presets: Waterfall (default), Belt & Suspenders, Escalation
- Visual matrix: steps × channels with status indicators
- 1st = primary, fb = fallback, + = parallel send
- Waterfall: WhatsApp → SMS → Email (most cost-effective)
- Belt & Suspenders: all channels for receipts + final
- Escalation: start gentle (WA only), add channels as urgency increases

## New: Customizable Timing
- Each step's delay is editable inline (dropdown next to phone)
- Default: Day 2, Day 7, Day 14
- Can change to any schedule: Day 1, Day 3, Day 21, Day 28

## Schema: 2 new models
- MessageTemplate: per-org editable templates with A/B variants
  (step, channel, variant, body, subject, splitPercent, sentCount, convertedCount)
- AutomationConfig: per-org timing + strategy + channel matrix

## API: /api/automations (GET/PATCH/DELETE)
- GET seeds defaults on first load (12 templates: 4 steps × 3 channels)
- PATCH upserts templates and config
- DELETE removes variant B and resets A to 100%

## Default templates (src/lib/templates.ts)
Extracted from hardcoded whatsapp.ts + reminders.ts into editable templates:
- WhatsApp: receipt, gentle, impact, final (with emoji + formatting)
- Email: receipt, gentle, impact, final (with cancel/pledge URLs)
- SMS: receipt, gentle, impact, final (160-char optimized)

## Architecture
templates.ts → resolvePreview() fills {{variables}} with examples
templates.ts → resolveTemplate() fills {{variables}} with real data
messaging.ts → sendToDonor() routes via channel waterfall
automations/route.ts → seeds + CRUD for templates + config

## Visual: Step timeline at top
4 tabs across the top with emoji, timing, description
Active step is dark (111827), others are white
Click to switch — editor and phone update together

## Layout
[Step Timeline — 4 tabs across top]
[Phone Mockup (left) | Editor (right)]
[Channel Strategy — expandable matrix]
[Live Feed — condensed stats + scheduled + messages]
2026-03-05 00:22:18 +08:00

288 lines
9.8 KiB
Plaintext

generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model Organization {
id String @id @default(cuid())
name String
slug String @unique
orgType String @default("charity") // charity | fundraiser
country String @default("UK")
timezone String @default("Europe/London")
bankName String?
bankSortCode String?
bankAccountNo String?
bankAccountName String?
refPrefix String @default("PNPL")
logo String?
primaryColor String @default("#1e40af")
gcAccessToken String?
gcEnvironment String @default("sandbox")
stripeSecretKey String?
stripeWebhookSecret String?
emailProvider String? // resend, sendgrid, smtp
emailApiKey String?
emailFromAddress String? // e.g. donations@mymosque.org
emailFromName String? // e.g. "Al Furqan Mosque"
smsProvider String? // twilio
smsAccountSid String?
smsAuthToken String?
smsFromNumber String? // e.g. +447123456789
whatsappConnected Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
events Event[]
pledges Pledge[]
imports Import[]
messageTemplates MessageTemplate[]
automationConfig AutomationConfig?
@@index([slug])
}
model User {
id String @id @default(cuid())
email String @unique
name String?
hashedPassword String?
role String @default("staff") // super_admin, org_admin, staff, volunteer
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
}
model Event {
id String @id @default(cuid())
name String
slug String
description String?
eventDate DateTime?
location String?
goalAmount Int? // in pence
currency String @default("GBP")
status String @default("active") // draft, active, closed, archived
paymentMode String @default("self") // self = we show bank details, external = redirect to URL
externalUrl String? // e.g. https://launchgood.com/my-campaign
externalPlatform String? // launchgood, enthuse, justgiving, gofundme, other
zakatEligible Boolean @default(false) // is this campaign Zakat-eligible?
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
qrSources QrSource[]
pledges Pledge[]
@@unique([organizationId, slug])
@@index([organizationId, status])
}
model QrSource {
id String @id @default(cuid())
label String // "Table 5", "Volunteer: Ahmed"
code String @unique // short token for URL
volunteerName String?
tableName String?
eventId String
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
scanCount Int @default(0)
createdAt DateTime @default(now())
pledges Pledge[]
@@index([eventId])
@@index([code])
}
model Pledge {
id String @id @default(cuid())
reference String @unique // human-safe bank ref e.g. "PNPL-7K4P-50"
amountPence Int
currency String @default("GBP")
rail String // bank, gocardless, card
status String @default("new") // new, initiated, paid, overdue, cancelled
donorName String?
donorEmail String?
donorPhone String?
// --- Home address (required by HMRC for Gift Aid claims) ---
donorAddressLine1 String?
donorPostcode String?
// --- Gift Aid (HMRC) ---
giftAid Boolean @default(false)
giftAidAt DateTime? // when the declaration was made
isZakat Boolean @default(false) // donor marked this as Zakat
// --- Communication consent (GDPR / PECR) ---
emailOptIn Boolean @default(false)
whatsappOptIn Boolean @default(false)
// --- Consent audit trail (immutable evidence) ---
// Stores exact text shown, timestamps, IP, user agent per consent type
consentMeta Json?
iPaidClickedAt DateTime?
notes String?
// Payment scheduling — the core of "pledge now, pay later"
dueDate DateTime? // null = pay now, set = promise to pay on this date
planId String? // groups installments together
installmentNumber Int? // e.g. 1 (of 4)
installmentTotal Int? // e.g. 4
reminderSentForDueDate Boolean @default(false)
eventId String
event Event @relation(fields: [eventId], references: [id])
qrSourceId String?
qrSource QrSource? @relation(fields: [qrSourceId], references: [id])
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
paymentInstruction PaymentInstruction?
payments Payment[]
reminders Reminder[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
paidAt DateTime?
cancelledAt DateTime?
@@index([organizationId, status])
@@index([reference])
@@index([eventId, status])
@@index([donorEmail])
@@index([donorPhone])
@@index([dueDate, status])
@@index([planId])
}
model PaymentInstruction {
id String @id @default(cuid())
pledgeId String @unique
pledge Pledge @relation(fields: [pledgeId], references: [id], onDelete: Cascade)
bankReference String // the unique ref to use
bankDetails Json // {sortCode, accountNo, accountName, bankName}
gcMandateId String?
gcMandateUrl String?
sentAt DateTime?
createdAt DateTime @default(now())
@@index([bankReference])
}
model Payment {
id String @id @default(cuid())
pledgeId String
pledge Pledge @relation(fields: [pledgeId], references: [id], onDelete: Cascade)
provider String // bank, gocardless, stripe
providerRef String? // external ID
amountPence Int
status String @default("pending") // pending, confirmed, failed
matchedBy String? // auto, manual
receivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
importId String?
import Import? @relation(fields: [importId], references: [id])
@@index([pledgeId])
@@index([providerRef])
}
model Reminder {
id String @id @default(cuid())
pledgeId String
pledge Pledge @relation(fields: [pledgeId], references: [id], onDelete: Cascade)
step Int // 0=instructions, 1=nudge, 2=urgency, 3=final
channel String @default("email") // email, sms, whatsapp
scheduledAt DateTime
sentAt DateTime?
status String @default("pending") // pending, sent, skipped, failed
payload Json?
createdAt DateTime @default(now())
@@index([pledgeId])
@@index([scheduledAt, status])
}
model Import {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
kind String // bank_statement, gocardless_export, crm_export
fileName String?
rowCount Int @default(0)
matchedCount Int @default(0)
unmatchedCount Int @default(0)
mappingConfig Json?
stats Json?
status String @default("pending") // pending, processing, completed, failed
uploadedAt DateTime @default(now())
payments Payment[]
@@index([organizationId])
}
model MessageTemplate {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
step Int // 0=receipt, 1=day2_gentle, 2=day7_impact, 3=day14_final
channel String // whatsapp, email, sms
variant String @default("A") // A, B for A/B testing
name String // human label: "Gentle reminder", "Impact nudge"
subject String? // email-only: subject line
body String // template with {{variables}}
isActive Boolean @default(true)
splitPercent Int @default(100) // A/B: e.g. 50 = 50% get this variant
sentCount Int @default(0)
convertedCount Int @default(0) // donors who paid after receiving this
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, step, channel, variant])
@@index([organizationId])
}
model AutomationConfig {
id String @id @default(cuid())
organizationId String @unique
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
isActive Boolean @default(true)
step1Delay Int @default(2) // days after pledge for step 1
step2Delay Int @default(7) // days after pledge for step 2
step3Delay Int @default(14) // days after pledge for step 3
strategy String @default("waterfall") // waterfall, parallel, escalation, custom
channelMatrix Json? // per-step channel config: { "0": ["whatsapp","email"], "1": ["whatsapp"], ... }
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AnalyticsEvent {
id String @id @default(cuid())
eventType String // pledge_start, amount_selected, rail_selected, identity_submitted, pledge_completed, instruction_copy_clicked, i_paid_clicked, payment_matched
pledgeId String?
eventId String?
qrSourceId String?
metadata Json?
createdAt DateTime @default(now())
@@index([eventType])
@@index([pledgeId])
@@index([eventId])
@@index([createdAt])
}