Compare commits

...
17 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
joshuaandClaude Opus 5.5 376b93a438 Docs: bring HANDOVER and ROADMAP-v2 up to date after 1.51.17
HANDOVER: version table in order with 1.51.4/1.51.5 added; Next up
renumbered with the 2.0 clean-ups (lint rules, node-fetch, password
hashing); event picker points at fetchManagedEvents; stale lines fixed.
ROADMAP: status rewritten as a dated summary; section 14 marks what's
done and that CI was removed; Haiku prices flagged to check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:00:49 +02:00
joshua 0cd78ab1b7 Merge branch 'fix/forgot-password-enumeration' 2026-10-30 08:51:41 +02:00
joshuaandClaude Opus 5.5 d49aebb1b0 Security: forgot password and sign-in don't reveal accounts (1.51.17)
- Forgot password answers the same (message, status, timing) for every email; the reset link
  is made and sent after the answer. Rate-limited like sign-in.
- Sign-in with an unknown email is refused exactly like a wrong password, after a password
  check against a stand-in hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:51:41 +02:00
joshuaandClaude Opus 5.5 98e5d05548 WIP 1.51.17: forgot password and sign-in don't reveal accounts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:47:04 +02:00
joshua 14ece27690 Merge branch 'feat/api-v2-webhooks-links' 2026-10-30 08:34:41 +02:00
joshuaandClaude Opus 5.5 5b50b0ce4f Incoming webhooks and message links on API v2 (1.51.16)
- /api/v2/webhooks/{yoco,payfast,whatsapp}; new provider set-ups use them, the v1 addresses
  keep working with the same handlers.
- Calendar and one-click unsubscribe links point at v2; old links answer 308 (a one-click POST
  to the old address is still handled there).
- /api/webhooks/whatsapp runs the real WhatsApp handler (was an empty stub).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:34:41 +02:00
joshuaandClaude Opus 5.5 99f16375b9 WIP 1.51.16: incoming webhooks and message links on v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:30:05 +02:00
joshua a62cac6335 Merge branch 'feat/api-v2-privacy-admin' 2026-10-30 08:23:49 +02:00
joshuaandClaude Opus 5.5 c4fff7c835 Privacy and admin on API v2 (1.51.15)
- v2: my privacy, unsubscribe links, the privacy admin, audit log, jobs, impersonation,
  settings and first-run set-up, the Hub, outbound webhooks, backups, Yoco/PayFast settings.
- Every call the app makes is now on /api/v2 (incoming webhooks and message links: 1.51.16).
- v1Route handles res.download/sendFile; impersonation end/log allowed on v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:23:49 +02:00
joshuaandClaude Opus 5.5 e310743b92 WIP 1.51.15: privacy and admin on v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:19:24 +02:00
joshua b6c13de5dc Merge branch 'feat/api-v2-people-messaging' 2026-10-30 08:11:48 +02:00
joshuaandClaude Opus 5.5 2a5b12db20 People and messaging on API v2 (1.51.14)
- v2: the rest of accounts (code resend, password reset, activation, sessions, closing,
  activity, two-step sign-in; admin users), /event-listing, messaging and broadcasts,
  scheduled messages, automations, WhatsApp connection, banner, saved filters, dashboards,
  stats, analytics, report files.
