Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cf982e191 | ||
|
|
ff4c0c76d3 | ||
|
|
4f9268707d | ||
|
|
89eab66145 | ||
|
|
75d10f6078 |
@@ -7,6 +7,43 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
@@ -12,6 +12,23 @@ 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.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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"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.2",
|
||||
"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' });
|
||||
|
||||
+28
-13
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+9
-4
@@ -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-03, after **2.0.2** and Hub **1.6.2**. 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,8 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 2.0.2 | **Billing bands:** 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,19 +86,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, all decided 2026-11-02): **crosscode.co.za**, Cross Code's
|
||||
- **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
|
||||
|
||||
+59
-6
@@ -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) | `joshua/crosscode-site` | to do — decided 2026-11-02 (§17); content and logo from Joshua |
|
||||
| 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)
|
||||
|
||||
---
|
||||
|
||||
@@ -1044,10 +1047,10 @@ Part of 2.0 (decided 2026-10-30): a **simple static HTML site** for selling, not
|
||||
| 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) |
|
||||
| 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** — "get in touch for a licence" instead |
|
||||
| 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) |
|
||||
@@ -1070,6 +1073,53 @@ Part of 2.0 (decided 2026-10-30): a **simple static HTML site** for selling, not
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## Suggested build order
|
||||
|
||||
| Phase | Version | Features | Why |
|
||||
@@ -1088,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
|
||||
@@ -1151,4 +1202,6 @@ feature.
|
||||
## Still open
|
||||
|
||||
- **Marketing site (§17):** everything is decided; waiting on Joshua for the logo, colours, text
|
||||
about Cross Code, photos and screenshots (built with placeholders meanwhile).
|
||||
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.
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"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.2",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user