Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f9268707d | ||
|
|
89eab66145 | ||
|
|
75d10f6078 | ||
|
|
8805101929 |
@@ -7,6 +7,23 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
@@ -12,6 +12,14 @@ 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.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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@cj-tech-master/excelts": "^10.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -19,6 +19,7 @@ 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',
|
||||
ai_report: 'Report summarised by the AI helper (anonymised totals)',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 } };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+13
-6
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
Last updated: 2026-11-02, after **2.0.1** 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).
|
||||
|
||||
@@ -17,6 +17,7 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 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,16 +85,22 @@ 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
|
||||
1. **After 2.0.x** (order decided 2026-11-02, roadmap §18: 2.0.1 → the marketing site → 2.1.0):
|
||||
- **Deploy:** `bash scripts/deploy.sh --check`, then `bash scripts/deploy.sh v2.0.1` 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).
|
||||
- **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".
|
||||
- **Marketing site — next, and before 2.1.0** (roadmap §17, all decided 2026-11-02): **crosscode.co.za**, Cross Code's
|
||||
main site with a Cross Events page; church website and church app as "coming later"; repo
|
||||
**`joshua/crosscode-site`** (create it on Gitea first); Joshua's own server (nginx); no prices;
|
||||
a **contact form to info@crosscode.co.za** (needs a small send endpoint on the server — see
|
||||
§17's building notes); a privacy notice and terms of use; no cookies or analytics. Logo,
|
||||
colours, wording and screenshots come from Joshua later — start with placeholders.
|
||||
- **2.1.0 — after the site** (roadmap §18): AI helpers on Privacy → **Consent log** (summarise how
|
||||
consent moved, as counts per purpose and channel) and **Access log** (a review for the
|
||||
information officer that flags unusual access, staff sent as pseudonyms). Open questions in §18.
|
||||
- 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`.
|
||||
2. **AI rules** (decided 2026-10-29, roadmap §15): the AI buttons show only when the licence's
|
||||
|
||||
+88
-10
@@ -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` | to do — decided 2026-11-02 (§17); content and logo from Joshua. **Done before 2.1.0** (§18) |
|
||||
| 7 | 2.0.1: Summarise this report on every report (people reports as anonymised totals) | 2.0.1 | ✅ (§18) |
|
||||
| 8 | 2.1.0: AI helpers on the privacy pages (Consent log, Access log) | 2.1.0 | planned (§18), after the marketing site |
|
||||
|
||||
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](#18-after-20-20x-the-site-then-210)
|
||||
19. [Suggested build order](#suggested-build-order)
|
||||
20. [Decisions](#decisions-2026-10-22)
|
||||
|
||||
---
|
||||
|
||||
@@ -1039,9 +1042,81 @@ 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) |
|
||||
| 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** — "get in touch for a licence" instead |
|
||||
| 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
|
||||
|
||||
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: AI helpers on the privacy pages
|
||||
|
||||
AI buttons on **Privacy → Consent log** and **Privacy → Access log** (admins and the information
|
||||
officer), reusing the Hub proxy, caps, `<AiButton>` and the `summarise_report` pattern from §15 —
|
||||
probably as new helpers so they can be switched on and off on their own in Site settings → AI.
|
||||
|
||||
- **Consent log — "Summarise consent":** how consent has moved over a period, per purpose and
|
||||
channel (opt-ins against opt-outs, WhatsApp STOPs, unsubscribes after a broadcast, spikes), and
|
||||
anything to act on (a purpose many people are leaving, consent given on an older notice text).
|
||||
Sent as counts per purpose, channel, action and day — never who.
|
||||
- **Access log — "Review access":** a plain-language review for the information officer: what was
|
||||
looked at or exported, how much, by which role, and **what looks unusual** (a bulk export at
|
||||
night, many medical-list views by one person, someone exporting much more than usual, sensitive
|
||||
entries). Spotting one person's unusual access needs entries grouped per staff member, so staff
|
||||
are sent as **pseudonyms** ("Staff 1", "Staff 2") that the screen turns back into names — the same
|
||||
idea as the `[[CONTACT1]]` placeholders — with only their role sent.
|
||||
- **To decide when it's picked up:** separate helpers or one "summarise a log" helper; how far
|
||||
back each looks (default 30 days?); whether the access-log review could also run on a schedule
|
||||
and email the information officer (an automatic monthly check); whether summarising the access
|
||||
log itself goes in the access log (probably yes, as `ai_access_review`).
|
||||
- Same as every helper: eval cases, help content, CHANGELOG / WHATS-NEW, the privacy notice only
|
||||
if what is sent changes.
|
||||
|
||||
---
|
||||
|
||||
@@ -1063,6 +1138,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); AI on the Consent log and Access log (2.1.0, §18) | 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 +1178,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 +1201,7 @@ feature.
|
||||
|
||||
## Still open
|
||||
|
||||
- **Marketing site (§17):** repository, domain(s), hosting, whether prices are shown, and the
|
||||
contact route.
|
||||
- **Marketing site (§17):** everything is decided; waiting on Joshua for the logo, colours, text
|
||||
about Cross Code, photos and screenshots (built with placeholders meanwhile). Must be done
|
||||
before 2.1.0 (§18).
|
||||
- **2.1.0 (§18):** the open questions on the Consent log and Access log helpers.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Vendored
+2
-2
@@ -7159,7 +7159,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.
|
||||
*/
|
||||
@@ -37937,7 +37937,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?: {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user