Compare commits

...
Author SHA1 Message Date
joshuaandClaude Opus 5.5 206a814973 Docs: 2.0 progress, marketing site plan, AI tested
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:58:48 +02:00
joshuaandClaude Opus 5.5 311fe8b6f1 WIP 2.0.0: AI helper plumbing (status, usage, Site settings -> AI)
The site calls AI through the Hub (protocol 3b, Hub 1.6.0): src/ai
works out whether the AI buttons show (licence token first, then the
Hub, cached a minute), runs a helper call with per-helper switches and
a per-person daily limit, logs AiCall rows, and emails admins at 80%.
v2 /ai/status, /ai/usage, /ai/settings; useAiStatus, AiButton, AiTab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:25:36 +02:00
joshua 7ce25bf40b Merge branch 'docs/handover-roadmap-tidy' 2026-10-30 09:00:49 +02:00
21 changed files with 1834 additions and 30 deletions
+18
View File
@@ -7,6 +7,24 @@ 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.
## [1.51.17] - 2026-10-30 — security release
### Security
@@ -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);
+20
View File
@@ -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])
}
+263
View File
@@ -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 };
+13 -8
View File
@@ -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 };
+2
View File
@@ -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 };
});
+4 -1
View File
@@ -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 };
+1
View File
@@ -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 };
+77
View File
@@ -0,0 +1,77 @@
import { z } from 'zod';
import { defineRoute } from '../route';
import { single } from '../paging';
import { aiStatus, getAiSettings, saveAiSettings, usageThisMonth, HELPERS, clearAiStatusCache } from '../../ai';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const { campusIndependent } = require('../../utils/campusScope') as { campusIndependent(reason: string): any };
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
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)) };
},
});
+134
View File
@@ -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');
});
});
+16 -5
View File
@@ -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).
@@ -79,10 +79,21 @@ encrypted settings unreadable or break existing installs.
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 (§15), v1 removed, a migration guide, and marketing site/docs for
selling. 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.
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
+58 -6
View File
@@ -12,8 +12,19 @@ providers), maintainable (tests, CI, typed backend, versioned API), and with
**AI helpers** as the headline feature (§15).
**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). **Next: 2.0.0** — AI helpers (§15) and v1 removed. The full
version list is in [HANDOVER.md](./HANDOVER.md) and the [build order](#suggested-build-order) below.
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 | Hub 1.6.0 (`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
@@ -68,8 +79,9 @@ Removed from scope (2026-10-28): Wallet passes (§7) and languages (§10).
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)
---
@@ -940,6 +952,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
@@ -993,6 +1022,25 @@ 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 |
@@ -1010,7 +1058,7 @@ income justify the hardware:
| 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`. 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, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
| 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
@@ -1049,6 +1097,8 @@ feature.
| — | 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) |
@@ -1070,4 +1120,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
View File
@@ -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>
+147
View File
@@ -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&apos;t part of this site&apos;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&apos;s Hub can&apos;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&apos;s Hub to Anthropic (Claude) in the United States. Only what a helper needs is sent — never names, contact details, medical or children&apos;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,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>
);
}
+24
View File
@@ -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;
}
+76
View File
@@ -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;
}
}
+546
View File
@@ -97,6 +97,9 @@
},
{
"name": "Webhooks"
},
{
"name": "AI"
}
],
"paths": {
@@ -39939,6 +39942,548 @@
}
}
}
},
"/ai/status": {
"get": {
"tags": [
"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.\n\n**Who:** Supervisors and admins.",
"operationId": "getAiStatus",
"security": [
{
"bearerAuth": []
}
],
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "false: hide the AI buttons (the licence has no AI, or the site isn't connected to the Hub)"
},
"capReached": {
"type": "boolean",
"description": "true: show the buttons disabled until `renewsOn`"
},
"available": {
"type": "boolean",
"description": "false: the Hub couldn't be reached just now"
},
"capCents": {
"type": "number",
"description": "The monthly allowance, in cents"
},
"usedCents": {
"type": "number",
"description": "Used this month, in cents (fractions of a cent)"
},
"usedPercent": {
"type": "number"
},
"renewsOn": {
"description": "YYYY-MM-DD",
"type": [
"string",
"null"
]
},
"helpers": {
"type": "object",
"properties": {
"draft_event": {
"type": "boolean"
},
"summarise_report": {
"type": "boolean"
},
"draft_message": {
"type": "boolean"
},
"summarise_feedback": {
"type": "boolean"
},
"improve_text": {
"type": "boolean"
}
},
"required": [
"draft_event",
"summarise_report",
"draft_message",
"summarise_feedback",
"improve_text"
],
"additionalProperties": false,
"description": "Each helper switched on or off in Site settings → AI"
}
},
"required": [
"enabled",
"capReached",
"available",
"capCents",
"usedCents",
"usedPercent",
"renewsOn",
"helpers"
],
"additionalProperties": false
}
},
"required": [
"data"
],
"additionalProperties": false
}
}
}
},
"401": {
"description": "Not signed in (UNAUTHENTICATED)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not allowed for this role (FORBIDDEN)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/ai/usage": {
"get": {
"tags": [
"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.\n\n**Who:** Admins only.",
"operationId": "getAiUsage",
"security": [
{
"bearerAuth": []
}
],
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"status": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "false: hide the AI buttons (the licence has no AI, or the site isn't connected to the Hub)"
},
"capReached": {
"type": "boolean",
"description": "true: show the buttons disabled until `renewsOn`"
},
"available": {
"type": "boolean",
"description": "false: the Hub couldn't be reached just now"
},
"capCents": {
"type": "number",
"description": "The monthly allowance, in cents"
},
"usedCents": {
"type": "number",
"description": "Used this month, in cents (fractions of a cent)"
},
"usedPercent": {
"type": "number"
},
"renewsOn": {
"description": "YYYY-MM-DD",
"type": [
"string",
"null"
]
},
"helpers": {
"type": "object",
"properties": {
"draft_event": {
"type": "boolean"
},
"summarise_report": {
"type": "boolean"
},
"draft_message": {
"type": "boolean"
},
"summarise_feedback": {
"type": "boolean"
},
"improve_text": {
"type": "boolean"
}
},
"required": [
"draft_event",
"summarise_report",
"draft_message",
"summarise_feedback",
"improve_text"
],
"additionalProperties": false,
"description": "Each helper switched on or off in Site settings → AI"
}
},
"required": [
"enabled",
"capReached",
"available",
"capCents",
"usedCents",
"usedPercent",
"renewsOn",
"helpers"
],
"additionalProperties": false
},
"settings": {
"type": "object",
"properties": {
"helpers": {
"type": "object",
"properties": {
"draft_event": {
"type": "boolean"
},
"summarise_report": {
"type": "boolean"
},
"draft_message": {
"type": "boolean"
},
"summarise_feedback": {
"type": "boolean"
},
"improve_text": {
"type": "boolean"
}
},
"required": [
"draft_event",
"summarise_report",
"draft_message",
"summarise_feedback",
"improve_text"
],
"additionalProperties": false
},
"dailyLimitPerPerson": {
"type": "integer",
"minimum": 0,
"maximum": 1000,
"description": "0 = no limit"
}
},
"required": [
"helpers",
"dailyLimitPerPerson"
],
"additionalProperties": false
},
"people": {
"type": "array",
"items": {
"type": "object",
"properties": {
"uses": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"chargedCents": {
"type": "number"
},
"userId": {
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
}
},
"required": [
"uses",
"chargedCents",
"userId",
"name"
],
"additionalProperties": false
}
},
"helpers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"uses": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"chargedCents": {
"type": "number"
},
"helper": {
"type": "string"
}
},
"required": [
"uses",
"chargedCents",
"helper"
],
"additionalProperties": false
}
},
"days": {
"type": "array",
"items": {
"type": "object",
"properties": {
"uses": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"chargedCents": {
"type": "number"
},
"day": {
"type": "string"
}
},
"required": [
"uses",
"chargedCents",
"day"
],
"additionalProperties": false
}
}
},
"required": [
"status",
"settings",
"people",
"helpers",
"days"
],
"additionalProperties": false
}
},
"required": [
"data"
],
"additionalProperties": false
}
}
}
},
"401": {
"description": "Not signed in (UNAUTHENTICATED)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not allowed for this role (FORBIDDEN)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/ai/settings": {
"put": {
"tags": [
"AI"
],
"summary": "Switch helpers on or off, and set a daily limit",
"description": "**Who:** Admins only.",
"operationId": "putAiSettings",
"security": [
{
"bearerAuth": []
}
],
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"helpers": {
"type": "object",
"properties": {
"draft_event": {
"type": "boolean"
},
"summarise_report": {
"type": "boolean"
},
"draft_message": {
"type": "boolean"
},
"summarise_feedback": {
"type": "boolean"
},
"improve_text": {
"type": "boolean"
}
}
},
"dailyLimitPerPerson": {
"type": "integer",
"minimum": 0,
"maximum": 1000
}
}
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"helpers": {
"type": "object",
"properties": {
"draft_event": {
"type": "boolean"
},
"summarise_report": {
"type": "boolean"
},
"draft_message": {
"type": "boolean"
},
"summarise_feedback": {
"type": "boolean"
},
"improve_text": {
"type": "boolean"
}
},
"required": [
"draft_event",
"summarise_report",
"draft_message",
"summarise_feedback",
"improve_text"
],
"additionalProperties": false
},
"dailyLimitPerPerson": {
"type": "integer",
"minimum": 0,
"maximum": 1000,
"description": "0 = no limit"
}
},
"required": [
"helpers",
"dailyLimitPerPerson"
],
"additionalProperties": false
}
},
"required": [
"data"
],
"additionalProperties": false
}
}
}
},
"400": {
"description": "Validation failed (VALIDATION_FAILED)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not signed in (UNAUTHENTICATED)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not allowed for this role (FORBIDDEN)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
},
"components": {
@@ -39973,6 +40518,7 @@
"CONFLICT",
"RATE_LIMITED",
"READ_ONLY",
"UNAVAILABLE",
"INTERNAL"
]
},
+290 -1
View File
@@ -6930,6 +6930,70 @@ 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;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -6937,7 +7001,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 +29785,229 @@ 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"];
};
};
};
};
}
+1 -1
View File
@@ -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}` })),