Compare commits

...
Author SHA1 Message Date
joshuaandClaude Opus 5.5 1adf1d4870 Docs: monorepo move, crosscode deploy script, releases per product
ROADMAP-v3 decisions 21-24: Cross Events moves into crosscode-platform
(2.2.0); one crosscode script deploys Accounts plus the products named;
the Hub tracks releases per product with requirements; the Hub gets the
contracts as a committed file, not from the package registry. Cross Events
versions in the build order move up by one. HANDOVER updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:48:36 +02:00
joshuaandClaude Opus 5.5 e42e244edc Roadmap v3: four Website templates; SEO for every public site
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:04:13 +02:00
joshuaandClaude Opus 5.5 f85842d4b3 Roadmap v3: Hub protocol v2 (today's is v1), event-feed schema, enum rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:47:42 +02:00
joshuaandClaude Opus 5.5 78bf6df40c Roadmap v3: sign-in returns to where you came from; crosscode-site per phase
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:31:29 +02:00
joshuaandClaude Opus 5.5 47725a689d Roadmap v3: decisions, pricing and authenticator-app 2FA; handover updated
One install of each product per customer; House of Prayer booking
defaults; email and WhatsApp only (no push); Accounts hidden from
members. Pricing: Website R99 with 5 GB plus storage add-on, App from
R99, address once per church (own domain, crosscode.co.za address R10,
domain included R25). Authenticator app 2FA as Cross Events 2.2.0.
Handover now points at ROADMAP-v3; nothing left open in ROADMAP-v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:48:23 +02:00
joshuaandClaude Opus 5.5 a9584cdde5 Roadmap v3: Accounts, Church Website, Church App, Cross Code Hub
Plan for selling three products from one Hub: shared users through a
per-church Accounts service, event sync in any direction on one shared
format (crosscode.event/1), PM2 installs per product per church.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:14:24 +02:00
joshua 316a2b40a1 Merge branch 'fix/feedback-background-send' 2026-11-03 14:11:44 +02:00
joshuaandClaude Opus 5.5 73547a0f8c Feedback Send now and Remind run in the background (2.1.1)
- Send now and Remind answer straight away with who they're going to and
  queue the feedback-send task; the Feedback page refreshes to show progress.
- One-off background tasks (enqueueTask in src/jobs/index.js): pg-boss with
  retries, or in-process after the response when the queue is off.
- Reminders are claimed with the 24-hour rule, so two tasks at once never
  remind the same person twice.
- Tests: tests/tasks.test.js, tests/feedbackPage.test.ts, e2e/feedback.spec.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:11:44 +02:00
joshuaandClaude Opus 5.5 b5821e1c18 Handover: Hub 1.7.0 bills on the income bands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:42:46 +02:00
joshua 84cf37d31d Merge branch 'feat/feedback-page' 2026-11-03 13:03:05 +02:00
joshuaandClaude Opus 5.5 84a1a9084c Feedback page: results, responses, who hasn't answered, reminders (2.1.0)
- Supervisor -> Feedback (supervisors and admins, campus-scoped): every
  event's form with answered of asked, response rate, average rating and
  last reminder; per event the results per question (with the AI summary),
  each response, and on named forms who was asked and who has answered.
- Remind those who haven't answered (or one person on a named form): a
  fresh link, plus the form to anyone eligible who hasn't had it; nobody gets
  the form or a reminder twice within 24 hours. The 3-day job is unchanged.
- Anonymous forms show no names and no list of who has answered.
- FeedbackInvite.reminderCount (migration 20261103120000_feedback_reminders);
  access log action feedback_responses; help page; dashboard and Ctrl+K links.
- Roadmap: the AI helpers on the privacy pages are dropped; 2.1.0 is the
  feedback page.
- Tests: tests/feedbackPage.test.ts, e2e/feedback.spec.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:03:05 +02:00
joshua 5cf982e191 Merge branch 'feat/billing-income-bands' 2026-11-03 12:18:01 +02:00
joshuaandClaude Opus 5.5 ff4c0c76d3 Billing income bands up to R10m, 12-month event income in the heartbeat (2.0.2)
- Heartbeat income bands widened from "over R100 000" to 14 bands up to
  "over R10 000 000"; every licence-tier edge is a band edge (tested).
- New income.eventIncome12m: last 12 months' event income without donations,
  sponsorships or donation legs, which the licence tier is billed on.
- Fix: donation legs were counted twice in the heartbeat income bands.
- Protocol doc, roadmap pricing decisions, changelog, what's new, handover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:18:01 +02:00
joshua 4f9268707d Merge branch 'fix/ai-summary-all-reports' 2026-11-02 18:42:27 +02:00
joshuaandClaude Opus 5.5 89eab66145 Summarise this report on every report (2.0.1)
The eight reports that list people get the AI summary too, sent as
anonymised totals built on the screen (aiReportData.ts): no names,
contact details, notes or comments; consent and medical only as counts.
The server refuses personal columns on those reports and logs each
summary in the access log (ai_report; sensitive for consent and medical).

Roadmap §18: 2.0.1, then crosscode.co.za, then 2.1.0 (AI helpers on the
Consent log and Access log).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:42:27 +02:00
joshua 75d10f6078 Merge branch 'docs/marketing-site' 2026-11-02 18:01:25 +02:00
joshuaandClaude Opus 5.5 8805101929 Docs: marketing site decisions (crosscode.co.za)
crosscode.co.za as Cross Code's main site with a Cross Events page;
church website and app as "coming later"; repo joshua/crosscode-site;
own server; no prices yet; contact form to info@crosscode.co.za;
privacy notice and terms of use; no cookies. Content from Joshua later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:01:23 +02:00
joshua 992fad37d2 Merge branch 'release/2.0' (2.0.0) 2026-11-02 17:49:34 +02:00
46 changed files with 3609 additions and 96 deletions
+90
View File
@@ -7,6 +7,96 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [2.1.1] - 2026-11-03
### Fixed
- **Feedback "Send now" and "Remind" no longer make the page wait** for every email: they work out
who they're going to, answer straight away ("Sending the feedback form to 40 people in the
background", "Reminding 12 people. It carries on in the background.") and the emails go out in a
background task. The Feedback page refreshes itself a few times to show them land.
### Added
- **One-off background tasks** (`enqueueTask` in `src/jobs/index.js`, first one `feedback-send` →
`runFeedbackTask` in `utils/feedback.js`): queued in pg-boss (retried twice, kept across a
restart), or run in-process straight after the response when the queue isn't running. Tasks must
be safe to run twice: sending skips people already invited, and a reminder is claimed with the
24-hour rule, so two tasks at once never remind the same person twice.
- `POST /api/v2/events/{id}/feedback/remind` answers with `queued`; its counts are what's being
sent. `send-now`'s `invited` is how many it's sending to.
## [2.1.0] - 2026-11-03
### Added
- **Feedback page** (Supervisor → Feedback, `/dashboard/supervisor/feedback`; supervisors and
admins, campus-scoped supervisors see their campuses). Every event with a feedback form, newest
first: sent / scheduled / no send time, answered of asked with the response rate, the average
star rating and the last reminder. One event's page has the per-question **Results** (with the
AI "Summarise feedback" button), each **Response**, and on a named form **People** — who was
asked, who has answered and the reminders each has had. "Send now" before it goes out; "Edit
form" opens the event editor.
- **Remind those who haven't answered** (`POST /api/v2/events/{id}/feedback/remind`): a fresh link
to each, and the form to anyone eligible who hasn't had it yet (e.g. checked in after it went
out). On a named form one person can be reminded (`userIds`). Nobody gets the form or a reminder
twice within 24 hours (`REMIND_GAP_MS`). The automatic 3-day reminder is unchanged.
- `GET /api/v2/feedback` (every form) and `GET /api/v2/events/{id}/feedback/results` (one
event's results, responses and people) — `src/services/feedback.ts`, `src/v2/resources/feedback.ts`.
- `FeedbackInvite.reminderCount`, counting automatic and manual reminders (migration
`20261103120000_feedback_reminders`; existing reminded invites start at 1).
- Access log action **`feedback_responses`**: opening a named form's answers.
- Help page for the Feedback page; Feedback on the supervisor and admin dashboards and in Ctrl+K.
### Changed
- **Anonymous forms stay anonymous on the page:** no names on answers, and no list of who has or
hasn't answered (it would give answers away on a small event) — counts only, and no reminding
one person.
- A reminder now only goes to someone who still hasn't answered at the moment it's sent.
### Removed
- The planned 2.1.0 AI helpers on the privacy pages (Consent log, Access log) are dropped from
the roadmap.
## [2.0.2] - 2026-11-03
### Changed
- **Wider income bands in the Hub heartbeat**, from `R0–R1 000` up to `over R10 000 000` (was
`over R100 000`): 14 bands, edges at R1 000, R5 000, R20 000, R50 000, R100 000, R150 000,
R250 000, R400 000, R700 000, R1m, R2.5m, R5m and R10m (`BAND_TOPS` in `src/hub/report.js`).
Every Cross Code licence-tier edge is a band edge; a test checks it.
### Added
- **`income.eventIncome12m`** in the heartbeat: the band of the last 12 months' event income
(payments less refunds, without donations, sponsorships or donation legs), which the licence
tier is billed on. Shown on the Hub from Hub 1.6.2.
### Fixed
- The heartbeat's income bands counted a donation twice once it was applied to a registration
(the leg payment); they now leave legs out, like every report.
## [2.0.1] - 2026-11-02
### Changed
- **Summarise this report on every report.** The eight reports that list people (Payments between
dates, Attendees per event, Revenue detailed, Master orders, Cashup audit trail, Parental consent
status, Feedback summary, Medical & emergency list) now have the button too. They're sent as
**anonymised totals**, worked out on the screen (`components/reports/aiReportData.ts`): per event
and method, status or option, with names, emails, who recorded or did what, notes and comments
left out; consent and medical details only as counts (given / pending / declined, with
allergies, on medication, …); feedback as the per-question results without the comments.
`POST /api/v2/ai/summarise-report` accepts every report key; for a people report it refuses
(403 `AI_REPORT_HAS_PEOPLE`) any column that would carry a person (name, email, attendee,
parent, emergency contact, notes, …), so a new column fails safe. Each people-report summary is
recorded in the access log (`ai_report`; sensitive for consent and medical). The prompt tells
the AI the totals are anonymised on purpose.
## [2.0.0] - 2026-11-02
### Added
+7
View File
@@ -261,6 +261,13 @@ Feedback → Attendance**.
3 days later to anyone who hasn't answered.
- Answers can be anonymous or named. "Send now" is available, and the questions
lock once people have answered.
- **Feedback page** (Supervisor → Feedback, supervisors and admins): every event's form
with answered of asked, response rate, average rating and last reminder; per event the
results per question (and the AI summary), each response, and on named forms who was
asked and who has answered. **Remind** sends everyone who hasn't answered a fresh link
(and the form to anyone eligible who hasn't had it), or one person on a named form;
nobody gets the form or a reminder twice within 24 hours. Anonymous forms show no names
and no list of who has answered. Opening a named form's answers goes in the access log.
- **Sponsor a spot** — per-event setting. A "Sponsor a spot" button on the event page
lets anyone, including guests, pay for one or more spots (at the main ticket price)
or any amount through Yoco. They can give anonymously or add a name and a message.
+35
View File
@@ -12,6 +12,41 @@ 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".
-->
## 2.1.1
- **Sending feedback forms and reminders no longer makes you wait.** "Send now" and "Remind" on the
Feedback page now answer straight away and the emails go out in the background, so you can carry
on working. The page updates itself as they go out.
## 2.1.0
- **A Feedback page for supervisors and admins** (Dashboard → Feedback). See every event's feedback
in one place: how many people answered, the average star rating, each question's results and
every response. On a named form you can also see who was asked and who hasn't answered yet.
- **Remind the people who haven't answered.** One button sends everyone who hasn't given feedback
a fresh link by email or WhatsApp, or you can remind one person at a time on a named form.
Nobody is reminded twice within 24 hours. The automatic reminder after 3 days still goes out.
- **Anonymous forms stay anonymous:** the page never shows who gave which answer, or who has and
hasn't answered.
- Opening the answers to a named form is recorded in the access log.
## 2.0.2
- **Licence information sent to Cross Code.** Your site now tells Cross Code which range your event
income falls in over the last 12 months (for example "R150 001–R250 000"), which is what your
licence tier is based on. Donations and sponsorships aren't counted, and the exact amount never
leaves your site. The ranges now go up to over R10 million.
- Fixed: a donation that was applied to a registration was counted twice in the income range sent
to Cross Code.
## 2.0.1
- **Summarise this report on every report.** Reports that list people — payments, attendees,
revenue detailed, master orders, the cashup audit trail, parental consent, feedback and the
medical list — now have the AI summary button too. Only anonymised totals are sent (for example
"40 attendees, 6 with allergies"): no names, contact details, notes, comments or anyone's medical
details. Each of these summaries is recorded in the access log.
## 2.0.0
- **AI helpers.** Where you already work, a button can now do the first draft for you: describe
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "2.0.0",
"version": "2.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "2.0.0",
"version": "2.1.1",
"hasInstallScript": true,
"dependencies": {
"@cj-tech-master/excelts": "^10.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "2.0.0",
"version": "2.1.1",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,3 @@
-- 2.1.0: how many times each person has been reminded about a feedback form (automatic and by hand).
ALTER TABLE "FeedbackInvite" ADD COLUMN "reminderCount" INTEGER NOT NULL DEFAULT 0;
UPDATE "FeedbackInvite" SET "reminderCount" = 1 WHERE "remindedAt" IS NOT NULL;
+2 -1
View File
@@ -1450,7 +1450,8 @@ model FeedbackInvite {
userId String
tokenHash String @unique
sentAt DateTime @default(now())
remindedAt DateTime?
remindedAt DateTime? // the last reminder (automatic or by hand)
reminderCount Int @default(0)
respondedAt DateTime?
form FeedbackForm @relation(fields: [formId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+22 -3
View File
@@ -164,10 +164,26 @@ export async function draftEvent(userId: string, notes: string, now = new Date()
// ─── 2. Summarise a report ────────────────────────────────────────────────────
/** Reports with no people in them: only these can be summarised (keys from ReportCatalog). */
/** Reports with no people in them: sent as they are on screen (keys from ReportCatalog). */
export const REPORTS_WITHOUT_PEOPLE = ['seriesComparison', 'regTypes', 'usage', 'revenue', 'regStatus', 'donations', 'funnel', 'discounts', 'cashup', 'financeReport', 'profitReport'] as const;
/**
* Reports that list people: the screen sends these as anonymised totals instead (no names, contact
* details, notes or comments; medical and consent only as counts). The server checks the columns too.
*/
export const REPORTS_WITH_PEOPLE = ['payments', 'attendees', 'revenueDetailed', 'masterOrders', 'cashupAudit', 'consentStatus', 'feedbackSummary', 'medicalList'] as const;
export const REPORT_KEYS = [...REPORTS_WITHOUT_PEOPLE, ...REPORTS_WITH_PEOPLE] as const;
/** Children's or special information: an AI summary of one is a sensitive access-log entry. */
export const SENSITIVE_REPORTS: readonly string[] = ['consentStatus', 'medicalList'];
/** Column headings that would carry a person: refused on a report with people in it. */
const PERSONAL_COLUMN = /^(user|name|e-?mail|phone|cell|attendee|donor)$|^(parent|guardian|emergency|performed by|recorded by|registration ?id|date of birth|dob|medical$|notes?$|comments?$|signed)/i;
const MAX_ROWS = 400;
/** The columns of a people report that would carry a person (none means it's safe to send). */
export function personalColumns(r: { reportKey: string; columns: string[] }): string[] {
if (!(REPORTS_WITH_PEOPLE as readonly string[]).includes(r.reportKey)) return [];
return r.columns.filter(c => PERSONAL_COLUMN.test(c.trim()));
}
export type ReportInput = {
reportKey: string;
title: string;
@@ -187,6 +203,7 @@ const REPORT_SYSTEM = (now: Date) => [
'- A one-sentence headline, then 3–5 insights: trends, changes against earlier periods, outliers, and anything that needs action.',
'- Use the report\'s numbers; work out percentages and differences where they help, and get them right. Never invent figures or causes; when you suggest a cause, say it is only a possibility.',
'- Mind the dates against today: an event that has already happened can\'t still be promoted or paid for before it starts.',
'- Reports about people come as anonymised totals: names, contact details and notes are left out on purpose, so don\'t ask for them or guess who is who.',
'- Plain, friendly South African English. Amounts in rand like R18 900.',
].join('\n');
@@ -202,8 +219,10 @@ export function reportText(r: ReportInput): string {
}
export async function summariseReport(userId: string, report: ReportInput, now = new Date()): Promise<{ summary: ReportSummary; status: AiStatus }> {
if (!(REPORTS_WITHOUT_PEOPLE as readonly string[]).includes(report.reportKey)) {
throw Object.assign(new ApiError('FORBIDDEN', 'This report lists people, so it can\'t be sent to the AI. Summaries work on totals-only reports.'), { reason: 'AI_REPORT_HAS_PEOPLE' });
if (!(REPORT_KEYS as readonly string[]).includes(report.reportKey)) throw new ApiError('VALIDATION_FAILED', `There is no report called ${report.reportKey}.`);
const personal = personalColumns(report);
if (personal.length) {
throw Object.assign(new ApiError('FORBIDDEN', `This report still has people in it (${personal.join(', ')}), so it can't be sent to the AI. Only anonymised totals are summarised.`), { reason: 'AI_REPORT_HAS_PEOPLE' });
}
const p = protect(reportText(report));
const { output, status } = await runHelper('summarise_report', userId, { system: REPORT_SYSTEM(now), input: p.text, schema: REPORT_SCHEMA, maxTokens: 2000, effort: 'low' });
@@ -1,6 +1,6 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { ANSWERABLE, feedbackError, cleanFields, cleanAnswer, summarise, sendFeedbackInvites, findInviteByToken } = require('../utils/feedback');
const { ANSWERABLE, feedbackError, cleanFields, cleanAnswer, summarise, feedbackRecipients, findInviteByToken } = require('../utils/feedback');
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const shape = (fields) => JSON.stringify(fields.map(f => [f.type, f.label, f.isRequired, f.options || null, f.helpText || null]));
@@ -86,9 +86,12 @@ const sendNow = async (req, res) => {
const form = await prisma.feedbackForm.findUnique({ where: { eventId: req.params.eventId } });
if (!form) throw feedbackError('This event has no feedback form.', 404);
if (!form.sentAt) await prisma.feedbackForm.update({ where: { id: form.id }, data: { sentAt: new Date(), sendAt: form.sendAt || new Date() } });
// Also invites anyone who checked in after the first send.
const invited = await sendFeedbackInvites(form.id);
res.json({ invited, message: invited ? `Feedback form sent to ${invited} ${invited === 1 ? 'person' : 'people'}.` : 'Everyone has already been sent the form.' });
// Also invites anyone who checked in after the first send. The emails go out in the
// background (2.1.1), so this answers straight away with how many it's going to.
const already = new Set((await prisma.feedbackInvite.findMany({ where: { formId: form.id }, select: { userId: true } })).map(i => i.userId));
const invited = (await feedbackRecipients(form)).filter(u => !already.has(u.id)).length;
if (invited) await require('../jobs').enqueueTask('feedback-send', { formId: form.id, kind: 'send' });
res.json({ invited, message: invited ? `Sending the feedback form to ${invited} ${invited === 1 ? 'person' : 'people'} in the background.` : 'Everyone has already been sent the form.' });
} catch (error) {
fail(res, error);
}
+28 -13
View File
@@ -13,21 +13,29 @@ const DAY = 24 * 60 * 60 * 1000;
const APP_VERSION = require('../../package.json').version;
const STARTED_AT = Date.now();
const INCOME_BANDS = [
[1000, 'R0–R1 000'],
[5000, 'R1 001–R5 000'],
[20000, 'R5 001–R20 000'],
[50000, 'R20 001–R50 000'],
[100000, 'R50 001–R100 000'],
];
// The tops of the income bands, in rand (docs/hub-protocol.md §3). Cross Code's licence tiers
// are billed on the 12-month event-income band, so every tier edge (R50 000, R150 000, R400 000,
// R1m, R2.5m, R5m, R10m) is also a band edge: change them together.
const BAND_TOPS = [1000, 5000, 20000, 50000, 100000, 150000, 250000, 400000, 700000, 1000000, 2500000, 5000000, 10000000];
/** The range an amount falls in: "R5 001–R20 000", or "over R100 000". */
const randText = n => `R${String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ' ')}`;
const INCOME_BANDS = BAND_TOPS.map((top, i) => [top, `${randText(i ? BAND_TOPS[i - 1] + 1 : 0)}–${randText(top)}`]);
const TOP_BAND = `over ${randText(BAND_TOPS[BAND_TOPS.length - 1])}`;
/** The range an amount falls in: "R5 001–R20 000", or "over R10 000 000". */
function incomeBand(amount) {
const rand = Math.max(0, Math.round(Number(amount) || 0));
for (const [max, label] of INCOME_BANDS) if (rand <= max) return label;
return 'over R100 000';
return TOP_BAND;
}
// Applying an earlier donation to a registration adds a "leg" payment that isn't new money
// (cashupUtils.isDonationLeg), so every money total leaves it out.
const NOT_DONATION_LEG = { NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] } };
// Event income, what the licence tier is billed on: payments less refunds, without donations or
// sponsorships (both isDonation) and without the legs that apply a donation to a registration.
const EVENT_INCOME = { isDonation: false, ...NOT_DONATION_LEG };
async function postgresVersion() {
try {
const rows = await prisma.$queryRawUnsafe('SHOW server_version');
@@ -98,8 +106,9 @@ async function health() {
async function stats(now = new Date()) {
const { campusesEnabled } = require('../utils/campuses');
const since = new Date(now.getTime() - 30 * DAY);
const yearAgo = new Date(now.getTime() - 365 * DAY);
const realUser = { isActive: true, NOT: [{ email: { endsWith: '@deleted.invalid' } }, { email: { endsWith: '@guest.local' } }] };
const [users, activeEvents, upcomingEvents, totalEvents, regs30, regsTotal, tickets30, ticketsTotal, checkIns30, income30, incomeAll, providers] = await Promise.all([
const [users, activeEvents, upcomingEvents, totalEvents, regs30, regsTotal, tickets30, ticketsTotal, checkIns30, income30, incomeAll, eventIncome12m, providers] = await Promise.all([
prisma.user.count({ where: realUser }),
prisma.event.count({ where: { isActive: true, startDate: { lte: now }, endDate: { gte: now } } }),
prisma.event.count({ where: { isActive: true, startDate: { gt: now } } }),
@@ -109,8 +118,9 @@ async function stats(now = new Date()) {
prisma.ticket.count({ where: { createdAt: { gte: since } } }),
prisma.ticket.count(),
prisma.ticketUsage.count({ where: { scannedAt: { gte: since } } }),
prisma.payment.aggregate({ where: { createdAt: { gte: since } }, _sum: { amount: true } }),
prisma.payment.aggregate({ _sum: { amount: true } }),
prisma.payment.aggregate({ where: { createdAt: { gte: since }, ...NOT_DONATION_LEG }, _sum: { amount: true } }),
prisma.payment.aggregate({ where: NOT_DONATION_LEG, _sum: { amount: true } }),
prisma.payment.aggregate({ where: { createdAt: { gte: yearAgo }, ...EVENT_INCOME }, _sum: { amount: true } }),
require('../payments').availableProviders().then(list => list.map(p => p.id)).catch(() => []),
]);
const campusesOn = await campusesEnabled().catch(() => false);
@@ -126,7 +136,12 @@ async function stats(now = new Date()) {
paymentProviders: providers,
campuses: campusesOn,
campusCount,
income: { last30d: incomeBand(income30._sum.amount), allTime: incomeBand(incomeAll._sum.amount) },
income: {
last30d: incomeBand(income30._sum.amount),
allTime: incomeBand(incomeAll._sum.amount),
// 2.0.2+: event income over the last 12 months, what the licence tier is billed on.
eventIncome12m: incomeBand(eventIncome12m._sum.amount),
},
};
}
+50 -1
View File
@@ -139,6 +139,38 @@ function isNoop(value) {
return false;
}
/**
* One-off background tasks (2.1.1): work a request starts but shouldn't wait for, like sending
* a feedback form to everyone. Queued in pg-boss (retried if it fails, kept across a restart);
* without the queue they run in this process straight after the response. A task must be safe
* to run twice.
*/
const TASKS = {
'feedback-send': (data) => require('../utils/feedback').runFeedbackTask(data),
};
async function runTask(name, data) {
const started = Date.now();
const result = await TASKS[name](data || {});
if (!isNoop(result)) console.log(`[jobs] task ${name} done in ${Date.now() - started} ms: ${summarize(result)}`);
return result ?? null;
}
/** Starts a task in the background. Resolves once it's queued (or started), not when it's done. */
async function enqueueTask(name, data) {
if (!TASKS[name]) throw new Error(`Unknown task ${name}`);
if (mode === 'queue' && boss) {
try {
await boss.send(name, data || {}, { retryLimit: 2, retryDelay: 30, expireInSeconds: 60 * 60 });
return { queued: true };
} catch (e) {
console.warn(`[jobs] could not queue ${name}, running it here:`, e?.message || e);
}
}
setImmediate(() => runTask(name, data).catch(e => console.warn(`[jobs] task ${name} failed:`, e?.message || e)));
return { queued: false };
}
async function runJob(job) {
const started = Date.now();
const result = await job.run();
@@ -218,6 +250,23 @@ async function startJobs() {
await boss.unschedule(job.name).catch(() => {});
}
}
// One-off tasks: any number can wait and run (unlike the stately scheduled jobs).
for (const name of Object.keys(TASKS)) {
if (!existing.has(name)) await boss.createQueue(name, { policy: 'standard', retryLimit: 2, retryDelay: 30, expireInSeconds: 60 * 60 });
await boss.work(name, async (batch) => {
const list = Array.isArray(batch) ? batch : [batch];
const out = [];
for (const job of list) {
try {
out.push(await runTask(name, job?.data));
} catch (e) {
console.warn(`[jobs] task ${name} failed:`, e?.message || e);
throw e; // recorded as failed, and retried
}
}
return out.length === 1 ? out[0] : out;
});
}
mode = 'queue';
const off = JOBS.filter(j => !isEnabled(j)).map(j => j.name);
console.log(`[jobs] Job queue started: ${enabled.map(j => j.name).join(', ')}${off.length ? ` (off: ${off.join(', ')})` : ''}.`);
@@ -298,4 +347,4 @@ async function jobStatus(prisma) {
};
}
module.exports = { JOBS, TIMEZONE, isEnabled, cronFor, nextRun, isNoop, startJobs, stopJobs, runNow, jobStatus };
module.exports = { JOBS, TASKS, TIMEZONE, isEnabled, cronFor, nextRun, isNoop, startJobs, stopJobs, runNow, jobStatus, enqueueTask };
+182
View File
@@ -0,0 +1,182 @@
/**
* The Feedback page (2.1.0): what supervisors and admins see of post-event feedback, and the
* "Remind" button. Forms, sending and the automatic reminder are in utils/feedback.js.
*
* Anonymous forms stay anonymous here: no names on answers, and no list of who has or hasn't
* answered (on a small event that would give the answers away) — only counts.
*/
import { prisma as typedPrisma } from '../v2/legacy';
import { fail } from './errors';
/* eslint-disable @typescript-eslint/no-explicit-any -- the records are Prisma's with includes */
const prisma = typedPrisma as any;
const fb = require('../utils/feedback') as {
ANSWERABLE: string[];
REMINDER_AFTER_MS: number;
REMIND_GAP_MS: number;
summarise(fields: any[], answers: any[]): any[];
feedbackRecipients(form: any): Promise<{ id: string }[]>;
sendFeedbackInvites(formId: string): Promise<number>;
sendFeedbackReminders(formId: string, opts?: { userIds?: string[] | null; minGapMs?: number | null; now?: Date }): Promise<number>;
};
const jobs = require('../jobs') as { enqueueTask(name: string, data: object): Promise<{ queued: boolean }> };
export type FormStatus = 'sent' | 'scheduled' | 'not_scheduled';
const statusOf = (form: { sentAt: Date | null; sendAt: Date | null }): FormStatus => (form.sentAt ? 'sent' : form.sendAt ? 'scheduled' : 'not_scheduled');
const rate = (answered: number, invited: number) => (invited ? Math.round((answered / invited) * 100) : null);
const round1 = (n: number) => Math.round(n * 10) / 10;
/** Average of every star rating on a form (all rating questions together), or null. Pure. */
export function averageRating(values: string[]): number | null {
const nums = values.map(Number).filter(n => Number.isInteger(n) && n >= 1 && n <= 5);
return nums.length ? round1(nums.reduce((s, n) => s + n, 0) / nums.length) : null;
}
/** Every event with a feedback form, newest event first. `where` is the campus scope (listWhere). */
export async function feedbackOverview(where: object = {}) {
const forms = await prisma.feedbackForm.findMany({
where,
include: {
event: { select: { id: true, title: true, startDate: true, endDate: true } },
_count: { select: { invites: true, responses: true } },
},
});
if (!forms.length) return [];
const ids = forms.map((f: any) => f.id);
const [ratings, reminders] = await Promise.all([
prisma.feedbackAnswer.findMany({ where: { field: { formId: { in: ids }, type: 'rating' } }, select: { value: true, field: { select: { formId: true } } } }),
prisma.feedbackInvite.groupBy({ by: ['formId'], where: { formId: { in: ids } }, _max: { remindedAt: true } }),
]);
const ratingsBy = new Map<string, string[]>();
for (const r of ratings) ratingsBy.set(r.field.formId, [...(ratingsBy.get(r.field.formId) || []), r.value]);
const remindedBy = new Map(reminders.map((r: any) => [r.formId, r._max.remindedAt]));
return forms
.map((f: any) => ({
event: f.event,
status: statusOf(f),
sendAt: f.sendAt,
sentAt: f.sentAt,
anonymous: f.anonymous,
invited: f._count.invites,
answered: f._count.responses,
responseRate: rate(f._count.responses, f._count.invites),
averageRating: averageRating(ratingsBy.get(f.id) || []),
lastReminderAt: remindedBy.get(f.id) || null,
}))
.sort((a: any, b: any) => new Date(b.event.startDate).getTime() - new Date(a.event.startDate).getTime());
}
async function formFor(eventId: string, include: object = {}) {
const form = await prisma.feedbackForm.findUnique({ where: { eventId }, include: { event: { select: { id: true, title: true, startDate: true, endDate: true } }, ...include } });
if (!form) fail(404, 'This event has no feedback form.', 'NO_FEEDBACK_FORM');
return form;
}
/** People who would get the form but haven't been sent it (not sent yet, or checked in later). */
async function waitingCount(form: any, invitedIds: Set<string>) {
return (await fb.feedbackRecipients(form)).filter(u => !invitedIds.has(u.id)).length;
}
/** One event's feedback: results per question, each response, and (named forms) who was asked. */
export async function eventFeedback(eventId: string, now = new Date()) {
const form = await formFor(eventId, {
fields: { orderBy: { order: 'asc' } },
responses: { orderBy: { createdAt: 'desc' }, include: { answers: true, user: { select: { name: true } } } },
invites: { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true } } } },
});
const named = !form.anonymous;
const fields = form.fields.filter((f: any) => fb.ANSWERABLE.includes(f.type));
const answers = form.responses.flatMap((r: any) => r.answers.map((a: any) => ({ fieldId: a.fieldId, value: a.value, ...(named ? { name: r.user?.name || '' } : {}) })));
const invited = form.invites.length;
const answered = form.responses.length;
const gapFrom = now.getTime() - fb.REMIND_GAP_MS;
const notAnswered = form.invites.filter((i: any) => !i.respondedAt);
// Not sent the form or a reminder in the last 24 hours (as remindFeedback).
const remindable = (i: any) => !i.respondedAt && new Date(i.sentAt).getTime() < gapFrom && (!i.remindedAt || new Date(i.remindedAt).getTime() < gapFrom);
const canRemind = notAnswered.filter(remindable).length;
const waiting = await waitingCount(form, new Set(form.invites.map((i: any) => i.userId)));
const autoReminderAt = form.sentAt ? new Date(new Date(form.sentAt).getTime() + fb.REMINDER_AFTER_MS) : null;
return {
event: form.event,
form: {
status: statusOf(form),
sendAt: form.sendAt,
sentAt: form.sentAt,
audience: (form.audience === 'all' ? 'all' : 'checked_in') as 'all' | 'checked_in',
anonymous: form.anonymous,
intro: form.intro,
autoReminderAt,
autoReminderSent: !!form.reminderSentAt,
},
counts: {
invited,
answered,
notAnswered: notAnswered.length,
responseRate: rate(answered, invited),
canRemind,
remindedRecently: notAnswered.length - canRemind,
waiting,
},
averageRating: averageRating(answers.filter((a: any) => fields.some((f: any) => f.id === a.fieldId && f.type === 'rating')).map((a: any) => a.value)),
questions: fb.summarise(fields, answers),
fields: fields.map((f: any) => ({ id: f.id, type: f.type, label: f.label })),
responses: form.responses.map((r: any) => ({
id: r.id,
createdAt: r.createdAt,
name: named ? r.user?.name || 'Someone who has since been removed' : null,
answers: Object.fromEntries(r.answers.map((a: any) => [a.fieldId, a.value])),
})),
people: named
? form.invites
.map((i: any) => ({
userId: i.userId,
name: i.user?.name || '',
email: i.user?.email && !/@(guest\.local|deleted\.invalid)$/.test(i.user.email) ? i.user.email : null,
phoneNumber: i.user?.phoneNumber || null,
sentAt: i.sentAt,
remindedAt: i.remindedAt,
reminderCount: i.reminderCount,
respondedAt: i.respondedAt,
canRemind: remindable(i),
}))
.sort((a: any, b: any) => Number(!!a.respondedAt) - Number(!!b.respondedAt) || a.name.localeCompare(b.name))
: null,
};
}
/**
* "Remind": a fresh link to everyone who hasn't answered (or the people in `userIds`, named
* forms only), skipping anyone sent the form or a reminder in the last 24 hours. Without `userIds` it also sends
* the form to people who became eligible after it went out (e.g. checked in later).
*
* 2.1.1: the emails go out in the background (jobs task `feedback-send`); this works out who
* they'll go to and answers straight away. The counts are what's being sent, not what was.
*/
export async function remindFeedback(eventId: string, { userIds }: { userIds?: string[] | null } = {}, now = new Date()) {
const form = await formFor(eventId);
if (!form.sentAt) fail(409, 'The feedback form hasn\'t gone out yet. Use "Send now" to send it.', 'FEEDBACK_NOT_SENT');
const only = userIds && userIds.length ? [...new Set(userIds)] : null;
if (only && form.anonymous) fail(400, 'This form is anonymous, so people can\'t be reminded one by one. Remind everyone who hasn\'t answered instead.', 'FEEDBACK_ANONYMOUS');
const since = new Date(now.getTime() - fb.REMIND_GAP_MS);
const mine = { formId: form.id, respondedAt: null, ...(only ? { userId: { in: only } } : {}) };
const [notAnswered, reminded, invitedIds] = await Promise.all([
prisma.feedbackInvite.count({ where: mine }),
// The same rule as sendFeedbackReminders with minGapMs.
prisma.feedbackInvite.count({ where: { ...mine, sentAt: { lt: since }, OR: [{ remindedAt: null }, { remindedAt: { lt: since } }] } }),
only ? Promise.resolve(null) : prisma.feedbackInvite.findMany({ where: { formId: form.id }, select: { userId: true } }),
]);
const newlyInvited = invitedIds ? await waitingCount(form, new Set(invitedIds.map((i: any) => i.userId))) : 0;
const skipped = Math.max(0, notAnswered - reminded);
if (reminded || newlyInvited) await jobs.enqueueTask('feedback-send', { formId: form.id, kind: 'remind', userIds: only });
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
const parts: string[] = [];
if (reminded) parts.push(`Reminding ${plural(reminded, 'person', 'people')}`);
if (newlyInvited) parts.push(`sending the form to ${plural(newlyInvited, 'person', 'people')} for the first time`);
let message = parts.length ? `${parts.join(' and ')}. It carries on in the background.` : 'Nobody to remind.';
if (!parts.length && skipped) message = 'Everyone who hasn\'t answered was sent the form or a reminder in the last 24 hours. Try again tomorrow.';
else if (parts.length && skipped) message += ` ${plural(skipped, 'person was', 'people were')} sent the form or a reminder in the last 24 hours, so not again yet.`;
message = message.charAt(0).toUpperCase() + message.slice(1);
return { reminded, newlyInvited, skipped, queued: !!(reminded || newlyInvited), message, title: form.event.title };
}
+2
View File
@@ -19,6 +19,8 @@ const ACCESS_ACTIONS = {
kids_offline: 'Kids check-in list downloaded for offline use',
volunteer_roster: 'Volunteer roster opened',
ai_feedback: 'Feedback comments summarised by the AI helper',
feedback_responses: 'Feedback answers viewed (named form)',
ai_report: 'Report summarised by the AI helper (anonymised totals)',
};
/**
+41 -7
View File
@@ -183,19 +183,53 @@ async function sendFeedbackInvites(formId) {
return people.length;
}
/** The single reminder: a fresh link for everyone who hasn't answered. */
async function sendFeedbackReminders(formId) {
/** A manual reminder isn't sent to the same person again within this long. */
const REMIND_GAP_MS = 24 * 3600e3;
/**
* A fresh link for everyone who hasn't answered. With no options it's the automatic reminder:
* only people never reminded. `userIds` limits it to those people; `minGapMs` skips anyone
* reminded more recently than that (the manual "Remind" button). Returns how many were sent.
*/
async function sendFeedbackReminders(formId, { userIds = null, minGapMs = null, now = new Date() } = {}) {
const form = await prisma.feedbackForm.findUnique({ where: { id: formId }, include: { event: true } });
if (!form) return 0;
// Manual: not if they were sent the form, or reminded, within minGapMs.
const since = minGapMs == null ? null : new Date(now.getTime() - minGapMs);
const recent = since == null ? { remindedAt: null } : { sentAt: { lt: since }, OR: [{ remindedAt: null }, { remindedAt: { lt: since } }] };
const invites = await prisma.feedbackInvite.findMany({
where: { formId, respondedAt: null, remindedAt: null },
where: { formId, respondedAt: null, ...recent, ...(userIds ? { userId: { in: userIds } } : {}) },
include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } },
});
let sent = 0;
for (const inv of invites) {
const token = newToken();
await prisma.feedbackInvite.update({ where: { id: inv.id }, data: { tokenHash: hashToken(token), remindedAt: new Date() } });
// Only if they still haven't answered (they may have while this ran).
const claimed = await prisma.feedbackInvite.updateMany({
// The same rule again, so two tasks running at once never remind the same person twice.
where: { id: inv.id, respondedAt: null, ...recent },
data: { tokenHash: hashToken(token), remindedAt: now, reminderCount: { increment: 1 } },
});
if (claimed.count !== 1) continue;
sent += 1;
await sendFeedbackMessage(inv.user, form.event, token, { reminder: true }).catch(e => console.warn('[feedback] reminder failed:', e?.message || e));
}
return invites.length;
return sent;
}
/**
* The background task behind "Send now" and "Remind" (jobs `feedback-send`), so the page doesn't
* wait for every email. Safe to run twice: people already invited are skipped, and nobody is
* reminded twice within REMIND_GAP_MS.
* { formId, kind: 'send' } the form to everyone eligible not yet invited
* { formId, kind: 'remind', userIds? } (everyone: also invite the newly eligible) then remind
*/
async function runFeedbackTask({ formId, kind, userIds = null } = {}) {
if (!formId) return { invited: 0, reminded: 0 };
const only = Array.isArray(userIds) && userIds.length ? userIds : null;
const invited = kind === 'send' || (kind === 'remind' && !only) ? await sendFeedbackInvites(formId) : 0;
const reminded = kind === 'remind' ? await sendFeedbackReminders(formId, { userIds: only, minGapMs: REMIND_GAP_MS }) : 0;
return { invited, reminded };
}
/** Background job: send forms whose time has come, and reminders 3 days after. */
@@ -224,7 +258,7 @@ async function findInviteByToken(token) {
}
module.exports = {
ANSWERABLE, ALL_TYPES, REMINDER_AFTER_MS, feedbackError, hashToken, feedbackLink,
ANSWERABLE, ALL_TYPES, REMINDER_AFTER_MS, REMIND_GAP_MS, feedbackError, hashToken, feedbackLink,
cleanFields, cleanAnswer, summarise,
feedbackRecipients, sendFeedbackInvites, sendFeedbackReminders, runFeedbackJob, findInviteByToken,
feedbackRecipients, sendFeedbackInvites, sendFeedbackReminders, runFeedbackTask, runFeedbackJob, findInviteByToken,
};
+1
View File
@@ -23,6 +23,7 @@ import './resources/adminPrivacy';
import './resources/checkin';
import './resources/auth';
import './resources/ai';
import './resources/feedback';
const pkg = require('../../package.json') as { version: string };
+10 -5
View File
@@ -5,12 +5,14 @@ import { id } from '../schemas';
import { notFound } from '../errors';
import { prisma } from '../legacy';
import { aiStatus, getAiSettings, saveAiSettings, usageThisMonth, HELPERS, clearAiStatusCache } from '../../ai';
import { draftEvent, summariseReport, draftMessage, summariseFeedback, improveText, REPORTS_WITHOUT_PEOPLE, type FeedbackQuestion } from '../../ai/helpers';
import { draftEvent, summariseReport, draftMessage, summariseFeedback, improveText, REPORTS_WITHOUT_PEOPLE, REPORTS_WITH_PEOPLE, REPORT_KEYS, SENSITIVE_REPORTS, type FeedbackQuestion } from '../../ai/helpers';
const ACCESS_AI_REPORT = 'Report summarised by the AI helper (anonymised totals)';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const { campusIndependent, scopeBody } = require('../../utils/campusScope') as { campusIndependent(reason: string): any; scopeBody(targets: [string, string][], opts?: object): any };
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
const { logDataAccess } = require('../../utils/accessLog') as { logDataAccess(req: unknown, entry: { action: string; eventId?: string; records?: number }): Promise<void> };
const { logDataAccess } = require('../../utils/accessLog') as { logDataAccess(req: unknown, entry: { action: string; what?: string; eventId?: string; records?: number; sensitive?: boolean }): Promise<void> };
const feedbackUtils = require('../../utils/feedback') as { summarise(fields: unknown[], answers: { fieldId: string; value: string }[]): any[] };
const helperFlags = z.object(Object.fromEntries(HELPERS.map(h => [h, z.boolean()])) as Record<(typeof HELPERS)[number], z.ZodBoolean>);
@@ -115,18 +117,21 @@ export const aiDraftEvent = defineRoute({
export const aiSummariseReport = defineRoute({
method: 'post', path: '/ai/summarise-report', tag: 'AI', summary: 'Summarise a report',
description: `A few plain-language insights on a report as it is on screen. Only reports without people in them: ${REPORTS_WITHOUT_PEOPLE.join(', ')}. ${AI_DESCRIPTION}`,
description: `A few plain-language insights on a report. Reports without people in them (${REPORTS_WITHOUT_PEOPLE.join(', ')}) are sent as they are on screen; reports that list people (${REPORTS_WITH_PEOPLE.join(', ')}) only as anonymised totals: a column that would carry a person (name, email, notes, …) is refused with \`AI_REPORT_HAS_PEOPLE\`, and each one is recorded in the access log. ${AI_DESCRIPTION}`,
...helperAccess,
body: z.object({
reportKey: z.enum(REPORTS_WITHOUT_PEOPLE),
reportKey: z.enum(REPORT_KEYS),
title: z.string().max(300), subtitle: z.string().max(500).optional(),
stats: z.array(z.object({ label: z.string().max(200), value: z.union([z.string().max(200), z.number()]) })).max(40).optional(),
columns: z.array(z.string().max(200)).max(40),
rows: z.array(z.array(z.union([z.string().max(500), z.number(), z.null()]).transform(v => v ?? ''))).max(5000),
}),
response: withStatus(z.object({ headline: z.string(), insights: z.array(z.object({ title: z.string(), detail: z.string() })), watchOut: textList })),
async handler({ body, user }) {
async handler({ body, user, req }) {
const { summary, status: s } = await summariseReport(user!.id, body);
if ((REPORTS_WITH_PEOPLE as readonly string[]).includes(body.reportKey)) {
await logDataAccess(req, { action: 'ai_report', what: `${ACCESS_AI_REPORT}: ${body.title}`, records: body.rows.length, sensitive: SENSITIVE_REPORTS.includes(body.reportKey) });
}
return { data: { output: summary, status: s } };
},
});
+85
View File
@@ -0,0 +1,85 @@
/**
* The Feedback page (2.1.0): see services/feedback.ts. Supervisors and admins; campus-scoped
* supervisors see their campuses' events. Setting a form up, "Send now" and the summary report
* stay in money.ts.
*/
import { z } from 'zod';
import { defineRoute } from '../route';
import { single } from '../paging';
import { id, idParams } from '../schemas';
import * as feedback from '../../services/feedback';
/* eslint-disable @typescript-eslint/no-explicit-any -- campus scope and the access log are JavaScript */
const cs = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage' }): any;
scopeList: any;
listWhere(req: unknown, path?: string): Promise<object>;
};
const { logDataAccess } = require('../../utils/accessLog') as { logDataAccess(req: unknown, entry: { action: string; what?: string; eventId?: string; records?: number }): Promise<void> };
const event = z.object({ id, title: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date() });
const status = z.enum(['sent', 'scheduled', 'not_scheduled']).describe('sent, scheduled (a send time is set) or not_scheduled');
const when = z.coerce.date().nullable();
const count = z.object({ label: z.string(), value: z.number().int() });
export const overview = defineRoute({
method: 'get', path: '/feedback', tag: 'Feedback', summary: 'Every event with a feedback form',
description: 'Newest event first, with how many were asked and answered, and the average star rating.',
access: 'supervisor', campusScope: 'scopeList (the handler filters by listWhere)', middleware: [cs.scopeList],
response: single(z.array(z.object({
event, status, sendAt: when, sentAt: when, anonymous: z.boolean(),
invited: z.number().int(), answered: z.number().int(), responseRate: z.number().nullable().describe('%'),
averageRating: z.number().nullable().describe('Every star rating on the form, 1–5'), lastReminderAt: when,
}))),
async handler({ req }) { return { data: await feedback.feedbackOverview(await cs.listWhere(req, 'event')) }; },
});
export const results = defineRoute({
method: 'get', path: '/events/:id/feedback/results', tag: 'Feedback', summary: 'An event\'s feedback: results, responses and who was asked',
description: 'On an anonymous form, responses have no name and `people` is null. Opening a named form\'s answers is recorded in the access log.',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams,
response: single(z.object({
event,
form: z.object({
status, sendAt: when, sentAt: when, audience: z.enum(['checked_in', 'all']), anonymous: z.boolean(), intro: z.string().nullable(),
autoReminderAt: when.describe('When the automatic reminder goes (3 days after sending)'), autoReminderSent: z.boolean(),
}),
counts: z.object({
invited: z.number().int(), answered: z.number().int(), notAnswered: z.number().int(), responseRate: z.number().nullable(),
canRemind: z.number().int().describe('Not answered, and not sent the form or a reminder in the last 24 hours'),
remindedRecently: z.number().int(),
waiting: z.number().int().describe('Would get the form but haven\'t been sent it yet (e.g. checked in after it went out)'),
}),
averageRating: z.number().nullable(),
questions: z.array(z.object({
fieldId: id, type: z.string(), label: z.string(), answered: z.number().int(),
average: z.number().nullable().optional(), distribution: z.array(count).optional(), counts: z.array(count).optional(),
texts: z.array(z.object({ value: z.string(), name: z.string().optional() })).optional(),
})),
fields: z.array(z.object({ id, type: z.string(), label: z.string() })),
responses: z.array(z.object({ id, createdAt: z.coerce.date(), name: z.string().nullable(), answers: z.record(z.string(), z.string()).describe('Question id → answer') })),
people: z.array(z.object({
userId: id, name: z.string(), email: z.string().nullable(), phoneNumber: z.string().nullable(),
sentAt: z.coerce.date(), remindedAt: when, reminderCount: z.number().int(), respondedAt: when, canRemind: z.boolean(),
})).nullable(),
})),
async handler({ params, req }) {
const data = await feedback.eventFeedback(params.id);
if (!data.form.anonymous && data.responses.length) {
await logDataAccess(req, { action: 'feedback_responses', what: `Feedback answers: ${data.event.title}`, eventId: data.event.id, records: data.responses.length });
}
return { data };
},
});
export const remind = defineRoute({
method: 'post', path: '/events/:id/feedback/remind', tag: 'Feedback', summary: 'Remind people who haven\'t answered',
description: 'A fresh link to everyone who hasn\'t answered, or to `userIds` (named forms only). Nobody is sent the form or a reminder twice within 24 hours. Without `userIds` it also sends the form to anyone who has become eligible since it went out. Answers straight away; the emails go out in the background.',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams,
body: z.object({ userIds: z.array(id).max(1000).nullish().describe('Only these people (named forms)') }),
response: single(z.object({ reminded: z.number().int().describe('Being reminded'), newlyInvited: z.number().int().describe('Being sent the form for the first time'), skipped: z.number().int(), queued: z.boolean().describe('Emails are going out in the background'), message: z.string() })),
async handler({ params, body }) {
const { title: _title, ...data } = await feedback.remindFeedback(params.id, { userIds: body.userIds });
return { data };
},
});
+1 -1
View File
@@ -361,6 +361,6 @@ v1Route({
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, documented: { response: D.message }, v1: feedback.deleteEventFeedback,
});
v1Route({
method: 'post', path: '/events/:id/feedback/send-now', tag: 'Feedback', summary: 'Send the feedback form now',
method: 'post', path: '/events/:id/feedback/send-now', tag: 'Feedback', summary: 'Send the feedback form now', description: 'Answers straight away: `invited` is how many it is going to; the emails go out in the background.',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, documented: { response: D.obj({ invited: D.num, message: D.str }) }, v1: feedback.sendNow,
});
+10 -2
View File
@@ -79,10 +79,18 @@ describe('what the helpers send', () => {
expect(r.warnings.some(w => w.includes('R7 800 short'))).toBe(true);
});
test('summarise a report: only reports without people', async () => {
await expect(summariseReport('u1', { reportKey: 'payments', title: 'Payments', columns: ['User'], rows: [['Jane']] })).rejects.toMatchObject({ reason: 'AI_REPORT_HAS_PEOPLE' });
test('summarise a report: people reports only as anonymised totals', async () => {
for (const columns of [['Event', 'User', 'Amount'], ['Event', 'Email'], ['Event', 'Performed by', 'Notes'], ['Event', 'Attendee', 'Medical'], ['Parent / guardian']]) {
await expect(summariseReport('u1', { reportKey: 'payments', title: 'Payments', columns, rows: [['Jane']] })).rejects.toMatchObject({ reason: 'AI_REPORT_HAS_PEOPLE' });
}
await expect(summariseReport('u1', { reportKey: 'nope', title: 'X', columns: ['A'], rows: [[1]] })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' });
expect(mockRun).not.toHaveBeenCalled();
mockRun.mockResolvedValue({ output: { headline: 'h', insights: [], watchOut: [] }, status });
await summariseReport('u1', { reportKey: 'medicalList', title: 'Medical', columns: ['Event', 'Attendees', 'With allergies', 'With medical aid'], rows: [['Camp', 40, 6, 30]] }, now);
await summariseReport('u1', { reportKey: 'feedbackSummary', title: 'Feedback', columns: ['Event', 'Question', 'Answered', 'Result'], rows: [['Camp', 'Overall', 12, '4.5 / 5']] }, now);
expect(mockRun).toHaveBeenCalledTimes(2);
mockRun.mockReset();
mockRun.mockResolvedValue({ output: { headline: 'h', insights: [], watchOut: [] }, status });
await summariseReport('u1', { reportKey: 'revenue', title: 'Revenue Summary', stats: [{ label: 'Total', value: 'R66 550' }], columns: ['Event', 'Amount'], rows: [['Camp', 'R16 650']] }, now);
expect(mockRun.mock.calls[0][2].input).toBe('Report: Revenue Summary\nTotal: R66 550\nColumns: Event | Amount\nCamp | R16 650');
});
+220
View File
@@ -0,0 +1,220 @@
/**
* 2.1.0: the Feedback page (services/feedback.ts) — results per event, anonymous forms that stay
* anonymous, and "Remind" with its 24-hour rule (utils/feedback.js sendFeedbackReminders). 2.1.1:
* "Remind" answers straight away and the emails go out in a background task (runFeedbackTask).
*/
const mockDb: any = {
feedbackForm: { findUnique: jest.fn(), findMany: jest.fn() },
feedbackInvite: { findMany: jest.fn(), updateMany: jest.fn(), count: jest.fn(), create: jest.fn(), groupBy: jest.fn() },
feedbackAnswer: { findMany: jest.fn() },
ticket: { findMany: jest.fn() },
registration: { findMany: jest.fn() },
user: { findMany: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
const mockMail = jest.fn(async () => undefined);
jest.mock('../src/utils/email', () => ({ sendMail: (...a: unknown[]) => (mockMail as jest.Mock)(...a), emailWrapper: (h: string) => h, ctaButton: () => '', fallbackLink: () => '' }));
jest.mock('../src/utils/notify', () => ({ waTextAny: jest.fn(async () => undefined) }));
jest.mock('../src/utils/whatsapp', () => ({ isValidZAPhone: () => false }));
const mockEnqueue = jest.fn(async () => ({ queued: true }));
jest.mock('../src/jobs', () => ({ enqueueTask: (...a: unknown[]) => (mockEnqueue as jest.Mock)(...a) }));
import * as feedback from '../src/services/feedback';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { sendFeedbackReminders, runFeedbackTask } = require('../src/utils/feedback');
const NOW = new Date('2026-11-10T12:00:00Z');
const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3600e3);
const EVENT = { id: 'ev1', title: 'Men\'s Breakfast', startDate: new Date('2026-11-01T07:00:00Z'), endDate: new Date('2026-11-01T10:00:00Z') };
const FIELDS = [
{ id: 'f1', type: 'rating', label: 'Overall', options: null },
{ id: 'f2', type: 'text', label: 'What could be better?', options: null },
{ id: 'f0', type: 'statement', label: 'Thanks for coming', options: null },
];
const person = (id: string, name: string) => ({ id, name, email: `${id}@example.com`, phoneNumber: null });
function form(extra: object = {}) {
return {
id: 'form1', eventId: 'ev1', event: EVENT, anonymous: false, audience: 'checked_in', intro: null,
sendAt: hoursAgo(72), sentAt: hoursAgo(72), reminderSentAt: null, fields: FIELDS,
responses: [
{ id: 'r1', createdAt: hoursAgo(60), user: { name: 'Anna' }, answers: [{ fieldId: 'f1', value: '5' }, { fieldId: 'f2', value: 'Longer coffee break' }] },
{ id: 'r2', createdAt: hoursAgo(50), user: { name: 'Ben' }, answers: [{ fieldId: 'f1', value: '4' }] },
],
invites: [
{ userId: 'u1', user: person('u1', 'Anna'), sentAt: hoursAgo(72), remindedAt: null, reminderCount: 0, respondedAt: hoursAgo(60) },
{ userId: 'u2', user: person('u2', 'Ben'), sentAt: hoursAgo(72), remindedAt: null, reminderCount: 0, respondedAt: hoursAgo(50) },
{ userId: 'u3', user: person('u3', 'Cara'), sentAt: hoursAgo(72), remindedAt: hoursAgo(2), reminderCount: 1, respondedAt: null },
{ userId: 'u4', user: person('u4', 'Dumi'), sentAt: hoursAgo(72), remindedAt: null, reminderCount: 0, respondedAt: null },
{ userId: 'u5', user: person('u5', 'Eve'), sentAt: hoursAgo(1), remindedAt: null, reminderCount: 0, respondedAt: null },
],
...extra,
};
}
beforeEach(() => {
jest.clearAllMocks();
mockDb.ticket.findMany.mockResolvedValue([]);
mockDb.registration.findMany.mockResolvedValue([]);
mockDb.user.findMany.mockResolvedValue([]);
mockDb.feedbackInvite.updateMany.mockResolvedValue({ count: 1 });
});
describe('averageRating', () => {
test('averages every valid star rating, to one decimal', () => {
expect(feedback.averageRating(['5', '4', '4'])).toBe(4.3);
expect(feedback.averageRating(['x', '9', '0'])).toBeNull();
expect(feedback.averageRating([])).toBeNull();
});
});
describe('one event\'s feedback', () => {
test('a named form: names on answers, everyone asked, not answered first', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
const data = await feedback.eventFeedback('ev1', NOW);
expect(data.counts).toMatchObject({ invited: 5, answered: 2, notAnswered: 3, responseRate: 40 });
expect(data.averageRating).toBe(4.5);
expect(data.fields.map((f: any) => f.id)).toEqual(['f1', 'f2']); // statements aren't questions
expect(data.responses[0]).toMatchObject({ name: 'Anna', answers: { f1: '5', f2: 'Longer coffee break' } });
expect(data.people!.map((p: any) => p.name)).toEqual(['Cara', 'Dumi', 'Eve', 'Anna', 'Ben']);
});
test('nobody is remindable within 24 hours of the form or a reminder', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
const data = await feedback.eventFeedback('ev1', NOW);
const can = Object.fromEntries(data.people!.map((p: any) => [p.name, p.canRemind]));
expect(can).toEqual({ Cara: false, Dumi: true, Eve: false, Anna: false, Ben: false });
expect(data.counts).toMatchObject({ canRemind: 1, remindedRecently: 2 });
});
test('an anonymous form: no names anywhere, and no list of who has answered', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form({ anonymous: true }));
const data = await feedback.eventFeedback('ev1', NOW);
expect(data.people).toBeNull();
expect(data.responses.every((r: any) => r.name === null)).toBe(true);
expect(JSON.stringify(data)).not.toMatch(/Anna|Ben|Cara|u1@example/);
expect(data.counts.notAnswered).toBe(3);
});
test('an event without a form is a 404', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(null);
await expect(feedback.eventFeedback('ev1', NOW)).rejects.toMatchObject({ statusCode: 404 });
});
});
describe('Remind', () => {
test('not before the form has gone out', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form({ sentAt: null }));
await expect(feedback.remindFeedback('ev1')).rejects.toMatchObject({ statusCode: 409 });
});
test('not one by one on an anonymous form', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form({ anonymous: true }));
await expect(feedback.remindFeedback('ev1', { userIds: ['u4'] })).rejects.toMatchObject({ statusCode: 400 });
});
// count() is called for: everyone not answered, then those remindable now (the 24-hour rule).
const counts = (notAnswered: number, remindable: number) => mockDb.feedbackInvite.count.mockResolvedValueOnce(notAnswered).mockResolvedValueOnce(remindable);
test('answers straight away with who it will remind; the emails go in the background', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
counts(3, 1);
mockDb.feedbackInvite.findMany.mockResolvedValue([{ userId: 'u3' }, { userId: 'u4' }, { userId: 'u5' }]);
const out = await feedback.remindFeedback('ev1', {}, NOW);
expect(out).toMatchObject({ reminded: 1, newlyInvited: 0, skipped: 2, queued: true });
expect(out.message).toBe('Reminding 1 person. It carries on in the background. 2 people were sent the form or a reminder in the last 24 hours, so not again yet.');
const where = mockDb.feedbackInvite.count.mock.calls[1][0].where;
expect(where).toMatchObject({ formId: 'form1', respondedAt: null });
expect(where.sentAt.lt).toEqual(new Date(NOW.getTime() - 24 * 3600e3));
expect(where.OR).toHaveLength(2);
expect(mockEnqueue).toHaveBeenCalledWith('feedback-send', { formId: 'form1', kind: 'remind', userIds: null });
expect(mockMail).not.toHaveBeenCalled(); // not in the request
});
test('people who can have the form but haven\'t yet are counted too', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
counts(1, 0);
mockDb.feedbackInvite.findMany.mockResolvedValue([{ userId: 'u1' }]);
mockDb.ticket.findMany.mockResolvedValue([{ userId: 'u1' }, { userId: 'u9' }]);
mockDb.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u9' }]);
const out = await feedback.remindFeedback('ev1', {}, NOW);
expect(out).toMatchObject({ reminded: 0, newlyInvited: 1, queued: true });
expect(out.message).toBe('Sending the form to 1 person for the first time. It carries on in the background. 1 person was sent the form or a reminder in the last 24 hours, so not again yet.');
});
test('one person on a named form', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
counts(1, 1);
const out = await feedback.remindFeedback('ev1', { userIds: ['u4'] }, NOW);
expect(out.message).toBe('Reminding 1 person. It carries on in the background.');
expect(mockDb.feedbackInvite.count.mock.calls[0][0].where.userId).toEqual({ in: ['u4'] });
expect(mockEnqueue).toHaveBeenCalledWith('feedback-send', { formId: 'form1', kind: 'remind', userIds: ['u4'] });
});
test('everyone already reminded today: nothing queued', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
counts(2, 0);
mockDb.feedbackInvite.findMany.mockResolvedValue([]);
const out = await feedback.remindFeedback('ev1', {}, NOW);
expect(out.message).toBe('Everyone who hasn\'t answered was sent the form or a reminder in the last 24 hours. Try again tomorrow.');
expect(mockEnqueue).not.toHaveBeenCalled();
});
});
describe('the background task', () => {
test('remind: invites the newly eligible, then reminds with the 24-hour rule', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
expect(await runFeedbackTask({ formId: 'form1', kind: 'remind' })).toEqual({ invited: 0, reminded: 1 });
const where = mockDb.feedbackInvite.findMany.mock.calls[0][0].where;
expect(where.sentAt.lt).toBeInstanceOf(Date);
expect(mockDb.feedbackInvite.updateMany.mock.calls[0][0].data.reminderCount).toEqual({ increment: 1 });
// Claimed with the same rule, so a second task running at once skips them.
expect(mockDb.feedbackInvite.updateMany.mock.calls[0][0].where).toMatchObject({ id: 'i4', respondedAt: null, sentAt: expect.any(Object), OR: expect.any(Array) });
expect(mockMail).toHaveBeenCalledWith(expect.objectContaining({ to: 'u4@example.com', subject: 'Reminder: How was Men\'s Breakfast?' }));
});
test('remind one person: no new invites', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
await runFeedbackTask({ formId: 'form1', kind: 'remind', userIds: ['u4'] });
expect(mockDb.feedbackInvite.findMany.mock.calls[0][0].where.userId).toEqual({ in: ['u4'] });
expect(mockDb.feedbackInvite.create).not.toHaveBeenCalled();
});
test('someone who answers while reminders go out isn\'t reminded', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
mockDb.feedbackInvite.updateMany.mockResolvedValue({ count: 0 });
expect((await runFeedbackTask({ formId: 'form1', kind: 'remind', userIds: ['u4'] })).reminded).toBe(0);
expect(mockMail).not.toHaveBeenCalled();
});
});
describe('the automatic reminder', () => {
test('still goes only to people never reminded, and counts it', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
expect(await sendFeedbackReminders('form1')).toBe(1);
expect(mockDb.feedbackInvite.findMany.mock.calls[0][0].where).toEqual({ formId: 'form1', respondedAt: null, remindedAt: null });
expect(mockDb.feedbackInvite.updateMany.mock.calls[0][0].data.reminderCount).toEqual({ increment: 1 });
});
});
describe('overview', () => {
test('newest event first, with status, response rate, average rating and last reminder', async () => {
const older = { ...EVENT, id: 'ev0', title: 'Camp', startDate: new Date('2026-06-01T00:00:00Z') };
mockDb.feedbackForm.findMany.mockResolvedValue([
{ id: 'form0', event: older, sendAt: null, sentAt: null, anonymous: true, _count: { invites: 0, responses: 0 } },
{ id: 'form1', event: EVENT, sendAt: hoursAgo(72), sentAt: hoursAgo(72), anonymous: false, _count: { invites: 4, responses: 3 } },
]);
mockDb.feedbackAnswer.findMany.mockResolvedValue([{ value: '5', field: { formId: 'form1' } }, { value: '3', field: { formId: 'form1' } }]);
mockDb.feedbackInvite.groupBy.mockResolvedValue([{ formId: 'form1', _max: { remindedAt: hoursAgo(5) } }]);
const rows = await feedback.feedbackOverview({});
expect(rows.map((r: any) => [r.event.title, r.status, r.responseRate, r.averageRating])).toEqual([
['Men\'s Breakfast', 'sent', 75, 4],
['Camp', 'not_scheduled', null, null],
]);
expect(rows[0].lastReminderAt).toEqual(hoursAgo(5));
});
});
+15 -1
View File
@@ -72,9 +72,23 @@ describe('heartbeat report', () => {
expect(incomeBand(1001)).toBe('R1 001–R5 000');
expect(incomeBand(19999)).toBe('R5 001–R20 000');
expect(incomeBand(100000)).toBe('R50 001–R100 000');
expect(incomeBand(250000)).toBe('over R100 000');
expect(incomeBand(100001)).toBe('R100 001–R150 000');
expect(incomeBand(250000)).toBe('R150 001–R250 000');
expect(incomeBand(400001)).toBe('R400 001–R700 000');
expect(incomeBand(1000000)).toBe('R700 001–R1 000 000');
expect(incomeBand(1000001)).toBe('R1 000 001–R2 500 000');
expect(incomeBand(5000000)).toBe('R2 500 001–R5 000 000');
expect(incomeBand(9999999)).toBe('R5 000 001–R10 000 000');
expect(incomeBand(10000001)).toBe('over R10 000 000');
expect(incomeBand(null)).toBe('R0–R1 000');
});
test('every licence tier edge is a band edge', () => {
// Cross Code bills the tiers on these (crosscode-site src/data/pricing.mjs).
for (const edge of [50000, 150000, 400000, 1000000, 2500000, 5000000, 10000000]) {
expect(incomeBand(edge)).not.toBe(incomeBand(edge + 1));
}
});
});
describe('read-only mode', () => {
+34
View File
@@ -0,0 +1,34 @@
/**
* 2.1.1: one-off background tasks (jobs/index.js enqueueTask). Without the queue (timers or jobs
* off) a task runs in this process right after the caller gets its answer.
*/
let finish;
const mockTask = jest.fn(() => new Promise(resolve => { finish = resolve; }));
jest.mock('../src/utils/feedback', () => ({ runFeedbackTask: (...a) => mockTask(...a) }));
const jobs = require('../src/jobs');
describe('enqueueTask without the queue', () => {
test('answers before the task has run, then runs it', async () => {
const answer = await jobs.enqueueTask('feedback-send', { formId: 'f1', kind: 'send' });
expect(answer).toEqual({ queued: false });
expect(mockTask).not.toHaveBeenCalled(); // not yet: after the response
await new Promise(r => setImmediate(r));
expect(mockTask).toHaveBeenCalledWith({ formId: 'f1', kind: 'send' });
finish({ invited: 3 });
});
test('a failing task is logged, not thrown at the caller', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
mockTask.mockImplementationOnce(() => Promise.reject(new Error('SMTP down')));
await expect(jobs.enqueueTask('feedback-send', { formId: 'f1', kind: 'send' })).resolves.toEqual({ queued: false });
await new Promise(r => setImmediate(r));
await new Promise(r => setImmediate(r));
expect(warn).toHaveBeenCalledWith('[jobs] task feedback-send failed:', 'SMTP down');
warn.mockRestore();
});
test('an unknown task is refused', async () => {
await expect(jobs.enqueueTask('nope', {})).rejects.toThrow('Unknown task nope');
});
});
+15 -2
View File
@@ -700,13 +700,26 @@ describe('AI helpers (2.0)', () => {
expect(mockRunHelper.mock.calls[0][0]).toBe('improve_text');
});
test('summarise a report: reports with people in them are refused by validation', async () => {
test('summarise a report: unknown reports fail validation, people columns are refused', async () => {
signedIn = { id: 's1', role: 'supervisor' };
expect((await request(app).post('/api/v2/ai/summarise-report').send({ reportKey: 'nope', title: 'X', columns: ['A'], rows: [[1]] })).status).toBe(400);
const res = await request(app).post('/api/v2/ai/summarise-report').send({ reportKey: 'payments', title: 'Payments', columns: ['User'], rows: [['Jane']] });
expect(res.status).toBe(400);
expect(res.status).toBe(403);
expect(res.body.error.reason).toBe('AI_REPORT_HAS_PEOPLE');
expect(mockRunHelper).not.toHaveBeenCalled();
});
test('summarise a report: a people report as totals, with an access-log entry (sensitive for medical)', async () => {
signedIn = { id: 's1', role: 'supervisor' };
mockRunHelper.mockResolvedValue({ output: { headline: 'h', insights: [], watchOut: [] }, status: st });
const res = await request(app).post('/api/v2/ai/summarise-report').send({ reportKey: 'medicalList', title: 'Medical & emergency list', columns: ['Event', 'Attendees', 'With allergies'], rows: [['Camp', 40, 6]] });
expect(res.status).toBe(200);
expect(mockAccessLog).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: 'ai_report', records: 1, sensitive: true }));
mockAccessLog.mockClear();
await request(app).post('/api/v2/ai/summarise-report').send({ reportKey: 'revenue', title: 'Revenue', columns: ['Event', 'Amount'], rows: [['Camp', 100]] });
expect(mockAccessLog).not.toHaveBeenCalled();
});
test('draft an event: the draft with warnings worked out in code', async () => {
signedIn = { id: 's1', role: 'supervisor' };
const draft = {
+37 -11
View File
@@ -1,8 +1,10 @@
# Handover — where things stand and how to carry on
Last updated: 2026-11-02, after **2.0.0** and Hub **1.6.1**. 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).
Last updated: 2026-11-04, after **2.1.1** (deployed), Hub **1.7.0** and **contracts 1.0.0**;
ROADMAP-v3 updated for the monorepo move and the `crosscode` deploy script (decisions 21–24). **The plan from here is
[ROADMAP-v3.md](./ROADMAP-v3.md)** (Accounts, Church Website, Church App, Cross Code Hub; decisions
table near the end). How Cross Events got to 2.x is in [ROADMAP-v2.md](./ROADMAP-v2.md); the round
before that in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
The product is called **Cross Events** (renamed from Hope Events in 1.41.3); the Hub is the
**Cross Events Hub**. Technical names keep `hope-events` on purpose — the repo, browser storage
@@ -17,6 +19,10 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 2.1.1 | **Feedback sends in the background:** "Send now" and "Remind" answer straight away and queue the `feedback-send` task (`enqueueTask` in `src/jobs/index.js` — pg-boss, or in-process when the queue is off; tasks must be safe to run twice). Tests `tests/tasks.test.js`, `tests/feedbackPage.test.ts` |
| **2.1.0** | **Feedback page** (Supervisor → Feedback): overview, per-event results / responses / people, **Remind** (all who haven't answered, or one person on a named form; 24-hour gap) — `src/services/feedback.ts`, `src/v2/resources/feedback.ts`, `frontend/src/app/dashboard/supervisor/feedback/page.tsx`; reminders share `sendFeedbackReminders` (utils/feedback.js) with the 3-day job; anonymous forms show no names or who answered; access log `feedback_responses`. Migration `20261103120000_feedback_reminders` (`FeedbackInvite.reminderCount`). Tests `tests/feedbackPage.test.ts`, `e2e/feedback.spec.ts` (the e2e seed puts a named form on the past event). AI on the privacy pages dropped from the roadmap |
| 2.0.2 | **Billing bands** (Hub 1.7.0 bills on them: invoices, receipts, statements, licence-change notices — events-hub README "Billing"): heartbeat income bands widened to `over R10 000 000` (`BAND_TOPS` in `src/hub/report.js`, every licence-tier edge is a band edge — test in `tests/hub.test.js`), new `income.eventIncome12m` (12 months, no donations/sponsorships/legs) that the tier is billed on, donation legs no longer double-counted. Tiers and prices live in `crosscode-site/src/data/pricing.mjs` and the Hub's `frontend/src/lib/tiers.ts` (Hub 1.6.2) |
| 2.0.1 | **Summarise this report on every report:** the eight reports that list people are sent as anonymised totals built on the screen (`frontend/src/components/reports/aiReportData.ts`; attendees in `buildAiPayload` in `ReportsV2.tsx`); the server refuses personal columns on those (`personalColumns` in `backend/src/ai/helpers.ts`, 403 `AI_REPORT_HAS_PEOPLE`) and logs each (`ai_report`) |
| **2.0.0** | **AI helpers** (draft an event, draft a message, summarise a report or feedback, improve text — `backend/src/ai/`, through the Hub with a rand cap per licence; eval set `npm run ai:eval`); **API v1 removed** (410 `API_V1_REMOVED`; provider webhooks and links in sent messages still answered — [v1 removed](#v1-removed-20)); **Argon2id passwords** with re-hashing at sign-in ([Passwords](#passwords-20)); `node-fetch` removed; React Compiler lint rules back to errors (`lib/hooks.ts`, see [Next.js 16](#nextjs-16-1515)); campus-scope route test on v2 (`tests/v2CampusScope.test.ts`); migration guide `MIGRATING-TO-2.md`. Migration `20261030120000_ai_helpers`. Needs Hub 1.6.0+ for AI |
| 1.52.3 | **Urgent fix:** licence read-only mode blocks the `/api/v2` routes too (`src/hub/readOnly.js` only listed v1 paths, and the screens have used v2 since 1.51.15). A test checks each blocked v2 path is a real route |
| 1.52.2 | `scripts/deploy.sh` stops first (listing them) when files in the install were changed or added by hand; `--check` runs only that; runs from a copy outside the install too; `ALLOW_LOCAL_CHANGES=1` overrides |
@@ -84,18 +90,38 @@ encrypted settings unreadable or break existing installs.
## Next up
1. **After 2.0.0:**
- **Deploy:** `bash scripts/deploy.sh --check`, then `bash scripts/deploy.sh v2.0.0` on each server
(installs on 1.52.x ask through Request update as usual). Before updating an install that has
its own integration, look in the nginx access log for `/api/` calls that aren't `/api/v2/`,
the webhooks or the old links — those get 410 after the update (MIGRATING-TO-2.md).
1. **Roadmap v3** ([ROADMAP-v3.md](./ROADMAP-v3.md), decided 2026-11-03). Everything in
ROADMAP-v2 is done: 2.1.1 is deployed, and the marketing site (`../crosscode-site`,
crosscode.co.za, with prices) is live. Build order (§9):
- **0 — contracts 1.0.0: released 2026-11-04** (`../crosscode-platform`, tag `contracts@1.0.0`):
`crosscode.event/1`, `crosscode.church/1`, `crosscode.identity/1`, Hub protocol v2, generated
JSON Schema in `packages/contracts/schemas/`. Not published to Gitea's package registry, and
won't be (decision 22).
- **1 — Hub 2.0.0** (`../events-hub`): Cross Code Hub — products, one licence per product,
**one install of each product per customer**, domain per customer, bundles, sibling discovery,
**releases per product** (Events from `crosscode-platform` `events@…` tags, keeping the
`hope-events` `v…` releases) **with requirements** (`— requires accounts 1.2.0`, Request update
disabled until met). Contracts as `vendor/crosscode-contracts-1.0.0.tgz` (ROADMAP-v3 §2, §5).
- **1b — Cross Events 2.2.0: moves into the monorepo** as `apps/events` with its history, and the
**`crosscode` script** (`deploy <church> [events] [website] [app]` — Accounts always —,
`status`, `install`, `adopt`) replaces `scripts/deploy.sh` / `install.sh`. Run
`crosscode adopt` once per existing install; then archive this repo (ROADMAP-v3 §8,
"Releases and updates"). From here on this file and the roadmap live in the monorepo.
- **1c — Cross Events 2.3.0:** **authenticator app 2FA** (TOTP + recovery codes) next to email
and WhatsApp (`TwoFactorMethod` enum in `prisma/schema.prisma`).
- **2 — Cross Events 2.4.0:** protocol v2, Linked products, the event feed (publish and show).
- **3 — Accounts 1.0.0**, **4 — Church Website 1.0.0**, **5 — Cross Events 3.0.0** (sign-in
moves to Accounts), **6 — Church App 1.0.0** (House of Prayer).
- **Before selling the Website or App:** put the prices decided in ROADMAP-v3 §10 in
`crosscode-site/src/data/pricing.mjs` and the Hub's `frontend/src/lib/tiers.ts`.
- Servers stay **PM2 + nginx** (no Docker); one database per product per church.
- **AI for a church:** set the licence's monthly AI cap above R0 on the Hub (Hub 1.6.0+); with
no cap the buttons don't show.
- **Marketing site** (roadmap §17): a simple static HTML site — Cross Code as the main site,
with a Cross Events page, in its own repo. Questions still open: see the roadmap's "Still
open".
- Testing AI locally: run the Hub (`../events-hub`, port 5200), set Settings → AI there, give a
test licence an AI cap, and start Cross Events with `HUB_URL=http://localhost:5200`.
- **Deploying:** `bash scripts/deploy.sh --check`, then `bash scripts/deploy.sh v<version>` on
each server (installs ask through Request update as usual). From Events 2.2.0 on:
`crosscode deploy <church> events` (ROADMAP-v3 §8).
2. **AI rules** (decided 2026-10-29, roadmap §15): the AI buttons show only when the licence's
monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub charges the
real cost plus a markup % (a default and a per-licence override) and counts the cap in charged
+85 -10
View File
@@ -13,7 +13,7 @@ providers), maintainable (tests, CI, typed backend, versioned API), and with
**Status (2026-11-02):** everything in this document has shipped — 1.32.0 – 1.52.3, then
**2.0.0 (released 2026-11-02)**, built on `release/2.0` in parts — except the marketing site
(§17), which lives in its own repo and waits on the questions under "Still open":
(§17), which lives in its own repo (decided 2026-11-02, not built yet):
| # | Part | Where | State |
|---|------|-------|-------|
@@ -22,7 +22,9 @@ providers), maintainable (tests, CI, typed backend, versioned API), and with
| 3 | The five helpers (§15) with their buttons, an eval set, privacy notice text | 2.0.0 | ✅ eval 19/19 through a local Hub (2026-10-30) |
| 4 | v1 removed (webhook paths and old message links keep working); clean-ups: Argon2id passwords with rehash-on-login, `node-fetch` gone, React Compiler lint rules back to errors | 2.0.0 | ✅ |
| 5 | Migration guide (`MIGRATING-TO-2.md`), docs | 2.0.0 | ✅ |
| 6 | Marketing site: Cross Code with a Cross Events page (§17) | its own repo | to do — repo, domain, hosting, prices and contact route still to decide |
| 6 | Marketing site: Cross Code with a Cross Events page (§17) | `joshua/crosscode-site` | ✅ built; prices, founding offer, free month and brochure added in site 1.1.0 (2026-11-03) |
| 7 | 2.0.1: Summarise this report on every report (people reports as anonymised totals) | 2.0.1 | ✅ (§18) |
| 8 | 2.1.0: Feedback page — results, answers, who hasn't answered, reminders (§18) | 2.1.0 | ✅ built. (AI helpers on the privacy pages were dropped on 2026-11-03.) |
The full version list is in [HANDOVER.md](./HANDOVER.md) and the [build order](#suggested-build-order) below.
@@ -80,8 +82,9 @@ Removed from scope (2026-10-28): Wallet passes (§7) and languages (§10).
15. [AI helpers (2.0 headline)](#15-ai-helpers-20-headline)
16. [AI: later stages](#16-ai-later-stages)
17. [Marketing site (Cross Code)](#17-marketing-site-cross-code)
18. [Suggested build order](#suggested-build-order)
19. [Decisions](#decisions-2026-10-22)
18. [After 2.0: 2.0.x, the site, then 2.1.0 (the feedback page)](#18-after-20-20x-the-site-then-210-the-feedback-page)
19. [Suggested build order](#suggested-build-order)
20. [Decisions](#decisions-2026-10-22)
---
@@ -1039,9 +1042,80 @@ Part of 2.0 (decided 2026-10-30): a **simple static HTML site** for selling, not
be added later — planned: a **church app** and a **full church website** — without a redesign
(shown as "coming later" at most, until they exist).
- Plain HTML and CSS (no build step, no framework), fast on a phone, no tracking cookies.
- **To decide before building:** the repository (its own, e.g. `joshua/crosscode-site`, not this
one), the domain(s) (e.g. crosscode.co.za, with Cross Events at /cross-events or its own domain),
hosting, pricing shown or "contact us", and the contact route (email address or a form).
- **Decided (2026-11-02):**
| Question | Answer |
|----------|--------|
| Domain | **crosscode.co.za** — the main site for Cross Code (Joshua's business), with a **Cross Events page** on it (e.g. `/cross-events`) |
| Other products | **Church website** and **church app** shown as **"coming later"** (cards or a section, no pages of their own yet). 2026-11-03: the church app is a **web app** at the church's own address (e.g. app.yourchurch.co.za), not an app-store app, to keep costs down; bundles save 15% (two products) or 25% (all three) |
| Repository | **`joshua/crosscode-site`** on c-git.crosscode.co.za — its own repo, not this one |
| Hosting | **Joshua's own server** (nginx serving the files). Mostly information, so as simple a build as possible |
| Prices | ~~Not shown for now~~ → **changed 2026-11-03: shown.** Eight tiers billed on the 12-month event-income band (`income.eventIncome12m`, Cross Events 2.0.2), founding-church prices, a one-month free trial and a PDF brochure; the prices live in `crosscode-site/src/data/pricing.mjs` |
| Contact | **A form** that sends to **info@crosscode.co.za** |
| Legal | A **privacy notice** and **terms of use** for the site |
| Cookies, analytics | **None for now** (so no cookie banner) |
| Logo, colours, wording about Cross Code, photos, screenshots, a church to name | **Joshua provides them later** — build with placeholders until then |
- **Building notes:**
- Plain HTML and CSS stays the plan; at most a tiny static-site step (shared header/footer) if
copying them by hand gets tedious — no framework.
- **The form needs something on the server to send the email** (a static site can't): a small
endpoint on the same server (e.g. a few lines of Node or PHP behind nginx) that checks the
fields, has a honeypot field and a rate limit against spam, and sends to info@crosscode.co.za
through the server's SMTP. No form-service third party, so the privacy notice stays short. The
page works without JavaScript (a normal POST, then a "thanks" page).
- The privacy notice covers only the contact form (what is kept, why, for how long, who to ask);
the terms of use cover using the site. Neither needs a cookie section while there are none.
- Cross Events page: what it does for a church (registrations and payments, multi-day events,
kids check-in, volunteer rosters, series and season passes, campuses, POPIA toolkit, the AI
helpers), how it's set up (one site and database per church, updates on request, support through
the Hub), and "get in touch" (the form).
---
## 18. After 2.0: 2.0.x, the site, then 2.1.0 (the feedback page)
Decided 2026-11-02. Order: **2.0.1** → **the marketing site (crosscode.co.za, §17)** → **2.1.0**.
The site comes first: 2.1.0 isn't started until crosscode.co.za is live.
### 2.0.1: Summarise this report on every report ✅
2.0.0 had the button only on the 11 reports without people in them, so churches couldn't
summarise payments, attendees, master orders and the rest. 2.0.1 puts it on **all** reports. The
eight that list people (payments, attendees, revenue detailed, master orders, cashup audit trail,
parental consent, feedback summary, medical list) are sent as **anonymised totals** built on the
screen — per event and method, status or option; no names, contact details, notes or comments;
consent and medical only as counts. The server refuses any personal column on those reports, and
each such summary goes in the access log (sensitive for consent and medical). The POPIA promise in
§15 is unchanged: no names, contact details, medical or children's information is sent.
### Marketing site (before 2.1.0)
crosscode.co.za as decided in §17 (repo `joshua/crosscode-site`, Joshua's own server, contact
form to info@crosscode.co.za, privacy notice and terms, no cookies). Placeholders until Joshua
sends the logo, colours, wording and screenshots.
### 2.1.0: Feedback page
Decided 2026-11-03. **AI helpers on the privacy pages (Consent log, Access log) are dropped** —
Joshua doesn't want AI on the privacy pages. 2.1.0 is a full feedback page instead.
Post-event feedback forms already exist (event wizard → Feedback, a 3-day automatic reminder, the
Feedback summary report). What was missing is a place for supervisors and admins to **see** the
feedback and to **chase** the people who haven't answered:
- **Supervisor → Feedback** (supervisors and admins; campus-scoped staff see their campuses):
every event with a feedback form — when it goes out or went out, invited, answered, response
rate, average rating.
- **One event's feedback:** the per-question results (star ratings, choices, comments), each
response on its own (with the person's name on a named form), and **People**: who was asked,
who has answered, reminders sent.
- **Remind everyone who hasn't answered:** a fresh link to each, and the form to anyone who has
become eligible since it went out (checked in later). On a named form, one person can be
reminded on their own. Nobody is reminded twice within 24 hours.
- **Anonymous forms stay anonymous:** no names on answers, and no list of who has or hasn't
answered (that would give answers away on a small event) — only the counts.
- Opening a named form's answers is recorded in the access log (`feedback_responses`).
---
@@ -1063,6 +1137,7 @@ Part of 2.0 (decided 2026-10-30): a **simple static HTML site** for selling, not
| 11 | 1.51.0 ✅, 1.51.1 ✅, 1.51.2 ✅, 1.51.3 ✅ | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 registrations and payments as TS services, typed frontend calls; 1.51.2 check-in and accounts; 1.51.3 Express 5, the scanner on v2, v1 deprecation headers, v2-shaped shared middleware | The breaking-change groundwork |
| 11b | 1.51.4 ✅ – 1.51.16 ✅ | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 managing registrations (the staff lists, status, at the door, bulk actions, items, coupons, attendees, attendee forms); 1.51.11 money, staff side (payments, cashup, provider transactions, waitlist, sponsorship, consent, form builder, feedback); 1.51.12 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.13 kids check-in and volunteer rosters; 1.51.14 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.15 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.16 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each puts its area's endpoints on v2 (1.51.1 – 1.51.10 by moving the logic into TS services; from 1.51.11 by `v1Route`, which runs the v1 handler behind a v2 route — moving those handlers into TS services is a 2.x clean-up that doesn't change the API), its screens onto `apiV2`, and its v1 routes into `SUCCESSORS`. Then 1.51.17, a security fix (forgot password and sign-in don't reveal accounts) | 2.0 can then remove v1 |
| 12 | **2.0.0** ✅ | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 removed (all of it is on v2 by then; the old webhook URLs and links already sent in messages keep forwarding to v2), migration guide, docs, and the marketing site (§17: a static Cross Code site with a Cross Events page). Released 2026-11-02 (built on `release/2.0` in parts); the marketing site follows in its own repo | The release. AI needs the Hub (phase 6) for the key, metering and caps |
| 13 | 2.0.1 ✅ → marketing site ✅ → **2.1.0** | Summarise every report (2.0.1); crosscode.co.za (§17); the feedback page (2.1.0, §18 — replaces AI on the privacy pages, dropped 2026-11-03) | Decided 2026-11-02: the site before 2.1.0 |
Each phase should add matching help content (`frontend/src/content/help/`),
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
@@ -1102,7 +1177,7 @@ feature.
| — | v1 in 2.0 | **Every endpoint moves to v2 before 2.0** (1.51.7 – 1.51.16, by area), then 2.0 removes v1. Changed the same day from "retire only what's replaced". Incoming webhooks (Yoco, PayFast, WAWP) and links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds) also get v2 URLs (1.51.16); their v1 paths keep working — links redirect (308) to v2, provider webhooks are handled by the same code — because old messages and provider registrations still use them (2026-10-29) |
| — | AI buttons and markup | AI buttons show only when the licence's monthly cap is **above R0**. When the cap is reached they stay visible but **disabled** until next month or until the cap is raised. Usage is charged at the real cost **plus a markup %** set in the Hub (a default, plus an override per licence). The markup covers small overruns and is the profit. The cap is counted in charged rand, and each call is checked against its worst-case cost before it's sent (2026-10-29) |
| — | Building 2.0 | On a `release/2.0` branch with a WIP commit per part, tagged 2.0.0 only when everything is in; the Hub's AI proxy ships first as its own release (Hub 1.6.0) (2026-10-30) |
| — | Marketing site | Part of 2.0: a simple static HTML site — Cross Code as the main site, with a page for Cross Events, the only product on sale for now. Laid out to add a church app and a full church website later. Repo, domain and hosting still to decide (2026-10-30) |
| — | Marketing site | Part of 2.0: a simple static HTML site — Cross Code as the main site, with a page for Cross Events, the only product on sale for now. Laid out to add a church app and a full church website later (2026-10-30). Decided 2026-11-02: crosscode.co.za on Joshua's own server, repo `joshua/crosscode-site`, church website and app shown as "coming later", no prices for now, a contact form to info@crosscode.co.za, a privacy notice and terms of use, no cookies or analytics; logo, colours and content from Joshua later (§17) |
| — | 2.0 clean-ups | All in 2.0 (2026-11-02): v1 removed, Argon2id passwords with rehash-on-login, `node-fetch` removed, React Compiler lint rules back to errors |
| — | AI later | Ask AI (read-only, tools only), help/navigation assistant, actions with confirmation, more helpers, and long-term **self-hosted** AI behind the same Hub endpoint — not tied to versions (2026-10-24) |
| — | POPIA: message kinds | Bulk messages are either **Event updates** (to registrants — still sent after unsubscribing) or **News & announcements** (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
@@ -1125,5 +1200,5 @@ feature.
## Still open
- **Marketing site (§17):** repository, domain(s), hosting, whether prices are shown, and the
contact route.
Nothing. The marketing site (§17) is done (2026-11-03). The plan from here is
[ROADMAP-v3.md](./ROADMAP-v3.md).
+757
View File
@@ -0,0 +1,757 @@
# Cross Code — Road to three products
Planned work to go from one product (Cross Events 2.1.x) to three that can be sold separately or
together: **Cross Events**, a **Church Website** and a **Church App**, all managed from one
**Cross Code Hub**. Based on the two concept specs (church website platform, church app v1.1,
3 November 2026), with the changes decided on 2026-11-03 (see [Decisions](#decisions-2026-11-03)).
See [ROADMAP-v2.md](./ROADMAP-v2.md) for how Cross Events got to 2.x.
**Theme:** a church can have any mix of products (Events, Website, App, all, or just the
Website). People sign in to all of them with **one account**, and events flow between them in
whatever direction the church chooses.
**Updated 2026-11-04:** Cross Events moves into the `crosscode-platform` monorepo (§8), every
product is released and deployed the same way with one `crosscode` script (§8, "Releases and
updates"), the Hub tracks releases per product (§5), and the contracts reach the Hub as a file
committed to its repo instead of through a package registry (§2). Cross Events versions in §9
moved up by one (the move is 2.2.0). See decisions 21–24.
---
## Contents
1. [The shape](#1-the-shape)
2. [Contracts (shared JSON formats)](#2-contracts-shared-json-formats)
3. [Accounts (shared users)](#3-accounts-shared-users)
4. [Event sync in any direction](#4-event-sync-in-any-direction)
5. [Cross Code Hub (all products)](#5-cross-code-hub-all-products)
6. [Church Website](#6-church-website)
7. [Church App](#7-church-app)
8. [Servers and deployment](#8-servers-and-deployment)
9. [Build order](#9-build-order)
10. [Pricing](#10-pricing)
11. [SEO (every public site)](#11-seo-every-public-site)
12. [Decisions](#decisions-2026-11-03)
13. [Still open](#still-open)
---
## 1. The shape
Each church gets its own **church stack**: one install per product it has bought, each with its
own process and its own database. The Hub only sees aggregate stats and licences.
```
Cross Code Hub (hub.crosscode.co.za)
customers · licences per product · stats · AI proxy · billing
▲ register / heartbeat / licence (install → Hub only)
┌───────────────┼────────────────────────────────────────┐
│ Church stack (e.g. Hope Family Church) │
│ │
│ Accounts (accounts.church.co.za) ◄── sign-in (OIDC) ──┤
│ ▲ ▲ ▲ │
│ Website (www.) ◄─feed─► Events (events.) ◄─feed─► App (app.)
└─────────────────────────────────────────────────────────┘
```
**Rules**
1. **No shared databases, ever.** Products only talk over HTTPS using documented contracts (§2).
2. **The Hub is never on the runtime path.** It stays read-only toward installs: installs push to
it, it never reaches into them, and a Hub outage switches nobody off.
3. **One maintained codebase per product.** Each church runs a tagged release of it. Nothing is
built specially for one church.
4. **The origin of a record owns it.** Copies elsewhere are read-only and link back to the origin.
**Products and what each one owns**
| Product | Owns | Doesn't do |
|---------|------|------------|
| **Accounts** | People's accounts: name, email, phone, password, 2FA, privacy notice acceptance, news consent | Anything product-specific |
| **Cross Events** | Events with registrations, tickets, payments, check-in, kids, rosters, POPIA toolkit | Website content |
| **Church Website** | Public pages, leaders, ministries, messages (sermons), service times, simple events | Registrations or payments |
| **Church App** | House of Prayer, announcements, devotionals, reading plans, groups, care, simple events | Registrations or payments |
---
## 2. Contracts (shared JSON formats)
Everything that crosses between products is defined **once**, in a shared package
`@crosscode/contracts` (zod schemas + generated JSON Schema + TypeScript types + sample payloads).
Every product, including Cross Events and the Hub, uses it. This is what keeps the key fields the
same in every product's JSON.
| Contract | Used for |
|----------|----------|
| `crosscode.event/1`, `crosscode.event-feed/1` | One event, and the feed every product publishes and reads (with the `event.changed` webhook) (§4) |
| `crosscode.church/1` | The church profile: name, logo, colours, campuses, service times, contact details. Set once and shared |
| `crosscode.identity/1` | The claims Accounts puts in a sign-in token, and the back-channel calls (`user.updated`, `user.deleted`, `user.export`) |
| Hub protocol v2 | Register, heartbeat, licence token, AI calls, for every product (§5). Today's protocol (Cross Events 2.1, Hub 1.7) is v1, in `docs/hub-protocol.md` |
**Versioning**
- Adding an **optional** field keeps the version (`/1`). Consumers ignore fields they don't know.
- Removing or renaming a field, adding a value to an enum (older consumers would refuse it), or changing a meaning needs a new version (`/2`). The old
version is served alongside it until every install has updated, as with `/api/v2`.
- Every payload carries its `schema` name, so a consumer always knows what it's reading.
- The package lives in the platform monorepo (§8). Every product there (Events, Accounts, Website,
App) uses it as a workspace, so no registry is involved.
- **The Hub** (its own repo, §8) uses a packed release committed to its repo
(`vendor/crosscode-contracts-<version>.tgz`, `"@crosscode/contracts": "file:vendor/…"`), so its
deploys only need git and the public npm registry, and the lockfile pins the file's checksum.
Upgrading is a normal commit with the new file. Gitea's package registry isn't used (decided
2026-11-04): a registry on the deploy path would need a token on every server and could block a
deploy or a roll back.
---
## 3. Accounts (shared users)
A small per-church **OpenID Connect** provider (`oidc-provider` on the server, `openid-client` in
each product). Every church stack has one, including website-only churches (where it only holds
staff logins).
**Why a separate service:** if one product held the users, cancelling that product would take
everyone's login with it. With Accounts on its own, products can be added or cancelled without
touching anyone's account.
### What lives where
| Accounts | Each product (keyed by the account's `sub`, a UUID) |
|----------|-----------------------------------------------------|
| Name, email, phone, password (Argon2id), 2FA (email, WhatsApp or an authenticator app), lockout, password reset, login history | Roles in that product. An Events supervisor isn't automatically an App prayer coordinator |
| Privacy notice versions and acceptance, news consent (email / WhatsApp) | Product data: registrations and tickets (Events), prayer commitments (App), content edits (Website) |
| "Download my data" and "Delete my account" entry points | Its share of the export and the anonymising, when Accounts asks |
| Inactive-account retention | — |
### How it works
- Sign-in is the standard authorisation-code flow with PKCE. People sign in once at
`accounts.church.co.za` and are signed in to every product of that church.
- **Sign-in always returns you to where you came from.** When a product sends someone to
`accounts.church.co.za` (they clicked Sign in, or opened a page that needs sign-in), it passes
the exact page they were on (path and query, e.g. `/events/youth-camp/register?ticket=adult`)
in the OIDC `state`. After sign-in, Accounts sends them back to **that product and that page**,
not to a dashboard or the account page. This holds through every step in between: 2FA,
"forgot password" and the reset link, creating a new account, the news question and privacy
notice re-acceptance. (Cross Events works this way today with `?redirect=`; the move to Accounts
keeps it.)
- **Safe redirects only:** Accounts only returns to the registered `redirect_uri` of a linked
product of the same church (exact match, no wildcards), and the product only accepts a
relative path from `state` (starting with a single `/`). Anything else falls back to the
product's home page. No open redirects.
- **Signing out** works the same way (`post_logout_redirect_uri`): you end up back on the
product you signed out from, signed out of all of the church's products.
- Opening `accounts.church.co.za` directly (nothing to return to) shows the account page.
- Tests: Playwright checks for each product that signing in from a deep link (including through
2FA and a password reset) lands back on that exact page, and that a foreign or absolute
return URL is refused.
- **Accounts stays in the background** (decided 2026-11-03). Members never see it as a product:
the sign-in page carries the church's name and branding ("Sign in to Hope Family Church"), and
they manage their account from whichever product they're in. Only **admins** see Accounts as
such (people, sign-in settings, linked products), and **account holders** see their own
account page (profile, password, 2FA, privacy).
- **2FA methods:** email, WhatsApp, or an **authenticator app** (TOTP, e.g. Google or Microsoft
Authenticator), with one-time recovery codes. Cross Events gets the authenticator option first
(2.3.0), and the migration carries it over (below).
- Each product keeps a local copy of the profile (name, email) for lists and messages, updated on
sign-in and by `user.updated`.
- **POPIA:** the church is one responsible party. "Download my data" asks every linked product for
its part (`user.export`) and bundles the answers. "Delete my account" deactivates the account at
once, then sends `user.deleted` to each product when an admin approves (the existing Events
rules apply: it waits while the person has an open registration for an upcoming event).
- Back-channel calls are signed with a secret per product link and retried through pg-boss.
- Accounts gets its own access log entries for exports and admin views of people.
### Moving Cross Events onto Accounts (Cross Events 3.0)
This is a breaking change, so it's a major version.
- A migration copies every Events user into Accounts **with their existing password hash**, 2FA
setting (including authenticator secrets and unused recovery codes), consent and notice acceptance, so nobody has to reset anything. The Events `User`
table keeps its id (now equal to the account `sub`), role, campus scope and product data.
- Sign-in, forgot password, 2FA, the news question and the notice re-acceptance move to Accounts.
The Events privacy centre links to Accounts for account-level items.
- Every place Events sends someone to sign in today (`/login?redirect=…`, `useRequireAuth`, the
register / ticket flows) goes to Accounts with the current page as the return target, so
members land back exactly where they were (§3, "Sign-in always returns you to where you came
from"). Old `/login?redirect=…` links keep working by forwarding to Accounts with the same target.
- Staff accounts work the same way: roles stay in Events.
---
## 4. Event sync in any direction
Every product can **publish** the events it owns and **show** events from its siblings. Each
install has two settings:
- **Publish my events:** on / off.
- **Show events from:** ☐ Events ☐ Website ☐ App (only linked products are listed).
That covers every setup: Events → Website and App; Events and App → Website; everything to
everything; or nothing.
### Rules
- **Only the origin edits an event.** Imported events are read-only copies with a link back.
Corrections happen at the origin and arrive with the next sync.
- **No loops:** a product only publishes events it *originated*, never ones it imported.
- **No duplicates:** an event is identified by `origin.product` + `origin.id`.
- **Only Cross Events takes registrations and payments.** The Website and App can create
**simple events** (title, dates, place, image, description, and an optional external link or
contact instructions).
- Each product keeps the last good copy of each feed. If a source is down, its events keep
showing, and admins see "last synced 2 hours ago".
### Transport
- `GET /api/feed/v1/events?updatedSince=<ISO>` → `{ schema: "crosscode.event-feed/1", generatedAt, events: [...], deleted: [...] }`.
Public events only, so no personal information ever goes through the feed.
- A signed webhook `event.changed` tells consumers to fetch straight away.
- Consumers also poll every 15 minutes as a fallback (pg-boss job).
- Timeouts, retries with back-off, and one failing source never blocks another.
### `crosscode.event/1`
```json
{
"schema": "crosscode.event/1",
"id": "8f1c2a4e-…",
"origin": {
"product": "events",
"installId": "inst_…",
"url": "https://events.hopefamily.co.za/events/youth-camp"
},
"status": "published",
"updatedAt": "2026-11-03T10:00:00Z",
"title": "Youth Camp 2026",
"summary": "Three days at Camp Wartburg",
"description": "Sanitised markdown…",
"imageUrl": "https://events.hopefamily.co.za/uploads/…",
"timezone": "Africa/Johannesburg",
"startsAt": "2026-03-14T08:00:00Z",
"endsAt": "2026-03-16T14:00:00Z",
"allDay": false,
"sessions": [
{ "id": "…", "label": "Day 1", "startsAt": "…", "endsAt": "…", "location": null, "cancelled": false }
],
"location": { "name": "Camp Wartburg", "address": "…", "onlineUrl": null },
"campus": { "slug": "meyerton", "name": "Meyerton" },
"series": { "id": "…", "name": "Youth Camp" },
"tags": ["youth"],
"registration": {
"mode": "origin",
"url": "https://events.hopefamily.co.za/events/youth-camp",
"status": "open",
"opensAt": null,
"closesAt": "2026-03-10T22:00:00Z",
"priceFrom": { "amountCents": 45000, "currency": "ZAR" }
}
}
```
| Field | Values / notes |
|-------|----------------|
| `origin.product` | `events` · `website` · `app` |
| `status` | `published` · `cancelled` (unpublished or deleted events go in `deleted`) |
| `sessions` | Empty for a single-date event. `startsAt` / `endsAt` are always the first start and last end |
| `campus` | `null` = all campuses or campuses off |
| `registration.mode` | `origin` (register at the origin, Events only) · `external` (a link) · `contact` (instructions in the description) · `none` |
| `registration.status` | `open` · `not_open` · `closed` · `full` · `waitlist`. Only Events sends anything but `open` |
| `priceFrom` | Lowest current price in cents, or `null` for free / not applicable |
---
## 5. Cross Code Hub (all products)
The Events Hub (`joshua/events-hub`, Hub 1.7.0) becomes **Cross Code Hub 2.0**: one place for every
product's customers, licences, stats, AI usage and billing.
- **Customer → installs.** Each install has a `product` (`events` · `website` · `app` ·
`accounts`) and its **own licence**. Accounts is licensed with whatever else the church has
(no charge of its own). **A customer has at most one install of each product** (decided
2026-11-03): the Hub refuses a second licence for the same product, so "the church's website"
or "the church's Events" is never ambiguous.
- **Address per customer:** own domain, Cross Code address (`church.crosscode.co.za`) or domain
included (§10), recorded on the customer, since one address covers all its products. The
Hub reminds Joshua before an included domain is due for renewal.
- **Add-ons per customer:** the address (R10 or R25) and extra website storage, billed with the
products but never bundle-discounted. Website installs report storage used in the heartbeat.
- **Billing per customer:** prices per product on the income bands, bundles at **15% off two
products** and **25% off all three**, founding prices and the free month (as on
crosscode.co.za). Invoices are recorded in the Hub (sent / paid / overdue). A payment provider
integration comes later.
- **Protocol v2:** adds `product` to register and heartbeat, and per-product stats (aggregate
counts only, as today: no personal information). The Hub keeps accepting protocol v1, so Events
installs keep reporting until they update.
- **Sibling discovery:** the licence token carries the `customerId`. The heartbeat reply lists the
church's other installs (product, URL, public key; nothing else). Each product shows them
under **Settings → Linked products** with a **Connect** button that sets up the feed (§4) and
the Accounts client (§3). The list is cached, so a Hub outage changes nothing.
- **AI:** the existing proxy, cap and markup serve every product. Usage is recorded per product,
and the cap stays per customer.
- **Releases per product.** Each product has a release source: repo, tag pattern and notes
file. Releases carry a `product`, so "update available", **Request update** and "versions
behind" work per install.
| Product | Repo | Tags | Notes |
|---------|------|------|-------|
| Events | `crosscode-platform` | `events@2.2.0` on | `apps/events/WHATS-NEW.md` |
| Events (before the move) | `hope-events` (archived) | `v1.0.0` … `v2.1.x` | its `WHATS-NEW.md` |
| Accounts | `crosscode-platform` | `accounts@1.2.3` | `apps/accounts/WHATS-NEW.md` |
| Website | `crosscode-platform` | `website@1.2.3` | `apps/website/WHATS-NEW.md` |
| App | `crosscode-platform` | `app@1.2.3` | `apps/app/WHATS-NEW.md` |
`contracts@` tags are ignored (nobody installs them). The Events releases before the move stay
(today's sync removes releases without a tag; with two sources it only removes a release
missing from its own source).
- **Requirements between products.** A release can say what it needs, in its `WHATS-NEW.md`
heading: `## 3.0.0 — requires accounts 1.2.0` (several: `requires accounts 1.2.0, website
1.1.0`; it can be combined with `— urgent`). The Hub shows it on the install, and **Request
update stays disabled** ("Accounts must be updated to 1.2.0 first") until the church's install
of that product reports that version or later. The `crosscode` script checks the same thing
before it deploys (§8). By default products never need to update together: the contract rules
(§2) and protocol v1 + v2 make that possible, so requirements are only for real breaking moves
such as Events 3.0.
- **Request update** works for every product. The Hub still never runs anything on an install.
- **Domain:** `hub.crosscode.co.za`. `events-hub.crosscode.co.za` stays as an alias so existing
installs keep working.
- Dashboard: installs per product, versions behind latest, health, licence status, monthly
recurring revenue, overdue invoices.
---
## 6. Church Website
A professional public website the church manages itself. From the website spec, adjusted.
### Public site
- **Pages:** Home, New Here? / Plan Your Visit, About (story, mission, vision, values, beliefs),
Leadership, Ministries (overview + one page each), Events (list, detail, calendar, past),
Messages (sermons, series), Give (information and provider links), Contact, Privacy /
POPIA / safeguarding / terms.
- **Homepage order:** hero with Plan Your Visit, service times and location, mission,
introduction, ministries and team, upcoming events, latest message, final invitation (each
template starts from this and arranges it its own way, below).
- Server-rendered, fast on a phone, accessible (WCAG 2.2 AA, axe checks in Playwright), with
the SEO in §11.
### Templates: each church its own identity
The website is the most personal of the products, so churches shouldn't all look the same.
The Website ships with **four templates**, each with its own **layout and design**. The church
picks one; its **logo and colours come from the church profile** (`crosscode.church/1`), and its
**content fills into the chosen template**.
| Template | Character | Layout and design |
|----------|-----------|-------------------|
| **Heritage** | Traditional, dignified | Centred logo and navigation above a framed hero image; serif headings; generous margins; content in a single centred column with thin rules between sections; service times in a formal panel |
| **Bold** | Modern, energetic | Full-screen photo or video hero with large overlaid headline; sticky transparent header; strong sans-serif type; full-bleed alternating image / text bands; large "Plan your visit" call to action |
| **Community** | Warm, friendly, family | Logo left, rounded friendly type; a hero with photo collage; content in cards (ministries, events, messages as a grid); soft shapes and the church colour as tinted backgrounds; people's faces prominent |
| **Clean** | Minimal, calm | Small header, lots of white space; typographic hero (no photo needed, good for churches without good photos); narrow text column; lists rather than cards; colour used sparingly as accent |
**How it works**
- **One content model, four presentations.** Content is structured (pages, sections, ministries,
leaders, messages, events, service times), never tied to a template. Every template renders
every section type in its own way, so **switching template never loses content**.
- **The template sets the layout:** header and navigation style, hero type, how each section
type looks, section spacing, typography (with two or three font pairings to choose from per
template), shapes and image treatment, and the default homepage order. Within it, churches can
reorder, hide and show the approved sections, but not edit layout freely, so a site always
looks designed.
- **Colours from the church, made safe:** the template builds its palette (tints, shades, text
colours, buttons) from the church's primary and accent colours, and checks contrast (WCAG AA).
A colour that fails is darkened or lightened automatically, and the admin is told why.
- **Logo** in the template's header and footer positions; the icon (from the profile) for the
favicon and share images.
- **Choosing:** the setup checklist starts with "Choose your template", showing all four
**previewed with the church's own logo, colours and content** (sample content until they have
their own). Switching later goes through preview → publish like any other change.
- **Templates are part of the product**, not per church (§1, rule 3): they improve in each
release for every church using them. More templates can be added in later releases.
- Every template meets the same bar: tested in Playwright (layout at phone / tablet / desktop,
axe accessibility) and with the §11 SEO checks.
### Admin (`www.church.co.za/admin`, signed in through Accounts)
- Dashboard with a setup checklist, recent edits and drafts.
- **Template:** choose and preview the template, font pairing, and which homepage sections show
in which order.
- Pages and sections, About, Leadership, Ministries, Messages, Media library, Contact and
service times, Navigation, Settings (including SEO and search engine verification, §11).
- **Events:** simple events, plus the feed settings (§4) and sync health.
- **Draft → preview → publish**, archive instead of delete, an audit log of who changed what.
- **Roles:** Owner, Admin, Editor, Ministry leader (own ministry only), Viewer.
### Not in the first release
Prayer requests and other forms with personal information (they belong in the App), a member
directory, online giving processing.
---
## 7. Church App
A church-branded **web app** (PWA) at `app.church.co.za`, added to the home screen. No app-store
release. From the app spec, adjusted.
### House of Prayer (the headline)
- **24/7 coverage view:** a 24-hour timeline, coverage % and gaps, in the church's timezone.
Live updates through server-sent events, with polling as a fallback.
- **Commitments:** one-off and recurring slots, change or cancel your own, optional reminders
by **email or WhatsApp**, a voluntary "I prayed" marker clearly shown as self-reported.
- **Booking rules** (defaults decided 2026-11-03; the church can change them):
| Rule | Default |
|------|---------|
| Slot length | **1 hour** (church can choose 15, 30 or 60 minutes) |
| Book ahead | Up to **12 months** in advance |
| Recurring bookings | Daily or weekly, for up to **12 months from the day of booking**. Before it runs out the member gets an email/WhatsApp to renew it for another year |
| Cancelling | Up to **8 hours before** the slot. Within 8 hours the slot can't be cancelled in the app (the member is shown who to contact) |
- **Anonymous:** public views show coverage only, never names. Admin access to bookings is
permission-controlled and goes in the access log.
- **Prayer requests:** private by default (submitter and prayer team only). Public or group
prayer needs an explicit choice. Moderation (approve, redact, remove) and a safeguarding
escalation route. Never used for analytics or AI.
- **Campaigns and testimonies:** time-bound campaigns with collective progress (no rankings),
testimonies moderated before publishing.
### Other modules (each can be switched off by the church)
Announcements, devotionals, reading plans, sermons (shared with the Website where both exist),
connect groups, pastoral care (restricted access, audited), serving opportunities, giving links,
an opt-in directory, and **events** (simple events plus the feed, §4).
**Every module is available to every church.** Pricing follows church size (§10), not features.
**Notifications are email and WhatsApp only** (decided 2026-11-03), using the same email and
WhatsApp setup as Cross Events. No push notifications. Members choose their channel per kind of
message (prayer reminders, announcements).
### Roles
Member, Prayer coordinator, Content admin, Pastoral care, Church admin. Public content can be
browsed without an account. Personal actions need sign-in through Accounts.
---
## 8. Servers and deployment
**PM2 and nginx, like Cross Events today.** No Docker.
- **Per church:** one PM2 process (or front/back pair) per product, one PostgreSQL database and
database user per product, its own `.env`, secrets, uploads folder, logs and backups. Names
carry the church slug (`hopefamily-website`, `hopefamily_website` DB, …).
- **One `crosscode` script** installs and updates every product (below, "Releases and updates"),
built from today's `install.sh` and `deploy.sh`: a tagged update with a database backup,
migrations, build, restart and roll back on failure, for whichever products you name.
- **Domains:** `www.`, `events.`, `app.` and `accounts.` on the church's own domain, or on
`<church>.crosscode.co.za` for the Cross Code address (a wildcard DNS record and certificate
for `*.crosscode.co.za` subdomains, so no DNS work per church). Certificates through certbot. Only configured domains are served (no trusting arbitrary Host headers).
- Every product has `/health` (version, DB, jobs, last backup), reported to the Hub.
- Several churches can share a server at first. Any church can move to its own server later
without code changes. The Hub stays on a separate server from church installs.
### Repositories
| Repo | What |
|------|------|
| `joshua/crosscode-platform` | **Every product a church installs**, and what they share: `apps/events` (Cross Events, moved here in 2.2.0), `apps/accounts`, `apps/website`, `apps/app`, `packages/contracts`, `packages/ui`, `packages/seo` (§11), and the `crosscode` script (`scripts/`) |
| `joshua/hope-events` | Cross Events up to 2.1.x. **Archived** after the move (read-only, README points to the monorepo), and kept so installs that haven't moved yet can still deploy or roll back a `v2.1.x` tag |
| `joshua/events-hub` | Cross Code Hub (renamed in the UI; repo name can stay). Stays on its own: it runs on its own server, is never installed for a church, and is deployed with its own `scripts/deploy.sh`. Gets the contracts as a committed file (§2) |
| `joshua/crosscode-site` | The marketing site (crosscode.co.za). Updated at the end of every phase that changes what's on sale or what a product does (see §9, "Updating crosscode-site") |
**Stack:** the same as Cross Events (Next.js, Express, TypeScript, Prisma, PostgreSQL, pg-boss,
Playwright, Jest), so code and habits carry over.
### Releases and updates
**What doesn't change:** git is how code reaches a server, the Hub only records releases and
passes on update requests (it never deploys, §1 rule 2), and Joshua runs the deploys.
**Releasing.** Each product is tagged on its own in the monorepo: `events@2.2.0`,
`accounts@1.0.0`, `website@1.0.0`, `app@1.0.0` (and `contracts@1.0.0`, which nobody installs).
Each app keeps its own `CHANGELOG.md` and church-facing `WHATS-NEW.md` in its folder. The release
routine is as today (branch → merge → tag → push), with the product's name in the tag. The Hub
picks the tags up per product (§5).
**On a server.** Each church gets a folder with a config file and one checkout of the monorepo
per product, each at that product's own tag:
```
/opt/crosscode/
tools/ the crosscode script (a checkout of main, updated before every run)
churches/hopefamily/
church.env slug, products, domains, ports, PM2 names, database names, folders
accounts/ crosscode-platform at accounts@1.0.0
events/ crosscode-platform at events@3.1.0
website/ crosscode-platform at website@1.2.0
```
A product's folder can live elsewhere (`church.env` says where), so existing installs such as
`/opt/hope-events` don't have to move, and their PM2 names, nginx names and database names stay
as they are. Each checkout installs and builds only its own app (`npm ci -w apps/website`, its
own migrations), so a Website install never builds Events.
**The script** (run as root or the app user; `crosscode help` lists everything):
| Command | What it does |
|---------|--------------|
| `crosscode deploy <church> [events] [website] [app]` | Updates **Accounts, always**, plus the products named, to their latest released tags. Products not named aren't touched |
| `… --version events@3.1.0` | A specific release instead of the latest (repeatable, one per product) |
| `… --main` | The latest main instead of a tag (for testing on a staging church only) |
| `crosscode deploy <church> --check` | Only the checks below; changes nothing |
| `crosscode deploy --every-church [events] …` | The same deploy for every church on this server, one after the other |
| `crosscode status [<church>]` | Each product's version, latest release, health (from `/health`) and last backup |
| `crosscode install <church> --products events,website --domain … ` | A new church (from today's `install.sh`): Accounts always, plus the products named; databases, `.env` files, PM2, nginx and certificates; prints the DNS records needed |
| `crosscode add <church> app` | Adds a product to an existing church |
| `crosscode adopt <church> --events-dir /opt/hope-events` | Once per existing Cross Events install: moves it onto the monorepo (below) |
Before Accounts exists (until phase 3), "always Accounts" is skipped for churches that don't have
it yet.
**How a deploy runs**
1. **Checks first, for every product named; if one fails nothing is touched:** files changed by
hand in a checkout (as `deploy.sh --check` today), the Node version, and the requirements of
the target releases (§5: `events@3.0.0` needs `accounts@1.2.0`, and Accounts is either already
there or part of this deploy).
2. **In a fixed order: Accounts, then Events, Website, App.** Each product: database backup →
check out its tag → install and build its app → migrations → PM2 reload → `/health` answers
with the new version. If any step fails, **that product rolls back** to its previous tag (as
`deploy.sh` does today) and the deploy stops there.
3. Products already updated in this run stay updated (no product needs its siblings to update
with it, §5), and the summary says which ones were updated, which rolled back and which weren't
reached.
**Moving existing Cross Events installs (Events 2.2.0, `crosscode adopt`).** Once per server
install, run by Joshua. It writes `church.env` (pointing at the existing folder), changes the
checkout's remote to `crosscode-platform`, moves the install's own files to the new paths
(`backend/.env` → `apps/events/backend/.env`, likewise the frontend `.env`,
`backend/public/uploads/`, `backend/backups/`, `backend/data/*.json`, and `ecosystem.config.js`
with its `cwd` paths updated), checks out `events@2.2.0`, builds and reloads. The folder, PM2
names, nginx config (it only proxies to ports), database and its name stay the same. If anything
fails it moves the files back and returns to the `hope-events` remote and the version it was on.
Installs that haven't moved yet keep working with `deploy.sh` on the archived repo.
---
## 9. Build order
| Phase | Version | What | Why this order |
|-------|---------|------|----------------|
| 0 | contracts 1.0.0 | `crosscode.event/1`, `crosscode.church/1`, `crosscode.identity/1`, Hub protocol v2: schemas, generated JSON Schema, docs, sample payloads, the monorepo set up. **Released 2026-11-04** | Everything else depends on them |
| 1 | **Hub 2.0.0** | Cross Code Hub: products, licence per product, per-customer billing with bundles, protocol v2 (v1 still accepted), sibling discovery, AI usage per product, **releases per product with requirements** (§5), contracts as a committed file (§2) | Must exist before a second product is sold, and before Events releases come from the monorepo |
| 1b | **Cross Events 2.2.0** | **Moves into the monorepo** as `apps/events`, with its git history (merged in, not copied); its packages become workspaces; the **`crosscode` script** (`deploy`, `status`, `install`, `adopt`; §8) replaces `scripts/deploy.sh` and `scripts/install.sh`; every install moved with `crosscode adopt`; `hope-events` archived. No feature changes, so a problem is clearly the move | Before more products, so every product is released and deployed the same way from the start |
| 1c | **Cross Events 2.3.0** | **Authenticator app 2FA** (TOTP) next to email and WhatsApp, with recovery codes; admins can see which method each staff account uses | Independent and useful now; the Accounts migration later carries it over |
| 2 | **Cross Events 2.4.0** | Reports `product: "events"` (protocol v2), **Linked products**, the event feed (publish + show events from siblings), `packages/seo` (§11) with canonical links for imported events | Small; ready for when the Website exists |
| 3 | **Accounts 1.0.0** | OIDC provider, sign-in, 2FA (email, WhatsApp, authenticator app), password reset, consent and notice, export / delete fan-out, admin, install script | The Website is built on it from day one |
| 4 | **Church Website 1.0.0** | Public site with the **four templates** (§6), admin, simple events + feed, SEO (§11), Hope Family Church as the first church, then a second test church on a different template to prove it repeats | More churches need a website than an app; it also covers the "website only" case |
| 5 | **Cross Events 3.0.0** | Sign-in moves to Accounts; user migration keeping password hashes; migration guide | Breaking change, so a major version, after Accounts is proven on the Website |
| 6 | **Church App 1.0.0** | Foundation (PWA, branding, Accounts), House of Prayer, announcements, devotionals, events (simple + feed) | Starts with shared users and the feed already in place |
| 7 | Church App 1.x | Reading plans, sermons, groups, pastoral care, serving, giving links, directory | After House of Prayer is in use |
Each phase adds help content, audit log entries, tests, a CHANGELOG entry and updates to the
docs. Branch → merge → tag → push per feature, as before. **A phase isn't done until
crosscode-site is updated** for it (below), where the phase changes anything the site says.
### Updating crosscode-site
The marketing site (`../crosscode-site`, crosscode.co.za) has to match what's actually shipped.
At the end of each phase, update it in the same sitting and deploy it (its `DEPLOYMENT.md`):
| Phase | What changes on crosscode-site |
|-------|--------------------------------|
| 0 contracts | Nothing |
| 1 Hub 2.0.0 | Bundle discounts (15% / 25%) and the address add-on (own domain R0 · Cross Code address R10 · domain included R25) in `src/data/pricing.mjs`, worded so it's clear the Website and App are still coming. Legal pages if billing terms change |
| 1b Events 2.2.0 | Nothing (the move doesn't change what Cross Events does) |
| 1c Events 2.3.0 | Authenticator app 2FA in the Cross Events feature list (`src/data/cross-events.mjs`) and the security / POPIA wording |
| 2 Events 2.4.0 | "Works with the Church Website and App" (event feed, Linked products) on the Cross Events page, as "coming" until those ship |
| 3 Accounts 1.0.0 | "One sign-in for all your church's products" on the Products page. Accounts isn't sold, so no page or price of its own |
| 4 Website 1.0.0 | **Church Website goes on sale:** `status: "available"` and its own page in `src/data/products.mjs` / `src/pages/products/`, R99 + storage add-on in `pricing.mjs`, **a showcase of the four templates** (a page per template with screenshots of the same demo church in each), screenshots from a demo church (fictional people only), FAQ, home page, contact form product list, `SoftwareApplication` JSON-LD (§11) |
| 5 Events 3.0.0 | Cross Events page: sign-in through the church's shared account. Update screenshots that show the login |
| 6 App 1.0.0 | **Church App goes on sale:** own page, active-member tiers in `pricing.mjs`, House of Prayer screenshots, the bundle examples from §10 |
| 7 App 1.x | New modules added to the App page's feature list as each one ships |
Prices change in the Hub's `frontend/src/lib/tiers.ts` in the same phase, so the site and the
Hub never disagree. Run the site's tests and build before deploying it.
### Release checks (before selling a product)
- Two churches installed from the same tagged release with `crosscode install`, with separate databases, secrets and uploads.
- `crosscode deploy` updates a church from the previous release, and a failure part-way (e.g. a
broken migration) rolls that product back and leaves the others running.
- Church A's admin can't read or change Church B's data through direct API calls.
- Stopping one church's install, or the Hub, leaves the others running.
- A feed source going down: its last events keep showing, the other sources keep working.
- Signing in once works across all of a church's products; deleting an account reaches every product.
- Signing in from any page of any product returns to that same page (including through 2FA and a
password reset), and a foreign return URL is refused.
- crosscode-site shows the product as available, with its page, prices and screenshots.
- The §11 SEO checks pass (Playwright and Lighthouse), for the Website in all four templates.
- A database backup is restored successfully.
---
## 10. Pricing
Aimed at small and mid-sized South African churches: the Website and App start well below Cross
Events, since they replace things churches often get cheaply or free today (a basic website,
WhatsApp groups). Prices are rand per month. Decided by Joshua on 2026-11-03.
When the Website and App go on sale, they go in `crosscode-site/src/data/pricing.mjs` and the Hub's
`frontend/src/lib/tiers.ts`.
**The same rules as Cross Events apply to every product:** one month free, yearly billing = 10
months' price, founding churches 35% off for as long as they stay, bundles **15% off** two
products and **25% off** all three, and the tier is checked once a year at renewal. **Add-ons**
(address and storage) are not discounted.
### The church's address: three options
One address covers all of a church's products (`www.`, `events.`, `app.`, `accounts.`), so it's
charged **once per church**, not per product.
| Option | Price | What it means |
|--------|-------|---------------|
| **Own domain** | R0 | The church already has a domain (e.g. `hopefamily.co.za`). They, or whoever manages it, add the DNS records we send. We look after the certificates |
| **Cross Code address** | **+R10 / month** | For churches that don't want a domain of their own: `hopefamily.crosscode.co.za`, with the products at `events.hopefamily.crosscode.co.za`, `app.hopefamily.crosscode.co.za` and so on. Can move to a domain of their own at any time (old addresses redirect for 12 months). Trials use this address for free |
| **Domain included** | **+R25 / month** | We register a `.co.za` or `.org.za` **in the church's name**, renew it every year and manage the DNS. Other endings (`.com`, `.church`, `.org`) are charged at cost on top. If the church leaves, the domain is transferred to them, free |
### Church Website: one price plus storage
| | Price |
|--|------:|
| **Church Website** (5 GB of uploads included) | **R99** |
| Founding (35% off) | R64 |
| **Extra storage** add-on | **+R20 / month per extra 5 GB** |
Uploads are photos, documents and sermon notes. Sermon audio and video are linked (YouTube,
Spotify, SoundCloud…) rather than uploaded, so most churches never need extra storage. Admins see
how much they've used in the website's settings and get an email at 80%. Storage is counted per
church and reported to the Hub, which bills the add-on.
### Church App: on active members
Billed on **active members**: accounts that used the App in the last 90 days, reported to the Hub
as a band (like event income for Cross Events). Visitors browsing without signing in never count.
| Tier | Active members | Price | Founding |
|------|----------------|------:|---------:|
| Mustard Seed | Up to 100 | **R99** | R64 |
| Small Group | 101 – 300 | **R249** | R162 |
| Congregation | 301 – 750 | R399 | R259 |
| Gathering | 751 – 2 000 | R649 | R422 |
| Multitude | 2 001 – 5 000 | R999 | R649 |
| Network | Over 5 000 | From R1 500 | 35% off |
### What churches would pay (no founding discount)
| Church | Products | Products after bundle | + address | Per month |
|--------|----------|----------------------:|----------:|----------:|
| Small, website only | Website | R99 | own domain | **R99** |
| Small, website only | Website | R99 | Cross Code address | **R109** |
| Small, website only | Website | R99 | domain included | **R124** |
| Small, website + app | Website + App (≤100): R198 | R168 (15%) | domain included | **R193** |
| Small, everything | Events Seed + Website + App (≤100): R548 | R411 (25%) | domain included | **R436** |
| Mid-sized, everything | Events Grow + Website + App (301–750): R1 348 | R1 011 (25%) | domain included | **R1 036** |
(Rounded to the rand.)
---
## 11. SEO (every public site)
Every public site gets search engine optimisation at least as good as Cross Events has today:
the **Church Website**, **Cross Events**, the public parts of the **Church App**, and the
marketing site **crosscode.co.za**. A church should be found when someone searches "church near
me", its name, or an event it's running.
### The baseline (what Cross Events does now, for every product)
- **Server-rendered HTML** with a unique `<title>` and meta description on every page.
- **Canonical URL** on every page (tracking tags such as `?src=` dropped).
- **Open Graph and Twitter cards** with an image on every page. Pages without their own image
get a share image generated from the church's logo and colours.
- **`sitemap.xml`** generated from published content and refreshed hourly, and **`robots.txt`**
that points to it and keeps admin, sign-in and personal pages out.
- **schema.org JSON-LD** (e.g. `Event` on event pages, as `frontend/src/lib/eventSchema.ts` does,
checked with Google's Rich Results Test).
- Readable slugs, with **301 redirects** when a slug changes; real 404 status for missing pages;
`lang="en-ZA"`.
- **Fast and accessible:** responsive images with required alt text, good Core Web Vitals on a
phone (LCP under 2.5 s, CLS under 0.1), WCAG 2.2 AA.
The JSON-LD builders and meta helpers live in a shared `packages/seo` in the platform monorepo,
built on `@crosscode/contracts`, so Events, the Website and the App produce the same schema.org
data from the same `crosscode.event/1` and `crosscode.church/1`. Cross Events moves its
`eventSchema.ts` onto it in 2.4.0.
### Per product
| Product | SEO |
|---------|-----|
| **Church Website** | JSON-LD `Church` (name, logo, address, geo, phone, service times, social links) on the home and contact pages, so the church shows up in local search and maps; `Event` for its events; `VideoObject` / `PodcastEpisode` for messages; `BreadcrumbList`. Admin: per-page title and description (with a Google-style snippet preview), warnings for a missing description, alt text or a too-long title, and a field for Google Search Console / Bing verification. Setup checklist links to setting up a Google Business Profile. All four templates produce the same markup and pass the same checks |
| **Imported events** | A copy of another product's event (§4) has `<link rel="canonical">` pointing to the **origin's** page, so search engines see one event, not three. Only the origin is in its sitemap |
| **Cross Events** | Already has the baseline. Gains the shared `packages/seo`, `Organization` / `Church` data from the church profile, and the canonical rule for imported events |
| **Church App** | Public content (events, devotionals, sermons, announcements marked public) gets the baseline. Everything signed in, prayer and care is `noindex` and kept out of the sitemap. **Prayer requests are never indexable** |
| **Accounts** | `noindex` everywhere, blocked in `robots.txt`: sign-in pages should never appear in search |
| **Cross Code Hub** | `noindex` everywhere |
| **crosscode.co.za** | Already has canonical URLs, Open Graph, `Organization` / `WebPage` JSON-LD, sitemap and robots. Adds `SoftwareApplication` JSON-LD with prices (`Offer`) on each product page, `FAQPage` on the FAQs, a page per Website template, and Search Console / Bing set up and checked after each site update (§9, "Updating crosscode-site") |
### Moving addresses
When a church moves from the Cross Code address to its own domain (§10), every old URL
**301-redirects** to the same page on the new domain for 12 months, so it keeps its search
ranking.
### Checks (in every product's tests)
- Playwright: every public page has a title, description, canonical, `og:image`, exactly one
`<h1>`, and JSON-LD that parses; the sitemap lists the published pages and nothing private;
`robots.txt` blocks admin and sign-in; `noindex` pages really are `noindex`.
- Lighthouse (mobile) on a demo church's home, event and message pages, in every template:
SEO 100, accessibility 95+, performance 90+.
---
## Decisions (2026-11-03)
| # | Question | Decision |
|---|----------|----------|
| 1 | Separate infrastructure | **Yes:** one install and database per product per church, no shared databases, Hub not on the runtime path |
| 2 | Shared users | **A separate Accounts service per church** (OpenID Connect), used by every product. Roles stay per product |
| 3 | Docker | **No.** PM2 and nginx, like Cross Events |
| 4 | Simple events in the Website and App | **Yes.** Only Cross Events takes registrations and payments. (The app spec's "no event creation in the App" is overruled) |
| 5 | Event sync | Any direction, chosen per install ("Publish my events", "Show events from"). One shared format, `crosscode.event/1`. Only originated events are published |
| 6 | Domains | `www.`, `events.`, `app.` (and `accounts.`) on the church's own domain |
| 7 | Hub | Becomes **Cross Code Hub**: every product's licences, stats, AI and billing in one place, still read-only toward installs |
| 8 | Stack | Next.js + Express + Prisma + PostgreSQL + pg-boss, as Cross Events (not the specs' React + Vite, Redis + BullMQ, Socket.IO) |
| 9 | Pricing | Income bands per product, bundles 15% (two) / 25% (three); every App module for every church (not the website spec's Starter / Growth / Pro tiers) |
| 10 | Hub provisioning | Scripts Joshua runs; the Hub records installs but never deploys or rolls back (not the specs' automated provisioning) |
| 11 | Installs per product | **One install of each product per customer**; the Hub refuses a second licence for the same product |
| 12 | House of Prayer defaults | 1-hour slots, book up to 12 months ahead, recurring bookings for up to 12 months from the day of booking, no cancelling within 8 hours of the slot |
| 13 | App notifications | **Email and WhatsApp only.** No push notifications |
| 14 | Accounts visibility | Hidden from members (branded as the church's sign-in). Only admins and the account holder see account pages |
| 15 | 2FA | Add an **authenticator app** (TOTP, with recovery codes) next to email and WhatsApp: in Cross Events 2.3.0 first, then in Accounts |
| 16 | Pricing | Website **R99** with 5 GB, extra storage as an add-on in steps. App **R99** (up to 100 members), then **R249**. Address once per church: own domain R0, **Cross Code address R10**, **domain included R25**. Extra storage R20 per 5 GB; App tiers above 300 members R399 / R649 / R999 / from R1 500. Add-ons aren't bundle-discounted (§10) |
| 17 | Sign-in return | Signing in through `accounts.church.co.za` always returns to the product and page the person came from (through 2FA, reset, sign-up and notice steps), with exact-match redirect URIs only (§3) |
| 18 | Marketing site | crosscode-site is updated at the end of every phase that changes what's on sale or what a product does (§9) |
| 19 | Website templates | **Four templates** (Heritage, Bold, Community, Clean), each with its own layout and design. Colours and logo come from the church; content fills the chosen template; switching never loses content (§6) |
| 20 | SEO | Every public site (Website, Events, App's public pages, crosscode.co.za) gets at least Cross Events' SEO, through a shared `packages/seo`; Accounts and the Hub are `noindex` (§11) |
**Added 2026-11-04**
| # | Question | Decision |
|---|----------|----------|
| 21 | Repos | **Cross Events moves into `crosscode-platform`** (`apps/events`, Events 2.2.0, history kept); `hope-events` is archived. The Hub stays in its own repo (own server, never installed for a church) |
| 22 | Contracts for the Hub | A packed release **committed to the Hub's repo** (`file:vendor/…`), not Gitea's package registry, so no deploy depends on a registry or a token (§2) |
| 23 | Deploying | **One `crosscode` script** for every product: `crosscode deploy <church> [events] [website] [app]` always updates Accounts and then the products named, in a fixed order, each rolled back on its own if it fails. Still git checkouts of tags, still run by Joshua; the Hub never deploys (§8) |
| 24 | Product requirements | A release can require a minimum version of a sibling (`— requires accounts 1.2.0` in `WHATS-NEW.md`). The Hub **disables Request update** until it's met, and the script refuses to deploy without it (§5, §8) |
## Still open
Nothing. crosscode-site is updated at the end of each phase (§9, "Updating crosscode-site"); the
Website and App move from "coming later" to their own pages and prices when they ship.
+10 -2
View File
@@ -100,7 +100,8 @@ X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
"paymentProviders": ["yoco", "payfast"],
"campuses": false, // Site settings → Campuses on/off
"campusCount": 0, // active campuses (0 while off; Cross Events 1.43.0+)
"income": { "last30d": "R5 001–R20 000", "allTime": "over R100 000" }
"income": { "last30d": "R5 001–R20 000", "allTime": "R150 001–R250 000",
"eventIncome12m": "R100 001–R150 000" } // eventIncome12m: Cross Events 2.0.2+
}
}
→ 200 {
@@ -131,7 +132,14 @@ X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
clock are refused (`401 STALE_REQUEST`); a bad signature is `401 BAD_SIGNATURE`; an unknown or
released install is `404 INSTALL_NOT_FOUND` (the install should show "Reconnect to Hub").
- **Income bands** (worked out on the install; the amount never leaves it): `R0–R1 000`,
`R1 001–R5 000`, `R5 001–R20 000`, `R20 001–R50 000`, `R50 001–R100 000`, `over R100 000`.
`R1 001–R5 000`, `R5 001–R20 000`, `R20 001–R50 000`, `R50 001–R100 000`,
`R100 001–R150 000`, `R150 001–R250 000`, `R250 001–R400 000`, `R400 001–R700 000`,
`R700 001–R1 000 000`, `R1 000 001–R2 500 000`, `R2 500 001–R5 000 000`,
`R5 000 001–R10 000 000`, `over R10 000 000`. Before Cross Events 2.0.2 the bands stopped at
`over R100 000`, and donations applied to a registration were counted twice.
- **`eventIncome12m`** (Cross Events 2.0.2+) is the band of the last 365 days' event income:
payments less refunds, without donations or sponsorships. Cross Code's licence tiers are billed
on it, so every tier edge is a band edge. Older installs don't send it.
- The Hub keeps every heartbeat for 30 days, then one per install per day.
## 3a. Update request
+61
View File
@@ -0,0 +1,61 @@
import { test, expect, loginAs, api, v2, seed, API } from "./fixtures";
// 2.1.0: Supervisor → Feedback. The seed puts a named feedback form on "E2E Last Sunday", sent
// two days ago to Thandi (who answers here) and Lerato (who doesn't).
type PublicForm = { fields: { id: string; type: string }[] };
type AccessLog = { rows: { action: string; what: string; records: number | null; actor: string }[] };
test.describe("feedback page", () => {
test("see the answers, who hasn't answered, and remind them", async ({ page }) => {
const token = seed().feedback.tokens.user;
const form = await (await page.request.get(`${API}/api/v2/feedback-forms/${token}`)).json() as { data: PublicForm };
const [rating, text] = form.data.fields;
const sent = await page.request.post(`${API}/api/v2/feedback-forms/${token}`, {
headers: { "Content-Type": "application/json" },
data: JSON.stringify({ answers: { [rating.id]: 5, [text.id]: "Loved the worship" } }),
});
expect(sent.ok()).toBe(true);
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/feedback");
const row = page.getByRole("row", { name: /E2E Last Sunday/ });
await expect(row).toContainText("1 of 2");
await expect(row).toContainText("5.0");
await row.getByRole("button", { name: "E2E Last Sunday" }).click();
await expect(page.getByRole("heading", { name: "E2E Last Sunday" })).toBeVisible();
await expect(page.getByText("Loved the worship")).toBeVisible();
await expect(page.getByText("— Thandi Test")).toBeVisible();
await page.getByRole("tab", { name: /Responses/ }).click();
await expect(page.getByRole("article").filter({ hasText: "Thandi Test" })).toContainText("★★★★★");
await page.getByRole("tab", { name: /People/ }).click();
const lerato = page.getByRole("row", { name: /Lerato Late/ });
await expect(lerato).toContainText("Not yet");
await expect(page.getByRole("row", { name: /Thandi Test/ }).getByRole("button", { name: "Remind" })).toHaveCount(0);
page.once("dialog", d => d.accept());
await lerato.getByRole("button", { name: "Remind" }).click();
// 2.1.1: it answers straight away; the reminder goes out in the background and the page refreshes.
await expect(page.getByRole("status")).toHaveText("Reminding 1 person. It carries on in the background.");
await expect(lerato).toContainText("1 · last");
await expect(lerato.getByRole("button", { name: "Remind" })).toBeDisabled();
await expect(page.getByRole("button", { name: /Remind those who haven't answered/ })).toBeDisabled();
// Reminding again the same day sends nothing.
const again = await api<{ data: { reminded: number; message: string } }>(page, "supervisor", "POST", `/api/v2/events/${seed().events.past}/feedback/remind`, {});
expect(again.data.reminded).toBe(0);
expect(again.data.message).toMatch(/last 24 hours/);
// Opening a named form's answers is in the access log.
const log = await v2<AccessLog>(page, "admin", "GET", "/api/v2/privacy/access-log");
expect(log.rows.find(r => r.action === "feedback_responses")).toMatchObject({ what: "Feedback answers: E2E Last Sunday", records: 1, actor: "Sam Supervisor" });
});
test("staff can't see feedback", async ({ page }) => {
const res = await page.request.get(`${API}/api/v2/feedback`, { headers: { Authorization: `Bearer ${seed().users.staff.token}` } });
expect(res.status()).toBe(403);
});
});
+1
View File
@@ -11,6 +11,7 @@ export type Seed = {
formOptionId: string;
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
unsubscribeToken: string;
feedback: { tokens: { user: string; late: string } };
newsPromptUser: { id: string; token: string };
noticeFirstUser: { id: string; token: string };
retention: { toWarn: string; toAnonymise: string };
+21
View File
@@ -245,6 +245,27 @@ async function seed(url) {
await prisma.securityEvent.create({ data: { userId: toWarn.id, type: 'login', createdAt: new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000) } });
return { toWarn: toWarn.id, toAnonymise: toAnonymise.id };
})(),
// 2.1.0 Feedback page: a named form on the past event, sent two days ago to Thandi (the
// seeded user, who answers in the test) and Lerato (who doesn't). The links' tokens.
feedback: await (async () => {
const { hashToken } = require(path.join(backend, 'src/utils/feedback'));
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
const form = await prisma.feedbackForm.create({
data: {
eventId: pastEvent.id, audience: 'all', anonymous: false, sendAt: twoDaysAgo, sentAt: twoDaysAgo,
fields: { create: [
{ type: 'rating', label: 'How was the service?', isRequired: true, order: 0 },
{ type: 'text', label: 'What should we keep doing?', order: 1 },
] },
},
});
const tokens = { user: 'e2e-feedback-thandi-token', late: 'e2e-feedback-lerato-token' };
await prisma.feedbackInvite.createMany({ data: [
{ formId: form.id, userId: users.user.id, tokenHash: hashToken(tokens.user), sentAt: twoDaysAgo },
{ formId: form.id, userId: latecomer.id, tokenHash: hashToken(tokens.late), sentAt: twoDaysAgo },
] });
return { tokens };
})(),
// The link a news email to the seeded user would carry (signed with JWT_SECRET).
unsubscribeToken: require(path.join(backend, 'src/utils/privacy')).unsubscribeToken({ userId: users.user.id }),
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "2.0.0",
"version": "2.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "2.0.0",
"version": "2.1.1",
"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": "2.0.0",
"version": "2.1.1",
"private": true,
"scripts": {
"dev": "next dev",
+2 -1
View File
@@ -8,7 +8,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck, Inbox,
Baby, HandHeart, Layers, Wrench,
Baby, HandHeart, Layers, MessageSquareHeart, Wrench,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -38,6 +38,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
{ href: "/dashboard/supervisor/kids", label: "Kids check-in setup", description: "Switch it on for an event and set up the rooms", icon: Baby },
{ href: "/dashboard/supervisor/volunteers", label: "Volunteer rosters", description: "Serving roles, who serves where, check-ins and hours", icon: HandHeart },
{ href: "/dashboard/supervisor/feedback", label: "Feedback", description: "What people said after events, and reminders", icon: MessageSquareHeart },
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
@@ -0,0 +1,357 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { ArrowLeft, BellRing, MessageSquareHeart, Pencil, Send, Star } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { useDismissingState } from "@/hooks/useDismissingState";
import { apiV2Data, type V2Result } from "@/lib/apiV2";
import { errorMessage } from "@/lib/utils";
import { useExternalEffect } from "@/lib/hooks";
import { BackLink } from "@/components/shared/BackLink";
import { AiFeedbackSummary } from "@/components/reports/AiFeedbackSummary";
type Row = V2Result<"/feedback", "get">["data"][number];
type Detail = V2Result<"/events/{id}/feedback/results", "get">["data"];
type Question = Detail["questions"][number];
type Person = NonNullable<Detail["people"]>[number];
type Tab = "results" | "responses" | "people";
type Show = "all" | "sent" | "waiting";
// Dates arrive as ISO strings (the generated types call them unknown).
const fmt = (d: unknown, time = true) =>
typeof d === "string" || d instanceof Date ? new Date(d).toLocaleString("en-ZA", { day: "numeric", month: "short", year: "numeric", ...(time ? { hour: "2-digit", minute: "2-digit" } : {}) }) : "—";
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
const STATUS: Record<Row["status"], { label: string; cls: string }> = {
sent: { label: "Sent", cls: "bg-green-50 text-green-700 border-green-200" },
scheduled: { label: "Scheduled", cls: "bg-blue-50 text-blue-700 border-blue-200" },
not_scheduled: { label: "No send time", cls: "bg-gray-50 text-gray-600 border-gray-200" },
};
const StatusBadge = ({ status }: { status: Row["status"] }) => (
<span className={`inline-block rounded-full border px-2 py-0.5 text-xs font-medium ${STATUS[status].cls}`}>{STATUS[status].label}</span>
);
const Stars = ({ value }: { value: number | null | undefined }) =>
value == null ? <span className="text-gray-400">—</span> : <span className="inline-flex items-center gap-1 tabular-nums"><Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" aria-hidden />{value.toFixed(1)}</span>;
/** A horizontal bar per choice (or star), with its count. */
function Bars({ items, total }: { items: { label: string; value: number }[]; total: number }) {
return (
<ul className="space-y-1.5">
{items.map(i => (
<li key={i.label} className="grid grid-cols-[minmax(3rem,8rem)_1fr_3rem] items-center gap-2 text-sm">
<span className="truncate text-gray-700" title={i.label}>{i.label}</span>
<span className="h-2.5 rounded-full bg-gray-100"><span className="block h-2.5 rounded-full bg-brand-500" style={{ width: `${total ? (i.value / total) * 100 : 0}%` }} /></span>
<span className="text-right tabular-nums text-gray-600">{i.value}</span>
</li>
))}
</ul>
);
}
function QuestionResult({ q }: { q: Question }) {
return (
<section className="rounded-xl border bg-white p-4 shadow-sm">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<h3 className="font-medium text-gray-900">{q.label}</h3>
<span className="text-xs text-gray-500">{plural(q.answered, "answer")}</span>
</div>
{q.type === "rating" && (
<div className="grid gap-4 sm:grid-cols-[auto_1fr] sm:items-center">
<div className="text-center"><div className="text-3xl font-semibold tabular-nums">{q.average != null ? q.average.toFixed(1) : "—"}</div><div className="text-xs text-gray-500">out of 5</div></div>
<Bars items={[...(q.distribution || [])].reverse()} total={q.answered} />
</div>
)}
{q.counts && <Bars items={q.counts} total={q.answered} />}
{q.type === "numeric" && <p className="text-sm">Average: <strong className="tabular-nums">{q.average ?? "—"}</strong></p>}
{q.texts && (q.texts.length === 0 ? <p className="text-sm text-gray-500">No answers yet.</p> : (
<ul className="max-h-96 space-y-2 overflow-y-auto pr-1">
{q.texts.map((t, i) => (
<li key={i} className="rounded-lg bg-gray-50 px-3 py-2 text-sm text-gray-800">
<p className="whitespace-pre-wrap">{t.value}</p>
{t.name && <p className="mt-1 text-xs text-gray-500">— {t.name}</p>}
</li>
))}
</ul>
))}
</section>
);
}
/** Supervisor → Feedback: every event's feedback, one event's results and answers, and reminders. */
export default function FeedbackPage() {
const { token } = useAuth();
const [rows, setRows] = useState<Row[] | null>(null);
const [eventId, setEventId] = useState("");
const [detail, setDetail] = useState<Detail | null>(null);
const [tab, setTab] = useState<Tab>("results");
const [search, setSearch] = useState("");
const [show, setShow] = useState<Show>("all");
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const loadList = useCallback(async () => {
if (!token) return;
try {
setRows(await (apiV2Data("get", "/feedback", { authToken: token }) as Promise<Row[]>));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load feedback" });
}
}, [token, setMsg]);
useExternalEffect(() => { loadList(); }, [loadList]);
// ?event=<id> opens that event (links from elsewhere, and the back button).
useEffect(() => {
const read = () => setEventId(new URLSearchParams(window.location.search).get("event") || "");
read();
window.addEventListener("popstate", read);
return () => window.removeEventListener("popstate", read);
}, []);
const open = (id: string) => {
window.history.pushState(null, "", id ? `?event=${encodeURIComponent(id)}` : window.location.pathname);
setEventId(id);
setTab("results");
};
const loadDetail = useCallback(async () => {
if (!token || !eventId) { setDetail(null); return; }
try {
setDetail(await (apiV2Data("get", "/events/{id}/feedback/results", { params: { id: eventId }, authToken: token }) as Promise<Detail>));
} catch (e) {
setDetail(null);
setMsg({ type: "err", text: errorMessage(e) || "Could not load this event's feedback" });
}
}, [token, eventId, setMsg]);
useExternalEffect(() => { setDetail(null); loadDetail(); }, [loadDetail]);
// Sending and reminders run in the background (2.1.1): refresh a few times to show them land.
const refreshTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => () => refreshTimers.current.forEach(clearTimeout), []);
const refreshSoon = useCallback(() => {
refreshTimers.current.forEach(clearTimeout);
refreshTimers.current = [2000, 6000, 15000].map(ms => setTimeout(() => { loadDetail(); loadList(); }, ms));
}, [loadDetail, loadList]);
const remind = async (userIds?: string[]) => {
if (!detail) return;
const who = userIds ? detail.people?.find(p => p.userId === userIds[0])?.name || "this person" : null;
const question = who
? `Send ${who} a reminder with a new feedback link?`
: `Remind ${plural(detail.counts.canRemind, "person", "people")} who haven't answered${detail.counts.waiting ? `, and send the form to ${plural(detail.counts.waiting, "person", "people")} who haven't had it yet` : ""}?`;
if (!window.confirm(question)) return;
setBusy(userIds ? userIds[0] : "all");
try {
const out = await (apiV2Data("post", "/events/{id}/feedback/remind", { params: { id: detail.event.id }, authToken: token, body: { userIds: userIds ?? null } }) as Promise<{ message: string; reminded: number; newlyInvited: number }>);
setMsg({ type: out.reminded || out.newlyInvited ? "ok" : "err", text: out.message });
if (out.reminded || out.newlyInvited) refreshSoon();
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not send the reminders" });
} finally {
setBusy(null);
}
};
const sendNow = async () => {
if (!detail || !window.confirm("Send the feedback form now, instead of waiting for its send time?")) return;
setBusy("send");
try {
const out = await (apiV2Data("post", "/events/{id}/feedback/send-now", { params: { id: detail.event.id }, authToken: token, body: {} }) as Promise<{ message: string; invited: number }>);
setMsg({ type: "ok", text: out.message });
// The form now shows as sent; the people appear as the emails go out.
await Promise.all([loadDetail(), loadList()]);
if (out.invited) refreshSoon();
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not send the form" });
} finally {
setBusy(null);
}
};
const shown = useMemo(() => (rows || []).filter(r =>
(show === "all" || (show === "sent" ? r.status === "sent" : r.status !== "sent")) &&
(!search.trim() || r.event.title.toLowerCase().includes(search.trim().toLowerCase()))), [rows, show, search]);
const message = msg && <div role="status" className={`mb-4 rounded-lg border p-3 text-sm ${msg.type === "ok" ? "border-green-200 bg-green-50 text-green-800" : "border-red-200 bg-red-50 text-red-800"}`}>{msg.text}</div>;
return (
<div className="mx-auto w-full max-w-5xl p-6">
<div className="mb-5 flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-50"><MessageSquareHeart className="h-5 w-5 text-brand-600" /></div>
<div>
<BackLink href="/dashboard" />
<h1 className="mt-0.5 text-2xl font-semibold text-gray-900">Feedback</h1>
<p className="text-sm text-gray-500">What people said after your events, and who still has to answer</p>
</div>
</div>
{message}
{!eventId && (
<>
<div className="mb-4 flex flex-wrap items-end gap-3">
<label className="text-sm">
<span className="mb-1 block text-xs text-gray-500">Search</span>
<input type="search" value={search} onChange={e => setSearch(e.target.value)} placeholder="Event name" className="w-64 max-w-full rounded-lg border px-3 py-2 text-sm" />
</label>
<label className="text-sm">
<span className="mb-1 block text-xs text-gray-500">Show</span>
<select value={show} onChange={e => setShow(e.target.value as Show)} className="rounded-lg border px-3 py-2 text-sm">
<option value="all">All forms</option>
<option value="sent">Sent</option>
<option value="waiting">Not sent yet</option>
</select>
</label>
</div>
{!rows && <p className="text-sm text-gray-500">Loading…</p>}
{rows && rows.length === 0 && (
<div className="rounded-xl border bg-white p-6 text-sm text-gray-600 shadow-sm">
No event has a feedback form yet. Add one in the event editor&apos;s <strong>Feedback</strong> step (<Link className="text-brand-700 underline" href="/dashboard/supervisor/events">Manage events</Link>).
</div>
)}
{rows && rows.length > 0 && shown.length === 0 && <p className="text-sm text-gray-500">No forms match.</p>}
{shown.length > 0 && (
<div className="overflow-x-auto rounded-xl border bg-white shadow-sm">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-xs text-gray-500">
<tr><th className="p-3">Event</th><th className="p-3">Form</th><th className="p-3">Answered</th><th className="p-3">Rating</th><th className="p-3">Last reminder</th></tr>
</thead>
<tbody>
{shown.map(r => (
<tr key={r.event.id} className="cursor-pointer border-t hover:bg-gray-50" onClick={() => open(r.event.id)}>
<td className="p-3">
<button type="button" className="text-left font-medium text-gray-900 hover:text-brand-700" onClick={e => { e.stopPropagation(); open(r.event.id); }}>{r.event.title}</button>
<div className="text-xs text-gray-500">{fmt(r.event.startDate, false)}{r.anonymous ? " · anonymous" : ""}</div>
</td>
<td className="p-3"><StatusBadge status={r.status} /><div className="mt-1 text-xs text-gray-500">{r.status === "sent" ? fmt(r.sentAt) : r.status === "scheduled" ? `Goes ${fmt(r.sendAt)}` : ""}</div></td>
<td className="p-3 tabular-nums">{r.status === "sent" ? <>{r.answered} of {r.invited}{r.responseRate != null && <span className="text-gray-500"> ({r.responseRate}%)</span>}</> : <span className="text-gray-400">—</span>}</td>
<td className="p-3"><Stars value={r.averageRating} /></td>
<td className="p-3 text-gray-600">{r.lastReminderAt ? fmt(r.lastReminderAt) : "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
{eventId && (
<>
<button type="button" onClick={() => open("")} className="mb-3 inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"><ArrowLeft className="h-4 w-4" /> All feedback</button>
{!detail && <p className="text-sm text-gray-500">Loading…</p>}
{detail && (
<>
<div className="mb-4 rounded-xl border bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-gray-900">{detail.event.title}</h2>
<p className="text-sm text-gray-500">{fmt(detail.event.startDate, false)} · {detail.form.audience === "all" ? "everyone registered" : "people who checked in"} · {detail.form.anonymous ? "anonymous answers" : "named answers"}</p>
<p className="mt-1 text-sm text-gray-600">
<StatusBadge status={detail.form.status} />{" "}
{detail.form.status === "sent" && <>Sent {fmt(detail.form.sentAt)}. {detail.form.autoReminderSent ? "The automatic reminder has gone out." : `Automatic reminder ${fmt(detail.form.autoReminderAt)}.`}</>}
{detail.form.status === "scheduled" && <>Goes out {fmt(detail.form.sendAt)}.</>}
{detail.form.status === "not_scheduled" && <>No send time is set.</>}
</p>
</div>
<div className="flex flex-wrap gap-2">
{detail.form.status !== "sent" && (
<button type="button" onClick={sendNow} disabled={!!busy} className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"><Send className="h-4 w-4" /> {busy === "send" ? "Sending…" : "Send now"}</button>
)}
{detail.form.status === "sent" && (
<button type="button" onClick={() => remind()} disabled={!!busy || detail.counts.canRemind + detail.counts.waiting === 0}
title={detail.counts.canRemind + detail.counts.waiting === 0 ? (detail.counts.notAnswered ? "Everyone who hasn't answered was sent the form or a reminder in the last 24 hours" : "Everyone has answered") : undefined}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
<BellRing className="h-4 w-4" /> {busy === "all" ? "Sending…" : detail.counts.canRemind + detail.counts.waiting ? `Remind ${detail.counts.canRemind + detail.counts.waiting} who haven't answered` : "Remind those who haven't answered"}
</button>
)}
<Link href={`/dashboard/supervisor/events?edit=${encodeURIComponent(detail.event.id)}`} className="inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm hover:bg-gray-50"><Pencil className="h-4 w-4" /> Edit form</Link>
</div>
</div>
<dl className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
{[
["Asked", detail.counts.invited],
["Answered", detail.counts.answered],
["Response rate", detail.counts.responseRate != null ? `${detail.counts.responseRate}%` : "—"],
["Average rating", detail.averageRating != null ? `${detail.averageRating.toFixed(1)} ★` : "—"],
].map(([k, v]) => (
<div key={String(k)} className="rounded-lg bg-gray-50 p-3"><dt className="text-xs text-gray-500">{k}</dt><dd className="text-xl font-semibold tabular-nums text-gray-900">{v}</dd></div>
))}
</dl>
{detail.form.status === "sent" && (detail.counts.notAnswered > 0 || detail.counts.waiting > 0) && (
<p className="mt-3 text-xs text-gray-500">
{plural(detail.counts.notAnswered, "person hasn't", "people haven't")} answered
{detail.counts.remindedRecently > 0 && ` (${detail.counts.remindedRecently} sent the form or a reminder in the last 24 hours)`}
{detail.counts.waiting > 0 && `; ${plural(detail.counts.waiting, "person", "people")} can still be sent the form (e.g. checked in after it went out)`}.
</p>
)}
</div>
<div className="mb-4 flex flex-wrap gap-1 border-b" role="tablist">
{([["results", "Results"], ["responses", `Responses (${detail.responses.length})`], ...(detail.people ? [["people", `People (${detail.people.length})`]] : [])] as [Tab, string][]).map(([id, label]) => (
<button key={id} role="tab" aria-selected={tab === id} onClick={() => setTab(id)} className={`-mb-px border-b-2 px-3 py-2 text-sm ${tab === id ? "border-brand-600 font-medium text-brand-700" : "border-transparent text-gray-500"}`}>{label}</button>
))}
</div>
{tab === "results" && (
<div className="space-y-4">
<AiFeedbackSummary eventId={detail.event.id} responses={detail.responses.length} />
{detail.responses.length === 0 && <p className="text-sm text-gray-500">No answers yet.</p>}
{detail.responses.length > 0 && detail.questions.map(q => <QuestionResult key={q.fieldId} q={q} />)}
{detail.form.anonymous && <p className="text-xs text-gray-500">This form is anonymous: answers aren&apos;t linked to anyone, and who has or hasn&apos;t answered isn&apos;t shown.</p>}
</div>
)}
{tab === "responses" && (
<div className="space-y-3">
{detail.responses.length === 0 && <p className="text-sm text-gray-500">No answers yet.</p>}
{detail.responses.map((r, i) => (
<article key={r.id} className="rounded-xl border bg-white p-4 shadow-sm">
<div className="mb-2 flex flex-wrap justify-between gap-2 text-sm">
<strong className="text-gray-900">{r.name ?? `Response ${detail.responses.length - i}`}</strong>
<span className="text-gray-500">{fmt(r.createdAt)}</span>
</div>
<dl className="space-y-2 text-sm">
{detail.fields.map(f => (
<div key={f.id}>
<dt className="text-xs text-gray-500">{f.label}</dt>
<dd className="whitespace-pre-wrap text-gray-800">{r.answers[f.id] == null ? <span className="text-gray-400">—</span> : f.type === "rating" ? `${"★".repeat(Number(r.answers[f.id]))}${"☆".repeat(5 - Number(r.answers[f.id]))}` : f.type === "yes_no" ? (r.answers[f.id] === "yes" ? "Yes" : "No") : r.answers[f.id]}</dd>
</div>
))}
</dl>
</article>
))}
</div>
)}
{tab === "people" && detail.people && (
detail.people.length === 0 ? <p className="text-sm text-gray-500">The form hasn&apos;t been sent to anyone yet.</p> : (
<div className="overflow-x-auto rounded-xl border bg-white shadow-sm">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-xs text-gray-500"><tr><th className="p-3">Person</th><th className="p-3">Sent</th><th className="p-3">Answered</th><th className="p-3">Reminders</th><th className="p-3" /></tr></thead>
<tbody>
{detail.people.map((p: Person) => (
<tr key={p.userId} className="border-t">
<td className="p-3"><div className="font-medium text-gray-900">{p.name}</div><div className="text-xs text-gray-500">{[p.email, p.phoneNumber].filter(Boolean).join(" · ") || "No contact details"}</div></td>
<td className="p-3 text-gray-600">{fmt(p.sentAt)}</td>
<td className="p-3">{p.respondedAt ? <span className="text-green-700">{fmt(p.respondedAt)}</span> : <span className="text-amber-700">Not yet</span>}</td>
<td className="p-3 text-gray-600">{p.reminderCount ? <>{p.reminderCount} · last {fmt(p.remindedAt)}</> : "None"}</td>
<td className="p-3 text-right">
{!p.respondedAt && (
<button type="button" onClick={() => remind([p.userId])} disabled={!!busy || !p.canRemind}
title={p.canRemind ? undefined : "Sent the form or a reminder in the last 24 hours"}
className="rounded-lg border px-2.5 py-1 text-xs hover:bg-gray-50 disabled:opacity-50">{busy === p.userId ? "Sending…" : "Remind"}</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
</>
)}
</>
)}
</div>
);
}
@@ -8,7 +8,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck,
Baby, HandHeart, Layers,
Baby, HandHeart, Layers, MessageSquareHeart,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -35,6 +35,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
{ href: "/dashboard/supervisor/kids", label: "Kids check-in setup", description: "Switch it on for an event and set up the rooms", icon: Baby },
{ href: "/dashboard/supervisor/volunteers", label: "Volunteer rosters", description: "Serving roles, who serves where, check-ins and hours", icon: HandHeart },
{ href: "/dashboard/supervisor/feedback", label: "Feedback", description: "What people said after events, and reminders", icon: MessageSquareHeart },
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
@@ -6,40 +6,33 @@ import { useAuth } from "@/hooks/useAuth";
import { useAiStatus } from "@/hooks/useAiStatus";
import { runAi } from "@/lib/ai";
import type { V2Body, V2Result } from "@/lib/apiV2";
import type { ReportPdfPayload } from "@/lib/export";
import type { AiReportData } from "./aiReportData";
import { errorMessage } from "@/lib/utils";
import { AiButton, AiNotice } from "@/components/shared/AiButton";
type Summary = V2Result<"/ai/summarise-report", "post">["data"]["output"];
type ReportKey = V2Body<"/ai/summarise-report", "post">["reportKey"];
/** Reports without people in them: only these are sent to the AI (the server checks too). */
export const SUMMARISABLE_REPORTS: readonly string[] = ["seriesComparison", "regTypes", "usage", "revenue", "regStatus", "donations", "funnel", "discounts", "cashup", "financeReport", "profitReport"] satisfies ReportKey[];
/**
* "Summarise this report": a headline, a few insights and anything to act on, from the report as
* it is on screen (the same data as Print and Excel). Only on reports without people in them;
* hidden on sites without AI.
* it is on screen. Reports that list people are sent as anonymised totals (see aiReportData). Hidden
* on sites without AI.
*/
export function AiReportSummary({ reportKey, getPayload }: { reportKey: string; getPayload: () => ReportPdfPayload | null }) {
export function AiReportSummary({ reportKey, getPayload }: { reportKey: ReportKey; getPayload: () => AiReportData | null }) {
const { token } = useAuth();
const status = useAiStatus();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [summary, setSummary] = useState<Summary | null>(null);
if (!status?.enabled || !SUMMARISABLE_REPORTS.includes(reportKey)) return null;
if (!status?.enabled) return null;
const run = async () => {
const p = getPayload();
if (!token || !p?.table) { setError("This report has no figures to summarise yet."); return; }
if (!token || !p?.rows.length) { setError("This report has no figures to summarise yet."); return; }
setBusy(true);
setError(null);
try {
setSummary(await runAi<Summary>("summarise_report", {
reportKey, title: p.title, subtitle: p.subtitle,
stats: (p.stats || []).map(s => ({ label: s.label, value: s.value })),
columns: p.table.columns, rows: p.table.rows,
}, token));
setSummary(await runAi<Summary>("summarise_report", { reportKey, ...p }, token));
} catch (e) { setError(errorMessage(e) || "The AI couldn't summarise this report."); }
setBusy(false);
};
+26 -1
View File
@@ -10,6 +10,7 @@ import { REPORTS, type ReportKey } from "./ReportCatalog";
import ReportsShell from "./ReportsShell";
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
import { AiReportSummary } from "./AiReportSummary";
import { aiReportData, type AiReportData } from "./aiReportData";
import { AiFeedbackSummary } from "./AiFeedbackSummary";
import { StatTile, StatTileRow } from "./StatTile";
import { HorizontalBarChart } from "./charts/HorizontalBarChart";
@@ -1387,6 +1388,30 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
return null;
};
/** What "Summarise this report" sends: the figures, never the people (see aiReportData). */
const buildAiPayload = (): AiReportData | null => {
if (report === "attendees") {
// On screen a list of names per option: the AI gets how many people and tickets per option and status.
const rows: (string | number)[][] = [];
for (const opt of attendeesLayer.options) {
const byStatus = new Map<string, { people: number; qty: number }>();
for (const u of opt.users) {
const g = byStatus.get(u.status) ?? { people: 0, qty: 0 };
g.people += 1;
g.qty += u.qty || 0;
byStatus.set(u.status, g);
}
for (const [status, g] of byStatus) rows.push([opt.optionName, status, g.people, g.qty]);
}
return {
title: `Attendees: ${attendeesLayer.eventTitle}`, subtitle: `Generated ${new Date().toLocaleString()}${attIncludeCancelled ? " (cancelled included)" : ""}`,
stats: [{ label: "People", value: new Set(attendeesLayer.options.flatMap(o => o.users.map(u => u.name))).size }],
columns: ["Option", "Status", "People", "Tickets"], rows,
};
}
return aiReportData(report, buildReportPayload());
};
const doExportExcel = async () => {
if (!token) { alert('Please login to export Excel'); return; }
try {
@@ -1568,7 +1593,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
{!ready && <div className="text-sm text-gray-400">Loading…</div>}
{ready && (
<>
<AiReportSummary key={report} reportKey={report} getPayload={buildReportPayload} />
<AiReportSummary key={report} reportKey={report} getPayload={buildAiPayload} />
{report === "payments" && (
<div>
{paymentRows.length === 0 ? (
@@ -0,0 +1,157 @@
import type { ReportPdfPayload } from "@/lib/export";
/** What "Summarise this report" sends: the report's figures, never the people in it. */
export type AiReportData = {
title: string;
subtitle?: string;
stats: { label: string; value: string | number }[];
columns: string[];
rows: (string | number)[][];
};
type Table = { columns: string[]; rows: (string | number)[][] };
/** A rand amount or a count from a report cell ("R 1 250.00", 1250, "—"). */
export function amount(v: unknown): number {
if (typeof v === "number") return Number.isFinite(v) ? v : 0;
const n = Number(String(v ?? "").replace(/[^0-9.-]/g, ""));
return Number.isFinite(n) ? n : 0;
}
const r2 = (n: number) => Math.round(n * 100) / 100;
/**
* Group a table's rows by some columns and add up others: `count` is how many rows fell in each
* group, each `sums` entry the total of that column (or of what `pick` returns per row).
*/
function group(t: Table, by: string[], sums: { label: string; col?: string; pick?: (row: (string | number)[], c: (name: string) => string | number) => number }[], countLabel: string): Table {
const idx = (name: string) => t.columns.indexOf(name);
const groups = new Map<string, { key: (string | number)[]; count: number; sums: number[] }>();
for (const row of t.rows) {
const c = (name: string) => row[idx(name)] ?? "";
const key = by.map(b => String(c(b) || "—"));
const k = key.join("\u0000");
const g = groups.get(k) ?? { key, count: 0, sums: sums.map(() => 0) };
g.count += 1;
sums.forEach((s, i) => { g.sums[i] += s.pick ? s.pick(row, c) : amount(c(s.col!)); });
groups.set(k, g);
}
return {
columns: [...by, countLabel, ...sums.map(s => s.label)],
rows: [...groups.values()].map(g => [...g.key, g.count, ...g.sums.map(r2)]),
};
}
/** The table without the named columns. */
function without(t: Table, drop: string[]): Table {
const keep = t.columns.map((c, i) => (drop.includes(c) ? -1 : i)).filter(i => i >= 0);
return { columns: keep.map(i => t.columns[i]), rows: t.rows.map(r => keep.map(i => r[i] ?? "")) };
}
const yes = (v: unknown) => String(v).toLowerCase() === "yes";
const filled = (v: unknown) => String(v ?? "").trim() !== "";
/**
* The report as the AI helper may see it. Reports without people go as they are on screen; the
* ones that list people become totals per event (or per option, status, method), with names,
* contact details, notes and comments left out, and medical and consent only as counts. The server
* refuses a people report that still has a personal column, so a new column here fails safe.
* `attendees` is passed in already counted (see ReportsV2), as its on-screen form is a list of names.
*/
export function aiReportData(reportKey: string, p: ReportPdfPayload | null): AiReportData | null {
if (!p?.table) return null;
const base = { title: p.title, subtitle: p.subtitle, stats: (p.stats || []).map(s => ({ label: s.label, value: s.value })) };
const t = p.table;
const col = (name: string) => t.columns.indexOf(name);
switch (reportKey) {
case "payments": {
const dates = t.rows.map(r => new Date(String(r[col("Date")]))).filter(d => !Number.isNaN(d.getTime())).sort((a, b) => a.getTime() - b.getTime());
const total = t.rows.reduce((s, r) => s + amount(r[col("Amount")]), 0);
const g = group(t, ["Event", "Method"], [
{ label: "Amount", col: "Amount" },
{ label: "Of which donations", pick: (_r, c) => (yes(c("Donation")) ? amount(c("Amount")) : 0) },
], "Payments");
return {
...base,
stats: [
{ label: "Payments", value: t.rows.length },
{ label: "Total", value: `R ${r2(total).toFixed(2)}` },
...(dates.length ? [{ label: "First and last payment", value: `${dates[0].toLocaleDateString()} – ${dates[dates.length - 1].toLocaleDateString()}` }] : []),
],
...g,
};
}
case "revenueDetailed":
return {
...base,
...group(t, ["Event", "Status"], [
{ label: "Total paid", col: "Total paid" },
{ label: "Outstanding", col: "Outstanding" },
{ label: "Registrations still owing", pick: (_r, c) => (amount(c("Outstanding")) > 0 ? 1 : 0) },
], "Registrations"),
};
case "masterOrders": {
// The person rows only (not the TOTAL, revenue-per-ticket and unassigned-donations rows below them).
const people = { columns: t.columns, rows: t.rows.filter(r => filled(r[col("Name")]) || filled(r[col("Email")])) };
const numeric = t.columns.filter(c => !["Event", "Name", "Email"].includes(c));
const donations = p.extraTables?.find(x => x.title === "Donations made");
const dIdx = (name: string) => donations?.columns.indexOf(name) ?? -1;
const dSum = (name: string) => r2((donations?.rows || []).reduce((s, r) => s + amount(r[dIdx(name)]), 0));
return {
...base,
stats: [
...base.stats,
{ label: "Orders still owing", value: people.rows.filter(r => amount(r[col("Outstanding")]) > 0).length },
...(donations?.rows.length ? [
{ label: "Donations made", value: donations.rows.length },
{ label: "Donated (used / unused)", value: `R ${dSum("Donated").toFixed(2)} (R ${dSum("Used").toFixed(2)} / R ${dSum("Unused").toFixed(2)})` },
] : []),
],
...group(people, ["Event"], numeric.map(c => ({ label: c, col: c })), "Orders"),
};
}
case "cashupAudit":
// Who did each cashup and the free-text notes stay behind; the figures go.
return { ...base, ...without(t, ["Performed by", "Notes"]) };
case "consentStatus": {
const status = (r: (string | number)[]) => String(r[col("Consent")] || "");
const is = (s: string) => (r: (string | number)[]) => (status(r).startsWith(s) ? 1 : 0);
return {
...base,
...group(t, ["Event"], [
{ label: "Given", pick: r => is("given")(r) },
{ label: "Given on an older consent text", pick: r => (status(r).startsWith("given") && status(r).includes("older text") ? 1 : 0) },
{ label: "Pending", pick: r => is("pending")(r) },
{ label: "Declined", pick: r => is("declined")(r) },
{ label: "Missing", pick: r => is("missing")(r) },
], "Under-18 attendees"),
};
}
case "medicalList": {
const med = (r: (string | number)[]) => String(r[col("Medical")] || "");
const has = (what: string) => (r: (string | number)[]) => (med(r).includes(`${what}:`) ? 1 : 0);
const photos = (r: (string | number)[]) => String(r[col("Photos")] || "");
return {
...base,
stats: [],
...group(t, ["Event"], [
{ label: "With allergies", pick: has("Allergies") },
{ label: "On medication", pick: has("Medication") },
{ label: "With conditions", pick: has("Conditions") },
{ label: "With medical aid", pick: has("Medical aid") },
{ label: "With an emergency contact", pick: r => (filled(r[col("Emergency contact")]) ? 1 : 0) },
{ label: "Photos allowed", pick: r => (photos(r) === "Yes" ? 1 : 0) },
{ label: "Photos not allowed", pick: r => (photos(r) === "No" ? 1 : 0) },
{ label: "Medical details deleted after the event", pick: r => (med(r).startsWith("Deleted") ? 1 : 0) },
], "Attendees"),
};
}
case "feedbackSummary":
// The per-question results only: the comments (with who wrote them) are left out. "Summarise
// feedback" on each event reads the comments themselves, without names.
return { ...base, columns: t.columns, rows: t.rows.filter(r => filled(r[0]) || !String(r[1] ?? "").trimStart().startsWith("“")) };
default:
return { ...base, columns: t.columns, rows: t.rows };
}
}
+6
View File
@@ -38,6 +38,7 @@ import { userFormsHelpContent } from "./user-forms";
import { kidsDeskHelpContent, kidsRoomsHelpContent, kidsSetupHelpContent, userKidsHelpContent } from "./kids";
import { staffVolunteersHelpContent, userServingHelpContent, volunteerRostersHelpContent } from "./volunteers";
import { seriesHelpContent } from "./series";
import { supervisorFeedbackHelpContent } from "./supervisor-feedback";
type HelpRegistryEntry = {
/** Unique, stable identifier for this guide. */
@@ -194,6 +195,11 @@ const REGISTRY: HelpRegistryEntry[] = [
matches: pathname => pathname === "/dashboard/supervisor/volunteers",
content: volunteerRostersHelpContent,
},
{
slug: "supervisor-feedback",
matches: pathname => pathname === "/dashboard/supervisor/feedback",
content: supervisorFeedbackHelpContent,
},
{
slug: "staff-volunteers",
matches: pathname => pathname === "/dashboard/staff/volunteers",
@@ -62,7 +62,7 @@ export const supervisorEventsHelpContent: HelpContent = {
Upload downloadable files that appear on the public event page.
</GuideItem>
<GuideItem icon={MessageSquareHeart} title="Feedback" tone="violet">
Send a feedback form automatically after the event: pick when (e.g. the morning after) and whether it goes to everyone who checked in or everyone registered. Questions can be star ratings, multiple choice, yes/no or text. Answers can be anonymous. Anyone who hasn&apos;t answered gets one reminder 3 days later. Results are in Reports → Feedback summary. Rating and multiple-choice questions can also be used on the attendee form.
Send a feedback form automatically after the event: pick when (e.g. the morning after) and whether it goes to everyone who checked in or everyone registered. Questions can be star ratings, multiple choice, yes/no or text. Answers can be anonymous. Anyone who hasn&apos;t answered gets one reminder 3 days later. See the answers and remind people yourself on the Feedback page (Dashboard → Feedback); Reports → Feedback summary compares events. Rating and multiple-choice questions can also be used on the attendee form.
</GuideItem>
<GuideItem icon={ClipboardCheck} title="Attendance" tone="green">
For multi-session events: late joining (until which session, and a full, pro-rata or fixed late price for main tickets — pro-rata rounds to the nearest R10), the check-in window (the whole day of a session, or only from 2 hours before it until it ends), and reminders before each session. For any event: completion certificates — four designs, your wording and signature, and who qualifies. &quot;Preview certificate&quot; shows a sample.
@@ -0,0 +1,56 @@
import React from "react";
import { BellRing, EyeOff, ListChecks, MessageSquareHeart, Pencil, Sparkles, Star, Users } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for /dashboard/supervisor/feedback. */
export const supervisorFeedbackHelpContent: HelpContent = {
title: "Feedback",
subtitle: "What people said after your events, and who still has to answer.",
tabs: [
{
key: "results",
label: "Results",
icon: Star,
content: (
<div className="space-y-4">
<p>Every event with a feedback form is listed, newest first: when the form went out (or goes out), how many answered and the average star rating. Open an event to see its feedback.</p>
<GuideItem icon={Star} title="Results" tone="brand">
Each question with its answers: the average and spread of star ratings, how many chose each option, and every written answer.
</GuideItem>
<GuideItem icon={ListChecks} title="Responses" tone="gray">
Each person&apos;s answers together, newest first. On a named form you see who gave them; looking at them is recorded in the access log.
</GuideItem>
<GuideItem icon={Sparkles} title="Summarise feedback" tone="amber">
On sites with AI, &quot;Summarise feedback&quot; gives the main themes, what people liked and what to improve. The answers are sent without names.
</GuideItem>
<GuideItem icon={Pencil} title="Changing the form" tone="gray">
&quot;Edit form&quot; opens the event&apos;s Feedback step. Once someone has answered, the questions can&apos;t change.
</GuideItem>
</div>
),
},
{
key: "reminders",
label: "Reminders",
icon: BellRing,
content: (
<div className="space-y-4">
<p>Everyone who hasn&apos;t answered gets one reminder automatically, 3 days after the form goes out. You can remind people yourself too.</p>
<GuideItem icon={BellRing} title="Remind those who haven't answered" tone="brand">
Sends each of them a new link by email or WhatsApp. It also sends the form to anyone who can have it but hasn&apos;t yet, for example someone who checked in after it went out. Nobody gets the form or a reminder twice within 24 hours.
</GuideItem>
<GuideItem icon={Users} title="People" tone="green">
On a named form, the People tab shows who was asked, who has answered and the reminders each person has had, with a Remind button for one person.
</GuideItem>
<GuideItem icon={EyeOff} title="Anonymous forms" tone="gray">
Answers aren&apos;t linked to anyone, and the page doesn&apos;t show who has or hasn&apos;t answered — on a small event that would give the answers away. You can still remind everyone who hasn&apos;t answered.
</GuideItem>
<GuideItem icon={MessageSquareHeart} title="Before it goes out" tone="amber">
A form that hasn&apos;t gone out yet has a &quot;Send now&quot; button instead.
</GuideItem>
</div>
),
},
],
};
+803 -3
View File
@@ -27141,7 +27141,7 @@
"Feedback"
],
"summary": "Send the feedback form now",
"description": "**Who:** Supervisors and admins.",
"description": "Answers straight away: `invited` is how many it is going to; the emails go out in the background.\n\n**Who:** Supervisors and admins.",
"operationId": "postEventsIdFeedbackSend-now",
"security": [
{
@@ -68299,7 +68299,7 @@
"AI"
],
"summary": "Summarise a report",
"description": "A few plain-language insights on a report as it is on screen. Only reports without people in them: seriesComparison, regTypes, usage, revenue, regStatus, donations, funnel, discounts, cashup, financeReport, profitReport. Goes through the Cross Events Hub, which counts it against the site's monthly AI allowance. Errors carry a `reason`: AI_OFF, AI_HELPER_OFF, AI_CAP_REACHED, AI_DAILY_LIMIT, AI_REFUSED, AI_INCOMPLETE, AI_UNAVAILABLE.\n\n**Who:** Supervisors and admins.",
"description": "A few plain-language insights on a report. Reports without people in them (seriesComparison, regTypes, usage, revenue, regStatus, donations, funnel, discounts, cashup, financeReport, profitReport) are sent as they are on screen; reports that list people (payments, attendees, revenueDetailed, masterOrders, cashupAudit, consentStatus, feedbackSummary, medicalList) only as anonymised totals: a column that would carry a person (name, email, notes, …) is refused with `AI_REPORT_HAS_PEOPLE`, and each one is recorded in the access log. Goes through the Cross Events Hub, which counts it against the site's monthly AI allowance. Errors carry a `reason`: AI_OFF, AI_HELPER_OFF, AI_CAP_REACHED, AI_DAILY_LIMIT, AI_REFUSED, AI_INCOMPLETE, AI_UNAVAILABLE.\n\n**Who:** Supervisors and admins.",
"operationId": "postAiSummarise-report",
"security": [
{
@@ -68327,7 +68327,15 @@
"discounts",
"cashup",
"financeReport",
"profitReport"
"profitReport",
"payments",
"attendees",
"revenueDetailed",
"masterOrders",
"cashupAudit",
"consentStatus",
"feedbackSummary",
"medicalList"
]
},
"title": {
@@ -69222,6 +69230,798 @@
}
}
}
},
"/feedback": {
"get": {
"tags": [
"Feedback"
],
"summary": "Every event with a feedback form",
"description": "Newest event first, with how many were asked and answered, and the average star rating.\n\n**Who:** Supervisors and admins.",
"operationId": "getFeedback",
"security": [
{
"bearerAuth": []
}
],
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"event": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"title": {
"type": "string"
},
"startDate": {},
"endDate": {}
},
"required": [
"id",
"title",
"startDate",
"endDate"
],
"additionalProperties": false
},
"status": {
"type": "string",
"enum": [
"sent",
"scheduled",
"not_scheduled"
],
"description": "sent, scheduled (a send time is set) or not_scheduled"
},
"sendAt": {
"anyOf": [
{},
{
"type": "null"
}
]
},
"sentAt": {
"anyOf": [
{},
{
"type": "null"
}
]
},
"anonymous": {
"type": "boolean"
},
"invited": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"answered": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"responseRate": {
"description": "%",
"type": [
"number",
"null"
]
},
"averageRating": {
"description": "Every star rating on the form, 1–5",
"type": [
"number",
"null"
]
},
"lastReminderAt": {
"anyOf": [
{},
{
"type": "null"
}
]
}
},
"required": [
"event",
"status",
"sendAt",
"sentAt",
"anonymous",
"invited",
"answered",
"responseRate",
"averageRating",
"lastReminderAt"
],
"additionalProperties": false
}
}
},
"required": [
"data"
],
"additionalProperties": false
}
}
}
},
"401": {
"description": "Not signed in (UNAUTHENTICATED)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not allowed for this role (FORBIDDEN)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/events/{id}/feedback/results": {
"get": {
"tags": [
"Feedback"
],
"summary": "An event's feedback: results, responses and who was asked",
"description": "On an anonymous form, responses have no name and `people` is null. Opening a named form's answers is recorded in the access log.\n\n**Who:** Supervisors and admins.",
"operationId": "getEventsIdFeedbackResults",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The id",
"schema": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"event": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"title": {
"type": "string"
},
"startDate": {},
"endDate": {}
},
"required": [
"id",
"title",
"startDate",
"endDate"
],
"additionalProperties": false
},
"form": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"sent",
"scheduled",
"not_scheduled"
],
"description": "sent, scheduled (a send time is set) or not_scheduled"
},
"sendAt": {
"anyOf": [
{},
{
"type": "null"
}
]
},
"sentAt": {
"anyOf": [
{},
{
"type": "null"
}
]
},
"audience": {
"type": "string",
"enum": [
"checked_in",
"all"
]
},
"anonymous": {
"type": "boolean"
},
"intro": {
"type": [
"string",
"null"
]
},
"autoReminderAt": {
"anyOf": [
{},
{
"type": "null"
}
],
"description": "When the automatic reminder goes (3 days after sending)"
},
"autoReminderSent": {
"type": "boolean"
}
},
"required": [
"status",
"sendAt",
"sentAt",
"audience",
"anonymous",
"intro",
"autoReminderAt",
"autoReminderSent"
],
"additionalProperties": false
},
"counts": {
"type": "object",
"properties": {
"invited": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"answered": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"notAnswered": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"responseRate": {
"type": [
"number",
"null"
]
},
"canRemind": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"description": "Not answered, and not sent the form or a reminder in the last 24 hours"
},
"remindedRecently": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"waiting": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"description": "Would get the form but haven't been sent it yet (e.g. checked in after it went out)"
}
},
"required": [
"invited",
"answered",
"notAnswered",
"responseRate",
"canRemind",
"remindedRecently",
"waiting"
],
"additionalProperties": false
},
"averageRating": {
"type": [
"number",
"null"
]
},
"questions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"fieldId": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"type": {
"type": "string"
},
"label": {
"type": "string"
},
"answered": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"average": {
"type": [
"number",
"null"
]
},
"distribution": {
"type": "array",
"items": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"value": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
}
},
"required": [
"label",
"value"
],
"additionalProperties": false
}
},
"counts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"value": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
}
},
"required": [
"label",
"value"
],
"additionalProperties": false
}
},
"texts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"value": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"value"
],
"additionalProperties": false
}
}
},
"required": [
"fieldId",
"type",
"label",
"answered"
],
"additionalProperties": false
}
},
"fields": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"type": {
"type": "string"
},
"label": {
"type": "string"
}
},
"required": [
"id",
"type",
"label"
],
"additionalProperties": false
}
},
"responses": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"createdAt": {},
"name": {
"type": [
"string",
"null"
]
},
"answers": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
},
"description": "Question id → answer"
}
},
"required": [
"id",
"createdAt",
"name",
"answers"
],
"additionalProperties": false
}
},
"people": {
"anyOf": [
{
"type": "array",
"items": {
"type": "object",
"properties": {
"userId": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"name": {
"type": "string"
},
"email": {
"type": [
"string",
"null"
]
},
"phoneNumber": {
"type": [
"string",
"null"
]
},
"sentAt": {},
"remindedAt": {
"anyOf": [
{},
{
"type": "null"
}
]
},
"reminderCount": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"respondedAt": {
"anyOf": [
{},
{
"type": "null"
}
]
},
"canRemind": {
"type": "boolean"
}
},
"required": [
"userId",
"name",
"email",
"phoneNumber",
"sentAt",
"remindedAt",
"reminderCount",
"respondedAt",
"canRemind"
],
"additionalProperties": false
}
},
{
"type": "null"
}
]
}
},
"required": [
"event",
"form",
"counts",
"averageRating",
"questions",
"fields",
"responses",
"people"
],
"additionalProperties": false
}
},
"required": [
"data"
],
"additionalProperties": false
}
}
}
},
"400": {
"description": "Validation failed (VALIDATION_FAILED)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not signed in (UNAUTHENTICATED)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not allowed for this role (FORBIDDEN)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Not found (NOT_FOUND)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/events/{id}/feedback/remind": {
"post": {
"tags": [
"Feedback"
],
"summary": "Remind people who haven't answered",
"description": "A fresh link to everyone who hasn't answered, or to `userIds` (named forms only). Nobody is sent the form or a reminder twice within 24 hours. Without `userIds` it also sends the form to anyone who has become eligible since it went out. Answers straight away; the emails go out in the background.\n\n**Who:** Supervisors and admins.",
"operationId": "postEventsIdFeedbackRemind",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The id",
"schema": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"userIds": {
"description": "Only these people (named forms)",
"anyOf": [
{
"maxItems": 1000,
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
},
{
"type": "null"
}
]
}
}
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"reminded": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"description": "Being reminded"
},
"newlyInvited": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"description": "Being sent the form for the first time"
},
"skipped": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"queued": {
"type": "boolean",
"description": "Emails are going out in the background"
},
"message": {
"type": "string"
}
},
"required": [
"reminded",
"newlyInvited",
"skipped",
"queued",
"message"
],
"additionalProperties": false
}
},
"required": [
"data"
],
"additionalProperties": false
}
}
}
},
"400": {
"description": "Validation failed (VALIDATION_FAILED)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not signed in (UNAUTHENTICATED)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not allowed for this role (FORBIDDEN)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Not found (NOT_FOUND)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
},
"components": {
+350 -3
View File
@@ -2679,7 +2679,9 @@ export interface paths {
put?: never;
/**
* Send the feedback form now
* @description **Who:** Supervisors and admins.
* @description Answers straight away: `invited` is how many it is going to; the emails go out in the background.
*
* **Who:** Supervisors and admins.
*/
post: operations["postEventsIdFeedbackSend-now"];
delete?: never;
@@ -7159,7 +7161,7 @@ export interface paths {
put?: never;
/**
* Summarise a report
* @description A few plain-language insights on a report as it is on screen. Only reports without people in them: seriesComparison, regTypes, usage, revenue, regStatus, donations, funnel, discounts, cashup, financeReport, profitReport. Goes through the Cross Events Hub, which counts it against the site's monthly AI allowance. Errors carry a `reason`: AI_OFF, AI_HELPER_OFF, AI_CAP_REACHED, AI_DAILY_LIMIT, AI_REFUSED, AI_INCOMPLETE, AI_UNAVAILABLE.
* @description A few plain-language insights on a report. Reports without people in them (seriesComparison, regTypes, usage, revenue, regStatus, donations, funnel, discounts, cashup, financeReport, profitReport) are sent as they are on screen; reports that list people (payments, attendees, revenueDetailed, masterOrders, cashupAudit, consentStatus, feedbackSummary, medicalList) only as anonymised totals: a column that would carry a person (name, email, notes, …) is refused with `AI_REPORT_HAS_PEOPLE`, and each one is recorded in the access log. Goes through the Cross Events Hub, which counts it against the site's monthly AI allowance. Errors carry a `reason`: AI_OFF, AI_HELPER_OFF, AI_CAP_REACHED, AI_DAILY_LIMIT, AI_REFUSED, AI_INCOMPLETE, AI_UNAVAILABLE.
*
* **Who:** Supervisors and admins.
*/
@@ -7236,6 +7238,72 @@ export interface paths {
patch?: never;
trace?: never;
};
"/feedback": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Every event with a feedback form
* @description Newest event first, with how many were asked and answered, and the average star rating.
*
* **Who:** Supervisors and admins.
*/
get: operations["getFeedback"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/events/{id}/feedback/results": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* An event's feedback: results, responses and who was asked
* @description On an anonymous form, responses have no name and `people` is null. Opening a named form's answers is recorded in the access log.
*
* **Who:** Supervisors and admins.
*/
get: operations["getEventsIdFeedbackResults"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/events/{id}/feedback/remind": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Remind people who haven't answered
* @description A fresh link to everyone who hasn't answered, or to `userIds` (named forms only). Nobody is sent the form or a reminder twice within 24 hours. Without `userIds` it also sends the form to anyone who has become eligible since it went out. Answers straight away; the emails go out in the background.
*
* **Who:** Supervisors and admins.
*/
post: operations["postEventsIdFeedbackRemind"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -37937,7 +38005,7 @@ export interface operations {
content: {
"application/json": {
/** @enum {string} */
reportKey: "seriesComparison" | "regTypes" | "usage" | "revenue" | "regStatus" | "donations" | "funnel" | "discounts" | "cashup" | "financeReport" | "profitReport";
reportKey: "seriesComparison" | "regTypes" | "usage" | "revenue" | "regStatus" | "donations" | "funnel" | "discounts" | "cashup" | "financeReport" | "profitReport" | "payments" | "attendees" | "revenueDetailed" | "masterOrders" | "cashupAudit" | "consentStatus" | "feedbackSummary" | "medicalList";
title: string;
subtitle?: string;
stats?: {
@@ -38294,4 +38362,283 @@ export interface operations {
};
};
};
getFeedback: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
event: {
id: string;
title: string;
startDate: unknown;
endDate: unknown;
};
/**
* @description sent, scheduled (a send time is set) or not_scheduled
* @enum {string}
*/
status: "sent" | "scheduled" | "not_scheduled";
sendAt: unknown | null;
sentAt: unknown | null;
anonymous: boolean;
invited: number;
answered: number;
/** @description % */
responseRate: number | null;
/** @description Every star rating on the form, 1–5 */
averageRating: number | null;
lastReminderAt: unknown | null;
}[];
};
};
};
/** @description Not signed in (UNAUTHENTICATED) */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not allowed for this role (FORBIDDEN) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
getEventsIdFeedbackResults: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
event: {
id: string;
title: string;
startDate: unknown;
endDate: unknown;
};
form: {
/**
* @description sent, scheduled (a send time is set) or not_scheduled
* @enum {string}
*/
status: "sent" | "scheduled" | "not_scheduled";
sendAt: unknown | null;
sentAt: unknown | null;
/** @enum {string} */
audience: "checked_in" | "all";
anonymous: boolean;
intro: string | null;
/** @description When the automatic reminder goes (3 days after sending) */
autoReminderAt: unknown | null;
autoReminderSent: boolean;
};
counts: {
invited: number;
answered: number;
notAnswered: number;
responseRate: number | null;
/** @description Not answered, and not sent the form or a reminder in the last 24 hours */
canRemind: number;
remindedRecently: number;
/** @description Would get the form but haven't been sent it yet (e.g. checked in after it went out) */
waiting: number;
};
averageRating: number | null;
questions: {
fieldId: string;
type: string;
label: string;
answered: number;
average?: number | null;
distribution?: {
label: string;
value: number;
}[];
counts?: {
label: string;
value: number;
}[];
texts?: {
value: string;
name?: string;
}[];
}[];
fields: {
id: string;
type: string;
label: string;
}[];
responses: {
id: string;
createdAt: unknown;
name: string | null;
/** @description Question id → answer */
answers: {
[key: string]: string;
};
}[];
people: {
userId: string;
name: string;
email: string | null;
phoneNumber: string | null;
sentAt: unknown;
remindedAt: unknown | null;
reminderCount: number;
respondedAt: unknown | null;
canRemind: boolean;
}[] | null;
};
};
};
};
/** @description Validation failed (VALIDATION_FAILED) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not signed in (UNAUTHENTICATED) */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not allowed for this role (FORBIDDEN) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not found (NOT_FOUND) */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
postEventsIdFeedbackRemind: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description Only these people (named forms) */
userIds?: string[] | null;
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
/** @description Being reminded */
reminded: number;
/** @description Being sent the form for the first time */
newlyInvited: number;
skipped: number;
/** @description Emails are going out in the background */
queued: boolean;
message: string;
};
};
};
};
/** @description Validation failed (VALIDATION_FAILED) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not signed in (UNAUTHENTICATED) */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not allowed for this role (FORBIDDEN) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not found (NOT_FOUND) */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
}
+1
View File
@@ -37,6 +37,7 @@ export const COMMAND_PAGES: CommandPage[] = [
{ group: "Pages", label: "Waitlist", href: "/dashboard/supervisor/waitlist", roles: S, keywords: "sold out offers" },
{ group: "Pages", label: "Parental consent", href: "/dashboard/supervisor/consent", roles: S, keywords: "youth medical" },
{ group: "Pages", label: "Volunteer rosters", href: "/dashboard/supervisor/volunteers", roles: SUP, keywords: "serving roles cover" },
{ group: "Pages", label: "Feedback", href: "/dashboard/supervisor/feedback", roles: SUP, keywords: "survey responses ratings comments remind" },
{ group: "Pages", label: "Volunteers today", href: "/dashboard/staff/volunteers", roles: S, keywords: "serving check-in qr" },
{ group: "Pages", label: "Kids check-in setup", href: "/dashboard/supervisor/kids", roles: SUP, keywords: "rooms ratios" },
{ group: "Pages", label: "Kids room screen", href: "/dashboard/staff/kids/rooms", roles: S, keywords: "children leaders on duty" },
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "2.0.0",
"version": "2.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "2.0.0",
"version": "2.1.1",
"license": "ISC",
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "2.0.0",
"version": "2.1.1",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",