Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83e3aee40c | ||
|
|
f77b4f4164 | ||
|
|
36cabfee0d | ||
|
|
206a814973 | ||
|
|
311fe8b6f1 | ||
|
|
7ce25bf40b | ||
|
|
376b93a438 |
@@ -7,6 +7,49 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **AI helpers — the plumbing** (2.0, roadmap §15). The site calls AI through the Cross Events Hub
|
||||
(protocol §3b, Hub 1.6.0), which holds the Claude API key and a monthly allowance in rand per
|
||||
licence; no key is ever stored on the site. `GET /api/v2/ai/status` says whether to show the AI
|
||||
buttons: hidden when the licence has no AI (worked out from the licence token, without asking the
|
||||
Hub), disabled with the reason once the month's allowance is used up or the Hub can't be reached.
|
||||
The answer is cached for a minute and refreshed by every AI call. `<AiButton>` and
|
||||
`useAiStatus()` on the frontend; only supervisors and admins see AI.
|
||||
- **Site settings → AI** (admins): this month's use against the allowance, use per person, per
|
||||
helper and per day, a switch per helper, and an optional most-uses-per-person-per-day. Admins are
|
||||
emailed once a month when 80% is used. Changes are audited (`ai_settings_changed`).
|
||||
- Each AI call is logged in `AiCall` (person, helper, outcome, cost — never the prompt or answer);
|
||||
anonymising an account unlinks its calls, and calls go after 13 months. Anthropic is added to the
|
||||
operator register, switched off until a site uses AI. Migration `20261030120000_ai_helpers`.
|
||||
- v2 errors have a new code, `UNAVAILABLE` (503), for when the Hub or the AI service can't be
|
||||
reached.
|
||||
- **The five AI helpers' API** (supervisors and admins): `POST /api/v2/ai/draft-event` (the event
|
||||
form from a description, with ticket options, early bird, schedule and a budget from the notes),
|
||||
`/ai/summarise-report` (reports without people in them only), `/ai/draft-message` (email or
|
||||
WhatsApp, with `{{name}}` and the event placeholders), `/ai/summarise-feedback` (an event's
|
||||
feedback, sent without who answered; recorded in the access log) and `/ai/improve-text`
|
||||
(improve, shorter, or only fix mistakes). Every helper is told today's date. Email addresses,
|
||||
phone numbers and ID numbers in what's sent are swapped for placeholders and put back in the
|
||||
answer, so they never leave the site. Two checks are worked out in code as well as by the AI: a
|
||||
weekday that doesn't match its date, and costs a full event can't cover.
|
||||
- **AI buttons in the app** (only where the licence has AI; supervisors and admins):
|
||||
- New event → **Describe your event**: a few sentences fill in the form (details, dates, tickets,
|
||||
early bird; course dates as sessions), with the checks and a budget from the notes to look at
|
||||
first. Nothing changes until "Fill in the form".
|
||||
- **Improve / Shorter / Fix mistakes** under the event description, the site banner, and the
|
||||
email and WhatsApp message boxes; the suggestion replaces the text only with "Use this".
|
||||
- **Draft with AI** in Email attendees and WhatsApp attendees: a short note becomes the email's
|
||||
subject and body, or the WhatsApp message, with `{{name}}` and the event's details.
|
||||
- **Summarise this report** on reports without people in them, and **Summarise feedback** per
|
||||
event on the Feedback summary report.
|
||||
- Every suggestion says "AI suggestion — check it before saving".
|
||||
- **Privacy notice:** the built-in text says how the AI helpers use information and where requests
|
||||
go (sections 4 and 9). Sites with their own published notice see the change in the notice
|
||||
editor.
|
||||
- `npm run ai:eval` (backend): the helpers' eval set — fixed examples run through the site's Hub
|
||||
connection, with automatic checks on the facts and every answer printed to read.
|
||||
|
||||
## [1.51.17] - 2026-10-30 — security release
|
||||
|
||||
### Security
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"set-demo": "node scripts/set-demo.js",
|
||||
"build": "tsc -p tsconfig.json && node -e \"require('fs').writeFileSync('dist/build.json', JSON.stringify({ version: require('./package.json').version, builtAt: new Date().toISOString() }))\"",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"openapi": "tsx scripts/openapi.ts"
|
||||
"openapi": "tsx scripts/openapi.ts",
|
||||
"ai:eval": "tsx scripts/ai-eval.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cj-tech-master/excelts": "^10.2.0",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'ai_settings_changed';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AiCall" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"helper" TEXT NOT NULL,
|
||||
"outcome" TEXT NOT NULL,
|
||||
"chargedCents" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AiCall_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AiCall_createdAt_idx" ON "AiCall"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AiCall_userId_createdAt_idx" ON "AiCall"("userId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AiCall" ADD CONSTRAINT "AiCall_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
|
||||
-- 2.0 AI helpers: Anthropic in the operator register, off until the site uses AI.
|
||||
INSERT INTO "Operator" ("id", "name", "purpose", "dataShared", "location", "website", "active", "order", "updatedAt") VALUES
|
||||
(gen_random_uuid()::text, 'Anthropic (Claude), through the software provider''s Hub', 'AI helpers for staff: drafting events and messages, improving text, summarising reports and feedback', 'The text a staff member asks to draft or improve, report figures, and feedback comments with names and contact details removed. Not kept by the software provider; Anthropic''s commercial terms apply', 'United States', 'https://www.anthropic.com/legal/privacy', false, 9, CURRENT_TIMESTAMP);
|
||||
@@ -165,6 +165,7 @@ model User {
|
||||
seriesFollows SeriesFollow[]
|
||||
seasonPassPurchases SeasonPassPurchase[]
|
||||
savedFilters SavedFilter[]
|
||||
aiCalls AiCall[]
|
||||
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
|
||||
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
|
||||
|
||||
@@ -820,6 +821,7 @@ enum AdminAuditAction {
|
||||
season_pass_changed
|
||||
webhook_endpoint_changed
|
||||
registrations_bulk
|
||||
ai_settings_changed
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -2315,3 +2317,21 @@ model SavedFilter {
|
||||
@@unique([userId, page, name])
|
||||
@@index([userId, page])
|
||||
}
|
||||
|
||||
/// One AI helper call (2.0). The Hub meters the money; this copy is for Site settings → AI (use
|
||||
/// per person, per helper and per day) and per-person daily limits. No prompt or answer text is
|
||||
/// kept. `userId` is cleared when the account is anonymised; rows go after 13 months.
|
||||
model AiCall {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
helper String
|
||||
// ok, refused, incomplete, failed, cap_reached, unavailable
|
||||
outcome String
|
||||
// What the church was charged, in cents (fractions of a cent).
|
||||
chargedCents Float @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* The AI helpers' eval set (roadmap §15): `npm run ai:eval` in backend/.
|
||||
*
|
||||
* Runs fixed examples of all five helpers through this site's real Hub connection (so the site must
|
||||
* be connected and its licence must have an AI allowance), checks the facts that matter
|
||||
* automatically, and prints every answer for a person to read. Run it after changing a helper's
|
||||
* instructions or the model on the Hub, and compare with the last run. It costs about R1 a run
|
||||
* with Claude Sonnet 5.5; the calls count against this site's allowance.
|
||||
*
|
||||
* The examples come from the October 2026 model trial; the inputs are made up and hold no
|
||||
* personal information.
|
||||
*/
|
||||
import { prisma } from '../src/v2/legacy';
|
||||
import { draftEvent, summariseReport, draftMessage, summariseFeedback, improveText } from '../src/ai/helpers';
|
||||
|
||||
type Check = [label: string, ok: boolean];
|
||||
type Case = { name: string; run: (userId: string) => Promise<{ output: unknown; checks: Check[] }> };
|
||||
|
||||
const has = (text: unknown, re: RegExp) => re.test(JSON.stringify(text));
|
||||
const near = (n: number, target: number, give = 1) => Math.abs(n - target) <= give;
|
||||
|
||||
const CAMP_NOTES = 'Youth camp for grades 8–12, Fri 14 March 18:00 to Sun 16 March 14:00 at Camp Wartburg near Pietermaritzburg. 60 places. R450 per person, R400 if they book by 31 January. Parents must give consent. Theme "Rooted" (Colossians 2:7). Costs: venue R180 per person per night including meals, bus hire R6 500 return, speaker gift R1 000, T-shirts R95 each. Friday evening worship, Saturday sessions morning and evening with sport in the afternoon, Sunday morning service before we leave. Bring a sleeping bag, Bible and a torch.';
|
||||
|
||||
const FEEDBACK_COMMENTS = [
|
||||
'The keynote on Saturday morning was exactly what I needed this year.', 'Could not hear a thing in Hall B, the sound kept cutting out.',
|
||||
'Lunch queue took 40 minutes, we missed the start of the afternoon session.', 'Loved the worship, so good to be together again.',
|
||||
'Breakouts were too short, just as it got deep we had to leave.', 'Check-in with the QR code was so quick, well done.',
|
||||
'Parking was a nightmare, we parked on the road and walked.', 'Please have childcare next year, I almost could not come.',
|
||||
'Speaker in the parenting breakout was brilliant.', 'Sound in Hall B again, the mic was crackling the whole time.',
|
||||
'Food ran out of vegetarian options by the time we got there.', 'The whole day felt so well organised.',
|
||||
'More time for breakouts please, and maybe fewer of them.', 'Lunch took forever.', 'Loved the gift bags!',
|
||||
'Hall A was freezing, the aircon was too cold.', 'The app reminders the day before were really helpful.',
|
||||
'Would love a longer Q&A with the main speaker.', 'Queue for lunch was very long, maybe stagger the breakouts?',
|
||||
'Parking marshals would help a lot.', 'Worship team was amazing.', 'Childcare or a kids programme would let young moms come.',
|
||||
'Some of the slides were hard to read from the back.', 'Best conference yet, thank you to the whole team.',
|
||||
'I would pay a bit more for a better lunch.', 'Hall B sound please fix it.',
|
||||
];
|
||||
|
||||
const CASES: Case[] = [
|
||||
{
|
||||
name: 'Draft an event — full notes with costs',
|
||||
async run(userId) {
|
||||
const r = await draftEvent(userId, CAMP_NOTES);
|
||||
const d = r.draft;
|
||||
return {
|
||||
output: r,
|
||||
checks: [
|
||||
['price R450, early bird R400 until 31 January', d.ticketOptions[0]?.priceRand === 450 && d.ticketOptions[0]?.earlyBirdPriceRand === 400 && d.ticketOptions[0]?.earlyBirdUntil.endsWith('-01-31')],
|
||||
['60 places, parental consent', d.capacity === 60 && d.needsParentalConsent],
|
||||
['budget R34 800, break-even 78', r.totalCostRand === 34800 && r.breakEvenPlaces === 78],
|
||||
['warns the camp is short even when full', r.warnings.some(w => w.includes('short'))],
|
||||
['times not in the notes left empty (only 18:00 and 14:00 given)', d.sessions.every(s => !s.startTime || s.startTime === '18:00')],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Summarise a report — a month of payments',
|
||||
async run(userId) {
|
||||
const { summary } = await summariseReport(userId, {
|
||||
reportKey: 'revenue', title: 'Revenue summary — October 2026 (September for comparison)',
|
||||
columns: ['Method', 'October payments', 'October amount', 'September payments', 'September amount'],
|
||||
rows: [['Card', 96, 'R42 300', 81, 'R35 100'], ['EFT', 14, 'R18 000', 17, 'R21 500'], ['Cash at the door', 31, 'R6 250', 22, 'R4 100'], ['Refunds', 4, 'R2 700', 2, 'R450']],
|
||||
stats: [{ label: 'Card payments waiting for the provider over 2 days', value: '3 (R1 350)' }],
|
||||
});
|
||||
return {
|
||||
output: summary,
|
||||
checks: [
|
||||
['October total R66 550', has(summary, /66[\s ]?550/)],
|
||||
['up on September (R60 700)', has(summary, /60[\s ]?700|5[\s ]?850|9[.,]6/)],
|
||||
['the 3 waiting card payments need checking', has(summary.watchOut, /1[\s ]?350|3 (online |card )?payments/i)],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Summarise feedback — women\'s conference',
|
||||
async run(userId) {
|
||||
const { summary } = await summariseFeedback(userId, {
|
||||
event: { title: 'Women\'s Conference 2026' }, responses: 112, invited: 186,
|
||||
questions: [
|
||||
{ label: 'Overall', type: 'rating', answered: 112, average: 4.4 }, { label: 'Venue', type: 'rating', answered: 110, average: 3.9 },
|
||||
{ label: 'Food', type: 'rating', answered: 108, average: 3.6 }, { label: 'Comments', type: 'text', answered: FEEDBACK_COMMENTS.length, texts: FEEDBACK_COMMENTS },
|
||||
],
|
||||
});
|
||||
const count = (re: RegExp) => summary.improve.find(t => re.test(t.theme))?.mentions ?? -1;
|
||||
return {
|
||||
output: summary,
|
||||
checks: [
|
||||
['lunch/food raised by about 5', near(count(/lunch|food|queue/i), 5)],
|
||||
['Hall B sound raised by 3', near(count(/sound|hall b/i), 3, 0)],
|
||||
['quotes copied exactly', summary.quotes.every(q => FEEDBACK_COMMENTS.includes(q))],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Draft a message — WhatsApp reminder',
|
||||
async run(userId) {
|
||||
const { message } = await draftMessage(userId, { channel: 'whatsapp', note: 'Youth camp this weekend: remind them to bring a sleeping bag; the bus leaves at 7:30 on Friday from the church car park.' });
|
||||
return {
|
||||
output: message,
|
||||
checks: [
|
||||
['uses {{name}}', message.body.includes('{{name}}')],
|
||||
['keeps 7:30, sleeping bag, car park', /7:30/.test(message.body) && /sleeping bag/i.test(message.body) && /car park/i.test(message.body)],
|
||||
['no "reminda" (seen with Haiku)', !/reminda/i.test(message.body)],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Draft a message — email about a venue change',
|
||||
async run(userId) {
|
||||
const { message } = await draftMessage(userId, { channel: 'email', note: 'Men\'s breakfast this Saturday is moving from the church hall to the Protea Hotel on Main Road because the hall is being renovated. Same time, 7:00. Park at the back of the hotel. Price stays R120, nothing to do if you\'ve paid. Speaker is still Pastor Mike. Questions: events@church.example.org' });
|
||||
return {
|
||||
output: message,
|
||||
checks: [
|
||||
['has a subject', message.subject.trim().length > 5],
|
||||
['keeps the facts', /Protea/.test(message.body) && /7:00/.test(message.body) && /R120/.test(message.body)],
|
||||
['the email address came back intact', message.body.includes('events@church.example.org')],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Improve this text — a messy description',
|
||||
async run(userId) {
|
||||
const { result } = await improveText(userId, { text: 'join us for are anual family fun day!!! theres jumping castles, food stalls and a talent show for the kids. Bring the hole family its going to be awesome. tickets R50 for adults kids under 12 free. starts 9 till 2 on the church field, if it rains we move inside to the hall. please register so we know how many boerewors rolls to make lol', mode: 'improve', where: 'description' });
|
||||
return {
|
||||
output: result,
|
||||
checks: [
|
||||
['spelling fixed', /annual/i.test(result.text) && /whole family/i.test(result.text) && !/\bare anual\b/.test(result.text)],
|
||||
['every fact kept (R50, under 12, 9 to 2, hall)', /R50/.test(result.text) && /12/.test(result.text) && /\b9\b/.test(result.text) && /\b2\b/.test(result.text) && /hall/i.test(result.text)],
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
(async () => {
|
||||
const admin = await prisma.user.findFirst({ where: { role: 'admin', isActive: true }, select: { id: true } });
|
||||
if (!admin) throw new Error('No active admin to log the calls against.');
|
||||
let passed = 0;
|
||||
let total = 0;
|
||||
for (const c of CASES) {
|
||||
console.log(`\n━━ ${c.name}`);
|
||||
try {
|
||||
const { output, checks } = await c.run(admin.id);
|
||||
for (const [label, ok] of checks) {
|
||||
total++;
|
||||
if (ok) passed++;
|
||||
console.log(` ${ok ? '✓' : '✗'} ${label}`);
|
||||
}
|
||||
console.log(JSON.stringify(output, null, 2).split('\n').map(l => ` ${l}`).join('\n'));
|
||||
} catch (e) {
|
||||
total++;
|
||||
console.log(` ✗ failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
console.log(`\n${passed} of ${total} checks passed. Read the answers too: the checks only cover the facts.`);
|
||||
await prisma.$disconnect();
|
||||
process.exit(passed === total ? 0 : 1);
|
||||
})().catch(async e => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* The five AI helpers (roadmap §15): each one's instructions, the shape of its answer, and what it
|
||||
* sends. Run through `runHelper` (./index.ts), which goes via the Hub.
|
||||
*
|
||||
* What leaves the site (POPIA): only what a helper needs, and never names, contact details,
|
||||
* medical or children's information.
|
||||
* - Email addresses, phone numbers and ID numbers in any free text are swapped for placeholders
|
||||
* like [[CONTACT1]] before sending, and put back in the answer (`protect` / `restore`).
|
||||
* - Reports: only those without people in them (REPORTS_WITHOUT_PEOPLE).
|
||||
* - Feedback: ratings, choice counts and comments, never who answered.
|
||||
*
|
||||
* Lessons from the October 2026 trial, built in: every helper is told today's date, and the checks
|
||||
* that matter most (a weekday that doesn't match its date, an event that loses money when full)
|
||||
* are worked out here in code as well, not left to the model.
|
||||
*/
|
||||
|
||||
import { ApiError } from '../v2/errors';
|
||||
import { runHelper, type AiStatus } from './index';
|
||||
|
||||
// ─── Dates ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SAST_MS = 2 * 3600 * 1000;
|
||||
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
/** "Today is Friday 30 October 2026." in South African time. */
|
||||
export function todayLine(now = new Date()): string {
|
||||
const d = new Date(now.getTime() + SAST_MS);
|
||||
return `Today is ${WEEKDAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}.`;
|
||||
}
|
||||
|
||||
// ─── Keeping contact details on the site ──────────────────────────────────────
|
||||
|
||||
const CONTACT_RE = [
|
||||
/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, // email
|
||||
/(?:\+27|\b0)(?:[\s-]?\d){9}\b/g, // South African phone numbers
|
||||
/\b\d{13}\b/g, // South African ID numbers
|
||||
];
|
||||
|
||||
export type Protected = { text: string; found: Map<string, string> };
|
||||
|
||||
/** Swap contact details for [[CONTACTn]], so they never leave the site. */
|
||||
export function protect(text: string, found: Map<string, string> = new Map()): Protected {
|
||||
let out = text;
|
||||
for (const re of CONTACT_RE) {
|
||||
out = out.replace(re, match => {
|
||||
for (const [k, v] of found) if (v === match) return k;
|
||||
const key = `[[CONTACT${found.size + 1}]]`;
|
||||
found.set(key, match);
|
||||
return key;
|
||||
});
|
||||
}
|
||||
return { text: out, found };
|
||||
}
|
||||
|
||||
/** Put the contact details back into an answer (strings anywhere inside it). */
|
||||
export function restore<T>(value: T, found: Map<string, string>): T {
|
||||
if (!found.size) return value;
|
||||
const fix = (s: string) => s.replace(/\[\[CONTACT\d+\]\]/g, k => found.get(k) ?? k);
|
||||
const walk = (v: unknown): unknown => (typeof v === 'string' ? fix(v) : Array.isArray(v) ? v.map(walk) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])) : v);
|
||||
return walk(value) as T;
|
||||
}
|
||||
|
||||
const KEEP_PLACEHOLDERS = 'Text like [[CONTACT1]] stands for a contact detail the site keeps private: copy it exactly where it belongs and never change it.';
|
||||
|
||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const str = { type: 'string' };
|
||||
const num = { type: 'number' };
|
||||
const int = { type: 'integer' };
|
||||
const bool = { type: 'boolean' };
|
||||
const list = (items: object) => ({ type: 'array', items });
|
||||
const obj = (properties: Record<string, object>, descriptions: Record<string, string> = {}) => ({
|
||||
type: 'object', additionalProperties: false, required: Object.keys(properties),
|
||||
properties: Object.fromEntries(Object.entries(properties).map(([k, v]) => [k, descriptions[k] ? { ...v, description: descriptions[k] } : v])),
|
||||
});
|
||||
|
||||
// ─── 1. Draft an event ────────────────────────────────────────────────────────
|
||||
|
||||
export type EventDraftAnswer = {
|
||||
title: string; summary: string; description: string;
|
||||
startDate: string; startTime: string; endDate: string; endTime: string;
|
||||
location: string; capacity: number; needsParentalConsent: boolean; ageGroup: string;
|
||||
ticketOptions: { name: string; priceRand: number; description: string; earlyBirdPriceRand: number; earlyBirdUntil: string }[];
|
||||
sessions: { date: string; startTime: string; endTime: string; label: string }[];
|
||||
costs: { item: string; basis: string; amountRand: number }[];
|
||||
checks: string[];
|
||||
};
|
||||
|
||||
const EVENT_SCHEMA = obj({
|
||||
title: str, summary: str, description: str,
|
||||
startDate: str, startTime: str, endDate: str, endTime: str,
|
||||
location: str, capacity: int, needsParentalConsent: bool, ageGroup: str,
|
||||
ticketOptions: list(obj({ name: str, priceRand: num, description: str, earlyBirdPriceRand: num, earlyBirdUntil: str }, { earlyBirdPriceRand: '0 when there is none', earlyBirdUntil: 'YYYY-MM-DD, or empty' })),
|
||||
sessions: list(obj({ date: str, startTime: str, endTime: str, label: str })),
|
||||
costs: list(obj({ item: str, basis: str, amountRand: num }, { basis: 'How it is worked out, e.g. "R180 × 60 people × 2 nights"', amountRand: 'Total for this line at full capacity' })),
|
||||
checks: list(str),
|
||||
}, {
|
||||
summary: 'One sentence for the event card',
|
||||
description: 'The public event page: 2–4 short paragraphs, plain text',
|
||||
startDate: 'YYYY-MM-DD', endDate: 'YYYY-MM-DD', startTime: 'HH:MM, 24-hour, or empty', endTime: 'HH:MM, 24-hour, or empty',
|
||||
capacity: 'Places in total; 0 when not given',
|
||||
sessions: 'Only for an event that meets on several separate dates people attend, like a weekly course: one row per date. Empty for a single event, even one over a weekend.',
|
||||
checks: 'Things to look at before publishing: missing details, contradictions, money that does not add up. Empty if none.',
|
||||
});
|
||||
|
||||
const EVENT_SYSTEM = (now: Date) => [
|
||||
'You help a church events team set up an event in Cross Events, an event registration system. From the organiser\'s notes, fill in the event form.',
|
||||
todayLine(now),
|
||||
'- Title: short and clear. Description: for the public event page, warm and clear, 2–4 short paragraphs, plain text (no markdown): who it is for, what happens, what is included, what to bring if known.',
|
||||
'- South African rand; 24-hour times. Dates as YYYY-MM-DD: the next time that date comes round after today, unless the notes give a year.',
|
||||
'- If a weekday in the notes does not match its date, keep the date and say so in "checks".',
|
||||
'- Ticket options: the prices the notes give, with any early-bird price and its last day. One option when only one price is given.',
|
||||
'- Leave anything the notes do not give empty (or 0) rather than guess, including times.',
|
||||
'- Costs: one line per cost the notes give, worked out at full capacity, showing how. Never invent costs.',
|
||||
'- Never invent names, phone numbers, email addresses or links.',
|
||||
KEEP_PLACEHOLDERS,
|
||||
].join('\n');
|
||||
|
||||
/** Weekdays written next to dates in the notes ("Fri 14 March") that don't match the year used. */
|
||||
export function weekdayClashes(notes: string, year: number): string[] {
|
||||
const out: string[] = [];
|
||||
const re = /\b(mon|tue|wed|thu|fri|sat|sun)[a-z]*\.?,?\s+(\d{1,2})(?:st|nd|rd|th)?\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*/gi;
|
||||
for (const m of notes.matchAll(re)) {
|
||||
const day = Number(m[2]);
|
||||
const month = MONTHS.findIndex(x => x.toLowerCase().startsWith(m[3].toLowerCase()));
|
||||
const actual = new Date(Date.UTC(year, month, day)).getUTCDay();
|
||||
const said = WEEKDAYS.findIndex(w => w.toLowerCase().startsWith(m[1].toLowerCase()));
|
||||
if (actual !== said) out.push(`The notes say ${WEEKDAYS[said]} ${day} ${MONTHS[month]}, but ${day} ${MONTHS[month]} ${year} is a ${WEEKDAYS[actual]}. Check the dates.`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** R34 800 — plain spaces between thousands, as the AI writes them (en-ZA uses a no-break space). */
|
||||
const rands = (n: number) => `R${Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')}`;
|
||||
|
||||
/** Money and date checks worked out in code, whatever the model said. */
|
||||
export function eventWarnings(notes: string, a: EventDraftAnswer): string[] {
|
||||
const out: string[] = [];
|
||||
const year = Number(a.startDate.slice(0, 4));
|
||||
if (year > 2000) out.push(...weekdayClashes(notes, year));
|
||||
if (a.startDate && a.endDate && `${a.endDate}T${a.endTime || '23:59'}` < `${a.startDate}T${a.startTime || '00:00'}`) out.push('The end is before the start.');
|
||||
const costs = a.costs.reduce((s, c) => s + (Number(c.amountRand) || 0), 0);
|
||||
const main = a.ticketOptions[0];
|
||||
if (costs > 0 && main?.priceRand > 0) {
|
||||
const breakEven = Math.ceil(costs / main.priceRand);
|
||||
if (a.capacity > 0 && breakEven > a.capacity) {
|
||||
const full = a.capacity * main.priceRand;
|
||||
const early = main.earlyBirdPriceRand > 0 ? a.capacity * main.earlyBirdPriceRand : null;
|
||||
out.push(`The costs in the notes come to ${rands(costs)}, which needs ${breakEven} places at ${rands(main.priceRand)} — more than the ${a.capacity} available. Even full, the event takes ${rands(full)}${early ? ` (${rands(early)} if everyone books early)` : ''}, ${rands(costs - full)} short.`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function draftEvent(userId: string, notes: string, now = new Date()): Promise<{ draft: EventDraftAnswer; warnings: string[]; totalCostRand: number; breakEvenPlaces: number | null; status: AiStatus }> {
|
||||
const p = protect(notes);
|
||||
const { output, status } = await runHelper('draft_event', userId, { system: EVENT_SYSTEM(now), input: p.text, schema: EVENT_SCHEMA, maxTokens: 4000, effort: 'low' });
|
||||
const draft = restore(output as EventDraftAnswer, p.found);
|
||||
const totalCostRand = draft.costs.reduce((s, c) => s + (Number(c.amountRand) || 0), 0);
|
||||
const price = draft.ticketOptions[0]?.priceRand || 0;
|
||||
return { draft, warnings: eventWarnings(notes, draft), totalCostRand, breakEvenPlaces: totalCostRand > 0 && price > 0 ? Math.ceil(totalCostRand / price) : null, status };
|
||||
}
|
||||
|
||||
// ─── 2. Summarise a report ────────────────────────────────────────────────────
|
||||
|
||||
/** Reports with no people in them: only these can be summarised (keys from ReportCatalog). */
|
||||
export const REPORTS_WITHOUT_PEOPLE = ['seriesComparison', 'regTypes', 'usage', 'revenue', 'regStatus', 'donations', 'funnel', 'discounts', 'cashup', 'financeReport', 'profitReport'] as const;
|
||||
const MAX_ROWS = 400;
|
||||
|
||||
export type ReportInput = {
|
||||
reportKey: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
stats?: { label: string; value: string | number }[];
|
||||
columns: string[];
|
||||
rows: (string | number)[][];
|
||||
};
|
||||
|
||||
export type ReportSummary = { headline: string; insights: { title: string; detail: string }[]; watchOut: string[] };
|
||||
|
||||
const REPORT_SCHEMA = obj({ headline: str, insights: list(obj({ title: str, detail: str })), watchOut: list(str) }, { watchOut: 'Things that need someone to act; empty if none' });
|
||||
|
||||
const REPORT_SYSTEM = (now: Date) => [
|
||||
'You summarise a report for the leaders of a church that uses Cross Events, an event registration system. They are busy volunteers and staff, not analysts.',
|
||||
todayLine(now),
|
||||
'- 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.',
|
||||
'- Plain, friendly South African English. Amounts in rand like R18 900.',
|
||||
].join('\n');
|
||||
|
||||
/** The report as compact text: stats, then the table as pipe-separated rows. */
|
||||
export function reportText(r: ReportInput): string {
|
||||
const lines = [`Report: ${r.title}`];
|
||||
if (r.subtitle) lines.push(r.subtitle);
|
||||
for (const s of r.stats || []) lines.push(`${s.label}: ${s.value}`);
|
||||
lines.push(`Columns: ${r.columns.join(' | ')}`);
|
||||
for (const row of r.rows.slice(0, MAX_ROWS)) lines.push(row.map(c => String(c ?? '').replace(/\s+/g, ' ')).join(' | '));
|
||||
if (r.rows.length > MAX_ROWS) lines.push(`(${r.rows.length - MAX_ROWS} more rows not shown)`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
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' });
|
||||
return { summary: restore(output as ReportSummary, p.found), status };
|
||||
}
|
||||
|
||||
// ─── 3. Draft a message ───────────────────────────────────────────────────────
|
||||
|
||||
export type MessageAnswer = { subject: string; body: string };
|
||||
|
||||
const MESSAGE_SCHEMA = obj({ subject: str, body: str }, { subject: 'Empty for WhatsApp' });
|
||||
|
||||
const MESSAGE_SYSTEM = (channel: 'email' | 'whatsapp', now: Date) => [
|
||||
'You write messages from a church events team to people registered for an event, from the organiser\'s short note.',
|
||||
todayLine(now),
|
||||
channel === 'whatsapp'
|
||||
? '- WhatsApp: plain text, at most 3 short sentences, at most one emoji. "subject" is empty.'
|
||||
: '- Email: a clear subject, then the body in short plain-text paragraphs, the most important thing first. Start with "Hi {{name}}," and end with "The events team".',
|
||||
'- Use {{name}} for the person\'s first name. {{event.title}}, {{event.start}} and {{event.link}} are filled in for each event, if useful.',
|
||||
'- Use only the facts in the note and the event details; never invent times, places, prices, links or contact details, or add advice the note doesn\'t give.',
|
||||
'- Spell carefully: this goes to everyone registered.',
|
||||
KEEP_PLACEHOLDERS,
|
||||
].join('\n');
|
||||
|
||||
export type EventFacts = { title: string; start: string; location: string | null };
|
||||
|
||||
export async function draftMessage(userId: string, input: { channel: 'email' | 'whatsapp'; note: string; event?: EventFacts | null }, now = new Date()): Promise<{ message: MessageAnswer; status: AiStatus }> {
|
||||
const p = protect(input.note);
|
||||
const facts = input.event ? `\n\nEvent: ${input.event.title}, ${input.event.start}${input.event.location ? `, at ${input.event.location}` : ''}` : '';
|
||||
const { output, status } = await runHelper('draft_message', userId, {
|
||||
system: MESSAGE_SYSTEM(input.channel, now), input: `Note: ${p.text}${facts}`, schema: MESSAGE_SCHEMA, maxTokens: input.channel === 'whatsapp' ? 600 : 1500, effort: 'low',
|
||||
});
|
||||
const message = restore(output as MessageAnswer, p.found);
|
||||
return { message: input.channel === 'whatsapp' ? { subject: '', body: message.body } : message, status };
|
||||
}
|
||||
|
||||
// ─── 4. Summarise feedback ────────────────────────────────────────────────────
|
||||
|
||||
export type FeedbackQuestion = { label: string; type: string; answered: number; average?: number | null; counts?: { label: string; value: number }[]; texts?: string[] };
|
||||
export type FeedbackSummary = { overall: string; liked: { theme: string; mentions: number }[]; improve: { theme: string; mentions: number }[]; quotes: string[]; nextTime: string[] };
|
||||
|
||||
const FEEDBACK_SCHEMA = obj({
|
||||
overall: str, liked: list(obj({ theme: str, mentions: int })), improve: list(obj({ theme: str, mentions: int })), quotes: list(str), nextTime: list(str),
|
||||
}, { liked: 'Most-mentioned first', improve: 'Most-mentioned first', quotes: 'Up to 3 short comments, copied exactly' });
|
||||
|
||||
const FEEDBACK_SYSTEM = [
|
||||
'You summarise the feedback people gave after a church event, for the team that ran it.',
|
||||
'- "overall": two sentences on how it went, using the ratings.',
|
||||
'- "liked" and "improve": the main themes, most-mentioned first, each with how many comments raised it. Count carefully: a comment counts once per theme.',
|
||||
'- "quotes": up to 3 short comments that capture the feedback well, copied exactly.',
|
||||
'- "nextTime": 2–4 concrete suggestions for next time, based only on the feedback.',
|
||||
'- The comments are anonymous; never guess who wrote one.',
|
||||
KEEP_PLACEHOLDERS,
|
||||
].join('\n');
|
||||
const MAX_COMMENTS = 400;
|
||||
const MAX_COMMENT = 600;
|
||||
|
||||
/** The feedback as text: each question with its average or counts, and the comments numbered. */
|
||||
export function feedbackText(event: { title: string }, responses: number, invited: number, questions: FeedbackQuestion[]): string {
|
||||
const lines = [`Event: ${event.title}`, `Responses: ${responses}${invited ? ` of ${invited} asked` : ''}`];
|
||||
let n = 0;
|
||||
for (const q of questions) {
|
||||
if (q.average != null) lines.push(`${q.label} — average ${q.average} (${q.answered} answers)`);
|
||||
else if (q.counts) lines.push(`${q.label} — ${q.counts.map(c => `${c.label}: ${c.value}`).join(', ')}`);
|
||||
else if (q.texts?.length) {
|
||||
lines.push(`${q.label} — comments:`);
|
||||
for (const t of q.texts) {
|
||||
if (n++ >= MAX_COMMENTS) break;
|
||||
lines.push(`${n}. ${t.replace(/\s+/g, ' ').slice(0, MAX_COMMENT)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export async function summariseFeedback(userId: string, input: { event: { title: string }; responses: number; invited: number; questions: FeedbackQuestion[] }): Promise<{ summary: FeedbackSummary; status: AiStatus }> {
|
||||
const p = protect(feedbackText(input.event, input.responses, input.invited, input.questions));
|
||||
const { output, status } = await runHelper('summarise_feedback', userId, { system: FEEDBACK_SYSTEM, input: p.text, schema: FEEDBACK_SCHEMA, maxTokens: 2000, effort: 'low' });
|
||||
return { summary: restore(output as FeedbackSummary, p.found), status };
|
||||
}
|
||||
|
||||
// ─── 5. Improve this text ─────────────────────────────────────────────────────
|
||||
|
||||
export const IMPROVE_MODES = { improve: 'Make it clear and friendly', shorter: 'Make it shorter', fix: 'Only fix spelling and grammar' } as const;
|
||||
export type ImproveMode = keyof typeof IMPROVE_MODES;
|
||||
export type ImproveAnswer = { text: string; changes: string[] };
|
||||
|
||||
const IMPROVE_SCHEMA = obj({ text: str, changes: list(str) }, { changes: 'A short list of what changed and why' });
|
||||
|
||||
const IMPROVE_SYSTEM = (mode: ImproveMode, where: string) => [
|
||||
`You improve text a church events team wrote for ${where}.`,
|
||||
mode === 'fix'
|
||||
? '- Only fix spelling, grammar and punctuation. Change nothing else.'
|
||||
: mode === 'shorter'
|
||||
? '- Make it noticeably shorter and easier to scan, fixing spelling and grammar too.'
|
||||
: '- Fix spelling, grammar and punctuation, and make it clear and friendly, about the same length or shorter.',
|
||||
'- Keep every fact (times, dates, prices, places, ages) exactly; add none.',
|
||||
'- Plain text, no markdown. Keep the writer\'s warmth, and any humour that works in public. Keep placeholders like {{name}} exactly.',
|
||||
KEEP_PLACEHOLDERS,
|
||||
].join('\n');
|
||||
|
||||
const WHERE = { description: 'an event page', banner: 'a short banner across the top of the website', message: 'a message to people registered for an event' } as const;
|
||||
export type ImproveWhere = keyof typeof WHERE;
|
||||
|
||||
export async function improveText(userId: string, input: { text: string; mode: ImproveMode; where: ImproveWhere }): Promise<{ result: ImproveAnswer; status: AiStatus }> {
|
||||
const p = protect(input.text);
|
||||
const { output, status } = await runHelper('improve_text', userId, { system: IMPROVE_SYSTEM(input.mode, WHERE[input.where]), input: p.text, schema: IMPROVE_SCHEMA, maxTokens: Math.min(8000, 600 + Math.ceil(input.text.length / 2)), effort: 'low' });
|
||||
return { result: restore(output as ImproveAnswer, p.found), status };
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* AI helpers (2.0, roadmap §15). The site never holds a Claude API key: every call goes through
|
||||
* the Cross Events Hub (docs/hub-protocol.md §3b), which checks the licence's monthly cap in rand,
|
||||
* calls Claude and meters the call. This module:
|
||||
* - works out what the AI buttons do (`aiStatus`): hidden, working, or disabled until the
|
||||
* allowance renews — from the licence token first, then the Hub (cached for a minute);
|
||||
* - runs one helper call (`runHelper`), with this site's own switches per helper and an optional
|
||||
* daily limit per person, and logs it in `AiCall` (no prompt or answer text);
|
||||
* - emails the site admins once a month when 80% of the allowance is used.
|
||||
* The helpers themselves (prompts, schemas, what they send) are in ./helpers.ts.
|
||||
*/
|
||||
|
||||
import { ApiError } from '../v2/errors';
|
||||
import { prisma } from '../v2/legacy';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS Hub client and helpers aren't typed */
|
||||
const hub = require('../hub/client') as {
|
||||
PROTOCOL_VERSION: number;
|
||||
signedPost(path: string, body: object, opts?: { timeoutMs?: number }): Promise<any>;
|
||||
HubError: new (...args: any[]) => Error & { code: string; statusCode: number; data: any };
|
||||
};
|
||||
const { licenceStatus } = require('../hub/licence') as {
|
||||
licenceStatus(): Promise<{ connected: boolean; claims: { limits?: { aiMonthlyCapCents?: number } } | null; state: string; readOnly: boolean }>;
|
||||
};
|
||||
const email = require('../utils/email') as {
|
||||
sendMail(m: { to: string; subject: string; html: string; text: string }): Promise<unknown>;
|
||||
emailWrapper(body: string): string;
|
||||
isSmtpConfigured(): boolean;
|
||||
};
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<unknown> };
|
||||
|
||||
export const HELPERS = ['draft_event', 'summarise_report', 'draft_message', 'summarise_feedback', 'improve_text'] as const;
|
||||
export type HelperName = (typeof HELPERS)[number];
|
||||
|
||||
/** What the Hub says (§3b). Cents are charged rand, with fractions. */
|
||||
export type HubAiStatus = { enabled: boolean; capReached: boolean; capCents: number; usedCents: number; usedPercent: number; renewsOn: string | null };
|
||||
/** What the buttons need: the Hub's answer, whether it could be reached, and this site's switches. */
|
||||
export type AiStatus = HubAiStatus & { available: boolean; helpers: Record<HelperName, boolean> };
|
||||
|
||||
export type AiSettings = { helpers: Record<HelperName, boolean>; dailyLimitPerPerson: number };
|
||||
const SETTINGS_KEY = 'ai_settings';
|
||||
const WARNED_KEY = 'ai_warned_month';
|
||||
const STATUS_TTL_MS = 60 * 1000;
|
||||
const CALL_TIMEOUT_MS = 90 * 1000;
|
||||
const KEEP_CALLS_DAYS = 400;
|
||||
|
||||
const OFF: HubAiStatus = { enabled: false, capReached: false, capCents: 0, usedCents: 0, usedPercent: 0, renewsOn: null };
|
||||
|
||||
// ─── This site's settings ─────────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_SETTINGS: AiSettings = { helpers: Object.fromEntries(HELPERS.map(h => [h, true])) as Record<HelperName, boolean>, dailyLimitPerPerson: 0 };
|
||||
|
||||
export async function getAiSettings(): Promise<AiSettings> {
|
||||
const row = await prisma.appSetting.findUnique({ where: { key: SETTINGS_KEY } });
|
||||
let saved: Partial<AiSettings> = {};
|
||||
try { saved = row ? JSON.parse(row.value) : {}; } catch { /* keep the defaults */ }
|
||||
return {
|
||||
helpers: { ...DEFAULT_SETTINGS.helpers, ...Object.fromEntries(HELPERS.filter(h => typeof saved.helpers?.[h] === 'boolean').map(h => [h, saved.helpers![h]])) },
|
||||
dailyLimitPerPerson: Number.isInteger(saved.dailyLimitPerPerson) && saved.dailyLimitPerPerson! >= 0 ? saved.dailyLimitPerPerson! : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveAiSettings(next: { helpers?: Partial<Record<HelperName, boolean | undefined>>; dailyLimitPerPerson?: number }, actor: { id: string; role: string }, ip?: string): Promise<AiSettings> {
|
||||
const current = await getAiSettings();
|
||||
const changed = Object.fromEntries(HELPERS.filter(h => typeof next.helpers?.[h] === 'boolean').map(h => [h, next.helpers![h]]));
|
||||
const value: AiSettings = {
|
||||
helpers: { ...current.helpers, ...changed },
|
||||
dailyLimitPerPerson: next.dailyLimitPerPerson ?? current.dailyLimitPerPerson,
|
||||
};
|
||||
await prisma.appSetting.upsert({ where: { key: SETTINGS_KEY }, update: { value: JSON.stringify(value) }, create: { key: SETTINGS_KEY, value: JSON.stringify(value) } });
|
||||
await logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'ai_settings_changed', targetType: 'setting', targetId: SETTINGS_KEY, metadata: value, ip });
|
||||
return value;
|
||||
}
|
||||
|
||||
// ─── Status ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let cached: { at: number; value: HubAiStatus; available: boolean } | null = null;
|
||||
|
||||
/** Keep what the Hub just said (every AI reply and error carries it). */
|
||||
function remember(value: unknown, available = true): void {
|
||||
const v = value as Partial<HubAiStatus> | null;
|
||||
if (v && typeof v.enabled === 'boolean') cached = { at: Date.now(), value: { ...OFF, ...v } as HubAiStatus, available };
|
||||
}
|
||||
|
||||
export function clearAiStatusCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cap from the licence token (refreshed by every heartbeat): no Hub call is needed to know AI
|
||||
* is off, which is most sites.
|
||||
*/
|
||||
async function licenceCap(): Promise<number> {
|
||||
const l = await licenceStatus();
|
||||
if (!l.connected || !l.claims || l.readOnly) return 0;
|
||||
return Number(l.claims.limits?.aiMonthlyCapCents) || 0;
|
||||
}
|
||||
|
||||
async function hubStatus(fresh = false): Promise<{ value: HubAiStatus; available: boolean }> {
|
||||
if (!fresh && cached && Date.now() - cached.at < STATUS_TTL_MS) return cached;
|
||||
try {
|
||||
remember(await hub.signedPost('/api/installs/ai/status', { protocolVersion: hub.PROTOCOL_VERSION }));
|
||||
} catch (e) {
|
||||
const err = e as { code?: string; statusCode?: number };
|
||||
// A Hub before 1.6.0 has no AI; a released install has no licence: both mean off.
|
||||
if (err.statusCode === 404) cached = { at: Date.now(), value: OFF, available: true };
|
||||
else {
|
||||
console.warn('[ai] could not ask the Hub for the AI status:', (e as Error).message);
|
||||
// Unreachable: keep showing the buttons (they say AI is unavailable when pressed).
|
||||
cached = { at: Date.now(), value: { ...(cached?.value || OFF), enabled: true }, available: false };
|
||||
}
|
||||
}
|
||||
return cached!;
|
||||
}
|
||||
|
||||
export async function aiStatus({ fresh = false } = {}): Promise<AiStatus> {
|
||||
const settings = await getAiSettings();
|
||||
if ((await licenceCap()) <= 0) return { ...OFF, available: true, helpers: settings.helpers };
|
||||
const { value, available } = await hubStatus(fresh);
|
||||
return { ...value, available, helpers: settings.helpers };
|
||||
}
|
||||
|
||||
// ─── Running a helper ─────────────────────────────────────────────────────────
|
||||
|
||||
export type HelperRequest = {
|
||||
/** The helper's fixed instructions (the Hub has Claude cache them). */
|
||||
system: string;
|
||||
/** This request's text. Never names, contact details, medical or children's information. */
|
||||
input: string;
|
||||
/** A JSON schema: the answer comes back as JSON in this shape. */
|
||||
schema?: object;
|
||||
maxTokens: number;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
};
|
||||
|
||||
const failure = (code: 'FORBIDDEN' | 'RATE_LIMITED' | 'UNAVAILABLE', reason: string, message: string) => Object.assign(new ApiError(code, message), { reason });
|
||||
|
||||
function renewsText(renewsOn: string | null): string {
|
||||
if (!renewsOn) return 'next month';
|
||||
return new Date(`${renewsOn}T12:00:00Z`).toLocaleDateString('en-ZA', { day: 'numeric', month: 'long', timeZone: 'Africa/Johannesburg' });
|
||||
}
|
||||
|
||||
/** The Hub's errors (§3b) as v2 errors, and what to log. */
|
||||
function fromHubError(e: { code?: string; statusCode?: number; message: string; data?: any }): { error: ApiError; outcome: string } {
|
||||
remember(e.data?.status);
|
||||
switch (e.code) {
|
||||
case 'AI_CAP_REACHED':
|
||||
return { outcome: 'cap_reached', error: failure('FORBIDDEN', 'AI_CAP_REACHED', `This month's AI allowance is used up — it renews on ${renewsText(e.data?.status?.renewsOn ?? null)}.`) };
|
||||
case 'AI_OFF': case 'LICENCE_INACTIVE': case 'AI_NOT_CONFIGURED':
|
||||
return { outcome: 'unavailable', error: failure('FORBIDDEN', 'AI_OFF', 'AI helpers are not available on this site.') };
|
||||
case 'AI_REFUSED':
|
||||
return { outcome: 'refused', error: failure('UNAVAILABLE', 'AI_REFUSED', "The AI couldn't help with this one. Try wording it differently.") };
|
||||
case 'AI_INCOMPLETE':
|
||||
return { outcome: 'incomplete', error: failure('UNAVAILABLE', 'AI_INCOMPLETE', 'The answer was too long and got cut off. Try asking for less.') };
|
||||
case 'AI_INPUT_TOO_LARGE':
|
||||
return { outcome: 'failed', error: failure('UNAVAILABLE', 'AI_INPUT_TOO_LARGE', 'There is too much here to send in one go.') };
|
||||
case 'AI_FAILED':
|
||||
return { outcome: 'failed', error: failure('UNAVAILABLE', 'AI_FAILED', "The AI's answer couldn't be used. Try again.") };
|
||||
default:
|
||||
return { outcome: 'unavailable', error: failure('UNAVAILABLE', 'AI_UNAVAILABLE', 'AI is unavailable right now. Try again in a few minutes.') };
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts of today in South African time, for the per-person daily limit. */
|
||||
function startOfToday(now = new Date()): Date {
|
||||
const sast = new Date(now.getTime() + 2 * 3600 * 1000);
|
||||
return new Date(Date.UTC(sast.getUTCFullYear(), sast.getUTCMonth(), sast.getUTCDate()) - 2 * 3600 * 1000);
|
||||
}
|
||||
|
||||
let prunedOn = '';
|
||||
/** Calls older than about 13 months go (once a day, on the first call). */
|
||||
async function pruneOldCalls(now = new Date()): Promise<void> {
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
if (prunedOn === today) return;
|
||||
prunedOn = today;
|
||||
await prisma.aiCall.deleteMany({ where: { createdAt: { lt: new Date(now.getTime() - KEEP_CALLS_DAYS * 86400000) } } }).catch(() => {});
|
||||
}
|
||||
|
||||
/** At 80% of the allowance, the site's admins get one email a month. */
|
||||
async function warnAt80(status: HubAiStatus): Promise<void> {
|
||||
if (!status.enabled || status.usedPercent < 80 || !status.renewsOn) return;
|
||||
const row = await prisma.appSetting.findUnique({ where: { key: WARNED_KEY } });
|
||||
if (row?.value === status.renewsOn) return;
|
||||
await prisma.appSetting.upsert({ where: { key: WARNED_KEY }, update: { value: status.renewsOn }, create: { key: WARNED_KEY, value: status.renewsOn } });
|
||||
if (!email.isSmtpConfigured()) return;
|
||||
const admins = await prisma.user.findMany({ where: { role: 'admin', isActive: true }, select: { email: true } });
|
||||
const when = renewsText(status.renewsOn);
|
||||
const text = `This site has used ${status.usedPercent}% of this month's AI allowance (R${(status.usedCents / 100).toFixed(2)} of R${(status.capCents / 100).toFixed(2)}). The AI buttons stop working when it runs out, until it renews on ${when}. Site settings → AI shows who used it and for what.`;
|
||||
for (const a of admins) {
|
||||
await email.sendMail({ to: a.email, subject: `AI allowance ${status.usedPercent}% used`, text, html: email.emailWrapper(`<p>${text}</p>`) })
|
||||
.catch(e => console.warn('[ai] 80% warning email failed:', (e as Error).message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one helper call for a person. Resolves to the answer (text, or JSON when a schema was
|
||||
* given) and the new status; throws an ApiError with a `reason` (AI_OFF, AI_HELPER_OFF,
|
||||
* AI_CAP_REACHED, AI_DAILY_LIMIT, AI_REFUSED, AI_INCOMPLETE, AI_UNAVAILABLE…).
|
||||
*/
|
||||
export async function runHelper(helper: HelperName, userId: string, req: HelperRequest): Promise<{ output: unknown; status: AiStatus }> {
|
||||
const status = await aiStatus();
|
||||
if (!status.enabled) throw failure('FORBIDDEN', 'AI_OFF', 'AI helpers are not available on this site.');
|
||||
if (!status.helpers[helper]) throw failure('FORBIDDEN', 'AI_HELPER_OFF', 'This AI helper is switched off in Site settings → AI.');
|
||||
if (status.capReached) throw failure('FORBIDDEN', 'AI_CAP_REACHED', `This month's AI allowance is used up — it renews on ${renewsText(status.renewsOn)}.`);
|
||||
const { dailyLimitPerPerson } = await getAiSettings();
|
||||
if (dailyLimitPerPerson > 0) {
|
||||
const today = await prisma.aiCall.count({ where: { userId, createdAt: { gte: startOfToday() }, outcome: { in: ['ok', 'refused', 'incomplete'] } } });
|
||||
if (today >= dailyLimitPerPerson) throw failure('RATE_LIMITED', 'AI_DAILY_LIMIT', `You've used the AI helpers ${dailyLimitPerPerson} times today, the most this site allows. Try again tomorrow.`);
|
||||
}
|
||||
|
||||
let outcome = 'ok';
|
||||
let chargedCents = 0;
|
||||
try {
|
||||
const reply = await hub.signedPost('/api/installs/ai/complete', { protocolVersion: hub.PROTOCOL_VERSION, helper, ...req }, { timeoutMs: CALL_TIMEOUT_MS });
|
||||
chargedCents = Number(reply.chargedCents) || 0;
|
||||
remember(reply.status);
|
||||
await warnAt80(reply.status).catch(e => console.warn('[ai] 80% check failed:', (e as Error).message));
|
||||
return { output: reply.output, status: { ...(cached?.value || OFF), available: true, helpers: status.helpers } };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) throw e;
|
||||
const mapped = fromHubError(e as Error & { code?: string; data?: any });
|
||||
outcome = mapped.outcome;
|
||||
if ((e as { code?: string }).code === 'HUB_UNREACHABLE') remember(cached?.value || { ...OFF, enabled: true }, false);
|
||||
throw mapped.error;
|
||||
} finally {
|
||||
await prisma.aiCall.create({ data: { userId, helper, outcome, chargedCents } }).catch(err => console.warn('[ai] could not log the call:', (err as Error).message));
|
||||
await pruneOldCalls();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Usage (Site settings → AI) ───────────────────────────────────────────────
|
||||
|
||||
/** This month's use on this site: per person, per helper and per day (South African time). */
|
||||
export async function usageThisMonth(now = new Date()) {
|
||||
const sast = new Date(now.getTime() + 2 * 3600 * 1000);
|
||||
const from = new Date(Date.UTC(sast.getUTCFullYear(), sast.getUTCMonth(), 1) - 2 * 3600 * 1000);
|
||||
const calls = await prisma.aiCall.findMany({ where: { createdAt: { gte: from } }, select: { userId: true, helper: true, outcome: true, chargedCents: true, createdAt: true } });
|
||||
const add = (m: Map<string, { uses: number; chargedCents: number }>, key: string, cents: number) => {
|
||||
const v = m.get(key) || { uses: 0, chargedCents: 0 };
|
||||
v.uses++;
|
||||
v.chargedCents += cents;
|
||||
m.set(key, v);
|
||||
};
|
||||
const byPerson = new Map<string, { uses: number; chargedCents: number }>();
|
||||
const byHelper = new Map<string, { uses: number; chargedCents: number }>();
|
||||
const byDay = new Map<string, { uses: number; chargedCents: number }>();
|
||||
for (const c of calls) {
|
||||
if (c.outcome === 'cap_reached' || c.outcome === 'unavailable') continue;
|
||||
add(byPerson, c.userId || '', c.chargedCents);
|
||||
add(byHelper, c.helper, c.chargedCents);
|
||||
add(byDay, new Date(c.createdAt.getTime() + 2 * 3600 * 1000).toISOString().slice(0, 10), c.chargedCents);
|
||||
}
|
||||
const people = await prisma.user.findMany({ where: { id: { in: [...byPerson.keys()].filter(Boolean) } }, select: { id: true, name: true } });
|
||||
const names = new Map(people.map(p => [p.id, p.name]));
|
||||
return {
|
||||
people: [...byPerson.entries()].map(([id, v]) => ({ userId: id || null, name: id ? names.get(id) || 'Unknown' : 'Removed account', ...v })).sort((a, b) => b.chargedCents - a.chargedCents),
|
||||
helpers: [...byHelper.entries()].map(([helper, v]) => ({ helper, ...v })).sort((a, b) => b.chargedCents - a.chargedCents),
|
||||
days: [...byDay.entries()].map(([day, v]) => ({ day, ...v })).sort((a, b) => a.day.localeCompare(b.day)),
|
||||
};
|
||||
}
|
||||
|
||||
// For tests.
|
||||
export const _internal = { fromHubError, startOfToday, renewsText };
|
||||
@@ -25,10 +25,12 @@ function hubUrl() {
|
||||
}
|
||||
|
||||
class HubError extends Error {
|
||||
constructor(message, code, status) {
|
||||
constructor(message, code, status, data = null) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.statusCode = status;
|
||||
// The Hub's whole error body (AI errors carry the new AI status, protocol §3b).
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +55,7 @@ async function read(keys) {
|
||||
return Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
}
|
||||
|
||||
async function post(path, body, headers = {}) {
|
||||
async function post(path, body, headers = {}, timeoutMs = TIMEOUT_MS) {
|
||||
const raw = JSON.stringify(body);
|
||||
let res;
|
||||
try {
|
||||
@@ -61,18 +63,21 @@ async function post(path, body, headers = {}) {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(typeof headers === 'function' ? headers(raw) : headers) },
|
||||
body: raw,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new HubError(`The Hub (${hubUrl()}) could not be reached: ${e?.message || e}`, 'HUB_UNREACHABLE', 502);
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new HubError(data.message || `The Hub answered ${res.status}`, data.code || 'HUB_ERROR', res.status);
|
||||
if (!res.ok) throw new HubError(data.message || `The Hub answered ${res.status}`, data.code || 'HUB_ERROR', res.status, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** POST signed with the install secret (§3). Throws NOT_CONNECTED when there is no connection. */
|
||||
async function signedPost(path, body) {
|
||||
/**
|
||||
* POST signed with the install secret (§3). Throws NOT_CONNECTED when there is no connection.
|
||||
* AI calls wait longer than the default (Claude can take a while to write).
|
||||
*/
|
||||
async function signedPost(path, body, { timeoutMs = TIMEOUT_MS } = {}) {
|
||||
const s = await read(['hub_install_id', 'hub_install_secret']);
|
||||
if (!s.hub_install_id || !s.hub_install_secret) throw new HubError('This site isn\'t connected to the Hub yet.', 'NOT_CONNECTED', 400);
|
||||
const secret = decrypt(s.hub_install_secret);
|
||||
@@ -83,7 +88,7 @@ async function signedPost(path, body) {
|
||||
'X-Hub-Timestamp': timestamp,
|
||||
'X-Hub-Signature': crypto.createHmac('sha256', secret).update(`${timestamp}.${raw}`).digest('hex'),
|
||||
};
|
||||
});
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
/** Register this install with a licence key. Returns the licence claims. */
|
||||
@@ -211,4 +216,4 @@ function heartbeatSoon(delayMs = 30 * 1000) {
|
||||
soonTimer.unref?.();
|
||||
}
|
||||
|
||||
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate, cancelUpdateRequest, snoozeUpdate };
|
||||
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, signedPost, connect, heartbeat, heartbeatSoon, requestUpdate, cancelUpdateRequest, snoozeUpdate };
|
||||
|
||||
@@ -18,6 +18,7 @@ const ACCESS_ACTIONS = {
|
||||
kids_rooms: 'Kids check-in desk or room opened',
|
||||
kids_offline: 'Kids check-in list downloaded for offline use',
|
||||
volunteer_roster: 'Volunteer roster opened',
|
||||
ai_feedback: 'Feedback comments summarised by the AI helper',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -469,6 +469,8 @@ async function anonymiseUser(userId, db = prisma) {
|
||||
await tx.webhookDelivery.deleteMany({ where: { userId } });
|
||||
// Their saved list filters (can hold search text, such as names).
|
||||
await tx.savedFilter.deleteMany({ where: { userId } });
|
||||
// AI helper calls (2.0): kept for the site's monthly totals, no longer linked to them.
|
||||
await tx.aiCall.updateMany({ where: { userId }, data: { userId: null } });
|
||||
|
||||
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
|
||||
});
|
||||
|
||||
@@ -96,6 +96,7 @@ const DEFAULT_SECTIONS = [
|
||||
'• Tell you when a new event in a series you follow comes out. This counts as news, so it\'s only sent if you get news and announcements.',
|
||||
'• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.',
|
||||
'• Send you an optional feedback form after an event, with one reminder.',
|
||||
'• If we use the AI helpers: our staff can ask an AI service to help draft event details and messages, improve text, and summarise reports and event feedback. A person checks every suggestion before anything is saved or sent. Only what a task needs is sent — never your name, contact details, or medical or children\'s information — and feedback comments are sent without who wrote them (see section 9).',
|
||||
'• Manage user accounts and event attendance records, and give support when you ask for help.',
|
||||
'• Improve our events and website (using anonymous statistics, and only with your permission).',
|
||||
'• Comply with legal obligations.',
|
||||
@@ -166,6 +167,7 @@ const DEFAULT_SECTIONS = [
|
||||
'We do not sell or rent your personal information. Outside our organisation, it is shared only with these service providers ("operators"), each only for its purpose:',
|
||||
'{operators}',
|
||||
'• Anyone we are required to share it with under South African law.',
|
||||
'If we use the AI helpers, requests go through our software provider\'s service to Anthropic (the Claude AI service) in the United States, which is listed above. Our software provider does not keep what was sent. Anthropic handles it under its commercial terms, which do not allow it to be used to train AI; it may be kept for a short time for safety checks. Because this is outside South Africa, it is only done under the protections POPIA requires for such transfers.',
|
||||
'If we connect another system — for example our church-management software — through a webhook, it is listed above with what it receives. It gets only the updates we chose (such as new registrations, payments and check-ins), and never children\'s details from kids check-in.',
|
||||
'Within our organisation, people see only what they need:',
|
||||
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
|
||||
@@ -279,6 +281,14 @@ const BUILT_IN_UPDATES = [
|
||||
],
|
||||
sections: ['summary', 'information', 'use', 'whatsapp', 'retention', 'sharing', 'cookies', 'changes'],
|
||||
},
|
||||
{
|
||||
revision: '2.0.0',
|
||||
summary: [
|
||||
'AI helpers: staff can ask an AI service (Anthropic\'s Claude, through the software provider) to draft events and messages, improve text and summarise reports and feedback; a person checks every suggestion, and no names, contact details, medical or children\'s information are sent.',
|
||||
'Where AI requests go (the United States), that the provider doesn\'t keep them, and that they aren\'t used to train AI.',
|
||||
],
|
||||
sections: ['use', 'sharing'],
|
||||
},
|
||||
];
|
||||
const BUILT_IN_REVISION = BUILT_IN_UPDATES[BUILT_IN_UPDATES.length - 1].revision;
|
||||
const REVIEWED_KEY = 'privacy_notice_builtin_reviewed';
|
||||
|
||||
@@ -14,6 +14,7 @@ export type ErrorCode =
|
||||
| 'CONFLICT'
|
||||
| 'RATE_LIMITED'
|
||||
| 'READ_ONLY'
|
||||
| 'UNAVAILABLE'
|
||||
| 'INTERNAL';
|
||||
|
||||
export const ERROR_STATUS: Record<ErrorCode, number> = {
|
||||
@@ -24,6 +25,8 @@ export const ERROR_STATUS: Record<ErrorCode, number> = {
|
||||
CONFLICT: 409,
|
||||
RATE_LIMITED: 429,
|
||||
READ_ONLY: 403,
|
||||
// Something this depends on (the Hub, the AI service) can't be reached right now.
|
||||
UNAVAILABLE: 503,
|
||||
INTERNAL: 500,
|
||||
};
|
||||
|
||||
@@ -54,7 +57,7 @@ export function zodFields(err: ZodError): Record<string, string> {
|
||||
return fields;
|
||||
}
|
||||
|
||||
const STATUS_CODE: Record<number, ErrorCode> = { 400: 'VALIDATION_FAILED', 401: 'UNAUTHENTICATED', 403: 'FORBIDDEN', 404: 'NOT_FOUND', 409: 'CONFLICT', 423: 'READ_ONLY', 429: 'RATE_LIMITED' };
|
||||
const STATUS_CODE: Record<number, ErrorCode> = { 400: 'VALIDATION_FAILED', 401: 'UNAUTHENTICATED', 403: 'FORBIDDEN', 404: 'NOT_FOUND', 409: 'CONFLICT', 423: 'READ_ONLY', 429: 'RATE_LIMITED', 503: 'UNAVAILABLE' };
|
||||
const PRISMA_CODE: Record<string, ErrorCode> = { P2002: 'CONFLICT', P2025: 'NOT_FOUND', P2003: 'CONFLICT' };
|
||||
|
||||
type LegacyError = Error & { statusCode?: number; status?: number; code?: string };
|
||||
|
||||
@@ -21,6 +21,7 @@ import './resources/peopleMessaging';
|
||||
import './resources/adminPrivacy';
|
||||
import './resources/checkin';
|
||||
import './resources/auth';
|
||||
import './resources/ai';
|
||||
|
||||
const pkg = require('../../package.json') as { version: string };
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { z } from 'zod';
|
||||
import { defineRoute } from '../route';
|
||||
import { single } from '../paging';
|
||||
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';
|
||||
|
||||
/* 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 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>);
|
||||
|
||||
const status = z.object({
|
||||
enabled: z.boolean().describe('false: hide the AI buttons (the licence has no AI, or the site isn\'t connected to the Hub)'),
|
||||
capReached: z.boolean().describe('true: show the buttons disabled until `renewsOn`'),
|
||||
available: z.boolean().describe('false: the Hub couldn\'t be reached just now'),
|
||||
capCents: z.number().describe('The monthly allowance, in cents'),
|
||||
usedCents: z.number().describe('Used this month, in cents (fractions of a cent)'),
|
||||
usedPercent: z.number(),
|
||||
renewsOn: z.string().nullable().describe('YYYY-MM-DD'),
|
||||
helpers: helperFlags.describe('Each helper switched on or off in Site settings → AI'),
|
||||
});
|
||||
|
||||
const settings = z.object({
|
||||
helpers: helperFlags,
|
||||
dailyLimitPerPerson: z.number().int().min(0).max(1000).describe('0 = no limit'),
|
||||
});
|
||||
|
||||
const use = z.object({ uses: z.number().int(), chargedCents: z.number() });
|
||||
|
||||
export const getAiStatus = defineRoute({
|
||||
method: 'get',
|
||||
path: '/ai/status',
|
||||
tag: 'AI',
|
||||
summary: 'Whether to show the AI buttons',
|
||||
description: 'Hidden when `enabled` is false; shown but disabled when `capReached` (until `renewsOn`, or until the allowance is raised). Cached for a minute; every AI call refreshes it.',
|
||||
access: 'supervisor',
|
||||
campusScope: 'campusIndependent (the site\'s AI allowance)',
|
||||
middleware: [campusIndependent('the site\'s AI allowance')],
|
||||
response: single(status),
|
||||
async handler() {
|
||||
return { data: await aiStatus() };
|
||||
},
|
||||
});
|
||||
|
||||
export const getAiUsage = defineRoute({
|
||||
method: 'get',
|
||||
path: '/ai/usage',
|
||||
tag: 'AI',
|
||||
summary: 'AI use this month',
|
||||
description: 'For Site settings → AI: the allowance, this site\'s switches, and this month\'s use per person, per helper and per day.',
|
||||
access: 'admin',
|
||||
response: single(z.object({
|
||||
status,
|
||||
settings,
|
||||
people: z.array(use.extend({ userId: z.string().nullable(), name: z.string() })),
|
||||
helpers: z.array(use.extend({ helper: z.string() })),
|
||||
days: z.array(use.extend({ day: z.string() })),
|
||||
})),
|
||||
async handler() {
|
||||
clearAiStatusCache();
|
||||
const [s, settingsNow, usage] = await Promise.all([aiStatus({ fresh: true }), getAiSettings(), usageThisMonth()]);
|
||||
return { data: { status: s, settings: settingsNow, ...usage } };
|
||||
},
|
||||
});
|
||||
|
||||
export const updateAiSettings = defineRoute({
|
||||
method: 'put',
|
||||
path: '/ai/settings',
|
||||
tag: 'AI',
|
||||
summary: 'Switch helpers on or off, and set a daily limit',
|
||||
access: 'admin',
|
||||
body: z.object({ helpers: helperFlags.partial().optional(), dailyLimitPerPerson: z.number().int().min(0).max(1000).optional() }),
|
||||
response: single(settings),
|
||||
async handler({ body, user, req }) {
|
||||
return { data: await saveAiSettings(body, user!, getClientIp(req)) };
|
||||
},
|
||||
});
|
||||
|
||||
// ─── The helpers ──────────────────────────────────────────────────────────────
|
||||
// Each answer is a suggestion for a person to check: nothing is saved or sent by these routes.
|
||||
|
||||
const withStatus = <T extends z.ZodType>(output: T) => single(z.object({ output, status }));
|
||||
const HELPER_SCOPE = 'campusIndependent (an AI suggestion from what is sent; nothing is read or changed)';
|
||||
const helperAccess = { access: 'supervisor' as const, campusScope: HELPER_SCOPE, middleware: [campusIndependent('an AI suggestion from what is sent; nothing is read or changed')] };
|
||||
const AI_DESCRIPTION = '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.';
|
||||
|
||||
const textList = z.array(z.string());
|
||||
const eventDraft = z.object({
|
||||
title: z.string(), summary: z.string(), description: z.string(),
|
||||
startDate: z.string(), startTime: z.string(), endDate: z.string(), endTime: z.string(),
|
||||
location: z.string(), capacity: z.number(), needsParentalConsent: z.boolean(), ageGroup: z.string(),
|
||||
ticketOptions: z.array(z.object({ name: z.string(), priceRand: z.number(), description: z.string(), earlyBirdPriceRand: z.number(), earlyBirdUntil: z.string() })),
|
||||
sessions: z.array(z.object({ date: z.string(), startTime: z.string(), endTime: z.string(), label: z.string() })),
|
||||
costs: z.array(z.object({ item: z.string(), basis: z.string(), amountRand: z.number() })),
|
||||
checks: textList,
|
||||
});
|
||||
|
||||
export const aiDraftEvent = defineRoute({
|
||||
method: 'post', path: '/ai/draft-event', tag: 'AI', summary: 'Draft an event from a description',
|
||||
description: `Fills in the event form from the organiser's notes. \`warnings\` are checked in code (a weekday that doesn't match its date, costs that a full event can't cover); \`draft.checks\` are the AI's own. ${AI_DESCRIPTION}`,
|
||||
...helperAccess,
|
||||
body: z.object({ notes: z.string().trim().min(10).max(4000) }),
|
||||
response: withStatus(z.object({ draft: eventDraft, warnings: textList, totalCostRand: z.number(), breakEvenPlaces: z.number().nullable() })),
|
||||
async handler({ body, user }) {
|
||||
const { status: s, ...output } = await draftEvent(user!.id, body.notes);
|
||||
return { data: { output, status: s } };
|
||||
},
|
||||
});
|
||||
|
||||
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}`,
|
||||
...helperAccess,
|
||||
body: z.object({
|
||||
reportKey: z.enum(REPORTS_WITHOUT_PEOPLE),
|
||||
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 }) {
|
||||
const { summary, status: s } = await summariseReport(user!.id, body);
|
||||
return { data: { output: summary, status: s } };
|
||||
},
|
||||
});
|
||||
|
||||
export const aiDraftMessage = defineRoute({
|
||||
method: 'post', path: '/ai/draft-message', tag: 'AI', summary: 'Draft an email or WhatsApp message',
|
||||
description: `Turns a short note into a message, using {{name}} and the event placeholders. With \`eventId\`, the event's title, start and place are given to the AI as facts. ${AI_DESCRIPTION}`,
|
||||
access: 'supervisor', campusScope: 'scopeBody(eventId)', middleware: [scopeBody([['eventId', 'event']])],
|
||||
body: z.object({ channel: z.enum(['email', 'whatsapp']), note: z.string().trim().min(3).max(3000), eventId: id.optional() }),
|
||||
response: withStatus(z.object({ subject: z.string(), body: z.string() })),
|
||||
async handler({ body, user }) {
|
||||
let event = null;
|
||||
if (body.eventId) {
|
||||
const e = await prisma.event.findUnique({ where: { id: body.eventId }, select: { title: true, startDate: true, location: true } });
|
||||
if (!e) throw notFound('The event');
|
||||
event = { title: e.title, start: e.startDate.toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }), location: e.location };
|
||||
}
|
||||
const { message, status: s } = await draftMessage(user!.id, { channel: body.channel, note: body.note, event });
|
||||
return { data: { output: message, status: s } };
|
||||
},
|
||||
});
|
||||
|
||||
export const aiSummariseFeedback = defineRoute({
|
||||
method: 'post', path: '/ai/summarise-feedback', tag: 'AI', summary: 'Summarise an event\'s feedback',
|
||||
description: `Themes, what people liked, what to improve and suggestions, from the event's feedback. Sent without who answered; contact details in comments are kept back. Recorded in the access log. ${AI_DESCRIPTION}`,
|
||||
access: 'supervisor', campusScope: 'scopeBody(eventId)', middleware: [scopeBody([['eventId', 'event']])],
|
||||
body: z.object({ eventId: id }),
|
||||
response: withStatus(z.object({
|
||||
overall: z.string(), liked: z.array(z.object({ theme: z.string(), mentions: z.number() })), improve: z.array(z.object({ theme: z.string(), mentions: z.number() })),
|
||||
quotes: textList, nextTime: textList, responses: z.number(),
|
||||
})),
|
||||
async handler({ body, user, req }) {
|
||||
const form = await prisma.feedbackForm.findUnique({
|
||||
where: { eventId: body.eventId },
|
||||
include: { event: { select: { title: true } }, fields: { orderBy: { order: 'asc' } }, responses: { include: { answers: true } }, _count: { select: { invites: true } } },
|
||||
});
|
||||
if (!form) throw notFound('Feedback for this event');
|
||||
if (!form.responses.length) throw Object.assign(notFound('Feedback answers for this event'), { reason: 'AI_NOTHING_TO_SUMMARISE' });
|
||||
// Never who answered: only the answers.
|
||||
const answers = form.responses.flatMap(r => r.answers.map(a => ({ fieldId: a.fieldId, value: a.value })));
|
||||
const questions: FeedbackQuestion[] = feedbackUtils.summarise(form.fields, answers).map((q: any) => ({
|
||||
label: q.label, type: q.type, answered: q.answered, average: q.average, counts: q.counts, texts: q.texts?.map((t: { value: string }) => t.value),
|
||||
}));
|
||||
await logDataAccess(req, { action: 'ai_feedback', eventId: body.eventId, records: form.responses.length });
|
||||
const { summary, status: s } = await summariseFeedback(user!.id, { event: form.event, responses: form.responses.length, invited: form._count.invites, questions });
|
||||
return { data: { output: { ...summary, responses: form.responses.length }, status: s } };
|
||||
},
|
||||
});
|
||||
|
||||
export const aiImproveText = defineRoute({
|
||||
method: 'post', path: '/ai/improve-text', tag: 'AI', summary: 'Improve a piece of text',
|
||||
description: `Fixes spelling and grammar and makes it clearer (\`improve\`), shorter (\`shorter\`) or only fixes mistakes (\`fix\`), keeping every fact. ${AI_DESCRIPTION}`,
|
||||
...helperAccess,
|
||||
body: z.object({ text: z.string().trim().min(3).max(8000), mode: z.enum(['improve', 'shorter', 'fix']).default('improve'), where: z.enum(['description', 'banner', 'message']).default('description') }),
|
||||
response: withStatus(z.object({ text: z.string(), changes: textList })),
|
||||
async handler({ body, user }) {
|
||||
const { result, status: s } = await improveText(user!.id, body);
|
||||
return { data: { output: result, status: s } };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
// AI helpers (2.0): what the buttons do, and how a helper call goes through the Hub (§3b).
|
||||
|
||||
const mockCalls: Array<Record<string, unknown>> = [];
|
||||
const mockSettings = new Map<string, string>();
|
||||
const mockDb = {
|
||||
appSetting: {
|
||||
findUnique: jest.fn(async ({ where }: { where: { key: string } }) => (mockSettings.has(where.key) ? { key: where.key, value: mockSettings.get(where.key) } : null)),
|
||||
upsert: jest.fn(async ({ where, update }: { where: { key: string }; update: { value: string } }) => { mockSettings.set(where.key, update.value); }),
|
||||
},
|
||||
aiCall: {
|
||||
count: jest.fn(async () => mockCalls.filter(c => c.outcome === 'ok').length),
|
||||
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => { mockCalls.push(data); return data; }),
|
||||
deleteMany: jest.fn(async () => ({ count: 0 })),
|
||||
},
|
||||
user: { findMany: jest.fn(async () => [{ email: 'admin@test.local' }]) },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
|
||||
const mockLicence = { connected: true, readOnly: false, state: 'active', claims: { limits: { aiMonthlyCapCents: 5000 } } as { limits: { aiMonthlyCapCents: number } } | null };
|
||||
jest.mock('../src/hub/licence', () => ({ licenceStatus: jest.fn(async () => mockLicence) }));
|
||||
|
||||
class MockHubError extends Error {
|
||||
constructor(message: string, public code: string, public statusCode: number, public data: unknown = null) { super(message); }
|
||||
}
|
||||
const mockPost = jest.fn();
|
||||
jest.mock('../src/hub/client', () => ({ PROTOCOL_VERSION: 1, HubError: MockHubError, signedPost: (...a: unknown[]) => mockPost(...a) }));
|
||||
const mockMail = jest.fn(async (_m: unknown) => undefined);
|
||||
jest.mock('../src/utils/email', () => ({ sendMail: (m: unknown) => mockMail(m), emailWrapper: (s: string) => s, isSmtpConfigured: () => true }));
|
||||
jest.mock('../src/utils/adminAudit', () => ({ logAdminAction: jest.fn(async () => undefined) }));
|
||||
|
||||
import { aiStatus, runHelper, saveAiSettings, clearAiStatusCache, _internal } from '../src/ai';
|
||||
|
||||
const hubStatus = (over = {}) => ({ enabled: true, capReached: false, capCents: 5000, usedCents: 100, usedPercent: 2, renewsOn: '2026-12-01', ...over });
|
||||
const req = { system: 'Write a friendly message.', input: 'Bring a sleeping bag.', maxTokens: 500 };
|
||||
|
||||
beforeEach(() => {
|
||||
mockCalls.length = 0;
|
||||
mockSettings.clear();
|
||||
mockPost.mockReset();
|
||||
mockMail.mockClear();
|
||||
mockLicence.connected = true;
|
||||
mockLicence.readOnly = false;
|
||||
mockLicence.claims = { limits: { aiMonthlyCapCents: 5000 } };
|
||||
clearAiStatusCache();
|
||||
});
|
||||
|
||||
describe('status', () => {
|
||||
test('a licence without AI never asks the Hub', async () => {
|
||||
mockLicence.claims = { limits: { aiMonthlyCapCents: 0 } };
|
||||
expect(await aiStatus()).toMatchObject({ enabled: false, capReached: false });
|
||||
mockLicence.connected = false;
|
||||
expect((await aiStatus()).enabled).toBe(false);
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('asks the Hub and caches its answer for a minute', async () => {
|
||||
mockPost.mockResolvedValue(hubStatus());
|
||||
expect(await aiStatus()).toMatchObject({ enabled: true, available: true, usedCents: 100, helpers: { draft_event: true } });
|
||||
await aiStatus();
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
expect(mockPost).toHaveBeenCalledWith('/api/installs/ai/status', { protocolVersion: 1 });
|
||||
});
|
||||
|
||||
test('a Hub without AI (404) means off; an unreachable Hub keeps the buttons, marked unavailable', async () => {
|
||||
mockPost.mockRejectedValueOnce(new MockHubError('Not found', 'NOT_FOUND', 404));
|
||||
expect((await aiStatus()).enabled).toBe(false);
|
||||
clearAiStatusCache();
|
||||
mockPost.mockRejectedValueOnce(new MockHubError('down', 'HUB_UNREACHABLE', 502));
|
||||
expect(await aiStatus()).toMatchObject({ enabled: true, available: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('running a helper', () => {
|
||||
test('sends the request to the Hub, logs the call, returns the answer', async () => {
|
||||
mockPost.mockResolvedValueOnce(hubStatus()).mockResolvedValueOnce({ output: 'Hi all!', chargedCents: 0.31, status: hubStatus({ usedCents: 100.31 }) });
|
||||
const r = await runHelper('draft_message', 'u1', req);
|
||||
expect(r.output).toBe('Hi all!');
|
||||
expect(r.status.usedCents).toBe(100.31);
|
||||
expect(mockPost).toHaveBeenLastCalledWith('/api/installs/ai/complete', { protocolVersion: 1, helper: 'draft_message', ...req }, { timeoutMs: 90000 });
|
||||
expect(mockCalls).toEqual([{ userId: 'u1', helper: 'draft_message', outcome: 'ok', chargedCents: 0.31 }]);
|
||||
});
|
||||
|
||||
test('a helper switched off, or the cap reached, never reaches the Hub', async () => {
|
||||
mockPost.mockResolvedValue(hubStatus({ capReached: true }));
|
||||
await expect(runHelper('draft_message', 'u1', req)).rejects.toMatchObject({ code: 'FORBIDDEN', reason: 'AI_CAP_REACHED' });
|
||||
clearAiStatusCache();
|
||||
mockPost.mockResolvedValue(hubStatus());
|
||||
await saveAiSettings({ helpers: { draft_message: false } }, { id: 'a1', role: 'admin' });
|
||||
await expect(runHelper('draft_message', 'u1', req)).rejects.toMatchObject({ reason: 'AI_HELPER_OFF' });
|
||||
expect(mockPost).not.toHaveBeenCalledWith('/api/installs/ai/complete', expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
test('the per-person daily limit', async () => {
|
||||
await saveAiSettings({ dailyLimitPerPerson: 1 }, { id: 'a1', role: 'admin' });
|
||||
mockPost.mockResolvedValueOnce(hubStatus()).mockResolvedValueOnce({ output: 'x', chargedCents: 0.1, status: hubStatus() });
|
||||
await runHelper('draft_message', 'u1', req);
|
||||
await expect(runHelper('draft_message', 'u1', req)).rejects.toMatchObject({ code: 'RATE_LIMITED', reason: 'AI_DAILY_LIMIT' });
|
||||
});
|
||||
|
||||
test('the Hub refusing at the cap updates the status, so the buttons disable at once', async () => {
|
||||
mockPost.mockResolvedValueOnce(hubStatus())
|
||||
.mockRejectedValueOnce(new MockHubError('used up', 'AI_CAP_REACHED', 402, { code: 'AI_CAP_REACHED', status: hubStatus({ capReached: true }) }));
|
||||
await expect(runHelper('draft_message', 'u1', req)).rejects.toMatchObject({ reason: 'AI_CAP_REACHED', message: expect.stringContaining('1 December') });
|
||||
expect((await aiStatus()).capReached).toBe(true);
|
||||
expect(mockCalls[0]).toMatchObject({ outcome: 'cap_reached', chargedCents: 0 });
|
||||
});
|
||||
|
||||
test('admins are emailed once a month at 80%', async () => {
|
||||
const at80 = hubStatus({ usedPercent: 81, usedCents: 4050 });
|
||||
mockPost.mockResolvedValueOnce(hubStatus()).mockResolvedValue({ output: 'x', chargedCents: 1, status: at80 });
|
||||
await runHelper('draft_message', 'u1', req);
|
||||
await runHelper('draft_message', 'u1', req);
|
||||
expect(mockMail).toHaveBeenCalledTimes(1);
|
||||
expect(mockMail.mock.calls[0]).toEqual([expect.objectContaining({ to: 'admin@test.local', subject: 'AI allowance 81% used' })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hub errors', () => {
|
||||
test.each([
|
||||
['AI_REFUSED', 'UNAVAILABLE', 'refused'],
|
||||
['AI_INCOMPLETE', 'UNAVAILABLE', 'incomplete'],
|
||||
['AI_OFF', 'FORBIDDEN', 'unavailable'],
|
||||
['HUB_UNREACHABLE', 'UNAVAILABLE', 'unavailable'],
|
||||
])('%s → %s, logged as %s', (hubCode, code, outcome) => {
|
||||
const r = _internal.fromHubError({ code: hubCode, message: 'x' });
|
||||
expect(r.error.code).toBe(code);
|
||||
expect(r.outcome).toBe(outcome);
|
||||
});
|
||||
|
||||
test('today starts at midnight South African time', () => {
|
||||
expect(_internal.startOfToday(new Date('2026-11-10T22:30:00Z')).toISOString()).toBe('2026-11-10T22:00:00.000Z');
|
||||
expect(_internal.startOfToday(new Date('2026-11-10T10:00:00Z')).toISOString()).toBe('2026-11-09T22:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// The five AI helpers (2.0): what they send, what they keep back, and the checks done in code.
|
||||
|
||||
const mockRun = jest.fn();
|
||||
jest.mock('../src/ai/index', () => ({ runHelper: (...a: unknown[]) => mockRun(...a) }));
|
||||
jest.mock('../src/config/db', () => ({}));
|
||||
|
||||
import {
|
||||
todayLine, protect, restore, weekdayClashes, eventWarnings, reportText, feedbackText,
|
||||
draftEvent, summariseReport, draftMessage, improveText, type EventDraftAnswer,
|
||||
} from '../src/ai/helpers';
|
||||
|
||||
const status = { enabled: true, capReached: false, available: true, capCents: 5000, usedCents: 1, usedPercent: 0, renewsOn: '2026-12-01', helpers: {} };
|
||||
const now = new Date('2026-10-30T08:00:00Z');
|
||||
|
||||
beforeEach(() => mockRun.mockReset());
|
||||
|
||||
const camp: EventDraftAnswer = {
|
||||
title: 'Rooted Youth Camp', summary: '', description: '', startDate: '2027-03-14', startTime: '18:00', endDate: '2027-03-16', endTime: '14:00',
|
||||
location: 'Camp Wartburg', capacity: 60, needsParentalConsent: true, ageGroup: 'Grades 8–12',
|
||||
ticketOptions: [{ name: 'Camper', priceRand: 450, description: '', earlyBirdPriceRand: 400, earlyBirdUntil: '2027-01-31' }],
|
||||
sessions: [],
|
||||
costs: [
|
||||
{ item: 'Venue', basis: 'R180 × 60 × 2', amountRand: 21600 }, { item: 'Bus', basis: '', amountRand: 6500 },
|
||||
{ item: 'Speaker gift', basis: '', amountRand: 1000 }, { item: 'T-shirts', basis: 'R95 × 60', amountRand: 5700 },
|
||||
],
|
||||
checks: [],
|
||||
};
|
||||
|
||||
describe('today and contact details', () => {
|
||||
test('today is given in South African time', () => {
|
||||
expect(todayLine(now)).toBe('Today is Friday 30 October 2026.');
|
||||
expect(todayLine(new Date('2026-10-30T22:30:00Z'))).toBe('Today is Saturday 31 October 2026.');
|
||||
});
|
||||
|
||||
test('emails, phone numbers and ID numbers never leave the site, and come back in the answer', () => {
|
||||
const p = protect('Call Sarah on 082 555 1234 or +27 21 555 9876, email youth@church.co.za. ID 9001015009087. Bus R6 500 at 07:30.');
|
||||
// Emails are found first, then phone numbers, then ID numbers.
|
||||
expect(p.text).toBe('Call Sarah on [[CONTACT2]] or [[CONTACT3]], email [[CONTACT1]]. ID [[CONTACT4]]. Bus R6 500 at 07:30.');
|
||||
expect(restore({ body: 'Questions? [[CONTACT1]] or [[CONTACT2]].', list: ['[[CONTACT3]]'] }, p.found))
|
||||
.toEqual({ body: 'Questions? youth@church.co.za or 082 555 1234.', list: ['+27 21 555 9876'] });
|
||||
});
|
||||
|
||||
test('the same detail twice gets one placeholder', () => {
|
||||
expect(protect('a@b.co.za and again a@b.co.za').text).toBe('[[CONTACT1]] and again [[CONTACT1]]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checks done in code', () => {
|
||||
test('a weekday that does not match its date', () => {
|
||||
expect(weekdayClashes('Youth camp Fri 14 March to Sun 16 March', 2027)).toEqual([
|
||||
'The notes say Friday 14 March, but 14 March 2027 is a Sunday. Check the dates.',
|
||||
'The notes say Sunday 16 March, but 16 March 2027 is a Tuesday. Check the dates.',
|
||||
]);
|
||||
expect(weekdayClashes('Fri 14 March', 2025)).toEqual([]);
|
||||
});
|
||||
|
||||
test('an event that loses money even when full', () => {
|
||||
expect(eventWarnings('', camp)).toEqual([
|
||||
'The costs in the notes come to R34 800, which needs 78 places at R450 — more than the 60 available. Even full, the event takes R27 000 (R24 000 if everyone books early), R7 800 short.',
|
||||
]);
|
||||
expect(eventWarnings('', { ...camp, capacity: 80 })).toEqual([]);
|
||||
expect(eventWarnings('', { ...camp, endDate: '2027-03-13' })).toContain('The end is before the start.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('what the helpers send', () => {
|
||||
test('draft an event: today\'s date, a schema, contact details kept back; code warnings added', async () => {
|
||||
mockRun.mockResolvedValue({ output: { ...camp, description: 'Questions? [[CONTACT1]]' }, status });
|
||||
const r = await draftEvent('u1', 'Youth camp Fri 14 March, R450, 60 places. Questions: youth@church.co.za', now);
|
||||
const [helper, user, req] = mockRun.mock.calls[0];
|
||||
expect([helper, user]).toEqual(['draft_event', 'u1']);
|
||||
expect(req.system).toContain('Today is Friday 30 October 2026.');
|
||||
expect(req.input).not.toContain('youth@church.co.za');
|
||||
expect(req.schema.required).toContain('ticketOptions');
|
||||
expect(r.draft.description).toBe('Questions? youth@church.co.za');
|
||||
expect(r.totalCostRand).toBe(34800);
|
||||
expect(r.breakEvenPlaces).toBe(78);
|
||||
expect(r.warnings.some(w => w.includes('is a Sunday'))).toBe(true);
|
||||
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' });
|
||||
expect(mockRun).not.toHaveBeenCalled();
|
||||
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');
|
||||
});
|
||||
|
||||
test('report text is capped at 400 rows', () => {
|
||||
const t = reportText({ reportKey: 'usage', title: 'T', columns: ['A'], rows: Array.from({ length: 450 }, (_, i) => [i]) });
|
||||
expect(t).toContain('(50 more rows not shown)');
|
||||
expect(t.split('\n')).toHaveLength(2 + 400 + 1);
|
||||
});
|
||||
|
||||
test('feedback text: averages, counts and numbered comments, no names', () => {
|
||||
expect(feedbackText({ title: 'Women\'s Conference' }, 112, 186, [
|
||||
{ label: 'Overall', type: 'rating', answered: 112, average: 4.4 },
|
||||
{ label: 'Come again?', type: 'yes_no', answered: 100, counts: [{ label: 'Yes', value: 90 }, { label: 'No', value: 10 }] },
|
||||
{ label: 'Comments', type: 'text', answered: 2, texts: ['Hall B sound please fix it.', 'Loved the worship!'] },
|
||||
])).toBe([
|
||||
'Event: Women\'s Conference', 'Responses: 112 of 186 asked', 'Overall — average 4.4 (112 answers)', 'Come again? — Yes: 90, No: 10',
|
||||
'Comments — comments:', '1. Hall B sound please fix it.', '2. Loved the worship!',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('draft a message: WhatsApp has no subject; the event is given as facts', async () => {
|
||||
mockRun.mockResolvedValue({ output: { subject: 'ignored', body: 'Hi {{name}}!' }, status });
|
||||
const r = await draftMessage('u1', { channel: 'whatsapp', note: 'bring a sleeping bag', event: { title: 'Youth Camp', start: 'Friday 14 March 2027, 18:00', location: 'Camp Wartburg' } }, now);
|
||||
expect(r.message).toEqual({ subject: '', body: 'Hi {{name}}!' });
|
||||
expect(mockRun.mock.calls[0][2].input).toBe('Note: bring a sleeping bag\n\nEvent: Youth Camp, Friday 14 March 2027, 18:00, at Camp Wartburg');
|
||||
expect(mockRun.mock.calls[0][2].system).toContain('WhatsApp');
|
||||
});
|
||||
|
||||
test('improve text: the mode and where it goes shape the instructions', async () => {
|
||||
mockRun.mockResolvedValue({ output: { text: 'Fixed.', changes: [] }, status });
|
||||
await improveText('u1', { text: 'join us for are anual fun day', mode: 'fix', where: 'banner' });
|
||||
const req = mockRun.mock.calls[0][2];
|
||||
expect(req.system).toContain('a short banner across the top of the website');
|
||||
expect(req.system).toContain('Only fix spelling, grammar and punctuation');
|
||||
});
|
||||
});
|
||||
+58
-32
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-30, after **1.51.17** (and Hub 1.5.0). The plan itself is in
|
||||
Last updated: 2026-10-30, after **1.51.17** and Hub **1.6.0**; 2.0.0 in progress on `release/2.0`. The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
@@ -14,9 +14,6 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.51.3 | **Express 5** (qs query parser and `req.body = {}` kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on `/api/v2` (`legacyErrorsToV2`, `reason`); v1 `Deprecation` + `successor-version` headers (`src/v2/deprecation.ts`) |
|
||||
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
|
||||
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
|
||||
| 1.51.17 | **Security:** forgot-password and sign-in don't reveal whether an account exists (same answer, status and timing); forgot-password rate-limited |
|
||||
| 1.51.16 | **Incoming webhooks and message links on v2** — `src/v2/links.ts` (`/api/v2/webhooks/…`, one-click unsubscribe, 308s for old links); provider URLs and email links point at v2 |
|
||||
| 1.51.15 | **Privacy and admin on v2** — `resources/adminPrivacy.ts`; every frontend call on v2; impersonation end/log allowed on v2 while read-only |
|
||||
@@ -29,6 +26,11 @@ encrypted settings unreadable or break existing installs.
|
||||
| 1.51.8 | **Rest of event set-up on v2**: event in full, calendar, alias, ticket options and variants, attachments, sections, costs, image uploads — `src/services/eventDetails.ts`, `src/services/setup.ts`; frontend `fetchEvent()` and `lib/eventSetup.ts`; v2 `file` and `upload` routes |
|
||||
| 1.51.7 | **Setting events up on v2** (`/events/manage`, create, `PATCH`, deactivate, copy-from, notify-recipients) via `src/services/events.ts`; `fetchManagedEvents()` for every picker; **pg-boss 11** in schema `pgboss_v11` |
|
||||
| 1.51.6 | Command palette lists every report (from `ReportCatalog`) and every dashboard page (e2e drift check); exceljs → ExcelTS 10 (`@cj-tech-master/excelts`, no dependencies; e2e `e2e/excel.spec.ts`); backend `engines.node` >= 22 |
|
||||
| 1.51.5 | **Next.js 16** and React 19.3 (Turbopack), **ESLint 10** with Next 16's flat configs (`npm run lint` = `eslint src`); React Compiler-era rules as warnings for now; empty `middleware.ts` removed |
|
||||
| 1.51.4 | **Security and dependency updates**: Next.js 15.5.28, @sentry/nextjs, zod, multer, nodemailer and others for published advisories; minimum versions raised in package.json; cron-parser 5 |
|
||||
| 1.51.3 | **Express 5** (qs query parser and `req.body = {}` kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on `/api/v2` (`legacyErrorsToV2`, `reason`); v1 `Deprecation` + `successor-version` headers (`src/v2/deprecation.ts`) |
|
||||
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
|
||||
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
|
||||
| 1.51.0 | **TypeScript backend + `/api/v2` foundations** (§14): tsconfig (`allowJs`), build to `dist/`, launcher `src/index.js` → `src/app.js`; v2 route declarations with zod → validation, response stripping and OpenAPI 3.1 at `/docs/v2`; first endpoints events, event, me, my registrations. Tests `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts` |
|
||||
| 1.50.2 | Searchable **event picker** (`components/shared/EventPicker.tsx`) on staff/supervisor pages and the kiosk; searchable reports events list; **How webhooks work** guide on Site settings → Webhooks (examples from `EVENT_TYPES[].example`). e2e `e2e/event-picker.spec.ts` |
|
||||
| 1.50.1 | Long lists: audit-log search/role filter/all actions and inclusive To date, data-request search and paging, webhook-delivery status filter and Load older, supervisor Payments loads only what it needs (`unpaid`, `userId`, `from`, `donations` filters). `components/shared/Pager.tsx`; e2e `e2e/long-lists.spec.ts` |
|
||||
@@ -44,9 +46,9 @@ encrypted settings unreadable or break existing installs.
|
||||
| 1.47.2 | **Series: year on year** report (§6 part 3): `GET /api/series/:id/compare` (`compareSeries` in `utils/series.js`), report `seriesComparison` in `ReportsV2`, "Compare" on the Event series page |
|
||||
| 1.47.1 | **Event series** (§6 part 2): Event series page, Series box on events, public `/series/<slug>`, "Next in this series", follow (News & announcements email from the `series` job when a new event goes live). Migration `20261028140000_event_series` |
|
||||
| 1.47.0 | **Copy an event** (§6 part 1): "Copy" opens the event editor filled in from the source (campus, sessions, options, tiers, sections, form, recipients, feedback, certificates); "New start" moves every date; allocation groups and (admins) costs copied by `POST /api/events/:id/copy-from`. No registrations, coupons, attachments, kids check-in or volunteer roles |
|
||||
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
|
||||
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
|
||||
| 1.46.2 | Licence page: what's new (from WHATS-NEW.md via the Hub), withdraw update request, "Not now", urgent updates, provider notices banner (Hub 1.4.0: releases from git tags, Notices page) |
|
||||
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
|
||||
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
|
||||
| 1.45.1 | Privacy notice (built-in text) and terms of use brought up to date with kids check-in, sessions, certificates, campuses, the Hub heartbeat and where data is stored. Installs with a published notice must publish a new version themselves |
|
||||
| 1.45.0 | **Kids check-in** (§4), fully digital: families/children/authorised adults kept between events (My account → Kids), staff desk with pickup codes and a "write on the sticker" panel, rooms with age ranges and ratios, room screen (on duty, call parent, alerts), check-out by code (wrong code alerts, override with reason), offline desk with reserved codes, attendance through a free kids ticket; 6-month retention rule for children |
|
||||
| 1.44.0 | **Campus-scoped staff** (§12 part 2): Admin → Users "Limit to campuses"; `utils/campusScope.js` on every staff/supervisor route (and owner-or-staff routes), lists filtered, scanner names the other campus, church-wide events view/scan/door-only for scoped staff; route test `tests/campusScopeRoutes.test.js` |
|
||||
@@ -60,10 +62,6 @@ encrypted settings unreadable or break existing installs.
|
||||
| 1.40.0 | POPIA toolkit part 3 — readiness checklist with score, retention policies + nightly `retention` job (inactive accounts anonymised after 5 years with a 30-day warning, on by default), breach register with notification drafts, children's/medical flag in the access log, `{retention}` in the notice |
|
||||
| 1.39.0 | POPIA toolkit part 2 — Admin → Privacy: privacy notice editor (built-in text as the start, placeholders, drafts, numbered versions, re-acceptance after login), operator register, consent log, access log; Information Officer on the contact page |
|
||||
| 1.38.0 | POPIA toolkit part 1 — Privacy page (news opt-in + history, download my data, correction and deletion requests), Event update vs News & announcements on every bulk send, unsubscribe links + one-click headers, WhatsApp STOP, one-time news question, Admin → Data requests, thorough anonymising |
|
||||
| 1.32.0 | Early-bird ribbon on event cards |
|
||||
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
|
||||
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, `scripts/deploy.sh` |
|
||||
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
|
||||
| 1.37.4 | Deploy script: no tag = latest main; per-install `ecosystem.config.js` out of git (`ecosystem.config.example.js`) |
|
||||
| 1.37.3 | Fix: the production build failed on unescaped quotes in a help text |
|
||||
| 1.37.2 | Payment status checks (PayFast transaction history; job every 15 min; Payments → Reconcile → Waiting for payment), PayFast Test connection |
|
||||
@@ -71,24 +69,49 @@ encrypted settings unreadable or break existing installs.
|
||||
| 1.37.0 | PayFast (signed form, ITN checks), `ProviderCheckout` with amount checks, the "Pay online" page (`/pay/<id>`) where payers choose a provider |
|
||||
| 1.36.0 | Payment provider interface (`backend/src/payments`) with Yoco behind it, `ProviderTransaction`, `Payment.provider`, Site settings → Payments (on/off and order per provider), R15 minimum |
|
||||
| 1.35.1 | Waitlist per session, session picker at the door, "All sessions" tickets let people tick sessions at one price; fixes (per-session stock limits, offline service worker serving stale scripts) |
|
||||
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
|
||||
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow (removed in 1.46.1), `scripts/deploy.sh` |
|
||||
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
|
||||
| 1.32.0 | Early-bird ribbon on event cards |
|
||||
|
||||
## Next up
|
||||
|
||||
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
|
||||
a test server (both done 2026-10-28).
|
||||
|
||||
1. **2.0.0** — AI helpers, and v1 removed. Every endpoint is on v2 since 1.51.16. Keep
|
||||
answering the v1 webhook paths and the old links in sent messages (`src/v2/links.ts`) after
|
||||
v1 goes: providers and old emails still use them.
|
||||
2. **Servers run Node 24** (Joshua, 2026-10-29), so the backend may need Node 22+. Still to do:
|
||||
move from ExcelTS — deprecated in favour of `documonster` by the same author (pre-1.0 in Oct
|
||||
2026) — once documonster is stable. After 1.51.7 has run everywhere, the old `pgboss` schema
|
||||
can be dropped (`DROP SCHEMA pgboss CASCADE;`).
|
||||
Languages (Afrikaans, §10) are removed from scope (2026-10-28).
|
||||
2. AI rules decided 2026-10-29 (see §15 in the roadmap): the AI buttons show only when the
|
||||
licence's monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub
|
||||
charges the real cost plus a markup % (a default and a per-licence override) and counts the cap
|
||||
in charged rand. AI endpoints are `/api/v2` only.
|
||||
1. **2.0.0 — in progress on `release/2.0`** (WIP commit per part; tag 2.0.0 only when all of it
|
||||
is in). The parts and their state are in the roadmap's Status table:
|
||||
- ✅ **Hub 1.6.0** — the AI proxy (tagged, pushed). Real Claude calls tested 2026-10-30 through
|
||||
a local Hub with Joshua's key: fine, and a fraction of a cent each.
|
||||
- ✅ **AI plumbing** on Cross Events (`backend/src/ai/`, `/api/v2/ai/*`, Site settings → AI,
|
||||
`<AiButton>`, `useAiStatus()`; roadmap §15 "Progress").
|
||||
- **Next: the five helpers** (`backend/src/ai/helpers.ts`, their routes and buttons, an eval set,
|
||||
privacy notice text).
|
||||
- Then **v1 removed** — keep answering the v1 webhook paths and the old links in sent messages
|
||||
(`src/v2/links.ts`): providers and old emails still use them. Then the **migration guide**.
|
||||
- **Marketing site** (roadmap §17): a simple static HTML site — Cross Code as the main site,
|
||||
with a Cross Events page; room for a church app and a full church website later. Repo,
|
||||
domain and hosting to decide first.
|
||||
- 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
|
||||
monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub charges the
|
||||
real cost plus a markup % (a default and a per-licence override) and counts the cap in charged
|
||||
rand. AI endpoints are `/api/v2` only. The model is **Claude Sonnet 5.5** for every helper
|
||||
(2026-10-30, after a Haiku/Sonnet trial), set on the Hub (Settings → AI, Hub 1.6.1); its list
|
||||
prices live in `events-hub/backend/src/utils/aiModels.js`.
|
||||
3. **Clean-ups before or with 2.0:**
|
||||
- Turn the React Compiler lint rules back to errors (see [Next.js 16](#nextjs-16-1515)) — every
|
||||
screen is on v2 now, but they're still `warn` in `frontend/eslint.config.mjs`.
|
||||
- `node-fetch` is still listed in `backend/package.json` though nothing in `src/` uses it —
|
||||
remove it.
|
||||
- Password hashing upgrade with rehash-on-login (roadmap §14) isn't done: still `bcryptjs`.
|
||||
Decide whether it's in 2.0.
|
||||
4. **Later:** move from ExcelTS — deprecated in favour of `documonster` by the same author
|
||||
(pre-1.0 in Oct 2026) — once documonster is stable. After 1.51.7 has run everywhere, the old
|
||||
`pgboss` schema can be dropped (`DROP SCHEMA pgboss CASCADE;`).
|
||||
|
||||
Languages (Afrikaans, §10) and Wallet passes (§7) are removed from scope (2026-10-28).
|
||||
|
||||
## Working on this repo
|
||||
|
||||
@@ -176,8 +199,8 @@ a test server (both done 2026-10-28).
|
||||
up in its transaction history (`findPayments`); Yoco's Checkout API documents no lookup, so
|
||||
Yoco checkouts are "can't check". Tests: `backend/tests/paymentStatus.test.js`.
|
||||
- **Adding a provider:** write `providers/<id>.js` with the same interface, add it to
|
||||
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/v2/webhooks/<id>` (in `src/v2/links.ts`; add the v1 path in `routes/webhookRoutes.js` only for providers already registered there)
|
||||
(`routes/webhookRoutes.js`), and give it a settings card in
|
||||
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/v2/webhooks/<id>` (in `src/v2/links.ts`; add the v1 path in `routes/webhookRoutes.js` only for providers already registered there),
|
||||
and give it a settings card in
|
||||
`frontend/src/components/admin/PaymentsTab.tsx`. New providers start switched off.
|
||||
- `Payment.method` stays the wallet type (card, apple_pay…) because cashups bucket on it;
|
||||
the provider is `Payment.provider`.
|
||||
@@ -194,7 +217,8 @@ a test server (both done 2026-10-28).
|
||||
- Bulk sends: `broadcastController`, `whatsappBroadcastController`, and `emailEventAttendees` /
|
||||
`whatsappEventAttendees` in `eventController` take `kind`; scheduled payloads carry it.
|
||||
- WhatsApp STOP: `handleIncomingMessage` in `whatsappController.js` (WAWP `message` event on
|
||||
`/api/v2/webhooks/whatsapp?secret=…` — older set-ups use `/api/whatsapp/webhook`, which still works; secret in AppSetting `WAWP_WEBHOOK_SECRET`).
|
||||
`/api/v2/webhooks/whatsapp?secret=…` — older set-ups use `/api/whatsapp/webhook` or `/api/webhooks/whatsapp`, which run the same handler
|
||||
since 1.51.16; secret in AppSetting `WAWP_WEBHOOK_SECRET`).
|
||||
- Frontend: `app/dashboard/user/privacy`, `app/unsubscribe`, `app/dashboard/admin/data-requests`,
|
||||
`components/shared/NewsPrompt.tsx` (dashboard layout), `MessageKindPicker.tsx`.
|
||||
- **Anything new that stores personal data** must be added to `anonymiseUser` and
|
||||
@@ -467,7 +491,8 @@ a test server (both done 2026-10-28).
|
||||
- A list that grows for ever must page on the server (`page`/`limit`, `total`, `pages`) or load
|
||||
older items with a `before` cursor, and offer a search. `components/shared/Pager.tsx` renders
|
||||
"21–40 of 312 · Prev · Next". Already paged: users, registrations (Manage registrations), events,
|
||||
payments, audit log, consent/access logs, data requests, My payments.
|
||||
payments, audit log, consent/access logs, data requests, My payments. (Paths below are the v1
|
||||
names; each has a v2 successor in `SUCCESSORS`, `src/v2/deprecation.ts`.)
|
||||
- **Audit-log actions:** a new `AdminAuditAction` needs a label in `ACTION_LABELS`
|
||||
(`app/dashboard/admin/audit-log/page.tsx`) — the filter is built from that list.
|
||||
- **Still loading everything (fine for now, watch as installs grow):** user pickers that call
|
||||
@@ -548,8 +573,9 @@ a test server (both done 2026-10-28).
|
||||
- Turbopack for dev and build (the default). Lint with `npm run lint` / `npx eslint src` (`next lint`
|
||||
is gone). There's no middleware/proxy file — add `src/proxy.ts` (Next 16's name) only if needed.
|
||||
- **React Compiler lint rules are warnings, not errors** (`eslint.config.mjs`): 181 existing places,
|
||||
mostly `useEffect(() => { load(); }, [...])`. Fix them in a screen when it's moved onto v2
|
||||
(1.51.6 – 1.51.11), then turn the rules back to errors. Don't add new ones.
|
||||
mostly `useEffect(() => { load(); }, [...])`. The plan was to fix them as each screen moved onto
|
||||
v2; every screen is on v2 now (1.51.15) but the rules are still `warn` — fix what's left and turn
|
||||
them back to errors before or with 2.0. Don't add new ones.
|
||||
- `eslint-config-next` 16's bundled plugins (react, import, jsx-a11y) don't list ESLint 10 as a peer
|
||||
yet; they work, and npm notes the override. Revisit when they publish ESLint 10 support.
|
||||
|
||||
@@ -594,13 +620,14 @@ a test server (both done 2026-10-28).
|
||||
## Event picker and webhook guide (1.50.2)
|
||||
|
||||
- **Choosing an event:** use `EventPicker` (`components/shared/EventPicker.tsx`), not a `<select>`.
|
||||
Give it the whole list (`/api/events/all?includePast=true`); it opens on upcoming events, keeps
|
||||
Give it the whole list (`fetchManagedEvents(token, { past: true })`, v2 `GET /events/manage`
|
||||
since 1.51.7); it opens on upcoming events, keeps
|
||||
past ones behind "Show past events", and searches all of them. `emptyLabel` adds a clearable
|
||||
choice ("All events"), `graceHours` keeps just-ended events current (the scanner uses 12),
|
||||
`describe` adds detail per row, and `defaultEventId(events)` picks the next upcoming one. In e2e,
|
||||
`pickEvent(page, title)` from `e2e/fixtures.ts`.
|
||||
- `/api/events/all` still returns full events (options, form, sessions) — fine for a few hundred;
|
||||
a slim `?fields=` mode would help if a church gets into the thousands.
|
||||
- The managed events list still returns full events (options, form, sessions) — fine for a few
|
||||
hundred; a slim `?fields=` mode would help if a church gets into the thousands.
|
||||
- **Webhook examples:** a new webhook event type needs an entry in `EXAMPLES` in
|
||||
`utils/webhooks.js`; `tests/webhooks.test.js` fails if an example and `buildPayload` differ in
|
||||
shape.
|
||||
@@ -611,7 +638,6 @@ a test server (both done 2026-10-28).
|
||||
into WAWP (events `message` + `session.status`), and only for senders WhatsApp identifies by
|
||||
phone number — a sender known only by a "LID" is logged and ignored (they can still use the
|
||||
link in the message).
|
||||
- `/api/webhooks/whatsapp` runs the real WAWP handler since 1.51.16 (it was an empty stub).
|
||||
- There's no log of individual messages sent, so "Download my data" can't include "messages
|
||||
sent to you" yet.
|
||||
|
||||
|
||||
+107
-56
@@ -11,41 +11,46 @@ sold — trustworthy (POPIA), flexible (multi-day events, several payment
|
||||
providers), maintainable (tests, CI, typed backend, versioned API), and with
|
||||
**AI helpers** as the headline feature (§15).
|
||||
|
||||
**Status (2026-10-27):** all features in this document are approved. Shipped
|
||||
so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events
|
||||
phase A (sessions, late joining, per-session check-in, attendance register,
|
||||
reminders, certificates); 1.34.0 job queue (pg-boss), Playwright E2E tests, Gitea CI
|
||||
workflow (waiting for a runner) and deploy script; 1.35.0 multi-day events phase B
|
||||
(pick sessions, per-session capacity), with 1.35.1 adding the per-session waitlist, the session
|
||||
picker at the door and session choice on "All sessions" tickets; 1.36.0 payment provider interface
|
||||
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page;
|
||||
1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
|
||||
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23);
|
||||
1.38.0 POPIA toolkit part 1 (privacy page, unsubscribe, WhatsApp STOP, data requests); 1.39.0 part 2 (privacy notice editor and versions, operator register, consent and access logs); 1.40.0 part 3 (retention policies and job, breach register, children’s-data flags, readiness page).
|
||||
1.41.0 the Hub connection (licence, heartbeat, read-only mode, `/health`, install script) with Hub 1.0.0 (`joshua/events-hub`);
|
||||
1.41.1–1.41.2 licence and job-time fixes; 1.41.3 renamed **Hope Events → Cross Events** (Hub 1.0.1:
|
||||
Cross Events Hub); 1.42.0 **Request update** button instead of the deploy command, with Hub 1.1.0
|
||||
(update requests); 1.42.1 the production Hub key built in. Later versions moved up by one
|
||||
(2026-10-27). Multi-campus is split in two — 1.43.0 campuses, 1.44.0 campus-scoped staff — so
|
||||
everything after moves up by one more (2026-10-27). 1.43.0 **campuses** shipped (settings, events
|
||||
per campus, public filter and campus pages, home campus, filters, campus broadcasts and summaries);
|
||||
1.44.0 **campus-scoped staff** shipped (Admin → Users "Limit to campuses", every staff route checked).
|
||||
Wallet passes are removed from scope (2026-10-28), so everything after volunteer rosters moves
|
||||
up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticker panel, rooms with
|
||||
ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or session, sign-ups and
|
||||
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
|
||||
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) 1.47.2 **year-on-year
|
||||
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. 1.49.0 **dashboard (Today, Waiting for you) and Ctrl+K palette**
|
||||
shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is done. Languages
|
||||
(Afrikaans) are removed from scope (2026-10-28), so everything after moves up by one. 1.50.0 **accessibility
|
||||
pass** shipped. 1.50.1 **pagination and search on long lists** and 1.50.2 **event picker and webhook guide** shipped. 1.51.0 **TypeScript backend and `/api/v2`
|
||||
foundations**, 1.51.1 **v2 registrations and payments**, 1.51.2 **v2 accounts and check-in** and 1.51.3 **Express 5,
|
||||
scanner on v2, v1 deprecation headers** shipped. 1.51.4 **security and dependency updates**, 1.51.5 **Next.js 16** and 1.51.6 **the complete command palette and
|
||||
ExcelTS** , 1.51.7 **setting events up on v2, pg-boss 11** and 1.51.8 **the rest of event set-up on v2** and 1.51.9
|
||||
**series, passes, deals and campuses on v2** 1.51.10 **managing registrations on v2**, 1.51.11 **money on v2** and 1.51.12 **tickets and
|
||||
attendance on v2** 1.51.13 **kids and volunteers on v2**, 1.51.14 **people and messaging on v2** and 1.51.15
|
||||
**privacy and admin on v2** and 1.51.16 **incoming webhooks and links on v2** shipped — every
|
||||
endpoint is on v2. **Next: 2.0.0.**
|
||||
**Status (2026-10-30):** all features in this document are approved, and everything before 2.0.0
|
||||
has shipped (1.32.0 – 1.51.17). **2.0.0 is in progress** on the `release/2.0` branch, tagged only
|
||||
when all of it is in (decided 2026-10-30):
|
||||
|
||||
| # | Part | Where | State |
|
||||
|---|------|-------|-------|
|
||||
| 1 | AI proxy: key, metering in rand, caps, markup, usage views; model list with prices (Sonnet 5.5 default) | Hub 1.6.0 – 1.6.1 (`events-hub`) | ✅ tagged; real Claude calls tested 2026-10-30 |
|
||||
| 2 | AI plumbing: `/api/v2/ai/status`, usage, Site settings → AI, `<AiButton>`, `useAiStatus()`, `AiCall` log, Anthropic operator entry | `release/2.0` | ✅ committed (WIP) |
|
||||
| 3 | The five helpers (§15) with their buttons, an eval set, privacy notice text | `release/2.0` | next |
|
||||
| 4 | v1 removed (webhook paths and old message links keep working) | `release/2.0` | to do |
|
||||
| 5 | Migration guide, docs | `release/2.0` | to do |
|
||||
| 6 | Marketing site: Cross Code with a Cross Events page (§17) | its own repo | to do |
|
||||
|
||||
The full version list is in [HANDOVER.md](./HANDOVER.md) and the [build order](#suggested-build-order) below.
|
||||
|
||||
- **Multi-day events** (§1): 1.33.0 phase A (sessions, late joining, per-session check-in,
|
||||
attendance register, reminders, certificates); 1.35.0 phase B (pick sessions, per-session
|
||||
capacity); 1.35.1 per-session waitlist, session picker at the door.
|
||||
- **Platform:** 1.34.0 job queue (pg-boss), Playwright E2E tests and deploy script (the Gitea CI
|
||||
workflow was removed in 1.46.1 — no runner).
|
||||
- **Payments** (§2): 1.36.0 provider interface (Yoco behind it); 1.37.0 – 1.37.2 PayFast, "Pay
|
||||
online", payment status checks. SnapScan and Zapper dropped (2026-10-23).
|
||||
- **POPIA toolkit** (§3): 1.38.0 – 1.40.0.
|
||||
- **Hub** (§13): 1.41.0 connection (licence, heartbeat, read-only mode, `/health`, install
|
||||
script) with Hub 1.0.0; 1.41.3 renamed **Hope Events → Cross Events**; 1.42.0 **Request
|
||||
update** with Hub 1.1.0; 1.42.1 production Hub key built in.
|
||||
- **Multi-campus** (§12): 1.43.0 campuses; 1.44.0 campus-scoped staff.
|
||||
- **Kids check-in** (§4) 1.45.0; **volunteer rosters** (§5) 1.46.0.
|
||||
- **Event series and cloning** (§6): 1.47.0 copy an event, 1.47.1 series, 1.47.2 year-on-year
|
||||
report, 1.47.3 season passes.
|
||||
- **Outbound webhooks** (§8) 1.48.0.
|
||||
- **Dashboard, palette, bulk actions, saved filters** (§9): 1.49.0 – 1.49.2.
|
||||
- **Accessibility** (§11) 1.50.0; 1.50.1 long lists; 1.50.2 event picker and webhook guide.
|
||||
- **TypeScript backend and `/api/v2`** (§14): 1.51.0 – 1.51.3 foundations, registrations and
|
||||
payments, accounts and check-in, Express 5; 1.51.4 security and dependency updates; 1.51.5
|
||||
Next.js 16; 1.51.6 command palette and ExcelTS; 1.51.7 – 1.51.16 every remaining area on v2 —
|
||||
**every endpoint is on v2**.
|
||||
- 1.51.17 security fix: forgot password and sign-in don't reveal whether an account exists.
|
||||
|
||||
Removed from scope (2026-10-28): Wallet passes (§7) and languages (§10).
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -74,8 +79,9 @@ endpoint is on v2. **Next: 2.0.0.**
|
||||
14. [Foundations (platform work)](#14-foundations-platform-work)
|
||||
15. [AI helpers (2.0 headline)](#15-ai-helpers-20-headline)
|
||||
16. [AI: later stages](#16-ai-later-stages)
|
||||
17. [Suggested build order](#suggested-build-order)
|
||||
18. [Decisions](#decisions-2026-10-22)
|
||||
17. [Marketing site (Cross Code)](#17-marketing-site-cross-code)
|
||||
18. [Suggested build order](#suggested-build-order)
|
||||
19. [Decisions](#decisions-2026-10-22)
|
||||
|
||||
---
|
||||
|
||||
@@ -833,13 +839,13 @@ keep adding to.
|
||||
|
||||
| Item | What |
|
||||
|------|------|
|
||||
| CI (Gitea Actions) | Lint, typecheck, Jest, build on every push; deploy on tag |
|
||||
| Playwright E2E | Register → pay (sandbox) → ticket → scan → cashup; multi-day check-in; kiosk |
|
||||
| Job queue | Move `setInterval` jobs out of `index.js` into `pg-boss` (uses Postgres): retries, no double runs, job history in admin |
|
||||
| Split big controllers | `registrationController` (2.3k lines), `eventController`, `paymentController` → services by domain. Done **as each feature touches them**, not as a big-bang rewrite |
|
||||
| TypeScript backend | Incremental (`allowJs`), new code in TS first; share zod schemas/types with the frontend |
|
||||
| API v2 | `/api/v2` with consistent errors, pagination and an OpenAPI spec (replaces the hand-written API docs). `/api` (v1) kept during the transition |
|
||||
| Dependencies | Express 5, native `fetch` instead of `node-fetch`, password hashing upgrade with rehash-on-login |
|
||||
| ~~CI (Gitea Actions)~~ | Added in 1.34.0, **removed in 1.46.1** (no machine to run a runner): lint, tests and deploy are done by hand |
|
||||
| Playwright E2E ✅ | Register → pay (sandbox) → ticket → scan → cashup; multi-day check-in; kiosk (1.34.0) |
|
||||
| Job queue ✅ | Move `setInterval` jobs out of `index.js` into `pg-boss` (uses Postgres): retries, no double runs, job history in admin (1.34.0; pg-boss 11 in 1.51.7) |
|
||||
| Split big controllers | `registrationController` (2.3k lines), `eventController`, `paymentController` → services by domain. Done **as each feature touches them**, not as a big-bang rewrite. Partly done (1.51.1 – 1.51.10); the handlers behind `v1Route` are a 2.x clean-up |
|
||||
| TypeScript backend ✅ | Incremental (`allowJs`), new code in TS first; share zod schemas/types with the frontend (1.51.0) |
|
||||
| API v2 ✅ | `/api/v2` with consistent errors, pagination and an OpenAPI spec (replaces the hand-written API docs). `/api` (v1) kept during the transition; every endpoint on v2 since 1.51.16 |
|
||||
| Dependencies | Express 5 ✅ (1.51.3); native `fetch` ✅ (nothing uses `node-fetch` any more — it's still in `backend/package.json`, to remove); password hashing upgrade with rehash-on-login — **not done** (still `bcryptjs`), decide whether it's in 2.0 |
|
||||
|
||||
---
|
||||
|
||||
@@ -866,13 +872,18 @@ never saves, sends or publishes by itself.
|
||||
|
||||
### Provider and model
|
||||
|
||||
- **One provider: Claude (Anthropic), model Claude Haiku 5.5** — the cheapest
|
||||
current model ($0.10 per million input tokens, $0.50 per million output, for
|
||||
prompts under 100K tokens; prices as of 2026-10). Good enough for drafting and
|
||||
summarising; a helper can move to a bigger model later if quality needs it.
|
||||
- **Rough cost:** a helper call is ~2,000–10,000 tokens in and ~500 out, so well under
|
||||
US$0.002 per click — about R1.50–R3 per thousand clicks. Fixed instructions are
|
||||
prompt-cached, which makes repeat calls cheaper still.
|
||||
- **One provider: Claude (Anthropic), model Claude Sonnet 5.5** for every helper (decided
|
||||
2026-10-30) — $2 per million input tokens, $10 per million output (October 2026 list prices).
|
||||
First planned as Haiku 5.5 ($0.10 / $0.50) for cost; a trial of all five helpers on both
|
||||
(2026-10-30) found Haiku wrong on a report figure, over-counting feedback themes and misspelling
|
||||
words in messages, while Sonnet was right on all of them. One model for all helpers, no
|
||||
per-helper choice. The model is a setting on the Hub (Settings → AI: a list with prices filled
|
||||
in, Hub 1.6.1), so changing it later needs no Cross Events release.
|
||||
- **Measured cost** (trial, charged at R17/$ and 5% markup): R0.02 – R0.31 per use with Sonnet —
|
||||
a WhatsApp message about R0.02, a report summary R0.17 – R0.22, a full event draft with costs
|
||||
R0.31. A typical church month (10 event drafts, 20 messages, 20 text improvements, 10 reports, 4
|
||||
feedback summaries) comes to about R8.33, so a R50 allowance covers it about 6 times. Fixed
|
||||
instructions are prompt-cached, which makes repeat calls cheaper still.
|
||||
- Structured output (a JSON schema) for "Draft an event", so the reply maps straight
|
||||
onto the event form and is validated before it's shown.
|
||||
|
||||
@@ -945,6 +956,23 @@ an install (one leaked install would leak it for everyone), so:
|
||||
looks like) to check quality when changing the prompt or model.
|
||||
- Help content, audit log entries (AI settings changed), CHANGELOG, FEATURES.md, SETTINGS.md.
|
||||
|
||||
### Progress (2026-10-30)
|
||||
|
||||
- **Done — Hub 1.6.0:** `POST /api/installs/ai/status` and `/api/installs/ai/complete` (protocol §3b);
|
||||
the worst case of each call held against the cap in a locked transaction; Settings → AI (key,
|
||||
model, prices, exchange rate, default markup, test button, use per site); markup override per
|
||||
licence; AI use (real cost, charged, profit) on the install page. Tested with real calls: a plain
|
||||
message (118 tokens in, 70 out, ~1.3 s) and a JSON-schema event draft (483 in, 95 out, ~3 s), each
|
||||
charged a fraction of a cent (R0.0008 – R0.0017 at R17/$ and 5% markup).
|
||||
- **Done — Cross Events, `release/2.0`:** `backend/src/ai/` (status from the licence token, then
|
||||
the Hub, cached a minute; per-helper switches; per-person daily limit; `AiCall` log; 80% email),
|
||||
`/api/v2/ai/status`, `/ai/usage`, `/ai/settings`, the `UNAVAILABLE` (503) error code,
|
||||
`frontend/src/lib/ai.ts` (`runAi`, which posts to `/api/v2/ai/<helper-with-dashes>`),
|
||||
`useAiStatus()`, `<AiButton>` / `<AiNotice>`, Site settings → AI.
|
||||
- **Next:** the helpers in `backend/src/ai/helpers.ts` (prompt, schema, `maxTokens`, and what each
|
||||
sends — no personal information), their `/api/v2/ai/<helper>` routes, the buttons on the five
|
||||
screens, the eval set, and the privacy notice text (a `BUILT_IN_UPDATES` entry).
|
||||
|
||||
---
|
||||
|
||||
## 16. AI: later stages
|
||||
@@ -998,13 +1026,32 @@ income justify the hardware:
|
||||
|
||||
---
|
||||
|
||||
## 17. Marketing site (Cross Code)
|
||||
|
||||
Part of 2.0 (decided 2026-10-30): a **simple static HTML site** for selling, not part of the app.
|
||||
|
||||
- **Cross Code** (Joshua's company) is the main site: who we are, what we make, contact.
|
||||
- **Cross Events** has its own 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 how to get in touch for a licence.
|
||||
- **Cross Events is the only product on sale for now.** The site is laid out so more products can
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## Suggested build order
|
||||
|
||||
| Phase | Version | Features | Why |
|
||||
|-------|---------|----------|-----|
|
||||
| 0 | 1.32.0 ✅ | **Early-bird ribbon** on event cards (shipped 2026-10-22) | Quick win |
|
||||
| 1 | 1.33.0 ✅ | **Multi-day events — phase A** (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work |
|
||||
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
|
||||
| 2 | 1.34.0 ✅ | CI (removed in 1.46.1) + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
|
||||
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
|
||||
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
|
||||
| 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
@@ -1014,8 +1061,8 @@ income justify the hardware:
|
||||
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
|
||||
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (Afrikaans removed from scope) | Polish before the big release |
|
||||
| 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` | 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, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
|
||||
| 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). Built on `release/2.0` in parts; Hub 1.6.0 (AI proxy) ✅ and the AI plumbing ✅ are in | The release. AI needs the Hub (phase 6) for the key, metering and caps |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
|
||||
@@ -1050,10 +1097,12 @@ feature.
|
||||
| — | Income in the heartbeat | Included, as **ranges** only (R0–R1 000, R1 001–R5 000, …), never exact amounts (2026-10-23) |
|
||||
| — | Licence plans | One plan for now (2026-10-23) |
|
||||
| — | AI in 2.0 | The **headline feature of 2.0**: one-button helpers only (draft an event, summarise reports and feedback, draft messages, improve text) — no chat yet (2026-10-24) |
|
||||
| — | AI provider and key | One provider, **Claude** (Haiku 5.5 to start, for cost). Joshua provides the key; installs call AI **through the Hub**, which holds the key and meters usage (2026-10-24) |
|
||||
| — | AI provider and key | One provider, **Claude**. Joshua provides the key; installs call AI **through the Hub**, which holds the key and meters usage (2026-10-24). Model: **Sonnet 5.5 for every helper** (2026-10-30, after the Haiku/Sonnet trial; was Haiku 5.5 for cost), chosen on the Hub |
|
||||
| — | AI cost limits | A **monthly rand cap per install**, set in the Hub per licence to match what the church pays; admins see their usage in Site settings → AI (2026-10-24) |
|
||||
| — | 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) |
|
||||
| — | 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) |
|
||||
| — | POPIA: existing users | Kept opted in to news (logged as "existing account"), asked once at their next login to confirm or opt out. New sign-ups choose with an unticked box (2026-10-23) |
|
||||
@@ -1075,4 +1124,6 @@ feature.
|
||||
|
||||
## Still open
|
||||
|
||||
Nothing for now.
|
||||
- **Marketing site (§17):** repository, domain(s), hosting, whether prices are shown, and the
|
||||
contact route.
|
||||
- **Password hashing upgrade** (§14): in 2.0 or later.
|
||||
|
||||
+60
-6
@@ -178,6 +178,65 @@ X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
|
||||
history. Rate-limited with update requests. A Hub older than 1.4.0 answers `404 NOT_FOUND`.
|
||||
- **"Not now"** (no reminders until the next version) stays on the install; the Hub isn't told.
|
||||
|
||||
## 3b. AI (Cross Events 2.0, Hub 1.6.0)
|
||||
|
||||
Installs never hold the Claude API key. Each AI helper call goes through the Hub, which adds the
|
||||
key, checks the licence's monthly cap, calls Claude and meters the call. Both requests are signed
|
||||
exactly like the heartbeat. The install keeps its prompts; the Hub stores **no prompt or response
|
||||
text** — only the install, helper name, tokens, cost and time.
|
||||
|
||||
### Status
|
||||
|
||||
```
|
||||
POST /api/installs/ai/status
|
||||
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
|
||||
{ "protocolVersion": 1 }
|
||||
→ 200 { "enabled": true, "capReached": false, "capCents": 5000, "usedCents": 812.4,
|
||||
"usedPercent": 16, "renewsOn": "2026-12-01" }
|
||||
```
|
||||
|
||||
- **`enabled: false`** — hide the AI buttons everywhere: the licence's cap is R0, the licence isn't
|
||||
active (suspended, revoked, or past its grace days), or the Hub's AI isn't set up.
|
||||
- **`capReached: true`** — show the buttons **disabled** ("This month's AI allowance is used up — it
|
||||
renews on 1 December"). Reached means there isn't room left for even a small call.
|
||||
- Amounts are cents (rand) as **charged** to the church, with fractions (a call costs a fraction of a
|
||||
cent). The month is South African time; `renewsOn` is the 1st of the next month.
|
||||
|
||||
### A helper call
|
||||
|
||||
```
|
||||
POST /api/installs/ai/complete
|
||||
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"helper": "draft_event", // lower-case name, for usage reports
|
||||
"system": "…", // the helper's fixed instructions (cached by Claude)
|
||||
"input": "…", // this request's text
|
||||
"schema": { … }, // optional JSON schema: the reply is JSON in that shape
|
||||
"maxTokens": 2000, // 1–8000
|
||||
"effort": "low" // optional: low (default) | medium | high
|
||||
}
|
||||
→ 200 { "output": "…" | { … }, // text, or the parsed JSON when a schema was given
|
||||
"usage": { "inputTokens": 1840, "outputTokens": 420 },
|
||||
"chargedCents": 0.31,
|
||||
"status": { …as above… } }
|
||||
```
|
||||
|
||||
- **Errors** are `{ code, message, status }` — `status` is the new AI status, so buttons can disable
|
||||
straight after the call that used up the allowance:
|
||||
- `402 AI_CAP_REACHED` — this call could pass the cap (checked before calling Claude).
|
||||
- `403 AI_OFF` (cap R0) · `403 LICENCE_INACTIVE` · `503 AI_NOT_CONFIGURED` (Hub not set up).
|
||||
- `422 AI_REFUSED` — Claude declined. `502 AI_INCOMPLETE` — the answer hit `maxTokens`.
|
||||
`502 AI_FAILED` — not usable (e.g. JSON in the wrong shape). These still cost what was used.
|
||||
- `503 AI_BUSY` / `503 AI_UNAVAILABLE` — Claude unreachable or rate-limited; nothing is charged.
|
||||
- `400 BAD_REQUEST`, `413 AI_INPUT_TOO_LARGE` (about 90 000 tokens), and the §3 errors.
|
||||
- **Metering:** real cost = tokens × the model's price × the exchange rate set in the Hub; charged =
|
||||
real × (1 + markup). The cap counts charged rand. Before calling Claude the Hub holds the worst
|
||||
case (an over-estimate of the prompt, plus `maxTokens`) against the cap in one transaction per
|
||||
install, so calls at the same moment can't pass it; afterwards the hold becomes the real amount.
|
||||
- Rate-limited to 60 a minute per IP (against runaway loops; money is limited by the cap).
|
||||
- A Hub older than 1.6.0 answers `404 NOT_FOUND`: the install treats that as "AI off".
|
||||
|
||||
## 4. Licence token
|
||||
|
||||
`hel1.<payload>.<signature>` — `payload` is base64url JSON, `signature` is the base64url
|
||||
@@ -195,7 +254,7 @@ Ed25519 signature of the ASCII string `hel1.<payload>`.
|
||||
"graceDays": 14,
|
||||
"issuedAt": "2026-10-24T08:15:00.000Z",
|
||||
"validUntil": "2026-11-23T08:15:00.000Z", // issuedAt + 30 days
|
||||
"limits": { "aiMonthlyCapCents": 0 }
|
||||
"limits": { "aiMonthlyCapCents": 0 } // the AI cap (§3b); 0 = AI off
|
||||
}
|
||||
```
|
||||
|
||||
@@ -221,8 +280,3 @@ grace starts). Blocking the Hub can't keep a suspended licence active for more t
|
||||
fields to a heartbeat or a token doesn't need a new version — both sides ignore fields they
|
||||
don't know.
|
||||
- The Hub accepts protocol `N` and `N-1`; anything else is `400 UNSUPPORTED_PROTOCOL`.
|
||||
|
||||
## 6. Later (not in v1)
|
||||
|
||||
- **AI proxy** (Cross Events 2.0): `POST /api/ai/complete`, signed like the heartbeat; the Hub holds
|
||||
the Claude API key, meters each call in rand and enforces `limits.aiMonthlyCapCents`.
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ApiError, resolveToApiOrigin } from "@/lib/api";
|
||||
import { apiV2Raw, apiV2Data } from "@/lib/apiV2";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, MapPinned, type LucideIcon, Webhook } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, MapPinned, type LucideIcon, Webhook, Sparkles } from "lucide-react";
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { BackupsTab } from "@/components/admin/BackupsTab";
|
||||
@@ -16,6 +16,7 @@ import { JobsTab } from "@/components/admin/JobsTab";
|
||||
import { PaymentsTab } from "@/components/admin/PaymentsTab";
|
||||
import { LicenceTab } from "@/components/admin/LicenceTab";
|
||||
import { WebhooksTab } from "@/components/admin/WebhooksTab";
|
||||
import { AiTab } from "@/components/admin/AiTab";
|
||||
import { CampusesTab } from "@/components/admin/CampusesTab";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
@@ -23,7 +24,7 @@ import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { uploadImage } from "@/lib/eventSetup";
|
||||
|
||||
type TabId = "organisation" | "campuses" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "webhooks" | "backups" | "jobs" | "licence";
|
||||
type TabId = "organisation" | "campuses" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "webhooks" | "backups" | "jobs" | "licence" | "ai";
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "organisation", label: "Organisation", icon: Building2 },
|
||||
@@ -38,6 +39,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "backups", label: "Backups", icon: DatabaseBackup },
|
||||
{ id: "jobs", label: "Background jobs", icon: Timer },
|
||||
{ id: "licence", label: "Licence", icon: KeyRound },
|
||||
{ id: "ai", label: "AI", icon: Sparkles },
|
||||
];
|
||||
|
||||
const inputCls =
|
||||
@@ -717,6 +719,7 @@ function SiteSettingsPageInner() {
|
||||
{activeTab === "webhooks" && <WebhooksTab active={activeTab === "webhooks"} />}
|
||||
{activeTab === "jobs" && <JobsTab active={activeTab === "jobs"} />}
|
||||
{activeTab === "licence" && <LicenceTab active={activeTab === "licence"} />}
|
||||
{activeTab === "ai" && <AiTab active={activeTab === "ai"} />}
|
||||
{activeTab === "campuses" && <CampusesTab active={activeTab === "campuses"} />}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import { MessageKindPicker, matchedText, type MessageKind } from "@/components/s
|
||||
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
import { AiDraftMessage } from "@/components/shared/AiDraftMessage";
|
||||
import { AiImprove } from "@/components/shared/AiImprove";
|
||||
|
||||
type Attendee = { id: string; name: string; email: string; pref: string };
|
||||
type TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets';
|
||||
@@ -614,9 +616,13 @@ function EmailAttendeesPageInner() {
|
||||
|
||||
{templateKey !== 'tickets' ? (
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<AiDraftMessage channel="email" eventId={eventId || undefined} onUse={d => { setSubject(d.subject); setSubjectDirty(true); setBody(d.body); setBodyDirty(true); }} />
|
||||
</div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Message</label>
|
||||
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={8} value={body} onChange={e => { setBody(e.target.value); setBodyDirty(true); }} placeholder="Write your message to attendees..." />
|
||||
<div className="text-[11px] text-gray-500 mt-1">Hint: Start with <html> or <div> to send HTML; otherwise plain text will be sent.</div>
|
||||
{!/^\s*</.test(body) && <AiImprove text={body} onChange={t => { setBody(t); setBodyDirty(true); }} where="message" />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 border rounded bg-gray-50 text-sm text-gray-700">This will send each selected attendee their ticket(s) as attachments for this event.</div>
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
} from "@/components/events/ScheduleEditor";
|
||||
import { toSessionDraft, scheduleSummary } from "@/lib/sessions";
|
||||
import { fetchApiFile, openBlob } from "@/lib/export";
|
||||
import { AiEventDraft, type AiEventDraftFields } from "@/components/events/AiEventDraft";
|
||||
import { AiImprove } from "@/components/shared/AiImprove";
|
||||
import { apiV2, apiV2Data } from "@/lib/apiV2";
|
||||
import { createTicketOption, updateTicketOption, listSections, createSection, updateSection, deleteSection, listAttachments, uploadAttachment, deleteAttachment, uploadImage } from "@/lib/eventSetup";
|
||||
|
||||
@@ -1127,6 +1129,33 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
|
||||
const upd = (patch: Partial<EventDraft>) => setDraft(d => ({ ...d, ...patch }));
|
||||
|
||||
// ── AI: "Describe your event" fills in the form (a person checks every step before saving) ──
|
||||
const applyAiDraft = (a: AiEventDraftFields) => {
|
||||
const at = (date: string, time: string, fallback: string) => (date ? `${date}T${time || fallback}` : "");
|
||||
const asSessions = a.sessions.length > 1;
|
||||
upd({
|
||||
...(a.title ? { title: a.title } : {}),
|
||||
...(a.description ? { description: a.description } : {}),
|
||||
...(a.location ? { location: a.location } : {}),
|
||||
requiresParentalConsent: a.needsParentalConsent,
|
||||
scheduleType: asSessions ? "sessions" : "single",
|
||||
...(asSessions ? {} : { startDate: at(a.startDate, a.startTime, "09:00"), endDate: at(a.endDate || a.startDate, a.endTime, "17:00") }),
|
||||
...(a.ticketOptions[0] ? { price: String(a.ticketOptions[0].priceRand) } : {}),
|
||||
});
|
||||
if (asSessions) {
|
||||
setSessions(a.sessions.map(s => keyed({ label: s.label, startsAt: at(s.date, s.startTime, "09:00"), endsAt: at(s.date, s.endTime || s.startTime, "17:00"), location: "", cancelled: false, capacity: "" })));
|
||||
}
|
||||
if (a.ticketOptions.length) {
|
||||
setOptions(a.ticketOptions.map((t, i) => ({
|
||||
name: t.name || "Ticket", price: String(t.priceRand), isMainTicket: i === 0,
|
||||
// The event's places go on the main ticket; 0 means no limit.
|
||||
stockLimit: i === 0 && a.capacity > 0 ? String(a.capacity) : "0",
|
||||
earlyBirdTiers: t.earlyBirdPriceRand > 0 && t.earlyBirdUntil ? [{ deadline: `${t.earlyBirdUntil}T23:59`, price: String(t.earlyBirdPriceRand), stockLimit: "0" }] : [],
|
||||
variants: [],
|
||||
})));
|
||||
}
|
||||
};
|
||||
|
||||
// ── copy: move every date by the same amount (sessions, deadline, early birds, feedback) ──
|
||||
const copyStart = multi ? scheduleBounds?.start || "" : draft.startDate;
|
||||
const moveAllDates = (newStart: string) => {
|
||||
@@ -1539,6 +1568,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
{copyStart && new Date(copyStart) < new Date() && <p className="text-xs text-amber-700">This copy still starts in the past.</p>}
|
||||
</div>
|
||||
)}
|
||||
{mode === "create" && !cloning && <AiEventDraft onApply={applyAiDraft} />}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Title <span className="text-red-500">*</span></label>
|
||||
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.title} onChange={e => upd({ title: e.target.value })} placeholder="Event title" />
|
||||
@@ -1546,6 +1576,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Description</label>
|
||||
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={3} value={draft.description} onChange={e => upd({ description: e.target.value })} />
|
||||
<AiImprove text={draft.description} onChange={description => upd({ description })} where="description" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="block text-xs text-gray-600 mb-1">When <span className="text-red-500">*</span></div>
|
||||
|
||||
@@ -14,6 +14,8 @@ import { MessageKindPicker, matchedText, type MessageKind } from "@/components/s
|
||||
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
import { AiDraftMessage } from "@/components/shared/AiDraftMessage";
|
||||
import { AiImprove } from "@/components/shared/AiImprove";
|
||||
|
||||
// Attendee with preference info
|
||||
type Attendee = { id: string; name: string; phone: string; pref: string };
|
||||
@@ -716,10 +718,14 @@ function WhatsAppAttendeesPageInner() {
|
||||
|
||||
{templateKey !== "tickets" ? (
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<AiDraftMessage channel="whatsapp" eventId={eventId || undefined} onUse={d => { setMessage(d.body); messageDirtyRef.current = true; }} />
|
||||
</div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Message</label>
|
||||
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={8} value={message}
|
||||
onChange={(e) => { setMessage(e.target.value); messageDirtyRef.current = true; }}
|
||||
placeholder="Write your WhatsApp message to attendees..." />
|
||||
<AiImprove text={message} onChange={t => { setMessage(t); messageDirtyRef.current = true; }} where="message" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 border rounded bg-gray-50 text-sm text-gray-700">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
import { AiImprove } from "@/components/shared/AiImprove";
|
||||
|
||||
type BannerType = "info" | "warning" | "success" | "danger";
|
||||
|
||||
@@ -124,6 +125,7 @@ export default function SetBannerPage() {
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
/>
|
||||
<AiImprove text={message} onChange={setMessage} where="banner" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiV2, type V2Result } from "@/lib/apiV2";
|
||||
import { loadAiStatus, renewsText } from "@/lib/ai";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Usage = V2Result<"/ai/usage", "get">["data"];
|
||||
type Helper = keyof Usage["settings"]["helpers"];
|
||||
|
||||
const HELPERS: { key: Helper; label: string; where: string }[] = [
|
||||
{ key: "draft_event", label: "Draft an event", where: "New event: describe it and the form is filled in" },
|
||||
{ key: "summarise_report", label: "Summarise this report", where: "Reports: a few plain-language insights" },
|
||||
{ key: "draft_message", label: "Draft a message", where: "Emails and WhatsApp messages to attendees, broadcasts" },
|
||||
{ key: "summarise_feedback", label: "Summarise feedback", where: "Post-event feedback results" },
|
||||
{ key: "improve_text", label: "Improve this text", where: "Event descriptions, the banner, message boxes" },
|
||||
];
|
||||
|
||||
// Fractions of a cent add up: show R0.0123 below a rand.
|
||||
const money = (cents: number) => (cents > 0 && cents < 100 ? `R${(cents / 100).toFixed(4)}` : `R${(cents / 100).toFixed(2)}`);
|
||||
|
||||
/** Site settings → AI: the monthly allowance, who used it for what, and a switch per helper. */
|
||||
export function AiTab({ active }: { active: boolean }) {
|
||||
const { token } = useAuth();
|
||||
const [usage, setUsage] = useState<Usage | null>(null);
|
||||
const [limit, setLimit] = useState("0");
|
||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !token) return;
|
||||
let alive = true;
|
||||
apiV2("get", "/ai/usage", { authToken: token })
|
||||
.then(({ data }) => { if (alive) { setUsage(data); setLimit(String(data.settings.dailyLimitPerPerson)); } })
|
||||
.catch(e => { if (alive) setMsg({ ok: false, text: errorMessage(e) || "Could not load AI use." }); });
|
||||
return () => { alive = false; };
|
||||
}, [active, token]);
|
||||
|
||||
const save = async (helpers: Partial<Record<Helper, boolean>>, dailyLimitPerPerson?: number) => {
|
||||
if (!token) return;
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const { data } = await apiV2("put", "/ai/settings", { authToken: token, body: { helpers, ...(dailyLimitPerPerson != null ? { dailyLimitPerPerson } : {}) } });
|
||||
setUsage(u => (u ? { ...u, settings: data, status: { ...u.status, helpers: data.helpers } } : u));
|
||||
setMsg({ ok: true, text: "Saved." });
|
||||
loadAiStatus(token, { fresh: true }).catch(() => {});
|
||||
} catch (e) { setMsg({ ok: false, text: errorMessage(e) || "Could not save." }); }
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
if (!usage) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-500">Loading…</p>;
|
||||
const s = usage.status;
|
||||
const maxDay = Math.max(0.0001, ...usage.days.map(d => d.chargedCents));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold"><Sparkles className="h-5 w-5 text-violet-600" aria-hidden /> AI helpers</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
One-click helpers for supervisors and admins: drafting events and messages, improving text, summarising reports and feedback. A person always checks the suggestion before anything is saved or sent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!s.enabled ? (
|
||||
<div className="rounded-lg border bg-gray-50 p-4 text-sm text-gray-700">
|
||||
AI helpers aren't part of this site's licence, so their buttons are hidden. To add them, ask your provider for a monthly AI allowance.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium">This month</span>
|
||||
<span className="text-sm text-gray-600">{money(s.usedCents)} of {money(s.capCents)} · renews on {renewsText(s.renewsOn)}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-gray-100" role="progressbar" aria-label="AI allowance used" aria-valuenow={s.usedPercent} aria-valuemin={0} aria-valuemax={100}>
|
||||
<div className={`h-full ${s.usedPercent >= 80 ? "bg-amber-500" : "bg-violet-500"}`} style={{ width: `${s.usedPercent}%` }} />
|
||||
</div>
|
||||
{s.capReached && <p className="mt-2 text-sm text-amber-800">The allowance is used up: the AI buttons are disabled until {renewsText(s.renewsOn)}, or until your provider raises it.</p>}
|
||||
{!s.available && <p className="mt-2 text-sm text-amber-800">The provider's Hub can't be reached right now, so AI is unavailable. Everything else works as usual.</p>}
|
||||
<p className="mt-2 text-xs text-gray-500">Admins get an email when 80% is used.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Helpers</h3>
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{HELPERS.map(h => (
|
||||
<li key={h.key} className="flex items-center justify-between gap-3 p-3">
|
||||
<div>
|
||||
<label htmlFor={`ai-${h.key}`} className="text-sm font-medium">{h.label}</label>
|
||||
<p className="text-xs text-gray-500">{h.where}</p>
|
||||
</div>
|
||||
<input id={`ai-${h.key}`} type="checkbox" className="h-4 w-4" disabled={busy} checked={usage.settings.helpers[h.key]} onChange={e => save({ [h.key]: e.target.checked })} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-wrap items-end gap-3" onSubmit={e => { e.preventDefault(); save({}, Math.max(0, parseInt(limit, 10) || 0)); }}>
|
||||
<div>
|
||||
<label htmlFor="ai-daily-limit" className="block text-sm font-medium">Most uses per person per day</label>
|
||||
<input id="ai-daily-limit" type="number" min={0} max={1000} className="mt-1 w-28 rounded-lg border px-3 py-2 text-sm" value={limit} onChange={e => setLimit(e.target.value)} />
|
||||
<p className="mt-1 text-xs text-gray-500">0 = no limit. Guards against one person using up the month.</p>
|
||||
</div>
|
||||
<button className="rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-60" disabled={busy}>Save limit</button>
|
||||
</form>
|
||||
{msg && <p className={`text-sm ${msg.ok ? "text-green-700" : "text-red-700"}`} role="status">{msg.text}</p>}
|
||||
|
||||
{s.enabled && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">By person this month</h3>
|
||||
{usage.people.length === 0 ? <p className="text-sm text-gray-500">Nobody has used AI yet this month.</p> : (
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-xs text-gray-500"><th className="py-1">Person</th><th>Uses</th><th className="text-right">Cost</th></tr></thead>
|
||||
<tbody>{usage.people.map(p => <tr key={p.userId || "removed"} className="border-t"><td className="py-1">{p.name}</td><td>{p.uses}</td><td className="text-right">{money(p.chargedCents)}</td></tr>)}</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">By helper this month</h3>
|
||||
{usage.helpers.length === 0 ? <p className="text-sm text-gray-500">—</p> : (
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-xs text-gray-500"><th className="py-1">Helper</th><th>Uses</th><th className="text-right">Cost</th></tr></thead>
|
||||
<tbody>{usage.helpers.map(h => <tr key={h.helper} className="border-t"><td className="py-1">{HELPERS.find(x => x.key === h.helper)?.label || h.helper}</td><td>{h.uses}</td><td className="text-right">{money(h.chargedCents)}</td></tr>)}</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
{usage.days.length > 0 && (
|
||||
<div className="md:col-span-2">
|
||||
<h3 className="mb-2 font-medium">Per day</h3>
|
||||
<div className="flex h-20 items-end gap-1" aria-label="AI cost per day this month">
|
||||
{usage.days.map(d => <div key={d.day} title={`${d.day}: ${d.uses} uses, ${money(d.chargedCents)}`} className="flex-1 rounded-t bg-violet-300" style={{ height: `${Math.max(4, (d.chargedCents / maxDay) * 100)}%` }} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
Requests go through your provider's Hub to Anthropic (Claude) in the United States. Only what a helper needs is sent — never names, contact details, medical or children's information — and nothing is kept by the provider. Add Anthropic to your operator register (Admin → Privacy) when you start using AI.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import React, { useId, useState } from "react";
|
||||
import { AlertTriangle, Sparkles } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useAiStatus } from "@/hooks/useAiStatus";
|
||||
import { runAi } from "@/lib/ai";
|
||||
import type { V2Result } from "@/lib/apiV2";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { AiButton, AiNotice } from "@/components/shared/AiButton";
|
||||
|
||||
export type AiEventDraftResult = V2Result<"/ai/draft-event", "post">["data"]["output"];
|
||||
export type AiEventDraftFields = AiEventDraftResult["draft"];
|
||||
|
||||
const money = (n: number) => `R${Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ")}`;
|
||||
|
||||
/**
|
||||
* "Describe your event": a few sentences in, the event form filled in (after a person checks the
|
||||
* suggestion). Hidden on sites without AI. The budget from the notes is shown, not saved — costs
|
||||
* are entered on the event by admins.
|
||||
*/
|
||||
export function AiEventDraft({ onApply }: { onApply: (draft: AiEventDraftFields) => void }) {
|
||||
const { token } = useAuth();
|
||||
const status = useAiStatus();
|
||||
const notesId = useId();
|
||||
const [notes, setNotes] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<AiEventDraftResult | null>(null);
|
||||
if (!status?.enabled) return null;
|
||||
|
||||
const run = async () => {
|
||||
if (!token || notes.trim().length < 10) { setError("Describe the event in a sentence or two first."); return; }
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setResult(await runAi<AiEventDraftResult>("draft_event", { notes }, token));
|
||||
} catch (e) { setError(errorMessage(e) || "The AI couldn't draft this event."); }
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const d = result?.draft;
|
||||
const checks = result ? [...result.warnings, ...d!.checks] : [];
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border border-violet-200 bg-violet-50/60 p-3" data-testid="ai-event-draft">
|
||||
<label htmlFor={notesId} className="flex items-center gap-1.5 text-sm font-medium text-violet-900">
|
||||
<Sparkles className="h-4 w-4" aria-hidden /> Describe your event
|
||||
</label>
|
||||
<textarea
|
||||
id={notesId} rows={3} maxLength={4000}
|
||||
className="w-full rounded border bg-white px-3 py-2 text-sm"
|
||||
placeholder="e.g. Youth camp for grades 8–12, Fri 13 March 18:00 to Sun 15 March 14:00 at Camp Wartburg. 60 places, R450 or R400 if they book by 31 January. Parents must give consent. Costs: venue R180 per person per night, bus R6 500."
|
||||
value={notes} onChange={e => setNotes(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AiButton helper="draft_event" onClick={run} busy={busy} size="sm">{busy ? "Drafting…" : "Draft the event"}</AiButton>
|
||||
<span className="text-xs text-gray-500">Fills in the details, tickets and dates for you to check.</span>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-700" role="alert">{error}</p>}
|
||||
|
||||
{d && (
|
||||
<div className="space-y-3 rounded border border-violet-200 bg-white p-3 text-sm">
|
||||
<AiNotice />
|
||||
<div>
|
||||
<p className="font-medium">{d.title}</p>
|
||||
<p className="text-gray-600">
|
||||
{[d.startDate && `${d.startDate}${d.startTime ? ` ${d.startTime}` : ""}`, d.endDate && `to ${d.endDate}${d.endTime ? ` ${d.endTime}` : ""}`].filter(Boolean).join(" ")}
|
||||
{d.location ? ` · ${d.location}` : ""}{d.capacity ? ` · ${d.capacity} places` : ""}{d.needsParentalConsent ? " · parental consent" : ""}
|
||||
</p>
|
||||
{d.ticketOptions.length > 0 && (
|
||||
<p className="text-gray-600">
|
||||
{d.ticketOptions.map(t => `${t.name} ${money(t.priceRand)}${t.earlyBirdPriceRand > 0 ? ` (${money(t.earlyBirdPriceRand)} until ${t.earlyBirdUntil || "?"})` : ""}`).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
{d.sessions.length > 1 && <p className="text-gray-600">{d.sessions.length} sessions</p>}
|
||||
</div>
|
||||
|
||||
{checks.length > 0 && (
|
||||
<ul className="space-y-1 rounded border border-amber-200 bg-amber-50 p-2 text-amber-900" aria-label="Check before saving">
|
||||
{checks.map(c => <li key={c} className="flex gap-1.5"><AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />{c}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{d.costs.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Budget from your notes (not saved)</p>
|
||||
<table className="mt-1 w-full text-xs tabular-nums">
|
||||
<tbody>
|
||||
{d.costs.map(c => <tr key={c.item} className="border-t"><td className="py-1 pr-2">{c.item}<span className="block text-gray-500">{c.basis}</span></td><td className="py-1 text-right">{money(c.amountRand)}</td></tr>)}
|
||||
<tr className="border-t font-medium"><td className="py-1">Total</td><td className="py-1 text-right">{money(result!.totalCostRand)}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{result!.breakEvenPlaces != null && <p className="mt-1 text-xs text-gray-600">Break-even: {result!.breakEvenPlaces} places at the full price.</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" className="rounded bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700" onClick={() => { onApply(d); setResult(null); }}>
|
||||
Fill in the form
|
||||
</button>
|
||||
<button type="button" className="rounded border px-3 py-1.5 text-sm hover:bg-gray-50" onClick={() => setResult(null)}>Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useAiStatus } from "@/hooks/useAiStatus";
|
||||
import { runAi } from "@/lib/ai";
|
||||
import type { V2Result } from "@/lib/apiV2";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { AiButton, AiNotice } from "@/components/shared/AiButton";
|
||||
|
||||
type Summary = V2Result<"/ai/summarise-feedback", "post">["data"]["output"];
|
||||
|
||||
const Themes = ({ title, list }: { title: string; list: { theme: string; mentions: number }[] }) => (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{title}</p>
|
||||
{list.length === 0 ? <p className="text-gray-500">—</p> : (
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{list.map(t => <li key={t.theme} className="flex justify-between gap-3"><span>{t.theme}</span><span className="shrink-0 tabular-nums text-gray-500">{t.mentions}</span></li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* "Summarise feedback" for one event: how it went, what people liked and want improved (with how
|
||||
* many said so), a few quotes and suggestions. The server sends the answers without who gave them
|
||||
* and records it in the access log. Hidden on sites without AI.
|
||||
*/
|
||||
export function AiFeedbackSummary({ eventId, responses }: { eventId: string; responses: number }) {
|
||||
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 || responses < 1) return null;
|
||||
|
||||
const run = async () => {
|
||||
if (!token) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try { setSummary(await runAi<Summary>("summarise_feedback", { eventId }, token)); } catch (e) { setError(errorMessage(e) || "The AI couldn't summarise this feedback."); }
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-3 space-y-2">
|
||||
{!summary && <AiButton helper="summarise_feedback" size="sm" busy={busy} onClick={run}>{busy ? "Summarising…" : "Summarise feedback"}</AiButton>}
|
||||
{error && <p className="text-sm text-red-700" role="alert">{error}</p>}
|
||||
{summary && (
|
||||
<div className="space-y-3 rounded-lg border border-violet-200 bg-violet-50/60 p-3 text-sm">
|
||||
<AiNotice />
|
||||
<p>{summary.overall}</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Themes title="Liked" list={summary.liked} />
|
||||
<Themes title="To improve" list={summary.improve} />
|
||||
</div>
|
||||
{summary.quotes.length > 0 && (
|
||||
<div className="space-y-1">{summary.quotes.map(q => <blockquote key={q} className="border-l-2 border-violet-300 pl-2 italic text-gray-700">{q}</blockquote>)}</div>
|
||||
)}
|
||||
{summary.nextTime.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Next time</p>
|
||||
<ul className="ml-4 mt-1 list-disc space-y-0.5">{summary.nextTime.map(n => <li key={n}>{n}</li>)}</ul>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="text-xs text-gray-600 underline" onClick={() => setSummary(null)}>Hide the summary</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
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 { 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.
|
||||
*/
|
||||
export function AiReportSummary({ reportKey, getPayload }: { reportKey: string; getPayload: () => ReportPdfPayload | 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;
|
||||
|
||||
const run = async () => {
|
||||
const p = getPayload();
|
||||
if (!token || !p?.table) { 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));
|
||||
} catch (e) { setError(errorMessage(e) || "The AI couldn't summarise this report."); }
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 space-y-2" data-testid="ai-report-summary">
|
||||
{!summary && <AiButton helper="summarise_report" busy={busy} onClick={run}>{busy ? "Summarising…" : "Summarise this report"}</AiButton>}
|
||||
{error && <p className="text-sm text-red-700" role="alert">{error}</p>}
|
||||
{summary && (
|
||||
<div className="space-y-2 rounded-lg border border-violet-200 bg-violet-50/60 p-4 text-sm">
|
||||
<AiNotice />
|
||||
<p className="font-medium text-gray-900">{summary.headline}</p>
|
||||
<ul className="space-y-1.5">
|
||||
{summary.insights.map(i => <li key={i.title}><span className="font-medium">{i.title}.</span> {i.detail}</li>)}
|
||||
</ul>
|
||||
{summary.watchOut.length > 0 && (
|
||||
<ul className="space-y-1 rounded border border-amber-200 bg-amber-50 p-2 text-amber-900" aria-label="Needs attention">
|
||||
{summary.watchOut.map(w => <li key={w} className="flex gap-1.5"><AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />{w}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
<button type="button" className="text-xs text-gray-600 underline" onClick={() => setSummary(null)}>Hide the summary</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard
|
||||
import { REPORTS, type ReportKey } from "./ReportCatalog";
|
||||
import ReportsShell from "./ReportsShell";
|
||||
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
|
||||
import { AiReportSummary } from "./AiReportSummary";
|
||||
import { AiFeedbackSummary } from "./AiFeedbackSummary";
|
||||
import { StatTile, StatTileRow } from "./StatTile";
|
||||
import { HorizontalBarChart } from "./charts/HorizontalBarChart";
|
||||
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||
@@ -1566,6 +1568,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} />
|
||||
{report === "payments" && (
|
||||
<div>
|
||||
{paymentRows.length === 0 ? (
|
||||
@@ -2015,6 +2018,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
{f.sentAt ? `${f.responses} of ${f.invited} responded${f.responseRate != null ? ` (${f.responseRate}%)` : ""}` : "Not sent yet"}{f.anonymous ? " · anonymous" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<AiFeedbackSummary eventId={f.eventId} responses={f.responses || 0} />
|
||||
<div className="space-y-4">
|
||||
{(f.questions || []).map((q) => (
|
||||
<div key={q.fieldId}>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import React, { useId } from "react";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
import { useAiStatus } from "@/hooks/useAiStatus";
|
||||
import { disabledReason, type AiHelper } from "@/lib/ai";
|
||||
|
||||
/**
|
||||
* A button for one AI helper. Not shown at all when the site has no AI (or for anyone but
|
||||
* supervisors and admins); shown but disabled, with the reason, when this month's allowance is
|
||||
* used up, the Hub can't be reached, or the helper is switched off in Site settings → AI.
|
||||
*/
|
||||
export function AiButton({ helper, onClick, busy = false, children, className = "", size = "md" }: {
|
||||
helper: AiHelper;
|
||||
onClick: () => void;
|
||||
busy?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const status = useAiStatus();
|
||||
const reasonId = useId();
|
||||
if (!status?.enabled) return null;
|
||||
const reason = disabledReason(status, helper);
|
||||
const pad = size === "sm" ? "px-2 py-1 text-xs" : "px-3 py-1.5 text-sm";
|
||||
return (
|
||||
<span className="inline-flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={busy || !!reason}
|
||||
title={reason || undefined}
|
||||
aria-describedby={reason ? reasonId : undefined}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg border border-violet-200 bg-violet-50 font-medium text-violet-800 hover:bg-violet-100 disabled:cursor-not-allowed disabled:opacity-60 ${pad} ${className}`}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : <Sparkles className="h-4 w-4" aria-hidden />}
|
||||
{children}
|
||||
</button>
|
||||
{reason && <span id={reasonId} className="sr-only">{reason}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Under every AI result: a person checks it before anything is saved or sent. */
|
||||
export function AiNotice({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<p className={`flex items-center gap-1.5 text-xs text-violet-800 ${className}`}>
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden /> AI suggestion — check it before saving.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import React, { useId, useState } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useAiStatus } from "@/hooks/useAiStatus";
|
||||
import { runAi } from "@/lib/ai";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { AiButton, AiNotice } from "@/components/shared/AiButton";
|
||||
|
||||
type Draft = { subject: string; body: string };
|
||||
|
||||
/**
|
||||
* "Draft a message": a short note in, an email (subject and body) or WhatsApp message out, using
|
||||
* {{name}} and the event placeholders. The draft goes into the message box only with "Use this",
|
||||
* where it can be edited before sending. Hidden on sites without AI.
|
||||
*/
|
||||
export function AiDraftMessage({ channel, eventId, onUse }: { channel: "email" | "whatsapp"; eventId?: string; onUse: (draft: Draft) => void }) {
|
||||
const { token } = useAuth();
|
||||
const status = useAiStatus();
|
||||
const noteId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
if (!status?.enabled) return null;
|
||||
|
||||
if (!open) {
|
||||
return <AiButton helper="draft_message" size="sm" onClick={() => setOpen(true)}>Draft with AI</AiButton>;
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
if (!token) return;
|
||||
if (note.trim().length < 3) { setError("Say in a few words what the message is about."); return; }
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try { setDraft(await runAi<Draft>("draft_message", { channel, note, ...(eventId ? { eventId } : {}) }, token)); } catch (e) { setError(errorMessage(e) || "The AI couldn't draft this message."); }
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border border-violet-200 bg-violet-50/60 p-3" data-testid="ai-draft-message">
|
||||
<label htmlFor={noteId} className="flex items-center gap-1.5 text-sm font-medium text-violet-900"><Sparkles className="h-4 w-4" aria-hidden /> What should the message say?</label>
|
||||
<textarea id={noteId} rows={2} maxLength={3000} className="w-full rounded border bg-white px-3 py-2 text-sm" value={note} onChange={e => setNote(e.target.value)}
|
||||
placeholder={channel === "whatsapp" ? "e.g. remind them to bring a sleeping bag; the bus leaves at 7:30 on Friday from the church car park" : "e.g. the breakfast moves to the Protea Hotel on Main Road, same time, park at the back, nothing to do if you've paid"} />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AiButton helper="draft_message" size="sm" busy={busy} onClick={run}>{busy ? "Drafting…" : `Draft the ${channel === "whatsapp" ? "WhatsApp message" : "email"}`}</AiButton>
|
||||
<button type="button" className="text-xs text-gray-600 underline" onClick={() => { setOpen(false); setDraft(null); setError(null); }}>Close</button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-700" role="alert">{error}</p>}
|
||||
{draft && (
|
||||
<div className="space-y-2 rounded border border-violet-200 bg-white p-2 text-sm">
|
||||
<AiNotice />
|
||||
{draft.subject && <p><span className="text-xs text-gray-500">Subject: </span>{draft.subject}</p>}
|
||||
<p className="whitespace-pre-wrap">{draft.body}</p>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className="rounded bg-brand-600 px-3 py-1 text-xs font-medium text-white hover:bg-brand-700" onClick={() => { onUse(draft); setDraft(null); setOpen(false); }}>Use this</button>
|
||||
<button type="button" className="rounded border px-3 py-1 text-xs hover:bg-gray-50" onClick={() => setDraft(null)}>Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useAiStatus } from "@/hooks/useAiStatus";
|
||||
import { runAi } from "@/lib/ai";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { AiButton, AiNotice } from "@/components/shared/AiButton";
|
||||
|
||||
type Mode = "improve" | "shorter" | "fix";
|
||||
type Answer = { text: string; changes: string[] };
|
||||
const MODES: { mode: Mode; label: string }[] = [
|
||||
{ mode: "improve", label: "Improve" },
|
||||
{ mode: "shorter", label: "Shorter" },
|
||||
{ mode: "fix", label: "Fix mistakes" },
|
||||
];
|
||||
|
||||
/**
|
||||
* "Improve this text" under a text box: improve it, make it shorter, or only fix mistakes. The
|
||||
* suggestion shows beside what was there, and replaces it only with "Use this". Hidden on sites
|
||||
* without AI.
|
||||
*/
|
||||
export function AiImprove({ text, onChange, where }: { text: string; onChange: (text: string) => void; where: "description" | "banner" | "message" }) {
|
||||
const { token } = useAuth();
|
||||
const status = useAiStatus();
|
||||
const [busy, setBusy] = useState<Mode | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [answer, setAnswer] = useState<Answer | null>(null);
|
||||
if (!status?.enabled) return null;
|
||||
|
||||
const run = async (mode: Mode) => {
|
||||
if (!token) return;
|
||||
if (text.trim().length < 3) { setError("Write something first."); return; }
|
||||
setBusy(mode);
|
||||
setError(null);
|
||||
try { setAnswer(await runAi<Answer>("improve_text", { text, mode, where }, token)); } catch (e) { setError(errorMessage(e) || "The AI couldn't improve this text."); }
|
||||
setBusy(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{MODES.map(m => (
|
||||
<AiButton key={m.mode} helper="improve_text" size="sm" busy={busy === m.mode} onClick={() => run(m.mode)}>{m.label}</AiButton>
|
||||
))}
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-700" role="alert">{error}</p>}
|
||||
{answer && (
|
||||
<div className="space-y-2 rounded border border-violet-200 bg-violet-50/60 p-2 text-sm">
|
||||
<AiNotice />
|
||||
<p className="whitespace-pre-wrap rounded border bg-white p-2">{answer.text}</p>
|
||||
{answer.changes.length > 0 && (
|
||||
<details className="text-xs text-gray-600"><summary className="cursor-pointer">What changed</summary>
|
||||
<ul className="ml-4 mt-1 list-disc">{answer.changes.map(c => <li key={c}>{c}</li>)}</ul>
|
||||
</details>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className="rounded bg-brand-600 px-3 py-1 text-xs font-medium text-white hover:bg-brand-700" onClick={() => { onChange(answer.text); setAnswer(null); }}>Use this</button>
|
||||
<button type="button" className="rounded border bg-white px-3 py-1 text-xs hover:bg-gray-50" onClick={() => setAnswer(null)}>Keep mine</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { currentAiStatus, loadAiStatus, subscribeAiStatus, type AiStatus } from "@/lib/ai";
|
||||
|
||||
/**
|
||||
* Whether to show the AI buttons, shared across the page (one request per page load). Null while
|
||||
* loading, and for anyone who isn't a supervisor or admin — AI helpers are theirs only.
|
||||
*/
|
||||
export function useAiStatus(): AiStatus | null {
|
||||
const { user, token } = useAuth();
|
||||
const allowed = !!token && (user?.role === "admin" || user?.role === "supervisor");
|
||||
const [status, setStatus] = useState<AiStatus | null>(() => (allowed ? currentAiStatus() : null));
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowed || !token) return;
|
||||
const off = subscribeAiStatus(setStatus);
|
||||
loadAiStatus(token).then(setStatus).catch(() => {});
|
||||
return off;
|
||||
}, [allowed, token]);
|
||||
|
||||
return allowed ? status : null;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { apiV2Data, apiV2Raw } from "@/lib/apiV2";
|
||||
import type { V2Result } from "@/lib/apiV2";
|
||||
|
||||
/**
|
||||
* AI helpers (2.0). The site calls AI through the Cross Events Hub, which keeps a monthly
|
||||
* allowance in rand per licence. One status per page load is shared by every AI button
|
||||
* (`useAiStatus`), and every helper call refreshes it — so the buttons disable straight after the
|
||||
* call that used up the allowance.
|
||||
*/
|
||||
|
||||
export type AiStatus = V2Result<"/ai/status", "get">["data"];
|
||||
export type AiHelper = keyof AiStatus["helpers"];
|
||||
|
||||
const OFF: AiStatus = {
|
||||
enabled: false, capReached: false, available: true, capCents: 0, usedCents: 0, usedPercent: 0, renewsOn: null,
|
||||
helpers: { draft_event: false, summarise_report: false, draft_message: false, summarise_feedback: false, improve_text: false },
|
||||
};
|
||||
|
||||
let current: AiStatus | null = null;
|
||||
let loading: Promise<AiStatus> | null = null;
|
||||
const listeners = new Set<(s: AiStatus) => void>();
|
||||
|
||||
function publish(s: AiStatus) {
|
||||
current = s;
|
||||
listeners.forEach(l => l(s));
|
||||
}
|
||||
|
||||
/** The status, fetched once per page load (staff never see AI, so they never ask). */
|
||||
export function loadAiStatus(authToken: string, { fresh = false } = {}): Promise<AiStatus> {
|
||||
if (current && !fresh) return Promise.resolve(current);
|
||||
if (!loading || fresh) {
|
||||
loading = (apiV2Data("get", "/ai/status", { authToken }) as Promise<AiStatus>)
|
||||
.then(s => { publish(s); return s; })
|
||||
.catch(() => { publish(OFF); return OFF; })
|
||||
.finally(() => { loading = null; });
|
||||
}
|
||||
return loading;
|
||||
}
|
||||
|
||||
export function subscribeAiStatus(fn: (s: AiStatus) => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => { listeners.delete(fn); };
|
||||
}
|
||||
|
||||
export function currentAiStatus(): AiStatus | null {
|
||||
return current;
|
||||
}
|
||||
|
||||
/** "1 December" from "2026-12-01". */
|
||||
export function renewsText(renewsOn: string | null): string {
|
||||
if (!renewsOn) return "next month";
|
||||
return new Date(`${renewsOn}T12:00:00Z`).toLocaleDateString("en-ZA", { day: "numeric", month: "long" });
|
||||
}
|
||||
|
||||
/** What the button's tooltip says when it can't be used. */
|
||||
export function disabledReason(s: AiStatus, helper: AiHelper): string | null {
|
||||
if (s.capReached) return `This month's AI allowance is used up — it renews on ${renewsText(s.renewsOn)}.`;
|
||||
if (!s.available) return "AI is unavailable right now. Try again in a few minutes.";
|
||||
if (!s.helpers[helper]) return "This AI helper is switched off in Site settings → AI.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a helper (`POST /api/v2/ai/<helper>`). Resolves to its answer; throws the API error (with
|
||||
* a message written for people). Either way the shared status is refreshed.
|
||||
*/
|
||||
export async function runAi<T>(helper: AiHelper, body: object, authToken: string): Promise<T> {
|
||||
try {
|
||||
const res = await apiV2Raw<{ output: T; status: AiStatus }>("post", `/ai/${helper.replace(/_/g, "-")}`, { authToken, body });
|
||||
publish(res.status);
|
||||
return res.output;
|
||||
} catch (e) {
|
||||
loadAiStatus(authToken, { fresh: true }).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+884
-1
@@ -6930,6 +6930,180 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/ai/status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Whether to show the AI buttons
|
||||
* @description Hidden when `enabled` is false; shown but disabled when `capReached` (until `renewsOn`, or until the allowance is raised). Cached for a minute; every AI call refreshes it.
|
||||
*
|
||||
* **Who:** Supervisors and admins.
|
||||
*/
|
||||
get: operations["getAiStatus"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/ai/usage": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* AI use this month
|
||||
* @description For Site settings → AI: the allowance, this site's switches, and this month's use per person, per helper and per day.
|
||||
*
|
||||
* **Who:** Admins only.
|
||||
*/
|
||||
get: operations["getAiUsage"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/ai/settings": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* Switch helpers on or off, and set a daily limit
|
||||
* @description **Who:** Admins only.
|
||||
*/
|
||||
put: operations["putAiSettings"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/ai/draft-event": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Draft an event from a description
|
||||
* @description Fills in the event form from the organiser's notes. `warnings` are checked in code (a weekday that doesn't match its date, costs that a full event can't cover); `draft.checks` are the AI's own. 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.
|
||||
*/
|
||||
post: operations["postAiDraft-event"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/ai/summarise-report": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
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.
|
||||
*
|
||||
* **Who:** Supervisors and admins.
|
||||
*/
|
||||
post: operations["postAiSummarise-report"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/ai/draft-message": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Draft an email or WhatsApp message
|
||||
* @description Turns a short note into a message, using {{name}} and the event placeholders. With `eventId`, the event's title, start and place are given to the AI as facts. 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.
|
||||
*/
|
||||
post: operations["postAiDraft-message"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/ai/summarise-feedback": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Summarise an event's feedback
|
||||
* @description Themes, what people liked, what to improve and suggestions, from the event's feedback. Sent without who answered; contact details in comments are kept back. 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.
|
||||
*/
|
||||
post: operations["postAiSummarise-feedback"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/ai/improve-text": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Improve a piece of text
|
||||
* @description Fixes spelling and grammar and makes it clearer (`improve`), shorter (`shorter`) or only fixes mistakes (`fix`), keeping every fact. 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.
|
||||
*/
|
||||
post: operations["postAiImprove-text"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -6937,7 +7111,7 @@ export interface components {
|
||||
Error: {
|
||||
error: {
|
||||
/** @enum {string} */
|
||||
code: "VALIDATION_FAILED" | "UNAUTHENTICATED" | "FORBIDDEN" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMITED" | "READ_ONLY" | "INTERNAL";
|
||||
code: "VALIDATION_FAILED" | "UNAUTHENTICATED" | "FORBIDDEN" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMITED" | "READ_ONLY" | "UNAVAILABLE" | "INTERNAL";
|
||||
message: string;
|
||||
/** @description Per-field problems (VALIDATION_FAILED only) */
|
||||
fields?: {
|
||||
@@ -29721,4 +29895,713 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getAiStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
/** @description false: hide the AI buttons (the licence has no AI, or the site isn't connected to the Hub) */
|
||||
enabled: boolean;
|
||||
/** @description true: show the buttons disabled until `renewsOn` */
|
||||
capReached: boolean;
|
||||
/** @description false: the Hub couldn't be reached just now */
|
||||
available: boolean;
|
||||
/** @description The monthly allowance, in cents */
|
||||
capCents: number;
|
||||
/** @description Used this month, in cents (fractions of a cent) */
|
||||
usedCents: number;
|
||||
usedPercent: number;
|
||||
/** @description YYYY-MM-DD */
|
||||
renewsOn: string | null;
|
||||
/** @description Each helper switched on or off in Site settings → AI */
|
||||
helpers: {
|
||||
draft_event: boolean;
|
||||
summarise_report: boolean;
|
||||
draft_message: boolean;
|
||||
summarise_feedback: boolean;
|
||||
improve_text: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Not signed in (UNAUTHENTICATED) */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not allowed for this role (FORBIDDEN) */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getAiUsage: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
status: {
|
||||
/** @description false: hide the AI buttons (the licence has no AI, or the site isn't connected to the Hub) */
|
||||
enabled: boolean;
|
||||
/** @description true: show the buttons disabled until `renewsOn` */
|
||||
capReached: boolean;
|
||||
/** @description false: the Hub couldn't be reached just now */
|
||||
available: boolean;
|
||||
/** @description The monthly allowance, in cents */
|
||||
capCents: number;
|
||||
/** @description Used this month, in cents (fractions of a cent) */
|
||||
usedCents: number;
|
||||
usedPercent: number;
|
||||
/** @description YYYY-MM-DD */
|
||||
renewsOn: string | null;
|
||||
/** @description Each helper switched on or off in Site settings → AI */
|
||||
helpers: {
|
||||
draft_event: boolean;
|
||||
summarise_report: boolean;
|
||||
draft_message: boolean;
|
||||
summarise_feedback: boolean;
|
||||
improve_text: boolean;
|
||||
};
|
||||
};
|
||||
settings: {
|
||||
helpers: {
|
||||
draft_event: boolean;
|
||||
summarise_report: boolean;
|
||||
draft_message: boolean;
|
||||
summarise_feedback: boolean;
|
||||
improve_text: boolean;
|
||||
};
|
||||
/** @description 0 = no limit */
|
||||
dailyLimitPerPerson: number;
|
||||
};
|
||||
people: {
|
||||
uses: number;
|
||||
chargedCents: number;
|
||||
userId: string | null;
|
||||
name: string;
|
||||
}[];
|
||||
helpers: {
|
||||
uses: number;
|
||||
chargedCents: number;
|
||||
helper: string;
|
||||
}[];
|
||||
days: {
|
||||
uses: number;
|
||||
chargedCents: number;
|
||||
day: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Not signed in (UNAUTHENTICATED) */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not allowed for this role (FORBIDDEN) */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
putAiSettings: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
helpers?: {
|
||||
draft_event?: boolean;
|
||||
summarise_report?: boolean;
|
||||
draft_message?: boolean;
|
||||
summarise_feedback?: boolean;
|
||||
improve_text?: boolean;
|
||||
};
|
||||
dailyLimitPerPerson?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
helpers: {
|
||||
draft_event: boolean;
|
||||
summarise_report: boolean;
|
||||
draft_message: boolean;
|
||||
summarise_feedback: boolean;
|
||||
improve_text: boolean;
|
||||
};
|
||||
/** @description 0 = no limit */
|
||||
dailyLimitPerPerson: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation failed (VALIDATION_FAILED) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not signed in (UNAUTHENTICATED) */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not allowed for this role (FORBIDDEN) */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
"postAiDraft-event": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
notes: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
output: {
|
||||
draft: {
|
||||
title: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
startDate: string;
|
||||
startTime: string;
|
||||
endDate: string;
|
||||
endTime: string;
|
||||
location: string;
|
||||
capacity: number;
|
||||
needsParentalConsent: boolean;
|
||||
ageGroup: string;
|
||||
ticketOptions: {
|
||||
name: string;
|
||||
priceRand: number;
|
||||
description: string;
|
||||
earlyBirdPriceRand: number;
|
||||
earlyBirdUntil: string;
|
||||
}[];
|
||||
sessions: {
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
label: string;
|
||||
}[];
|
||||
costs: {
|
||||
item: string;
|
||||
basis: string;
|
||||
amountRand: number;
|
||||
}[];
|
||||
checks: string[];
|
||||
};
|
||||
warnings: string[];
|
||||
totalCostRand: number;
|
||||
breakEvenPlaces: number | null;
|
||||
};
|
||||
status: {
|
||||
/** @description false: hide the AI buttons (the licence has no AI, or the site isn't connected to the Hub) */
|
||||
enabled: boolean;
|
||||
/** @description true: show the buttons disabled until `renewsOn` */
|
||||
capReached: boolean;
|
||||
/** @description false: the Hub couldn't be reached just now */
|
||||
available: boolean;
|
||||
/** @description The monthly allowance, in cents */
|
||||
capCents: number;
|
||||
/** @description Used this month, in cents (fractions of a cent) */
|
||||
usedCents: number;
|
||||
usedPercent: number;
|
||||
/** @description YYYY-MM-DD */
|
||||
renewsOn: string | null;
|
||||
/** @description Each helper switched on or off in Site settings → AI */
|
||||
helpers: {
|
||||
draft_event: boolean;
|
||||
summarise_report: boolean;
|
||||
draft_message: boolean;
|
||||
summarise_feedback: boolean;
|
||||
improve_text: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation failed (VALIDATION_FAILED) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not signed in (UNAUTHENTICATED) */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not allowed for this role (FORBIDDEN) */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
"postAiSummarise-report": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @enum {string} */
|
||||
reportKey: "seriesComparison" | "regTypes" | "usage" | "revenue" | "regStatus" | "donations" | "funnel" | "discounts" | "cashup" | "financeReport" | "profitReport";
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
stats?: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}[];
|
||||
columns: string[];
|
||||
rows: (string | number | null)[][];
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
output: {
|
||||
headline: string;
|
||||
insights: {
|
||||
title: string;
|
||||
detail: string;
|
||||
}[];
|
||||
watchOut: string[];
|
||||
};
|
||||
status: {
|
||||
/** @description false: hide the AI buttons (the licence has no AI, or the site isn't connected to the Hub) */
|
||||
enabled: boolean;
|
||||
/** @description true: show the buttons disabled until `renewsOn` */
|
||||
capReached: boolean;
|
||||
/** @description false: the Hub couldn't be reached just now */
|
||||
available: boolean;
|
||||
/** @description The monthly allowance, in cents */
|
||||
capCents: number;
|
||||
/** @description Used this month, in cents (fractions of a cent) */
|
||||
usedCents: number;
|
||||
usedPercent: number;
|
||||
/** @description YYYY-MM-DD */
|
||||
renewsOn: string | null;
|
||||
/** @description Each helper switched on or off in Site settings → AI */
|
||||
helpers: {
|
||||
draft_event: boolean;
|
||||
summarise_report: boolean;
|
||||
draft_message: boolean;
|
||||
summarise_feedback: boolean;
|
||||
improve_text: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation failed (VALIDATION_FAILED) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not signed in (UNAUTHENTICATED) */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not allowed for this role (FORBIDDEN) */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
"postAiDraft-message": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @enum {string} */
|
||||
channel: "email" | "whatsapp";
|
||||
note: string;
|
||||
eventId?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
output: {
|
||||
subject: string;
|
||||
body: string;
|
||||
};
|
||||
status: {
|
||||
/** @description false: hide the AI buttons (the licence has no AI, or the site isn't connected to the Hub) */
|
||||
enabled: boolean;
|
||||
/** @description true: show the buttons disabled until `renewsOn` */
|
||||
capReached: boolean;
|
||||
/** @description false: the Hub couldn't be reached just now */
|
||||
available: boolean;
|
||||
/** @description The monthly allowance, in cents */
|
||||
capCents: number;
|
||||
/** @description Used this month, in cents (fractions of a cent) */
|
||||
usedCents: number;
|
||||
usedPercent: number;
|
||||
/** @description YYYY-MM-DD */
|
||||
renewsOn: string | null;
|
||||
/** @description Each helper switched on or off in Site settings → AI */
|
||||
helpers: {
|
||||
draft_event: boolean;
|
||||
summarise_report: boolean;
|
||||
draft_message: boolean;
|
||||
summarise_feedback: boolean;
|
||||
improve_text: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation failed (VALIDATION_FAILED) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not signed in (UNAUTHENTICATED) */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not allowed for this role (FORBIDDEN) */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
"postAiSummarise-feedback": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
eventId: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
output: {
|
||||
overall: string;
|
||||
liked: {
|
||||
theme: string;
|
||||
mentions: number;
|
||||
}[];
|
||||
improve: {
|
||||
theme: string;
|
||||
mentions: number;
|
||||
}[];
|
||||
quotes: string[];
|
||||
nextTime: string[];
|
||||
responses: number;
|
||||
};
|
||||
status: {
|
||||
/** @description false: hide the AI buttons (the licence has no AI, or the site isn't connected to the Hub) */
|
||||
enabled: boolean;
|
||||
/** @description true: show the buttons disabled until `renewsOn` */
|
||||
capReached: boolean;
|
||||
/** @description false: the Hub couldn't be reached just now */
|
||||
available: boolean;
|
||||
/** @description The monthly allowance, in cents */
|
||||
capCents: number;
|
||||
/** @description Used this month, in cents (fractions of a cent) */
|
||||
usedCents: number;
|
||||
usedPercent: number;
|
||||
/** @description YYYY-MM-DD */
|
||||
renewsOn: string | null;
|
||||
/** @description Each helper switched on or off in Site settings → AI */
|
||||
helpers: {
|
||||
draft_event: boolean;
|
||||
summarise_report: boolean;
|
||||
draft_message: boolean;
|
||||
summarise_feedback: boolean;
|
||||
improve_text: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation failed (VALIDATION_FAILED) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not signed in (UNAUTHENTICATED) */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not allowed for this role (FORBIDDEN) */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
"postAiImprove-text": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
text: string;
|
||||
/**
|
||||
* @default improve
|
||||
* @enum {string}
|
||||
*/
|
||||
mode?: "improve" | "shorter" | "fix";
|
||||
/**
|
||||
* @default description
|
||||
* @enum {string}
|
||||
*/
|
||||
where?: "description" | "banner" | "message";
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
output: {
|
||||
text: string;
|
||||
changes: string[];
|
||||
};
|
||||
status: {
|
||||
/** @description false: hide the AI buttons (the licence has no AI, or the site isn't connected to the Hub) */
|
||||
enabled: boolean;
|
||||
/** @description true: show the buttons disabled until `renewsOn` */
|
||||
capReached: boolean;
|
||||
/** @description false: the Hub couldn't be reached just now */
|
||||
available: boolean;
|
||||
/** @description The monthly allowance, in cents */
|
||||
capCents: number;
|
||||
/** @description Used this month, in cents (fractions of a cent) */
|
||||
usedCents: number;
|
||||
usedPercent: number;
|
||||
/** @description YYYY-MM-DD */
|
||||
renewsOn: string | null;
|
||||
/** @description Each helper switched on or off in Site settings → AI */
|
||||
helpers: {
|
||||
draft_event: boolean;
|
||||
summarise_report: boolean;
|
||||
draft_message: boolean;
|
||||
summarise_feedback: boolean;
|
||||
improve_text: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation failed (VALIDATION_FAILED) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not signed in (UNAUTHENTICATED) */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not allowed for this role (FORBIDDEN) */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export const COMMAND_PAGES: CommandPage[] = [
|
||||
...([
|
||||
["organisation", "Organisation"], ["campuses", "Campuses"], ["branding", "Branding"], ["notifications", "Notifications"],
|
||||
["email", "Email"], ["legal", "Legal"], ["whatsapp", "WhatsApp"], ["payments", "Payments"], ["webhooks", "Webhooks"],
|
||||
["backups", "Backups"], ["jobs", "Background jobs"], ["licence", "Licence & updates"],
|
||||
["backups", "Backups"], ["jobs", "Background jobs"], ["licence", "Licence & updates"], ["ai", "AI helpers"],
|
||||
] as const).map(([tab, label]): CommandPage => ({ group: "Settings", label: `Settings: ${label}`, href: `/dashboard/admin/settings?tab=${tab}`, roles: ADM, keywords: "site settings" })),
|
||||
// Reports — every report in the catalogue
|
||||
...REPORTS.map((r): CommandPage => ({ group: "Reports", label: `Report: ${r.label}`, href: `/dashboard/supervisor/reports?report=${r.key}`, roles: SUP, keywords: `report ${r.category} ${r.description}` })),
|
||||
|
||||
Reference in New Issue
Block a user