- /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>
1941 lines
103 KiB
JavaScript
1941 lines
103 KiB
JavaScript
/**
|
||
* 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()]));
|