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