Files
joshuaandClaude Opus 5.5 9913a13f70 WIP 2.0.0: v1 removed; Argon2id passwords; node-fetch gone
- /api (v1) answers 410 API_V1_REMOVED, except provider webhooks
  (/api/webhooks/*, /api/whatsapp/webhook) and links in sent messages
  (308s, one-click POST). 43 v1 route files and deprecation.ts removed;
  /docs redirects to /docs/v2; API_DOCUMENTATION.md points there.
- Read-only and impersonation lists name v2 paths only. The campus
  scope route test now runs over v2 routes (v2CampusScope.test.ts).
- Passwords hashed with Argon2id (@node-rs/argon2); bcrypt hashes still
  sign in and are re-hashed at the next sign-in.
- e2e on /api/v2 only (v2() helper); Playwright waits on /api/v2.
- MIGRATING-TO-2.md with the full v1 -> v2 table.
- Two documented response shapes corrected.

Backend 690 tests and e2e 102/102 (PayFast sandbox skipped) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:35:06 +02:00

1941 lines
103 KiB
JavaScript
Raw Permalink 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. Four profiles, for different kinds of church (see PROFILES below, or
* `npm run demo:list`):
*
* small One congregation, the essentials: a few events, payments, check-ins, cashups.
* events Events and conferences (the original demo, the default): six upcoming events
* with coupons, a waitlist, camp cabins, sponsorships, bundles, costs and funnel
* analytics, and ten completed events with door sales, feedback and cashups.
* ministry One campus with weekly ministry: a multi-session course (attendance and
* certificates), a "pick your days" conference, kids church with kids check-in,
* volunteer rosters, and a Men's Breakfast series with followers and a season pass.
* multicampus Three campuses: events per campus and church-wide, home campuses,
* campus-limited staff, a series across campuses, kids check-in and rosters at two
* campuses.
*
* 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 upcoming 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.
*
* Campuses are switched on (site setting) for "multicampus" and off for the others; restart the
* backend afterwards so it picks the setting up.
*
* Usage (from the repo root):
* npm run demo:small | demo:events | demo:ministry | demo:multicampus (asks for confirmation)
* npm run demo:list (what each one has)
* or in backend/:
* npm run set-demo -- --profile=ministry --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 { hashPassword } = require('../src/config/auth');
const { v4: uuidv4 } = require('uuid');
const { createPrismaClient } = require('../src/config/db');
const { applyBundles } = require('../src/utils/bundles');
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');
const PROFILE_NAME = (process.argv.find(a => a.startsWith('--profile=')) || '--profile=events').split('=')[1].trim().toLowerCase();
const LIST_ONLY = process.argv.includes('--list');
// 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 = [
'campus', 'user', 'eventSeries', 'event', 'eventSession', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
'eventForm', 'eventFormField', 'coupon', 'couponEvent', 'eventBundle', 'eventBundleItem',
'seasonPass', 'seasonPassItem', 'seasonPassPurchase',
'registration', 'registrationOption', 'registrationOptionTranche', 'attendee', 'payment', 'ticket',
'ticketUsage', 'couponRedemption', 'registrationBundle',
'formResponse', 'formAnswer', 'consentRequest', 'waitlistEntry', 'allocationSet',
'allocationGroup', 'allocationAssignment', 'eventCost', 'feedbackForm', 'feedbackField',
'feedbackInvite', 'feedbackResponse', 'feedbackAnswer', 'analyticsEvent', 'analyticsDaily',
'certificate', 'seriesFollow', 'family', 'familyMember', 'child', 'authorisedAdult',
'kidsPickupCode', 'kidsCheckIn', 'kidsRoomDuty', 'servingRole', 'volunteerSlot', 'volunteerSwap',
];
// 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;
}
/** A bundle deal: items are [option, quantity] pairs (quantity 1 for pick-any and the other kinds). */
function addBundle(event, data, items) {
const bundle = add('eventBundle', { id: uuidv4(), eventId: event.id, description: null, kind: 'exact', setSize: null, freeCount: null, groupPricing: null, isActive: true, validFrom: null, validUntil: null, order: 0, createdAt: addTime(NOW, -30 * DAY), updatedAt: NOW, ...data });
for (const [option, quantity] of items) add('eventBundleItem', { id: uuidv4(), bundleId: bundle.id, eventOptionId: option.id, variantId: null, quantity });
return bundle;
}
/** The event's bundles with their items, in the shape utils/bundles.js expects. */
function bundlesFor(event) {
return (rows.eventBundle || []).filter(b => b.eventId === event.id)
.map(b => ({ ...b, items: (rows.eventBundleItem || []).filter(i => i.bundleId === b.id) }));
}
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' | 'refunded'
* atDoor: sold at the event by a staff member (card on the card machine, not online).
* payBy: the latest a payment may be dated (e.g. the event's start, for past events).
*/
function addRegistration(event, user, lines, { createdAt, pay = 'full', method = 'card', deposit = 0, coupon = null, atDoor = false, payBy = null }) {
const registration = add('registration', {
id: uuidv4(),
userId: user.id,
eventId: event.id,
status: 'pending',
discountTotal: 0,
bundleDiscount: 0,
ticketDelivery: 'payer',
analyticsSessionId: null,
createdAt,
});
let subtotal = 0;
const bundleLines = [];
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,
// "Pick sessions" events: a per-session line.
sessionId: l.sessionId || null,
});
add('registrationOptionTranche', {
id: uuidv4(),
registrationOptionId: ro.id,
quantity: l.quantity,
priceSnapshot: price,
appliedTierId: tier ? tier.id : null,
createdAt,
});
subtotal += l.quantity * price;
bundleLines.push({ eventOptionId: l.option.id, variantId: l.variant ? l.variant.id : null, quantity: l.quantity, unitPrice: price, basePrice: 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 };
});
// Bundle deals first, the same way the app applies them; then the coupon on what's left.
const deals = applyBundles(bundlesFor(event), bundleLines);
if (deals.discount > 0) {
registration.bundleDiscount = deals.discount;
registration.discountTotal = deals.discount;
for (const a of deals.applications) {
add('registrationBundle', { id: uuidv4(), registrationId: registration.id, bundleId: a.bundleId, count: a.count, discount: a.discount, createdAt });
}
}
if (coupon && pay !== 'cancelled') {
const base = subtotal - registration.bundleDiscount;
const discount = coupon.type === 'percent' ? round2(base * coupon.value / 100) : Math.min(base, coupon.value);
registration.discountTotal = round2(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 online = method === 'card' && !atDoor;
const recorder = pick(staffTeam);
const pay1 = (amount, at, how = method) => {
payments.push(add('payment', {
id: uuidv4(),
amount: round2(amount),
method: how,
userId: user.id,
recordedById: online ? null : recorder.id,
registrationId: registration.id,
eventId: event.id,
status: online ? 'completed' : null,
externalId: online ? `demo_${uuidv4().replace(/-/g, '')}` : null,
createdAt: payBy && at > payBy ? payBy : at,
}));
};
const firstPaidAt = atDoor
? after(createdAt, 1, 3)
: after(createdAt, method === 'card' ? 2 : 600, method === 'card' ? 15 : 3 * 24 * 60);
if (total > 0) {
if (pay === 'full' || pay === 'refunded') 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));
}
if (pay === 'refunded' && payments.length) {
// Recorded the way the payments screen does it: a negative "<method>-refund" payment
// linked to the original, with the reason in status.
const original = payments[0];
add('payment', {
id: uuidv4(), amount: -original.amount, method: `${method}-refund`, userId: user.id, recordedById: pick(staffTeam).id,
registrationId: registration.id, eventId: event.id, originalPaymentId: original.id,
status: pick(['Can no longer attend', 'Family emergency', 'Booked twice by mistake']),
createdAt: after(original.createdAt, 24 * 60, 7 * 24 * 60),
});
}
const paid = round2(payments.reduce((s, p) => s + p.amount, 0));
if (pay === 'cancelled' || pay === 'refunded') 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 lunch = addOption(event, { name: 'Lunch pack', price: 65, isMainTicket: false });
addBundle(event, { name: 'Family deal', price: 220, description: 'Two adults and three kids, R60 less than buying them separately.' }, [[adult, 2], [child, 3]]);
addBundle(event, { name: 'Lunch for four', kind: 'mix', setSize: 4, price: 220, description: 'Any four lunch packs for R220.' }, [[lunch, 1]]);
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]);
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)) {
// About a third come as a family of 2 + 3 and get the bundle; a few bring two families.
const families = chance(0.35) ? (chance(0.15) ? 2 : 1) : 0;
const lines = families
? [line(adult, 2 * families), line(child, 3 * families)]
: [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;
}
// ─── Past events ─────────────────────────────────────────────────────────────
// Ten completed events over the past year, with door sales, check-ins, feedback and every
// kind of cashup, so the cashup screens and the historical reports have something to show.
// The cashups themselves run after the insert, through the real cashup code (see
// closePastEvents), so their figures always agree with what the app works out.
const pastEvents = [];
/** The `weekday` (0 = Sunday) at hour:minute, on or before `daysAgo` days ago. */
function lastWeekday(daysAgo, weekday, hour, minute = 0) {
const d = new Date(NOW);
d.setDate(d.getDate() - daysAgo);
while (d.getDay() !== weekday) d.setDate(d.getDate() - 1);
d.setHours(hour, minute, 0, 0);
return d;
}
const within = (from, to) => new Date(from.getTime() + rand() * (to.getTime() - from.getTime()));
/** A random moment between two dates during waking hours, kept inside them. */
function daytimeBetween(from, to) {
const d = within(from, to);
d.setHours(int(7, 21), int(0, 59), int(0, 59), 0);
return d < from ? from : d > to ? to : d;
}
const IMPROVEMENTS = [
'More parking marshals', 'Start on time', 'Bigger venue next year', 'More vegetarian options',
'Clearer signage', 'Shorter announcements', 'More time to chat', 'Better sound at the back',
];
/**
* One completed event. spec: title, description, start, hours, picture, location, options,
* costs(opts), pool ('everyone' | 'women' | 'men'), online, refunds, door, lines(opts, atDoor),
* onlinePay (weights, or a function returning registration options), doorPay (weights),
* donations { card, cash, amounts }, showRate, feedback (what people enjoyed), cashup.
*/
function buildPastEvent(people, spec) {
// Each profile has its own selection of past events (`always`: built by a profile's extras).
if (!spec.always && profile.past && !profile.past.includes(spec.title)) return null;
const { start } = spec;
const event = addEvent({
...(spec.seriesId ? { seriesId: spec.seriesId, seriesAnnouncedAt: start } : {}),
title: spec.title,
description: spec.description,
startDate: start,
endDate: addTime(start, spec.hours * HOUR),
registrationDeadline: addTime(start, -HOUR),
price: spec.options[0].price,
picture: IMG(spec.picture),
location: spec.location,
goLiveAt: addTime(start, -40 * DAY),
createdAt: addTime(start, -45 * DAY),
...(spec.contactName ? { contactName: spec.contactName } : {}),
});
const opts = spec.options.map(o => addOption(event, o));
for (const [label, type, amount, option] of spec.costs(opts)) addCost(event, label, type, amount, option || null);
const buyers = shuffle(people[spec.pool || 'everyone']);
const nextBuyer = () => {
if (buyers.length === 0) throw new Error(`Not enough demo people for ${spec.title}`);
return buyers.shift();
};
const salesFrom = addTime(start, -35 * DAY);
const payBy = addTime(start, -HOUR);
for (let i = 0; i < spec.online; i++) {
const plan = typeof spec.onlinePay === 'function' ? spec.onlinePay() : payPlan(spec.onlinePay);
addRegistration(event, nextBuyer(), spec.lines(opts, false), { createdAt: daytimeBetween(salesFrom, addTime(start, -DAY)), payBy, ...plan });
}
for (let i = 0; i < (spec.refunds || 0); i++) {
addRegistration(event, nextBuyer(), spec.lines(opts, false), { createdAt: daytimeBetween(salesFrom, addTime(start, -10 * DAY)), pay: 'refunded', method: 'card' });
}
for (let i = 0; i < (spec.door || 0); i++) {
const method = weighted(spec.doorPay || { cash: 70, card: 30 });
addRegistration(event, nextBuyer(), spec.lines(opts, true), { createdAt: within(addTime(start, -30 * MINUTE), addTime(start, HOUR)), method, atDoor: true });
}
const { card = 0, cash = 0, amounts = [] } = spec.donations || {};
for (let i = 0; i < card; i++) {
add('payment', {
id: uuidv4(), amount: pick(amounts), method: 'card', userId: pick(people.everyone).id, eventId: event.id, isDonation: true,
status: 'completed', externalId: `demo_${uuidv4().replace(/-/g, '')}`, createdAt: daytimeBetween(salesFrom, start),
});
}
for (let i = 0; i < cash; i++) {
add('payment', {
id: uuidv4(), amount: pick(amounts), method: 'cash', userId: pick(people.everyone).id, recordedById: pick(staffTeam).id,
eventId: event.id, isDonation: true, createdAt: within(start, event.endDate),
});
}
addCheckIns(event, spec.showRate ?? 0.9);
if (spec.feedback) addFeedback(event, spec.feedback);
pastEvents.push({ event, cashup: spec.cashup });
return event;
}
function addCheckIns(event, showRate) {
const start = event.startDate;
for (const ticket of rows.ticket.filter(t => t.eventId === event.id)) {
if (!chance(showRate)) continue;
// Now and then only part of a group ticket arrives.
const redeemed = ticket.quantity > 1 && chance(0.15) ? int(1, ticket.quantity - 1) : ticket.quantity;
const scannedAt = within(addTime(start, -30 * MINUTE), addTime(start, 45 * MINUTE));
add('ticketUsage', {
id: uuidv4(), ticketId: ticket.id, scannedById: pick(staffTeam).id, quantityRedeemed: redeemed,
scannedAt: scannedAt < ticket.createdAt ? addTime(ticket.createdAt, 2 * MINUTE) : scannedAt,
});
ticket.isUsed = redeemed === ticket.quantity;
}
}
/** A post-event survey sent to everyone who checked in, with about half responding. */
function addFeedback(event, enjoyed) {
const sentAt = addTime(event.endDate, DAY);
const remindedAt = addTime(sentAt, 3 * DAY);
const form = add('feedbackForm', {
id: uuidv4(), eventId: event.id, sendAt: sentAt, audience: 'checked_in', anonymous: false,
intro: `Thanks for coming to ${event.title}! Two minutes of feedback helps us plan the next one.`,
sentAt, reminderSentAt: remindedAt, createdAt: addTime(event.startDate, -7 * DAY),
});
const [overall, best, again, better] = [
{ type: 'rating', label: 'Overall, how was the event?', isRequired: true },
{ type: 'multiple_choice', label: 'What did you enjoy most?', isRequired: true, options: enjoyed },
{ type: 'yes_no', label: 'Would you come again?', isRequired: true },
{ type: 'text', label: 'What could we do better next time?' },
].map((f, order) => add('feedbackField', { id: uuidv4(), formId: form.id, order, isRequired: false, helpText: null, options: null, ...f }));
const scanned = new Set((rows.ticketUsage || []).map(u => u.ticketId));
const attendees = [...new Set(rows.ticket.filter(t => t.eventId === event.id && scanned.has(t.id)).map(t => t.userId))];
for (const userId of attendees) {
const respondedAt = chance(0.45) ? within(sentAt, addTime(sentAt, 6 * DAY)) : null;
add('feedbackInvite', {
id: uuidv4(), formId: form.id, userId, sentAt,
tokenHash: crypto.createHash('sha256').update(uuidv4()).digest('hex'),
remindedAt: respondedAt && respondedAt < remindedAt ? null : remindedAt,
respondedAt,
});
if (!respondedAt) continue;
const response = add('feedbackResponse', { id: uuidv4(), formId: form.id, userId, createdAt: respondedAt });
const rating = Number(weighted({ 5: 45, 4: 35, 3: 14, 2: 5, 1: 1 }));
const answers = [
[overall, String(rating)],
[best, pick(enjoyed)],
[again, rating >= 3 && chance(0.95) ? 'yes' : 'no'],
[better, chance(0.35) ? pick(IMPROVEMENTS) : null],
];
for (const [field, value] of answers) {
if (value !== null) add('feedbackAnswer', { id: uuidv4(), responseId: response.id, fieldId: field.id, value });
}
}
}
// How each method's money is settled in the demo cashups. Card settles net of Yoco's fee.
const cardFee = (amount) => round2(amount * 0.0295);
function cardLine(exp) {
if (!(exp.card > 0)) return null;
const fee = cardFee(exp.card);
return { method: 'card', bankAmount: round2(exp.card - fee), writeOffs: [{ category: 'card_fees', amount: fee, reason: 'Yoco fees (2.95%)' }] };
}
function eftLine(exp, pending = 0, reference = null) {
if (!(exp.eft > 0)) return null;
const owed = Math.min(pending, exp.eft);
return { method: 'eft', bankAmount: round2(exp.eft - owed), pendingAmount: owed || null, pendingReference: owed ? reference : null };
}
function cashLine(counted, { keep = 0, depositFee = 0, shortfall = 0 } = {}) {
if (!counted) return null;
const writeOffs = [];
if (depositFee) writeOffs.push({ category: 'bank_charges', amount: depositFee, reason: 'Cash deposit fee' });
if (shortfall) writeOffs.push({ category: 'cash_shortfall', amount: shortfall, reason: 'Short in the count' });
return { method: 'cash', bankAmount: round2(counted - keep - depositFee), writeOffs };
}
/** ZAR notes and coins adding up to `amount`, largest first. */
function toDenominations(amount) {
const out = [];
let left = Math.round(amount * 100);
for (const value of [200, 100, 50, 20, 10, 5, 2, 1, 0.5, 0.2, 0.1]) {
const cents = Math.round(value * 100);
const count = Math.floor(left / cents);
if (count > 0) {
out.push({ value, count });
left -= count * cents;
}
}
return out;
}
function buildPastEvents(people) {
const qty = (weights) => Number(weighted(weights));
return [
buildPastEvent(people, {
title: 'Church Family Picnic',
description: 'Our end-of-year picnic on the sports field: boerewors rolls, a jumping castle, tug of war and the annual staff vs youth soccer match.',
start: lastWeekday(330, 6, 11), hours: 5, picture: 'photo-1529156069898-49953e39b3ac', location: 'Church sports field',
options: [{ name: 'Adult', price: 60 }, { name: 'Child (3–12)', price: 30 }],
costs: o => [['Jumping castle hire', 'once_off', 1800], ['Boerewors rolls', 'per_item', 22, o[0]], ['Boerewors rolls (kids)', 'per_item', 15, o[1]]],
online: 35, onlinePay: { card: 80, eft: 15, none: 5 }, door: 10, doorPay: { cash: 80, card: 20 },
lines: o => [line(o[0], int(1, 2)), line(o[1], qty({ 0: 3, 1: 3, 2: 3, 3: 1 }))],
showRate: 0.92,
cashup: { close: 'quick', daysAfter: 4, notes: 'Small event, so quick closed. Totals checked against the bank statement.' },
}),
buildPastEvent(people, {
title: 'Christmas Eve Candlelight Service',
description: 'Carols, candles and the Christmas story, for the whole family. Entry is free; the collection goes to the children’s home.',
start: lastWeekday(301, 3, 18), hours: 1.5, picture: 'photo-1543589077-47d81606c1bf', location: 'Main auditorium',
options: [{ name: 'Free entry', price: 0, stockLimit: 600 }],
costs: () => [['Candles & holders', 'once_off', 1450], ['Sound & lighting hire', 'once_off', 2800]],
online: 60, onlinePay: { card: 1 },
lines: o => [line(o[0], qty({ 1: 15, 2: 30, 3: 15, 4: 25, 5: 10, 6: 5 }))],
donations: { card: 18, cash: 9, amounts: [100, 150, 200, 250, 500, 500, 1000] },
showRate: 0.88,
cashup: { close: 'full', daysAfter: 6, lines: (exp, counted) => [cardLine(exp), cashLine(counted)], notes: 'Christmas collection banked on the 29th.' },
}),
buildPastEvent(people, {
title: 'Easter Sunrise Service & Breakfast',
description: 'A sunrise service on the hill, followed by a cooked breakfast together on the lawn.',
start: lastWeekday(199, 0, 6), hours: 3.5, picture: 'photo-1511632765486-a01980e01a18', location: 'Church lawn',
options: [{ name: 'Adult breakfast', price: 95 }, { name: 'Child breakfast (under 12)', price: 45 }],
costs: o => [['Breakfast catering', 'per_item', 48, o[0]], ['Breakfast catering (kids)', 'per_item', 25, o[1]], ['Gazebos & chairs', 'once_off', 1600]],
online: 45, onlinePay: { card: 75, eft: 15, none: 10 }, refunds: 1, door: 16, doorPay: { cash: 75, card: 25 },
lines: o => [line(o[0], int(1, 2)), line(o[1], qty({ 0: 4, 1: 3, 2: 2 }))],
showRate: 0.9,
feedback: ['The sunrise service', 'Breakfast together', 'Kids activities', 'Worship'],
cashup: { close: 'full', daysAfter: 2, lines: (exp, counted) => [cashLine(counted, { depositFee: 58.5 }), cardLine(exp), eftLine(exp)], notes: 'Cash deposited at the branch on Tuesday.' },
}),
buildPastEvent(people, {
title: 'Mother’s Day Brunch',
description: 'Spoil the moms in your life with a long, slow brunch, a flower for every mom and a short word of encouragement.',
start: lastWeekday(164, 0, 11), hours: 3, picture: 'photo-1414235077428-338989a2e8c0', location: 'Church hall',
options: [{ name: 'Brunch ticket', price: 180 }],
costs: o => [['Brunch catering', 'per_item', 95, o[0]], ['Flowers for every mom', 'once_off', 1400]],
online: 40, onlinePay: { card: 70, eft: 25, none: 5 }, door: 6, doorPay: { cash: 85, card: 15 },
lines: o => [line(o[0], qty({ 1: 30, 2: 45, 3: 15, 4: 10 }))],
showRate: 0.95,
cashup: {
close: 'reclose', daysAfter: 1, shortBy: 50, firstNotes: 'Cash R50 short. Will recount tomorrow.',
reopenDaysAfter: 2, reopenNotes: 'Found R50 in the float bag. Recounting.',
secondDaysAfter: 3, notes: 'Recounted: the cash is all there.',
lines: (exp, counted) => [cashLine(counted), cardLine(exp), eftLine(exp)],
},
}),
buildPastEvent(people, {
title: 'Youth Winter Camp',
description: 'A weekend away for Grade 8–12s: hikes, campfires, night games and three evening services.',
start: lastWeekday(103, 5, 17), hours: 45, picture: 'photo-1478131143081-80f7f84ca84d', location: 'Hartbeespoort campsite',
options: [{ name: 'Camper', price: 1200, stockLimit: 60 }],
costs: o => [['Campsite (per camper)', 'per_item', 520, o[0]], ['Food (per camper)', 'per_item', 260, o[0]], ['Bus hire', 'once_off', 9800]],
online: 34,
onlinePay: () => ({ pay: weighted({ full: 55, split: 40, none: 5 }), method: chance(0.55) ? 'eft' : 'card', deposit: 400 }),
lines: o => [line(o[0], qty({ 1: 75, 2: 25 }))],
showRate: 0.97,
feedback: ['Night games', 'Evening services', 'Hiking', 'Small groups', 'The food'],
cashup: {
close: 'full', daysAfter: 5, notes: 'Two parents’ EFTs still clearing.',
lines: (exp) => [eftLine(exp, 2400, 'POPs from two parents'), cardLine(exp)],
later: { method: 'eft', outcome: 'received', daysAfter: 12, notes: 'Both EFTs on the bank statement' },
},
}),
buildPastEvent(people, {
title: 'Men’s Golf Day',
description: 'Our annual four-ball alliance in support of the youth ministry, with halfway-house lunch and prize-giving at the clubhouse.',
start: lastWeekday(80, 5, 7, 30), hours: 8, picture: 'photo-1528605248644-14dd04022da1', location: 'Kloof Country Club', pool: 'men',
options: [{ name: 'Four-ball', price: 2400 }, { name: 'Single player', price: 650 }, { name: 'Halfway-house lunch', price: 120, isMainTicket: false }],
costs: o => [['Green fees (four-ball)', 'per_item', 1800, o[0]], ['Green fees (single)', 'per_item', 450, o[1]], ['Lunch', 'per_item', 75, o[2]], ['Prizes & trophies', 'once_off', 3500]],
online: 22, onlinePay: { card: 65, eft: 35 }, refunds: 1,
lines: o => (chance(0.4) ? [line(o[0], 1), line(o[2], 4)] : [line(o[1], 1), line(o[2], chance(0.7) ? 1 : 0)]),
showRate: 0.96,
cashup: { close: 'full', daysAfter: 3, lines: (exp) => [cardLine(exp), eftLine(exp)], notes: 'Card settlements match the Yoco payout report.' },
}),
buildPastEvent(people, {
title: 'Kids Holiday Club',
description: 'Four mornings of Bible stories, crafts, games and snacks for children aged 4–12.',
start: lastWeekday(62, 1, 8, 30), hours: 3 * 24 + 4.5, picture: 'photo-1529156069898-49953e39b3ac', location: 'Children’s ministry wing',
options: [{ name: 'Child (whole week)', price: 250 }, { name: 'Child (one day)', price: 70 }],
costs: o => [['Craft supplies', 'once_off', 2200], ['Snacks', 'per_item', 60, o[0]], ['Snacks (day visitors)', 'per_item', 15, o[1]]],
online: 40, onlinePay: { card: 60, eft: 35, none: 5 }, door: 18, doorPay: { cash: 85, card: 15 },
lines: (o, atDoor) => (atDoor ? [line(o[1], int(1, 3))] : [line(o[0], qty({ 1: 50, 2: 35, 3: 15 }))]),
showRate: 0.93,
feedback: ['Bible stories', 'Crafts', 'Games', 'Snacks', 'The leaders'],
cashup: {
close: 'full', daysAfter: 4, shortBy: 20, notes: 'R500 kept as the float for the next holiday club. R20 short written off.',
lines: (exp, counted) => [cashLine(counted, { keep: 500, shortfall: 20 }), cardLine(exp), eftLine(exp)],
},
}),
buildPastEvent(people, {
title: 'Women’s High Tea',
description: 'An afternoon of tea, scones and good conversation, with a short talk on finding rest in a busy season.',
start: lastWeekday(45, 6, 14), hours: 3, picture: 'photo-1511632765486-a01980e01a18', location: 'Church hall', pool: 'women',
options: [{ name: 'High tea ticket', price: 220 }],
costs: o => [['High tea catering', 'per_item', 110, o[0]], ['Décor & flowers', 'once_off', 1500]],
online: 40, onlinePay: { card: 75, eft: 20, none: 5 }, door: 6, doorPay: { cash: 80, card: 20 },
lines: o => [line(o[0], qty({ 1: 50, 2: 30, 3: 10, 4: 10 }))],
showRate: 0.94,
feedback: ['The speaker', 'The food', 'Time with friends', 'The décor'],
cashup: { close: 'full', daysAfter: 2, shortBy: 30, lines: (exp, counted) => [cashLine(counted), cardLine(exp), eftLine(exp)], notes: 'Cash R30 short and we could not trace it.' },
}),
buildPastEvent(people, {
title: 'Missions Fundraiser Dinner',
description: 'A three-course dinner with stories from our mission partners. Proceeds go to the Malawi school project.',
start: lastWeekday(27, 5, 18, 30), hours: 3.5, picture: 'photo-1414235077428-338989a2e8c0', location: 'Church hall',
options: [{ name: 'Dinner ticket', price: 350 }, { name: 'Table of 8', price: 2600 }],
costs: o => [['Dinner catering', 'per_item', 160, o[0]], ['Dinner catering (tables)', 'per_item', 1280, o[1]], ['Venue décor', 'once_off', 3000]],
online: 30, onlinePay: { eft: 50, card: 45, none: 5 },
lines: o => (chance(0.25) ? [line(o[1], 1)] : [line(o[0], qty({ 1: 30, 2: 55, 4: 15 }))]),
donations: { card: 12, cash: 6, amounts: [200, 300, 500, 500, 1000, 2000] },
showRate: 0.95,
cashup: {
close: 'full', daysAfter: 3, notes: 'One table paid by EFT on the night. POP received, waiting for it to clear.',
lines: (exp, counted) => [eftLine(exp, 2600, 'POP: Molefe family table'), cardLine(exp), cashLine(counted)],
},
}),
buildPastEvent(people, {
title: 'Worship Night',
description: 'An evening of worship with the band, with coffee and the food trucks from 18:00.',
start: lastWeekday(10, 5, 19), hours: 2.5, picture: 'photo-1501281668745-f7f57925c3b4', location: 'Main auditorium',
options: [{ name: 'Entry', price: 80 }],
costs: () => [['Band sound & lights', 'once_off', 2800]],
online: 50, onlinePay: { card: 95, none: 5 }, door: 20, doorPay: { cash: 70, card: 30 },
lines: o => [line(o[0], qty({ 1: 30, 2: 40, 3: 15, 4: 15 }))],
showRate: 0.9,
// Left open, with the cash already counted, so a cashup can be done live in a demo.
cashup: { close: 'none' },
}),
];
}
/**
* Runs each past event's cashup through the real cashup code (counts, close, reopen,
* settling pending money), then dates it a few days after the event.
*/
async function closePastEvents() {
const cashupController = require('../src/controllers/cashupController');
const { computeEventFinancials, savePersonCashCount, bucketForMethod } = require('../src/utils/cashupUtils');
const call = (fn, params, body = {}) => new Promise((resolve, reject) => {
const res = {
statusCode: 200,
status(code) { this.statusCode = code; return this; },
json(data) { if (this.statusCode >= 400) reject(new Error(data.message)); else resolve(data); },
};
fn({ params, body, user: admin, query: {} }, res);
});
// Each staff member's count of the cash they took; the first one comes up `shortBy` short.
const enterCounts = async (event, shortBy) => {
const byStaff = new Map();
for (const p of rows.payment) {
if (p.eventId === event.id && p.recordedById && bucketForMethod(p.method) === 'cash') {
byStaff.set(p.recordedById, (byStaff.get(p.recordedById) || 0) + p.amount);
}
}
let total = 0;
let first = true;
for (const [userId, amount] of byStaff) {
const counted = round2(amount - (first ? shortBy : 0));
first = false;
total += counted;
await savePersonCashCount(event.id, userId, { denominations: toDenominations(counted), notes: null, enteredById: admin.id });
}
return byStaff.size > 0 ? round2(total) : null;
};
const dayAfter = (event, days) => {
const d = addTime(event.endDate, days * DAY);
d.setHours(int(9, 16), int(0, 59), 0, 0);
return d < NOW ? d : addTime(NOW, -HOUR);
};
const close = async (event, { lines, notes }, counted, when) => {
const fin = await computeEventFinancials(event.id);
const body = { notes: notes || null };
if (lines) body.lines = lines(fin.expectedCashByMethod, counted).filter(Boolean);
const cashup = await call(cashupController.closeEvent, { eventId: event.id }, body);
await prisma.eventCashup.update({ where: { id: cashup.id }, data: { createdAt: when } });
await prisma.event.update({ where: { id: event.id }, data: { closedAt: when } });
};
for (const { event, cashup } of pastEvents) {
const counted = await enterCounts(event, cashup.shortBy || 0);
if (cashup.close === 'none') continue;
if (cashup.close === 'quick') {
await close(event, { notes: cashup.notes }, counted, dayAfter(event, cashup.daysAfter));
} else if (cashup.close === 'reclose') {
await close(event, { lines: cashup.lines, notes: cashup.firstNotes }, counted, dayAfter(event, cashup.daysAfter));
const reopenedAt = dayAfter(event, cashup.reopenDaysAfter);
const reopened = await call(cashupController.reopenEvent, { eventId: event.id }, { notes: cashup.reopenNotes });
await prisma.eventCashup.update({ where: { id: reopened.id }, data: { createdAt: reopenedAt } });
await prisma.event.update({ where: { id: event.id }, data: { reopenedAt } });
const recounted = await enterCounts(event, 0);
await close(event, { lines: cashup.lines, notes: cashup.notes }, recounted, dayAfter(event, cashup.secondDaysAfter));
} else {
await close(event, cashup, counted, dayAfter(event, cashup.daysAfter));
}
if (cashup.later) {
const fin = await computeEventFinancials(event.id);
const r = fin.reconciled.byMethod[cashup.later.method];
const resolution = await call(cashupController.resolvePending, { lineId: r.lineId }, { outcome: cashup.later.outcome, amount: r.pendingOutstanding, notes: cashup.later.notes });
await prisma.eventCashupPendingResolution.update({ where: { id: resolution.id }, data: { createdAt: dayAfter(event, cashup.later.daysAfter) } });
}
}
// Each event's figures as the cashup screen shows them, for the summary.
const results = new Map();
for (const { event } of pastEvents) results.set(event.id, await computeEventFinancials(event.id));
return results;
}
// ─── Profiles ────────────────────────────────────────────────────────────────
const UPCOMING = {
mensBreakfast: buildMensBreakfast,
familyFunDay: buildFamilyFunDay,
womensConference: buildWomensConference,
youthCamp: buildYouthCamp,
carols: buildCarols,
marriageWeekend: buildMarriageWeekend,
};
/**
* upcoming: which of the six upcoming events (UPCOMING); past: titles of past events to build
* (null = all ten); extras: the newer features (series, courses, conference, kids, volunteers);
* campuses: three campuses, switched on.
*/
const PROFILES = {
small: {
title: 'Small church — one congregation, the essentials',
about: [
'Three upcoming events: a breakfast (sold out, with a waitlist), a family fun day and free carols.',
'Four past events with check-ins and cashups (one left open to cash up live).',
'No campuses, kids check-in, volunteer rosters, courses or series.',
],
campuses: false,
upcoming: ['mensBreakfast', 'familyFunDay', 'carols'],
past: ['Church Family Picnic', 'Christmas Eve Candlelight Service', 'Easter Sunrise Service & Breakfast', 'Worship Night'],
extras: [],
},
events: {
title: 'Events & conferences — the full events demo (default)',
about: [
'Six upcoming events: breakfast with a waitlist, family day, women\'s conference (variants, early birds, coupons),',
'youth camp (parental consent, cabins, medical forms), free carols with donations, marriage weekend (bundles).',
'Ten past events with door sales, refunds, feedback and every kind of cashup; funnel analytics.',
'One campus; no kids check-in, rosters, courses or series.',
],
campuses: false,
upcoming: Object.keys(UPCOMING),
past: null,
extras: [],
},
ministry: {
title: 'Weekly ministry — one campus, courses, kids church and volunteers',
about: [
'Alpha Course running now (7 weekly sessions, attendance per session, late joining, certificates) and a finished',
'Marriage Course with certificates issued; a 3-day leadership conference sold by day ("pick sessions").',
'Kids Church every Sunday with kids check-in: families, children (some allergies), rooms with ratios, past check-ins.',
'Volunteer rosters for kids church and the family day (sign-ups, QR check-ins, an open cover request).',
'A Men\'s Breakfast series (two past, three coming) with followers and a season pass; family day, women\'s conference, carols.',
],
campuses: false,
upcoming: ['familyFunDay', 'womensConference', 'carols'],
past: ['Church Family Picnic', 'Christmas Eve Candlelight Service', 'Kids Holiday Club', 'Youth Winter Camp', 'Worship Night'],
extras: ['series', 'courses', 'conference', 'kids', 'volunteers'],
},
multicampus: {
title: 'Multi-campus church — three campuses',
about: [
'Campuses Central, North and South (switched on), events per campus and church-wide, home campuses for most people.',
'Staff limited to a campus: Anri Botha (North) and Daniel Pillay (South).',
'The Men\'s Breakfast series moves between the campuses, with followers and a season pass.',
'Kids church with kids check-in and volunteer rosters at Central and North.',
'All six upcoming and ten past events of the events demo, spread over the campuses.',
],
campuses: true,
upcoming: ['familyFunDay', 'womensConference', 'youthCamp', 'carols', 'marriageWeekend'],
past: null,
extras: ['series', 'kids', 'volunteers'],
},
};
let profile = PROFILES[PROFILE_NAME];
const hex = (bytes = 16) => crypto.randomBytes(bytes).toString('hex');
let campuses = [];
// ─── Campuses (multicampus) ──────────────────────────────────────────────────
function buildCampuses() {
const campus = (name, slug, address, colour, defaultLocation, order) => add('campus', {
id: uuidv4(), name, slug, address, contactName: 'Lerato Khumalo', contactEmail: `${slug}@example.com`,
colour, defaultLocation, order, createdAt: addTime(NOW, -300 * DAY), updatedAt: NOW,
});
campuses = [
campus('Central', 'central', '12 Church Street, Hatfield, Pretoria', '#2563eb', 'Main auditorium', 0),
campus('North', 'north', '45 Zambezi Drive, Montana', '#16a34a', 'North campus hall', 1),
campus('South', 'south', '8 Lenchen Avenue, Centurion', '#ea580c', 'South campus hall', 2),
];
for (const u of rows.user) if (u.role === 'user' && chance(0.85)) u.homeCampusId = pick(campuses).id;
}
/** Spreads the events over the campuses; the big ones stay church-wide. */
function assignCampuses() {
const byTitle = {
'Family Fun Day': 1, 'Carols by Candlelight': 2, 'Youth Winter Camp': null, 'Christmas Eve Candlelight Service': 0,
'Rooted Women’s Conference': null, 'Ignite Summer Camp': null, 'Marriage Enrichment Weekend': null,
};
let i = 0;
for (const event of rows.event) {
if (event.campusId !== undefined && event.campusId !== null) continue; // set by its builder
if (event.seriesId || event.kidsCheckIn) continue;
const idx = event.title in byTitle ? byTitle[event.title] : (i++ % campuses.length);
event.campusId = idx === null ? null : campuses[idx].id;
if (idx !== null && event.location === 'Church hall') event.location = campuses[idx].defaultLocation;
}
}
/** Staff limited to one campus (a many-to-many, so it's set after the rows are in). */
async function scopeStaff(tx) {
const byName = (first) => staffTeam.find(s => s.name.startsWith(first));
for (const [first, campusIdx] of [['Anri', 1], ['Daniel', 2]]) {
await tx.user.update({ where: { id: byName(first).id }, data: { scopedCampuses: { connect: [{ id: campuses[campusIdx].id }] } } });
}
}
// ─── Event series and a season pass ──────────────────────────────────────────
function buildBreakfast(people, { start, title, sold }) {
const event = addEvent({
title,
description: 'A full breakfast, a guest speaker and an honest conversation about faith, work and family. Bring a friend.',
startDate: start,
endDate: addTime(start, 2.5 * HOUR),
registrationDeadline: addTime(start, -2 * DAY),
price: 150,
picture: IMG('photo-1414235077428-338989a2e8c0'),
location: 'Church hall',
contactName: 'Sipho Dlamini',
});
const ticket = addOption(event, { name: 'Breakfast ticket', price: 150, stockLimit: 60 });
addCost(event, 'Breakfast catering', 'per_item', 85, ticket);
for (const user of shuffle(people.men).slice(0, sold)) {
addRegistration(event, user, [line(ticket, 1)], { createdAt: pastDate(20, 1), ...payPlan({ card: 0.7, eft: 0.1, none: 0.2 }) });
}
return { event, ticket };
}
/**
* The Men's Breakfast as a series: two past breakfasts (cashed up), this month's (sold out) and two
* more, 18 followers, and a season pass for the next two with six bought. On the multi-campus demo
* each breakfast is at a different campus.
*/
function buildSeries(people) {
const series = add('eventSeries', {
id: uuidv4(), name: "Men's Breakfast", slug: 'mens-breakfast',
description: 'Once a month on a Saturday morning: a full breakfast, a guest speaker and an honest conversation about faith, work and family. Follow the series to hear when the next one is out.',
picture: IMG('photo-1414235077428-338989a2e8c0'), createdAt: addTime(NOW, -200 * DAY), updatedAt: NOW,
});
const campusAt = (i) => (profile.campuses ? campuses[i % campuses.length] : null);
const pastSpec = (title, daysAgo, speaker) => ({
always: true, seriesId: series.id, title, description: `Breakfast with ${speaker}.`,
start: lastWeekday(daysAgo, 6, 7, 30), hours: 2.5, picture: 'photo-1414235077428-338989a2e8c0', location: 'Church hall', pool: 'men',
options: [{ name: 'Breakfast ticket', price: 150 }],
costs: o => [['Breakfast catering', 'per_item', 85, o[0]]],
online: int(34, 42), onlinePay: { card: 80, eft: 15, none: 5 }, door: int(3, 8), doorPay: { cash: 70, card: 30 },
lines: o => [line(o[0], chance(0.2) ? 2 : 1)],
showRate: 0.9,
cashup: { close: 'quick', daysAfter: 3, notes: 'Quick closed — card and cash banked.' },
});
const past = [
buildPastEvent(people, pastSpec("Men's Breakfast: Fathers & Sons", 63, 'Pastor Ernest Molefe')),
buildPastEvent(people, pastSpec("Men's Breakfast: Faith at Work", 35, 'Thabo Nkosi')),
];
const current = buildMensBreakfast(people);
const next = buildBreakfast(people, { start: nextWeekday(36, 6, 7, 30), title: "Men's Breakfast: Leading at Home", sold: 22 });
const later = buildBreakfast(people, { start: nextWeekday(64, 6, 7, 30), title: "Men's Breakfast: Money & Generosity", sold: 9 });
[...past, current, next.event, later.event].forEach((event, i) => {
event.seriesId = series.id;
// Followers were told when each came out (so the series job doesn't email demo people).
event.seriesAnnouncedAt = event.goLiveAt < NOW ? event.goLiveAt : NOW;
const campus = campusAt(i);
if (campus) { event.campusId = campus.id; event.location = campus.defaultLocation; }
});
for (const user of shuffle(people.men).slice(0, 18)) add('seriesFollow', { id: uuidv4(), seriesId: series.id, userId: user.id, createdAt: pastDate(150, 3) });
// Season pass: the next two breakfasts for R260 (R300 separately), R130 per event.
const pass = add('seasonPass', {
id: uuidv4(), seriesId: series.id, name: 'Next two breakfasts', description: 'Both of the next breakfasts for one price.',
price: 260, isActive: true, saleEndsAt: null, createdAt: addTime(NOW, -20 * DAY), updatedAt: NOW,
});
[next, later].forEach((b, order) => add('seasonPassItem', { id: uuidv4(), passId: pass.id, eventId: b.event.id, eventOptionId: b.ticket.id, variantId: null, order }));
for (const user of shuffle(people.men).slice(0, 6)) {
const at = pastDate(15, 1);
const purchase = add('seasonPassPurchase', { id: uuidv4(), passId: pass.id, userId: user.id, quantity: 1, total: 260, status: 'paid', checkoutId: null, createdAt: at, updatedAt: at });
[next, later].forEach((b, i) => {
const reg = add('registration', { id: uuidv4(), userId: user.id, eventId: b.event.id, status: 'paid', discountTotal: 0, bundleDiscount: 0, ticketDelivery: 'payer', analyticsSessionId: null, seasonPassPurchaseId: purchase.id, createdAt: at });
const ro = add('registrationOption', { id: uuidv4(), registrationId: reg.id, eventOptionId: b.ticket.id, variantId: null, quantity: 1, priceSnapshot: 130, appliedTierId: null, sessionId: null });
add('registrationOptionTranche', { id: uuidv4(), registrationOptionId: ro.id, quantity: 1, priceSnapshot: 130, appliedTierId: null, createdAt: at });
add('payment', {
id: uuidv4(), amount: 130, method: 'card', userId: user.id, recordedById: null, registrationId: reg.id, eventId: b.event.id,
status: 'completed', externalId: i === 0 ? `demo_${uuidv4().replace(/-/g, '')}` : null, seasonPassPurchaseId: purchase.id, createdAt: after(at, 2, 10),
});
add('ticket', { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: ro.id, userId: user.id, eventId: b.event.id, quantity: 1, emailSent: true, createdAt: at });
});
}
return [current, next.event, later.event];
}
// ─── Courses and a pick-your-days conference (ministry) ──────────────────────
function addSessions(event, first, count, { everyDays = 7, minutes = 120, labels = [], capacity = null } = {}) {
return Array.from({ length: count }, (_, k) => {
const startsAt = addTime(first, k * everyDays * DAY);
return add('eventSession', {
id: uuidv4(), eventId: event.id, label: labels[k] || null, startsAt, endsAt: addTime(startsAt, minutes * MINUTE),
order: k, capacity, location: null, createdAt: event.createdAt, updatedAt: NOW,
});
});
}
/** Check-ins per session that has ended, for the event's tickets. */
function attendSessions(event, sessions, showRate) {
const attended = new Map();
for (const s of sessions.filter(x => x.endsAt < NOW)) {
for (const t of rows.ticket.filter(x => x.eventId === event.id)) {
if (!chance(showRate)) continue;
add('ticketUsage', { id: uuidv4(), ticketId: t.id, scannedById: pick(staffTeam).id, quantityRedeemed: t.quantity, sessionId: s.id, scannedAt: within(addTime(s.startsAt, -20 * MINUTE), addTime(s.startsAt, 15 * MINUTE)) });
attended.set(t.id, (attended.get(t.id) || 0) + 1);
}
}
return attended;
}
const CERTIFICATE = (min) => ({
template: 'classic', title: 'Certificate of Completion', signatureImage: '', sendOnIssue: false,
body: 'This certifies that {name} has completed {event}, attending {sessionsAttended} of {totalSessions} sessions.',
signatureName: 'Pastor Ernest Molefe', signatureRole: 'Senior Pastor', rule: { type: 'count', value: min },
});
function buildCourses(people) {
// Alpha: seven Wednesdays, three already held; late joining until week 4 at a pro-rata price.
const first = lastWeekday(15, 3, 18, 30);
const alpha = addEvent({
title: 'Alpha Course',
description: 'Seven Wednesday evenings exploring the big questions of life and faith — each with dinner, a short talk and honest conversation in small groups. Everyone is welcome; no question is off limits.',
startDate: first, endDate: addTime(first, (6 * 7) * DAY + 2 * HOUR), price: 250,
picture: IMG('photo-1529070538774-1843cb3265df'), location: 'Church hall',
scheduleType: 'sessions', attendanceMode: 'whole', lateJoinEnabled: true, latePricing: 'prorata', checkInWindow: 'whole_day',
certificatesEnabled: true, certificateConfig: CERTIFICATE(5),
goLiveAt: addTime(first, -35 * DAY), createdAt: addTime(first, -40 * DAY),
});
const alphaSessions = addSessions(alpha, first, 7, { labels: ['Is there more to life than this?', 'Who is Jesus?', 'Why did Jesus die?', 'How can I have faith?', 'Why and how do I pray?', 'How can I resist evil?', 'What about the church?'] });
alpha.lateJoinUntilSessionId = alphaSessions[3].id;
const alphaTicket = addOption(alpha, { name: 'Course (dinner included)', price: 250, stockLimit: 40 });
for (const user of shuffle(people.everyone).slice(0, 30)) {
addRegistration(alpha, user, [line(alphaTicket, 1)], { createdAt: daytimeBetween(addTime(first, -30 * DAY), addTime(first, -DAY)), ...payPlan({ card: 0.7, eft: 0.2, none: 0.1 }) });
}
attendSessions(alpha, alphaSessions, 0.85);
// Marriage Course: finished six weeks ago; certificates issued to couples who came to 5 of 6.
const mFirst = lastWeekday(80, 4, 19);
const marriage = addEvent({
title: 'The Marriage Course',
description: 'Six Thursday evenings for couples: a candle-lit dinner for two, a short talk and time to talk together — no group sharing.',
startDate: mFirst, endDate: addTime(mFirst, 35 * DAY + 2 * HOUR), price: 500,
picture: IMG('photo-1529634806980-85c3dd6d34ac'), location: 'Church hall',
scheduleType: 'sessions', attendanceMode: 'whole', checkInWindow: 'whole_day',
certificatesEnabled: true, certificateConfig: CERTIFICATE(5),
goLiveAt: addTime(mFirst, -40 * DAY), createdAt: addTime(mFirst, -45 * DAY),
});
const mSessions = addSessions(marriage, mFirst, 6, { labels: ['Building strong foundations', 'The art of communication', 'Resolving conflict', 'The power of forgiveness', 'Parents and in-laws', 'Love in action'] });
const couple = addOption(marriage, { name: 'Couple', price: 500, stockLimit: 16 });
for (const user of shuffle(people.everyone).slice(0, 14)) {
addRegistration(marriage, user, [line(couple, 1)], { createdAt: daytimeBetween(addTime(mFirst, -35 * DAY), addTime(mFirst, -2 * DAY)), payBy: mFirst, ...payPlan({ card: 0.75, eft: 0.25 }) });
}
const attended = attendSessions(marriage, mSessions, 0.88);
const issuedAt = addTime(mSessions[5].endsAt, DAY);
for (const t of rows.ticket.filter(x => x.eventId === marriage.id)) {
const n = attended.get(t.id) || 0;
if (n < 5) continue;
const owner = rows.user.find(u => u.id === t.userId);
add('certificate', { id: uuidv4(), eventId: marriage.id, ticketId: t.id, userId: owner.id, name: owner.name, code: hex(4).toUpperCase(), sessionsAttended: n, totalSessions: 6, manual: false, issuedAt, emailedAt: issuedAt });
}
return [alpha];
}
function buildLeadershipConference(people) {
const day1 = nextWeekday(40, 4, 8, 30);
const event = addEvent({
title: 'Lead Well Leadership Conference',
description: 'Three days for everyone who leads — small groups, ministries, teams at work. Come to all three, or pick the days you can make.',
startDate: day1, endDate: addTime(day1, 2 * DAY + 8 * HOUR), price: 650,
picture: IMG('photo-1540575467063-178a50c2df87'), location: 'Main auditorium',
scheduleType: 'sessions', attendanceMode: 'pick', checkInWindow: 'whole_day',
});
const days = addSessions(event, day1, 3, { everyDays: 1, minutes: 8 * 60, labels: ['Day 1 — Vision', 'Day 2 — Teams', 'Day 3 — Practice'], capacity: 150 });
const full = addOption(event, { name: 'Full conference pass', price: 650, sessionMode: 'all' });
addTier(full, { deadline: addTime(day1, -14 * DAY), price: 550, stockLimit: 40 });
const dayPass = addOption(event, { name: 'Day pass', price: 280, sessionMode: 'pick' });
const buyers = shuffle(people.everyone);
for (const user of buyers.splice(0, 34)) {
addRegistration(event, user, [line(full, chance(0.2) ? 2 : 1)], { createdAt: pastDate(30, 1), ...payPlan({ card: 0.8, eft: 0.12, none: 0.08 }) });
}
for (const user of buyers.splice(0, 26)) {
const picked = shuffle(days).slice(0, chance(0.4) ? 2 : 1);
addRegistration(event, user, picked.map(s => line(dayPass, 1, { sessionId: s.id })), { createdAt: pastDate(25, 1), ...payPlan({ card: 0.8, none: 0.2 }) });
}
return [event];
}
// ─── Kids church with kids check-in ──────────────────────────────────────────
const ROOMS = [['Nursery', 0, 2, 3, 12], ['Little Ones', 3, 5, 6, 24], ['Explorers', 6, 9, 8, 30], ['Pre-teens', 10, 12, 10, 24]];
const inFamily = new Set();
const kidsChurches = [];
function ageAt(dob, at) {
let age = at.getFullYear() - dob.getFullYear();
if (at.getMonth() < dob.getMonth() || (at.getMonth() === dob.getMonth() && at.getDate() < dob.getDate())) age--;
return Math.max(0, age);
}
/** A pickup code that's free on this occasion (4 characters, no look-alikes). */
function kidsCode(taken) {
const { CODE_ALPHABET, CODE_LENGTH } = require('../src/utils/kids');
for (;;) {
let c = '';
for (let i = 0; i < CODE_LENGTH; i++) c += CODE_ALPHABET[int(0, CODE_ALPHABET.length - 1)];
if (!taken.has(c)) { taken.add(c); return c; }
}
}
/**
* Sunday kids church: a hidden event with a session every Sunday (four held, eight to come), the
* kids ticket and rooms, 16 families with their children and authorised adults, and check-ins
* and check-outs (with pickup codes, tickets and room duty) for the Sundays already held.
*/
function buildKidsChurch(people, campus = null) {
const { encrypt } = require('../src/utils/encryption');
const first = lastWeekday(28, 0, 9, 0);
const event = addEvent({
title: campus ? `Kids Church — ${campus.name}` : 'Kids Church',
description: 'Sunday kids church during the 09:00 service, for babies to 12-year-olds. Check in at the kids desk: each family gets a pickup code, and only someone with the code can collect.',
startDate: first, endDate: addTime(first, 77 * DAY + 90 * MINUTE), price: 0, isHidden: true,
scheduleType: 'sessions', attendanceMode: 'whole', checkInWindow: 'whole_day', kidsCheckIn: true,
location: campus ? `${campus.name} kids wing` : 'Kids wing', campusId: campus ? campus.id : null,
goLiveAt: addTime(first, -30 * DAY), createdAt: addTime(first, -32 * DAY),
});
const sessions = addSessions(event, first, 12, { minutes: 90 });
// A session today, so the desk and room screens have children in right now whatever day the
// demo is shown (on a Sunday it's that Sunday's session).
let live = sessions.find(s => s.startsAt.toDateString() === NOW.toDateString() && s.endsAt > NOW);
if (!live) {
const startsAt = new Date(NOW); startsAt.setMinutes(0, 0, 0); startsAt.setHours(Math.max(8, startsAt.getHours() - 1));
live = add('eventSession', { id: uuidv4(), eventId: event.id, label: 'Midweek Kids Club', startsAt, endsAt: addTime(startsAt, 3 * HOUR), order: 0, capacity: null, location: null, createdAt: event.createdAt, updatedAt: NOW });
sessions.push(live);
sessions.sort((a, b) => a.startsAt - b.startsAt).forEach((s, order) => { s.order = order; });
}
const option = addOption(event, { name: 'Kids check-in', price: 0 });
event.kidsOptionId = option.id;
const set = add('allocationSet', { id: uuidv4(), eventId: event.id, name: 'Kids rooms', showOnTicket: false, order: 0 });
event.kidsRoomSetId = set.id;
const rooms = ROOMS.map(([name, minAge, maxAge, ratio, capacity], order) => add('allocationGroup', { id: uuidv4(), setId: set.id, name, minAge, maxAge, ratio, capacity, order, gender: null, leaderUserId: null }));
const roomFor = age => rooms.find(r => age >= r.minAge && age <= r.maxAge) || rooms[rooms.length - 1];
const parents = shuffle(people.everyone).filter(u => !inFamily.has(u.id)).slice(0, 16);
const families = parents.map(parent => {
inFamily.add(parent.id);
const { last } = userMeta.get(parent.id);
const createdAt = pastDate(200, 40);
const family = add('family', { id: uuidv4(), name: `The ${last} family`, qrToken: hex(), createdAt, updatedAt: NOW });
add('familyMember', { id: uuidv4(), familyId: family.id, userId: parent.id, createdAt });
if (chance(0.5)) {
add('authorisedAdult', { id: uuidv4(), familyId: family.id, name: `${pick(chance(0.5) ? FEMALE_FIRST : MALE_FIRST)} ${last}`, phone: null, relationship: pick(['Grandmother', 'Grandfather', 'Aunt', 'Uncle']), createdAt });
}
const children = Array.from({ length: int(1, 3) }, () => {
const girl = chance(0.5);
const dob = new Date(NOW);
dob.setFullYear(dob.getFullYear() - int(1, 12), int(0, 11), int(1, 28));
dob.setHours(0, 0, 0, 0);
return add('child', {
id: uuidv4(), familyId: family.id, firstName: pick(girl ? GIRL_FIRST : BOY_FIRST), lastName: last, dateOfBirth: dob, gender: girl ? 'female' : 'male',
allergies: chance(0.15) ? encrypt(pick(['Peanuts', 'Tree nuts', 'Dairy', 'Eggs', 'Bee stings'])) : null,
medicalNotes: chance(0.06) ? encrypt('Asthma — inhaler in the nappy bag') : null,
custodyNotes: chance(0.04) ? encrypt('Only the mother or grandmother may collect') : null,
lastCheckInAt: null, createdAt, updatedAt: NOW,
});
});
return { parent, family, children };
});
const held = sessions.filter(s => s.endsAt < NOW);
const codes = new Map([...held, live].map(s => [s.id, new Set()]));
let liveFamilies = 0;
for (const f of families) {
const attending = held.filter(() => chance(0.75));
// About half the families are in right now (checked in, not yet collected).
const inNow = live.startsAt <= NOW && liveFamilies < 8 && chance(0.6);
if (inNow) { attending.push(live); liveFamilies++; }
if (!attending.length) continue;
// The desk's first check-in gives the parent a free kids registration: a named place and ticket per child.
const { regLines } = addRegistration(event, f.parent, [line(option, f.children.length, {
attendees: f.children.map(c => ({ name: `${c.firstName} ${c.lastName}`, dateOfBirth: c.dateOfBirth, gender: c.gender, parentName: f.parent.name, parentEmail: f.parent.email })),
})], { createdAt: addTime(attending[0].startsAt, -25 * MINUTE) });
const attendees = regLines[0].attendees;
for (const s of attending) {
const code = add('kidsPickupCode', { id: uuidv4(), eventId: event.id, occasion: s.id, code: kidsCode(codes.get(s.id)), familyId: f.family.id, createdAt: addTime(s.startsAt, -int(10, 20) * MINUTE) });
const isLive = s === live;
f.children.forEach((c, i) => {
if (!chance(0.92)) return;
const at = isLive
? within(addTime(NOW, -50 * MINUTE), addTime(NOW, -5 * MINUTE))
: within(addTime(s.startsAt, -20 * MINUTE), addTime(s.startsAt, 10 * MINUTE));
const ticket = rows.ticket.find(t => t.attendeeId === attendees[i].id);
const desk = pick(staffTeam);
add('kidsCheckIn', {
id: uuidv4(), eventId: event.id, occasion: s.id, sessionId: s.id, childId: c.id, childName: `${c.firstName} ${c.lastName}`,
roomId: roomFor(ageAt(c.dateOfBirth, s.startsAt)).id, pickupCodeId: code.id, attendeeId: attendees[i].id, ticketId: ticket.id,
checkedInAt: at, checkedInById: desk.id,
checkedOutAt: isLive ? null : within(addTime(s.endsAt, -5 * MINUTE), addTime(s.endsAt, 15 * MINUTE)),
checkedOutById: isLive ? null : pick(staffTeam).id,
collectedBy: isLive ? null : chance(0.85) ? f.parent.name : 'Authorised adult', offline: false,
});
add('ticketUsage', { id: uuidv4(), ticketId: ticket.id, scannedById: desk.id, scannedAt: at, sessionId: s.id, quantityRedeemed: 1 });
if (!c.lastCheckInAt || at > c.lastCheckInAt) c.lastCheckInAt = at;
});
}
}
for (const s of [...held, live]) {
const isLive = s === live;
if (isLive && live.startsAt > NOW) continue;
for (const room of rooms) {
for (const leader of shuffle(staffTeam).slice(0, int(1, 2))) {
add('kidsRoomDuty', {
id: uuidv4(), roomId: room.id, occasion: s.id, userId: leader.id, fromRoster: false,
startedAt: isLive ? addTime(NOW, -55 * MINUTE) : addTime(s.startsAt, -15 * MINUTE),
endedAt: isLive ? null : addTime(s.endsAt, 10 * MINUTE),
});
}
}
}
kidsChurches.push({ event, sessions, rooms });
return [event];
}
// ─── Volunteer rosters ───────────────────────────────────────────────────────
/**
* Serving roles with people on them. `roles`: [name, needed, room?]. For a held session or event
* the places are filled and most checked in (QR); later ones are partly signed up. Reminders are
* marked as sent, so the reminder job doesn't email demo people.
*/
function addRoster(people, event, { session = null, roles, rooms = [] }) {
const start = session ? session.startsAt : event.startDate;
const held = (session ? session.endsAt : event.endDate) < NOW;
const daysAway = (start - NOW) / DAY;
const pool = shuffle(people.everyone);
const slots = [];
roles.forEach(([name, needed, roomName], order) => {
const role = add('servingRole', {
id: uuidv4(), eventId: event.id, sessionId: session ? session.id : null, name, description: null, needed,
startsAt: null, endsAt: null, kidsRoomId: roomName ? rooms.find(r => r.name === roomName)?.id || null : null,
order, createdAt: addTime(NOW, -25 * DAY), updatedAt: NOW,
});
const filled = held ? needed : daysAway < 10 ? int(Math.max(0, needed - 1), needed) : int(0, Math.max(0, needed - 1));
for (const user of pool.splice(0, filled)) {
const checked = held && chance(0.9);
// Most scan the QR code themselves; now and then a staff member ticks them in.
const method = checked ? (chance(0.85) ? 'qr' : 'manual') : null;
slots.push(add('volunteerSlot', {
id: uuidv4(), roleId: role.id, userId: user.id, assignedById: chance(0.3) ? staffTeam[0].id : null,
checkedInAt: checked ? within(addTime(start, -40 * MINUTE), addTime(start, -5 * MINUTE)) : null,
checkedInById: method === 'qr' ? user.id : method ? pick(staffTeam).id : null, checkInMethod: method,
remindedAt: held ? addTime(start, -DAY) : NOW, registrationId: null, createdAt: pastDate(20, 1),
}));
}
});
return slots;
}
function buildVolunteers(people, built) {
for (const { event, sessions, rooms } of kidsChurches) {
event.volunteerCheckInToken = hex();
const kidsRoles = [['Welcome desk', 3], ['Nursery leader', 2, 'Nursery'], ['Little Ones leader', 2, 'Little Ones'], ['Explorers leader', 2, 'Explorers'], ['Pre-teens leader', 1, 'Pre-teens']];
const lastHeld = sessions.filter(s => s.endsAt < NOW).pop();
const coming = sessions.filter(s => s.startsAt > NOW).slice(0, 2);
if (lastHeld) addRoster(people, event, { session: lastHeld, roles: kidsRoles, rooms });
coming.forEach((session, i) => {
const slots = addRoster(people, event, { session, roles: kidsRoles, rooms });
// Someone can't make the next Sunday and has asked for cover.
if (i === 0 && slots.length) {
const slot = slots[0];
add('volunteerSwap', { id: uuidv4(), slotId: slot.id, roleId: slot.roleId, fromUserId: slot.userId, toUserId: null, note: 'Away for a family wedding — can anyone take this?', status: 'open', createdAt: pastDate(2) });
}
});
}
const dayEvent = built.find(e => e.title === 'Family Fun Day');
if (dayEvent) {
dayEvent.volunteerCheckInToken = hex();
addRoster(people, dayEvent, { roles: [['Parking marshals', 4], ['Welcome & info table', 3], ['First aid', 1], ['Coffee bar', 3], ['Jumping castle supervisors', 2]] });
}
}
/** The extras of the chosen profile, in order; returns the upcoming events they add. */
function buildExtras(people, built) {
const out = [];
const has = (x) => profile.extras.includes(x);
if (has('series')) out.push(...buildSeries(people));
if (has('courses')) out.push(...buildCourses(people));
if (has('conference')) out.push(...buildLeadershipConference(people));
if (has('kids')) {
if (profile.campuses) for (const c of campuses.slice(0, 2)) out.push(...buildKidsChurch(people, c));
else out.push(...buildKidsChurch(people));
}
if (has('volunteers')) buildVolunteers(people, built.concat(out));
return out;
}
function listProfiles() {
console.log('\nDemo profiles — npm run demo:<name> (from the repo root):\n');
for (const [name, p] of Object.entries(PROFILES)) {
console.log(` ${name.padEnd(12)} ${p.title}`);
for (const line of p.about) console.log(` ${''.padEnd(12)} ${line}`);
console.log('');
}
}
// ─── 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 { sessionsFrom, rollupRows, RAW_DAYS } = require('../src/utils/analytics');
const rawCutoff = addTime(NOW, -RAW_DAYS * DAY);
// Hidden events (kids church) aren't shown publicly, so nobody "visits" them.
for (const event of rows.event.filter(e => !e.isHidden)) {
const raw = [];
const session = (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) {
raw.push({ id: uuidv4(), sessionId, eventId: event.id, stage, source, device, createdAt: new Date(Math.min(t, at.getTime())) });
t += int(1, 12) * MINUTE;
}
return sessionId;
};
// Sold at the door, not online.
const registrations = (rows.registration || []).filter(r => r.eventId === event.id && r.createdAt < addTime(event.startDate, -2 * HOUR));
for (const reg of registrations) {
// Most registrations arrive from a tracked session (the rest declined cookies).
if (chance(0.8)) reg.analyticsSessionId = session(['view', 'start', 'submit'], reg.createdAt);
}
const salesEnd = event.startDate < NOW ? event.startDate : NOW;
const browsers = registrations.length * int(3, 5);
for (let i = 0; i < browsers; i++) {
session(chance(0.3) ? ['view', 'start'] : ['view'], daytimeBetween(event.goLiveAt, salesEnd));
}
// As the app does nightly: sessions last active more than 90 days ago are rolled up into
// daily counts and their raw rows dropped.
const lastSeen = new Map();
for (const e of raw) lastSeen.set(e.sessionId, Math.max(lastSeen.get(e.sessionId) || 0, e.createdAt.getTime()));
const old = raw.filter(e => lastSeen.get(e.sessionId) < rawCutoff.getTime());
for (const e of raw) if (lastSeen.get(e.sessionId) >= rawCutoff.getTime()) add('analyticsEvent', e);
if (old.length > 0) {
const paid = new Set(registrations.filter(r => r.status === 'paid' && r.analyticsSessionId).map(r => r.analyticsSessionId));
for (const r of rollupRows(sessionsFrom(old, paid))) {
add('analyticsDaily', { id: uuidv4(), eventId: event.id, day: new Date(r.day), stage: r.stage, source: r.source, device: r.device, sessions: r.sessions });
}
}
}
}
// ─── Clearing ────────────────────────────────────────────────────────────────
async function clearData(tx) {
const tables = await tx.$queryRaw`SELECT tablename FROM pg_tables WHERE schemaname = current_schema()`;
// Tables the User table points at (e.g. Campus via homeCampusId): TRUNCATE ... CASCADE on them
// would truncate User too — and with it the admin we keep — so their rows are deleted instead,
// after the admin's links to them are cleared.
const userRefs = await tx.$queryRaw`
SELECT DISTINCT ref.relname AS table, att.attname AS column
FROM pg_constraint c
JOIN pg_class src ON src.oid = c.conrelid
JOIN pg_class ref ON ref.oid = c.confrelid
JOIN pg_attribute att ON att.attrelid = c.conrelid AND att.attnum = ANY (c.conkey)
WHERE c.contype = 'f' AND src.relname = 'User' AND ref.relname <> 'User'`;
const deleteInstead = new Set(userRefs.map(r => r.table));
const toClear = tables.map(t => t.tablename).filter(t => !KEEP_TABLES.has(t) && !deleteInstead.has(t));
if (toClear.length) {
await tx.$executeRawUnsafe(`TRUNCATE TABLE ${toClear.map(t => `"${t}"`).join(', ')} CASCADE`);
}
if (userRefs.length) {
await tx.$executeRawUnsafe(`UPDATE "User" SET ${[...new Set(userRefs.map(r => `"${r.column}" = NULL`))].join(', ')}`);
for (const table of deleteInstead) await tx.$executeRawUnsafe(`DELETE FROM "${table}"`);
}
// 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) {
console.log(`\nDemo profile "${PROFILE_NAME}": ${profile.title}`);
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() {
if (LIST_ONLY) { listProfiles(); return; }
if (!profile) {
console.error(`Unknown demo profile "${PROFILE_NAME}". Choose one of: ${Object.keys(PROFILES).join(', ')}.`);
listProfiles();
process.exitCode = 1;
return;
}
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 hashPassword(DEMO_PASSWORD);
const people = buildPeople();
if (profile.campuses) buildCampuses();
const events = profile.upcoming.map(key => UPCOMING[key](people));
events.push(...buildExtras(people, events));
buildPastEvents(people);
if (profile.campuses) assignCampuses();
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] });
}
if (profile.campuses) await scopeStaff(tx);
// Campuses on for the multi-campus demo, off for the others (site settings are kept).
await tx.appSetting.upsert({
where: { key: 'campuses_enabled' },
update: { value: profile.campuses ? 'true' : 'false' },
create: { key: 'campuses_enabled', value: profile.campuses ? 'true' : 'false' },
});
return result;
}, { maxWait: 10 * 1000, timeout: 5 * 60 * 1000 });
clearScheduledEmails();
const pastFinancials = await closePastEvents();
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`);
}
const CASHUP_LABELS = {
none: 'OPEN: cash counted, ready to cash up',
quick: 'quick closed',
reclose: 'closed, reopened, closed again',
full: 'closed with a cashup',
};
const money = (n) => `R${n.toFixed(2)}`;
const ticketEvent = new Map(rows.ticket.map(t => [t.id, t.eventId]));
console.log('\nPast events:');
for (const { event, cashup } of pastEvents) {
const regs = rows.registration.filter(r => r.eventId === event.id).length;
const scans = (rows.ticketUsage || []).filter(u => ticketEvent.get(u.ticketId) === event.id).length;
const f = pastFinancials.get(event.id);
const unaccounted = f.reconciled ? Object.values(f.reconciled.byMethod).reduce((s, r) => s + (r.unaccounted || 0), 0) : 0;
const notes = [
f.totalWriteOffs > 0 && `${money(f.totalWriteOffs)} written off`,
f.pendingOutstandingTotal > 0 && `${money(f.pendingOutstandingTotal)} pending`,
cashup.later && 'pending money received later',
Math.abs(unaccounted) >= 0.005 && `${money(unaccounted)} unaccounted`,
].filter(Boolean).join(', ');
console.log(` ${event.startDate.toDateString().padEnd(16)} ${event.title.padEnd(34)} ${String(regs).padStart(3)} registrations, ${String(scans).padStart(3)} check-ins — ${CASHUP_LABELS[cashup.close]}${notes ? ` (${notes})` : ''}`);
}
console.log(`\nCreated ${count('user')} users, ${count('payment')} payments, ${count('ticket')} tickets, ${count('ticketUsage')} check-ins, ${count('attendee')} named attendees, ${count('feedbackResponse')} feedback responses, ${count('analyticsEvent') + count('analyticsDaily')} funnel rows.`);
const extras = [
count('campus') && `${count('campus')} campuses (on), 2 campus-limited staff`,
count('eventSeries') && `${count('eventSeries')} series with ${count('seriesFollow')} followers, ${count('seasonPassPurchase')} season passes sold`,
count('eventSession') && `${count('eventSession')} sessions`,
count('certificate') && `${count('certificate')} certificates`,
count('family') && `${count('family')} families, ${count('child')} children, ${count('kidsCheckIn')} kids check-ins`,
count('servingRole') && `${count('servingRole')} serving roles, ${count('volunteerSlot')} volunteers rostered, ${count('volunteerSwap')} cover request`,
].filter(Boolean);
if (extras.length) console.log(`Also: ${extras.join('; ')}.`);
console.log(`Campuses are ${profile.campuses ? 'ON' : 'off'} — restart the backend so it picks the setting up.`);
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; })
// The cashup code used by closePastEvents has its own client (src/config/db).
.finally(() => Promise.all([prisma.$disconnect(), require('../src/config/db').$disconnect()]));