Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6c96706c8 | ||
|
|
4c577fffb7 | ||
|
|
70460a28c7 | ||
|
|
cc855ec8bb | ||
|
|
0a9b08e1fd | ||
|
|
d6ff08f5d9 | ||
|
|
b1a5c5abc3 | ||
|
|
1adf1d4870 | ||
|
|
e42e244edc | ||
|
|
f85842d4b3 | ||
|
|
78bf6df40c | ||
|
|
47725a689d | ||
|
|
a9584cdde5 | ||
|
|
316a2b40a1 | ||
|
|
73547a0f8c | ||
|
|
b5821e1c18 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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`).
|
||||
|
||||
@@ -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();
|
||||
|
||||
Generated
+2
-2
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+8
-2
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user