Compare commits

..
16 Commits
Author SHA1 Message Date
joshua e6c96706c8 Merge branch 'docs/moved-to-platform' 2026-11-05 09:48:33 +02:00
joshuaandClaude Opus 5.5 4c577fffb7 Docs: roadmap v3 and Hub protocol moved to crosscode-platform/docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 09:48:31 +02:00
joshuaandClaude Opus 5.5 70460a28c7 Merge branch 'docs/hub-2-released'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 09:10:34 +02:00
joshuaandClaude Opus 5.5 cc855ec8bb Docs: Hub 2.0.0 released (phase 1); hub-protocol.md with protocol v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 09:10:33 +02:00
joshuaandClaude Opus 5.5 0a9b08e1fd Merge branch 'docs/sites-folder'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:54:05 +02:00
joshuaandClaude Opus 5.5 d6ff08f5d9 Docs: church installs under /var/www/sites/<church>/<product>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:54:05 +02:00
joshuaandClaude Opus 5.5 b1a5c5abc3 Merge branch 'docs/roadmap-monorepo-deploy'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:48:36 +02:00
joshuaandClaude Opus 5.5 1adf1d4870 Docs: monorepo move, crosscode deploy script, releases per product
ROADMAP-v3 decisions 21-24: Cross Events moves into crosscode-platform
(2.2.0); one crosscode script deploys Accounts plus the products named;
the Hub tracks releases per product with requirements; the Hub gets the
contracts as a committed file, not from the package registry. Cross Events
versions in the build order move up by one. HANDOVER updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:48:36 +02:00
joshuaandClaude Opus 5.5 e42e244edc Roadmap v3: four Website templates; SEO for every public site
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:04:13 +02:00
joshuaandClaude Opus 5.5 f85842d4b3 Roadmap v3: Hub protocol v2 (today's is v1), event-feed schema, enum rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:47:42 +02:00
joshuaandClaude Opus 5.5 78bf6df40c Roadmap v3: sign-in returns to where you came from; crosscode-site per phase
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:31:29 +02:00
joshuaandClaude Opus 5.5 47725a689d Roadmap v3: decisions, pricing and authenticator-app 2FA; handover updated
One install of each product per customer; House of Prayer booking
defaults; email and WhatsApp only (no push); Accounts hidden from
members. Pricing: Website R99 with 5 GB plus storage add-on, App from
R99, address once per church (own domain, crosscode.co.za address R10,
domain included R25). Authenticator app 2FA as Cross Events 2.2.0.
Handover now points at ROADMAP-v3; nothing left open in ROADMAP-v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:48:23 +02:00
joshuaandClaude Opus 5.5 a9584cdde5 Roadmap v3: Accounts, Church Website, Church App, Cross Code Hub
Plan for selling three products from one Hub: shared users through a
per-church Accounts service, event sync in any direction on one shared
format (crosscode.event/1), PM2 installs per product per church.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:14:24 +02:00
joshua 316a2b40a1 Merge branch 'fix/feedback-background-send' 2026-11-03 14:11:44 +02:00
joshuaandClaude Opus 5.5 73547a0f8c Feedback Send now and Remind run in the background (2.1.1)
- Send now and Remind answer straight away with who they're going to and
  queue the feedback-send task; the Feedback page refreshes to show progress.
- One-off background tasks (enqueueTask in src/jobs/index.js): pg-boss with
  retries, or in-process after the response when the queue is off.
- Reminders are claimed with the 24-hour rule, so two tasks at once never
  remind the same person twice.
- Tests: tests/tasks.test.js, tests/feedbackPage.test.ts, e2e/feedback.spec.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:11:44 +02:00
joshuaandClaude Opus 5.5 b5821e1c18 Handover: Hub 1.7.0 bills on the income bands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:42:46 +02:00
24 changed files with 295 additions and 375 deletions
+19
View File
@@ -7,6 +7,25 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [2.1.1] - 2026-11-03
### Fixed
- **Feedback "Send now" and "Remind" no longer make the page wait** for every email: they work out
who they're going to, answer straight away ("Sending the feedback form to 40 people in the
background", "Reminding 12 people. It carries on in the background.") and the emails go out in a
background task. The Feedback page refreshes itself a few times to show them land.
### Added
- **One-off background tasks** (`enqueueTask` in `src/jobs/index.js`, first one `feedback-send` →
`runFeedbackTask` in `utils/feedback.js`): queued in pg-boss (retried twice, kept across a
restart), or run in-process straight after the response when the queue isn't running. Tasks must
be safe to run twice: sending skips people already invited, and a reminder is claimed with the
24-hour rule, so two tasks at once never remind the same person twice.
- `POST /api/v2/events/{id}/feedback/remind` answers with `queued`; its counts are what's being
sent. `send-now`'s `invited` is how many it's sending to.
## [2.1.0] - 2026-11-03
### Added
+6
View File
@@ -12,6 +12,12 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 2.1.1
- **Sending feedback forms and reminders no longer makes you wait.** "Send now" and "Remind" on the
Feedback page now answer straight away and the emails go out in the background, so you can carry
on working. The page updates itself as they go out.
## 2.1.0
- **A Feedback page for supervisors and admins** (Dashboard → Feedback). See every event's feedback
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "2.1.0",
"version": "2.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "2.1.0",
"version": "2.1.1",
"hasInstallScript": true,
"dependencies": {
"@cj-tech-master/excelts": "^10.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "2.1.0",
"version": "2.1.1",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -1,6 +1,6 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { ANSWERABLE, feedbackError, cleanFields, cleanAnswer, summarise, sendFeedbackInvites, findInviteByToken } = require('../utils/feedback');
const { ANSWERABLE, feedbackError, cleanFields, cleanAnswer, summarise, feedbackRecipients, findInviteByToken } = require('../utils/feedback');
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const shape = (fields) => JSON.stringify(fields.map(f => [f.type, f.label, f.isRequired, f.options || null, f.helpText || null]));
@@ -86,9 +86,12 @@ const sendNow = async (req, res) => {
const form = await prisma.feedbackForm.findUnique({ where: { eventId: req.params.eventId } });
if (!form) throw feedbackError('This event has no feedback form.', 404);
if (!form.sentAt) await prisma.feedbackForm.update({ where: { id: form.id }, data: { sentAt: new Date(), sendAt: form.sendAt || new Date() } });
// Also invites anyone who checked in after the first send.
const invited = await sendFeedbackInvites(form.id);
res.json({ invited, message: invited ? `Feedback form sent to ${invited} ${invited === 1 ? 'person' : 'people'}.` : 'Everyone has already been sent the form.' });
// Also invites anyone who checked in after the first send. The emails go out in the
// background (2.1.1), so this answers straight away with how many it's going to.
const already = new Set((await prisma.feedbackInvite.findMany({ where: { formId: form.id }, select: { userId: true } })).map(i => i.userId));
const invited = (await feedbackRecipients(form)).filter(u => !already.has(u.id)).length;
if (invited) await require('../jobs').enqueueTask('feedback-send', { formId: form.id, kind: 'send' });
res.json({ invited, message: invited ? `Sending the feedback form to ${invited} ${invited === 1 ? 'person' : 'people'} in the background.` : 'Everyone has already been sent the form.' });
} catch (error) {
fail(res, error);
}
+50 -1
View File
@@ -139,6 +139,38 @@ function isNoop(value) {
return false;
}
/**
* One-off background tasks (2.1.1): work a request starts but shouldn't wait for, like sending
* a feedback form to everyone. Queued in pg-boss (retried if it fails, kept across a restart);
* without the queue they run in this process straight after the response. A task must be safe
* to run twice.
*/
const TASKS = {
'feedback-send': (data) => require('../utils/feedback').runFeedbackTask(data),
};
async function runTask(name, data) {
const started = Date.now();
const result = await TASKS[name](data || {});
if (!isNoop(result)) console.log(`[jobs] task ${name} done in ${Date.now() - started} ms: ${summarize(result)}`);
return result ?? null;
}
/** Starts a task in the background. Resolves once it's queued (or started), not when it's done. */
async function enqueueTask(name, data) {
if (!TASKS[name]) throw new Error(`Unknown task ${name}`);
if (mode === 'queue' && boss) {
try {
await boss.send(name, data || {}, { retryLimit: 2, retryDelay: 30, expireInSeconds: 60 * 60 });
return { queued: true };
} catch (e) {
console.warn(`[jobs] could not queue ${name}, running it here:`, e?.message || e);
}
}
setImmediate(() => runTask(name, data).catch(e => console.warn(`[jobs] task ${name} failed:`, e?.message || e)));
return { queued: false };
}
async function runJob(job) {
const started = Date.now();
const result = await job.run();
@@ -218,6 +250,23 @@ async function startJobs() {
await boss.unschedule(job.name).catch(() => {});
}
}
// One-off tasks: any number can wait and run (unlike the stately scheduled jobs).
for (const name of Object.keys(TASKS)) {
if (!existing.has(name)) await boss.createQueue(name, { policy: 'standard', retryLimit: 2, retryDelay: 30, expireInSeconds: 60 * 60 });
await boss.work(name, async (batch) => {
const list = Array.isArray(batch) ? batch : [batch];
const out = [];
for (const job of list) {
try {
out.push(await runTask(name, job?.data));
} catch (e) {
console.warn(`[jobs] task ${name} failed:`, e?.message || e);
throw e; // recorded as failed, and retried
}
}
return out.length === 1 ? out[0] : out;
});
}
mode = 'queue';
const off = JOBS.filter(j => !isEnabled(j)).map(j => j.name);
console.log(`[jobs] Job queue started: ${enabled.map(j => j.name).join(', ')}${off.length ? ` (off: ${off.join(', ')})` : ''}.`);
@@ -298,4 +347,4 @@ async function jobStatus(prisma) {
};
}
module.exports = { JOBS, TIMEZONE, isEnabled, cronFor, nextRun, isNoop, startJobs, stopJobs, runNow, jobStatus };
module.exports = { JOBS, TASKS, TIMEZONE, isEnabled, cronFor, nextRun, isNoop, startJobs, stopJobs, runNow, jobStatus, enqueueTask };
+22 -10
View File
@@ -19,6 +19,7 @@ const fb = require('../utils/feedback') as {
sendFeedbackInvites(formId: string): Promise<number>;
sendFeedbackReminders(formId: string, opts?: { userIds?: string[] | null; minGapMs?: number | null; now?: Date }): Promise<number>;
};
const jobs = require('../jobs') as { enqueueTask(name: string, data: object): Promise<{ queued: boolean }> };
export type FormStatus = 'sent' | 'scheduled' | 'not_scheduled';
const statusOf = (form: { sentAt: Date | null; sendAt: Date | null }): FormStatus => (form.sentAt ? 'sent' : form.sendAt ? 'scheduled' : 'not_scheduled');
@@ -148,23 +149,34 @@ export async function eventFeedback(eventId: string, now = new Date()) {
* "Remind": a fresh link to everyone who hasn't answered (or the people in `userIds`, named
* forms only), skipping anyone sent the form or a reminder in the last 24 hours. Without `userIds` it also sends
* the form to people who became eligible after it went out (e.g. checked in later).
*
* 2.1.1: the emails go out in the background (jobs task `feedback-send`); this works out who
* they'll go to and answers straight away. The counts are what's being sent, not what was.
*/
export async function remindFeedback(eventId: string, { userIds }: { userIds?: string[] | null } = {}) {
export async function remindFeedback(eventId: string, { userIds }: { userIds?: string[] | null } = {}, now = new Date()) {
const form = await formFor(eventId);
if (!form.sentAt) fail(409, 'The feedback form hasn\'t gone out yet. Use "Send now" to send it.', 'FEEDBACK_NOT_SENT');
const only = userIds && userIds.length ? [...new Set(userIds)] : null;
if (only && form.anonymous) fail(400, 'This form is anonymous, so people can\'t be reminded one by one. Remind everyone who hasn\'t answered instead.', 'FEEDBACK_ANONYMOUS');
const newlyInvited = only ? 0 : await fb.sendFeedbackInvites(form.id);
const reminded = await fb.sendFeedbackReminders(form.id, { userIds: only, minGapMs: fb.REMIND_GAP_MS });
const stillWaiting = await prisma.feedbackInvite.count({ where: { formId: form.id, respondedAt: null, ...(only ? { userId: { in: only } } : {}) } });
const skipped = Math.max(0, stillWaiting - reminded - newlyInvited);
const since = new Date(now.getTime() - fb.REMIND_GAP_MS);
const mine = { formId: form.id, respondedAt: null, ...(only ? { userId: { in: only } } : {}) };
const [notAnswered, reminded, invitedIds] = await Promise.all([
prisma.feedbackInvite.count({ where: mine }),
// The same rule as sendFeedbackReminders with minGapMs.
prisma.feedbackInvite.count({ where: { ...mine, sentAt: { lt: since }, OR: [{ remindedAt: null }, { remindedAt: { lt: since } }] } }),
only ? Promise.resolve(null) : prisma.feedbackInvite.findMany({ where: { formId: form.id }, select: { userId: true } }),
]);
const newlyInvited = invitedIds ? await waitingCount(form, new Set(invitedIds.map((i: any) => i.userId))) : 0;
const skipped = Math.max(0, notAnswered - reminded);
if (reminded || newlyInvited) await jobs.enqueueTask('feedback-send', { formId: form.id, kind: 'remind', userIds: only });
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
const parts: string[] = [];
if (reminded) parts.push(`Reminded ${plural(reminded, 'person', 'people')}`);
if (newlyInvited) parts.push(`sent the form to ${plural(newlyInvited, 'person', 'people')} for the first time`);
let message = parts.length ? `${parts.join(' and ')}.` : 'Nobody to remind.';
if (reminded) parts.push(`Reminding ${plural(reminded, 'person', 'people')}`);
if (newlyInvited) parts.push(`sending the form to ${plural(newlyInvited, 'person', 'people')} for the first time`);
let message = parts.length ? `${parts.join(' and ')}. It carries on in the background.` : 'Nobody to remind.';
if (!parts.length && skipped) message = 'Everyone who hasn\'t answered was sent the form or a reminder in the last 24 hours. Try again tomorrow.';
else if (skipped) message += ` ${plural(skipped, 'person was', 'people were')} sent the form or a reminder in the last 24 hours, so not again yet.`;
else if (parts.length && skipped) message += ` ${plural(skipped, 'person was', 'people were')} sent the form or a reminder in the last 24 hours, so not again yet.`;
message = message.charAt(0).toUpperCase() + message.slice(1);
return { reminded, newlyInvited, skipped, message, title: form.event.title };
return { reminded, newlyInvited, skipped, queued: !!(reminded || newlyInvited), message, title: form.event.title };
}
+18 -2
View File
@@ -206,7 +206,8 @@ async function sendFeedbackReminders(formId, { userIds = null, minGapMs = null,
const token = newToken();
// Only if they still haven't answered (they may have while this ran).
const claimed = await prisma.feedbackInvite.updateMany({
where: { id: inv.id, respondedAt: null },
// The same rule again, so two tasks running at once never remind the same person twice.
where: { id: inv.id, respondedAt: null, ...recent },
data: { tokenHash: hashToken(token), remindedAt: now, reminderCount: { increment: 1 } },
});
if (claimed.count !== 1) continue;
@@ -216,6 +217,21 @@ async function sendFeedbackReminders(formId, { userIds = null, minGapMs = null,
return sent;
}
/**
* The background task behind "Send now" and "Remind" (jobs `feedback-send`), so the page doesn't
* wait for every email. Safe to run twice: people already invited are skipped, and nobody is
* reminded twice within REMIND_GAP_MS.
* { formId, kind: 'send' } the form to everyone eligible not yet invited
* { formId, kind: 'remind', userIds? } (everyone: also invite the newly eligible) then remind
*/
async function runFeedbackTask({ formId, kind, userIds = null } = {}) {
if (!formId) return { invited: 0, reminded: 0 };
const only = Array.isArray(userIds) && userIds.length ? userIds : null;
const invited = kind === 'send' || (kind === 'remind' && !only) ? await sendFeedbackInvites(formId) : 0;
const reminded = kind === 'remind' ? await sendFeedbackReminders(formId, { userIds: only, minGapMs: REMIND_GAP_MS }) : 0;
return { invited, reminded };
}
/** Background job: send forms whose time has come, and reminders 3 days after. */
async function runFeedbackJob(now = new Date()) {
let sent = 0, reminded = 0;
@@ -244,5 +260,5 @@ async function findInviteByToken(token) {
module.exports = {
ANSWERABLE, ALL_TYPES, REMINDER_AFTER_MS, REMIND_GAP_MS, feedbackError, hashToken, feedbackLink,
cleanFields, cleanAnswer, summarise,
feedbackRecipients, sendFeedbackInvites, sendFeedbackReminders, runFeedbackJob, findInviteByToken,
feedbackRecipients, sendFeedbackInvites, sendFeedbackReminders, runFeedbackTask, runFeedbackJob, findInviteByToken,
};
+2 -2
View File
@@ -74,10 +74,10 @@ export const results = defineRoute({
export const remind = defineRoute({
method: 'post', path: '/events/:id/feedback/remind', tag: 'Feedback', summary: 'Remind people who haven\'t answered',
description: 'A fresh link to everyone who hasn\'t answered, or to `userIds` (named forms only). Nobody is sent the form or a reminder twice within 24 hours. Without `userIds` it also sends the form to anyone who has become eligible since it went out.',
description: 'A fresh link to everyone who hasn\'t answered, or to `userIds` (named forms only). Nobody is sent the form or a reminder twice within 24 hours. Without `userIds` it also sends the form to anyone who has become eligible since it went out. Answers straight away; the emails go out in the background.',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams,
body: z.object({ userIds: z.array(id).max(1000).nullish().describe('Only these people (named forms)') }),
response: single(z.object({ reminded: z.number().int(), newlyInvited: z.number().int(), skipped: z.number().int(), message: z.string() })),
response: single(z.object({ reminded: z.number().int().describe('Being reminded'), newlyInvited: z.number().int().describe('Being sent the form for the first time'), skipped: z.number().int(), queued: z.boolean().describe('Emails are going out in the background'), message: z.string() })),
async handler({ params, body }) {
const { title: _title, ...data } = await feedback.remindFeedback(params.id, { userIds: body.userIds });
return { data };
+1 -1
View File
@@ -361,6 +361,6 @@ v1Route({
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, documented: { response: D.message }, v1: feedback.deleteEventFeedback,
});
v1Route({
method: 'post', path: '/events/:id/feedback/send-now', tag: 'Feedback', summary: 'Send the feedback form now',
method: 'post', path: '/events/:id/feedback/send-now', tag: 'Feedback', summary: 'Send the feedback form now', description: 'Answers straight away: `invited` is how many it is going to; the emails go out in the background.',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, documented: { response: D.obj({ invited: D.num, message: D.str }) }, v1: feedback.sendNow,
});
+64 -26
View File
@@ -1,6 +1,7 @@
/**
* 2.1.0: the Feedback page (services/feedback.ts) — results per event, anonymous forms that stay
* anonymous, and "Remind" with its 24-hour rule (utils/feedback.js sendFeedbackReminders).
* anonymous, and "Remind" with its 24-hour rule (utils/feedback.js sendFeedbackReminders). 2.1.1:
* "Remind" answers straight away and the emails go out in a background task (runFeedbackTask).
*/
const mockDb: any = {
feedbackForm: { findUnique: jest.fn(), findMany: jest.fn() },
@@ -15,10 +16,12 @@ const mockMail = jest.fn(async () => undefined);
jest.mock('../src/utils/email', () => ({ sendMail: (...a: unknown[]) => (mockMail as jest.Mock)(...a), emailWrapper: (h: string) => h, ctaButton: () => '', fallbackLink: () => '' }));
jest.mock('../src/utils/notify', () => ({ waTextAny: jest.fn(async () => undefined) }));
jest.mock('../src/utils/whatsapp', () => ({ isValidZAPhone: () => false }));
const mockEnqueue = jest.fn(async () => ({ queued: true }));
jest.mock('../src/jobs', () => ({ enqueueTask: (...a: unknown[]) => (mockEnqueue as jest.Mock)(...a) }));
import * as feedback from '../src/services/feedback';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { sendFeedbackReminders } = require('../src/utils/feedback');
const { sendFeedbackReminders, runFeedbackTask } = require('../src/utils/feedback');
const NOW = new Date('2026-11-10T12:00:00Z');
const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3600e3);
@@ -110,45 +113,80 @@ describe('Remind', () => {
await expect(feedback.remindFeedback('ev1', { userIds: ['u4'] })).rejects.toMatchObject({ statusCode: 400 });
});
test('reminds those not reminded or sent the form in the last 24 hours, and says who was skipped', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue({ ...form(), invites: [{ userId: 'u3' }, { userId: 'u4' }, { userId: 'u5' }] });
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
mockDb.feedbackInvite.count.mockResolvedValue(3);
const out = await feedback.remindFeedback('ev1');
expect(out).toMatchObject({ reminded: 1, newlyInvited: 0, skipped: 2 });
expect(out.message).toBe('Reminded 1 person. 2 people were sent the form or a reminder in the last 24 hours, so not again yet.');
const where = mockDb.feedbackInvite.findMany.mock.calls[0][0].where;
// count() is called for: everyone not answered, then those remindable now (the 24-hour rule).
const counts = (notAnswered: number, remindable: number) => mockDb.feedbackInvite.count.mockResolvedValueOnce(notAnswered).mockResolvedValueOnce(remindable);
test('answers straight away with who it will remind; the emails go in the background', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
counts(3, 1);
mockDb.feedbackInvite.findMany.mockResolvedValue([{ userId: 'u3' }, { userId: 'u4' }, { userId: 'u5' }]);
const out = await feedback.remindFeedback('ev1', {}, NOW);
expect(out).toMatchObject({ reminded: 1, newlyInvited: 0, skipped: 2, queued: true });
expect(out.message).toBe('Reminding 1 person. It carries on in the background. 2 people were sent the form or a reminder in the last 24 hours, so not again yet.');
const where = mockDb.feedbackInvite.count.mock.calls[1][0].where;
expect(where).toMatchObject({ formId: 'form1', respondedAt: null });
expect(where.sentAt.lt).toBeInstanceOf(Date);
expect(where.sentAt.lt).toEqual(new Date(NOW.getTime() - 24 * 3600e3));
expect(where.OR).toHaveLength(2);
expect(mockDb.feedbackInvite.updateMany.mock.calls[0][0].data.reminderCount).toEqual({ increment: 1 });
expect(mockMail).toHaveBeenCalledWith(expect.objectContaining({ to: 'u4@example.com', subject: 'Reminder: How was Men\'s Breakfast?' }));
expect(mockEnqueue).toHaveBeenCalledWith('feedback-send', { formId: 'form1', kind: 'remind', userIds: null });
expect(mockMail).not.toHaveBeenCalled(); // not in the request
});
test('people who can have the form but haven\'t yet are counted too', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
counts(1, 0);
mockDb.feedbackInvite.findMany.mockResolvedValue([{ userId: 'u1' }]);
mockDb.ticket.findMany.mockResolvedValue([{ userId: 'u1' }, { userId: 'u9' }]);
mockDb.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u9' }]);
const out = await feedback.remindFeedback('ev1', {}, NOW);
expect(out).toMatchObject({ reminded: 0, newlyInvited: 1, queued: true });
expect(out.message).toBe('Sending the form to 1 person for the first time. It carries on in the background. 1 person was sent the form or a reminder in the last 24 hours, so not again yet.');
});
test('one person on a named form', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
mockDb.feedbackInvite.count.mockResolvedValue(1);
const out = await feedback.remindFeedback('ev1', { userIds: ['u4'] });
expect(out.message).toBe('Reminded 1 person.');
expect(mockDb.feedbackInvite.findMany.mock.calls[0][0].where.userId).toEqual({ in: ['u4'] });
expect(mockDb.feedbackInvite.create).not.toHaveBeenCalled(); // no new invites when reminding one person
counts(1, 1);
const out = await feedback.remindFeedback('ev1', { userIds: ['u4'] }, NOW);
expect(out.message).toBe('Reminding 1 person. It carries on in the background.');
expect(mockDb.feedbackInvite.count.mock.calls[0][0].where.userId).toEqual({ in: ['u4'] });
expect(mockEnqueue).toHaveBeenCalledWith('feedback-send', { formId: 'form1', kind: 'remind', userIds: ['u4'] });
});
test('everyone already reminded today', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue({ ...form(), invites: [] });
test('everyone already reminded today: nothing queued', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
counts(2, 0);
mockDb.feedbackInvite.findMany.mockResolvedValue([]);
mockDb.feedbackInvite.count.mockResolvedValue(2);
const out = await feedback.remindFeedback('ev1');
const out = await feedback.remindFeedback('ev1', {}, NOW);
expect(out.message).toBe('Everyone who hasn\'t answered was sent the form or a reminder in the last 24 hours. Try again tomorrow.');
expect(mockEnqueue).not.toHaveBeenCalled();
});
});
describe('the background task', () => {
test('remind: invites the newly eligible, then reminds with the 24-hour rule', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
expect(await runFeedbackTask({ formId: 'form1', kind: 'remind' })).toEqual({ invited: 0, reminded: 1 });
const where = mockDb.feedbackInvite.findMany.mock.calls[0][0].where;
expect(where.sentAt.lt).toBeInstanceOf(Date);
expect(mockDb.feedbackInvite.updateMany.mock.calls[0][0].data.reminderCount).toEqual({ increment: 1 });
// Claimed with the same rule, so a second task running at once skips them.
expect(mockDb.feedbackInvite.updateMany.mock.calls[0][0].where).toMatchObject({ id: 'i4', respondedAt: null, sentAt: expect.any(Object), OR: expect.any(Array) });
expect(mockMail).toHaveBeenCalledWith(expect.objectContaining({ to: 'u4@example.com', subject: 'Reminder: How was Men\'s Breakfast?' }));
});
test('remind one person: no new invites', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
await runFeedbackTask({ formId: 'form1', kind: 'remind', userIds: ['u4'] });
expect(mockDb.feedbackInvite.findMany.mock.calls[0][0].where.userId).toEqual({ in: ['u4'] });
expect(mockDb.feedbackInvite.create).not.toHaveBeenCalled();
});
test('someone who answers while reminders go out isn\'t reminded', async () => {
mockDb.feedbackForm.findUnique.mockResolvedValue({ ...form(), invites: [] });
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
mockDb.feedbackInvite.updateMany.mockResolvedValue({ count: 0 });
mockDb.feedbackInvite.count.mockResolvedValue(0);
expect((await feedback.remindFeedback('ev1')).reminded).toBe(0);
expect((await runFeedbackTask({ formId: 'form1', kind: 'remind', userIds: ['u4'] })).reminded).toBe(0);
expect(mockMail).not.toHaveBeenCalled();
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* 2.1.1: one-off background tasks (jobs/index.js enqueueTask). Without the queue (timers or jobs
* off) a task runs in this process right after the caller gets its answer.
*/
let finish;
const mockTask = jest.fn(() => new Promise(resolve => { finish = resolve; }));
jest.mock('../src/utils/feedback', () => ({ runFeedbackTask: (...a) => mockTask(...a) }));
const jobs = require('../src/jobs');
describe('enqueueTask without the queue', () => {
test('answers before the task has run, then runs it', async () => {
const answer = await jobs.enqueueTask('feedback-send', { formId: 'f1', kind: 'send' });
expect(answer).toEqual({ queued: false });
expect(mockTask).not.toHaveBeenCalled(); // not yet: after the response
await new Promise(r => setImmediate(r));
expect(mockTask).toHaveBeenCalledWith({ formId: 'f1', kind: 'send' });
finish({ invited: 3 });
});
test('a failing task is logged, not thrown at the caller', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
mockTask.mockImplementationOnce(() => Promise.reject(new Error('SMTP down')));
await expect(jobs.enqueueTask('feedback-send', { formId: 'f1', kind: 'send' })).resolves.toEqual({ queued: false });
await new Promise(r => setImmediate(r));
await new Promise(r => setImmediate(r));
expect(warn).toHaveBeenCalledWith('[jobs] task feedback-send failed:', 'SMTP down');
warn.mockRestore();
});
test('an unknown task is refused', async () => {
await expect(jobs.enqueueTask('nope', {})).rejects.toThrow('Unknown task nope');
});
});
+20 -19
View File
@@ -1,8 +1,14 @@
# Handover — where things stand and how to carry on
Last updated: 2026-11-03, after **2.1.0** and Hub **1.6.2**. The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
Last updated: 2026-11-05, after **2.1.1** (deployed). **This file covers Cross Events only**
(what's been done, where the code lives, known gaps). **The plan for every product and where each
phase stands moved to the platform monorepo:** `crosscode-platform/docs/` →
[ROADMAP-v3.md](../../crosscode-platform/docs/ROADMAP-v3.md),
[HANDOVER.md](../../crosscode-platform/docs/HANDOVER.md) (all products) and
[EVENTS-2.2-PLAN.md](../../crosscode-platform/docs/EVENTS-2.2-PLAN.md) (the next Events release:
the move into the monorepo). The Hub protocol moved there too (`docs/hub-protocol.md`). How Cross
Events got to 2.x is in [ROADMAP-v2.md](./ROADMAP-v2.md); the round before that in
[FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
The product is called **Cross Events** (renamed from Hope Events in 1.41.3); the Hub is the
**Cross Events Hub**. Technical names keep `hope-events` on purpose — the repo, browser storage
@@ -17,8 +23,9 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 2.1.1 | **Feedback sends in the background:** "Send now" and "Remind" answer straight away and queue the `feedback-send` task (`enqueueTask` in `src/jobs/index.js` — pg-boss, or in-process when the queue is off; tasks must be safe to run twice). Tests `tests/tasks.test.js`, `tests/feedbackPage.test.ts` |
| **2.1.0** | **Feedback page** (Supervisor → Feedback): overview, per-event results / responses / people, **Remind** (all who haven't answered, or one person on a named form; 24-hour gap) — `src/services/feedback.ts`, `src/v2/resources/feedback.ts`, `frontend/src/app/dashboard/supervisor/feedback/page.tsx`; reminders share `sendFeedbackReminders` (utils/feedback.js) with the 3-day job; anonymous forms show no names or who answered; access log `feedback_responses`. Migration `20261103120000_feedback_reminders` (`FeedbackInvite.reminderCount`). Tests `tests/feedbackPage.test.ts`, `e2e/feedback.spec.ts` (the e2e seed puts a named form on the past event). AI on the privacy pages dropped from the roadmap |
| 2.0.2 | **Billing bands:** heartbeat income bands widened to `over R10 000 000` (`BAND_TOPS` in `src/hub/report.js`, every licence-tier edge is a band edge — test in `tests/hub.test.js`), new `income.eventIncome12m` (12 months, no donations/sponsorships/legs) that the tier is billed on, donation legs no longer double-counted. Tiers and prices live in `crosscode-site/src/data/pricing.mjs` and the Hub's `frontend/src/lib/tiers.ts` (Hub 1.6.2) |
| 2.0.2 | **Billing bands** (Hub 1.7.0 bills on them: invoices, receipts, statements, licence-change notices — events-hub README "Billing"): heartbeat income bands widened to `over R10 000 000` (`BAND_TOPS` in `src/hub/report.js`, every licence-tier edge is a band edge — test in `tests/hub.test.js`), new `income.eventIncome12m` (12 months, no donations/sponsorships/legs) that the tier is billed on, donation legs no longer double-counted. Tiers and prices live in `crosscode-site/src/data/pricing.mjs` and the Hub's `frontend/src/lib/tiers.ts` (Hub 1.6.2) |
| 2.0.1 | **Summarise this report on every report:** the eight reports that list people are sent as anonymised totals built on the screen (`frontend/src/components/reports/aiReportData.ts`; attendees in `buildAiPayload` in `ReportsV2.tsx`); the server refuses personal columns on those (`personalColumns` in `backend/src/ai/helpers.ts`, 403 `AI_REPORT_HAS_PEOPLE`) and logs each (`ai_report`) |
| **2.0.0** | **AI helpers** (draft an event, draft a message, summarise a report or feedback, improve text — `backend/src/ai/`, through the Hub with a rand cap per licence; eval set `npm run ai:eval`); **API v1 removed** (410 `API_V1_REMOVED`; provider webhooks and links in sent messages still answered — [v1 removed](#v1-removed-20)); **Argon2id passwords** with re-hashing at sign-in ([Passwords](#passwords-20)); `node-fetch` removed; React Compiler lint rules back to errors (`lib/hooks.ts`, see [Next.js 16](#nextjs-16-1515)); campus-scope route test on v2 (`tests/v2CampusScope.test.ts`); migration guide `MIGRATING-TO-2.md`. Migration `20261030120000_ai_helpers`. Needs Hub 1.6.0+ for AI |
| 1.52.3 | **Urgent fix:** licence read-only mode blocks the `/api/v2` routes too (`src/hub/readOnly.js` only listed v1 paths, and the screens have used v2 since 1.51.15). A test checks each blocked v2 path is a real route |
@@ -87,22 +94,16 @@ encrypted settings unreadable or break existing installs.
## Next up
1. **After 2.0.x** (order decided 2026-11-02, roadmap §18: 2.0.1 → the marketing site → 2.1.0):
- **Deploy:** `bash scripts/deploy.sh --check`, then `bash scripts/deploy.sh v2.0.1` on each server
(installs on 1.52.x ask through Request update as usual). Before updating an install that has
its own integration, look in the nginx access log for `/api/` calls that aren't `/api/v2/`,
the webhooks or the old links — those get 410 after the update (MIGRATING-TO-2.md).
1. **Roadmap v3**, across every product: see `crosscode-platform/docs/HANDOVER.md` ("Where each
phase stands", "Next up"). For Cross Events that means **2.2.0** (the move into the monorepo as
`apps/events`, with the `crosscode` script; `crosscode-platform/docs/EVENTS-2.2-PLAN.md`), then
**2.3.0** (authenticator app 2FA, `TwoFactorMethod` in `prisma/schema.prisma`) and **2.4.0**
(protocol v2, Linked products, the event feed).
- **Deploying until 2.2.0:** `bash scripts/deploy.sh --check`, then
`bash scripts/deploy.sh v<version>` on each server (installs ask through Request update as
usual). From 2.2.0 on: `crosscode deploy <church> events`.
- **AI for a church:** set the licence's monthly AI cap above R0 on the Hub (Hub 1.6.0+); with
no cap the buttons don't show.
- **Marketing site — next, and before 2.1.0** (roadmap §17, all decided 2026-11-02): **crosscode.co.za**, Cross Code's
main site with a Cross Events page; church website and church app as "coming later"; repo
**`joshua/crosscode-site`** (create it on Gitea first); Joshua's own server (nginx); no prices;
a **contact form to info@crosscode.co.za** (needs a small send endpoint on the server — see
§17's building notes); a privacy notice and terms of use; no cookies or analytics. Logo,
colours, wording and screenshots come from Joshua later — start with placeholders.
- **2.1.0** (roadmap §18): the **Feedback page** — results per event, each response, who
hasn't answered and a "remind" action. AI helpers on the privacy pages were dropped
(2026-11-03).
- 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
@@ -257,7 +258,7 @@ Languages (Afrikaans, §10) and Wallet passes (§7) are removed from scope (2026
## Where the Hub client lives (1.41.0, update requests 1.42.0)
- Protocol: `docs/hub-protocol.md` — the same file is in the Hub repo (`joshua/events-hub`,
- Protocol: `crosscode-platform/docs/hub-protocol.md` (moved from `docs/` here on 2026-11-05) — the same file is in the Hub repo (`joshua/events-hub`,
developed at `../events-hub`); change both together.
- `backend/src/hub/`: `publicKey.js` (the production Hub key, built in since 1.42.1 — if the Hub's
signing key is ever regenerated, update it here in a release; `HUB_PUBLIC_KEY` env overrides it
+2 -2
View File
@@ -1200,5 +1200,5 @@ feature.
## Still open
- **Marketing site (§17):** built; waiting on Joshua's real logo, wording and photos where
placeholders remain.
Nothing. The marketing site (§17) is done (2026-11-03). The plan from here is
[ROADMAP-v3.md](./ROADMAP-v3.md).
+4
View File
@@ -0,0 +1,4 @@
# Cross Code — Road to three products — moved
The roadmap for every Cross Code product (Accounts, Church Website, Church App, Cross Events, Cross Code Hub) moved on 2026-11-05 to the platform monorepo, since it covers every product:
**`joshua/crosscode-platform` → [`docs/ROADMAP-v3.md`](https://c-git.crosscode.co.za/joshua/crosscode-platform/src/branch/main/docs/ROADMAP-v3.md)** (locally `../crosscode-platform/docs/ROADMAP-v3.md`).
+3 -289
View File
@@ -1,290 +1,4 @@
# Hub protocol (version 1)
# Hub protocol (versions 1 and 2) — moved
The contract between a **Cross Events install** (the church's site) and the **Cross Events
Hub** (`events-hub.crosscode.co.za`). A copy of this file lives in both repositories
(`joshua/hope-events` → `docs/hub-protocol.md`, `joshua/events-hub` → `docs/hub-protocol.md`);
change both together.
Principles:
- **Install → Hub only.** The install always starts the conversation; the Hub never calls
into an install. Works behind firewalls, and the Hub has no access to church data.
- **No personal information** is ever sent — counts, versions and health only. Income is
sent as a range, never an amount.
- **A Hub outage never switches a church off.** Licences are signed tokens the install
verifies offline with the Hub's public key.
- Every request carries `protocolVersion`. The Hub accepts the current and the previous
version, so installs waiting to update keep working.
All bodies are JSON (`Content-Type: application/json`). Times are ISO 8601 UTC strings.
Errors are `{ "message": "...", "code": "..." }` with a 4xx/5xx status.
---
## 1. Public key
```
GET /api/public-key
→ 200 { "protocolVersions": [1], "keyId": "k1", "algorithm": "Ed25519", "publicKey": "-----BEGIN PUBLIC KEY-----\n…" }
```
Installs ship with the Hub's public key built in (and may refresh it from here). The private
key exists only on the Hub server.
## 2. Register an install
Sent once, at the end of `/setup` (licence step) or from Site settings → "Connect to Hub".
```
POST /api/installs/register
{
"protocolVersion": 1,
"licenceKey": "CEH-7K2M-9QXA-P4RT-WD8C",
"install": {
"name": "Hope Family Church",
"url": "https://events.hopefamily.co.za",
"adminContact": { "name": "Jane Admin", "email": "jane@hopefamily.co.za" },
"version": "1.41.0",
"timezone": "Africa/Johannesburg",
"server": { "node": "20.18.0", "postgres": "16.4", "os": "linux 6.8" },
"features": { "whatsapp": true, "paymentProviders": ["yoco", "payfast"], "campuses": false }
}
}
→ 201 {
"installId": "inst_…",
"installSecret": "…", // shown once; the install stores it encrypted
"licence": "<licence token>",
"heartbeatIntervalMinutes": 15,
"latestVersion": "1.41.0",
"releases": [ … ], // as in the heartbeat reply (§3); Hub 1.4.0+
"notices": [ … ]
}
```
- `404 LICENCE_NOT_FOUND` — unknown key. `403 LICENCE_REVOKED` — revoked licences can't register.
- One licence = one install. Registering again **from the same URL** re-registers (new
secret, same `installId`). From a different URL → `409 LICENCE_IN_USE` until Joshua releases
the licence in the Hub (e.g. after a move to a new domain).
- The `adminContact` is the one piece of personal information: a work contact for the
church's admin, so Joshua can reach them. It is not used for anything else.
- Rate-limited per IP.
## 3. Heartbeat
Every `heartbeatIntervalMinutes` (15) from the install's job queue.
```
POST /api/installs/heartbeat
X-Hub-Install: inst_…
X-Hub-Timestamp: 2026-10-24T08:15:00.000Z
X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
{
"protocolVersion": 1,
"version": "1.41.0",
"health": {
"uptimeSeconds": 86400,
"db": true,
"jobs": { "mode": "queue", "failedLast24h": 0 },
"lastBackupAt": "2026-10-24T00:00:12.000Z", // null if none
"disk": { "freeBytes": 21474836480, "totalBytes": 53687091200 }, // null if unknown
"errorsLast24h": 0, // null if not tracked
"pendingMigrations": 0
},
"stats": {
"users": 412,
"events": { "active": 3, "upcoming": 5, "total": 61 },
"registrations": { "last30d": 88, "total": 2310 },
"tickets": { "last30d": 140, "total": 3901 },
"checkIns": { "last30d": 96 },
"messages": { "email30d": 610, "whatsapp30d": 233 },
"paymentProviders": ["yoco", "payfast"],
"campuses": false, // Site settings → Campuses on/off
"campusCount": 0, // active campuses (0 while off; Cross Events 1.43.0+)
"income": { "last30d": "R5 001–R20 000", "allTime": "R150 001–R250 000",
"eventIncome12m": "R100 001–R150 000" } // eventIncome12m: Cross Events 2.0.2+
}
}
→ 200 {
"licence": "<fresh licence token>",
"latestVersion": "1.42.0",
"releases": [ // Hub 1.4.0+: newer than the version sent, newest first, at most 20
{ "version": "1.42.0", "releasedAt": "2026-10-27T10:00:00.000Z", "urgent": false, "notes": "- Event series\n- Fixed: …" }
],
"notices": [ // Hub 1.4.0+: the provider's banners for this install
{ "id": "…", "level": "maintenance", "title": "Planned maintenance", "message": "…",
"startsAt": "2026-11-01T16:00:00.000Z", "endsAt": "2026-11-01T16:30:00.000Z", "updatedAt": "…" }
],
"serverTime": "…"
}
```
- **`releases`** — what's new in each version, so the church can decide whether to ask for the
update. The Hub takes the versions from the Cross Events repository's `v*` tags and the notes from
its `WHATS-NEW.md`. `notes` is plain text; lines starting with `- ` are
a list. `urgent` marks security or serious bug fixes: the install shows those in red and doesn't let
admins put them off. The highest release is `latestVersion`.
- **`notices`** — banners Joshua posts on the Hub's Notices page (`level`: `info`, `maintenance` or
`warning`). Sent until `endsAt`, including ones that haven't started, so the install shows each one
from `startsAt` without waiting for a heartbeat. The install shows them to site admins and
supervisors. Notices can also be emailed; that happens on the Hub, not through the install.
- A Hub older than 1.4.0 sends neither field; the install then shows no notes and no notices.
- The signature covers the exact bytes sent. Requests more than **5 minutes** off the Hub's
clock are refused (`401 STALE_REQUEST`); a bad signature is `401 BAD_SIGNATURE`; an unknown or
released install is `404 INSTALL_NOT_FOUND` (the install should show "Reconnect to Hub").
- **Income bands** (worked out on the install; the amount never leaves it): `R0–R1 000`,
`R1 001–R5 000`, `R5 001–R20 000`, `R20 001–R50 000`, `R50 001–R100 000`,
`R100 001–R150 000`, `R150 001–R250 000`, `R250 001–R400 000`, `R400 001–R700 000`,
`R700 001–R1 000 000`, `R1 000 001–R2 500 000`, `R2 500 001–R5 000 000`,
`R5 000 001–R10 000 000`, `over R10 000 000`. Before Cross Events 2.0.2 the bands stopped at
`over R100 000`, and donations applied to a registration were counted twice.
- **`eventIncome12m`** (Cross Events 2.0.2+) is the band of the last 365 days' event income:
payments less refunds, without donations or sponsorships. Cross Code's licence tiers are billed
on it, so every tier edge is a band edge. Older installs don't send it.
- The Hub keeps every heartbeat for 30 days, then one per install per day.
## 3a. Update request
Sent when a site admin presses **Request update** (Site settings → Licence). Installs never update
themselves and never show the deploy command; the request tells Joshua, who updates the server.
Signed exactly like the heartbeat.
```
POST /api/installs/update-request
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
{ "protocolVersion": 1, "version": "1.41.2" }
→ 201 { "requestedAt": "2026-10-27T09:00:00.000Z", "version": "1.42.0", "alreadyRequested": false, "changed": false }
→ 200 { …, "alreadyRequested": true, "changed": false } // a request for the latest is already open: not emailed again
→ 200 { …, "alreadyRequested": true, "changed": true, "previousVersion": "1.42.0" }
// the open request was for an older version: it now asks for the latest
```
- The Hub opens an `update_requested` alert on the install (shown under Alerts) and emails the
alert address once. It is resolved when a heartbeat reports a newer version than before.
- **Changing a request (Hub 1.5.0):** when a request is open for a version that has since been
overtaken, asking again moves it on to the latest version — the alert says "latest is 1.47.3
(changed from 1.47.0)" and the alert address is emailed once about the change. `version` is
always the version now asked for. A Hub before 1.5.0 doesn't send `changed`; installs then
withdraw the request and ask again (two emails, same result).
- `409 UP_TO_DATE` — the install already runs the Hub's latest version (or none is set).
- Errors as in §3 (`INSTALL_NOT_FOUND`, `STALE_REQUEST`, `BAD_SIGNATURE`). Rate-limited (10 an
hour per IP). A Hub older than this section answers `404 NOT_FOUND`.
- No personal information: not who pressed the button (the Hub already has the admin contact).
### Withdrawing it
Sent when a site admin presses **Withdraw request**. Signed like the heartbeat.
```
POST /api/installs/update-request/cancel
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
{ "protocolVersion": 1 }
→ 200 { "cancelled": true } // the open request was resolved, and Joshua is emailed
→ 200 { "cancelled": false } // there was no open request
```
- The Hub resolves the `update_requested` alert and records `update_request_withdrawn` in the install's
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
Ed25519 signature of the ASCII string `hel1.<payload>`.
```json
{
"v": 1,
"licenceId": "lic_…",
"installId": "inst_…",
"customer": "Hope Family Church",
"plan": "standard",
"status": "active", // active | suspended | revoked
"licenceExpiresAt": "2027-10-31T21:59:59.000Z", // null = no end date
"graceDays": 14,
"issuedAt": "2026-10-24T08:15:00.000Z",
"validUntil": "2026-11-23T08:15:00.000Z", // issuedAt + 30 days
"limits": { "aiMonthlyCapCents": 0 } // the AI cap (§3b); 0 = AI off
}
```
### What the install does with it
The install keeps the newest valid token (signature checks out, `installId` matches) and works
out its state:
| State | When | What happens |
|-------|------|--------------|
| **active** | `status` is active and now is before `min(licenceExpiresAt, validUntil)` | Everything works |
| **grace** | after that date, for up to `graceDays` | Everything works; **only site admins** see a banner "Licence needs renewing — X days left" |
| **suspended** | `status` is suspended, or grace has run out | Read-only: events can't be created or edited; no new registrations or payments. Tickets still scan; admins can view and export all data; privacy requests still work |
| **revoked** | `status` is revoked | Same as suspended, with a permanent banner |
| **unlicensed** | never connected, or the Hub answered `INSTALL_NOT_FOUND` (the install drops its token) | Same as suspended (events can't be created or edited either); admins see "Connect to Hub" |
`validUntil` is refreshed by every heartbeat, so a Hub outage only matters after 30 days (then
grace starts). Blocking the Hub can't keep a suspended licence active for more than 30 days.
## 5. Versioning
- `protocolVersion` goes up only for changes an older install can't handle. Adding optional
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`.
The install ↔ Hub protocol, for every product (a copy is also in `joshua/events-hub`, `docs/hub-protocol.md`) moved on 2026-11-05 to the platform monorepo, since it covers every product:
**`joshua/crosscode-platform` → [`docs/hub-protocol.md`](https://c-git.crosscode.co.za/joshua/crosscode-platform/src/branch/main/docs/hub-protocol.md)** (locally `../crosscode-platform/docs/hub-protocol.md`).
+2 -1
View File
@@ -38,7 +38,8 @@ test.describe("feedback page", () => {
page.once("dialog", d => d.accept());
await lerato.getByRole("button", { name: "Remind" }).click();
await expect(page.getByRole("status")).toHaveText("Reminded 1 person.");
// 2.1.1: it answers straight away; the reminder goes out in the background and the page refreshes.
await expect(page.getByRole("status")).toHaveText("Reminding 1 person. It carries on in the background.");
await expect(lerato).toContainText("1 · last");
await expect(lerato.getByRole("button", { name: "Remind" })).toBeDisabled();
await expect(page.getByRole("button", { name: /Remind those who haven't answered/ })).toBeDisabled();
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "2.1.0",
"version": "2.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "2.1.0",
"version": "2.1.1",
"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": "2.1.0",
"version": "2.1.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -1,6 +1,6 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { ArrowLeft, BellRing, MessageSquareHeart, Pencil, Send, Star } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
@@ -124,6 +124,14 @@ export default function FeedbackPage() {
}, [token, eventId, setMsg]);
useExternalEffect(() => { setDetail(null); loadDetail(); }, [loadDetail]);
// Sending and reminders run in the background (2.1.1): refresh a few times to show them land.
const refreshTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => () => refreshTimers.current.forEach(clearTimeout), []);
const refreshSoon = useCallback(() => {
refreshTimers.current.forEach(clearTimeout);
refreshTimers.current = [2000, 6000, 15000].map(ms => setTimeout(() => { loadDetail(); loadList(); }, ms));
}, [loadDetail, loadList]);
const remind = async (userIds?: string[]) => {
if (!detail) return;
const who = userIds ? detail.people?.find(p => p.userId === userIds[0])?.name || "this person" : null;
@@ -135,7 +143,7 @@ export default function FeedbackPage() {
try {
const out = await (apiV2Data("post", "/events/{id}/feedback/remind", { params: { id: detail.event.id }, authToken: token, body: { userIds: userIds ?? null } }) as Promise<{ message: string; reminded: number; newlyInvited: number }>);
setMsg({ type: out.reminded || out.newlyInvited ? "ok" : "err", text: out.message });
await Promise.all([loadDetail(), loadList()]);
if (out.reminded || out.newlyInvited) refreshSoon();
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not send the reminders" });
} finally {
@@ -147,9 +155,11 @@ export default function FeedbackPage() {
if (!detail || !window.confirm("Send the feedback form now, instead of waiting for its send time?")) return;
setBusy("send");
try {
const out = await (apiV2Data("post", "/events/{id}/feedback/send-now", { params: { id: detail.event.id }, authToken: token, body: {} }) as Promise<{ message: string }>);
const out = await (apiV2Data("post", "/events/{id}/feedback/send-now", { params: { id: detail.event.id }, authToken: token, body: {} }) as Promise<{ message: string; invited: number }>);
setMsg({ type: "ok", text: out.message });
// The form now shows as sent; the people appear as the emails go out.
await Promise.all([loadDetail(), loadList()]);
if (out.invited) refreshSoon();
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not send the form" });
} finally {
+11 -4
View File
@@ -27141,7 +27141,7 @@
"Feedback"
],
"summary": "Send the feedback form now",
"description": "**Who:** Supervisors and admins.",
"description": "Answers straight away: `invited` is how many it is going to; the emails go out in the background.\n\n**Who:** Supervisors and admins.",
"operationId": "postEventsIdFeedbackSend-now",
"security": [
{
@@ -69877,7 +69877,7 @@
"Feedback"
],
"summary": "Remind people who haven't answered",
"description": "A fresh link to everyone who hasn't answered, or to `userIds` (named forms only). Nobody is sent the form or a reminder twice within 24 hours. Without `userIds` it also sends the form to anyone who has become eligible since it went out.\n\n**Who:** Supervisors and admins.",
"description": "A fresh link to everyone who hasn't answered, or to `userIds` (named forms only). Nobody is sent the form or a reminder twice within 24 hours. Without `userIds` it also sends the form to anyone who has become eligible since it went out. Answers straight away; the emails go out in the background.\n\n**Who:** Supervisors and admins.",
"operationId": "postEventsIdFeedbackRemind",
"security": [
{
@@ -69940,18 +69940,24 @@
"reminded": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
"maximum": 9007199254740991,
"description": "Being reminded"
},
"newlyInvited": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
"maximum": 9007199254740991,
"description": "Being sent the form for the first time"
},
"skipped": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"queued": {
"type": "boolean",
"description": "Emails are going out in the background"
},
"message": {
"type": "string"
}
@@ -69960,6 +69966,7 @@
"reminded",
"newlyInvited",
"skipped",
"queued",
"message"
],
"additionalProperties": false
+8 -2
View File
@@ -2679,7 +2679,9 @@ export interface paths {
put?: never;
/**
* Send the feedback form now
* @description **Who:** Supervisors and admins.
* @description Answers straight away: `invited` is how many it is going to; the emails go out in the background.
*
* **Who:** Supervisors and admins.
*/
post: operations["postEventsIdFeedbackSend-now"];
delete?: never;
@@ -7291,7 +7293,7 @@ export interface paths {
put?: never;
/**
* Remind people who haven't answered
* @description A fresh link to everyone who hasn't answered, or to `userIds` (named forms only). Nobody is sent the form or a reminder twice within 24 hours. Without `userIds` it also sends the form to anyone who has become eligible since it went out.
* @description A fresh link to everyone who hasn't answered, or to `userIds` (named forms only). Nobody is sent the form or a reminder twice within 24 hours. Without `userIds` it also sends the form to anyone who has become eligible since it went out. Answers straight away; the emails go out in the background.
*
* **Who:** Supervisors and admins.
*/
@@ -38589,9 +38591,13 @@ export interface operations {
content: {
"application/json": {
data: {
/** @description Being reminded */
reminded: number;
/** @description Being sent the form for the first time */
newlyInvited: number;
skipped: number;
/** @description Emails are going out in the background */
queued: boolean;
message: string;
};
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "2.1.0",
"version": "2.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "2.1.0",
"version": "2.1.1",
"license": "ISC",
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "2.1.0",
"version": "2.1.1",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",