Bundles get a kind: exact (as before), mix (any setSize of the qualifying tickets for a set price), free (cheapest freeCount in each set free) and group (setSize or more qualifying tickets at a per-ticket price or % off, applied once). The supervisor page has a type picker, buyers see a one-line description of each deal, and the Complete bundle cards handle every type. Demo: Family Fun Day adds a Lunch for four deal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1362 lines
68 KiB
JavaScript
1362 lines
68 KiB
JavaScript
/**
|
||
* set-demo.js
|
||
*
|
||
* Replaces the database contents with demo data for showing the system to other
|
||
* churches: six upcoming events with registrations, payments, tickets, attendee forms,
|
||
* coupons, a waitlist, camp cabins, sponsorships, costs and funnel analytics, plus ten
|
||
* completed events from the past year with door sales, check-ins, feedback and cashups.
|
||
*
|
||
* 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.
|
||
*
|
||
* Usage:
|
||
* cd backend
|
||
* npm run set-demo (asks for confirmation)
|
||
* npm run set-demo -- --yes (no prompt)
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const crypto = require('crypto');
|
||
const readline = require('readline');
|
||
|
||
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
|
||
|
||
const bcrypt = require('bcryptjs');
|
||
const { v4: uuidv4 } = require('uuid');
|
||
const { createPrismaClient } = require('../src/config/db');
|
||
const { 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');
|
||
|
||
// Not truncated: migrations, site settings, users (all but the admin are deleted
|
||
// separately) and the admin's own account-security rows.
|
||
const KEEP_TABLES = new Set([
|
||
'_prisma_migrations', 'AppSetting', 'User',
|
||
'SecurityEvent', 'PasswordReset', 'TwoFactorChallenge', 'TrustedDevice',
|
||
]);
|
||
|
||
const SCHEDULED_EMAILS_PATH = path.resolve(__dirname, '../data/scheduled-emails.json');
|
||
|
||
// ─── Random helpers (seeded, so every run produces the same-looking demo) ────
|
||
|
||
let seed = 20261021;
|
||
function rand() {
|
||
seed = (seed + 0x6D2B79F5) | 0;
|
||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
}
|
||
const int = (min, max) => min + Math.floor(rand() * (max - min + 1));
|
||
const pick = (arr) => arr[Math.floor(rand() * arr.length)];
|
||
const chance = (p) => rand() < p;
|
||
const round2 = (n) => Math.round(n * 100) / 100;
|
||
|
||
/** Picks a key from { key: weight }. */
|
||
function weighted(weights) {
|
||
const total = Object.values(weights).reduce((s, w) => s + w, 0);
|
||
let r = rand() * total;
|
||
for (const [key, w] of Object.entries(weights)) {
|
||
if ((r -= w) < 0) return key;
|
||
}
|
||
return Object.keys(weights)[0];
|
||
}
|
||
|
||
function shuffle(arr) {
|
||
const out = arr.slice();
|
||
for (let i = out.length - 1; i > 0; i--) {
|
||
const j = Math.floor(rand() * (i + 1));
|
||
[out[i], out[j]] = [out[j], out[i]];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ─── Dates ───────────────────────────────────────────────────────────────────
|
||
|
||
const NOW = new Date();
|
||
const MINUTE = 60 * 1000;
|
||
const HOUR = 60 * MINUTE;
|
||
const DAY = 24 * HOUR;
|
||
|
||
/** The first `weekday` (0 = Sunday) at least `minDays` from today, at hour:minute. */
|
||
function nextWeekday(minDays, weekday, hour, minute = 0) {
|
||
const d = new Date(NOW);
|
||
d.setDate(d.getDate() + minDays);
|
||
while (d.getDay() !== weekday) d.setDate(d.getDate() + 1);
|
||
d.setHours(hour, minute, 0, 0);
|
||
return d;
|
||
}
|
||
|
||
const addTime = (date, ms) => new Date(date.getTime() + ms);
|
||
const daysFromNow = (days) => addTime(NOW, days * DAY);
|
||
|
||
/** A random moment between minDaysAgo and maxDaysAgo, during waking hours, never in the future. */
|
||
function pastDate(maxDaysAgo, minDaysAgo = 0) {
|
||
const d = new Date(NOW.getTime() - (minDaysAgo + rand() * (maxDaysAgo - minDaysAgo)) * DAY);
|
||
d.setHours(int(7, 21), int(0, 59), int(0, 59), 0);
|
||
return d < NOW ? d : new Date(NOW.getTime() - int(5, 120) * MINUTE);
|
||
}
|
||
|
||
/** A moment a little after `from`, clamped to the past. */
|
||
function after(from, minMinutes, maxMinutes) {
|
||
const t = from.getTime() + int(minMinutes, maxMinutes) * MINUTE;
|
||
return new Date(Math.min(t, NOW.getTime() - MINUTE));
|
||
}
|
||
|
||
// ─── People ──────────────────────────────────────────────────────────────────
|
||
|
||
const FEMALE_FIRST = [
|
||
'Thandi', 'Nomsa', 'Lerato', 'Zanele', 'Palesa', 'Ayanda', 'Naledi', 'Busisiwe', 'Precious',
|
||
'Refilwe', 'Anri', 'Elmarie', 'Liezl', 'Marike', 'Annelie', 'Charlene', 'Michelle', 'Nicole',
|
||
'Sarah', 'Rachel', 'Hannah', 'Grace', 'Megan', 'Jessica', 'Lauren', 'Kelly', 'Tamryn', 'Candice',
|
||
'Fatima', 'Priya', 'Kavitha', 'Nadia', 'Bianca', 'Chantelle', 'Tanya', 'Mpho', 'Keabetswe',
|
||
'Lindiwe', 'Sibongile', 'Yolanda', 'Ruth', 'Esther', 'Joanne', 'Karen', 'Debbie', 'Nompumelelo',
|
||
'Carmen', 'Simone', 'Natasha', 'Ilse', 'Rochelle', 'Tumi', 'Dineo', 'Hlengiwe', 'Abigail',
|
||
];
|
||
const MALE_FIRST = [
|
||
'Sipho', 'Thabo', 'Bongani', 'Mandla', 'Kagiso', 'Tshepo', 'Lwazi', 'Sizwe', 'Themba', 'Musa',
|
||
'Pieter', 'Johan', 'Francois', 'Hendrik', 'Marius', 'Wikus', 'Riaan', 'Gerhard', 'David',
|
||
'Michael', 'Daniel', 'Matthew', 'Andrew', 'Jonathan', 'Ryan', 'Craig', 'Brandon', 'Shaun',
|
||
'Grant', 'Kyle', 'Ashwin', 'Rajesh', 'Yusuf', 'Tumelo', 'Neo', 'Karabo', 'Peter', 'Mark',
|
||
'Paul', 'Simon', 'Brian', 'Trevor', 'Wayne', 'Chris', 'Luyanda', 'Jaco', 'Stefan', 'Ernest',
|
||
'Vusi', 'Kabelo', 'Dumisani', 'Graham', 'Clive', 'Nathan', 'Ruben', 'Emmanuel', 'Isaac',
|
||
];
|
||
const LAST = [
|
||
'Dlamini', 'Nkosi', 'Mokoena', 'Ndlovu', 'Mthembu', 'Zulu', 'Nxumalo', 'Khumalo', 'Molefe',
|
||
'Mahlangu', 'Sithole', 'Mabaso', 'Radebe', 'Baloyi', 'Maseko', 'Ntuli', 'du Plessis',
|
||
'van der Merwe', 'Botha', 'Steyn', 'Fourie', 'Pretorius', 'Venter', 'Coetzee', 'van Niekerk',
|
||
'Lombard', 'Nel', 'Kruger', 'Smit', 'Jacobs', 'Smith', 'Williams', 'Brown', 'Johnson', 'Adams',
|
||
'Petersen', 'Daniels', 'Hendricks', 'Abrahams', 'Isaacs', 'Naidoo', 'Pillay', 'Govender',
|
||
'Reddy', 'Moodley', 'Patel', 'Campbell', 'Taylor', 'Thomas', 'Wilson', 'Clarke', 'Morgan',
|
||
];
|
||
const GIRL_FIRST = [
|
||
'Amahle', 'Lesedi', 'Isabella', 'Mia', 'Zoe', 'Ava', 'Leah', 'Kayla', 'Nandi', 'Olwethu', 'Jade',
|
||
'Chloe', 'Emma', 'Lisa', 'Anika', 'Karabo', 'Lilitha', 'Bella', 'Gabriella', 'Imaan',
|
||
];
|
||
const BOY_FIRST = [
|
||
'Liam', 'Noah', 'Ethan', 'Luke', 'Josh', 'Siyabonga', 'Lethabo', 'Caleb', 'Aiden', 'Jayden',
|
||
'Ruan', 'Tiaan', 'Bandile', 'Ofentse', 'Zaid', 'Reece', 'Keegan', 'Asanda', 'Jordan', 'Kian',
|
||
];
|
||
|
||
const emailSlug = (s) => s.toLowerCase().normalize('NFD').replace(/[^a-z]/g, '');
|
||
|
||
// ─── Row collection ──────────────────────────────────────────────────────────
|
||
// Everything is built in memory with pre-generated ids, then written with createMany
|
||
// in dependency order inside one transaction.
|
||
|
||
const rows = {};
|
||
const add = (model, row) => {
|
||
(rows[model] = rows[model] || []).push(row);
|
||
return row;
|
||
};
|
||
|
||
const INSERT_ORDER = [
|
||
'user', 'event', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
|
||
'eventForm', 'eventFormField', 'coupon', 'couponEvent', 'eventBundle', 'eventBundleItem',
|
||
'registration', 'registrationOption', 'registrationOptionTranche', 'attendee', 'payment', 'ticket',
|
||
'ticketUsage', 'couponRedemption', 'registrationBundle',
|
||
'formResponse', 'formAnswer', 'consentRequest', 'waitlistEntry', 'allocationSet',
|
||
'allocationGroup', 'allocationAssignment', 'eventCost', 'feedbackForm', 'feedbackField',
|
||
'feedbackInvite', 'feedbackResponse', 'feedbackAnswer', 'analyticsEvent', 'analyticsDaily',
|
||
];
|
||
|
||
// 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,
|
||
});
|
||
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) {
|
||
const { start } = spec;
|
||
const event = addEvent({
|
||
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;
|
||
}
|
||
|
||
// ─── 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);
|
||
|
||
for (const event of rows.event) {
|
||
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()`;
|
||
const toClear = tables.map(t => t.tablename).filter(t => !KEEP_TABLES.has(t));
|
||
if (toClear.length) {
|
||
await tx.$executeRawUnsafe(`TRUNCATE TABLE ${toClear.map(t => `"${t}"`).join(', ')} CASCADE`);
|
||
}
|
||
// Their password resets, 2FA codes and trusted devices go with them (onDelete: Cascade).
|
||
const { count } = await tx.user.deleteMany({ where: { id: { not: admin.id } } });
|
||
await tx.securityEvent.deleteMany({ where: { OR: [{ userId: null }, { userId: { not: admin.id } }] } });
|
||
return { tables: toClear.length, users: count };
|
||
}
|
||
|
||
function clearScheduledEmails() {
|
||
if (!fs.existsSync(SCHEDULED_EMAILS_PATH)) return;
|
||
fs.writeFileSync(SCHEDULED_EMAILS_PATH, JSON.stringify({ jobs: [] }, null, 2), 'utf-8');
|
||
}
|
||
|
||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||
|
||
function describeDatabase() {
|
||
try {
|
||
const u = new URL(process.env.DATABASE_URL);
|
||
return `${u.pathname.replace(/^\//, '')} on ${u.hostname}${u.port ? `:${u.port}` : ''}`;
|
||
} catch {
|
||
return 'the configured database';
|
||
}
|
||
}
|
||
|
||
async function confirm(admin) {
|
||
if (SKIP_PROMPT) return true;
|
||
console.log(`\nThis will DELETE all events, registrations, payments, tickets, coupons and users`);
|
||
console.log(`in ${describeDatabase()}, keeping only the site settings and ${admin.email}.`);
|
||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||
const answer = await new Promise(resolve => rl.question('Type "demo" to continue: ', resolve));
|
||
rl.close();
|
||
return answer.trim().toLowerCase() === 'demo';
|
||
}
|
||
|
||
async function main() {
|
||
admin = await prisma.user.findFirst({ where: { email: { equals: ADMIN_EMAIL, mode: 'insensitive' } } });
|
||
if (!admin) {
|
||
console.error(`Admin account ${ADMIN_EMAIL} was not found — nothing was changed.`);
|
||
console.error('Set DEMO_ADMIN_EMAIL to the account you want to keep.');
|
||
process.exitCode = 1;
|
||
return;
|
||
}
|
||
if (admin.role !== 'admin') console.warn(`[warn] ${admin.email} has role "${admin.role}", not admin.`);
|
||
|
||
if (!(await confirm(admin))) {
|
||
console.log('Cancelled — nothing was changed.');
|
||
return;
|
||
}
|
||
|
||
passwordHash = await bcrypt.hash(DEMO_PASSWORD, 10);
|
||
const people = buildPeople();
|
||
const events = [
|
||
buildMensBreakfast(people),
|
||
buildFamilyFunDay(people),
|
||
buildWomensConference(people),
|
||
buildYouthCamp(people),
|
||
buildCarols(people),
|
||
buildMarriageWeekend(people),
|
||
];
|
||
buildPastEvents(people);
|
||
buildAnalytics();
|
||
|
||
const cleared = await prisma.$transaction(async (tx) => {
|
||
const result = await clearData(tx);
|
||
for (const model of INSERT_ORDER) {
|
||
if (rows[model] && rows[model].length) await tx[model].createMany({ data: rows[model] });
|
||
}
|
||
return result;
|
||
}, { maxWait: 10 * 1000, timeout: 5 * 60 * 1000 });
|
||
clearScheduledEmails();
|
||
const 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 campers, ${count('feedbackResponse')} feedback responses, ${count('analyticsEvent') + count('analyticsDaily')} funnel rows.`);
|
||
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()]));
|