Files
hope-events/backend/scripts/set-demo.js
T
joshuaandClaude Opus 5.5 101632e758 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>
2026-10-21 12:21:03 +02:00

864 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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());