Add set-demo script for staging demo data

npm run set-demo wipes the database, keeping the site settings and the
admin account, and stages six upcoming events with registrations,
payments, tickets, a waitlist, camp cabins, coupons, costs and funnel
stats for showing the platform to other churches. Documented in
backend/DEMO_DATA.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 12:21:03 +02:00
co-authored by Claude Opus 5.5
parent 7cf5f79d01
commit 101632e758
5 changed files with 1008 additions and 1 deletions
+4
View File
@@ -7,6 +7,10 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- `npm run set-demo` in the backend replaces the database contents with demo data for showing the platform to other churches. It keeps the site settings and the admin account, and stages six upcoming events with registrations, payments, tickets, a waitlist, camp cabins, coupons, costs and funnel stats. See `backend/DEMO_DATA.md`.
## [1.26.0] - 2026-10-21
### Changed
+135
View File
@@ -0,0 +1,135 @@
# Demo data
`npm run set-demo` replaces everything in the database with a realistic set of demo data,
for showing the platform to other churches. It stages six upcoming events with
registrations, payments, tickets and people, so every dashboard, report and check-in
screen has something to show.
> **This deletes real data.** Run it only against a database you're happy to wipe, such
> as a dedicated demo deployment. It is not something to run on a live church site.
---
## Running it
```bash
cd backend
npm run set-demo # shows the database it will wipe and asks you to type "demo"
npm run set-demo -- --yes # no prompt (for scripts)
```
The script uses `DATABASE_URL` from `backend/.env`. To point it at another database
for one run, set it in the environment first. A value already in the environment wins
over `.env`:
```bash
DATABASE_URL="postgresql://user:pass@host:5432/hope_demo" npm run set-demo
```
It takes a few seconds and is safe to re-run as often as you like. Each run wipes the
previous demo and creates it again.
### Before you run it
- The admin account must already exist. By default that's `joshua@crosscode.co.za`.
To keep a different account, set `DEMO_ADMIN_EMAIL`:
```bash
DEMO_ADMIN_EMAIL="admin@example.org" npm run set-demo
```
If the account isn't found, the script stops before changing anything.
- Migrations must be up to date (`npm run prisma:deploy`).
---
## What it keeps and what it clears
| Kept | Cleared |
|---|---|
| Site settings (`AppSetting`): org name, branding, SMTP, Yoco, WhatsApp, legal pages | Every event, with its options, variants, early-bird tiers, sections, forms, costs and cash-ups |
| The admin account, including its login history, trusted devices and 2FA | Every other user |
| Migration history | Registrations, payments, tickets, scans, coupons, waitlists, consents, cabins, feedback, analytics, audit log |
| Uploaded files on disk (`public/uploads`) | Queued scheduled emails/WhatsApps (`data/scheduled-emails.json`) |
The wipe and the new data are written in one database transaction. If anything fails,
nothing changes.
---
## What it creates
All dates are worked out from the day you run it, so the events are always upcoming.
Registrations are spread over the past month, as if sign-ups have been coming in.
The data is generated from a fixed seed, so every run looks the same.
| When (from today) | Event | Shows off |
|---|---|---|
| ~1½ weeks, Sat 07:30 | **Men's Breakfast: Built to Last** | **Sold out** (60 places), 5 people on the **waitlist**, card / cash / EFT payments recorded by staff, 2 cancelled registrations |
| ~2½ weeks, Sat 10:00 | **Family Fun Day** | Several ticket types (adult, child, family pass), a non-ticket **add-on** (lunch pack), a fixed-amount **coupon** (`FUNDAY20`), entry and food-stall check-in sections |
| ~5 weeks, Fri–Sat | **Rooted Women's Conference** | Two **early-bird tiers** (one already expired, one still open) plus a Saturday-only ticket, T-shirt **variants** (S–XXL), a **required sign-up form** (dietary needs, workshop choice, prayer requests), a 20% **group coupon** (`ROOTED20`) used by 4 registrations |
| ~7 weeks, Mon–Thu | **Ignite Summer Camp** | **Named attendees** (campers aged 12–17), signed **parental consent**, gender collected, **deposits and part-payments**, campers placed in boys' and girls' **cabins** with staff leaders, **sponsor-a-spot** donations towards a 10-spot goal |
| ~8 weeks, Sun 19:00 | **Carols by Candlelight** | A **free event** with group tickets (1–6 people), plus general **donations** |
| ~14 weeks, Fri–Sun | **Marriage Enrichment Weekend** | Room-type **variants with limited stock**, early-bird prices per room type, a per-person spa add-on, R1 000 EFT deposits |
Each event also gets:
- A cover photo (from Unsplash, which `next.config` already allows) and a contact person.
- **Costs**, both once-off and per item, so the profit view and cash-up have figures.
- About 30 days of **funnel analytics** (views → started → submitted → paid) by source
(WhatsApp, Facebook, Instagram, email, direct) and device.
A typical run gives roughly:
| | |
|---|---|
| Users | 130 (plus the admin) |
| Registrations | ~220 (paid, part-paid, pending and cancelled) |
| Payments | ~180, including donations and sponsorships |
| Tickets | ~245 |
| Named campers | ~45 |
| Funnel events | ~1 700 |
Paid registrations have tickets with QR codes, the same way the app issues them once
payment is complete. Part-paid and pending ones don't have tickets yet.
---
## Logging in
Every demo account uses the password **`Demo@1234`**.
| Role | Email | Good for showing |
|---|---|---|
| Supervisor | `lerato.khumalo@example.com` | Event setup, reports, cash-up, coupons |
| Staff | `sipho.dlamini@example.com` | At-the-door payments and check-in scanning |
| Staff | `anri.botha@example.com` | |
| Staff | `daniel.pillay@example.com` | |
The other 126 people are ordinary members (role `user`), with emails like
`thandi.mokoena@example.com`. Log in as one of them to show the member view: their
registrations, tickets and payments. Any registrant's email from an event's attendee
list works.
Your own admin account keeps its existing password.
---
## Safety
- **Nothing is sent.** The script writes straight to the database and never calls the
email, WhatsApp or Yoco code.
- **Nothing can reach a real person later.** Demo people use `@example.com` addresses
(a reserved domain that can't receive mail) and have **no phone numbers**, so
reminders or broadcasts sent during a demo go nowhere. All parental consents are
already signed, so no consent reminders go out.
- Card payments have made-up Yoco ids (`demo_…`). They don't exist in Yoco, so leave
the demo site's Yoco settings in test mode or empty.
---
## Customising
Everything lives in [`scripts/set-demo.js`](scripts/set-demo.js). There is one
`build…()` function per event (`buildMensBreakfast`, `buildYouthCamp`, …), and
`main()` decides which ones run. To change an event's name, prices, ticket types or
number of registrations, edit its function. To add an event, write a new `build…()`
and add it to the list in `main()`.
+4
View File
@@ -143,6 +143,10 @@ npm run prisma:status
npm run prisma:generate
```
### Demo data
`npm run set-demo` wipes the database (keeping site settings and the admin account) and stages six upcoming demo events with registrations, payments and tickets. See [DEMO_DATA.md](DEMO_DATA.md).
---
## Running the Server
+2 -1
View File
@@ -13,7 +13,8 @@
"prisma:status": "prisma migrate status",
"prisma:reset": "prisma migrate reset --force",
"migrate:env-to-db": "node scripts/migrate-env-to-db.js",
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run"
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run",
"set-demo": "node scripts/set-demo.js"
},
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+863
View File
@@ -0,0 +1,863 @@
/**
* set-demo.js
*
* Replaces the database contents with demo data for showing the system to other
* churches: six upcoming events with registrations, payments, tickets, attendee forms,
* coupons, a waitlist, camp cabins, sponsorships, costs and funnel analytics.
*
* Everything is removed first EXCEPT the site settings (AppSetting) and the admin
* account (DEMO_ADMIN_EMAIL, default joshua@crosscode.co.za). Dates are relative to
* today, so the events are always in the future whenever this is run.
*
* No emails or WhatsApp messages are sent. Demo people use @example.com addresses and
* have no phone numbers, so later reminders can't reach a real person.
*
* Usage:
* cd backend
* npm run set-demo (asks for confirmation)
* npm run set-demo -- --yes (no prompt)
*/
'use strict';
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const readline = require('readline');
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
const bcrypt = require('bcryptjs');
const { v4: uuidv4 } = require('uuid');
const { createPrismaClient } = require('../src/config/db');
const prisma = createPrismaClient();
const ADMIN_EMAIL = (process.env.DEMO_ADMIN_EMAIL || 'joshua@crosscode.co.za').trim().toLowerCase();
const DEMO_PASSWORD = 'Demo@1234';
const SKIP_PROMPT = process.argv.includes('--yes') || process.argv.includes('-y');
// Not truncated: migrations, site settings, users (all but the admin are deleted
// separately) and the admin's own account-security rows.
const KEEP_TABLES = new Set([
'_prisma_migrations', 'AppSetting', 'User',
'SecurityEvent', 'PasswordReset', 'TwoFactorChallenge', 'TrustedDevice',
]);
const SCHEDULED_EMAILS_PATH = path.resolve(__dirname, '../data/scheduled-emails.json');
// ─── Random helpers (seeded, so every run produces the same-looking demo) ────
let seed = 20261021;
function rand() {
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
const int = (min, max) => min + Math.floor(rand() * (max - min + 1));
const pick = (arr) => arr[Math.floor(rand() * arr.length)];
const chance = (p) => rand() < p;
const round2 = (n) => Math.round(n * 100) / 100;
/** Picks a key from { key: weight }. */
function weighted(weights) {
const total = Object.values(weights).reduce((s, w) => s + w, 0);
let r = rand() * total;
for (const [key, w] of Object.entries(weights)) {
if ((r -= w) < 0) return key;
}
return Object.keys(weights)[0];
}
function shuffle(arr) {
const out = arr.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
// ─── Dates ───────────────────────────────────────────────────────────────────
const NOW = new Date();
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** The first `weekday` (0 = Sunday) at least `minDays` from today, at hour:minute. */
function nextWeekday(minDays, weekday, hour, minute = 0) {
const d = new Date(NOW);
d.setDate(d.getDate() + minDays);
while (d.getDay() !== weekday) d.setDate(d.getDate() + 1);
d.setHours(hour, minute, 0, 0);
return d;
}
const addTime = (date, ms) => new Date(date.getTime() + ms);
const daysFromNow = (days) => addTime(NOW, days * DAY);
/** A random moment between minDaysAgo and maxDaysAgo, during waking hours, never in the future. */
function pastDate(maxDaysAgo, minDaysAgo = 0) {
const d = new Date(NOW.getTime() - (minDaysAgo + rand() * (maxDaysAgo - minDaysAgo)) * DAY);
d.setHours(int(7, 21), int(0, 59), int(0, 59), 0);
return d < NOW ? d : new Date(NOW.getTime() - int(5, 120) * MINUTE);
}
/** A moment a little after `from`, clamped to the past. */
function after(from, minMinutes, maxMinutes) {
const t = from.getTime() + int(minMinutes, maxMinutes) * MINUTE;
return new Date(Math.min(t, NOW.getTime() - MINUTE));
}
// ─── People ──────────────────────────────────────────────────────────────────
const FEMALE_FIRST = [
'Thandi', 'Nomsa', 'Lerato', 'Zanele', 'Palesa', 'Ayanda', 'Naledi', 'Busisiwe', 'Precious',
'Refilwe', 'Anri', 'Elmarie', 'Liezl', 'Marike', 'Annelie', 'Charlene', 'Michelle', 'Nicole',
'Sarah', 'Rachel', 'Hannah', 'Grace', 'Megan', 'Jessica', 'Lauren', 'Kelly', 'Tamryn', 'Candice',
'Fatima', 'Priya', 'Kavitha', 'Nadia', 'Bianca', 'Chantelle', 'Tanya', 'Mpho', 'Keabetswe',
'Lindiwe', 'Sibongile', 'Yolanda', 'Ruth', 'Esther', 'Joanne', 'Karen', 'Debbie', 'Nompumelelo',
'Carmen', 'Simone', 'Natasha', 'Ilse', 'Rochelle', 'Tumi', 'Dineo', 'Hlengiwe', 'Abigail',
];
const MALE_FIRST = [
'Sipho', 'Thabo', 'Bongani', 'Mandla', 'Kagiso', 'Tshepo', 'Lwazi', 'Sizwe', 'Themba', 'Musa',
'Pieter', 'Johan', 'Francois', 'Hendrik', 'Marius', 'Wikus', 'Riaan', 'Gerhard', 'David',
'Michael', 'Daniel', 'Matthew', 'Andrew', 'Jonathan', 'Ryan', 'Craig', 'Brandon', 'Shaun',
'Grant', 'Kyle', 'Ashwin', 'Rajesh', 'Yusuf', 'Tumelo', 'Neo', 'Karabo', 'Peter', 'Mark',
'Paul', 'Simon', 'Brian', 'Trevor', 'Wayne', 'Chris', 'Luyanda', 'Jaco', 'Stefan', 'Ernest',
'Vusi', 'Kabelo', 'Dumisani', 'Graham', 'Clive', 'Nathan', 'Ruben', 'Emmanuel', 'Isaac',
];
const LAST = [
'Dlamini', 'Nkosi', 'Mokoena', 'Ndlovu', 'Mthembu', 'Zulu', 'Nxumalo', 'Khumalo', 'Molefe',
'Mahlangu', 'Sithole', 'Mabaso', 'Radebe', 'Baloyi', 'Maseko', 'Ntuli', 'du Plessis',
'van der Merwe', 'Botha', 'Steyn', 'Fourie', 'Pretorius', 'Venter', 'Coetzee', 'van Niekerk',
'Lombard', 'Nel', 'Kruger', 'Smit', 'Jacobs', 'Smith', 'Williams', 'Brown', 'Johnson', 'Adams',
'Petersen', 'Daniels', 'Hendricks', 'Abrahams', 'Isaacs', 'Naidoo', 'Pillay', 'Govender',
'Reddy', 'Moodley', 'Patel', 'Campbell', 'Taylor', 'Thomas', 'Wilson', 'Clarke', 'Morgan',
];
const GIRL_FIRST = [
'Amahle', 'Lesedi', 'Isabella', 'Mia', 'Zoe', 'Ava', 'Leah', 'Kayla', 'Nandi', 'Olwethu', 'Jade',
'Chloe', 'Emma', 'Lisa', 'Anika', 'Karabo', 'Lilitha', 'Bella', 'Gabriella', 'Imaan',
];
const BOY_FIRST = [
'Liam', 'Noah', 'Ethan', 'Luke', 'Josh', 'Siyabonga', 'Lethabo', 'Caleb', 'Aiden', 'Jayden',
'Ruan', 'Tiaan', 'Bandile', 'Ofentse', 'Zaid', 'Reece', 'Keegan', 'Asanda', 'Jordan', 'Kian',
];
const emailSlug = (s) => s.toLowerCase().normalize('NFD').replace(/[^a-z]/g, '');
// ─── Row collection ──────────────────────────────────────────────────────────
// Everything is built in memory with pre-generated ids, then written with createMany
// in dependency order inside one transaction.
const rows = {};
const add = (model, row) => {
(rows[model] = rows[model] || []).push(row);
return row;
};
const INSERT_ORDER = [
'user', 'event', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
'eventForm', 'eventFormField', 'coupon', 'couponEvent', 'registration', 'registrationOption',
'registrationOptionTranche', 'attendee', 'payment', 'ticket', 'couponRedemption',
'formResponse', 'formAnswer', 'consentRequest', 'waitlistEntry', 'allocationSet',
'allocationGroup', 'allocationAssignment', 'eventCost', 'analyticsEvent',
];
// Extra facts about demo people that aren't columns (kept out of the inserted rows).
const userMeta = new Map();
let admin;
let passwordHash;
const staffTeam = [];
function addUser({ first, last, gender, role = 'user', email }) {
const usedEmails = new Set((rows.user || []).map(u => u.email).concat(ADMIN_EMAIL));
let address = email || `${emailSlug(first)}.${emailSlug(last)}@example.com`;
for (let n = 2; usedEmails.has(address); n++) address = `${emailSlug(first)}.${emailSlug(last)}${n}@example.com`;
const user = add('user', {
id: uuidv4(),
name: `${first} ${last}`,
email: address,
password: passwordHash,
role,
isActive: true,
notificationPreference: 'email',
createdAt: pastDate(420, 40),
});
userMeta.set(user.id, { first, last, gender });
return user;
}
function buildPeople() {
staffTeam.push(
addUser({ first: 'Lerato', last: 'Khumalo', gender: 'female', role: 'supervisor', email: 'lerato.khumalo@example.com' }),
addUser({ first: 'Sipho', last: 'Dlamini', gender: 'male', role: 'staff', email: 'sipho.dlamini@example.com' }),
addUser({ first: 'Anri', last: 'Botha', gender: 'female', role: 'staff', email: 'anri.botha@example.com' }),
addUser({ first: 'Daniel', last: 'Pillay', gender: 'male', role: 'staff', email: 'daniel.pillay@example.com' }),
);
const women = [];
const men = [];
for (let i = 0; i < 62; i++) women.push(addUser({ first: pick(FEMALE_FIRST), last: pick(LAST), gender: 'female' }));
for (let i = 0; i < 64; i++) men.push(addUser({ first: pick(MALE_FIRST), last: pick(LAST), gender: 'male' }));
return { women, men, everyone: women.concat(men) };
}
// ─── Event building blocks ───────────────────────────────────────────────────
function addEvent(data) {
return add('event', {
id: uuidv4(),
createdById: admin.id,
goLiveAt: addTime(NOW, -35 * DAY),
createdAt: addTime(NOW, -38 * DAY),
isActive: true,
contactName: 'Lerato Khumalo',
contactEmail: 'events@example.com',
contactPhone: '021 555 0142',
...data,
});
}
function addOption(event, data) {
return add('eventOption', { id: uuidv4(), eventId: event.id, isMainTicket: true, stockLimit: 0, ...data });
}
function addVariants(option, variants) {
return variants.map((v, order) => add('optionVariant', { id: uuidv4(), eventOptionId: option.id, order, stockLimit: 0, price: null, ...v }));
}
function addTier(option, data) {
return add('earlyBirdTier', { id: uuidv4(), eventOptionId: option.id, variantId: null, order: 0, stockLimit: 0, ...data });
}
function addSection(event, name, options) {
const section = add('section', { id: uuidv4(), eventId: event.id, name });
for (const o of options) add('sectionOption', { id: uuidv4(), sectionId: section.id, eventOptionId: o.id });
return section;
}
function addCost(event, label, costType, amount, option = null, notes = null) {
add('eventCost', { id: uuidv4(), eventId: event.id, eventOptionId: option ? option.id : null, label, costType, amount, paidFromMethod: null, notes });
}
// Early-bird tiers already sold, for stock limits.
const tierSold = new Map();
/** The price a line bought at `when` gets: cheapest open tier with stock left, else the base price. */
function priceAt(option, variant, when, quantity) {
const tiers = (rows.earlyBirdTier || [])
.filter(t => t.eventOptionId === option.id && t.variantId === (variant ? variant.id : null))
.sort((a, b) => a.price - b.price);
for (const tier of tiers) {
if (when >= tier.deadline) continue;
const sold = tierSold.get(tier.id) || 0;
if (tier.stockLimit > 0 && sold + quantity > tier.stockLimit) continue;
tierSold.set(tier.id, sold + quantity);
return { price: tier.price, tier };
}
const base = variant && variant.price !== null ? variant.price : option.price;
return { price: base, tier: null };
}
const line = (option, quantity, extra = {}) => ({ option, quantity, ...extra });
// ─── Registrations ───────────────────────────────────────────────────────────
/**
* One registration with its lines, payments and (once paid) tickets.
* pay: 'full' | 'split' (deposit then balance) | 'deposit' | 'none' | 'cancelled'
*/
function addRegistration(event, user, lines, { createdAt, pay = 'full', method = 'card', deposit = 0, coupon = null }) {
const registration = add('registration', {
id: uuidv4(),
userId: user.id,
eventId: event.id,
status: 'pending',
discountTotal: 0,
ticketDelivery: 'payer',
analyticsSessionId: null,
createdAt,
});
let subtotal = 0;
const regLines = lines.filter(l => l.quantity > 0).map(l => {
const { price, tier } = priceAt(l.option, l.variant || null, createdAt, l.quantity);
const ro = add('registrationOption', {
id: uuidv4(),
registrationId: registration.id,
eventOptionId: l.option.id,
variantId: l.variant ? l.variant.id : null,
quantity: l.quantity,
priceSnapshot: price,
appliedTierId: tier ? tier.id : null,
});
add('registrationOptionTranche', {
id: uuidv4(),
registrationOptionId: ro.id,
quantity: l.quantity,
priceSnapshot: price,
appliedTierId: tier ? tier.id : null,
createdAt,
});
subtotal += l.quantity * price;
const attendees = (l.attendees || []).map((a, order) => add('attendee', {
id: uuidv4(),
registrationId: registration.id,
registrationOptionId: ro.id,
eventOptionId: l.option.id,
variantId: l.variant ? l.variant.id : null,
order,
createdAt,
...a,
}));
return { ro, option: l.option, attendees };
});
if (coupon && pay !== 'cancelled') {
const discount = coupon.type === 'percent' ? round2(subtotal * coupon.value / 100) : Math.min(subtotal, coupon.value);
registration.discountTotal = discount;
add('couponRedemption', { id: uuidv4(), couponId: coupon.id, registrationId: registration.id, userId: user.id, discountAmount: discount, createdAt });
}
const total = round2(subtotal - registration.discountTotal);
const payments = [];
const pay1 = (amount, at, how = method) => {
payments.push(add('payment', {
id: uuidv4(),
amount: round2(amount),
method: how,
userId: user.id,
recordedById: how === 'card' ? null : pick(staffTeam).id,
registrationId: registration.id,
eventId: event.id,
status: how === 'card' ? 'completed' : null,
externalId: how === 'card' ? `demo_${uuidv4().replace(/-/g, '')}` : null,
createdAt: at,
}));
};
const firstPaidAt = after(createdAt, method === 'card' ? 2 : 600, method === 'card' ? 15 : 3 * 24 * 60);
if (total > 0) {
if (pay === 'full') pay1(total, firstPaidAt);
if (pay === 'deposit' || pay === 'split') pay1(Math.min(deposit, total), firstPaidAt);
if (pay === 'split') pay1(total - Math.min(deposit, total), after(firstPaidAt, 3 * 24 * 60, 20 * 24 * 60));
}
const paid = round2(payments.reduce((s, p) => s + p.amount, 0));
if (pay === 'cancelled') registration.status = 'cancelled';
else if (paid >= total) registration.status = 'paid';
else if (paid > 0) registration.status = 'partial_paid';
if (registration.status === 'paid') {
const issuedAt = payments.length ? payments[payments.length - 1].createdAt : after(createdAt, 0, 1);
for (const { ro, attendees } of regLines) {
for (const a of attendees) {
add('ticket', { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: ro.id, userId: user.id, eventId: event.id, quantity: 1, attendeeId: a.id, holderUserId: a.userId || null, emailSent: true, createdAt: issuedAt });
}
const unnamed = ro.quantity - attendees.length;
if (unnamed > 0) {
add('ticket', { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: ro.id, userId: user.id, eventId: event.id, quantity: unnamed, emailSent: true, createdAt: issuedAt });
}
}
}
return { registration, regLines };
}
/** Picks a payment plan from weights like { card: 0.7, eft: 0.1, none: 0.2 }. */
function payPlan(weights) {
const choice = weighted(weights);
return choice === 'none' ? { pay: 'none', method: 'card' } : { pay: 'full', method: choice };
}
function addForm(event, fields) {
const form = add('eventForm', { id: uuidv4(), eventId: event.id, isRequired: true });
return fields.map((f, order) => add('eventFormField', { id: uuidv4(), formId: form.id, order, isRequired: false, helpText: null, options: null, ...f }));
}
function addFormResponse(registration, answers) {
const response = add('formResponse', { id: uuidv4(), registrationId: registration.id, createdAt: registration.createdAt });
for (const [field, value] of answers) {
if (value !== null && value !== '') add('formAnswer', { id: uuidv4(), responseId: response.id, fieldId: field.id, value: String(value) });
}
}
// ─── The six events ──────────────────────────────────────────────────────────
const IMG = (id) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=1600&q=80`;
function buildFamilyFunDay(people) {
const start = nextWeekday(15, 6, 10);
const event = addEvent({
title: 'Family Fun Day',
description: 'A full day of fun for the whole family on the church grounds!\n\n'
+ 'Jumping castles, a petting zoo, face painting, a five-a-side soccer tournament and a '
+ 'treasure hunt for the kids — plus coffee, boerewors rolls and a quiet shady spot for the grown-ups.\n\n'
+ 'Bring a picnic blanket and your camp chairs. Children under 3 get in free.',
startDate: start,
endDate: addTime(start, 5 * HOUR),
registrationDeadline: addTime(start, -DAY),
price: 80,
picture: IMG('photo-1529156069898-49953e39b3ac'),
location: 'Church grounds & sports field',
});
const adult = addOption(event, { name: 'Adult', price: 80 });
const child = addOption(event, { name: 'Child (3–12)', price: 40 });
const family = addOption(event, { name: 'Family pass (2 adults + 3 kids)', price: 220 });
const lunch = addOption(event, { name: 'Lunch pack', price: 65, isMainTicket: false });
add('coupon', {
id: uuidv4(), code: 'FUNDAY20', description: 'R20 off for first-time visitors', type: 'fixed', value: 20,
stackEarlyBird: false, validFrom: addTime(NOW, -30 * DAY), validUntil: start, maxUses: 50, maxUsesPerUser: 1,
restrictRoles: [], active: true, createdById: admin.id, createdAt: addTime(NOW, -30 * DAY),
});
add('couponEvent', { id: uuidv4(), couponId: rows.coupon[rows.coupon.length - 1].id, eventId: event.id });
addSection(event, 'Main gate', [adult, child, family]);
addSection(event, 'Food stall', [lunch]);
addCost(event, 'Jumping castles & games hire', 'once_off', 4500);
addCost(event, 'Face painter', 'once_off', 1200);
addCost(event, 'Lunch packs', 'per_item', 38, lunch, 'Supplied by the catering team');
for (const user of shuffle(people.everyone).slice(0, 30)) {
const lines = chance(0.35)
? [line(family, 1)]
: [line(adult, int(1, 2)), line(child, Number(weighted({ 0: 2, 1: 3, 2: 3, 3: 1 })))];
lines.push(line(lunch, chance(0.6) ? int(1, 4) : 0));
addRegistration(event, user, lines, { createdAt: pastDate(30), ...payPlan({ card: 0.72, eft: 0.12, none: 0.16 }) });
}
return event;
}
function buildMensBreakfast(people) {
const start = nextWeekday(8, 6, 7, 30);
const event = addEvent({
title: "Men's Breakfast: Built to Last",
description: 'Join the men of the church for a full English breakfast and an honest conversation '
+ 'about faith, work and family.\n\n'
+ 'Our guest speaker is Pastor Ernest Molefe, who has spent twenty years mentoring young fathers. '
+ 'Bring a friend, a neighbour or a colleague — there is a seat for everyone.',
startDate: start,
endDate: addTime(start, 2.5 * HOUR),
registrationDeadline: addTime(start, -2 * DAY),
price: 150,
picture: IMG('photo-1414235077428-338989a2e8c0'),
location: 'Church hall',
waitlistEnabled: true,
contactName: 'Sipho Dlamini',
});
const ticket = addOption(event, { name: 'Breakfast ticket', price: 150, stockLimit: 60 });
addSection(event, 'Hall entrance', [ticket]);
addCost(event, 'Breakfast catering', 'per_item', 85, ticket);
addCost(event, 'Guest speaker gift', 'once_off', 750);
const men = shuffle(people.men);
let sold = 0;
while (sold < 60) {
const user = men.shift();
const quantity = Math.min(chance(0.2) ? 2 : 1, 60 - sold);
sold += quantity;
addRegistration(event, user, [line(ticket, quantity)], { createdAt: pastDate(28), ...payPlan({ card: 0.65, cash: 0.15, eft: 0.08, none: 0.12 }) });
}
for (let i = 0; i < 2; i++) {
addRegistration(event, men.shift(), [line(ticket, 1)], { createdAt: pastDate(25, 5), pay: 'cancelled' });
}
for (let position = 1; position <= 5; position++) {
add('waitlistEntry', {
id: uuidv4(), eventId: event.id, eventOptionId: ticket.id, userId: men.shift().id,
quantity: position === 3 ? 2 : 1, position, status: 'waiting', createdAt: pastDate(4),
});
}
return event;
}
function buildWomensConference(people) {
const start = nextWeekday(34, 5, 18);
const event = addEvent({
title: 'Rooted Women’s Conference',
description: 'Two days of worship, teaching and real connection for women of every age and season.\n\n'
+ 'Friday evening opens with a worship night. Saturday brings two main sessions, your choice of '
+ 'workshop and a long lunch together in the garden.\n\n'
+ 'Early-bird prices are available for a limited time — gather your small group and register together.',
startDate: start,
endDate: addTime(start, 22 * HOUR),
registrationDeadline: addTime(start, -3 * DAY),
price: 450,
picture: IMG('photo-1511632765486-a01980e01a18'),
location: 'Main auditorium',
contactName: 'Anri Botha',
});
const full = addOption(event, { name: 'Full conference (Fri + Sat)', price: 450 });
const saturday = addOption(event, { name: 'Saturday only', price: 280 });
const shirt = addOption(event, { name: 'Conference T-shirt', price: 180, isMainTicket: false });
const sizes = addVariants(shirt, [{ name: 'S' }, { name: 'M' }, { name: 'L' }, { name: 'XL' }, { name: 'XXL' }]);
addTier(full, { deadline: addTime(NOW, -8 * DAY), price: 300, stockLimit: 25, order: 0 });
addTier(full, { deadline: addTime(NOW, 10 * DAY), price: 380, stockLimit: 0, order: 1 });
const coupon = add('coupon', {
id: uuidv4(), code: 'ROOTED20', description: '20% off for small groups registering together', type: 'percent', value: 20,
stackEarlyBird: true, validFrom: addTime(NOW, -30 * DAY), validUntil: start, maxUses: 30, maxUsesPerUser: 1,
restrictRoles: [], active: true, createdById: admin.id, createdAt: addTime(NOW, -30 * DAY),
});
add('couponEvent', { id: uuidv4(), couponId: coupon.id, eventId: event.id });
addSection(event, 'Main hall', [full, saturday]);
addSection(event, 'Merch table', [shirt]);
addCost(event, 'Venue, sound & lighting', 'once_off', 12500);
addCost(event, 'Catering (full conference)', 'per_item', 120, full);
addCost(event, 'Catering (Saturday)', 'per_item', 70, saturday);
addCost(event, 'T-shirt printing', 'per_item', 95, shirt);
const [diet, workshop, firstTime, prayer] = addForm(event, [
{ type: 'multiple_choice', label: 'Dietary requirements', isRequired: true, options: ['None', 'Vegetarian', 'Vegan', 'Halaal', 'Gluten-free'] },
{ type: 'multiple_choice', label: 'Which Saturday workshop would you like to attend?', isRequired: true, options: ['Prayer & intercession', 'Marriage & family', 'Women in leadership', 'Finding rest'] },
{ type: 'yes_no', label: 'Is this your first Rooted conference?', isRequired: true },
{ type: 'text', label: 'Is there anything we can pray with you about?', helpText: 'Only our prayer team will see this.' },
]);
const PRAYERS = ['My mom’s health', 'A new job', 'Wisdom for my family', 'My teenage kids', 'Healing', 'Peace in a tough season'];
for (const user of shuffle(people.women).slice(0, 50)) {
const quantity = Number(weighted({ 1: 70, 2: 20, 3: 6, 4: 4 }));
const main = chance(0.75) ? full : saturday;
const lines = [line(main, quantity)];
if (chance(0.4)) lines.push(line(shirt, 1, { variant: pick(sizes) }));
const plan = payPlan({ card: 0.8, eft: 0.08, none: 0.12 });
const { registration } = addRegistration(event, user, lines, {
createdAt: pastDate(30),
...plan,
coupon: quantity >= 2 && plan.pay === 'full' && chance(0.5) ? coupon : null,
});
for (let i = 0; i < quantity; i++) {
addFormResponse(registration, [
[diet, weighted({ None: 70, Vegetarian: 12, Vegan: 4, Halaal: 8, 'Gluten-free': 6 })],
[workshop, pick(workshop.options)],
[firstTime, chance(0.35) ? 'yes' : 'no'],
[prayer, chance(0.25) ? pick(PRAYERS) : null],
]);
}
}
return event;
}
function buildYouthCamp(people) {
const start = nextWeekday(46, 1, 9);
const event = addEvent({
title: 'Ignite Summer Camp',
description: 'Four days in the mountains for Grade 7–12s: hiking, river tubing, campfires, '
+ 'night games and powerful evening services.\n\n'
+ 'The bus leaves the church at 09:00 on Monday and returns on Thursday afternoon. All meals, '
+ 'activities and accommodation are included. A R500 deposit per camper secures a place, with '
+ 'the balance due two weeks before camp.\n\n'
+ 'Want to help a teenager get to camp? Use “Sponsor a spot” to give towards our bursary fund.',
startDate: start,
endDate: addTime(start, 3 * DAY + 5 * HOUR),
registrationDeadline: addTime(start, -14 * DAY),
price: 1650,
picture: IMG('photo-1478131143081-80f7f84ca84d'),
location: 'Bergview Camp, Drakensberg',
allowNamedAttendees: true,
requiresParentalConsent: true,
consentText: 'I am the parent or legal guardian of this camper and give permission for them to attend '
+ 'Ignite Summer Camp, take part in all supervised activities (including hiking and water activities) '
+ 'and travel on the camp bus. In an emergency I authorise the camp leaders to obtain medical treatment.',
consentCollectPhoto: true,
collectGender: true,
allowSponsorships: true,
sponsorshipGoalSpots: 10,
contactName: 'Daniel Pillay',
});
const camper = addOption(event, { name: 'Camper (Grade 7–12)', price: 1650, stockLimit: 80 });
const hoodie = addOption(event, { name: 'Camp hoodie', price: 380, isMainTicket: false });
const sizes = addVariants(hoodie, [{ name: 'Youth L' }, { name: 'S' }, { name: 'M' }, { name: 'L' }, { name: 'XL' }]);
addSection(event, 'Bus check-in', [camper]);
addCost(event, 'Campsite (per camper)', 'per_item', 820, camper);
addCost(event, 'Bus hire', 'once_off', 16800);
addCost(event, 'Camp hoodies', 'per_item', 210, hoodie);
const campers = [];
for (const parent of shuffle(people.everyone).slice(0, 30)) {
const surname = userMeta.get(parent.id).last;
const count = Number(weighted({ 1: 55, 2: 35, 3: 10 }));
const attendees = [];
for (let i = 0; i < count; i++) {
const gender = chance(0.5) ? 'female' : 'male';
// 12–17 on the first day of camp.
const dateOfBirth = new Date(start.getTime() - (int(12, 17) * 365.25 + int(10, 350)) * DAY);
dateOfBirth.setUTCHours(0, 0, 0, 0);
attendees.push({
name: `${pick(gender === 'female' ? GIRL_FIRST : BOY_FIRST)} ${surname}`,
gender,
dateOfBirth,
parentName: parent.name,
parentEmail: parent.email,
});
}
const lines = [line(camper, count, { attendees })];
if (chance(0.4)) lines.push(line(hoodie, int(1, count), { variant: pick(sizes) }));
const plan = weighted({ full: 30, split: 20, deposit: 35, none: 15 });
const { registration, regLines } = addRegistration(event, parent, lines, {
createdAt: pastDate(32),
pay: plan,
method: chance(0.5) ? 'card' : 'eft',
deposit: 500 * count,
});
if (registration.status === 'pending') continue;
for (const attendee of regLines[0].attendees) {
campers.push(attendee);
add('consentRequest', {
id: uuidv4(),
attendeeId: attendee.id,
eventId: event.id,
tokenHash: crypto.createHash('sha256').update(uuidv4()).digest('hex'),
status: 'given',
textVersion: 1,
consentText: event.consentText,
parentName: parent.name,
parentEmail: parent.email,
signedName: parent.name,
photoConsent: chance(0.9),
respondedAt: after(registration.createdAt, 30, 2 * 24 * 60),
lastSentAt: registration.createdAt,
expiresAt: event.startDate,
createdAt: registration.createdAt,
});
}
}
// Cabins, with most campers already placed.
const cabins = add('allocationSet', { id: uuidv4(), eventId: event.id, name: 'Cabins', showOnTicket: true, order: 0 });
const groups = [
['Eagle cabin', 'male', staffTeam[1]], ['Lion cabin', 'male', staffTeam[3]],
['Dove cabin', 'female', staffTeam[0]], ['Fawn cabin', 'female', staffTeam[2]],
].map(([name, gender, leader], order) => add('allocationGroup', { id: uuidv4(), setId: cabins.id, name, capacity: 14, gender, leaderUserId: leader.id, order }));
const placed = new Map(groups.map(g => [g.id, 0]));
for (const attendee of campers) {
if (!chance(0.8)) continue;
const group = groups.find(g => g.gender === attendee.gender && placed.get(g.id) < g.capacity);
if (!group) continue;
placed.set(group.id, placed.get(group.id) + 1);
add('allocationAssignment', { id: uuidv4(), setId: cabins.id, groupId: group.id, attendeeId: attendee.id });
}
// Sponsor-a-spot donations into the bursary pool.
const MESSAGES = ['Every child deserves camp!', 'Camp changed my life — pass it on.', 'Praying for a great week.', null, null];
for (const sponsor of shuffle(people.everyone).slice(0, 9)) {
const anonymous = chance(0.3);
add('payment', {
id: uuidv4(), amount: pick([500, 825, 1000, 1650, 1650, 3300]), method: 'card', userId: sponsor.id,
eventId: event.id, isDonation: true, isSponsorship: true, sponsorName: anonymous ? null : sponsor.name,
sponsorMessage: pick(MESSAGES), sponsorAnonymous: anonymous, status: 'completed',
externalId: `demo_${uuidv4().replace(/-/g, '')}`, createdAt: pastDate(30),
});
}
return event;
}
function buildCarols(people) {
const start = nextWeekday(55, 0, 19);
const event = addEvent({
title: 'Carols by Candlelight',
description: 'Our much-loved Christmas evening under the stars.\n\n'
+ 'Bring a blanket and the whole family for carols, the Christmas story told by our children’s '
+ 'ministry, and hot chocolate afterwards. Entry is free, but please register so we know how '
+ 'many candles and chairs to set out.\n\n'
+ 'A collection will be taken for the local children’s home.',
startDate: start,
endDate: addTime(start, 2 * HOUR),
price: 0,
picture: IMG('photo-1543589077-47d81606c1bf'),
location: 'Church lawn (hall if it rains)',
});
const entry = addOption(event, { name: 'Free entry', price: 0, stockLimit: 500 });
addSection(event, 'Lawn entrance', [entry]);
addCost(event, 'Candles & songbooks', 'once_off', 1800);
addCost(event, 'Sound & lighting', 'once_off', 3500);
for (const user of shuffle(people.everyone).slice(0, 48)) {
const quantity = Number(weighted({ 1: 15, 2: 30, 3: 15, 4: 25, 5: 10, 6: 5 }));
addRegistration(event, user, [line(entry, quantity)], { createdAt: pastDate(30) });
}
for (const donor of shuffle(people.everyone).slice(0, 14)) {
add('payment', {
id: uuidv4(), amount: pick([100, 150, 200, 250, 500, 500, 1000]), method: 'card', userId: donor.id,
eventId: event.id, isDonation: true, status: 'completed',
externalId: `demo_${uuidv4().replace(/-/g, '')}`, createdAt: pastDate(30),
});
}
return event;
}
function buildMarriageWeekend(people) {
const start = nextWeekday(98, 5, 17);
const event = addEvent({
title: 'Marriage Enrichment Weekend',
description: 'Take a weekend away just for the two of you.\n\n'
+ 'Three relaxed sessions on communication, conflict and dreaming together, led by Pastor Johan '
+ '& Elmarie Venter — with plenty of free time to walk, rest and enjoy the lodge.\n\n'
+ 'Your package covers two nights’ accommodation, all meals and the course material. A R1 000 '
+ 'deposit secures your room; rooms are limited.',
startDate: start,
endDate: addTime(start, 43 * HOUR),
registrationDeadline: addTime(start, -21 * DAY),
price: 3400,
picture: IMG('photo-1606800052052-a08af7148866'),
location: 'Kloofzicht Lodge, Muldersdrift',
contactName: 'Lerato Khumalo',
});
const couple = addOption(event, { name: 'Couple package (2 nights)', price: 3400 });
const [garden, mountain] = addVariants(couple, [
{ name: 'Garden room', price: 3400, stockLimit: 8 },
{ name: 'Mountain-view room', price: 3900, stockLimit: 6 },
]);
addTier(couple, { variantId: garden.id, deadline: addTime(NOW, 45 * DAY), price: 2990 });
addTier(couple, { variantId: mountain.id, deadline: addTime(NOW, 45 * DAY), price: 3450 });
const spa = addOption(event, { name: 'Saturday spa treatment (per person)', price: 650, isMainTicket: false });
addCost(event, 'Lodge accommodation & meals', 'per_item', 1950, couple);
addCost(event, 'Spa treatments', 'per_item', 420, spa);
addCost(event, 'Course workbooks', 'once_off', 1100);
const variants = shuffle([...Array(6).fill(garden), ...Array(5).fill(mountain)]);
for (const user of shuffle(people.everyone).slice(0, variants.length)) {
const lines = [line(couple, 1, { variant: variants.pop() })];
if (chance(0.4)) lines.push(line(spa, 2));
const plan = weighted({ eft: 35, card: 15, deposit: 35, none: 15 });
addRegistration(event, user, lines, {
createdAt: pastDate(30),
pay: plan === 'eft' || plan === 'card' ? 'full' : plan,
method: plan === 'card' ? 'card' : 'eft',
deposit: 1000,
});
}
return event;
}
// ─── Funnel analytics ────────────────────────────────────────────────────────
const SOURCES = { direct: 30, whatsapp: 35, facebook: 18, instagram: 10, email: 7 };
const DEVICES = { mobile: 72, desktop: 22, tablet: 6 };
function buildAnalytics() {
const session = (eventId, stages, at) => {
const sessionId = uuidv4();
const source = weighted(SOURCES);
const device = weighted(DEVICES);
let t = at.getTime() - stages.length * int(2, 15) * MINUTE;
for (const stage of stages) {
add('analyticsEvent', { id: uuidv4(), sessionId, eventId, stage, source, device, createdAt: new Date(Math.min(t, at.getTime())) });
t += int(1, 12) * MINUTE;
}
return sessionId;
};
for (const event of rows.event) {
const registrations = (rows.registration || []).filter(r => r.eventId === event.id);
for (const reg of registrations) {
// Most registrations arrive from a tracked session (the rest declined cookies).
if (chance(0.8)) reg.analyticsSessionId = session(event.id, ['view', 'start', 'submit'], reg.createdAt);
}
const browsers = registrations.length * int(3, 5);
for (let i = 0; i < browsers; i++) {
session(event.id, chance(0.3) ? ['view', 'start'] : ['view'], pastDate(30));
}
}
}
// ─── Clearing ────────────────────────────────────────────────────────────────
async function clearData(tx) {
const tables = await tx.$queryRaw`SELECT tablename FROM pg_tables WHERE schemaname = current_schema()`;
const toClear = tables.map(t => t.tablename).filter(t => !KEEP_TABLES.has(t));
if (toClear.length) {
await tx.$executeRawUnsafe(`TRUNCATE TABLE ${toClear.map(t => `"${t}"`).join(', ')} CASCADE`);
}
// Their password resets, 2FA codes and trusted devices go with them (onDelete: Cascade).
const { count } = await tx.user.deleteMany({ where: { id: { not: admin.id } } });
await tx.securityEvent.deleteMany({ where: { OR: [{ userId: null }, { userId: { not: admin.id } }] } });
return { tables: toClear.length, users: count };
}
function clearScheduledEmails() {
if (!fs.existsSync(SCHEDULED_EMAILS_PATH)) return;
fs.writeFileSync(SCHEDULED_EMAILS_PATH, JSON.stringify({ jobs: [] }, null, 2), 'utf-8');
}
// ─── Main ────────────────────────────────────────────────────────────────────
function describeDatabase() {
try {
const u = new URL(process.env.DATABASE_URL);
return `${u.pathname.replace(/^\//, '')} on ${u.hostname}${u.port ? `:${u.port}` : ''}`;
} catch {
return 'the configured database';
}
}
async function confirm(admin) {
if (SKIP_PROMPT) return true;
console.log(`\nThis will DELETE all events, registrations, payments, tickets, coupons and users`);
console.log(`in ${describeDatabase()}, keeping only the site settings and ${admin.email}.`);
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise(resolve => rl.question('Type "demo" to continue: ', resolve));
rl.close();
return answer.trim().toLowerCase() === 'demo';
}
async function main() {
admin = await prisma.user.findFirst({ where: { email: { equals: ADMIN_EMAIL, mode: 'insensitive' } } });
if (!admin) {
console.error(`Admin account ${ADMIN_EMAIL} was not found — nothing was changed.`);
console.error('Set DEMO_ADMIN_EMAIL to the account you want to keep.');
process.exitCode = 1;
return;
}
if (admin.role !== 'admin') console.warn(`[warn] ${admin.email} has role "${admin.role}", not admin.`);
if (!(await confirm(admin))) {
console.log('Cancelled — nothing was changed.');
return;
}
passwordHash = await bcrypt.hash(DEMO_PASSWORD, 10);
const people = buildPeople();
const events = [
buildMensBreakfast(people),
buildFamilyFunDay(people),
buildWomensConference(people),
buildYouthCamp(people),
buildCarols(people),
buildMarriageWeekend(people),
];
buildAnalytics();
const cleared = await prisma.$transaction(async (tx) => {
const result = await clearData(tx);
for (const model of INSERT_ORDER) {
if (rows[model] && rows[model].length) await tx[model].createMany({ data: rows[model] });
}
return result;
}, { maxWait: 10 * 1000, timeout: 5 * 60 * 1000 });
clearScheduledEmails();
const count = (model, where = () => true) => (rows[model] || []).filter(where).length;
console.log(`\nCleared ${cleared.tables} tables and ${cleared.users} users.`);
console.log('\nDemo events:');
for (const event of events) {
const regs = rows.registration.filter(r => r.eventId === event.id);
const places = rows.ticket.filter(t => t.eventId === event.id).reduce((s, t) => s + t.quantity, 0);
const byStatus = ['paid', 'partial_paid', 'pending', 'cancelled']
.map(s => [s, regs.filter(r => r.status === s).length]).filter(([, n]) => n).map(([s, n]) => `${n} ${s}`).join(', ');
console.log(` ${event.startDate.toDateString().padEnd(16)} ${event.title.padEnd(34)} ${String(regs.length).padStart(3)} registrations (${byStatus}), ${places} ticketed places`);
}
console.log(`\nCreated ${count('user')} users, ${count('payment')} payments, ${count('ticket')} tickets, ${count('attendee')} named campers, ${count('analyticsEvent')} funnel events.`);
console.log(`\nKept: site settings and ${admin.email}.`);
console.log(`Demo staff logins (password ${DEMO_PASSWORD}):`);
for (const s of staffTeam) console.log(` ${s.role.padEnd(10)} ${s.email}`);
console.log(`Every other demo person also uses ${DEMO_PASSWORD}.`);
}
main()
.catch(err => { console.error(err); process.exitCode = 1; })
.finally(() => prisma.$disconnect());