Compare commits

...
3 Commits
Author SHA1 Message Date
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
21 changed files with 269 additions and 67 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');
});
});
+3 -2
View File
@@ -1,6 +1,6 @@
# 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
Last updated: 2026-11-03, after **2.1.1** and Hub **1.7.0**. The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -17,8 +17,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 |
+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",