Compare commits

...
Author SHA1 Message Date
joshua 78bcd7cc7c Merge branch 'feat/demo-seeds' 2026-10-28 14:02:56 +02:00
joshuaandClaude Opus 5.5 37731a4696 Four demo seeds; fix set-demo wiping the admin (1.48.2)
npm run demo:small | demo:events | demo:ministry | demo:multicampus
(demo:list describes them; set-demo -- --profile=<name> in backend/).
New builders: courses with sessions, attendance and certificates, a
pick-your-days conference, kids church with families, rooms and live
check-ins, volunteer rosters, a series with followers and a season
pass, and three campuses with campus-limited staff.

set-demo had failed since 1.43.0: TRUNCATE CASCADE on Campus also
emptied User (homeCampusId), deleting the admin it keeps. Tables that
User points at are now emptied with DELETE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:02:56 +02:00
joshua 4752516b15 Merge branch 'fix/privacy-notice-draft' 2026-10-28 13:15:57 +02:00
12 changed files with 678 additions and 39 deletions
+21
View File
@@ -7,6 +7,27 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.48.2] - 2026-10-28
### Added
- **Four demo seeds**, to match the church a demo is for — `npm run demo:small`, `demo:events`
(the original demo), `demo:ministry` and `demo:multicampus` from the repo root (`demo:list`
describes them; in `backend/` it's `npm run set-demo -- --profile=<name>`). New in the seeds:
multi-session courses with attendance and certificates, a "pick your days" conference, kids church
with families, children, rooms and check-ins (including some checked in right now), volunteer
rosters with QR check-ins and a cover request, an event series with followers and a season pass,
and three campuses with campus-limited staff. Campuses are switched on only for `multicampus`.
Series announcements and volunteer reminders are marked as sent, so the jobs don't email demo
people. See `backend/DEMO_DATA.md`.
### Fixed
- **`npm run set-demo` failed since 1.43.0** ("Event_createdById_fkey"): clearing the `Campus` table
with TRUNCATE … CASCADE also emptied `User` (it points at Campus for the home campus), deleting the
admin it keeps. Tables that `User` points at are now emptied with DELETE after the admin's links
to them are cleared.
## [1.48.1] - 2026-10-28
### Changed
+5
View File
@@ -12,6 +12,11 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 1.48.2
- No changes for churches in this version — it adds demo data for showing Cross Events to other
churches.
## 1.48.1
- **Privacy notice brought up to date** with volunteer rosters, event series, season passes and
+21 -8
View File
@@ -1,10 +1,19 @@
# Demo data
`npm run set-demo` replaces everything in the database with a realistic set of demo data,
for showing the platform to other churches. It stages six upcoming events with
registrations, payments, tickets and people, and ten completed events from the past year
with door sales, check-ins, feedback and cashups. That way every dashboard, report,
check-in and cashup screen has something to show.
The demo seeds replace everything in the database with a realistic set of demo data, for
showing the platform to other churches. Pick the one that looks like the church you're
showing it to:
| Command (repo root) | For | What's in it |
|---|---|---|
| `npm run demo:small` | A small church, one congregation | 3 upcoming events (a sold-out breakfast with a waitlist, a family day, free carols), 4 past events with check-ins and cashups. No campuses, kids check-in, rosters, courses or series. |
| `npm run demo:events` | Events and conferences (the original demo) | 6 upcoming events (coupons, variants, early birds, a youth camp with consent and cabins, bundles, donations) and 10 past events with every kind of cashup and funnel analytics. |
| `npm run demo:ministry` | One campus with weekly ministry | The Alpha Course running now (sessions, attendance, late joining, certificates), a finished Marriage Course with certificates, a 3-day conference sold by day, **Kids Church** with families, children, rooms and check-ins, **volunteer rosters**, a **Men's Breakfast series** with followers and a **season pass**, plus a family day, women's conference and carols. |
| `npm run demo:multicampus` | A church with several campuses | **Campuses** Central, North and South (switched on), events per campus and church-wide, home campuses, staff limited to a campus (Anri Botha → North, Daniel Pillay → South), the breakfast series moving between campuses, kids church and rosters at two campuses, and all the events-demo events spread over the campuses. |
`npm run demo:list` prints the same list. Kids church always has a session **today** with
children checked in right now, so the kids desk and room screens have something live to
show on any day.
> **This deletes real data.** Run it only against a database you're happy to wipe, such
> as a dedicated demo deployment. It is not something to run on a live church site.
@@ -14,11 +23,15 @@ check-in and cashup screen has something to show.
## Running it
```bash
cd backend
npm run set-demo # shows the database it will wipe and asks you to type "demo"
npm run set-demo -- --yes # no prompt (for scripts)
npm run demo:ministry # (repo root) shows the database it will wipe and asks you to type "demo"
npm run demo:ministry -- --yes # no prompt (for scripts)
# or in backend/:
npm run set-demo -- --profile=ministry --yes # without --profile it seeds "events"
```
The campuses setting is switched on for `multicampus` and off for the others (site settings
are otherwise kept). **Restart the backend afterwards** so it picks the setting up.
The script uses `DATABASE_URL` from `backend/.env`. To point it at another database
for one run, set it in the environment first. A value already in the environment wins
over `.env`:
+1 -1
View File
@@ -145,7 +145,7 @@ npm run prisma:generate
### Demo data
`npm run set-demo` wipes the database (keeping site settings and the admin account) and stages six upcoming demo events with registrations, payments and tickets. See [DEMO_DATA.md](DEMO_DATA.md).
`npm run set-demo -- --profile=<small|events|ministry|multicampus>` wipes the database (keeping site settings and the admin account) and stages demo data for that kind of church; from the repo root it's `npm run demo:<name>` (`npm run demo:list` describes them). See [DEMO_DATA.md](DEMO_DATA.md).
---
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.48.1",
"version": "1.48.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.48.1",
"version": "1.48.2",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.48.1",
"version": "1.48.2",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+598 -19
View File
@@ -2,9 +2,19 @@
* set-demo.js
*
* Replaces the database contents with demo data for showing the system to other
* churches: six upcoming events with registrations, payments, tickets, attendee forms,
* coupons, a waitlist, camp cabins, sponsorships, costs and funnel analytics, plus ten
* completed events from the past year with door sales, check-ins, feedback and cashups.
* 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
@@ -13,10 +23,14 @@
* 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)
* 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';
@@ -38,6 +52,8 @@ 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.
@@ -161,13 +177,16 @@ const add = (model, row) => {
};
const INSERT_ORDER = [
'user', 'event', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
'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).
@@ -314,6 +333,8 @@ function addRegistration(event, user, lines, { createdAt, pay = 'full', method =
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(),
@@ -823,8 +844,11 @@ const IMPROVEMENTS = [
* 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,
@@ -1189,6 +1213,524 @@ async function closePastEvents() {
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 };
@@ -1198,7 +1740,8 @@ function buildAnalytics() {
const { sessionsFrom, rollupRows, RAW_DAYS } = require('../src/utils/analytics');
const rawCutoff = addTime(NOW, -RAW_DAYS * DAY);
for (const event of rows.event) {
// 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();
@@ -1242,10 +1785,25 @@ function buildAnalytics() {
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));
// 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 } }] } });
@@ -1269,6 +1827,7 @@ function describeDatabase() {
}
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}.`);
@@ -1279,6 +1838,13 @@ async function confirm(admin) {
}
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.`);
@@ -1295,15 +1861,11 @@ async function main() {
passwordHash = await bcrypt.hash(DEMO_PASSWORD, 10);
const people = buildPeople();
const events = [
buildMensBreakfast(people),
buildFamilyFunDay(people),
buildWomensConference(people),
buildYouthCamp(people),
buildCarols(people),
buildMarriageWeekend(people),
];
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) => {
@@ -1311,6 +1873,13 @@ async function main() {
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();
@@ -1348,7 +1917,17 @@ async function main() {
].filter(Boolean).join(', ');
console.log(` ${event.startDate.toDateString().padEnd(16)} ${event.title.padEnd(34)} ${String(regs).padStart(3)} registrations, ${String(scans).padStart(3)} check-ins — ${CASHUP_LABELS[cashup.close]}${notes ? ` (${notes})` : ''}`);
}
console.log(`\nCreated ${count('user')} users, ${count('payment')} payments, ${count('ticket')} tickets, ${count('ticketUsage')} check-ins, ${count('attendee')} named campers, ${count('feedbackResponse')} feedback responses, ${count('analyticsEvent') + count('analyticsDaily')} funnel rows.`);
console.log(`\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}`);
+17 -1
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-28, after **1.48.1** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-10-28, after **1.48.2** (and Hub 1.5.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -14,6 +14,7 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.48.2 | Four demo seeds (`npm run demo:small|events|ministry|multicampus`, `demo:list`); fixes `set-demo`, broken since 1.43.0 by TRUNCATE CASCADE through `User.homeCampusId` |
| 1.48.1 | Built-in privacy notice updated (rosters, series, season passes, webhooks, acceptance); editor shows built-in changes to sites with their own version; activation asks for agreement; never-accepted users asked once after login |
| 1.48.0 | **Outbound webhooks** (§8): Site settings → Webhooks, 8 event types, signed deliveries with retries and a delivery log, operator register entry per endpoint. Migration `20261028160000_webhooks` |
| 1.47.4 | Fix: an update request for a version that has since been overtaken shows as such, with **Ask for … instead** (and the banner again). Hub 1.5.0 moves the open request on (`changed`); with an older Hub `requestUpdate` withdraws and asks again |
@@ -361,6 +362,21 @@ a test server (both done 2026-10-28).
- Frontend: `components/admin/WebhooksTab.tsx`. Tests: `tests/webhooks.test.js`, e2e
`webhooks.spec.ts` (runs a receiver on localhost:5199; e2e backend has `WEBHOOKS_ALLOW_PRIVATE`).
## Demo data (1.48.2)
- `backend/scripts/set-demo.js` builds everything in memory and writes it with `createMany` in one
transaction (so no webhooks fire). `PROFILES` says which of the six upcoming events, which past
events (by title) and which extras (`series`, `courses`, `conference`, `kids`, `volunteers`) each
profile gets, and whether campuses are on. **A new feature that's worth demoing** gets a builder
and an extra; add its tables to `INSERT_ORDER` (parents first).
- Keep jobs quiet: set anything a job would act on as already done (`seriesAnnouncedAt`,
`remindedAt`, certificates issued). Demo people use @example.com and no phone numbers.
- `clearData` truncates every table except settings and users, but deletes (not truncates) tables
that `User` points at — TRUNCATE CASCADE would empty `User` with them.
- Tried by seeding all four into a throwaway database and calling the API (events, series page and
report, season pass, kids board, roster, attendance registers, certificate check, cashups, campus
scoping).
## Known gaps / follow-ups
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.48.1",
"version": "1.48.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.48.1",
"version": "1.48.2",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.48.1",
"version": "1.48.2",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.48.1",
"version": "1.48.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.48.1",
"version": "1.48.2",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+7 -2
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.48.1",
"version": "1.48.2",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",
@@ -11,7 +11,12 @@
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"build": "cd frontend && npm run build && cd .. && cd backend && npm run prisma:generate",
"test:e2e": "playwright test",
"test": "npm --prefix backend test"
"test": "npm --prefix backend test",
"demo:list": "npm --prefix backend run set-demo -- --list",
"demo:small": "npm --prefix backend run set-demo -- --profile=small",
"demo:events": "npm --prefix backend run set-demo -- --profile=events",
"demo:ministry": "npm --prefix backend run set-demo -- --profile=ministry",
"demo:multicampus": "npm --prefix backend run set-demo -- --profile=multicampus"
},
"keywords": [],
"author": "",