- GET /me carries the session fields the app needs (prompts, campus scope, 2FA required).
- defineRoute refuses duplicate routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:11:48 +02:00
joshuaandClaude Opus 5.5 8747431bd5 WIP 1.51.14: GET /me carries the session fields the app needs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:07:06 +02:00
joshuaandClaude Opus 5.5 e95d52ccdd WIP 1.51.14: people and messaging on v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 17:02:26 +02:00
115 changed files with 21955 additions and 510 deletions
+103
View File
@@ -7,6 +7,109 @@ 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
- **Forgot password no longer reveals whether an account exists.** It answered "Email not found"
(404) for an unknown address, so anyone could check which emails have accounts. It now gives
the same answer and status for every address, sent before any work that only happens for a
real account (so the response time doesn't tell either); the reset link is made and sent
afterwards. Applies to `POST /api/users/forgot` and `POST /api/v2/auth/password/forgot`.
- **Sign-in no longer reveals it either.** An unknown email answered "User does not exist" (with a
different status on v1); it now gets exactly the answer a wrong password gets, after a password
check against a stand-in hash so it takes as long.
- Forgot password is rate-limited like sign-in, so it can't be used to flood someone's inbox with
reset emails.
### Upgrading
- Update as soon as you can. Nothing else changes; the forgot-password page shows the new message.
## [1.51.16] - 2026-10-30
### Added
- **Incoming webhooks on v2:** `/api/v2/webhooks/yoco`, `/payfast` and `/whatsapp`. New provider
set-ups use them — the PayFast notify URL (every new checkout), the Yoco webhook URL offered in
Site settings → Payments, and the WhatsApp webhook URL. The v1 addresses (`/api/webhooks/…`,
`/api/whatsapp/webhook`) keep working with the same handlers, because a provider may not follow
a redirect on a POST; they now name their v2 address in a `Link` header.
- **Links in sent messages on v2:** "Add to calendar" links point at
`/api/v2/events/{id}/calendar.ics`, and the one-click unsubscribe header at
`/api/v2/unsubscribe/{token}/one-click`. The same links in messages already sent answer `308`
with their v2 address; a mail app's one-click POST to the old address is still handled there.
### Fixed
- `/api/webhooks/whatsapp` was an empty stub that answered 500; it now runs the WhatsApp webhook
handler like `/api/whatsapp/webhook`.
### Upgrading
- Nothing has to change: existing Yoco and WhatsApp webhook registrations keep working. To move a
registration to its v2 address, generate the Yoco webhook again (Site settings → Payments → Yoco)
and paste the webhook URL shown in Site settings → WhatsApp into WAWP. PayFast moves by itself.
## [1.51.15] - 2026-10-30
### Added
- **API v2 — privacy and admin:** my privacy (`/me/privacy`, news, my data as JSON or PDF, requests,
accepting the notice), `/privacy/notice`, unsubscribe links (`/unsubscribe/{token}`), the privacy
admin (requests, notice drafts and publishing, operators, consent and access logs, readiness,
retention, breaches); `/admin/audit-log`, jobs, impersonation (`/admin/impersonate/{userId}`,
`/me/impersonation` and its end and log); site settings and first-run set-up (`/settings`,
`/setup`); the Hub; outbound webhooks; backups (including the download); Yoco and PayFast settings
(`/payments/yoco/…`, `/payments/payfast/…`).
- `v1Route` handles `res.download` and `res.sendFile`.
### Changed
- **Every API call the app makes now goes to `/api/v2`.** Only the incoming webhooks (Yoco,
PayFast, WhatsApp) and the links in sent messages are still v1 — those are 1.51.16.
- Ending a viewing-as session works on v2 while the session is read-only.
## [1.51.14] - 2026-10-29
### Added
- **API v2 — people and messaging:** the rest of accounts (`/auth/login/code/resend`,
`/auth/password/forgot` and `/reset`, `/auth/activate`, `/me/sessions/revoke`, `/me/close`,
`/me/activity`, `/me/verify-password`, `/me/two-factor/…`; admin `/users`, `/users/{id}`,
`/users/exists`, sessions and anonymising); `/event-listing` (the full public list the site's event
pages show) and `/admin/events`; messaging an event's people, email and WhatsApp broadcasts,
`/scheduled-messages`, `/automations/schedule`; the WhatsApp connection (`/whatsapp/…`); `/banner`;
`/saved-filters`; `/dashboard/today` and `/search`; `/stats/…`; `/analytics/…`; report files
(`/reports/pdf`, `/excel`, `/email`, `/whatsapp`).
- `defineRoute` refuses a method and path defined twice (the first would silently win).
- `GET /me` also answers `newsPromptPending`, `noticePromptPending`, `twoFactorRequired` and
`campusScope`, so the app's session (and its after-sign-in prompts) comes from v2.
### Changed
- Sign-in (including the self-service kiosk's), password reset, two-step sign-in, account
settings, admin users, messaging, broadcasts, WhatsApp set-up, dashboards, saved filters,
reports and the public event lists use v2.
## [1.51.13] - 2026-10-29
### Added
+1 -1
View File
@@ -137,7 +137,7 @@ set names that match your PM2 apps (`pm2 ls`) and the port you use.
2. Set all required environment variables (see backend/README.md).
3. Run Prisma migrations (if applicable).
4. Start the server with: npm start (or a process manager like PM2).
5. Expose the webhook endpoint /api/webhooks/yoco publicly and configure the YOCO webhook to point to it.
5. Expose the webhook endpoint /api/v2/webhooks/yoco publicly and register it with Yoco (Site settings → Payments → Yoco → Generate does this).
6. Ensure the uploads directory public/uploads exists and is writable.
### Frontend Deployment
+23
View File
@@ -12,6 +12,29 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 1.51.17 — security update
- **Better protection for people's accounts.** The forgot-password and sign-in pages no longer
let anyone find out whether an email address has an account here.
## 1.51.16
- **The move to the new server connection is complete.** Payment confirmations from Yoco and
PayFast, WhatsApp messages coming in, and the calendar and unsubscribe links in emails now use
the new addresses. Links in emails you've already sent keep working.
## 1.51.15
- **The app now talks to the server only through its new version.** Privacy settings, the admin
pages, site settings, the Hub, webhooks and backups were the last to move. Everything works just
as before.
## 1.51.14
- **More groundwork for 2.0.** Accounts, messaging and broadcasts, WhatsApp set-up, the dashboards,
saved filters and report files now run on the new version of the server's connection to the
app. They work just as before.
## 1.51.13
- **More groundwork for 2.0.** Kids check-in and volunteer rosters now run on the new version of
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.51.13",
"version": "1.51.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.51.13",
"version": "1.51.17",
"hasInstallScript": true,
"dependencies": {
"@cj-tech-master/excelts": "^10.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.51.13",
"version": "1.51.17",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -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 };
+8 -2
View File
@@ -99,8 +99,10 @@ const globalLimiter = rateLimit({
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many requests, please try again later.' },
skip: (req) => req.path.startsWith('/api/webhooks'),
skip: (req) => RAW_BODY_PREFIXES.some(p => req.path.startsWith(p)),
});
// Incoming webhooks and links in sent messages on v2 (1.51.16): see src/v2/links.ts.
const { RAW_BODY_PREFIXES, webhooksRouter, linksRouter, legacyLinkRedirects } = require('./v2/links');
// /api/v2 answers every error as { error: { code, message } }, including the shared
// middleware below (rate limits, read-only guards, auth) that writes v1's { message }.
app.use('/api/v2', require('./v2/errors').legacyErrorsToV2);
@@ -115,7 +117,7 @@ app.use(require('./utils/impersonation').readOnlyGuard);
// Raw body parsing middleware for webhooks
app.use((req, res, next) => {
if (req.path.startsWith('/api/webhooks')) {
if (RAW_BODY_PREFIXES.some(p => req.path.startsWith(p))) {
getRawBody(req, {
length: req.headers['content-length'],
encoding: 'utf-8'
@@ -161,6 +163,7 @@ const privacyRoutes = require('./routes/privacyRoutes');
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
app.use('/api/webhooks', webhookRoutes);
app.use('/api/v2/webhooks', webhooksRouter);
// Apply JSON body parser for all non-webhook routes
app.use(express.json());
@@ -196,7 +199,10 @@ app.get('/health', async (req, res) => {
// Use routes
// /api/v2 (1.51.0, TypeScript): consistent errors and paging, described by OpenAPI at /docs/v2.
app.use('/api/v2', linksRouter);
app.use('/api/v2', require('./v2').v2Router());
// Links in messages already sent: v1 addresses answer 308 with their v2 one.
app.use(legacyLinkRedirects);
app.use('/docs/v2', require('./v2').v2DocsRouter());
app.use('/api/hub', require('./routes/hubRoutes'));
app.use('/api/campuses', require('./routes/campusRoutes'));
+41 -44
View File
@@ -539,55 +539,52 @@ const requestPasswordReset = async (req, res) => {
const user = await prisma.user.findUnique({ where: { email } });
// Explicitly check existence as requested
if (!user) {
return res.status(404).json({ message: 'Email not found' });
}
// Create token valid for 1 hour
const token = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
// If PasswordReset model isn't available (migration not run), return clear error
if (!prisma.passwordReset || typeof prisma.passwordReset.create !== 'function' || typeof prisma.passwordReset.updateMany !== 'function') {
console.warn('[PasswordReset] Prisma model not available. Run Prisma migrations to enable password reset tokens.');
return res.status(500).json({ message: 'Password reset is not available. Please contact support.' });
}
// Invalidate previous tokens (optional)
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
data: { used: true }
});
await prisma.passwordReset.create({
data: {
id: uuidv4(),
userId: user.id,
token,
expiresAt,
used: false
}
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
// Security: always email
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
// Also WhatsApp if preferred (security message — sent in addition to email)
const { waText } = require('../utils/notify');
waText(user, emailContent.text).catch(() => {});
return res.json({ message: 'If that email exists, a password reset link has been sent.' });
// Security (1.51.17): the same answer, with the same status, whether or not an account has
// this email — and it's sent before any work that only happens for real accounts, so the
// response time doesn't tell either. The link is made and sent afterwards.
res.json({ message: GENERIC_RESET_MESSAGE });
if (user) sendPasswordResetLink(user).catch(e => console.warn('[password reset] Failed:', e?.message || e));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
const GENERIC_RESET_MESSAGE = 'If an account uses that email, we\'ve sent it a link to reset the password.';
/** A one-hour reset link for this account, by email (and WhatsApp when they prefer it). */
async function sendPasswordResetLink(user) {
const token = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
// Invalidate previous tokens (optional)
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
data: { used: true }
});
await prisma.passwordReset.create({
data: {
id: uuidv4(),
userId: user.id,
token,
expiresAt,
used: false
}
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
// Security: always email
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
// Also WhatsApp if preferred (security message — sent in addition to email)
const { waText } = require('../utils/notify');
waText(user, emailContent.text).catch(() => {});
}
// @desc Reset password using token
// @route POST /api/users/reset
// @access Public
@@ -5,16 +5,6 @@ const { handleProviderWebhook } = require('../payments/webhook');
// @access Public
const handleYocoWebhook = handleProviderWebhook('yoco');
const handleWhatsappWebhook = async (req, res) => {
try {
const { body } = req;
const { message } = body;
} catch (error) {
res.status(500).json({ error: 'Internal Server Error' });
}
}
module.exports = {
handleYocoWebhook,
handleWhatsappWebhook,
};
+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 };
+1 -1
View File
@@ -33,7 +33,7 @@ router.post('/', registerUser);
router.post('/login', loginLimiter, loginUser);
router.post('/login/2fa', loginLimiter, verifyLoginTwoFactor);
router.post('/login/2fa/resend', loginLimiter, resendLoginTwoFactor);
router.post('/forgot', requestPasswordReset);
router.post('/forgot', loginLimiter, requestPasswordReset);
router.post('/reset', resetPassword);
router.post('/activate', activateAccount);
+3 -2
View File
@@ -1,11 +1,12 @@
const express = require('express');
const router = express.Router();
const { handleYocoWebhook, handleWhatsappWebhook } = require('../controllers/webhookController');
const { handleYocoWebhook } = require('../controllers/webhookController');
const { handleProviderWebhook } = require('../payments/webhook');
// Payment provider webhooks (src/payments)
router.post('/yoco', handleYocoWebhook);
router.post('/payfast', handleProviderWebhook('payfast'));
router.post('/whatsapp', handleWhatsappWebhook);
// The WAWP handler (WAWP is registered at /api/whatsapp/webhook, now /api/v2/webhooks/whatsapp).
router.post('/whatsapp', ...require('../v2/links').whatsappWebhook);
module.exports = router;
+10 -1
View File
@@ -207,6 +207,10 @@ export type LoginResult =
| { status: 'signed_in'; session: Session }
| { status: 'code_required'; challenge: { challengeId: string; method: string; destination: string | null }; enrolling: boolean };
let standIn: Promise<string> | null = null;
/** A hash nobody's password matches, so a sign-in with an unknown email does the same work. */
const standInHash = () => (standIn ??= auth.hashPassword(uuidv4()));
const MAX_ATTEMPTS = 5;
const LOCK_TIME = 5 * 60 * 1000;
@@ -219,7 +223,12 @@ export async function logIn(input: { identifier: string; password: string; devic
// SA local format (0xx) → international (27xx)
const normalizedPhone = whatsapp().normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
const user = await prisma.user.findFirst({ where: { OR: [{ email: rawInput }, ...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : [])] } });
if (!user) return fail(401, 'User does not exist');
// Security (1.51.17): an unknown email answers like a wrong password — same message and
// status, and a password check against a stand-in hash so it takes as long.
if (!user) {
await auth.comparePassword(input.password, await standInHash());
return fail(401, 'Invalid email or password', undefined, { v1Status: 400 });
}
if (!user.isActive) {
// Resend the activation link on each attempt, in case the earlier one expired.
+2 -1
View File
@@ -17,7 +17,8 @@ const prisma = require('../config/db');
const MAX_MINUTES = 30;
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log'];
// v1 and v2 paths for ending a viewing-as session and logging what was viewed.
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log', '/api/v2/me/impersonation/end', '/api/v2/me/impersonation/log'];
/** The `imp` claim of a bearer token, or null (no token, invalid token, or not impersonating). */
function impersonationClaim(req) {
+1 -1
View File
@@ -34,7 +34,7 @@ function getBackendUrl() {
function calendarLinkRow(eventId) {
const backendUrl = getBackendUrl();
if (!backendUrl || !eventId) return '';
return secondaryLink('Add to calendar', `${backendUrl}/api/events/${eventId}/ics`);
return secondaryLink('Add to calendar', `${backendUrl}/api/v2/events/${eventId}/calendar.ics`);
}
function getOrg() {
+1 -1
View File
@@ -38,7 +38,7 @@ const payfastHost = (mode) => HOSTS[mode === 'live' ? 'live' : 'sandbox'];
/** Where PayFast should post ITNs: BACKEND_URL (or the request's origin) + /api/webhooks/payfast. */
function payfastNotifyUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/payfast` : '';
return base ? `${base}/api/v2/webhooks/payfast` : '';
}
/** Why PayFast can't reach the notify URL (null when it can). */
+3 -1
View File
@@ -120,7 +120,7 @@ function unsubscribePageUrl(target) {
/** RFC 8058 one-click endpoint, for the List-Unsubscribe header (mail apps POST to it). */
function oneClickUrl(target) {
return `${backendUrl()}/api/privacy/unsubscribe/${encodeURIComponent(unsubscribeToken(target))}/one-click`;
return `${backendUrl()}/api/v2/unsubscribe/${encodeURIComponent(unsubscribeToken(target))}/one-click`;
}
/** What an unsubscribe link refers to, for the landing page. */
@@ -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 };
});
+1 -1
View File
@@ -314,7 +314,7 @@ async function getWebhookSecret() {
/** The URL to paste into WAWP's webhook settings (events: message, session.status). */
async function webhookUrl() {
const base = String(process.env.BACKEND_URL || process.env.API_URL || 'http://localhost:5000').replace(/\/$/, '');
return `${base}/api/whatsapp/webhook?secret=${encodeURIComponent(await getWebhookSecret())}`;
return `${base}/api/v2/webhooks/whatsapp?secret=${encodeURIComponent(await getWebhookSecret())}`;
}
/** The sender's phone (27…) from a WAWP chat id, or null for groups, channels and LIDs. */
+1 -1
View File
@@ -114,7 +114,7 @@ const deleteWebhook = (id) => yocoRequest('delete', `/webhooks/${encodeURIComp
*/
function defaultWebhookUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/yoco` : '';
return base ? `${base}/api/v2/webhooks/yoco` : '';
}
/** Returns an error message if Yoco won't be able to reach the URL, otherwise null. */
+102
View File
@@ -46,6 +46,108 @@ const SUCCESSORS: Successor[] = [
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/copy-from$/, v2: m => `/api/v2/events/${m[1]}/copy-from` },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
// 1.51.16: incoming webhooks and links in sent messages (v1 still answered by the same code)
{ method: 'POST', v1: /^\/api\/webhooks\/(yoco|payfast|whatsapp)$/, v2: m => `/api/v2/webhooks/${m[1]}` },
{ method: 'POST', v1: /^\/api\/whatsapp\/webhook$/, v2: () => '/api/v2/webhooks/whatsapp' },
{ method: 'POST', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)\/one-click$/, v2: m => `/api/v2/unsubscribe/${m[1]}/one-click` },
// 1.51.15: privacy and admin
{ method: 'GET', v1: /^\/api\/privacy\/me$/, v2: () => '/api/v2/me/privacy' },
{ method: 'PUT', v1: /^\/api\/privacy\/me\/news$/, v2: () => '/api/v2/me/privacy/news' },
{ method: 'GET', v1: /^\/api\/privacy\/me\/export$/, v2: () => '/api/v2/me/privacy/export' },
{ method: 'POST', v1: /^\/api\/privacy\/me\/requests$/, v2: () => '/api/v2/me/privacy/requests' },
{ method: 'POST', v1: /^\/api\/privacy\/me\/notice\/accept$/, v2: () => '/api/v2/me/privacy/notice/accept' },
{ method: 'GET', v1: /^\/api\/privacy\/notice$/, v2: () => '/api/v2/privacy/notice' },
{ method: 'GET', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)$/, v2: m => `/api/v2/unsubscribe/${m[1]}` },
{ method: 'POST', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)$/, v2: m => `/api/v2/unsubscribe/${m[1]}` },
{ method: 'GET', v1: /^\/api\/privacy\/requests$/, v2: () => '/api/v2/privacy/requests' },
{ method: 'POST', v1: /^\/api\/privacy\/requests\/([^/]+)\/(complete|reject)$/, v2: m => `/api/v2/privacy/requests/${m[1]}/${m[2]}` },
{ method: 'GET', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
{ method: 'PUT', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
{ method: 'DELETE', v1: /^\/api\/privacy\/notice\/draft$/, v2: () => '/api/v2/privacy/notice/draft' },
{ method: 'POST', v1: /^\/api\/privacy\/notice\/(publish|builtin-reviewed)$/, v2: m => `/api/v2/privacy/notice/${m[1]}` },
{ method: 'GET', v1: /^\/api\/privacy\/operators$/, v2: () => '/api/v2/privacy/operators' },
{ method: 'POST', v1: /^\/api\/privacy\/operators$/, v2: () => '/api/v2/privacy/operators' },
{ method: 'PUT', v1: /^\/api\/privacy\/operators\/([^/]+)$/, v2: m => `/api/v2/privacy/operators/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/privacy\/operators\/([^/]+)$/, v2: m => `/api/v2/privacy/operators/${m[1]}` },
{ method: 'GET', v1: /^\/api\/privacy\/(consent-log|access-log|readiness|retention|breaches)$/, v2: m => `/api/v2/privacy/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/privacy\/retention$/, v2: () => '/api/v2/privacy/retention' },
{ method: 'POST', v1: /^\/api\/privacy\/retention\/run$/, v2: () => '/api/v2/privacy/retention/run' },
{ method: 'POST', v1: /^\/api\/privacy\/breaches$/, v2: () => '/api/v2/privacy/breaches' },
{ method: 'PUT', v1: /^\/api\/privacy\/breaches\/([^/]+)$/, v2: m => `/api/v2/privacy/breaches/${m[1]}` },
{ method: 'GET', v1: /^\/api\/admin\/(audit-log|jobs|impersonation-sessions)$/, v2: m => `/api/v2/admin/${m[1]}` },
{ method: 'POST', v1: /^\/api\/admin\/jobs\/([^/]+)\/run$/, v2: m => `/api/v2/admin/jobs/${m[1]}/run` },
{ method: 'POST', v1: /^\/api\/admin\/impersonate\/(end|log)$/, v2: m => `/api/v2/me/impersonation/${m[1]}` },
{ method: 'GET', v1: /^\/api\/admin\/impersonate\/current$/, v2: () => '/api/v2/me/impersonation' },
{ method: 'POST', v1: /^\/api\/admin\/impersonate\/([^/]+)$/, v2: m => `/api/v2/admin/impersonate/${m[1]}` },
{ method: 'GET', v1: /^\/api\/settings\/needs-setup$/, v2: () => '/api/v2/setup/needed' },
{ method: 'GET', v1: /^\/api\/settings\/all$/, v2: () => '/api/v2/settings/all' },
{ method: 'GET', v1: /^\/api\/settings\/?$/, v2: () => '/api/v2/settings' },
{ method: 'PUT', v1: /^\/api\/settings\/?$/, v2: () => '/api/v2/settings' },
{ method: 'POST', v1: /^\/api\/settings\/test-smtp$/, v2: () => '/api/v2/settings/test-smtp' },
{ method: 'POST', v1: /^\/api\/setup\/register$/, v2: () => '/api/v2/setup/register' },
{ method: 'POST', v1: /^\/api\/setup\/?$/, v2: () => '/api/v2/setup' },
{ method: 'GET', v1: /^\/api\/hub\/(status|notices)$/, v2: m => `/api/v2/hub/${m[1]}` },
{ method: 'POST', v1: /^\/api\/hub\/(connect|heartbeat|update-request|update-snooze)$/, v2: m => `/api/v2/hub/${m[1]}` },
{ method: 'POST', v1: /^\/api\/hub\/update-request\/cancel$/, v2: () => '/api/v2/hub/update-request/cancel' },
{ method: 'GET', v1: /^\/api\/outbound-webhooks\/?$/, v2: () => '/api/v2/outbound-webhooks' },
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/?$/, v2: () => '/api/v2/outbound-webhooks' },
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/deliveries\/([^/]+)\/resend$/, v2: m => `/api/v2/outbound-webhook-deliveries/${m[1]}/resend` },
{ method: 'PUT', v1: /^\/api\/outbound-webhooks\/([^/]+)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/outbound-webhooks\/([^/]+)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}` },
{ method: 'POST', v1: /^\/api\/outbound-webhooks\/([^/]+)\/(rotate-secret|test)$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}/${m[2]}` },
{ method: 'GET', v1: /^\/api\/outbound-webhooks\/([^/]+)\/deliveries$/, v2: m => `/api/v2/outbound-webhooks/${m[1]}/deliveries` },
{ method: 'GET', v1: /^\/api\/backups\/?$/, v2: () => '/api/v2/backups' },
{ method: 'POST', v1: /^\/api\/backups\/run$/, v2: () => '/api/v2/backups/run' },
{ method: 'GET', v1: /^\/api\/backups\/([^/]+)\/download$/, v2: m => `/api/v2/backups/${m[1]}/download` },
{ method: 'GET', v1: /^\/api\/(yoco|payfast)\/config$/, v2: m => `/api/v2/payments/${m[1]}/config` },
{ method: 'PUT', v1: /^\/api\/(yoco|payfast)\/config$/, v2: m => `/api/v2/payments/${m[1]}/config` },
{ method: 'GET', v1: /^\/api\/yoco\/webhooks$/, v2: () => '/api/v2/payments/yoco/webhooks' },
{ method: 'POST', v1: /^\/api\/yoco\/webhooks$/, v2: () => '/api/v2/payments/yoco/webhooks' },
{ method: 'DELETE', v1: /^\/api\/yoco\/webhooks\/([^/]+)$/, v2: m => `/api/v2/payments/yoco/webhooks/${m[1]}` },
{ method: 'POST', v1: /^\/api\/payfast\/test$/, v2: () => '/api/v2/payments/payfast/test' },
// 1.51.14: people and messaging
{ method: 'POST', v1: /^\/api\/users\/login\/2fa\/resend$/, v2: () => '/api/v2/auth/login/code/resend' },
{ method: 'POST', v1: /^\/api\/users\/forgot$/, v2: () => '/api/v2/auth/password/forgot' },
{ method: 'POST', v1: /^\/api\/users\/reset$/, v2: () => '/api/v2/auth/password/reset' },
{ method: 'POST', v1: /^\/api\/users\/activate$/, v2: () => '/api/v2/auth/activate' },
{ method: 'POST', v1: /^\/api\/users\/revoke-sessions$/, v2: () => '/api/v2/me/sessions/revoke' },
{ method: 'POST', v1: /^\/api\/users\/close-account$/, v2: () => '/api/v2/me/close' },
{ method: 'GET', v1: /^\/api\/users\/activity$/, v2: () => '/api/v2/me/activity' },
{ method: 'POST', v1: /^\/api\/users\/verify-password$/, v2: () => '/api/v2/me/verify-password' },
{ method: 'POST', v1: /^\/api\/users\/2fa\/(enable|confirm|disable)$/, v2: m => `/api/v2/me/two-factor/${m[1]}` },
{ method: 'GET', v1: /^\/api\/users\/check-exists$/, v2: () => '/api/v2/users/exists' },
{ method: 'POST', v1: /^\/api\/users\/([^/]+)\/revoke-sessions$/, v2: m => `/api/v2/users/${m[1]}/sessions/revoke` },
{ method: 'POST', v1: /^\/api\/users\/([^/]+)\/anonymize$/, v2: m => `/api/v2/users/${m[1]}/anonymize` },
{ method: 'GET', v1: /^\/api\/users\/?$/, v2: () => '/api/v2/users' },
// (/api/users/profile is /api/v2/me — see 1.51.2 below.)
{ method: 'GET', v1: /^\/api\/users\/(?!profile$)([^/]+)$/, v2: m => `/api/v2/users/${m[1]}` },
{ method: 'PUT', v1: /^\/api\/users\/(?!profile$)([^/]+)$/, v2: m => `/api/v2/users/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/users\/([^/]+)$/, v2: m => `/api/v2/users/${m[1]}` },
{ method: 'GET', v1: /^\/api\/events\/admin\/all$/, v2: () => '/api/v2/admin/events' },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/(email|whatsapp)-attendees$/, v2: m => `/api/v2/events/${m[1]}/${m[2]}-attendees` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/(email|whatsapp)-attendees\/schedule$/, v2: m => `/api/v2/events/${m[1]}/${m[2]}-attendees/schedule` },
{ method: 'POST', v1: /^\/api\/broadcasts\/(preview|send|schedule)$/, v2: m => `/api/v2/broadcasts/email/${m[1]}` },
{ method: 'POST', v1: /^\/api\/whatsapp-broadcasts\/(preview|send|schedule)$/, v2: m => `/api/v2/broadcasts/whatsapp/${m[1]}` },
{ method: 'GET', v1: /^\/api\/scheduled-emails\/?$/, v2: () => '/api/v2/scheduled-messages' },
{ method: 'PATCH', v1: /^\/api\/scheduled-emails\/([^/]+)$/, v2: m => `/api/v2/scheduled-messages/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/scheduled-emails\/([^/]+)$/, v2: m => `/api/v2/scheduled-messages/${m[1]}` },
{ method: 'POST', v1: /^\/api\/automations\/schedule$/, v2: () => '/api/v2/automations/schedule' },
{ method: 'GET', v1: /^\/api\/whatsapp\/(config|status|qr)$/, v2: m => `/api/v2/whatsapp/${m[1]}` },
{ method: 'POST', v1: /^\/api\/whatsapp\/config$/, v2: () => '/api/v2/whatsapp/config' },
{ method: 'POST', v1: /^\/api\/whatsapp\/create-instance$/, v2: () => '/api/v2/whatsapp/instance' },
{ method: 'POST', v1: /^\/api\/whatsapp\/delete-instance$/, v2: () => '/api/v2/whatsapp/instance' },
{ method: 'POST', v1: /^\/api\/whatsapp\/(request-code|logout|start|restart)$/, v2: m => `/api/v2/whatsapp/${m[1]}` },
{ method: 'GET', v1: /^\/api\/banner\/?$/, v2: () => '/api/v2/banner' },
{ method: 'POST', v1: /^\/api\/banner\/?$/, v2: () => '/api/v2/banner' },
{ method: 'GET', v1: /^\/api\/saved-filters\/?$/, v2: () => '/api/v2/saved-filters' },
{ method: 'POST', v1: /^\/api\/saved-filters\/?$/, v2: () => '/api/v2/saved-filters' },
{ method: 'PUT', v1: /^\/api\/saved-filters\/([^/]+)$/, v2: m => `/api/v2/saved-filters/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/saved-filters\/([^/]+)$/, v2: m => `/api/v2/saved-filters/${m[1]}` },
{ method: 'GET', v1: /^\/api\/dashboard\/(today|search)$/, v2: m => `/api/v2/dashboard/${m[1]}` },
{ method: 'GET', v1: /^\/api\/stats\/(staff|supervisor|admin|overview)$/, v2: m => `/api/v2/stats/${m[1]}` },
{ method: 'POST', v1: /^\/api\/analytics\/track$/, v2: () => '/api/v2/analytics/track' },
{ method: 'GET', v1: /^\/api\/analytics\/funnel$/, v2: () => '/api/v2/analytics/funnel' },
{ method: 'POST', v1: /^\/api\/reports\/(pdf|excel|email|whatsapp)$/, v2: m => `/api/v2/reports/${m[1]}` },
// 1.51.13: kids check-in and volunteer rosters
{ method: 'GET', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
{ method: 'POST', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
+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 };
+3
View File
@@ -17,8 +17,11 @@ import './resources/registrationsManage';
import './resources/money';
import './resources/attendance';
import './resources/kidsVolunteers';
import './resources/peopleMessaging';
import './resources/adminPrivacy';
import './resources/checkin';
import './resources/auth';
import './resources/ai';
const pkg = require('../../package.json') as { version: string };
+52
View File
@@ -0,0 +1,52 @@
/**
* Incoming webhooks and the links in sent messages on v2 (1.51.16). These aren't JSON API calls,
* so they're plain Express routes, mounted by app.js beside the v2 router:
*
* - `webhooksRouter` at /api/v2/webhooks: Yoco, PayFast and WAWP (WhatsApp) post here. New
* provider registrations use these URLs; the v1 paths (/api/webhooks/…, /api/whatsapp/webhook)
* are still answered by the same handlers, because providers may not follow a redirect on POST.
* - `linksRouter` at /api/v2: the one-click unsubscribe link (RFC 8058) from email headers.
* - `legacyLinkRedirects` at /api: links in messages already sent (calendar files, opening a
* one-click unsubscribe link) answer 308 with their v2 address. A mail app's one-click POST to
* the v1 address is still handled there, like a webhook.
*/
import express, { Router, type RequestHandler } from 'express';
/* eslint-disable @typescript-eslint/no-explicit-any -- the handlers are JavaScript */
const { handleYocoWebhook } = require('../controllers/webhookController') as { handleYocoWebhook: RequestHandler };
const { handleProviderWebhook } = require('../payments/webhook') as { handleProviderWebhook(id: string): RequestHandler };
const { handleWebhook: handleWhatsappWebhook } = require('../controllers/whatsappController') as { handleWebhook: RequestHandler };
const privacy = require('../controllers/privacyController') as { oneClickRedirect: RequestHandler; oneClickUnsubscribe: RequestHandler };
/** Paths whose request body is read raw (signatures are checked over the exact bytes). */
export const RAW_BODY_PREFIXES = ['/api/webhooks', '/api/v2/webhooks'];
/** WAWP sends JSON (and ?secret=…); on the webhook paths its body arrives raw. */
export const whatsappWebhook: RequestHandler[] = [(req: any, _res, next) => {
try { req.body = req.rawBody ? JSON.parse(req.rawBody) : {}; } catch { req.body = {}; }
next();
}, handleWhatsappWebhook];
export const webhooksRouter = Router();
webhooksRouter.post('/yoco', handleYocoWebhook);
webhooksRouter.post('/payfast', handleProviderWebhook('payfast'));
webhooksRouter.post('/whatsapp', ...whatsappWebhook);
export const linksRouter = Router();
linksRouter.get('/unsubscribe/:token/one-click', privacy.oneClickRedirect);
linksRouter.post('/unsubscribe/:token/one-click', express.urlencoded({ extended: false }), privacy.oneClickUnsubscribe);
/** v1 links in messages already sent → their v2 address (308 keeps the method). */
const REDIRECTS: { method: string; v1: RegExp; v2: (m: RegExpMatchArray) => string }[] = [
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/ics$/, v2: m => `/api/v2/events/${m[1]}/calendar.ics` },
{ method: 'GET', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)\/one-click$/, v2: m => `/api/v2/unsubscribe/${m[1]}/one-click` },
];
export const legacyLinkRedirects: RequestHandler = (req, res, next) => {
const [path, query] = req.originalUrl.split('?');
for (const r of REDIRECTS) {
const m = req.method === r.method ? path.match(r.v1) : null;
if (m) return res.redirect(308, r.v2(m) + (query ? `?${query}` : ''));
}
next();
};
+126
View File
@@ -0,0 +1,126 @@
/**
* Privacy and admin on v2 (1.51.15): my privacy (news consent, my data, requests, the notice),
* unsubscribe links, the privacy admin (requests, notice drafts, operators, logs, retention,
* breaches), the audit log, jobs, impersonation, site settings and first-run set-up, the Hub,
* outbound webhooks and backups. Each route runs the v1 handler (viaV1.ts) behind the same
* access checks as v1.
*/
import { z } from 'zod';
import { idParams } from '../schemas';
import { v1Route } from '../viaV1';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const rateLimit = require('express-rate-limit') as (opts: object) => any;
const { campusIndependent } = require('../../utils/campusScope') as { campusIndependent(reason: string): any };
const privacy = { ...require('../../controllers/privacyController'), ...require('../../controllers/privacyAdminController') };
const audit = require('../../controllers/adminAuditController');
const imp = require('../../controllers/impersonationController');
const jobs = require('../../controllers/jobsController');
const settings = require('../../controllers/settingsController');
const hub = require('../../controllers/hubController');
const hooks = require('../../controllers/outboundWebhookController');
const backups = require('../../controllers/backupController');
const loose = z.looseObject({});
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
const tokenParams = z.object({ token: z.string().min(1).max(500) });
const exportLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false });
// ─── My privacy ──────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/me/privacy', tag: 'Privacy', summary: 'My privacy settings', description: 'News consent, open requests, and whether there\'s a privacy notice to accept.', access: 'user', v1: privacy.getMyPrivacy });
v1Route({ method: 'put', path: '/me/privacy/news', tag: 'Privacy', summary: 'Choose whether to get news', access: 'user', body: loose, v1: privacy.updateMyNews });
v1Route({ method: 'get', path: '/me/privacy/export', tag: 'Privacy', summary: 'Download everything held about me', description: '`format=pdf` or JSON (the default).', access: 'user', middleware: [exportLimiter], query: filters('format'), file: { contentType: 'application/json' }, v1: privacy.exportMyData });
v1Route({ method: 'post', path: '/me/privacy/requests', tag: 'Privacy', summary: 'Ask for my data to be corrected or deleted', access: 'user', status: 201, body: loose, v1: privacy.createMyRequest });
v1Route({ method: 'post', path: '/me/privacy/notice/accept', tag: 'Privacy', summary: 'Accept the privacy notice', access: 'user', body: loose, v1: privacy.acceptMyNotice });
v1Route({ method: 'get', path: '/privacy/notice', tag: 'Privacy', summary: 'The published privacy notice', access: 'public', query: filters('version'), v1: privacy.getNotice });
v1Route({ method: 'get', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'What an unsubscribe link is for', description: 'The token in the link is the credential.', access: 'public', params: tokenParams, v1: privacy.getUnsubscribe });
v1Route({ method: 'post', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'Unsubscribe with a link', access: 'public', params: tokenParams, body: loose, v1: privacy.postUnsubscribe });
// ─── Privacy admin ───────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/privacy/requests', tag: 'Privacy', summary: 'Data requests', description: '`status=open` (default), `closed` or `all`.', access: 'admin', query: filters('status', 'search', 'page', 'limit'), v1: privacy.listRequests });
v1Route({ method: 'post', path: '/privacy/requests/:id/complete', tag: 'Privacy', summary: 'Mark a data request done', access: 'admin', params: idParams, body: loose, v1: privacy.completeRequest });
v1Route({ method: 'post', path: '/privacy/requests/:id/reject', tag: 'Privacy', summary: 'Turn down a data request', access: 'admin', params: idParams, body: loose, v1: privacy.rejectRequest });
v1Route({ method: 'get', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'The privacy notice being drafted', access: 'admin', v1: privacy.getNoticeDraft });
v1Route({ method: 'put', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Save the draft notice', access: 'admin', body: loose, v1: privacy.saveNoticeDraft });
v1Route({ method: 'delete', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Throw the draft away', access: 'admin', v1: privacy.discardNoticeDraft });
v1Route({ method: 'post', path: '/privacy/notice/publish', tag: 'Privacy', summary: 'Publish the draft notice', description: 'Everyone is asked to accept it at their next sign-in.', access: 'admin', body: loose, v1: privacy.publishNotice });
v1Route({ method: 'post', path: '/privacy/notice/builtin-reviewed', tag: 'Privacy', summary: 'Mark the built-in notice as reviewed', access: 'admin', body: loose, v1: privacy.markBuiltInReviewed });
v1Route({ method: 'get', path: '/privacy/operators', tag: 'Privacy', summary: 'Who processes personal information for the church', access: 'admin', v1: privacy.listOperators });
v1Route({ method: 'post', path: '/privacy/operators', tag: 'Privacy', summary: 'Add an operator', access: 'admin', status: 201, body: loose, v1: privacy.createOperator });
v1Route({ method: 'put', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Change an operator', access: 'admin', params: idParams, body: loose, v1: privacy.updateOperator });
v1Route({ method: 'delete', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Remove an operator', access: 'admin', params: idParams, v1: privacy.deleteOperator });
v1Route({ method: 'get', path: '/privacy/consent-log', tag: 'Privacy', summary: 'Consent given and withdrawn', access: 'admin', query: filters('type', 'page', 'q'), v1: privacy.consentLog });
v1Route({ method: 'get', path: '/privacy/access-log', tag: 'Privacy', summary: 'Who looked at sensitive information', access: 'admin', query: filters('page', 'action', 'sensitive', 'actorId', 'from', 'to'), v1: privacy.accessLog });
v1Route({ method: 'get', path: '/privacy/readiness', tag: 'Privacy', summary: 'POPIA checklist', access: 'admin', v1: privacy.getReadiness });
v1Route({ method: 'get', path: '/privacy/retention', tag: 'Privacy', summary: 'How long information is kept', access: 'admin', v1: privacy.getRetention });
v1Route({ method: 'put', path: '/privacy/retention', tag: 'Privacy', summary: 'Change how long information is kept', access: 'admin', body: loose, v1: privacy.saveRetention });
v1Route({ method: 'post', path: '/privacy/retention/run', tag: 'Privacy', summary: 'Apply the retention rules now', access: 'admin', body: loose, v1: privacy.runRetentionNow });
v1Route({ method: 'get', path: '/privacy/breaches', tag: 'Privacy', summary: 'Recorded data breaches', access: 'admin', v1: privacy.listBreaches });
v1Route({ method: 'post', path: '/privacy/breaches', tag: 'Privacy', summary: 'Record a data breach', access: 'admin', status: 201, body: loose, v1: privacy.createBreach });
v1Route({ method: 'put', path: '/privacy/breaches/:id', tag: 'Privacy', summary: 'Update a data breach', access: 'admin', params: idParams, body: loose, v1: privacy.updateBreach });
// ─── Admin ───────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/admin/audit-log', tag: 'Admin', summary: 'The audit log', access: 'admin', query: filters('page', 'limit', 'actorId', 'action', 'role', 'from', 'to', 'search'), v1: audit.listAuditLog });
v1Route({ method: 'get', path: '/admin/jobs', tag: 'Admin', summary: 'Background jobs', access: 'admin', v1: jobs.listJobs });
v1Route({ method: 'post', path: '/admin/jobs/:name/run', tag: 'Admin', summary: 'Run a background job now', access: 'admin', params: z.object({ name: z.string().min(1).max(100) }), body: loose, v1: jobs.runJobNow });
v1Route({ method: 'get', path: '/admin/impersonation-sessions', tag: 'Admin', summary: 'Times admins viewed the site as someone else', access: 'admin', query: filters('limit'), v1: imp.listSessions });
v1Route({ method: 'post', path: '/admin/impersonate/:userId', tag: 'Admin', summary: 'View the site as someone (read-only)', description: 'Answers a token for a read-only session, logged.', access: 'admin', params: z.object({ userId: z.string().min(1).max(64) }), body: loose, v1: imp.startImpersonation });
v1Route({ method: 'get', path: '/me/impersonation', tag: 'Admin', summary: 'The viewing-as session I\'m in, if any', access: 'user', v1: imp.currentImpersonation });
v1Route({ method: 'post', path: '/me/impersonation/end', tag: 'Admin', summary: 'Stop viewing as someone', access: 'user', body: loose, v1: imp.endImpersonation });
v1Route({ method: 'post', path: '/me/impersonation/log', tag: 'Admin', summary: 'Record a page viewed while viewing as someone', access: 'user', body: loose, v1: imp.logPageView });
// ─── Settings and first-run set-up ───────────────────────────────────────────
v1Route({ method: 'get', path: '/settings', tag: 'Site', summary: 'Public site settings', description: 'Name, branding, contact details and what\'s switched on.', access: 'public', v1: settings.getSettings });
v1Route({ method: 'get', path: '/settings/all', tag: 'Site', summary: 'Every setting', description: 'Admins. Secrets are masked.', access: 'admin', v1: settings.getAllSettings });
v1Route({ method: 'put', path: '/settings', tag: 'Site', summary: 'Change settings', access: 'admin', body: loose, v1: settings.updateSettings });
v1Route({ method: 'post', path: '/settings/test-smtp', tag: 'Site', summary: 'Send a test email', access: 'admin', body: loose, v1: settings.testSmtp });
v1Route({ method: 'get', path: '/setup/needed', tag: 'Site', summary: 'Does the site still need setting up?', access: 'public', v1: settings.needsSetup });
v1Route({ method: 'post', path: '/setup/register', tag: 'Site', summary: 'Create the first admin', description: 'Only while no account exists.', access: 'public', body: loose, v1: settings.setupRegister });
v1Route({ method: 'post', path: '/setup', tag: 'Site', summary: 'Finish first-run set-up', access: 'admin', body: loose, v1: settings.runSetup });
// ─── Hub ─────────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/hub/status', tag: 'Site', summary: 'The connection to the provider\'s Hub', access: 'admin', v1: hub.getStatus });
v1Route({ method: 'post', path: '/hub/connect', tag: 'Site', summary: 'Connect to the Hub', access: 'admin', body: loose, v1: hub.connect });
v1Route({ method: 'post', path: '/hub/heartbeat', tag: 'Site', summary: 'Check in with the Hub now', access: 'admin', body: loose, v1: hub.heartbeatNow });
v1Route({ method: 'post', path: '/hub/update-request', tag: 'Site', summary: 'Ask for the update to be installed', access: 'admin', body: loose, v1: hub.requestUpdate });
v1Route({ method: 'post', path: '/hub/update-request/cancel', tag: 'Site', summary: 'Withdraw the update request', access: 'admin', body: loose, v1: hub.cancelUpdateRequest });
v1Route({ method: 'post', path: '/hub/update-snooze', tag: 'Site', summary: 'Remind me about the update later', access: 'admin', body: loose, v1: hub.snoozeUpdate });
v1Route({
method: 'get', path: '/hub/notices', tag: 'Site', summary: 'Notices from the provider',
access: 'supervisor', campusScope: 'campusIndependent (notices from the provider are for the whole site)', middleware: [campusIndependent('notices from the provider are for the whole site')], v1: hub.getNotices,
});
// ─── Outbound webhooks ───────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Webhooks the site sends', access: 'admin', v1: hooks.list });
v1Route({ method: 'post', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Add a webhook', description: 'Answers its signing secret once.', access: 'admin', status: 201, body: loose, v1: hooks.create });
v1Route({ method: 'post', path: '/outbound-webhook-deliveries/:id/resend', tag: 'Webhooks', summary: 'Send a delivery again', access: 'admin', params: idParams, body: loose, v1: hooks.resend });
v1Route({ method: 'patch', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Change a webhook', access: 'admin', params: idParams, body: loose, v1: hooks.update });
v1Route({ method: 'delete', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Remove a webhook', access: 'admin', params: idParams, v1: hooks.remove });
v1Route({ method: 'post', path: '/outbound-webhooks/:id/rotate-secret', tag: 'Webhooks', summary: 'Give a webhook a new signing secret', access: 'admin', params: idParams, body: loose, v1: hooks.rotateSecret });
v1Route({ method: 'post', path: '/outbound-webhooks/:id/test', tag: 'Webhooks', summary: 'Send a test delivery', access: 'admin', params: idParams, body: loose, v1: hooks.test });
v1Route({ method: 'get', path: '/outbound-webhooks/:id/deliveries', tag: 'Webhooks', summary: 'A webhook\'s recent deliveries', access: 'admin', params: idParams, query: filters('page', 'limit'), v1: hooks.deliveries });
// ─── Backups ─────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/backups', tag: 'Admin', summary: 'Database backups', access: 'admin', v1: backups.getBackups });
v1Route({ method: 'post', path: '/backups/run', tag: 'Admin', summary: 'Back up the database now', access: 'admin', body: loose, v1: backups.triggerBackup });
v1Route({ method: 'get', path: '/backups/:filename/download', tag: 'Admin', summary: 'Download a backup', access: 'admin', params: z.object({ filename: z.string().min(1).max(200) }), file: { contentType: 'application/octet-stream' }, v1: backups.downloadBackup });
// ─── Yoco and PayFast settings (admins; their incoming webhooks are separate) ─
const yoco = require('../../controllers/yocoController');
const payfast = require('../../controllers/payfastController');
v1Route({ method: 'get', path: '/payments/yoco/config', tag: 'Payments', summary: 'Yoco settings', description: 'Keys are masked.', access: 'admin', v1: yoco.getConfig });
v1Route({ method: 'put', path: '/payments/yoco/config', tag: 'Payments', summary: 'Change Yoco settings', access: 'admin', body: loose, v1: yoco.updateConfig });
v1Route({ method: 'get', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Webhooks registered with Yoco', access: 'admin', v1: yoco.getWebhooks });
v1Route({ method: 'post', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Register a webhook with Yoco', access: 'admin', body: loose, v1: yoco.createWebhook });
v1Route({ method: 'delete', path: '/payments/yoco/webhooks/:id', tag: 'Payments', summary: 'Remove a webhook from Yoco', access: 'admin', params: z.object({ id: z.string().min(1).max(200) }), v1: yoco.removeWebhook });
v1Route({ method: 'get', path: '/payments/payfast/config', tag: 'Payments', summary: 'PayFast settings', description: 'The passphrase is masked.', access: 'admin', v1: payfast.getConfig });
v1Route({ method: 'put', path: '/payments/payfast/config', tag: 'Payments', summary: 'Change PayFast settings', access: 'admin', body: loose, v1: payfast.updateConfig });
v1Route({ method: 'post', path: '/payments/payfast/test', tag: 'Payments', summary: 'Check the PayFast details work', access: 'admin', body: loose, v1: payfast.testConnection });
+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)) };
},
});
+24 -4
View File
@@ -25,20 +25,40 @@ const toMe = (u: MeRow): z.input<typeof me> => ({
notificationPreference: u.notificationPreference, homeCampusId: u.homeCampusId ?? null, createdAt: iso(u.createdAt),
});
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const { requiresTwoFactor } = require('../../utils/twoFactor') as { requiresTwoFactor(user: object): boolean };
const privacyNotice = require('../../utils/privacyNotice') as { noticePromptPending(u: { noticeVersionAccepted: unknown }): Promise<false | 'first' | 'updated'> };
const campusScope = require('../../utils/campusScope') as { scopeFor(req: unknown): Promise<{ campusIds: string[] | null } | null> };
export const getMe = defineRoute({
method: 'get',
path: '/me',
tag: 'Me',
summary: 'The signed-in person',
description: 'With what the app asks after sign-in (`newsPromptPending`, `noticePromptPending`), whether two-step sign-in is required for their role, and the campuses they\'re limited to (`campusScope`, null = every campus). Viewing as someone else never prompts.',
access: 'user',
response: single(me),
async handler({ user }) {
response: single(me.extend({
twoFactorRequired: z.boolean().describe('Their role must use two-step sign-in'),
newsPromptPending: z.boolean(),
noticePromptPending: z.union([z.literal(false), z.enum(['first', 'updated'])]),
campusScope: z.array(id).nullable(),
})),
async handler({ user, req }) {
const u = await prisma.user.findUnique({
where: { id: user!.id },
select: { id: true, name: true, email: true, phoneNumber: true, role: true, twoFactorEnabled: true, notificationPreference: true, homeCampusId: true, createdAt: true },
select: { id: true, name: true, email: true, phoneNumber: true, role: true, twoFactorEnabled: true, twoFactorMethod: true, notificationPreference: true, homeCampusId: true, createdAt: true, newsAskedAt: true, noticeVersionAccepted: true },
});
if (!u) throw new ApiError('UNAUTHENTICATED', 'User not found');
return { data: toMe(u) };
const impersonating = !!(req as any).impersonation;
return {
data: {
...toMe(u),
twoFactorRequired: requiresTwoFactor(u),
newsPromptPending: !u.newsAskedAt && !impersonating,
noticePromptPending: impersonating ? false as const : await privacyNotice.noticePromptPending({ noticeVersionAccepted: u.noticeVersionAccepted }),
campusScope: (await campusScope.scopeFor(req))?.campusIds ?? null,
},
};
},
});
+1 -1
View File
@@ -115,7 +115,7 @@ v1Route({
});
v1Route({
method: 'post', path: '/registrations/:id/payment-link', tag: 'Payments', summary: 'Make a link to pay',
description: 'Supervisors: a checkout for `amount` (part of what\x27s owed) for someone else to pay. Answers `redirectUrl` (the link) — or `priceUpdated` when an early-bird price ran out.',
description: 'Supervisors: a checkout for `amount` (part of what\'s owed) for someone else to pay. Answers `redirectUrl` (the link) — or `priceUpdated` when an early-bird price ran out.',
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
body: z.looseObject({ amount: z.union([z.number(), z.string()]).optional() }), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), v1: pay.createCheckout,
});
+138
View File
@@ -0,0 +1,138 @@
/**
* People and messaging on v2 (1.51.14): the rest of accounts (password reset, activation,
* two-step sign-in, sessions, closing an account), messaging attendees, email and WhatsApp
* broadcasts, scheduled messages, automations, the site banner, saved filters, the dashboards,
* stats, analytics and report files. Each route runs the v1 handler (viaV1.ts) behind the same
* access and campus checks as v1.
*/
import { z } from 'zod';
import type { Request } from 'express';
import { id, idParams } from '../schemas';
import { v1Route } from '../viaV1';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const rateLimit = require('express-rate-limit') as (opts: object) => any;
const { loginLimiter } = require('../../middleware/authMiddleware') as { loginLimiter: any };
const cs = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage'; from?: (req: any) => unknown }): any;
scopeBroadcast(opts: { freeformField: string }): any;
scopeList: any;
unscopedOnly: any;
campusIndependent(reason: string): any;
};
const users = require('../../controllers/userController');
const events = require('../../controllers/eventController');
const wa = require('../../controllers/whatsappController');
const bc = require('../../controllers/broadcastController');
const wbc = require('../../controllers/whatsappBroadcastController');
const sched = require('../../controllers/scheduledEmailsController');
const auto = require('../../controllers/automationController');
const banner = require('../../controllers/bannerController');
const stats = require('../../controllers/statsController');
const analytics = require('../../controllers/analyticsController');
const reports = require('../../controllers/reportController');
// These two keep their handlers in the route file; v2 runs the v1 router with the v1 path.
const savedFilterRouter = require('../../routes/savedFilterRoutes');
const dashboardRouter = require('../../routes/dashboardRoutes');
const loose = z.looseObject({});
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
/** The query string of the v2 request, for a v1 router's URL. */
const qs = (req: Request) => (req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '');
// ─── Accounts ────────────────────────────────────────────────────────────────
v1Route({ method: 'post', path: '/auth/login/code/resend', tag: 'Accounts', summary: 'Send the sign-in code again', access: 'public', middleware: [loginLimiter], body: loose, v1: users.resendLoginTwoFactor });
v1Route({ method: 'post', path: '/auth/password/forgot', tag: 'Accounts', summary: 'Email me a link to reset my password', description: 'Always the same answer, so it doesn\'t say whether an account has that email. Rate-limited like sign-in.', access: 'public', middleware: [loginLimiter], body: z.looseObject({ email: z.string().max(200) }), v1: users.requestPasswordReset });
v1Route({ method: 'post', path: '/auth/password/reset', tag: 'Accounts', summary: 'Set a new password with a reset link', access: 'public', body: loose, v1: users.resetPassword });
v1Route({ method: 'post', path: '/auth/activate', tag: 'Accounts', summary: 'Activate an account with its link', access: 'public', body: loose, v1: users.activateAccount });
v1Route({ method: 'post', path: '/me/sessions/revoke', tag: 'Me', summary: 'Sign out everywhere else', access: 'user', body: loose, v1: users.revokeMySession });
v1Route({ method: 'post', path: '/me/close', tag: 'Me', summary: 'Close my account', description: 'Needs the password. Personal details are removed; payment records stay, anonymised.', access: 'user', body: loose, v1: users.closeAccount });
v1Route({ method: 'get', path: '/me/activity', tag: 'Me', summary: 'Recent sign-ins and changes to my account', access: 'user', v1: users.getMyActivity });
v1Route({ method: 'post', path: '/me/verify-password', tag: 'Me', summary: 'Check my password (before a sensitive change)', access: 'user', middleware: [loginLimiter], body: loose, v1: users.verifyPassword });
v1Route({ method: 'post', path: '/me/two-factor/enable', tag: 'Me', summary: 'Start turning on two-step sign-in', description: 'Sends a code to confirm with.', access: 'user', middleware: [loginLimiter], body: loose, v1: users.startEnableTwoFactor });
v1Route({ method: 'post', path: '/me/two-factor/confirm', tag: 'Me', summary: 'Confirm two-step sign-in with the code', access: 'user', middleware: [loginLimiter], body: loose, v1: users.confirmEnableTwoFactor });
v1Route({ method: 'post', path: '/me/two-factor/disable', tag: 'Me', summary: 'Turn off two-step sign-in', access: 'user', middleware: [loginLimiter], body: loose, v1: users.disableTwoFactor });
v1Route({
method: 'get', path: '/users/exists', tag: 'Accounts', summary: 'Is there an account with this email or phone?',
access: 'supervisor', campusScope: 'campusIndependent (accounts are shared across campuses)', middleware: [cs.campusIndependent('accounts are shared across campuses')],
query: filters('email', 'phone', 'phoneNumber'), v1: users.checkUserExists,
});
v1Route({
method: 'get', path: '/users', tag: 'Accounts', summary: 'Every account',
description: 'Paged (`page`, `limit`) and searchable (`q`, `role`, `campus`, …) — see v1 for every filter.',
access: 'supervisor', campusScope: 'campusIndependent (accounts are shared across campuses)', middleware: [cs.campusIndependent('accounts are shared across campuses')],
query: filters('page', 'limit', 'search', 'role', 'isActive', 'campus'), v1: users.getUsers,
});
v1Route({ method: 'get', path: '/users/:id', tag: 'Accounts', summary: 'One account', access: 'admin', params: idParams, v1: users.getUserById });
v1Route({ method: 'patch', path: '/users/:id', tag: 'Accounts', summary: 'Change an account', description: 'Name, contact details, role, campus limits, active.', access: 'admin', params: idParams, body: loose, v1: users.updateUser });
v1Route({ method: 'delete', path: '/users/:id', tag: 'Accounts', summary: 'Delete an account', access: 'admin', params: idParams, v1: users.deleteUser });
v1Route({ method: 'post', path: '/users/:id/sessions/revoke', tag: 'Accounts', summary: 'Sign someone out everywhere', access: 'admin', params: idParams, body: loose, v1: users.adminRevokeUserSessions });
v1Route({ method: 'post', path: '/users/:id/anonymize', tag: 'Accounts', summary: 'Anonymise an account', description: 'Admins. Personal details are removed for good; payment records stay.', access: 'admin', params: idParams, body: loose, v1: users.anonymizeUser });
// ─── Messaging an event's people ─────────────────────────────────────────────
const toEvent = { campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()] };
v1Route({
method: 'get', path: '/event-listing', tag: 'Events', summary: 'Upcoming public events in full',
description: 'What the site\'s event lists show (ticket options, prices, early-bird tiers), not paged. `campus` (id or slug) and `series` (slug) narrow it. For a paged, slimmer list use GET /events.',
access: 'public', query: filters('campus', 'series'), v1: events.getEvents,
});
v1Route({ method: 'get', path: '/admin/events', tag: 'Events (staff)', summary: 'Every event, including deactivated ones', access: 'admin', v1: events.getAllEvents });
v1Route({ method: 'post', path: '/events/:id/email-attendees', tag: 'Messaging', summary: 'Email an event\'s people', description: 'Filtered by status or chosen people; news consent is checked.', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.emailEventAttendees });
v1Route({ method: 'post', path: '/events/:id/email-attendees/schedule', tag: 'Messaging', summary: 'Schedule an email to an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.scheduleEmailEventAttendees });
v1Route({ method: 'post', path: '/events/:id/whatsapp-attendees', tag: 'Messaging', summary: 'WhatsApp an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.whatsappEventAttendees });
v1Route({ method: 'post', path: '/events/:id/whatsapp-attendees/schedule', tag: 'Messaging', summary: 'Schedule a WhatsApp to an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.scheduleWhatsappEventAttendees });
for (const [channel, field, ctrl] of [['email', 'emails', bc], ['whatsapp', 'phones', wbc]] as const) {
const scope = { campusScope: `scopeBroadcast(freeform ${field})`, middleware: [cs.scopeBroadcast({ freeformField: field })] };
const [preview, send, schedule] = channel === 'email' ? [ctrl.previewBroadcast, ctrl.sendBroadcast, ctrl.scheduleBroadcast] : [ctrl.previewWhatsAppBroadcast, ctrl.sendWhatsAppBroadcast, ctrl.scheduleWhatsAppBroadcast];
const name = channel === 'email' ? 'an email' : 'a WhatsApp';
v1Route({ method: 'post', path: `/broadcasts/${channel}/preview`, tag: 'Messaging', summary: `Who ${name} broadcast would reach`, access: 'supervisor', ...scope, body: loose, v1: preview });
v1Route({ method: 'post', path: `/broadcasts/${channel}/send`, tag: 'Messaging', summary: `Send ${name} broadcast`, description: 'To campuses, events, roles or listed people; news consent is checked.', access: 'supervisor', ...scope, body: loose, v1: send });
v1Route({ method: 'post', path: `/broadcasts/${channel}/schedule`, tag: 'Messaging', summary: `Schedule ${name} broadcast`, access: 'supervisor', ...scope, body: loose, v1: schedule });
}
v1Route({ method: 'get', path: '/scheduled-messages', tag: 'Messaging', summary: 'Scheduled emails and WhatsApps', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('status', 'eventId', 'channel'), v1: sched.listScheduledEmails });
v1Route({ method: 'patch', path: '/scheduled-messages/:id', tag: 'Messaging', summary: 'Change a scheduled message', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], params: idParams, body: loose, v1: sched.updateScheduledEmail });
v1Route({ method: 'delete', path: '/scheduled-messages/:id', tag: 'Messaging', summary: 'Cancel a scheduled message', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], params: idParams, v1: sched.deleteScheduledEmail });
v1Route({
method: 'post', path: '/automations/schedule', tag: 'Messaging', summary: 'Schedule an event\'s reminder messages',
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], body: z.looseObject({ eventId: id }), v1: auto.scheduleAutomations,
});
// ─── WhatsApp connection (admins) ────────────────────────────────────────────
v1Route({ method: 'get', path: '/whatsapp/config', tag: 'WhatsApp', summary: 'The WhatsApp connection settings', access: 'admin', v1: wa.getConfigHandler });
v1Route({ method: 'put', path: '/whatsapp/config', tag: 'WhatsApp', summary: 'Change the WhatsApp connection settings', access: 'admin', body: loose, v1: wa.saveConfigHandler });
v1Route({ method: 'post', path: '/whatsapp/instance', tag: 'WhatsApp', summary: 'Create the WhatsApp instance', access: 'admin', body: loose, v1: wa.createInstanceHandler });
v1Route({ method: 'delete', path: '/whatsapp/instance', tag: 'WhatsApp', summary: 'Delete the WhatsApp instance', access: 'admin', v1: wa.deleteInstanceHandler });
v1Route({ method: 'get', path: '/whatsapp/status', tag: 'WhatsApp', summary: 'Is WhatsApp connected?', access: 'admin', v1: wa.getStatusHandler });
v1Route({ method: 'get', path: '/whatsapp/qr', tag: 'WhatsApp', summary: 'The QR code to link a phone', access: 'admin', v1: wa.getQrHandler });
for (const [action, summary, handler] of [['request-code', 'Link a phone with a code instead', wa.requestCodeHandler], ['logout', 'Unlink the phone', wa.logoutHandler], ['start', 'Start the WhatsApp session', wa.startHandler], ['restart', 'Restart the WhatsApp session', wa.restartHandler]] as const) {
v1Route({ method: 'post', path: `/whatsapp/${action}`, tag: 'WhatsApp', summary, access: 'admin', body: loose, v1: handler });
}
// ─── Banner, saved filters, dashboards, stats, analytics, reports ────────────
v1Route({ method: 'get', path: '/banner', tag: 'Site', summary: 'The site-wide banner', access: 'public', v1: banner.getBanner });
v1Route({ method: 'put', path: '/banner', tag: 'Site', summary: 'Set the site-wide banner', access: 'supervisor', campusScope: 'unscopedOnly (admins and unscoped supervisors)', middleware: [cs.unscopedOnly], body: loose, v1: banner.setBanner });
const OWN = 'campusIndependent (a person\'s own saved filters)';
v1Route({ method: 'get', path: '/saved-filters', tag: 'Me', summary: 'My saved filters', description: 'For one list page (`page`).', access: 'staff', campusScope: OWN, query: filters('page'), v1: savedFilterRouter, map: ctx => ({ url: `/${qs(ctx.req)}` }) });
v1Route({ method: 'post', path: '/saved-filters', tag: 'Me', summary: 'Save a filter', description: 'By name (replaces one with the same name); up to 30 per page.', access: 'staff', campusScope: OWN, body: loose, v1: savedFilterRouter, map: () => ({ url: '/' }) });
v1Route({ method: 'put', path: '/saved-filters/:id', tag: 'Me', summary: 'Change a saved filter', access: 'staff', campusScope: OWN, params: idParams, body: loose, v1: savedFilterRouter, map: ctx => ({ url: `/${encodeURIComponent(ctx.params.id)}` }) });
v1Route({ method: 'delete', path: '/saved-filters/:id', tag: 'Me', summary: 'Delete a saved filter', access: 'staff', campusScope: OWN, params: idParams, v1: savedFilterRouter, map: ctx => ({ url: `/${encodeURIComponent(ctx.params.id)}` }) });
v1Route({ method: 'get', path: '/dashboard/today', tag: 'Dashboard', summary: 'Today at a glance', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: dashboardRouter, map: ctx => ({ url: `/today${qs(ctx.req)}` }) });
v1Route({ method: 'get', path: '/dashboard/search', tag: 'Dashboard', summary: 'Search people, registrations and events', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('q'), v1: dashboardRouter, map: ctx => ({ url: `/search${qs(ctx.req)}` }) });
v1Route({ method: 'get', path: '/stats/staff', tag: 'Dashboard', summary: 'Staff dashboard numbers', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: stats.getStaffDashboardStats });
v1Route({ method: 'get', path: '/stats/supervisor', tag: 'Dashboard', summary: 'Supervisor dashboard numbers', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: stats.getSupervisorDashboardStats });
v1Route({ method: 'get', path: '/stats/admin', tag: 'Dashboard', summary: 'Admin dashboard numbers', access: 'admin', v1: stats.getAdminDashboardStats });
v1Route({ method: 'get', path: '/stats/overview', tag: 'Dashboard', summary: 'Overview numbers', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('from', 'to', 'campus'), v1: stats.getOverviewStats });
const trackLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false });
v1Route({ method: 'post', path: '/analytics/track', tag: 'Dashboard', summary: 'Record a step on the way to registering', access: 'public', middleware: [trackLimiter], body: loose, v1: analytics.trackStage });
v1Route({ method: 'get', path: '/analytics/funnel', tag: 'Dashboard', summary: 'How many get from viewing to paying', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'from', 'to'), v1: analytics.getFunnel });
v1Route({ method: 'post', path: '/reports/pdf', tag: 'Reports', summary: 'A report as a PDF', description: 'The report\'s rows and columns in the body.', access: 'user', body: loose, file: { contentType: 'application/pdf' }, v1: reports.generatePdf });
v1Route({ method: 'post', path: '/reports/excel', tag: 'Reports', summary: 'A report as an Excel file', access: 'user', body: loose, file: { contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, v1: reports.generateExcel });
v1Route({ method: 'post', path: '/reports/email', tag: 'Reports', summary: 'Email a report as a PDF', access: 'user', body: loose, v1: reports.emailPdf });
v1Route({ method: 'post', path: '/reports/whatsapp', tag: 'Reports', summary: 'WhatsApp a report as a PDF', access: 'user', body: loose, v1: reports.whatsappPdf });
+4
View File
@@ -78,6 +78,10 @@ export function defineRoute<P extends Schema | undefined, Q extends Schema | und
if ((def.access === 'staff' || def.access === 'supervisor') && !def.campusScope) {
throw new Error(`${def.method.toUpperCase()} /api/v2${def.path}: staff and supervisor routes must declare campusScope`);
}
// Two definitions of one route: the first would silently win.
if (routes.some(r => r.method === def.method && r.path === def.path)) {
throw new Error(`${def.method.toUpperCase()} /api/v2${def.path} is defined twice`);
}
routes.push(def as AnyRoute);
return def;
}
+17
View File
@@ -9,6 +9,8 @@
* The handlers are still JavaScript; moving each area into a TypeScript service (as 1.51.1 –
* 1.51.10 did) can follow later without changing the v2 API.
*/
import { readFile } from 'fs/promises';
import { basename } from 'path';
import { Writable } from 'stream';
import { z } from 'zod';
import type { Request, RequestHandler } from 'express';
@@ -73,6 +75,21 @@ class RecordingResponse extends Writable {
this.finishWith({});
return this;
}
/** `res.download` / `res.sendFile`: the file on disk becomes the answer. */
download(filePath: string, filename?: string | ((err?: unknown) => void)) {
const name = typeof filename === 'string' ? filename : basename(filePath);
readFile(filePath).then(buf => {
if (!this.headers['content-type']) this.setHeader('content-type', 'application/octet-stream');
this.setHeader('content-disposition', `attachment; filename="${name}"`);
this.chunks.push(buf);
this.finishWith({});
}, () => { this.statusCode = 404; this.finishWith({ json: { message: 'That file was not found.' } }); });
return this;
}
sendFile(filePath: string) {
readFile(filePath).then(buf => { this.chunks.push(buf); this.finishWith({}); }, () => { this.statusCode = 404; this.finishWith({ json: { message: 'That file was not found.' } }); });
return this;
}
redirect(a: number | string, b?: string) {
const url = typeof a === 'string' ? a : b!;
this.statusCode = typeof a === 'number' ? a : 302;
+83
View File
@@ -0,0 +1,83 @@
/**
* 1.51.17 security fix: nothing public says whether an account exists. Forgot-password answers
* the same for every email (before doing any per-account work), and sign-in with an unknown email
* answers exactly like a wrong password.
*/
const mockDb = {
user: { findUnique: jest.fn(), findFirst: jest.fn(), update: jest.fn() },
passwordReset: { updateMany: jest.fn(), create: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
const mockCompare = jest.fn(async () => false);
jest.mock('../src/config/auth', () => ({ comparePassword: (...a: unknown[]) => (mockCompare as jest.Mock)(...a), hashPassword: async () => 'stand-in-hash', generateToken: () => 't' }));
const mockSendMail = jest.fn(async () => undefined);
jest.mock('../src/utils/email', () => ({ sendMail: (...a: unknown[]) => (mockSendMail as jest.Mock)(...a), buildPasswordResetEmail: () => ({ html: '', text: 'reset' }) }));
jest.mock('../src/utils/notify', () => ({ waText: async () => undefined }));
jest.mock('../src/utils/securityEvents', () => ({ logSecurityEvent: async () => undefined, getRecentSecurityEvents: async () => [] }));
jest.mock('../src/utils/whatsapp', () => ({ normalizeZAPhone: () => null, isValidZAPhone: () => false }));
import { logIn } from '../src/services/auth';
import { ServiceError } from '../src/services/errors';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { requestPasswordReset } = require('../src/controllers/userController');
type Answer = { status: number; body: unknown };
function call(handler: (req: unknown, res: unknown) => Promise<void>, body: object): Promise<Answer> {
return new Promise(resolve => {
let status = 200;
const res = {
statusCode: 200,
status(n: number) { status = n; this.statusCode = n; return this; },
json(b: unknown) { resolve({ status, body: b }); return this; },
};
handler({ body, headers: {}, ip: '127.0.0.1' }, res);
});
}
const realUser = { id: 'u1', name: 'Thandi', email: 'thandi@example.org', isActive: true, password: 'real-hash', failedAttempts: 0, lockUntil: null, role: 'user' };
beforeEach(() => jest.clearAllMocks());
describe('forgot password', () => {
test('answers the same, with the same status, for an email with and without an account', async () => {
mockDb.user.findUnique.mockResolvedValueOnce(null);
const unknown = await call(requestPasswordReset, { email: 'nobody@example.org' });
mockDb.user.findUnique.mockResolvedValueOnce(realUser);
const known = await call(requestPasswordReset, { email: realUser.email });
expect(unknown).toEqual(known);
expect(known.status).toBe(200);
});
test('only a real account gets a link, made after the answer is sent', async () => {
mockDb.user.findUnique.mockResolvedValueOnce(null);
await call(requestPasswordReset, { email: 'nobody@example.org' });
await new Promise(r => setImmediate(r));
expect(mockDb.passwordReset.create).not.toHaveBeenCalled();
expect(mockSendMail).not.toHaveBeenCalled();
mockDb.user.findUnique.mockResolvedValueOnce(realUser);
const answered = call(requestPasswordReset, { email: realUser.email });
await answered;
await new Promise(r => setImmediate(r));
expect(mockDb.passwordReset.create).toHaveBeenCalledTimes(1);
expect(mockSendMail).toHaveBeenCalledWith(expect.objectContaining({ to: realUser.email }));
});
});
describe('sign-in', () => {
const failure = async (p: Promise<unknown>) => { try { await p; } catch (e) { return e as ServiceError; } throw new Error('expected a refusal'); };
test('an unknown email is refused exactly like a wrong password', async () => {
mockDb.user.findFirst.mockResolvedValueOnce(null);
const unknown = await failure(logIn({ identifier: 'nobody@example.org', password: 'x' }, { ip: '1', userAgent: 'jest' }));
mockDb.user.findFirst.mockResolvedValueOnce(realUser);
const wrong = await failure(logIn({ identifier: realUser.email, password: 'x' }, { ip: '1', userAgent: 'jest' }));
expect({ message: unknown.message, status: unknown.statusCode, v1: unknown.v1Status }).toEqual({ message: wrong.message, status: wrong.statusCode, v1: wrong.v1Status });
});
test('an unknown email still does a password check, so it takes as long', async () => {
mockDb.user.findFirst.mockResolvedValueOnce(null);
await failure(logIn({ identifier: 'nobody@example.org', password: 'guess' }, { ip: '1', userAgent: 'jest' }));
expect(mockCompare).toHaveBeenCalledWith('guess', 'stand-in-hash');
});
});
+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');
});
});
+4
View File
@@ -3,6 +3,10 @@ const { allowedWhileImpersonating, impersonationBlocker } = require('../src/util
describe('allowedWhileImpersonating', () => {
test('reads are allowed', () => {
expect(allowedWhileImpersonating('GET', '/api/registrations/myregistrations')).toBe(true);
// Ending (and logging) a viewing-as session works on v2 too; other v2 writes stay blocked.
expect(allowedWhileImpersonating('POST', '/api/v2/me/impersonation/end')).toBe(true);
expect(allowedWhileImpersonating('POST', '/api/v2/me/impersonation/log')).toBe(true);
expect(allowedWhileImpersonating('POST', '/api/v2/payments')).toBe(false);
expect(allowedWhileImpersonating('HEAD', '/api/events')).toBe(true);
expect(allowedWhileImpersonating('OPTIONS', '/api/payments')).toBe(true);
});
+77
View File
@@ -0,0 +1,77 @@
import express from 'express';
import request from 'supertest';
const mockCalls: { name: string; body?: unknown; query?: unknown }[] = [];
jest.mock('../src/controllers/webhookController', () => ({ handleYocoWebhook: (req: { rawBody: string }, res: { json(b: unknown): void }) => { mockCalls.push({ name: 'yoco', body: req.rawBody }); res.json({ ok: true }); } }));
jest.mock('../src/payments/webhook', () => ({ handleProviderWebhook: (id: string) => (_req: unknown, res: { json(b: unknown): void }) => { mockCalls.push({ name: id }); res.json({ ok: true }); } }));
jest.mock('../src/controllers/whatsappController', () => ({ handleWebhook: (req: { body: unknown; query: unknown }, res: { json(b: unknown): void }) => { mockCalls.push({ name: 'whatsapp', body: req.body, query: req.query }); res.json({ ok: true }); } }));
jest.mock('../src/controllers/privacyController', () => ({
oneClickRedirect: (req: { params: { token: string } }, res: { redirect(c: number, u: string): void }) => res.redirect(302, `https://site.example/unsubscribe?t=${req.params.token}`),
oneClickUnsubscribe: (req: { params: { token: string }; body: unknown }, res: { json(b: unknown): void }) => { mockCalls.push({ name: 'one-click', body: req.body }); res.json({ ok: true }); },
}));
import { webhooksRouter, linksRouter, legacyLinkRedirects } from '../src/v2/links';
// The same order and raw-body handling as app.js.
function app() {
const a = express();
a.use((req, _res, next) => {
if (req.path.startsWith('/api/v2/webhooks')) {
let raw = '';
req.setEncoding('utf8');
req.on('data', (c: string) => { raw += c; });
req.on('end', () => { (req as unknown as { rawBody: string }).rawBody = raw; next(); });
} else next();
});
a.use('/api/v2/webhooks', webhooksRouter);
a.use(express.json());
a.use('/api/v2', linksRouter);
a.use(legacyLinkRedirects);
a.post('/api/privacy/unsubscribe/:token/one-click', (_req, res) => { mockCalls.push({ name: 'v1 one-click' }); res.json({ ok: true }); });
return a;
}
beforeEach(() => { mockCalls.length = 0; });
describe('incoming webhooks on v2', () => {
test('Yoco gets the exact bytes it signed', async () => {
const body = '{"type":"payment.succeeded","payload":{"id":"p1"}}';
await request(app()).post('/api/v2/webhooks/yoco').set('Content-Type', 'application/json').send(body);
expect(mockCalls).toEqual([{ name: 'yoco', body }]);
});
test('PayFast reaches the PayFast handler', async () => {
await request(app()).post('/api/v2/webhooks/payfast').type('form').send('m_payment_id=c1');
expect(mockCalls[0].name).toBe('payfast');
});
test('WhatsApp gets its JSON parsed and keeps the secret in the query', async () => {
await request(app()).post('/api/v2/webhooks/whatsapp?secret=s3').send({ event: 'message', payload: { body: 'hi' } });
expect(mockCalls[0]).toEqual({ name: 'whatsapp', body: { event: 'message', payload: { body: 'hi' } }, query: { secret: 's3' } });
});
});
describe('links in sent messages', () => {
test('an old calendar link answers 308 with its v2 address', async () => {
const res = await request(app()).get('/api/events/e1/ics?x=1');
expect(res.status).toBe(308);
expect(res.headers.location).toBe('/api/v2/events/e1/calendar.ics?x=1');
});
test('opening an old one-click link goes to v2, which sends the person to the page', async () => {
const old = await request(app()).get('/api/privacy/unsubscribe/tok/one-click');
expect(old.status).toBe(308);
expect(old.headers.location).toBe('/api/v2/unsubscribe/tok/one-click');
const v2 = await request(app()).get('/api/v2/unsubscribe/tok/one-click');
expect(v2.status).toBe(302);
expect(v2.headers.location).toBe('https://site.example/unsubscribe?t=tok');
});
test("a mail app's one-click POST works on v2, and on the old address without a redirect", async () => {
await request(app()).post('/api/v2/unsubscribe/tok/one-click').type('form').send('List-Unsubscribe=One-Click');
expect(mockCalls[0]).toEqual({ name: 'one-click', body: { 'List-Unsubscribe': 'One-Click' } });
const old = await request(app()).post('/api/privacy/unsubscribe/tok/one-click').type('form').send('List-Unsubscribe=One-Click');
expect(old.status).toBe(200);
expect(mockCalls[1].name).toBe('v1 one-click');
});
});
+1 -1
View File
@@ -193,7 +193,7 @@ describe('PayFast provider', () => {
expect(form.action).toBe('https://sandbox.payfast.co.za/eng/process');
expect(Object.keys(form.fields)).toEqual(['merchant_id', 'merchant_key', 'return_url', 'cancel_url', 'notify_url', 'name_first', 'name_last', 'email_address', 'm_payment_id', 'amount', 'item_name', 'item_description', 'signature']);
expect(form.fields).toMatchObject({
notify_url: 'https://api.example.org/api/webhooks/payfast', name_first: 'Thandi', name_last: "O'Neil Mokoena",
notify_url: 'https://api.example.org/api/v2/webhooks/payfast', name_first: 'Thandi', name_last: "O'Neil Mokoena",
m_payment_id: 'chk_9', amount: '125.50', item_name: "Men's Camp (Youth)", item_description: 'Ticket x2',
});
const { signature, ...rest } = form.fields;
+1 -1
View File
@@ -97,7 +97,7 @@ describe('footers', () => {
test('news emails carry an unsubscribe link and one-click headers', () => {
const f = privacy.bulkEmailFooter({ kind: 'news', recipient: { email: 'a@b.co', userId: 'u1' } });
expect(f.html).toContain('https://events.example.org/unsubscribe?t=');
expect(f.headers['List-Unsubscribe']).toMatch(/^<https:\/\/api\.example\.org\/api\/privacy\/unsubscribe\/.+\/one-click>$/);
expect(f.headers['List-Unsubscribe']).toMatch(/^<https:\/\/api\.example\.org\/api\/v2\/unsubscribe\/.+\/one-click>$/);
expect(f.headers['List-Unsubscribe-Post']).toBe('List-Unsubscribe=One-Click');
});
+1
View File
@@ -51,6 +51,7 @@ jest.mock('../src/utils/campusScope', () => ({
scopeBody: () => (_req: unknown, _res: unknown, next: () => void) => next(),
listWhere: async () => ({}),
scopeList: (_req: unknown, _res: unknown, next: () => void) => next(),
scopeBroadcast: () => (_req: unknown, _res: unknown, next: () => void) => next(),
unscopedOnly: (_req: unknown, _res: unknown, next: () => void) => next(),
campusIndependent: () => (_req: unknown, _res: unknown, next: () => void) => next(),
}));
+12
View File
@@ -85,4 +85,16 @@ describe('v2 routes answered by v1 handlers', () => {
expect(res.status).toBe(404);
expect(res.body.error.code).toBe('NOT_FOUND');
});
test("a file v1 sends with res.download is a download", async () => {
const os = require("os"); const path = require("path");
const file = path.join(os.tmpdir(), "viaV1-test-backup.sql");
require("fs").writeFileSync(file, "-- backup");
const handler: RequestHandler = (_req, res) => { res.download(file, "backup.sql"); };
const app = appWith(v1Route({ method: "get", path: "/backup", tag: "T", summary: "s", access: "public", file: { contentType: "application/octet-stream" }, v1: handler }));
const res = await request(app).get("/api/v2/backup").buffer(true).parse((r, cb) => { const c: Buffer[] = []; r.on("data", (d: Buffer) => c.push(d)); r.on("end", () => cb(null, Buffer.concat(c))); });
expect(res.status).toBe(200);
expect(res.headers["content-disposition"]).toContain("backup.sql");
expect((res.body as Buffer).toString()).toBe("-- backup");
});
});
+2 -2
View File
@@ -82,12 +82,12 @@ describe('webhook URL', () => {
test('built from BACKEND_URL', () => {
process.env.BACKEND_URL = 'https://api.example.com/';
expect(defaultWebhookUrl(req)).toBe('https://api.example.com/api/webhooks/yoco');
expect(defaultWebhookUrl(req)).toBe('https://api.example.com/api/v2/webhooks/yoco');
});
test('falls back to the request origin', () => {
delete process.env.BACKEND_URL;
expect(defaultWebhookUrl(req)).toBe('https://events-demo-api.crosscode.co.za/api/webhooks/yoco');
expect(defaultWebhookUrl(req)).toBe('https://events-demo-api.crosscode.co.za/api/v2/webhooks/yoco');
});
test('must be public https', () => {
+62 -35
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-29, after **1.51.13** (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,10 @@ 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 |
| 1.51.14 | **People and messaging on v2** — `resources/peopleMessaging.ts`; `/event-listing` for the public pages; `defineRoute` refuses duplicates |
| 1.51.13 | **Kids check-in and volunteers on v2** — `resources/kidsVolunteers.ts` |
| 1.51.12 | **Tickets and attendance on v2** — `resources/attendance.ts`; frontend `apiV2Raw()` for string-path helpers |
| 1.51.11 | **Money on v2** — `src/v2/viaV1.ts` (`v1Route`: a v2 route answered by the v1 handler); `resources/money.ts`; `/docs/v2` collapsed with a filter |
@@ -25,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` |
@@ -40,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` |
@@ -56,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 |
@@ -67,25 +69,48 @@ 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. **1.51.14 – 1.51.16: every endpoint onto v2**, one area per release (roadmap build order, row
11b): services in TS, screens on `apiV2`, v1 routes into `SUCCESSORS`. 1.51.16 gives incoming
webhooks and links in sent messages v2 URLs, with the v1 paths still answering (links redirect,
webhooks handled by the same code). Then **2.0.0** — AI helpers and v1 removed.
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. Check Claude Haiku 5.5's current prices before setting
the markup and caps.
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
@@ -173,8 +198,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/webhooks/<id>`
(`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`.
@@ -191,7 +216,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/whatsapp/webhook?secret=…`; 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
@@ -464,7 +490,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
@@ -518,7 +545,7 @@ a test server (both done 2026-10-28).
send a file; `upload: { field, description }` documents a multipart body (the multer step goes in
`middleware`; check `req.multerError`/`req.file` in the handler).
- `eventController.js` still holds the public list (`getEvents`), the admin list and the attendee
email/WhatsApp senders (messaging, 1.51.14).
email/WhatsApp senders — all on v2 since 1.51.14 (`v1Route`).
## Setting events up on v2 (1.51.7)
@@ -545,8 +572,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.
@@ -591,13 +619,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.
@@ -608,8 +637,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` (`webhookController.handleWhatsappWebhook`) is an old empty stub; the
real WAWP webhook is `/api/whatsapp/webhook`. Remove the stub in a later release.
- There's no log of individual messages sent, so "Download my data" can't include "messages
sent to you" yet.
+96 -48
View File
@@ -11,40 +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** and 1.51.13 **kids and volunteers on v2** shipped. **Next: 1.51.14 – 1.51.16 (every
endpoint onto v2), then 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 | 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
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:**
@@ -73,8 +79,9 @@ endpoint onto v2), then 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)
---
@@ -832,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 |
---
@@ -867,7 +874,8 @@ never saves, sends or publishes by itself.
- **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
prompts under 100K tokens; prices as of 2026-10 — **check them against Anthropic's pricing
page before setting the markup and caps**). 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
@@ -944,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
@@ -997,13 +1022,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 |
@@ -1013,8 +1057,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.13 ✅, 1.51.14 – 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
@@ -1053,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) |
@@ -1074,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`.
+66 -2
View File
@@ -90,8 +90,18 @@ test.describe("API v2", () => {
expect(v1.headers()["link"]).toBe(`</api/v2/events>; rel="successor-version"`);
const all = await request.get(`${API}/api/events/all`, { headers: { Authorization: `Bearer ${seed().users.staff.token}` } });
expect(all.headers()["link"]).toBe(`</api/v2/events/manage>; rel="successor-version"`);
// Endpoints with no v2 version yet say nothing.
expect((await request.get(`${API}/api/settings/needs-setup`)).headers()["deprecation"]).toBeUndefined();
expect((await request.get(`${API}/api/settings/needs-setup`)).headers()["link"]).toBe(`</api/v2/setup/needed>; rel="successor-version"`);
// Incoming webhooks: the v1 address still works (same handler) and names its v2 one.
const hook = await request.post(`${API}/api/webhooks/whatsapp`, { data: {} });
expect(hook.status()).toBe(200);
expect(hook.headers()["link"]).toBe(`</api/v2/webhooks/whatsapp>; rel="successor-version"`);
expect((await request.post(`${API}/api/v2/webhooks/whatsapp`, { data: { event: "noop" } })).status()).toBe(200);
// A calendar link in an email already sent goes to its v2 address.
const ics = await request.get(`${API}/api/events/${seed().events.conference}/ics`, { maxRedirects: 0 });
expect(ics.status()).toBe(308);
expect(ics.headers()["location"]).toBe(`/api/v2/events/${seed().events.conference}/calendar.ics`);
const followed = await request.get(`${API}/api/events/${seed().events.conference}/ics`);
expect(followed.headers()["content-type"]).toContain("text/calendar");
});
test("staff list every event; supervisors create, change and admins deactivate", async ({ request }) => {
@@ -275,6 +285,60 @@ test.describe("API v2", () => {
expect((await request.get(`${API}/api/v2/kids/events`, { headers: user })).status()).toBe(403);
});
test("people and messaging: event listing, dashboards, saved filters, accounts", async ({ request }) => {
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const listing = (await (await request.get(`${API}/api/v2/event-listing`)).json()).data;
expect(Array.isArray(listing)).toBe(true);
expect(listing[0]).toHaveProperty("eventOptions");
for (const path of ["/api/v2/stats/staff", "/api/v2/dashboard/today", "/api/v2/banner"]) {
const res = await request.get(`${API}${path}`, { headers: staff });
expect(res.status(), path).toBe(200);
}
// Saved filters run the v1 router: save one, list it, delete it.
const saved = await request.post(`${API}/api/v2/saved-filters`, { headers: sup, data: { page: "events", name: "E2E v2 filter", params: { q: "x" } } });
expect(saved.status()).toBe(200);
const list = (await (await request.get(`${API}/api/v2/saved-filters?page=events`, { headers: sup })).json()).data;
const mine = (Array.isArray(list) ? list : list.filters || []).find((x: { name: string }) => x.name === "E2E v2 filter");
expect(mine).toBeTruthy();
expect((await request.delete(`${API}/api/v2/saved-filters/${mine.id}`, { headers: sup })).status()).toBe(200);
const users = (await (await request.get(`${API}/api/v2/users?page=1&limit=2`, { headers: sup })).json()).data;
expect(users).toMatchObject({ page: 1, limit: 2 });
// Forgot-password doesn't say whether an account exists (1.51.17): the same answer either way.
const unknown = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: "nobody-e2e@example.com" } });
const known = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: seed().users.user.email } });
expect(unknown.status()).toBe(200);
expect(await unknown.json()).toEqual(await known.json());
// Nor does sign-in: an unknown email is refused like a wrong password.
const noAccount = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: "nobody-e2e@example.com", password: "Wrong-password-1" } });
const wrongPassword = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: seed().users.user.email, password: "Wrong-password-1" } });
expect(noAccount.status()).toBe(wrongPassword.status());
expect(await noAccount.json()).toEqual(await wrongPassword.json());
});
test("privacy and admin: settings, my data, the audit log, webhooks, backups", async ({ request }) => {
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
const user = { Authorization: `Bearer ${seed().users.user.token}` };
expect((await (await request.get(`${API}/api/v2/settings`)).json()).data).toBeTruthy();
expect((await request.get(`${API}/api/v2/setup/needed`)).status()).toBe(200);
expect((await request.get(`${API}/api/v2/me/privacy`, { headers: user })).status()).toBe(200);
const exported = await request.get(`${API}/api/v2/me/privacy/export`, { headers: user });
expect(exported.status()).toBe(200);
expect(exported.headers()["content-disposition"]).toContain("my-data-");
expect(JSON.parse((await exported.body()).toString())).toBeTruthy();
for (const path of ["/api/v2/admin/audit-log?page=1&limit=5", "/api/v2/outbound-webhooks", "/api/v2/backups", "/api/v2/privacy/requests"]) {
const res = await request.get(`${API}${path}`, { headers: admin });
expect(res.status(), path).toBe(200);
}
// Admin areas aren't for everyone.
expect((await request.get(`${API}/api/v2/admin/audit-log`, { headers: user })).status()).toBe(403);
});
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
expect((await request.get(`${API}/api/v2/openapi.json`, { headers })).status()).toBe(403);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.51.13",
"version": "1.51.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.51.13",
"version": "1.51.17",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.51.13",
"version": "1.51.17",
"private": true,
"scripts": {
"dev": "next dev",
@@ -2,9 +2,9 @@
import { useState } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { KeyRound } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
@@ -17,7 +17,7 @@ export default function ForgotPasswordPage() {
try {
setLoading(true);
setStatus(null);
const res = await apiFetch<{ message?: string }>("/api/users/forgot", { method: "POST", body: { email } });
const res = await (apiV2Data("post", "/auth/password/forgot", { body: { email } }) as Promise<{ message?: string }>);
setStatus(res?.message || "If that email exists, a password reset link has been sent.");
} catch (err) {
try {
+2 -2
View File
@@ -3,12 +3,12 @@ import React, { Suspense, useMemo, useState } from "react";
import Link from "next/link";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { saveToken } from "@/lib/auth";
import { useSearchParams } from "next/navigation";
import { appName } from "@/lib/siteConfig";
import { ShieldCheck } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
function ActivateAccountContent() {
const search = useSearchParams();
@@ -29,7 +29,7 @@ function ActivateAccountContent() {
try {
setLoading(true);
setStatus(null);
const data = await apiFetch<{ token?: string }>("/api/users/activate", { method: "POST", body: { token, password, acceptTerms: accepted } });
const data = await (apiV2Data("post", "/auth/activate", { body: { token, password, acceptTerms: accepted } }) as Promise<{ token?: string }>);
// Auto-login: save the session token (under the key AuthContext reads) and load the
// dashboard fresh so the new session is picked up.
if (data?.token) saveToken(data.token);
+1 -1
View File
@@ -42,7 +42,7 @@ export default async function CampusPage({ params }: { params: Promise<{ slug: s
const campus = await loadCampus(slug);
if (!campus) notFound();
const events = await apiFetchListForPrerender<Event>(`/api/events?campus=${encodeURIComponent(campus.slug)}`, { nextOptions: { next: { revalidate } } });
const events = await apiFetchListForPrerender<Event>(`/api/v2/event-listing?campus=${encodeURIComponent(campus.slug)}`, { nextOptions: { next: { revalidate } } });
const now = Date.now();
const upcoming = (events || [])
.filter(e => e.cashupStatus !== "closed" && new Date(e.endDate || e.startDate).getTime() > now)
@@ -3,12 +3,12 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { History } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2Data } from "@/lib/apiV2";
const ACTION_LABELS: Record<string, string> = {
refund_created: "Refund created",
@@ -131,7 +131,7 @@ function ImpersonationSessions({ token }: { token: string }) {
const [sessions, setSessions] = useState<ImpSession[]>([]);
const [open, setOpen] = useState<string | null>(null);
useEffect(() => {
apiFetch<ImpSession[]>("/api/admin/impersonation-sessions?limit=20", { authToken: token }).then(setSessions).catch(() => setSessions([]));
(apiV2Data("get", "/admin/impersonation-sessions", { query: { limit: "20" }, authToken: token }) as Promise<ImpSession[]>).then(setSessions).catch(() => setSessions([]));
}, [token]);
if (sessions.length === 0) return null;
return (
@@ -196,7 +196,7 @@ export default function AdminAuditLogPage() {
if (search) qs.set("search", search);
if (fromFilter) qs.set("from", fromFilter);
if (toFilter) qs.set("to", toFilter);
return `/api/admin/audit-log?${qs.toString()}`;
return Object.fromEntries(qs);
}, [actionFilter, roleFilter, search, fromFilter, toFilter]);
// Search as you type, once typing pauses.
@@ -215,7 +215,7 @@ export default function AdminAuditLogPage() {
setError(null);
setFetching(true);
try {
const res = await apiFetch<{ rows: AuditLogEntry[]; total: number; page: number }>(buildQuery(p), { authToken: token });
const res = await (apiV2Data("get", "/admin/audit-log", { query: buildQuery(p), authToken: token }) as Promise<{ rows: AuditLogEntry[]; total: number; page: number }>);
setEntries(res?.rows || []);
setTotal(res?.total ?? 0);
setPage(p);
@@ -3,12 +3,12 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { Pager } from "@/components/shared/Pager";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Inbox, AlertTriangle } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2Data } from "@/lib/apiV2";
type RequestType = "access" | "correction" | "deletion";
type RequestStatus = "open" | "completed" | "rejected" | "cancelled";
@@ -44,8 +44,8 @@ function OpenRequest({ request, token, onDone }: { request: DataRequest; token:
setBusy(true);
setError(null);
try {
await apiFetch(`/api/privacy/requests/${request.id}/${mode}`, {
method: "POST",
await apiV2Data("post", mode === "reject" ? "/privacy/requests/{id}/reject" : "/privacy/requests/{id}/complete", {
params: { id: request.id },
authToken: token,
body: { response: response || undefined, cancelled: mode === "reject" ? cancelled : undefined },
});
@@ -155,7 +155,7 @@ export default function DataRequestsPage() {
try {
const qs = new URLSearchParams({ status: view, page: String(page), limit: String(pageSize) });
if (search) qs.set("search", search);
const res = await apiFetch<{ requests: DataRequest[]; openCount: number; total: number }>(`/api/privacy/requests?${qs.toString()}`, { authToken: token });
const res = await (apiV2Data("get", "/privacy/requests", { query: Object.fromEntries(qs), authToken: token }) as Promise<{ requests: DataRequest[]; openCount: number; total: number }>);
setRequests(res.requests);
setOpenCount(res.openCount);
setTotal(res.total ?? res.requests.length);
+3 -3
View File
@@ -3,7 +3,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
@@ -19,6 +18,7 @@ import type { DashboardStats, PaymentStats } from "@/types";
import { CampusFilter } from "@/components/shared/CampusFilter";
import { TodayPanel } from "@/components/dashboard/TodayPanel";
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
import { apiV2Data } from "@/lib/apiV2";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
@@ -93,9 +93,9 @@ export default function AdminDashboardPage() {
try {
if (isFirstLoad) setLoadingStats(true);
const [dash, overviewData] = await Promise.all([
apiFetch<DashboardStats>("/api/stats/admin", { authToken: token }),
(apiV2Data("get", "/stats/admin", { authToken: token }) as Promise<DashboardStats>),
// The month-over-month tiles follow the campus filter; today's totals stay church-wide.
apiFetch<Overview>(`/api/stats/overview${campus ? `?campus=${encodeURIComponent(campus)}` : ""}`, { authToken: token }),
(apiV2Data("get", "/stats/overview", { query: { campus: campus || undefined }, authToken: token }) as Promise<Overview>),
]);
setPaymentStats(dash.paymentStats);
setOverview(overviewData);
@@ -4,10 +4,11 @@ import React, { Suspense, useCallback, useEffect, useRef, useState } from "react
import { useAuth } from "@/hooks/useAuth";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
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";
@@ -15,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";
@@ -22,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 },
@@ -37,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 =
@@ -164,7 +167,7 @@ function SiteSettingsPageInner() {
// ── Load all settings once ────────────────────────────────────────────────
useEffect(() => {
if (!token) return;
apiFetch<Record<string, string>>("/api/settings/all", { authToken: token })
(apiV2Data("get", "/settings/all", { authToken: token }) as Promise<Record<string, string>>)
.then((s) => {
setOrgName(s.org_name || "");
setOrgTagline(s.org_tagline || "");
@@ -212,7 +215,7 @@ function SiteSettingsPageInner() {
setSaving(true);
setResult(null);
try {
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
await apiV2Data("put", "/settings", { authToken: token, body: updates });
setResult({ ok: true, message: "Saved." });
reloadSettings();
} catch (e) {
@@ -251,8 +254,8 @@ function SiteSettingsPageInner() {
let finalFaviconUrl = faviconUrl;
if (faviconFile) finalFaviconUrl = await uploadBrandingAsset(faviconFile, "favicon") || faviconUrl;
await apiFetch("/api/settings", {
method: "PUT", authToken: token,
await apiV2Data("put", "/settings", {
authToken: token,
body: {
primary_color: primaryColor,
secondary_color: secondaryColor,
@@ -283,8 +286,8 @@ function SiteSettingsPageInner() {
setSaving(true);
setResult(null);
try {
await apiFetch("/api/settings", {
method: "PUT", authToken: token,
await apiV2Data("put", "/settings", {
authToken: token,
body: { primary_color: "", secondary_color: "", accent_color: "", logo_url: "", favicon_url: "" },
});
setPrimaryColor("#4F46E5");
@@ -325,7 +328,7 @@ function SiteSettingsPageInner() {
smtp_user: smtpUser.trim(),
};
if (smtpPass.trim() !== "") updates.smtp_pass = smtpPass.trim();
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
await apiV2Data("put", "/settings", { authToken: token, body: updates });
if (smtpPass.trim() !== "") { setSmtpPassSet(true); setSmtpPass(""); }
setResult({ ok: true, message: "Saved." });
reloadSettings();
@@ -351,8 +354,7 @@ function SiteSettingsPageInner() {
setSmtpTesting(true);
setSmtpTestResult(null);
try {
await apiFetch("/api/settings/test-smtp", {
method: "POST",
await apiV2Data("post", "/settings/test-smtp", {
authToken: token,
body: {
host: smtpHost.trim(),
@@ -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>
@@ -884,7 +887,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
const fetchConfig = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<ConfigResponse>("/api/whatsapp/config", { authToken: token });
const res = await (apiV2Data("get", "/whatsapp/config", { authToken: token }) as Promise<ConfigResponse>);
setCfg(res);
} catch {
// network error — leave cfg null, tab shows loading state
@@ -898,7 +901,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
const fetchStatus = useCallback(async () => {
if (!token || step !== 3) return;
try {
const res = await apiFetch<StatusResponse>("/api/whatsapp/status", { authToken: token });
const res = await (apiV2Data("get", "/whatsapp/status", { authToken: token }) as Promise<StatusResponse>);
setStatus(res.status ?? null);
setStatusMsg(res.message ?? null);
} catch {
@@ -921,7 +924,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
const fetchQr = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ qr?: string }>("/api/whatsapp/qr", { authToken: token });
const res = await (apiV2Data("get", "/whatsapp/qr", { authToken: token }) as Promise<{ qr?: string }>);
if (res.qr) setQrSrc(`data:image/png;base64,${res.qr}`);
} catch {
setQrSrc(null);
@@ -945,7 +948,9 @@ function WhatsAppTab({ active }: { active: boolean }) {
setBusy(action);
setActionMsg(null);
try {
const res = await apiFetch<{ message?: string }>(`/api/whatsapp/${action}`, { method: "POST", authToken: token, body });
const res = action === "delete-instance"
? await apiV2Raw<{ message?: string }>("delete", "/whatsapp/instance", { authToken: token })
: await apiV2Raw<{ message?: string }>("post", `/whatsapp/${action}`, { authToken: token, body: body || {} });
setActionMsg({ type: "ok", text: res?.message || `${action} successful.` });
await fetchStatus();
await fetchConfig();
@@ -970,7 +975,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
setSavingToken(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: inputToken.trim(), instanceId: "" } });
await apiV2Data("put", "/whatsapp/config", { authToken: token, body: { token: inputToken.trim(), instanceId: "" } });
setInputToken("");
await fetchConfig();
} catch (e) {
@@ -988,7 +993,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
setSavingInstance(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: "", instanceId: inputInstanceId.trim() } });
await apiV2Data("put", "/whatsapp/config", { authToken: token, body: { token: "", instanceId: inputInstanceId.trim() } });
setInputInstanceId("");
await fetchConfig();
} catch (e) {
@@ -1002,7 +1007,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
setSavingInstance(true);
setActionMsg(null);
try {
const res = await apiFetch<{ message?: string }>("/api/whatsapp/create-instance", { method: "POST", authToken: token! });
const res = await (apiV2Data("post", "/whatsapp/instance", { authToken: token, body: {} }) as Promise<{ message?: string }>);
setActionMsg({ type: "ok", text: res?.message || "Instance created." });
await fetchConfig();
} catch (e) {
@@ -1023,7 +1028,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
const resetToken = async () => {
if (!confirm("This will clear your saved access token. You will need to re-enter it. Continue?")) return;
try {
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: "_clear_", instanceId: "" } });
await apiV2Data("put", "/whatsapp/config", { authToken: token, body: { token: "_clear_", instanceId: "" } });
} catch {}
setCfg(prev => prev ? { ...prev, hasToken: false, hasInstance: false, configured: false, tokenMasked: "", instanceId: "" } : null);
};
@@ -1257,9 +1262,8 @@ function WhatsAppTab({ active }: { active: boolean }) {
setSavingToken(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", {
method: "POST",
authToken: token!,
await apiV2Data("put", "/whatsapp/config", {
authToken: token,
body: { token: inputToken.trim() || undefined, instanceId: inputInstanceId.trim() || undefined },
});
setActionMsg({ type: "ok", text: "Credentials updated." });
+21 -17
View File
@@ -3,7 +3,6 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { beginImpersonation, type ImpersonationStart } from "@/lib/impersonation";
import { Users as UsersIcon } from "lucide-react";
@@ -13,6 +12,7 @@ import type { Paginated } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { useCampuses } from "@/hooks/useCampuses";
import { SavedFilters } from "@/components/shared/SavedFilters";
import { apiV2Data, apiV2 } from "@/lib/apiV2";
interface UserItem {
id: string;
@@ -91,7 +91,7 @@ export default function AdminUsersPage() {
if (debouncedQuery.trim()) qs.set("search", debouncedQuery.trim());
if (roleFilter) qs.set("role", roleFilter);
if (activeFilter !== "") qs.set("isActive", activeFilter);
return `/api/users?${qs.toString()}`;
return Object.fromEntries(qs);
}, [debouncedQuery, roleFilter, activeFilter, pageSize]);
const loadUsers = useCallback(async (p = 1, ps = pageSize) => {
@@ -99,8 +99,7 @@ export default function AdminUsersPage() {
setError(null);
setFetching(true);
try {
const url = buildQuery(p, ps);
const res = await apiFetch<Paginated<UserItem>>(url, { authToken: token });
const res = await (apiV2Data("get", "/users", { query: buildQuery(p, ps), authToken: token }) as Promise<Paginated<UserItem>>);
setUsers(Array.isArray(res?.data) ? res.data : []);
setTotal(res?.total ?? 0);
setTotalPages(res?.pages ?? 1);
@@ -131,13 +130,16 @@ export default function AdminUsersPage() {
if (!cEmail || !cPassword) { setError("Email and password are required"); return; }
try {
setCreating(true);
const created = await apiFetch<{ id?: string }>("/api/users", {
method: "POST",
body: { name: cName || cEmail.split("@")[0], email: cEmail, password: cPassword, phoneNumber: cPhone || undefined },
// An admin adding someone is a sign-up on their behalf (they agreed to the terms for them).
const { data: made } = await apiV2("post", "/auth/signup", {
body: { name: cName || cEmail.split("@")[0], email: cEmail, password: cPassword, acceptTerms: true, ...(cPhone ? { phone: cPhone } : {}) },
});
const created = { id: made.user.id };
if (cRole && cRole !== "user" && created?.id) {
await apiFetch(`/api/users/${encodeURIComponent(created.id)}`, {
method: "PUT", authToken: token!, body: { role: cRole },
await apiV2Data("patch", "/users/{id}", {
params: { id: created.id },
authToken: token,
body: { role: cRole },
});
}
resetCreateForm();
@@ -156,7 +158,7 @@ export default function AdminUsersPage() {
const [scope, setScope] = useState<{ id: string; name: string }[] | null>(null);
const startEdit = (u: UserItem) => {
setEditingId(u.id); setEditData({ ...u, password: "" }); setScope(null);
apiFetch<{ scopedCampuses?: { id: string; name: string }[] }>(`/api/users/${encodeURIComponent(u.id)}`, { authToken: token! })
(apiV2Data("get", "/users/{id}", { params: { id: u.id }, authToken: token }) as Promise<{ scopedCampuses?: { id: string; name: string }[] }>)
.then(full => setScope(full.scopedCampuses || []))
.catch(() => setScope(null));
};
@@ -181,8 +183,10 @@ export default function AdminUsersPage() {
payload.password = editData.password.trim();
}
if (campusInfo.enabled && canBeScoped && scope) payload.scopedCampusIds = scope.map(c => c.id);
await apiFetch(`/api/users/${encodeURIComponent(editingId)}`, {
method: "PUT", authToken: token!, body: payload,
await apiV2Data("patch", "/users/{id}", {
params: { id: editingId },
authToken: token,
body: payload,
});
setEditingId(null);
setEditData({});
@@ -197,7 +201,7 @@ export default function AdminUsersPage() {
const revokeUserSessions = async (id: string, name: string) => {
if (!confirm(`Revoke all active sessions for ${name}? They will be signed out on all devices.`)) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}/revoke-sessions`, { method: "POST", authToken: token! });
await apiV2Data("post", "/users/{id}/sessions/revoke", { params: { id: id }, authToken: token, body: {} });
} catch (e) {
setError(errorMessage(e) || "Failed to revoke sessions");
}
@@ -212,7 +216,7 @@ export default function AdminUsersPage() {
setImpBusy(true);
setImpError(null);
try {
const res = await apiFetch<ImpersonationStart>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
const res = await (apiV2Data("post", "/admin/impersonate/{userId}", { params: { userId: impTarget.id }, authToken: token, body: { reason: impReason } }) as Promise<ImpersonationStart>);
beginImpersonation(res);
} catch (e) {
setImpError(errorMessage(e) || "Couldn't start impersonation");
@@ -228,7 +232,7 @@ export default function AdminUsersPage() {
: `Turn off two-factor authentication for ${name}? They will be able to log in with just their password until they turn it back on.`;
if (!confirm(message)) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "PUT", authToken: token!, body: { twoFactorEnabled: false } });
await apiV2Data("patch", "/users/{id}", { params: { id: id }, authToken: token, body: { twoFactorEnabled: false } });
await loadUsers(page);
} catch (e) {
setError(errorMessage(e) || "Failed to reset two-factor authentication");
@@ -238,7 +242,7 @@ export default function AdminUsersPage() {
const deactivate = async (id: string) => {
if (!confirm("Deactivate this user? Their account will be disabled.")) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token! });
await apiV2Data("delete", "/users/{id}", { params: { id: id }, authToken: token });
await loadUsers(page);
} catch (e) {
setError(errorMessage(e) || "Failed to deactivate user");
@@ -248,7 +252,7 @@ export default function AdminUsersPage() {
const deleteUserData = async (id: string, name: string) => {
if (!confirm(`Delete personal data for "${name}"?\n\nThis will set their name to "Deleted User", clear their email and phone number, and deactivate the account. This action cannot be undone.`)) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}/anonymize`, { method: "POST", authToken: token! });
await apiV2Data("post", "/users/{id}/anonymize", { params: { id: id }, authToken: token, body: {} });
await loadUsers(page);
} catch (e) {
setError(errorMessage(e) || "Failed to delete user data");
+2 -2
View File
@@ -3,7 +3,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck, Baby, HandHeart } from "lucide-react";
@@ -13,6 +12,7 @@ import type { DashboardStats, RecentScan, ScanStats } from "@/types";
import { CampusScopeNote } from "@/components/shared/CampusFilter";
import { TodayPanel } from "@/components/dashboard/TodayPanel";
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
import { apiV2Data } from "@/lib/apiV2";
const QUICK_ACTIONS = [
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
@@ -52,7 +52,7 @@ export default function StaffDashboardPage() {
const isFirstLoad = !hasLoadedOnce.current;
try {
if (isFirstLoad) setLoadingStats(true);
const data = await apiFetch<DashboardStats>("/api/stats/staff", { authToken: token });
const data = await (apiV2Data("get", "/stats/staff", { authToken: token }) as Promise<DashboardStats>);
setStats(data.scanStats);
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
} catch {
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { TicketPercent, Plus, X, Search, Pencil, Power, Users, CalendarClock, ListChecks } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -446,7 +446,7 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
if (d.restrictTo !== "users" || userQuery.trim().length < 2) { setUserResults([]); return; }
const t = setTimeout(async () => {
try {
const res = await apiFetch<{ data: { id: string; name: string; email: string }[] }>(`/api/users?search=${encodeURIComponent(userQuery.trim())}&limit=8`, { authToken: token });
const res = await (apiV2Data("get", "/users", { query: { search: userQuery.trim(), limit: "8" }, authToken: token }) as Promise<{ data: { id: string; name: string; email: string }[] }>);
setUserResults(res?.data || []);
} catch { setUserResults([]); }
}, 300);
@@ -3,7 +3,7 @@
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Mail, Users, Zap, Clock } from "lucide-react";
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
@@ -280,7 +280,7 @@ function EmailAttendeesPageInner() {
try {
if (!token) return;
setLoadingScheduled(true);
const res = await apiFetch<{jobs: ScheduledJob[]}>(`/api/scheduled-emails?channel=email`, { authToken: token });
const res = await (apiV2Data("get", "/scheduled-messages", { query: { channel: "email" }, authToken: token }) as Promise<{jobs: ScheduledJob[]}>);
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
} catch {
// ignore here; surfaces via UI when tab open
@@ -315,7 +315,7 @@ function EmailAttendeesPageInner() {
body.html = '';
}
}
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: 'PATCH', authToken: token, body });
await apiV2Data("patch", "/scheduled-messages/{id}", { params: { id: editing.id }, authToken: token, body });
setInfo('Scheduled email updated.');
setEditing(null);
loadScheduled();
@@ -329,7 +329,7 @@ function EmailAttendeesPageInner() {
const removeJob = async (job: ScheduledJob) => {
try {
if (!token) { setError('Not authenticated'); return; }
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(job.id)}`, { method: 'DELETE', authToken: token });
await apiV2Data("delete", "/scheduled-messages/{id}", { params: { id: job.id }, authToken: token });
setInfo('Scheduled email removed.');
loadScheduled();
} catch (e) {
@@ -432,7 +432,7 @@ function EmailAttendeesPageInner() {
if (templateKey !== 'tickets') {
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body;
}
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
const res = await (apiV2Data("post", "/events/{id}/email-attendees", { params: { id: eventId }, authToken: token, body: payload }) as Promise<EmailSendResult>);
setPreviewCount(res?.matched ?? 0);
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(matchedText(res));
@@ -473,7 +473,7 @@ function EmailAttendeesPageInner() {
if (templateKey !== 'tickets') {
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
}
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
const res = await (apiV2Data("post", "/events/{id}/email-attendees", { params: { id: eventId }, authToken: token, body: payload }) as Promise<EmailSendResult>);
const queued = res?.queued ?? res?.matched ?? 0;
setInfo(`Queued ${queued} recipient(s) for sending.`);
// Reset form to default state
@@ -670,7 +670,7 @@ function EmailAttendeesPageInner() {
if (templateKey !== 'tickets') {
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
}
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees/schedule`, { method: 'POST', authToken: token, body: payload });
const res = await (apiV2Data("post", "/events/{id}/email-attendees/schedule", { params: { id: eventId }, authToken: token, body: payload }) as Promise<EmailSendResult>);
if (res?.job?.id) {
setInfo('Email scheduled. It will be sent around the specified time.');
} else {
@@ -779,7 +779,7 @@ Jane Doe <jane@example.com>
if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
if (!broadcastBody.trim()) { setError('Message is required'); return; }
const body = { userIds: selectedUserIds, emails: broadcastEmails, eventId: broadcastEventId || undefined, kind: broadcastKind };
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/preview`, { method: 'POST', authToken: token, body });
const res = await (apiV2Data("post", "/broadcasts/email/preview", { authToken: token, body }) as Promise<EmailSendResult>);
setBroadcastPreviewCount(res?.matched ?? 0);
setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(matchedText(res));
@@ -796,7 +796,7 @@ Jane Doe <jane@example.com>
if (!broadcastBody.trim()) { setError('Message is required'); return; }
const payload: { subject: string; eventId?: string; userIds: string[]; emails: string; kind: MessageKind } & EmailContent = { subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails, kind: broadcastKind };
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/send`, { method: 'POST', authToken: token, body: payload });
const res = await (apiV2Data("post", "/broadcasts/email/send", { authToken: token, body: payload }) as Promise<EmailSendResult>);
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).${res?.skipped ? ` ${res.skipped} skipped — not opted in to news or unsubscribed.` : ''}`);
// Reset broadcast form
resetBroadcastForm();
@@ -814,7 +814,7 @@ Jane Doe <jane@example.com>
const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
const payload: { scheduledAt: string; subject: string; eventId?: string; userIds: string[]; emails: string; kind: MessageKind } & EmailContent = { scheduledAt: whenIso, subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails, kind: broadcastKind };
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/schedule`, { method: 'POST', authToken: token, body: payload });
const res = await (apiV2Data("post", "/broadcasts/email/schedule", { authToken: token, body: payload }) as Promise<EmailSendResult>);
if (res?.job?.id) setInfo('Broadcast scheduled. It will be sent around the specified time.'); else setInfo('Scheduled.');
// Reset broadcast form
resetBroadcastForm();
@@ -1085,7 +1085,7 @@ With gratitude`);
jobs.push(payload);
}
if (jobs.length === 0) { onError('Please enable at least one automation and fill in details'); return; }
const res = await apiFetch<{ message?: string }>(`/api/automations/schedule`, { method: 'POST', authToken: token, body: { eventId: autoEventId, jobs } });
const res = await (apiV2Data("post", "/automations/schedule", { authToken: token, body: { eventId: autoEventId, jobs } }) as Promise<{ message?: string }>);
onInfo(res?.message || `Scheduled ${jobs.length} automation(s).`);
// Reset to defaults (keep event selection)
setEnablePre(true); setEnableFinal(true); setEnableThanks(true); setEnablePromo(false);
@@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { createPortal } from "react-dom";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, API_BASE, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { API_BASE, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
@@ -689,7 +689,7 @@ function NotifyRecipientsPicker({ selected, onChange, disabled }: { selected: No
setSearching(true);
const t = setTimeout(async () => {
try {
const res = await apiFetch<Paginated<NotifyUser>>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token || undefined });
const res = await (apiV2Data("get", "/users", { query: { search: search.trim(), limit: "10" }, authToken: token }) as Promise<Paginated<NotifyUser>>);
if (!cancelled) setResults(Array.isArray(res?.data) ? res.data : []);
} catch {
if (!cancelled) setResults([]);
@@ -3,7 +3,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
@@ -19,6 +18,7 @@ import type { DashboardStats, PaymentStats, RecentScan, ScanStats } from "@/type
import { CampusFilter, CampusScopeNote } from "@/components/shared/CampusFilter";
import { TodayPanel } from "@/components/dashboard/TodayPanel";
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
import { apiV2Data } from "@/lib/apiV2";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
@@ -91,9 +91,9 @@ export default function SupervisorDashboardPage() {
try {
if (isFirstLoad) setLoadingStats(true);
const [data, overviewData] = await Promise.all([
apiFetch<DashboardStats>("/api/stats/supervisor", { authToken: token }),
(apiV2Data("get", "/stats/supervisor", { authToken: token }) as Promise<DashboardStats>),
// The month-over-month tiles follow the campus filter; today's totals stay church-wide.
apiFetch<Overview>(`/api/stats/overview${campus ? `?campus=${encodeURIComponent(campus)}` : ""}`, { authToken: token }),
(apiV2Data("get", "/stats/overview", { query: { campus: campus || undefined }, authToken: token }) as Promise<Overview>),
]);
setScanStats(data.scanStats);
setPaymentStats(data.paymentStats);
@@ -3,7 +3,7 @@
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { MessageCircle, Users, Zap, Send, Clock, type LucideIcon } from "lucide-react";
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
@@ -249,8 +249,9 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: Event[
if (jobs.length === 0) { onError("Please enable at least one automation and fill in details"); return; }
let scheduled = 0;
for (const job of jobs) {
await apiFetch(`/api/events/${encodeURIComponent(autoEventId)}/whatsapp-attendees/schedule`, {
method: "POST", authToken: token,
await apiV2Data("post", "/events/{id}/whatsapp-attendees/schedule", {
params: { id: autoEventId },
authToken: token,
body: { message: job.message, scheduledAt: job.scheduledAt, filter: { status: "paid" } },
});
scheduled++;
@@ -462,7 +463,7 @@ function WhatsAppAttendeesPageInner() {
if (!token) { setError("Not authenticated"); return; }
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload(true) });
const res = await (apiV2Data("post", "/events/{id}/whatsapp-attendees", { params: { id: eventId }, authToken: token, body: buildPayload(true) }) as Promise<MessageSendResult>);
setPreviewCount(res?.matched ?? 0);
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(matchedText(res));
@@ -482,7 +483,7 @@ function WhatsAppAttendeesPageInner() {
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
setSending(true);
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload() });
const res = await (apiV2Data("post", "/events/{id}/whatsapp-attendees", { params: { id: eventId }, authToken: token, body: buildPayload() }) as Promise<MessageSendResult>);
setInfo(`Queued ${res?.queued ?? res?.matched ?? 0} recipient(s) for sending.`);
resetAttendeesForm();
} catch (e) { setError(errorMessage(e) || "Failed to send"); } finally { setSending(false); }
@@ -501,7 +502,7 @@ function WhatsAppAttendeesPageInner() {
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
template: templateKey, kind: messageKind, scheduledAt: whenIso,
};
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees/schedule`, { method: "POST", authToken: token, body: payload });
const res = await (apiV2Data("post", "/events/{id}/whatsapp-attendees/schedule", { params: { id: eventId }, authToken: token, body: payload }) as Promise<MessageSendResult>);
setInfo(res?.job?.id ? "WhatsApp scheduled. It will be sent around the specified time." : "Scheduled.");
resetAttendeesForm();
} catch (e) { setError(errorMessage(e) || "Failed to schedule"); }
@@ -554,7 +555,7 @@ function WhatsAppAttendeesPageInner() {
try {
if (!token) return;
setLoadingScheduled(true);
const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails?channel=whatsapp`, { authToken: token });
const res = await (apiV2Data("get", "/scheduled-messages", { query: { channel: "whatsapp" }, authToken: token }) as Promise<{ jobs: ScheduledJob[] }>);
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
} catch { } finally { setLoadingScheduled(false); }
}, [token]);
@@ -569,7 +570,7 @@ function WhatsAppAttendeesPageInner() {
const body: { scheduledAt?: string; message?: string } = {};
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
if (editMessage.trim()) body.message = editMessage;
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: "PATCH", authToken: token, body });
await apiV2Data("patch", "/scheduled-messages/{id}", { params: { id: editing.id }, authToken: token, body });
setInfo("Scheduled message updated.");
setEditing(null);
loadScheduled();
@@ -579,7 +580,7 @@ function WhatsAppAttendeesPageInner() {
const removeJob = async (job: ScheduledJob) => {
try {
if (!token) { setError("Not authenticated"); return; }
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(job.id)}`, { method: "DELETE", authToken: token });
await apiV2Data("delete", "/scheduled-messages/{id}", { params: { id: job.id }, authToken: token });
setInfo("Scheduled message removed.");
loadScheduled();
} catch (e) { setError(errorMessage(e) || "Failed to remove"); }
@@ -828,7 +829,7 @@ function WhatsAppAttendeesPageInner() {
try {
setError(null); setInfo(null); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null);
if (!token) { setError("Not authenticated"); return; }
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/preview`, { method: "POST", authToken: token, body: { userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } });
const res = await (apiV2Data("post", "/broadcasts/whatsapp/preview", { authToken: token, body: { userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } }) as Promise<MessageSendResult>);
setBroadcastPreviewCount(res?.matched ?? 0);
setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(matchedText(res));
@@ -840,7 +841,7 @@ function WhatsAppAttendeesPageInner() {
setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; }
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/send`, { method: "POST", authToken: token, body: { message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } });
const res = await (apiV2Data("post", "/broadcasts/whatsapp/send", { authToken: token, body: { message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } }) as Promise<MessageSendResult>);
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).${res?.skipped ? ` ${res.skipped} skipped — not opted in to news or unsubscribed.` : ""}`);
resetBroadcastForm();
} catch (e) { setError(errorMessage(e) || "Failed to send"); }
@@ -852,7 +853,7 @@ function WhatsAppAttendeesPageInner() {
if (!token) { setError("Not authenticated"); return; }
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/schedule`, { method: "POST", authToken: token, body: { scheduledAt: whenIso, message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } });
const res = await (apiV2Data("post", "/broadcasts/whatsapp/schedule", { authToken: token, body: { scheduledAt: whenIso, message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } }) as Promise<MessageSendResult>);
if (res?.job?.id) setInfo("WhatsApp broadcast scheduled."); else setInfo("Scheduled.");
resetBroadcastForm();
} catch (e) { setError(errorMessage(e) || "Failed to schedule broadcast"); }
@@ -1,7 +1,6 @@
"use client";
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { apiV2Data } from "@/lib/apiV2";
import { useDismissingState } from "@/hooks/useDismissingState";
import { HandHeart } from "lucide-react";
@@ -23,7 +22,7 @@ export default function DonatePage() {
useEffect(() => {
(async () => {
try {
const evs = await apiFetch<Event[]>("/api/events");
const evs = await (apiV2Data("get", "/events") as Promise<Event[]>);
setEvents(evs);
if (evs.length > 0) setEventId(evs[0].id);
} catch (e) {
@@ -3,11 +3,12 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { API_BASE } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Megaphone, Download, PencilLine, Trash2, ListChecks, type LucideIcon } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2Data } from "@/lib/apiV2";
type RequestType = "access" | "correction" | "deletion";
type RequestStatus = "open" | "completed" | "rejected" | "cancelled";
@@ -81,7 +82,7 @@ export default function PrivacyCentrePage() {
const load = useCallback(async () => {
if (!token) return;
try {
setInfo(await apiFetch<PrivacyInfo>("/api/privacy/me", { authToken: token }));
setInfo(await (apiV2Data("get", "/me/privacy", { authToken: token }) as Promise<PrivacyInfo>));
setLoadError(null);
} catch (e) {
setLoadError(errorMessage(e) || "Could not load your privacy settings.");
@@ -99,7 +100,7 @@ export default function PrivacyCentrePage() {
// Show the new state straight away; load() puts back the saved one if it fails.
setInfo({ ...info, news: { ...info.news, [channel]: value } });
try {
await apiFetch("/api/privacy/me/news", { method: "PUT", authToken: token, body: { [channel]: value } });
await apiV2Data("put", "/me/privacy/news", { authToken: token, body: { [channel]: value } });
await load();
setNewsMsg({ type: "ok", text: value ? "You're subscribed." : "You're unsubscribed." });
} catch (e) {
@@ -118,10 +119,10 @@ export default function PrivacyCentrePage() {
setDownloading(format);
setDownloadMsg(null);
try {
const res = await fetch(`${API_BASE}/api/privacy/me/export${format === "pdf" ? "?format=pdf" : ""}`, { headers: { Authorization: `Bearer ${token}` } });
const res = await fetch(`${API_BASE}/api/v2/me/privacy/export${format === "pdf" ? "?format=pdf" : ""}`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.message || `Download failed (${res.status})`);
throw new Error(body?.error?.message || body?.message || `Download failed (${res.status})`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
@@ -147,7 +148,7 @@ export default function PrivacyCentrePage() {
setSendingCorrection(true);
setCorrectionMsg(null);
try {
const res = await apiFetch<{ message?: string }>("/api/privacy/me/requests", { method: "POST", authToken: token, body: { type: "correction", details: correction } });
const res = await (apiV2Data("post", "/me/privacy/requests", { authToken: token, body: { type: "correction", details: correction } }) as Promise<{ message?: string }>);
setCorrection("");
setCorrectionMsg({ type: "ok", text: res?.message || "Request sent." });
load();
@@ -170,11 +171,10 @@ export default function PrivacyCentrePage() {
setDeleting(true);
setDeleteMsg(null);
try {
const res = await apiFetch<{ message?: string }>("/api/privacy/me/requests", {
method: "POST",
const res = await (apiV2Data("post", "/me/privacy/requests", {
authToken: token,
body: { type: "deletion", password: deletePassword, details: deleteReason || undefined },
});
}) as Promise<{ message?: string }>);
setDeleteMsg({ type: "ok", text: res?.message || "Your account is closed." });
setTimeout(() => logout(), 3000);
} catch (e) {
@@ -4,7 +4,6 @@ import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch } from "@/lib/api";
import { isValidZAPhone } from "@/lib/phone";
import { useDismissingState } from "@/hooks/useDismissingState";
import { TwoFactorChallenge, TwoFactorMethod } from "@/lib/auth";
@@ -12,7 +11,7 @@ import { User, Lock, ShieldAlert, ShieldCheck, ShieldOff, Trash2, Clock, LogIn,
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { useCampuses } from "@/hooks/useCampuses";
import { apiV2 } from "@/lib/apiV2";
import { apiV2, apiV2Data } from "@/lib/apiV2";
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset" | "two_factor_enabled" | "two_factor_disabled"; device: string | null; createdAt: string };
@@ -127,10 +126,10 @@ export default function UserProfilePage() {
setRevoking(true);
setRevokeMsg(null);
try {
const res = await apiFetch<{ token?: string; message?: string }>("/api/users/revoke-sessions", {
method: "POST",
const res = await (apiV2Data("post", "/me/sessions/revoke", {
authToken: token,
});
body: {},
}) as Promise<{ token?: string; message?: string }>);
// Revoking sessions invalidates the token this device was using too —
// save the freshly issued one so this device stays signed in.
if (res?.token) updateToken(res.token);
@@ -150,7 +149,7 @@ export default function UserProfilePage() {
if (!token) return;
setLoadingActivity(true);
try {
const res = await apiFetch<ActivityEvent[]>("/api/users/activity", { authToken: token });
const res = await (apiV2Data("get", "/me/activity", { authToken: token }) as Promise<ActivityEvent[]>);
setActivity(Array.isArray(res) ? res : []);
} catch {
// Non-critical — just leave the list empty rather than showing an error banner.
@@ -198,11 +197,10 @@ export default function UserProfilePage() {
setTfBusy(true);
setTfMsg(null);
try {
const res = await apiFetch<TwoFactorChallenge>("/api/users/2fa/enable", {
method: "POST",
const res = await (apiV2Data("post", "/me/two-factor/enable", {
authToken: token,
body: { method: tfMethod, password: tfPassword },
});
}) as Promise<TwoFactorChallenge>);
setTfChallenge(res);
setTfCode("");
setTfPassword("");
@@ -220,11 +218,10 @@ export default function UserProfilePage() {
setTfBusy(true);
setTfMsg(null);
try {
const res = await apiFetch<{ twoFactorMethod: TwoFactorMethod }>("/api/users/2fa/confirm", {
method: "POST",
const res = await (apiV2Data("post", "/me/two-factor/confirm", {
authToken: token,
body: { challengeId: tfChallenge.challengeId, code: tfCode },
});
}) as Promise<{ twoFactorMethod: TwoFactorMethod }>);
const wasEnabled = tfEnabled;
setTfEnabled(true);
setTfMethod(res.twoFactorMethod);
@@ -244,8 +241,7 @@ export default function UserProfilePage() {
setTfBusy(true);
setTfMsg(null);
try {
await apiFetch("/api/users/2fa/disable", {
method: "POST",
await apiV2Data("post", "/me/two-factor/disable", {
authToken: token,
body: { password: tfPassword },
});
@@ -273,11 +269,10 @@ export default function UserProfilePage() {
setClosing(true);
setCloseMsg(null);
try {
const res = await apiFetch<{ message?: string }>("/api/users/close-account", {
method: "POST",
const res = await (apiV2Data("post", "/me/close", {
authToken: token,
body: { password: closePassword, deleteData },
});
}) as Promise<{ message?: string }>);
setCloseMsg({ type: "ok", text: res?.message || "Account closed." });
// Slight delay so the user can read the message, then log out
setTimeout(() => logout(), 2500);
+1 -1
View File
@@ -32,7 +32,7 @@ type Event = {
};
export default async function EventsPage() {
const events = await apiFetchListForPrerender<Event>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
const events = await apiFetchListForPrerender<Event>("/api/v2/event-listing", { nextOptions: { next: { revalidate: 60 } } });
const now = Date.now();
const upcoming = (events || []).filter(e => {
if (e.cashupStatus === "closed") return false;
+3 -2
View File
@@ -7,7 +7,8 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useScrollSpy } from "@/hooks/useScrollSpy";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { apiFetch, ApiError } from "@/lib/api";
import { ApiError } from "@/lib/api";
import { apiV2Data } from "@/lib/apiV2";
import { PrivacyNoticeSection, type PublicNotice } from "@/components/shared/PrivacyNoticeSection";
const fmtDate = (d: string) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" });
@@ -28,7 +29,7 @@ function PrivacyNotice() {
useEffect(() => {
setNotice(null);
setError(null);
apiFetch<PublicNotice>(`/api/privacy/notice${versionParam ? `?version=${encodeURIComponent(versionParam)}` : ""}`)
(apiV2Data("get", "/privacy/notice", { query: { version: versionParam || undefined } }) as Promise<PublicNotice>)
.then(setNotice)
.catch(e => setError(e instanceof ApiError ? e.message : "The privacy notice couldn't be loaded. Please try again."));
}, [versionParam]);
+3 -3
View File
@@ -38,9 +38,9 @@ const FEATURES = [
async function getOrgName(): Promise<string> {
if (!API_BASE) return appName;
try {
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
const res = await fetch(`${API_BASE}/api/v2/settings`, { next: { revalidate: 60 } });
if (!res.ok) return appName;
const settings = await res.json();
const settings = (await res.json())?.data;
return settings?.org_name || appName;
} catch {
return appName;
@@ -49,7 +49,7 @@ async function getOrgName(): Promise<string> {
export default async function HomePage() {
const [events, displayName] = await Promise.all([
apiFetchListForPrerender<Event>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
apiFetchListForPrerender<Event>("/api/v2/event-listing", { nextOptions: { next: { revalidate: 60 } } }),
getOrgName(),
]);
const now = Date.now();
@@ -75,7 +75,7 @@ function RegistrationSuccessContent() {
(async () => {
try {
if (token && reg?.id) {
await (await import('@/lib/api')).apiFetch('/api/tickets/generate', { method: 'POST', authToken: token, body: { registrationId: reg.id } });
await apiV2Data("post", "/tickets/generate", { authToken: token, body: { registrationId: reg.id } });
}
} catch {}
setTimeout(() => router.replace('/dashboard'), 600);
+2 -2
View File
@@ -2,10 +2,10 @@
import React, { Suspense, useMemo, useState } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { useSearchParams, useRouter } from "next/navigation";
import { KeyRound } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
function ResetPasswordContent() {
const search = useSearchParams();
@@ -24,7 +24,7 @@ function ResetPasswordContent() {
try {
setLoading(true);
setStatus(null);
await apiFetch("/api/users/reset", { method: "POST", body: { token, password } });
await apiV2Data("post", "/auth/password/reset", { body: { token, password } });
setStatus("Your password has been reset. You can now log in.");
setTimeout(() => router.push("/login"), 1500);
} catch (err) {
+16 -15
View File
@@ -2,7 +2,7 @@
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiFetch, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { fetchManagedEvents, fetchEvent } from "@/lib/api";
import { AuthResponse, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
@@ -13,7 +13,8 @@ import { choosesSessions, isPickOption } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import { ModalOverlay } from "@/components/shared/ModalOverlay";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
import { apiV2Data, apiV2 } from "@/lib/apiV2";
import { toAuthResponse } from "@/contexts/AuthContext";
// ─── Types ────────────────────────────────────────────────────────────────────
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
@@ -337,15 +338,16 @@ export default function SelfServicePage() {
try {
// Second pass for accounts with two-factor on: exchange the code that was
// sent after the password step. The kiosk never asks to be remembered.
const data = setupChallenge
? await apiFetch<AuthResponse>("/api/users/login/2fa", {
method: "POST",
body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false },
})
: await apiFetch<AuthResponse | TwoFactorChallenge>("/api/users/login", {
method: "POST",
body: { email: setupEmail, password: setupPassword },
});
let data: AuthResponse | TwoFactorChallenge;
if (setupChallenge) {
const { data: session } = await apiV2("post", "/auth/login/code", { body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false } });
data = toAuthResponse(session);
} else {
const { data: res } = await apiV2("post", "/auth/login", { body: { identifier: setupEmail, password: setupPassword } });
data = res.status === "code_required"
? { twoFactorRequired: true, challengeId: res.challengeId, method: res.method, destination: res.destination || "" }
: toAuthResponse(res);
}
if (isTwoFactorChallenge(data)) {
setSetupChallenge(data);
setSetupCode("");
@@ -394,8 +396,7 @@ export default function SelfServicePage() {
// holds, rather than logging in again — a fresh login would send a new
// two-factor code every time the event is changed.
if (!supervisorToken) throw new Error("Your session has expired. Reload the kiosk and sign in again.");
await apiFetch("/api/users/verify-password", {
method: "POST",
await apiV2Data("post", "/me/verify-password", {
authToken: supervisorToken,
body: { password: changePassword },
});
@@ -635,10 +636,10 @@ export default function SelfServicePage() {
const params = new URLSearchParams();
if (kind === "email") params.set("email", value);
else params.set("phone", value);
const data = await apiFetch<{
const data = await (apiV2Data("get", "/users/exists", { query: Object.fromEntries(params), authToken: supervisorToken }) as Promise<{
exists: boolean;
user?: MatchedAccount | null;
}>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken });
}>);
if (data?.exists && data.user) {
setMatch(data.user);
setLookupMessage("Account found — details filled in below.");
+1 -1
View File
@@ -47,7 +47,7 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
const series = await loadSeries(slug);
if (!series) notFound();
const events = await apiFetchListForPrerender<Event>(`/api/events?series=${encodeURIComponent(series.slug)}`, { nextOptions: { next: { revalidate } } });
const events = await apiFetchListForPrerender<Event>(`/api/v2/event-listing?series=${encodeURIComponent(series.slug)}`, { nextOptions: { next: { revalidate } } });
const now = Date.now();
const upcoming = (events || [])
.filter(e => e.cashupStatus !== "closed" && new Date(e.endDate || e.startDate).getTime() > now)
+4 -6
View File
@@ -3,9 +3,9 @@
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { Megaphone } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type BannerType = "info" | "warning" | "success" | "danger";
@@ -38,7 +38,7 @@ export default function SetBannerPage() {
// Load current banner
useEffect(() => {
apiFetch<{ message?: string; type?: BannerType; liveFrom?: string | null; liveTill?: string | null } | null>("/api/banner")
(apiV2Data("get", "/banner") as Promise<{ message?: string; type?: BannerType; liveFrom?: string | null; liveTill?: string | null } | null>)
.then(b => {
if (!b) return;
setMessage(b.message || "");
@@ -65,8 +65,7 @@ export default function SetBannerPage() {
setStatus(null);
setBusy(true);
try {
await apiFetch("/api/banner", {
method: "POST",
await apiV2Data("put", "/banner", {
authToken: token,
body: {
message,
@@ -89,8 +88,7 @@ export default function SetBannerPage() {
setStatus(null);
setBusy(true);
try {
await apiFetch("/api/banner", {
method: "POST",
await apiV2Data("put", "/banner", {
authToken: token,
body: { message: "", type: "info", liveFrom: null, liveTill: null },
});
+7 -10
View File
@@ -2,13 +2,13 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { extractDominantColors } from "@/lib/extractColors";
import { errorMessage } from "@/lib/utils";
import { ConnectForm } from "@/components/admin/LicenceTab";
import { uploadImage } from "@/lib/eventSetup";
import { apiV2Data } from "@/lib/apiV2";
type Step = 1 | 2 | 3 | 4 | 5 | 6;
@@ -103,7 +103,7 @@ export default function SetupPage() {
// Check setup isn't already done
useEffect(() => {
apiFetch<{ needsSetup: boolean }>("/api/settings/needs-setup")
(apiV2Data("get", "/setup/needed") as Promise<{ needsSetup: boolean }>)
.then((d) => { if (!d?.needsSetup) router.replace("/login"); })
.catch(() => {});
}, [router]);
@@ -144,10 +144,9 @@ export default function SetupPage() {
if (adminPassword !== adminConfirm) { setError("Passwords do not match."); return; }
setLoading(true);
try {
const res = await apiFetch<{ token: string }>("/api/setup/register", {
method: "POST",
const res = await (apiV2Data("post", "/setup/register", {
body: { adminName, adminEmail, adminPassword },
});
}) as Promise<{ token: string }>);
setSetupToken(res.token);
next();
} catch (e) {
@@ -166,8 +165,7 @@ export default function SetupPage() {
setSmtpTesting(true);
setSmtpTestResult(null);
try {
const res = await apiFetch<{ ok: boolean; message?: string }>("/api/settings/test-smtp", {
method: "POST",
const res = await (apiV2Data("post", "/settings/test-smtp", {
authToken: setupToken,
body: {
host: smtpHost.trim(),
@@ -177,7 +175,7 @@ export default function SetupPage() {
user: smtpUser.trim() || undefined,
pass: smtpPass || undefined,
},
});
}) as Promise<{ ok: boolean; message?: string }>);
setSmtpTestResult({ ok: true, message: res.message || "Connection successful." });
} catch (e) {
setSmtpTestResult({ ok: false, message: errorMessage(e) || "Connection failed." });
@@ -235,8 +233,7 @@ export default function SetupPage() {
if (smtpPass.trim()) settings.smtp_pass = smtpPass.trim();
}
await apiFetch("/api/setup", {
method: "POST",
await apiV2Data("post", "/setup", {
authToken: setupToken,
body: { settings },
});
+1 -2
View File
@@ -1,5 +1,4 @@
import type { MetadataRoute } from "next";
import { apiFetch } from "@/lib/api";
import { appUrl } from "@/lib/siteConfig";
import { apiV2Data } from "@/lib/apiV2";
@@ -18,7 +17,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
let events: Event[] = [];
try {
events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate } } });
events = await (apiV2Data("get", "/events", { nextOptions: { next: { revalidate } } }) as Promise<Event[]>);
} catch {
// Backend unreachable at build/revalidate time — ship the static entries only,
// same degrade-gracefully posture as layout.tsx's getServerSettings.
+4 -3
View File
@@ -4,8 +4,9 @@ import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch, ApiError } from "@/lib/api";
import { ApiError } from "@/lib/api";
import { MailX } from "lucide-react";
import { apiV2Data } from "@/lib/apiV2";
type Info =
| { kind: "account"; email: string; hasPhone: boolean; news: { email: boolean; whatsapp: boolean } }
@@ -21,7 +22,7 @@ function Unsubscribe() {
useEffect(() => {
if (!token) return;
apiFetch<Info>(`/api/privacy/unsubscribe/${encodeURIComponent(token)}`)
(apiV2Data("get", "/unsubscribe/{token}", { params: { token: token } }) as Promise<Info>)
.then(setInfo)
.catch(e => setError(e instanceof ApiError ? e.message : "Couldn't load this link."));
}, [token]);
@@ -29,7 +30,7 @@ function Unsubscribe() {
const unsubscribe = async (channels?: ("email" | "whatsapp")[]) => {
setBusy(true);
try {
await apiFetch(`/api/privacy/unsubscribe/${encodeURIComponent(token)}`, { method: "POST", body: channels ? { channels } : {} });
await apiV2Data("post", "/unsubscribe/{token}", { params: { token: token }, body: channels ? { channels } : {} });
setDone(true);
} catch (e) {
setError(e instanceof ApiError ? e.message : "Couldn't unsubscribe. Please try again.");
+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>
);
}
+8 -7
View File
@@ -2,10 +2,11 @@
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { API_BASE } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { DatabaseBackup, Download } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
interface BackupEntry {
filename: string;
@@ -34,8 +35,8 @@ export function BackupsTab({ active }: { active: boolean }) {
setLoading(true);
try {
const [list, allSettings] = await Promise.all([
apiFetch<BackupEntry[]>("/api/backups", { authToken: token }),
apiFetch<Record<string, string>>("/api/settings/all", { authToken: token }),
(apiV2Data("get", "/backups", { authToken: token }) as Promise<BackupEntry[]>),
(apiV2Data("get", "/settings/all", { authToken: token }) as Promise<Record<string, string>>),
]);
setBackups(list || []);
setRetainCount(allSettings?.backup_retain_count || "14");
@@ -50,7 +51,7 @@ export function BackupsTab({ active }: { active: boolean }) {
if (!token) return;
setSavingRetain(true);
try {
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: { backup_retain_count: retainCount } });
await apiV2Data("put", "/settings", { authToken: token, body: { backup_retain_count: retainCount } });
setMessage({ type: "ok", text: "Retention setting saved." });
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Failed to save retention setting" });
@@ -66,7 +67,7 @@ export function BackupsTab({ active }: { active: boolean }) {
setRunning(true);
setMessage(null);
try {
const res = await apiFetch<{ filename: string }>("/api/backups/run", { method: "POST", authToken: token });
const res = await (apiV2Data("post", "/backups/run", { authToken: token, body: {} }) as Promise<{ filename: string }>);
setMessage({ type: "ok", text: `Backup created: ${res.filename}` });
await load();
} catch (e) {
@@ -141,14 +142,14 @@ export function BackupsTab({ active }: { active: boolean }) {
<td className="p-2.5 text-gray-500">{formatSize(b.size)}</td>
<td className="p-2.5">
<a
href={`${API_BASE}/api/backups/${encodeURIComponent(b.filename)}/download`}
href={`${API_BASE}/api/v2/backups/${encodeURIComponent(b.filename)}/download`}
className="inline-flex items-center gap-1.5 text-brand-600 hover:underline"
onClick={(e) => {
// authenticated download: fetch as blob rather than a bare link,
// since this route requires an admin bearer token
e.preventDefault();
if (!token) return;
fetch(`${API_BASE}/api/backups/${encodeURIComponent(b.filename)}/download`, {
fetch(`${API_BASE}/api/v2/backups/${encodeURIComponent(b.filename)}/download`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.blob())
@@ -3,7 +3,6 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { MapPin, Plus, ArrowUp, ArrowDown, Archive, ArchiveRestore, Trash2, Pencil } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { useDismissingState } from "@/hooks/useDismissingState";
import { reloadCampuses, type Campus } from "@/hooks/useCampuses";
@@ -41,7 +40,7 @@ function RecipientsField({ value, onChange }: { value: Person[]; onChange: (v: P
let cancelled = false;
const t = setTimeout(async () => {
try {
const res = await apiFetch<{ data?: (Person & { role?: string })[] }>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token });
const res = await (apiV2Data("get", "/users", { query: { search: search.trim(), limit: "10" }, authToken: token }) as Promise<{ data?: (Person & { role?: string })[] }>);
if (!cancelled) setResults((res?.data || []).filter(u => u.role !== "user"));
} catch { if (!cancelled) setResults([]); }
}, 300);
+3 -3
View File
@@ -3,9 +3,9 @@
import React, { useCallback, useEffect, useState } from "react";
import { Timer, Play, RefreshCw } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type JobRun = { state: string; createdOn?: string; startedOn: string | null; completedOn: string | null; output: unknown };
type JobInfo = { name: string; label: string; cron: string; enabled: boolean; envFlag: string | null; nextRun: string | null; recent: JobRun[] };
@@ -50,7 +50,7 @@ export function JobsTab({ active }: { active: boolean }) {
if (!token) return;
setLoading(true);
try {
setData(await apiFetch<JobsResponse>("/api/admin/jobs", { authToken: token }));
setData(await (apiV2Data("get", "/admin/jobs", { authToken: token }) as Promise<JobsResponse>));
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Failed to load background jobs" });
} finally {
@@ -64,7 +64,7 @@ export function JobsTab({ active }: { active: boolean }) {
if (!token) return;
setRunning(job.name);
try {
const r = await apiFetch<{ queued: boolean }>(`/api/admin/jobs/${job.name}/run`, { method: "POST", authToken: token });
const r = await (apiV2Data("post", "/admin/jobs/{name}/run", { params: { name: job.name }, authToken: token, body: {} }) as Promise<{ queued: boolean }>);
setMessage({ type: "ok", text: r.queued ? `"${job.label}" queued — it starts within a few seconds.` : `"${job.label}" ran.` });
setTimeout(load, r.queued ? 4000 : 0);
} catch (e) {
+9 -9
View File
@@ -2,9 +2,9 @@
import React, { useCallback, useEffect, useState } from "react";
import { KeyRound, RefreshCw, ShieldCheck, ShieldAlert, ArrowUpCircle, Undo2, BellOff, BellRing, Megaphone } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { errorMessage } from "@/lib/utils";
import { apiV2Data, apiV2Raw } from "@/lib/apiV2";
export type HubStatus = {
state: "active" | "grace" | "suspended" | "revoked" | "unlicensed";
@@ -76,7 +76,7 @@ export function ConnectForm({ token, onConnected, compact = false }: { token: st
setBusy(true);
setError(null);
try {
onConnected(await apiFetch<HubStatus>("/api/hub/connect", { method: "POST", authToken: token, body: { licenceKey: key } }));
onConnected(await (apiV2Data("post", "/hub/connect", { authToken: token, body: { licenceKey: key } }) as Promise<HubStatus>));
setKey("");
} catch (err) {
setError(errorMessage(err) || "Could not connect.");
@@ -107,7 +107,7 @@ export function LicenceTab({ active }: { active: boolean }) {
const load = useCallback(async () => {
if (!token) return;
try { setStatus(await apiFetch<HubStatus>("/api/hub/status", { authToken: token })); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Could not load the licence." }); }
try { setStatus(await (apiV2Data("get", "/hub/status", { authToken: token }) as Promise<HubStatus>)); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Could not load the licence." }); }
}, [token]);
useEffect(() => { if (active) load(); }, [active, load]);
@@ -115,7 +115,7 @@ export function LicenceTab({ active }: { active: boolean }) {
setBusy(true);
setMsg(null);
try {
setStatus(await apiFetch<HubStatus>("/api/hub/heartbeat", { method: "POST", authToken: token }));
setStatus(await (apiV2Data("post", "/hub/heartbeat", { authToken: token, body: {} }) as Promise<HubStatus>));
setMsg({ type: "ok", text: "Checked with the Hub." });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "The Hub could not be reached." });
@@ -130,7 +130,7 @@ export function LicenceTab({ active }: { active: boolean }) {
setBusy(true);
setMsg(null);
try {
setStatus(await apiFetch<HubStatus>(path, { method: "POST", authToken: token, body }));
setStatus(await apiV2Raw<HubStatus>("post", path, { authToken: token, body: body ?? {} }));
setMsg({ type: "ok", text: ok });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "The Hub could not be reached." });
@@ -138,14 +138,14 @@ export function LicenceTab({ active }: { active: boolean }) {
setBusy(false);
}
};
const requestUpdate = () => updateAction("/api/hub/update-request", undefined, "Update requested. Your provider has been told and will update the site.");
const requestUpdate = () => updateAction("/hub/update-request", undefined, "Update requested. Your provider has been told and will update the site.");
// Same request again: the Hub moves the open request on to the latest version.
const changeRequest = () => updateAction("/api/hub/update-request", undefined, `Request changed to version ${status?.latestVersion}. Your provider has been told.`);
const changeRequest = () => updateAction("/hub/update-request", undefined, `Request changed to version ${status?.latestVersion}. Your provider has been told.`);
const withdrawRequest = () => {
if (!confirm("Withdraw the update request? Your provider is told not to update the site for now.")) return;
updateAction("/api/hub/update-request/cancel", undefined, "Update request withdrawn. Your provider has been told.");
updateAction("/hub/update-request/cancel", undefined, "Update request withdrawn. Your provider has been told.");
};
const snooze = (on: boolean) => updateAction("/api/hub/update-snooze", { snooze: on }, on ? "Got it — no more reminders until the next version comes out." : "Update reminders are back on.");
const snooze = (on: boolean) => updateAction("/hub/update-snooze", { snooze: on }, on ? "Got it — no more reminders until the next version comes out." : "Update reminders are back on.");
if (!status) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
const st = STATE[status.state];
@@ -2,10 +2,10 @@
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Wallet } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
interface PayFastConfig {
merchantId: string;
@@ -39,7 +39,7 @@ export function PayFastSettings({ active, onChanged }: { active: boolean; onChan
const load = useCallback(async () => {
if (!token) return;
try {
const c = await apiFetch<PayFastConfig>("/api/payfast/config", { authToken: token });
const c = await (apiV2Data("get", "/payments/payfast/config", { authToken: token }) as Promise<PayFastConfig>);
setCfg(c);
setMerchantId(c.merchantId);
setMerchantKey(c.merchantKey);
@@ -57,8 +57,8 @@ export function PayFastSettings({ active, onChanged }: { active: boolean; onChan
if (!token) return;
setSaving(true);
try {
await apiFetch("/api/payfast/config", {
method: "PUT", authToken: token,
await apiV2Data("put", "/payments/payfast/config", {
authToken: token,
// An empty passphrase box keeps the saved one.
body: { merchantId, merchantKey, mode, ...(passphrase ? { passphrase } : {}) },
});
@@ -78,7 +78,7 @@ export function PayFastSettings({ active, onChanged }: { active: boolean; onChan
if (!token) return;
setTesting(true);
try {
const res = await apiFetch<{ message: string }>("/api/payfast/test", { method: "POST", authToken: token });
const res = await (apiV2Data("post", "/payments/payfast/test", { authToken: token, body: {} }) as Promise<{ message: string }>);
setMessage({ type: "ok", text: res.message });
} catch (err) {
setMessage({ type: "err", text: errorMessage(err) || "PayFast didn't accept the settings" });
@@ -90,7 +90,7 @@ export function PayFastSettings({ active, onChanged }: { active: boolean; onChan
const removePassphrase = async () => {
if (!token || !window.confirm("Remove the passphrase? Only do this if it's also removed on your PayFast dashboard.")) return;
try {
await apiFetch("/api/payfast/config", { method: "PUT", authToken: token, body: { passphrase: "" } });
await apiV2Data("put", "/payments/payfast/config", { authToken: token, body: { passphrase: "" } });
setMessage({ type: "ok", text: "Passphrase removed." });
await load();
} catch (err) {
+10 -10
View File
@@ -3,9 +3,9 @@
import React, { useCallback, useEffect, useState } from "react";
import { Webhook, Send, RefreshCw, KeyRound, Trash2, Pencil, Copy, ChevronDown, ChevronRight, RotateCcw } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { WebhookGuide } from "./WebhookGuide";
import { apiV2Data } from "@/lib/apiV2";
type EventType = { type: string; label: string; description: string; example?: unknown };
type Endpoint = {
@@ -60,7 +60,7 @@ function Deliveries({ endpointId, token }: { endpointId: string; token: string }
const qs = new URLSearchParams({ limit: String(PAGE) });
if (status) qs.set("status", status);
if (before) qs.set("before", before);
return apiFetch<Delivery[]>(`/api/outbound-webhooks/${endpointId}/deliveries?${qs.toString()}`, { authToken: token }).catch(() => [] as Delivery[]);
return (apiV2Data("get", "/outbound-webhooks/{id}/deliveries", { params: { id: endpointId }, query: Object.fromEntries(qs), authToken: token }) as Promise<Delivery[]>).catch(() => [] as Delivery[]);
}, [endpointId, token, status]);
const load = useCallback(async () => {
const page = await fetchPage();
@@ -76,7 +76,7 @@ function Deliveries({ endpointId, token }: { endpointId: string; token: string }
};
const resend = async (id: string) => {
setBusy(id);
await apiFetch(`/api/outbound-webhooks/deliveries/${id}/resend`, { method: "POST", authToken: token }).catch(() => {});
await apiV2Data("post", "/outbound-webhook-deliveries/{id}/resend", { params: { id: id }, authToken: token, body: {} }).catch(() => {});
setBusy(null);
load();
};
@@ -139,7 +139,7 @@ export function WebhooksTab({ active }: { active: boolean }) {
const load = useCallback(async () => {
if (!token) return;
try { setData(await apiFetch<ListResponse>("/api/outbound-webhooks", { authToken: token })); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Couldn't load webhooks" }); }
try { setData(await (apiV2Data("get", "/outbound-webhooks", { authToken: token }) as Promise<ListResponse>)); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Couldn't load webhooks" }); }
}, [token]);
useEffect(() => { if (active) load(); }, [active, load]);
@@ -152,10 +152,10 @@ export function WebhooksTab({ active }: { active: boolean }) {
if (!draft) return;
const body = { name: draft.name, url: draft.url, events: draft.events };
if (draft.id) {
await apiFetch(`/api/outbound-webhooks/${draft.id}`, { method: "PUT", authToken: token || undefined, body });
await apiV2Data("patch", "/outbound-webhooks/{id}", { params: { id: draft.id }, authToken: token, body });
setMsg({ type: "ok", text: "Webhook saved." });
} else {
const made = await apiFetch<Endpoint & { secret: string }>("/api/outbound-webhooks", { method: "POST", authToken: token || undefined, body });
const made = await (apiV2Data("post", "/outbound-webhooks", { authToken: token, body }) as Promise<Endpoint & { secret: string }>);
setSecret({ endpointId: made.id, value: made.secret });
setMsg({ type: "ok", text: `"${made.name}" added, and listed in the operator register (Admin → Privacy).` });
}
@@ -164,11 +164,11 @@ export function WebhooksTab({ active }: { active: boolean }) {
});
const toggle = (e: Endpoint) => run(async () => {
await apiFetch(`/api/outbound-webhooks/${e.id}`, { method: "PUT", authToken: token || undefined, body: { isActive: !e.isActive } });
await apiV2Data("patch", "/outbound-webhooks/{id}", { params: { id: e.id }, authToken: token, body: { isActive: !e.isActive } });
await load();
});
const test = (e: Endpoint) => run(async () => {
const r = await apiFetch<{ status: string; statusCode: number | null; error: string | null }>(`/api/outbound-webhooks/${e.id}/test`, { method: "POST", authToken: token || undefined });
const r = await (apiV2Data("post", "/outbound-webhooks/{id}/test", { params: { id: e.id }, authToken: token, body: {} }) as Promise<{ status: string; statusCode: number | null; error: string | null }>);
setMsg(r.status === "succeeded"
? { type: "ok", text: `Test delivered (answered ${r.statusCode}).` }
: { type: "err", text: `Test not delivered: ${r.error || "no answer"}. It will be retried; see the delivery log.` });
@@ -177,12 +177,12 @@ export function WebhooksTab({ active }: { active: boolean }) {
});
const rotate = (e: Endpoint) => run(async () => {
if (!confirm(`Make a new secret for "${e.name}"? The old one stops working at once, so update the receiving side straight away.`)) return;
const r = await apiFetch<{ secret: string }>(`/api/outbound-webhooks/${e.id}/rotate-secret`, { method: "POST", authToken: token || undefined });
const r = await (apiV2Data("post", "/outbound-webhooks/{id}/rotate-secret", { params: { id: e.id }, authToken: token, body: {} }) as Promise<{ secret: string }>);
setSecret({ endpointId: e.id, value: r.secret });
});
const remove = (e: Endpoint) => run(async () => {
if (!confirm(`Delete "${e.name}" and its delivery log?`)) return;
await apiFetch(`/api/outbound-webhooks/${e.id}`, { method: "DELETE", authToken: token || undefined });
await apiV2Data("delete", "/outbound-webhooks/{id}", { params: { id: e.id }, authToken: token });
setMsg({ type: "ok", text: "Webhook deleted. Its operator register entry is marked inactive." });
await load();
});
+14 -12
View File
@@ -2,10 +2,10 @@
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Copy, CreditCard, KeyRound, Trash2, Webhook } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type Source = "db" | "env" | "none";
@@ -98,7 +98,7 @@ export function YocoTab({ active, onChanged }: { active: boolean; onChanged?: ()
const loadHooks = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ webhooks: YocoWebhook[] }>("/api/yoco/webhooks", { authToken: token });
const res = await (apiV2Data("get", "/payments/yoco/webhooks", { authToken: token }) as Promise<{ webhooks: YocoWebhook[] }>);
setHooks(res.webhooks || []);
setHooksError(null);
} catch (e) {
@@ -111,7 +111,7 @@ export function YocoTab({ active, onChanged }: { active: boolean; onChanged?: ()
if (!token) return;
setLoading(true);
try {
const c = await apiFetch<YocoConfig>("/api/yoco/config", { authToken: token });
const c = await (apiV2Data("get", "/payments/yoco/config", { authToken: token }) as Promise<YocoConfig>);
setCfg(c);
setHookUrl((prev) => prev || c.webhookUrl);
if (c.secretKeySource !== "none") await loadHooks();
@@ -141,29 +141,31 @@ export function YocoTab({ active, onChanged }: { active: boolean; onChanged?: ()
};
const saveKey = (value: string) => run("key", async () => {
await apiFetch("/api/yoco/config", { method: "PUT", authToken: token!, body: { secretKey: value } });
await apiV2Data("put", "/payments/yoco/config", { authToken: token, body: { secretKey: value } });
setEditingKey(false);
setKeyInput("");
return value ? "Secret key saved." : "Secret key removed.";
});
const saveSecret = (value: string) => run("secret", async () => {
await apiFetch("/api/yoco/config", { method: "PUT", authToken: token!, body: { webhookSecret: value } });
await apiV2Data("put", "/payments/yoco/config", { authToken: token, body: { webhookSecret: value } });
setSecretInput("");
return value ? "Webhook secret saved." : "Webhook secret removed.";
});
const generate = () => run("generate", async () => {
const res = await apiFetch<{ message: string }>("/api/yoco/webhooks", {
method: "POST", authToken: token!, body: { name: hookName.trim(), url: hookUrl.trim() },
});
const res = await (apiV2Data("post", "/payments/yoco/webhooks", {
authToken: token,
body: { name: hookName.trim(), url: hookUrl.trim() },
}) as Promise<{ message: string }>);
return res.message;
});
const removeHook = (id: string) => run(`delete:${id}`, async () => {
const res = await apiFetch<{ message: string }>(`/api/yoco/webhooks/${encodeURIComponent(id)}`, {
method: "DELETE", authToken: token!,
});
const res = await (apiV2Data("delete", "/payments/yoco/webhooks/{id}", {
params: { id: id },
authToken: token,
}) as Promise<{ message: string }>);
setConfirmDelete(null);
return res.message;
});
@@ -282,7 +284,7 @@ export function YocoTab({ active, onChanged }: { active: boolean; onChanged?: ()
<p className="text-xs text-gray-500">Registers a webhook with Yoco and saves the secret it returns. Yoco only shows the secret once, so it is stored straight away.</p>
<div className="grid sm:grid-cols-3 gap-2">
<input className={inputCls} value={hookName} onChange={(e) => setHookName(e.target.value)} placeholder="Name" />
<input className={`${inputCls} sm:col-span-2`} value={hookUrl} onChange={(e) => setHookUrl(e.target.value)} placeholder="https://…/api/webhooks/yoco" />
<input className={`${inputCls} sm:col-span-2`} value={hookUrl} onChange={(e) => setHookUrl(e.target.value)} placeholder="https://…/api/v2/webhooks/yoco" />
</div>
<button type="button" className={primaryBtn} disabled={busy === "generate" || !hookUrl.trim()} onClick={generate}>
{busy === "generate" ? "Registering…" : "Register webhook"}
@@ -2,9 +2,9 @@
import React, { useCallback, useEffect, useState } from "react";
import { Plus, Copy, Pencil } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { apiV2Data } from "@/lib/apiV2";
type Status = "open" | "contained" | "closed";
type Breach = {
@@ -90,7 +90,7 @@ export function BreachesPanel({ token }: { token: string }) {
const load = useCallback(async () => {
try {
setList(await apiFetch<Breach[]>("/api/privacy/breaches", { authToken: token }));
setList(await (apiV2Data("get", "/privacy/breaches", { authToken: token }) as Promise<Breach[]>));
} catch (e) {
setError(errorMessage(e) || "Could not load the register.");
}
@@ -101,8 +101,8 @@ export function BreachesPanel({ token }: { token: string }) {
setBusy(true);
setError(null);
try {
if (id === "new") await apiFetch("/api/privacy/breaches", { method: "POST", authToken: token, body });
else await apiFetch(`/api/privacy/breaches/${id}`, { method: "PUT", authToken: token, body });
if (id === "new") await apiV2Data("post", "/privacy/breaches", { authToken: token, body });
else await apiV2Data("put", "/privacy/breaches/{id}", { params: { id: id }, authToken: token, body });
setEditing(null);
await load();
} catch (e) {
@@ -1,8 +1,8 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
const fmt = (d: string | null) => (d ? new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—");
const selectCls = "border rounded-lg px-3 py-2 text-sm bg-white";
@@ -44,7 +44,7 @@ export function ConsentLogPanel({ token }: { token: string }) {
const load = useCallback(async () => {
setData(null);
try {
setData(await apiFetch(`/api/privacy/consent-log?type=${type}&page=${page}${query ? `&q=${encodeURIComponent(query)}` : ""}`, { authToken: token }));
setData(await (apiV2Data("get", "/privacy/consent-log", { query: { type, page: String(page), q: query || undefined }, authToken: token }) as Promise<NonNullable<typeof data>>));
setError(null);
} catch (e) {
setError(errorMessage(e) || "Could not load the consent log.");
@@ -137,7 +137,7 @@ export function AccessLogPanel({ token }: { token: string }) {
if (action) params.set("action", action);
if (actorId) params.set("actorId", actorId);
if (sensitiveOnly) params.set("sensitive", "true");
setData(await apiFetch<AccessResponse>(`/api/privacy/access-log?${params}`, { authToken: token }));
setData(await (apiV2Data("get", "/privacy/access-log", { query: Object.fromEntries(new URLSearchParams(params)), authToken: token }) as Promise<AccessResponse>));
setError(null);
} catch (e) {
setError(errorMessage(e) || "Could not load the access log.");
@@ -3,9 +3,9 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { ArrowUp, ArrowDown, Trash2, Plus, RotateCcw, Eye, PencilLine, Sparkles } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { PrivacyNoticeSection, type NoticeSection } from "@/components/shared/PrivacyNoticeSection";
import { apiV2Data } from "@/lib/apiV2";
type Version = { version: number; publishedAt: string; changeSummary: string | null; requiresAcceptance: boolean; publishedBy: string | null; acceptances: number };
// Changes to the built-in text since this site last reviewed them (only once it has published its own).
@@ -41,7 +41,7 @@ export function NoticeEditor({ token }: { token: string }) {
const load = useCallback(async () => {
try {
const d = await apiFetch<Draft>("/api/privacy/notice/draft", { authToken: token });
const d = await (apiV2Data("get", "/privacy/notice/draft", { authToken: token }) as Promise<Draft>);
setDraft(d);
setSections(d.sections);
setDirty(false);
@@ -64,7 +64,7 @@ export function NoticeEditor({ token }: { token: string }) {
setBusy(true);
setMsg(null);
try {
const res = await apiFetch<{ sections: NoticeSection[]; rendered: NoticeSection[]; savedAt: string }>("/api/privacy/notice/draft", { method: "PUT", authToken: token, body: { sections } });
const res = await (apiV2Data("put", "/privacy/notice/draft", { authToken: token, body: { sections } }) as Promise<{ sections: NoticeSection[]; rendered: NoticeSection[]; savedAt: string }>);
setDraft(d => (d ? { ...d, sections: res.sections, rendered: res.rendered, isDraft: true, savedAt: res.savedAt } : d));
setSections(res.sections);
setDirty(false);
@@ -80,7 +80,7 @@ export function NoticeEditor({ token }: { token: string }) {
if (!confirm("Throw away the draft and go back to the published text?")) return;
setBusy(true);
try {
await apiFetch("/api/privacy/notice/draft", { method: "DELETE", authToken: token });
await apiV2Data("delete", "/privacy/notice/draft", { authToken: token });
await load();
setMsg({ type: "ok", text: "Draft discarded." });
} catch (e) {
@@ -95,7 +95,7 @@ export function NoticeEditor({ token }: { token: string }) {
setBusy(true);
setMsg(null);
try {
const res = await apiFetch<{ version: number }>("/api/privacy/notice/publish", { method: "POST", authToken: token, body: { sections, changeSummary, requiresAcceptance } });
const res = await (apiV2Data("post", "/privacy/notice/publish", { authToken: token, body: { sections, changeSummary, requiresAcceptance } }) as Promise<{ version: number }>);
setPublishing(false);
setChangeSummary("");
setRequiresAcceptance(false);
@@ -112,7 +112,7 @@ export function NoticeEditor({ token }: { token: string }) {
const markReviewed = async () => {
setBusy(true);
try {
await apiFetch("/api/privacy/notice/builtin-reviewed", { method: "POST", authToken: token });
await apiV2Data("post", "/privacy/notice/builtin-reviewed", { authToken: token, body: {} });
setDraft(d => (d ? { ...d, builtInUpdates: [] } : d));
setMsg({ type: "ok", text: "Done. Remember to save the draft and publish if you took any new text." });
} catch (e) {
@@ -2,8 +2,8 @@
import React, { useCallback, useEffect, useState } from "react";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type Operator = { id: string; name: string; purpose: string; dataShared: string; location: string | null; website: string | null; active: boolean; order: number };
type Form = { name: string; purpose: string; dataShared: string; location: string; website: string; active: boolean };
@@ -41,7 +41,7 @@ export function OperatorsPanel({ token }: { token: string }) {
const load = useCallback(async () => {
try {
setOps(await apiFetch<Operator[]>("/api/privacy/operators", { authToken: token }));
setOps(await (apiV2Data("get", "/privacy/operators", { authToken: token }) as Promise<Operator[]>));
} catch (e) {
setError(errorMessage(e) || "Could not load operators.");
}
@@ -53,8 +53,8 @@ export function OperatorsPanel({ token }: { token: string }) {
setError(null);
try {
const body = { ...f, location: f.location || null, website: f.website || null };
if (editing === "new") await apiFetch("/api/privacy/operators", { method: "POST", authToken: token, body });
else await apiFetch(`/api/privacy/operators/${editing}`, { method: "PUT", authToken: token, body });
if (editing === "new") await apiV2Data("post", "/privacy/operators", { authToken: token, body });
else await apiV2Data("put", "/privacy/operators/{id}", { params: { id: editing! }, authToken: token, body });
setEditing(null);
await load();
} catch (e) {
@@ -67,7 +67,7 @@ export function OperatorsPanel({ token }: { token: string }) {
const remove = async (op: Operator) => {
if (!confirm(`Remove ${op.name} from the register? (Untick "In use" instead if you might use them again.)`)) return;
try {
await apiFetch(`/api/privacy/operators/${op.id}`, { method: "DELETE", authToken: token });
await apiV2Data("delete", "/privacy/operators/{id}", { params: { id: op.id }, authToken: token });
await load();
} catch (e) {
setError(errorMessage(e) || "Could not remove.");
@@ -3,8 +3,8 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { CheckCircle2, Circle, ArrowRight } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type Check = { key: string; label: string; ok: boolean; detail: string; href: string | null; applies: boolean };
type Readiness = { score: number; passed: number; total: number; checks: Check[] };
@@ -16,7 +16,7 @@ export function ReadinessPanel({ token }: { token: string }) {
const load = useCallback(async () => {
try {
setData(await apiFetch<Readiness>("/api/privacy/readiness", { authToken: token }));
setData(await (apiV2Data("get", "/privacy/readiness", { authToken: token }) as Promise<Readiness>));
} catch (e) {
setError(errorMessage(e) || "Could not load the checklist.");
}
@@ -2,8 +2,8 @@
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type Policy = { enabled: boolean; months: number };
type Definition = { label: string; description: string; minMonths: number; defaults: Policy };
@@ -39,7 +39,7 @@ export function RetentionPanel({ token }: { token: string }) {
const apply = (s: State) => { setState(s); setPolicies(s.policies); };
const load = useCallback(async () => {
try {
apply(await apiFetch<State>("/api/privacy/retention", { authToken: token }));
apply(await (apiV2Data("get", "/privacy/retention", { authToken: token }) as Promise<State>));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load retention settings." });
}
@@ -52,7 +52,7 @@ export function RetentionPanel({ token }: { token: string }) {
setBusy(true);
setMsg(null);
try {
apply(await apiFetch<State>("/api/privacy/retention", { method: "PUT", authToken: token, body: { policies } }));
apply(await (apiV2Data("put", "/privacy/retention", { authToken: token, body: { policies } }) as Promise<State>));
setMsg({ type: "ok", text: "Saved. The privacy notice's retention section is updated to match." });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not save." });
@@ -66,7 +66,7 @@ export function RetentionPanel({ token }: { token: string }) {
setBusy(true);
setMsg(null);
try {
const res = await apiFetch<{ state: State }>("/api/privacy/retention/run", { method: "POST", authToken: token });
const res = await (apiV2Data("post", "/privacy/retention/run", { authToken: token, body: {} }) as Promise<{ state: State }>);
apply(res.state);
setMsg({ type: "ok", text: "Done — see \"Last run\" below." });
} catch (e) {
@@ -4,10 +4,10 @@ import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { CalendarClock, ArrowRight, Baby, HandHeart, UserCheck, Banknote, Inbox, CheckCircle2 } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { CampusBadge } from "@/components/shared/CampusFilter";
import type { Event } from "@/types";
import { apiV2Data } from "@/lib/apiV2";
type TodayEvent = {
id: string; title: string; location: string | null; campus: Event["campus"]; isHidden: boolean; kidsCheckIn: boolean;
@@ -35,7 +35,7 @@ export function TodayPanel({ supervisor }: { supervisor: boolean }) {
const [data, setData] = useState<Today | null>(null);
const load = useCallback(async () => {
if (!token) return;
try { setData(await apiFetch<Today>("/api/dashboard/today", { authToken: token })); } catch { /* keep what's shown */ }
try { setData(await (apiV2Data("get", "/dashboard/today", { authToken: token }) as Promise<Today>)); } catch { /* keep what's shown */ }
}, [token]);
useEffect(() => { load(); }, [load]);
useVisiblePolling(load, 30000, !!token);
@@ -3,7 +3,7 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE, fetchManagedEvents } from "@/lib/api";
import { API_BASE, fetchManagedEvents } from "@/lib/api";
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck, Star, MessageSquareHeart, Send, Eye, MousePointerClick, BadgeCheck, Users, Ticket as TicketIcon } from "lucide-react";
import { REPORTS, type ReportKey } from "./ReportCatalog";
@@ -374,7 +374,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
if (dateFrom) query.set("from", dateFrom);
if (dateTo) query.set("to", dateTo);
try {
return await apiFetch<FunnelReport>(`/api/analytics/funnel?${query.toString()}`, { authToken: token });
return await (apiV2Data("get", "/analytics/funnel", { query: Object.fromEntries(query), authToken: token }) as Promise<FunnelReport>);
} catch {
return null;
}
@@ -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>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { apiV2Data } from "@/lib/apiV2";
type BannerType = "info" | "warning" | "success" | "danger";
@@ -28,7 +28,7 @@ export function BannerBar() {
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
apiFetch<Banner>("/api/banner")
(apiV2Data("get", "/banner") as Promise<Banner>)
.then(b => {
if (!b?.message) return;
setBanner(b);
@@ -4,8 +4,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useRouter } from "next/navigation";
import { Search, Calendar, User, Banknote, ClipboardList, CornerDownLeft, Zap, FileText, Settings, BarChart2 } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { matchPages, type CommandPage } from "@/lib/commandPages";
import { apiV2Data } from "@/lib/apiV2";
type Result = { type: "event" | "payment" | "person" | "registration"; id: string; title: string; subtitle: string; href: string };
type Item = { key: string; group: string; title: string; subtitle?: string; href: string; icon: React.ElementType };
@@ -59,7 +59,7 @@ export function CommandPalette() {
const q = query.trim();
if (q.length < 2) { setResults([]); return; }
const timer = setTimeout(() => {
apiFetch<{ results: Result[] }>(`/api/dashboard/search?q=${encodeURIComponent(q)}`, { authToken: token })
(apiV2Data("get", "/dashboard/search", { query: { q: q }, authToken: token }) as Promise<{ results: Result[] }>)
.then(r => setResults(r.results || []))
.catch(() => setResults([]));
}, 200);
@@ -3,9 +3,9 @@
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { ShieldAlert, KeyRound, ArrowUpCircle, BellOff } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import type { HubStatus } from "@/components/admin/LicenceTab";
import { apiV2Data } from "@/lib/apiV2";
/**
* Licence and update notices for **site admins only** (never supervisors, staff, users or the
@@ -20,7 +20,7 @@ export function LicenceBanner() {
useEffect(() => {
if (!isAdmin || !token) return;
apiFetch<HubStatus>("/api/hub/status", { authToken: token }).then(setStatus).catch(() => {});
(apiV2Data("get", "/hub/status", { authToken: token }) as Promise<HubStatus>).then(setStatus).catch(() => {});
}, [isAdmin, token]);
if (!isAdmin || !status) return null;
@@ -69,7 +69,7 @@ export function LicenceBanner() {
if (status.updateAvailable && !awaiting && !status.updateSnoozed) {
const notNow = async () => {
setBusy(true);
try { setStatus(await apiFetch<HubStatus>("/api/hub/update-snooze", { method: "POST", authToken: token, body: { snooze: true } })); } catch { /* the Licence page shows why */ }
try { setStatus(await (apiV2Data("post", "/hub/update-snooze", { authToken: token, body: { snooze: true } }) as Promise<HubStatus>)); } catch { /* the Licence page shows why */ }
setBusy(false);
};
return (
@@ -4,8 +4,8 @@ import React, { useEffect, useState } from "react";
import Link from "next/link";
import { Megaphone } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type News = { email: boolean; whatsapp: boolean };
@@ -26,7 +26,7 @@ export function NewsPrompt() {
useEffect(() => {
if (!pending || !token || news) return;
apiFetch<{ news: News; canWhatsApp: boolean }>("/api/privacy/me", { authToken: token })
(apiV2Data("get", "/me/privacy", { authToken: token }) as Promise<{ news: News; canWhatsApp: boolean }>)
.then(res => { setNews(res.news); setCanWhatsApp(res.canWhatsApp); })
.catch(() => setDone(true)); // never block the dashboard over this
}, [pending, token, news]);
@@ -38,7 +38,7 @@ export function NewsPrompt() {
setSaving(true);
setError(null);
try {
await apiFetch("/api/privacy/me/news", { method: "PUT", authToken: token, body: { ...choice, source: "prompt" } });
await apiV2Data("put", "/me/privacy/news", { authToken: token, body: { ...choice, source: "prompt" } });
setDone(true);
} catch (e) {
setError(errorMessage(e) || "Could not save your choice.");
@@ -4,9 +4,9 @@ import React, { useEffect, useState } from "react";
import Link from "next/link";
import { ScrollText } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import type { PublicNotice } from "@/components/shared/PrivacyNoticeSection";
import { apiV2Data } from "@/lib/apiV2";
/**
* POPIA: asked once after login when this person never accepted the privacy notice (an account
@@ -21,7 +21,7 @@ export function NoticePrompt({ reason = "updated", onDone }: { reason?: "first"
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiFetch<PublicNotice>("/api/privacy/notice").then(setNotice).catch(() => onDone());
(apiV2Data("get", "/privacy/notice") as Promise<PublicNotice>).then(setNotice).catch(() => onDone());
}, [onDone]);
if (!notice) return null;
@@ -31,7 +31,7 @@ export function NoticePrompt({ reason = "updated", onDone }: { reason?: "first"
setSaving(true);
setError(null);
try {
await apiFetch("/api/privacy/me/notice/accept", { method: "POST", authToken: token });
await apiV2Data("post", "/me/privacy/notice/accept", { authToken: token, body: {} });
onDone();
} catch (e) {
setError(errorMessage(e) || "Could not save. Please try again.");
@@ -2,9 +2,9 @@
import React, { useEffect, useState } from "react";
import { Info, Wrench, AlertTriangle, X } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import type { HubNotice } from "@/components/admin/LicenceTab";
import { apiV2Data } from "@/lib/apiV2";
const DISMISSED_KEY = "cross_events_dismissed_notices";
@@ -35,7 +35,7 @@ export function ProviderNotices() {
useEffect(() => {
if (!canSee || !token) return;
setDismissed(readDismissed());
apiFetch<HubNotice[]>("/api/hub/notices", { authToken: token }).then(setNotices).catch(() => {});
(apiV2Data("get", "/hub/notices", { authToken: token }) as Promise<HubNotice[]>).then(setNotices).catch(() => {});
}, [canSee, token]);
if (!canSee) return null;
@@ -3,8 +3,8 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Star, Trash2, Check } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
export type FilterValues = Record<string, string | number | boolean | string[]>;
type Saved = { id: string; name: string; params: FilterValues; isDefault: boolean };
@@ -35,7 +35,7 @@ export function SavedFilters({ page, value, onApply, skipDefault = false, classN
const load = useCallback(async () => {
if (!token) return;
try {
const rows = await apiFetch<Saved[]>(`/api/saved-filters?page=${page}`, { authToken: token });
const rows = await (apiV2Data("get", "/saved-filters", { query: { page: page }, authToken: token }) as Promise<Saved[]>);
setList(rows);
// The default, once, when the page opens.
if (!defaultDone.current) {
@@ -64,14 +64,14 @@ export function SavedFilters({ page, value, onApply, skipDefault = false, classN
const save = () => act(async () => {
setSaving(true);
try {
const row = await apiFetch<Saved>("/api/saved-filters", { method: "POST", authToken: token || undefined, body: { page, name, params: value, isDefault: asDefault } });
const row = await (apiV2Data("post", "/saved-filters", { authToken: token, body: { page, name, params: value, isDefault: asDefault } }) as Promise<Saved>);
setApplied(row.id); setName(""); setAsDefault(false);
} finally { setSaving(false); }
});
const toggleDefault = (f: Saved) => act(() => apiFetch(`/api/saved-filters/${f.id}`, { method: "PUT", authToken: token || undefined, body: { isDefault: !f.isDefault } }));
const toggleDefault = (f: Saved) => act(() => apiV2Data("put", "/saved-filters/{id}", { params: { id: f.id }, authToken: token, body: { isDefault: !f.isDefault } }));
const remove = (f: Saved) => act(async () => {
if (!confirm(`Delete the saved filter "${f.name}"?`)) return;
await apiFetch(`/api/saved-filters/${f.id}`, { method: "DELETE", authToken: token || undefined });
await apiV2Data("delete", "/saved-filters/{id}", { params: { id: f.id }, authToken: token });
});
const current = list.find(f => f.id === applied);
@@ -2,7 +2,7 @@
import { useEffect, useRef } from "react";
import { useRouter, usePathname } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiV2Data } from "@/lib/apiV2";
// Checks once per browser session whether first-time setup is needed.
// If the backend says yes and we're not already on /setup, redirect there.
@@ -16,7 +16,7 @@ export function SetupGuard() {
if (pathname?.startsWith("/setup")) return;
checked.current = true;
apiFetch<{ needsSetup: boolean }>("/api/settings/needs-setup")
(apiV2Data("get", "/setup/needed") as Promise<{ needsSetup: boolean }>)
.then((data) => {
if (data?.needsSetup) {
router.replace("/setup");

Some files were not shown because too many files have changed in this diff Show More