Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
316a2b40a1 | ||
|
|
73547a0f8c | ||
|
|
b5821e1c18 | ||
|
|
84cf37d31d | ||
|
|
84a1a9084c |
@@ -7,6 +7,59 @@ 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
|
||||
|
||||
- **Feedback page** (Supervisor → Feedback, `/dashboard/supervisor/feedback`; supervisors and
|
||||
admins, campus-scoped supervisors see their campuses). Every event with a feedback form, newest
|
||||
first: sent / scheduled / no send time, answered of asked with the response rate, the average
|
||||
star rating and the last reminder. One event's page has the per-question **Results** (with the
|
||||
AI "Summarise feedback" button), each **Response**, and on a named form **People** — who was
|
||||
asked, who has answered and the reminders each has had. "Send now" before it goes out; "Edit
|
||||
form" opens the event editor.
|
||||
- **Remind those who haven't answered** (`POST /api/v2/events/{id}/feedback/remind`): a fresh link
|
||||
to each, and the form to anyone eligible who hasn't had it yet (e.g. checked in after it went
|
||||
out). On a named form one person can be reminded (`userIds`). Nobody gets the form or a reminder
|
||||
twice within 24 hours (`REMIND_GAP_MS`). The automatic 3-day reminder is unchanged.
|
||||
- `GET /api/v2/feedback` (every form) and `GET /api/v2/events/{id}/feedback/results` (one
|
||||
event's results, responses and people) — `src/services/feedback.ts`, `src/v2/resources/feedback.ts`.
|
||||
- `FeedbackInvite.reminderCount`, counting automatic and manual reminders (migration
|
||||
`20261103120000_feedback_reminders`; existing reminded invites start at 1).
|
||||
- Access log action **`feedback_responses`**: opening a named form's answers.
|
||||
- Help page for the Feedback page; Feedback on the supervisor and admin dashboards and in Ctrl+K.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Anonymous forms stay anonymous on the page:** no names on answers, and no list of who has or
|
||||
hasn't answered (it would give answers away on a small event) — counts only, and no reminding
|
||||
one person.
|
||||
- A reminder now only goes to someone who still hasn't answered at the moment it's sent.
|
||||
|
||||
### Removed
|
||||
|
||||
- The planned 2.1.0 AI helpers on the privacy pages (Consent log, Access log) are dropped from
|
||||
the roadmap.
|
||||
|
||||
## [2.0.2] - 2026-11-03
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -261,6 +261,13 @@ Feedback → Attendance**.
|
||||
3 days later to anyone who hasn't answered.
|
||||
- Answers can be anonymous or named. "Send now" is available, and the questions
|
||||
lock once people have answered.
|
||||
- **Feedback page** (Supervisor → Feedback, supervisors and admins): every event's form
|
||||
with answered of asked, response rate, average rating and last reminder; per event the
|
||||
results per question (and the AI summary), each response, and on named forms who was
|
||||
asked and who has answered. **Remind** sends everyone who hasn't answered a fresh link
|
||||
(and the form to anyone eligible who hasn't had it), or one person on a named form;
|
||||
nobody gets the form or a reminder twice within 24 hours. Anonymous forms show no names
|
||||
and no list of who has answered. Opening a named form's answers goes in the access log.
|
||||
- **Sponsor a spot** — per-event setting. A "Sponsor a spot" button on the event page
|
||||
lets anyone, including guests, pay for one or more spots (at the main ticket price)
|
||||
or any amount through Yoco. They can give anonymously or add a name and a message.
|
||||
|
||||
@@ -12,6 +12,24 @@ 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
|
||||
in one place: how many people answered, the average star rating, each question's results and
|
||||
every response. On a named form you can also see who was asked and who hasn't answered yet.
|
||||
- **Remind the people who haven't answered.** One button sends everyone who hasn't given feedback
|
||||
a fresh link by email or WhatsApp, or you can remind one person at a time on a named form.
|
||||
Nobody is reminded twice within 24 hours. The automatic reminder after 3 days still goes out.
|
||||
- **Anonymous forms stay anonymous:** the page never shows who gave which answer, or who has and
|
||||
hasn't answered.
|
||||
- Opening the answers to a named form is recorded in the access log.
|
||||
|
||||
## 2.0.2
|
||||
|
||||
- **Licence information sent to Cross Code.** Your site now tells Cross Code which range your event
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "2.0.2",
|
||||
"version": "2.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "2.0.2",
|
||||
"version": "2.1.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@cj-tech-master/excelts": "^10.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "2.0.2",
|
||||
"version": "2.1.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 2.1.0: how many times each person has been reminded about a feedback form (automatic and by hand).
|
||||
ALTER TABLE "FeedbackInvite" ADD COLUMN "reminderCount" INTEGER NOT NULL DEFAULT 0;
|
||||
UPDATE "FeedbackInvite" SET "reminderCount" = 1 WHERE "remindedAt" IS NOT NULL;
|
||||
@@ -1450,7 +1450,8 @@ model FeedbackInvite {
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
sentAt DateTime @default(now())
|
||||
remindedAt DateTime?
|
||||
remindedAt DateTime? // the last reminder (automatic or by hand)
|
||||
reminderCount Int @default(0)
|
||||
respondedAt DateTime?
|
||||
form FeedbackForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* The Feedback page (2.1.0): what supervisors and admins see of post-event feedback, and the
|
||||
* "Remind" button. Forms, sending and the automatic reminder are in utils/feedback.js.
|
||||
*
|
||||
* Anonymous forms stay anonymous here: no names on answers, and no list of who has or hasn't
|
||||
* answered (on a small event that would give the answers away) — only counts.
|
||||
*/
|
||||
import { prisma as typedPrisma } from '../v2/legacy';
|
||||
import { fail } from './errors';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the records are Prisma's with includes */
|
||||
const prisma = typedPrisma as any;
|
||||
const fb = require('../utils/feedback') as {
|
||||
ANSWERABLE: string[];
|
||||
REMINDER_AFTER_MS: number;
|
||||
REMIND_GAP_MS: number;
|
||||
summarise(fields: any[], answers: any[]): any[];
|
||||
feedbackRecipients(form: any): Promise<{ id: string }[]>;
|
||||
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');
|
||||
const rate = (answered: number, invited: number) => (invited ? Math.round((answered / invited) * 100) : null);
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
/** Average of every star rating on a form (all rating questions together), or null. Pure. */
|
||||
export function averageRating(values: string[]): number | null {
|
||||
const nums = values.map(Number).filter(n => Number.isInteger(n) && n >= 1 && n <= 5);
|
||||
return nums.length ? round1(nums.reduce((s, n) => s + n, 0) / nums.length) : null;
|
||||
}
|
||||
|
||||
/** Every event with a feedback form, newest event first. `where` is the campus scope (listWhere). */
|
||||
export async function feedbackOverview(where: object = {}) {
|
||||
const forms = await prisma.feedbackForm.findMany({
|
||||
where,
|
||||
include: {
|
||||
event: { select: { id: true, title: true, startDate: true, endDate: true } },
|
||||
_count: { select: { invites: true, responses: true } },
|
||||
},
|
||||
});
|
||||
if (!forms.length) return [];
|
||||
const ids = forms.map((f: any) => f.id);
|
||||
const [ratings, reminders] = await Promise.all([
|
||||
prisma.feedbackAnswer.findMany({ where: { field: { formId: { in: ids }, type: 'rating' } }, select: { value: true, field: { select: { formId: true } } } }),
|
||||
prisma.feedbackInvite.groupBy({ by: ['formId'], where: { formId: { in: ids } }, _max: { remindedAt: true } }),
|
||||
]);
|
||||
const ratingsBy = new Map<string, string[]>();
|
||||
for (const r of ratings) ratingsBy.set(r.field.formId, [...(ratingsBy.get(r.field.formId) || []), r.value]);
|
||||
const remindedBy = new Map(reminders.map((r: any) => [r.formId, r._max.remindedAt]));
|
||||
return forms
|
||||
.map((f: any) => ({
|
||||
event: f.event,
|
||||
status: statusOf(f),
|
||||
sendAt: f.sendAt,
|
||||
sentAt: f.sentAt,
|
||||
anonymous: f.anonymous,
|
||||
invited: f._count.invites,
|
||||
answered: f._count.responses,
|
||||
responseRate: rate(f._count.responses, f._count.invites),
|
||||
averageRating: averageRating(ratingsBy.get(f.id) || []),
|
||||
lastReminderAt: remindedBy.get(f.id) || null,
|
||||
}))
|
||||
.sort((a: any, b: any) => new Date(b.event.startDate).getTime() - new Date(a.event.startDate).getTime());
|
||||
}
|
||||
|
||||
async function formFor(eventId: string, include: object = {}) {
|
||||
const form = await prisma.feedbackForm.findUnique({ where: { eventId }, include: { event: { select: { id: true, title: true, startDate: true, endDate: true } }, ...include } });
|
||||
if (!form) fail(404, 'This event has no feedback form.', 'NO_FEEDBACK_FORM');
|
||||
return form;
|
||||
}
|
||||
|
||||
/** People who would get the form but haven't been sent it (not sent yet, or checked in later). */
|
||||
async function waitingCount(form: any, invitedIds: Set<string>) {
|
||||
return (await fb.feedbackRecipients(form)).filter(u => !invitedIds.has(u.id)).length;
|
||||
}
|
||||
|
||||
/** One event's feedback: results per question, each response, and (named forms) who was asked. */
|
||||
export async function eventFeedback(eventId: string, now = new Date()) {
|
||||
const form = await formFor(eventId, {
|
||||
fields: { orderBy: { order: 'asc' } },
|
||||
responses: { orderBy: { createdAt: 'desc' }, include: { answers: true, user: { select: { name: true } } } },
|
||||
invites: { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true } } } },
|
||||
});
|
||||
const named = !form.anonymous;
|
||||
const fields = form.fields.filter((f: any) => fb.ANSWERABLE.includes(f.type));
|
||||
const answers = form.responses.flatMap((r: any) => r.answers.map((a: any) => ({ fieldId: a.fieldId, value: a.value, ...(named ? { name: r.user?.name || '' } : {}) })));
|
||||
const invited = form.invites.length;
|
||||
const answered = form.responses.length;
|
||||
const gapFrom = now.getTime() - fb.REMIND_GAP_MS;
|
||||
const notAnswered = form.invites.filter((i: any) => !i.respondedAt);
|
||||
// Not sent the form or a reminder in the last 24 hours (as remindFeedback).
|
||||
const remindable = (i: any) => !i.respondedAt && new Date(i.sentAt).getTime() < gapFrom && (!i.remindedAt || new Date(i.remindedAt).getTime() < gapFrom);
|
||||
const canRemind = notAnswered.filter(remindable).length;
|
||||
const waiting = await waitingCount(form, new Set(form.invites.map((i: any) => i.userId)));
|
||||
const autoReminderAt = form.sentAt ? new Date(new Date(form.sentAt).getTime() + fb.REMINDER_AFTER_MS) : null;
|
||||
|
||||
return {
|
||||
event: form.event,
|
||||
form: {
|
||||
status: statusOf(form),
|
||||
sendAt: form.sendAt,
|
||||
sentAt: form.sentAt,
|
||||
audience: (form.audience === 'all' ? 'all' : 'checked_in') as 'all' | 'checked_in',
|
||||
anonymous: form.anonymous,
|
||||
intro: form.intro,
|
||||
autoReminderAt,
|
||||
autoReminderSent: !!form.reminderSentAt,
|
||||
},
|
||||
counts: {
|
||||
invited,
|
||||
answered,
|
||||
notAnswered: notAnswered.length,
|
||||
responseRate: rate(answered, invited),
|
||||
canRemind,
|
||||
remindedRecently: notAnswered.length - canRemind,
|
||||
waiting,
|
||||
},
|
||||
averageRating: averageRating(answers.filter((a: any) => fields.some((f: any) => f.id === a.fieldId && f.type === 'rating')).map((a: any) => a.value)),
|
||||
questions: fb.summarise(fields, answers),
|
||||
fields: fields.map((f: any) => ({ id: f.id, type: f.type, label: f.label })),
|
||||
responses: form.responses.map((r: any) => ({
|
||||
id: r.id,
|
||||
createdAt: r.createdAt,
|
||||
name: named ? r.user?.name || 'Someone who has since been removed' : null,
|
||||
answers: Object.fromEntries(r.answers.map((a: any) => [a.fieldId, a.value])),
|
||||
})),
|
||||
people: named
|
||||
? form.invites
|
||||
.map((i: any) => ({
|
||||
userId: i.userId,
|
||||
name: i.user?.name || '',
|
||||
email: i.user?.email && !/@(guest\.local|deleted\.invalid)$/.test(i.user.email) ? i.user.email : null,
|
||||
phoneNumber: i.user?.phoneNumber || null,
|
||||
sentAt: i.sentAt,
|
||||
remindedAt: i.remindedAt,
|
||||
reminderCount: i.reminderCount,
|
||||
respondedAt: i.respondedAt,
|
||||
canRemind: remindable(i),
|
||||
}))
|
||||
.sort((a: any, b: any) => Number(!!a.respondedAt) - Number(!!b.respondedAt) || a.name.localeCompare(b.name))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "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 } = {}, 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 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(`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 (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, queued: !!(reminded || newlyInvited), message, title: form.event.title };
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const ACCESS_ACTIONS = {
|
||||
kids_offline: 'Kids check-in list downloaded for offline use',
|
||||
volunteer_roster: 'Volunteer roster opened',
|
||||
ai_feedback: 'Feedback comments summarised by the AI helper',
|
||||
feedback_responses: 'Feedback answers viewed (named form)',
|
||||
ai_report: 'Report summarised by the AI helper (anonymised totals)',
|
||||
};
|
||||
|
||||
|
||||
@@ -183,19 +183,53 @@ async function sendFeedbackInvites(formId) {
|
||||
return people.length;
|
||||
}
|
||||
|
||||
/** The single reminder: a fresh link for everyone who hasn't answered. */
|
||||
async function sendFeedbackReminders(formId) {
|
||||
/** A manual reminder isn't sent to the same person again within this long. */
|
||||
const REMIND_GAP_MS = 24 * 3600e3;
|
||||
|
||||
/**
|
||||
* A fresh link for everyone who hasn't answered. With no options it's the automatic reminder:
|
||||
* only people never reminded. `userIds` limits it to those people; `minGapMs` skips anyone
|
||||
* reminded more recently than that (the manual "Remind" button). Returns how many were sent.
|
||||
*/
|
||||
async function sendFeedbackReminders(formId, { userIds = null, minGapMs = null, now = new Date() } = {}) {
|
||||
const form = await prisma.feedbackForm.findUnique({ where: { id: formId }, include: { event: true } });
|
||||
if (!form) return 0;
|
||||
// Manual: not if they were sent the form, or reminded, within minGapMs.
|
||||
const since = minGapMs == null ? null : new Date(now.getTime() - minGapMs);
|
||||
const recent = since == null ? { remindedAt: null } : { sentAt: { lt: since }, OR: [{ remindedAt: null }, { remindedAt: { lt: since } }] };
|
||||
const invites = await prisma.feedbackInvite.findMany({
|
||||
where: { formId, respondedAt: null, remindedAt: null },
|
||||
where: { formId, respondedAt: null, ...recent, ...(userIds ? { userId: { in: userIds } } : {}) },
|
||||
include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } },
|
||||
});
|
||||
let sent = 0;
|
||||
for (const inv of invites) {
|
||||
const token = newToken();
|
||||
await prisma.feedbackInvite.update({ where: { id: inv.id }, data: { tokenHash: hashToken(token), remindedAt: new Date() } });
|
||||
// Only if they still haven't answered (they may have while this ran).
|
||||
const claimed = await prisma.feedbackInvite.updateMany({
|
||||
// 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;
|
||||
sent += 1;
|
||||
await sendFeedbackMessage(inv.user, form.event, token, { reminder: true }).catch(e => console.warn('[feedback] reminder failed:', e?.message || e));
|
||||
}
|
||||
return invites.length;
|
||||
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. */
|
||||
@@ -224,7 +258,7 @@ async function findInviteByToken(token) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ANSWERABLE, ALL_TYPES, REMINDER_AFTER_MS, feedbackError, hashToken, feedbackLink,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import './resources/adminPrivacy';
|
||||
import './resources/checkin';
|
||||
import './resources/auth';
|
||||
import './resources/ai';
|
||||
import './resources/feedback';
|
||||
|
||||
const pkg = require('../../package.json') as { version: string };
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* The Feedback page (2.1.0): see services/feedback.ts. Supervisors and admins; campus-scoped
|
||||
* supervisors see their campuses' events. Setting a form up, "Send now" and the summary report
|
||||
* stay in money.ts.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { defineRoute } from '../route';
|
||||
import { single } from '../paging';
|
||||
import { id, idParams } from '../schemas';
|
||||
import * as feedback from '../../services/feedback';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- campus scope and the access log are JavaScript */
|
||||
const cs = require('../../utils/campusScope') as {
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage' }): any;
|
||||
scopeList: any;
|
||||
listWhere(req: unknown, path?: string): Promise<object>;
|
||||
};
|
||||
const { logDataAccess } = require('../../utils/accessLog') as { logDataAccess(req: unknown, entry: { action: string; what?: string; eventId?: string; records?: number }): Promise<void> };
|
||||
|
||||
const event = z.object({ id, title: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date() });
|
||||
const status = z.enum(['sent', 'scheduled', 'not_scheduled']).describe('sent, scheduled (a send time is set) or not_scheduled');
|
||||
const when = z.coerce.date().nullable();
|
||||
const count = z.object({ label: z.string(), value: z.number().int() });
|
||||
|
||||
export const overview = defineRoute({
|
||||
method: 'get', path: '/feedback', tag: 'Feedback', summary: 'Every event with a feedback form',
|
||||
description: 'Newest event first, with how many were asked and answered, and the average star rating.',
|
||||
access: 'supervisor', campusScope: 'scopeList (the handler filters by listWhere)', middleware: [cs.scopeList],
|
||||
response: single(z.array(z.object({
|
||||
event, status, sendAt: when, sentAt: when, anonymous: z.boolean(),
|
||||
invited: z.number().int(), answered: z.number().int(), responseRate: z.number().nullable().describe('%'),
|
||||
averageRating: z.number().nullable().describe('Every star rating on the form, 1–5'), lastReminderAt: when,
|
||||
}))),
|
||||
async handler({ req }) { return { data: await feedback.feedbackOverview(await cs.listWhere(req, 'event')) }; },
|
||||
});
|
||||
|
||||
export const results = defineRoute({
|
||||
method: 'get', path: '/events/:id/feedback/results', tag: 'Feedback', summary: 'An event\'s feedback: results, responses and who was asked',
|
||||
description: 'On an anonymous form, responses have no name and `people` is null. Opening a named form\'s answers is recorded in the access log.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams,
|
||||
response: single(z.object({
|
||||
event,
|
||||
form: z.object({
|
||||
status, sendAt: when, sentAt: when, audience: z.enum(['checked_in', 'all']), anonymous: z.boolean(), intro: z.string().nullable(),
|
||||
autoReminderAt: when.describe('When the automatic reminder goes (3 days after sending)'), autoReminderSent: z.boolean(),
|
||||
}),
|
||||
counts: z.object({
|
||||
invited: z.number().int(), answered: z.number().int(), notAnswered: z.number().int(), responseRate: z.number().nullable(),
|
||||
canRemind: z.number().int().describe('Not answered, and not sent the form or a reminder in the last 24 hours'),
|
||||
remindedRecently: z.number().int(),
|
||||
waiting: z.number().int().describe('Would get the form but haven\'t been sent it yet (e.g. checked in after it went out)'),
|
||||
}),
|
||||
averageRating: z.number().nullable(),
|
||||
questions: z.array(z.object({
|
||||
fieldId: id, type: z.string(), label: z.string(), answered: z.number().int(),
|
||||
average: z.number().nullable().optional(), distribution: z.array(count).optional(), counts: z.array(count).optional(),
|
||||
texts: z.array(z.object({ value: z.string(), name: z.string().optional() })).optional(),
|
||||
})),
|
||||
fields: z.array(z.object({ id, type: z.string(), label: z.string() })),
|
||||
responses: z.array(z.object({ id, createdAt: z.coerce.date(), name: z.string().nullable(), answers: z.record(z.string(), z.string()).describe('Question id → answer') })),
|
||||
people: z.array(z.object({
|
||||
userId: id, name: z.string(), email: z.string().nullable(), phoneNumber: z.string().nullable(),
|
||||
sentAt: z.coerce.date(), remindedAt: when, reminderCount: z.number().int(), respondedAt: when, canRemind: z.boolean(),
|
||||
})).nullable(),
|
||||
})),
|
||||
async handler({ params, req }) {
|
||||
const data = await feedback.eventFeedback(params.id);
|
||||
if (!data.form.anonymous && data.responses.length) {
|
||||
await logDataAccess(req, { action: 'feedback_responses', what: `Feedback answers: ${data.event.title}`, eventId: data.event.id, records: data.responses.length });
|
||||
}
|
||||
return { data };
|
||||
},
|
||||
});
|
||||
|
||||
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. 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().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,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 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). 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() },
|
||||
feedbackInvite: { findMany: jest.fn(), updateMany: jest.fn(), count: jest.fn(), create: jest.fn(), groupBy: jest.fn() },
|
||||
feedbackAnswer: { findMany: jest.fn() },
|
||||
ticket: { findMany: jest.fn() },
|
||||
registration: { findMany: jest.fn() },
|
||||
user: { findMany: jest.fn() },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
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, runFeedbackTask } = require('../src/utils/feedback');
|
||||
|
||||
const NOW = new Date('2026-11-10T12:00:00Z');
|
||||
const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3600e3);
|
||||
const EVENT = { id: 'ev1', title: 'Men\'s Breakfast', startDate: new Date('2026-11-01T07:00:00Z'), endDate: new Date('2026-11-01T10:00:00Z') };
|
||||
const FIELDS = [
|
||||
{ id: 'f1', type: 'rating', label: 'Overall', options: null },
|
||||
{ id: 'f2', type: 'text', label: 'What could be better?', options: null },
|
||||
{ id: 'f0', type: 'statement', label: 'Thanks for coming', options: null },
|
||||
];
|
||||
const person = (id: string, name: string) => ({ id, name, email: `${id}@example.com`, phoneNumber: null });
|
||||
|
||||
function form(extra: object = {}) {
|
||||
return {
|
||||
id: 'form1', eventId: 'ev1', event: EVENT, anonymous: false, audience: 'checked_in', intro: null,
|
||||
sendAt: hoursAgo(72), sentAt: hoursAgo(72), reminderSentAt: null, fields: FIELDS,
|
||||
responses: [
|
||||
{ id: 'r1', createdAt: hoursAgo(60), user: { name: 'Anna' }, answers: [{ fieldId: 'f1', value: '5' }, { fieldId: 'f2', value: 'Longer coffee break' }] },
|
||||
{ id: 'r2', createdAt: hoursAgo(50), user: { name: 'Ben' }, answers: [{ fieldId: 'f1', value: '4' }] },
|
||||
],
|
||||
invites: [
|
||||
{ userId: 'u1', user: person('u1', 'Anna'), sentAt: hoursAgo(72), remindedAt: null, reminderCount: 0, respondedAt: hoursAgo(60) },
|
||||
{ userId: 'u2', user: person('u2', 'Ben'), sentAt: hoursAgo(72), remindedAt: null, reminderCount: 0, respondedAt: hoursAgo(50) },
|
||||
{ userId: 'u3', user: person('u3', 'Cara'), sentAt: hoursAgo(72), remindedAt: hoursAgo(2), reminderCount: 1, respondedAt: null },
|
||||
{ userId: 'u4', user: person('u4', 'Dumi'), sentAt: hoursAgo(72), remindedAt: null, reminderCount: 0, respondedAt: null },
|
||||
{ userId: 'u5', user: person('u5', 'Eve'), sentAt: hoursAgo(1), remindedAt: null, reminderCount: 0, respondedAt: null },
|
||||
],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDb.ticket.findMany.mockResolvedValue([]);
|
||||
mockDb.registration.findMany.mockResolvedValue([]);
|
||||
mockDb.user.findMany.mockResolvedValue([]);
|
||||
mockDb.feedbackInvite.updateMany.mockResolvedValue({ count: 1 });
|
||||
});
|
||||
|
||||
describe('averageRating', () => {
|
||||
test('averages every valid star rating, to one decimal', () => {
|
||||
expect(feedback.averageRating(['5', '4', '4'])).toBe(4.3);
|
||||
expect(feedback.averageRating(['x', '9', '0'])).toBeNull();
|
||||
expect(feedback.averageRating([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('one event\'s feedback', () => {
|
||||
test('a named form: names on answers, everyone asked, not answered first', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
|
||||
const data = await feedback.eventFeedback('ev1', NOW);
|
||||
expect(data.counts).toMatchObject({ invited: 5, answered: 2, notAnswered: 3, responseRate: 40 });
|
||||
expect(data.averageRating).toBe(4.5);
|
||||
expect(data.fields.map((f: any) => f.id)).toEqual(['f1', 'f2']); // statements aren't questions
|
||||
expect(data.responses[0]).toMatchObject({ name: 'Anna', answers: { f1: '5', f2: 'Longer coffee break' } });
|
||||
expect(data.people!.map((p: any) => p.name)).toEqual(['Cara', 'Dumi', 'Eve', 'Anna', 'Ben']);
|
||||
});
|
||||
|
||||
test('nobody is remindable within 24 hours of the form or a reminder', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
|
||||
const data = await feedback.eventFeedback('ev1', NOW);
|
||||
const can = Object.fromEntries(data.people!.map((p: any) => [p.name, p.canRemind]));
|
||||
expect(can).toEqual({ Cara: false, Dumi: true, Eve: false, Anna: false, Ben: false });
|
||||
expect(data.counts).toMatchObject({ canRemind: 1, remindedRecently: 2 });
|
||||
});
|
||||
|
||||
test('an anonymous form: no names anywhere, and no list of who has answered', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue(form({ anonymous: true }));
|
||||
const data = await feedback.eventFeedback('ev1', NOW);
|
||||
expect(data.people).toBeNull();
|
||||
expect(data.responses.every((r: any) => r.name === null)).toBe(true);
|
||||
expect(JSON.stringify(data)).not.toMatch(/Anna|Ben|Cara|u1@example/);
|
||||
expect(data.counts.notAnswered).toBe(3);
|
||||
});
|
||||
|
||||
test('an event without a form is a 404', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue(null);
|
||||
await expect(feedback.eventFeedback('ev1', NOW)).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remind', () => {
|
||||
test('not before the form has gone out', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue(form({ sentAt: null }));
|
||||
await expect(feedback.remindFeedback('ev1')).rejects.toMatchObject({ statusCode: 409 });
|
||||
});
|
||||
|
||||
test('not one by one on an anonymous form', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue(form({ anonymous: true }));
|
||||
await expect(feedback.remindFeedback('ev1', { userIds: ['u4'] })).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
// 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).toEqual(new Date(NOW.getTime() - 24 * 3600e3));
|
||||
expect(where.OR).toHaveLength(2);
|
||||
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());
|
||||
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: nothing queued', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
|
||||
counts(2, 0);
|
||||
mockDb.feedbackInvite.findMany.mockResolvedValue([]);
|
||||
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());
|
||||
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
|
||||
mockDb.feedbackInvite.updateMany.mockResolvedValue({ count: 0 });
|
||||
expect((await runFeedbackTask({ formId: 'form1', kind: 'remind', userIds: ['u4'] })).reminded).toBe(0);
|
||||
expect(mockMail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the automatic reminder', () => {
|
||||
test('still goes only to people never reminded, and counts it', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
|
||||
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
|
||||
expect(await sendFeedbackReminders('form1')).toBe(1);
|
||||
expect(mockDb.feedbackInvite.findMany.mock.calls[0][0].where).toEqual({ formId: 'form1', respondedAt: null, remindedAt: null });
|
||||
expect(mockDb.feedbackInvite.updateMany.mock.calls[0][0].data.reminderCount).toEqual({ increment: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('overview', () => {
|
||||
test('newest event first, with status, response rate, average rating and last reminder', async () => {
|
||||
const older = { ...EVENT, id: 'ev0', title: 'Camp', startDate: new Date('2026-06-01T00:00:00Z') };
|
||||
mockDb.feedbackForm.findMany.mockResolvedValue([
|
||||
{ id: 'form0', event: older, sendAt: null, sentAt: null, anonymous: true, _count: { invites: 0, responses: 0 } },
|
||||
{ id: 'form1', event: EVENT, sendAt: hoursAgo(72), sentAt: hoursAgo(72), anonymous: false, _count: { invites: 4, responses: 3 } },
|
||||
]);
|
||||
mockDb.feedbackAnswer.findMany.mockResolvedValue([{ value: '5', field: { formId: 'form1' } }, { value: '3', field: { formId: 'form1' } }]);
|
||||
mockDb.feedbackInvite.groupBy.mockResolvedValue([{ formId: 'form1', _max: { remindedAt: hoursAgo(5) } }]);
|
||||
const rows = await feedback.feedbackOverview({});
|
||||
expect(rows.map((r: any) => [r.event.title, r.status, r.responseRate, r.averageRating])).toEqual([
|
||||
['Men\'s Breakfast', 'sent', 75, 4],
|
||||
['Camp', 'not_scheduled', null, null],
|
||||
]);
|
||||
expect(rows[0].lastReminderAt).toEqual(hoursAgo(5));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+7
-5
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-11-03, after **2.0.2** 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,7 +17,9 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 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.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** (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 |
|
||||
@@ -99,9 +101,9 @@ encrypted settings unreadable or break existing installs.
|
||||
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 — after the site** (roadmap §18): AI helpers on Privacy → **Consent log** (summarise how
|
||||
consent moved, as counts per purpose and channel) and **Access log** (a review for the
|
||||
information officer that flags unusual access, staff sent as pseudonyms). Open questions in §18.
|
||||
- **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
|
||||
|
||||
+26
-29
@@ -22,9 +22,9 @@ providers), maintainable (tests, CI, typed backend, versioned API), and with
|
||||
| 3 | The five helpers (§15) with their buttons, an eval set, privacy notice text | 2.0.0 | ✅ eval 19/19 through a local Hub (2026-10-30) |
|
||||
| 4 | v1 removed (webhook paths and old message links keep working); clean-ups: Argon2id passwords with rehash-on-login, `node-fetch` gone, React Compiler lint rules back to errors | 2.0.0 | ✅ |
|
||||
| 5 | Migration guide (`MIGRATING-TO-2.md`), docs | 2.0.0 | ✅ |
|
||||
| 6 | Marketing site: Cross Code with a Cross Events page (§17) | `joshua/crosscode-site` | to do — decided 2026-11-02 (§17); content and logo from Joshua. **Done before 2.1.0** (§18) |
|
||||
| 6 | Marketing site: Cross Code with a Cross Events page (§17) | `joshua/crosscode-site` | ✅ built; prices, founding offer, free month and brochure added in site 1.1.0 (2026-11-03) |
|
||||
| 7 | 2.0.1: Summarise this report on every report (people reports as anonymised totals) | 2.0.1 | ✅ (§18) |
|
||||
| 8 | 2.1.0: AI helpers on the privacy pages (Consent log, Access log) | 2.1.0 | planned (§18), after the marketing site |
|
||||
| 8 | 2.1.0: Feedback page — results, answers, who hasn't answered, reminders (§18) | 2.1.0 | ✅ built. (AI helpers on the privacy pages were dropped on 2026-11-03.) |
|
||||
|
||||
The full version list is in [HANDOVER.md](./HANDOVER.md) and the [build order](#suggested-build-order) below.
|
||||
|
||||
@@ -82,7 +82,7 @@ Removed from scope (2026-10-28): Wallet passes (§7) and languages (§10).
|
||||
15. [AI helpers (2.0 headline)](#15-ai-helpers-20-headline)
|
||||
16. [AI: later stages](#16-ai-later-stages)
|
||||
17. [Marketing site (Cross Code)](#17-marketing-site-cross-code)
|
||||
18. [After 2.0: 2.0.x, the site, then 2.1.0](#18-after-20-20x-the-site-then-210)
|
||||
18. [After 2.0: 2.0.x, the site, then 2.1.0 (the feedback page)](#18-after-20-20x-the-site-then-210-the-feedback-page)
|
||||
19. [Suggested build order](#suggested-build-order)
|
||||
20. [Decisions](#decisions-2026-10-22)
|
||||
|
||||
@@ -1073,7 +1073,7 @@ Part of 2.0 (decided 2026-10-30): a **simple static HTML site** for selling, not
|
||||
|
||||
---
|
||||
|
||||
## 18. After 2.0: 2.0.x, the site, then 2.1.0
|
||||
## 18. After 2.0: 2.0.x, the site, then 2.1.0 (the feedback page)
|
||||
|
||||
Decided 2026-11-02. Order: **2.0.1** → **the marketing site (crosscode.co.za, §17)** → **2.1.0**.
|
||||
The site comes first: 2.1.0 isn't started until crosscode.co.za is live.
|
||||
@@ -1095,28 +1095,27 @@ crosscode.co.za as decided in §17 (repo `joshua/crosscode-site`, Joshua's own s
|
||||
form to info@crosscode.co.za, privacy notice and terms, no cookies). Placeholders until Joshua
|
||||
sends the logo, colours, wording and screenshots.
|
||||
|
||||
### 2.1.0: AI helpers on the privacy pages
|
||||
### 2.1.0: Feedback page
|
||||
|
||||
AI buttons on **Privacy → Consent log** and **Privacy → Access log** (admins and the information
|
||||
officer), reusing the Hub proxy, caps, `<AiButton>` and the `summarise_report` pattern from §15 —
|
||||
probably as new helpers so they can be switched on and off on their own in Site settings → AI.
|
||||
Decided 2026-11-03. **AI helpers on the privacy pages (Consent log, Access log) are dropped** —
|
||||
Joshua doesn't want AI on the privacy pages. 2.1.0 is a full feedback page instead.
|
||||
|
||||
- **Consent log — "Summarise consent":** how consent has moved over a period, per purpose and
|
||||
channel (opt-ins against opt-outs, WhatsApp STOPs, unsubscribes after a broadcast, spikes), and
|
||||
anything to act on (a purpose many people are leaving, consent given on an older notice text).
|
||||
Sent as counts per purpose, channel, action and day — never who.
|
||||
- **Access log — "Review access":** a plain-language review for the information officer: what was
|
||||
looked at or exported, how much, by which role, and **what looks unusual** (a bulk export at
|
||||
night, many medical-list views by one person, someone exporting much more than usual, sensitive
|
||||
entries). Spotting one person's unusual access needs entries grouped per staff member, so staff
|
||||
are sent as **pseudonyms** ("Staff 1", "Staff 2") that the screen turns back into names — the same
|
||||
idea as the `[[CONTACT1]]` placeholders — with only their role sent.
|
||||
- **To decide when it's picked up:** separate helpers or one "summarise a log" helper; how far
|
||||
back each looks (default 30 days?); whether the access-log review could also run on a schedule
|
||||
and email the information officer (an automatic monthly check); whether summarising the access
|
||||
log itself goes in the access log (probably yes, as `ai_access_review`).
|
||||
- Same as every helper: eval cases, help content, CHANGELOG / WHATS-NEW, the privacy notice only
|
||||
if what is sent changes.
|
||||
Post-event feedback forms already exist (event wizard → Feedback, a 3-day automatic reminder, the
|
||||
Feedback summary report). What was missing is a place for supervisors and admins to **see** the
|
||||
feedback and to **chase** the people who haven't answered:
|
||||
|
||||
- **Supervisor → Feedback** (supervisors and admins; campus-scoped staff see their campuses):
|
||||
every event with a feedback form — when it goes out or went out, invited, answered, response
|
||||
rate, average rating.
|
||||
- **One event's feedback:** the per-question results (star ratings, choices, comments), each
|
||||
response on its own (with the person's name on a named form), and **People**: who was asked,
|
||||
who has answered, reminders sent.
|
||||
- **Remind everyone who hasn't answered:** a fresh link to each, and the form to anyone who has
|
||||
become eligible since it went out (checked in later). On a named form, one person can be
|
||||
reminded on their own. Nobody is reminded twice within 24 hours.
|
||||
- **Anonymous forms stay anonymous:** no names on answers, and no list of who has or hasn't
|
||||
answered (that would give answers away on a small event) — only the counts.
|
||||
- Opening a named form's answers is recorded in the access log (`feedback_responses`).
|
||||
|
||||
---
|
||||
|
||||
@@ -1138,7 +1137,7 @@ probably as new helpers so they can be switched on and off on their own in Site
|
||||
| 11 | 1.51.0 ✅, 1.51.1 ✅, 1.51.2 ✅, 1.51.3 ✅ | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 registrations and payments as TS services, typed frontend calls; 1.51.2 check-in and accounts; 1.51.3 Express 5, the scanner on v2, v1 deprecation headers, v2-shaped shared middleware | The breaking-change groundwork |
|
||||
| 11b | 1.51.4 ✅ – 1.51.16 ✅ | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 managing registrations (the staff lists, status, at the door, bulk actions, items, coupons, attendees, attendee forms); 1.51.11 money, staff side (payments, cashup, provider transactions, waitlist, sponsorship, consent, form builder, feedback); 1.51.12 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.13 kids check-in and volunteer rosters; 1.51.14 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.15 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.16 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each puts its area's endpoints on v2 (1.51.1 – 1.51.10 by moving the logic into TS services; from 1.51.11 by `v1Route`, which runs the v1 handler behind a v2 route — moving those handlers into TS services is a 2.x clean-up that doesn't change the API), its screens onto `apiV2`, and its v1 routes into `SUCCESSORS`. Then 1.51.17, a security fix (forgot password and sign-in don't reveal accounts) | 2.0 can then remove v1 |
|
||||
| 12 | **2.0.0** ✅ | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 removed (all of it is on v2 by then; the old webhook URLs and links already sent in messages keep forwarding to v2), migration guide, docs, and the marketing site (§17: a static Cross Code site with a Cross Events page). Released 2026-11-02 (built on `release/2.0` in parts); the marketing site follows in its own repo | The release. AI needs the Hub (phase 6) for the key, metering and caps |
|
||||
| 13 | 2.0.1 ✅ → marketing site → **2.1.0** | Summarise every report (2.0.1); crosscode.co.za (§17); AI on the Consent log and Access log (2.1.0, §18) | Decided 2026-11-02: the site before 2.1.0 |
|
||||
| 13 | 2.0.1 ✅ → marketing site ✅ → **2.1.0** | Summarise every report (2.0.1); crosscode.co.za (§17); the feedback page (2.1.0, §18 — replaces AI on the privacy pages, dropped 2026-11-03) | Decided 2026-11-02: the site before 2.1.0 |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
|
||||
@@ -1201,7 +1200,5 @@ feature.
|
||||
|
||||
## Still open
|
||||
|
||||
- **Marketing site (§17):** everything is decided; waiting on Joshua for the logo, colours, text
|
||||
about Cross Code, photos and screenshots (built with placeholders meanwhile). Must be done
|
||||
before 2.1.0 (§18).
|
||||
- **2.1.0 (§18):** the open questions on the Consent log and Access log helpers.
|
||||
- **Marketing site (§17):** built; waiting on Joshua's real logo, wording and photos where
|
||||
placeholders remain.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect, loginAs, api, v2, seed, API } from "./fixtures";
|
||||
|
||||
// 2.1.0: Supervisor → Feedback. The seed puts a named feedback form on "E2E Last Sunday", sent
|
||||
// two days ago to Thandi (who answers here) and Lerato (who doesn't).
|
||||
|
||||
type PublicForm = { fields: { id: string; type: string }[] };
|
||||
type AccessLog = { rows: { action: string; what: string; records: number | null; actor: string }[] };
|
||||
|
||||
test.describe("feedback page", () => {
|
||||
test("see the answers, who hasn't answered, and remind them", async ({ page }) => {
|
||||
const token = seed().feedback.tokens.user;
|
||||
const form = await (await page.request.get(`${API}/api/v2/feedback-forms/${token}`)).json() as { data: PublicForm };
|
||||
const [rating, text] = form.data.fields;
|
||||
const sent = await page.request.post(`${API}/api/v2/feedback-forms/${token}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
data: JSON.stringify({ answers: { [rating.id]: 5, [text.id]: "Loved the worship" } }),
|
||||
});
|
||||
expect(sent.ok()).toBe(true);
|
||||
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/feedback");
|
||||
const row = page.getByRole("row", { name: /E2E Last Sunday/ });
|
||||
await expect(row).toContainText("1 of 2");
|
||||
await expect(row).toContainText("5.0");
|
||||
await row.getByRole("button", { name: "E2E Last Sunday" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "E2E Last Sunday" })).toBeVisible();
|
||||
await expect(page.getByText("Loved the worship")).toBeVisible();
|
||||
await expect(page.getByText("— Thandi Test")).toBeVisible();
|
||||
|
||||
await page.getByRole("tab", { name: /Responses/ }).click();
|
||||
await expect(page.getByRole("article").filter({ hasText: "Thandi Test" })).toContainText("★★★★★");
|
||||
|
||||
await page.getByRole("tab", { name: /People/ }).click();
|
||||
const lerato = page.getByRole("row", { name: /Lerato Late/ });
|
||||
await expect(lerato).toContainText("Not yet");
|
||||
await expect(page.getByRole("row", { name: /Thandi Test/ }).getByRole("button", { name: "Remind" })).toHaveCount(0);
|
||||
|
||||
page.once("dialog", d => d.accept());
|
||||
await lerato.getByRole("button", { name: "Remind" }).click();
|
||||
// 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();
|
||||
|
||||
// Reminding again the same day sends nothing.
|
||||
const again = await api<{ data: { reminded: number; message: string } }>(page, "supervisor", "POST", `/api/v2/events/${seed().events.past}/feedback/remind`, {});
|
||||
expect(again.data.reminded).toBe(0);
|
||||
expect(again.data.message).toMatch(/last 24 hours/);
|
||||
|
||||
// Opening a named form's answers is in the access log.
|
||||
const log = await v2<AccessLog>(page, "admin", "GET", "/api/v2/privacy/access-log");
|
||||
expect(log.rows.find(r => r.action === "feedback_responses")).toMatchObject({ what: "Feedback answers: E2E Last Sunday", records: 1, actor: "Sam Supervisor" });
|
||||
});
|
||||
|
||||
test("staff can't see feedback", async ({ page }) => {
|
||||
const res = await page.request.get(`${API}/api/v2/feedback`, { headers: { Authorization: `Bearer ${seed().users.staff.token}` } });
|
||||
expect(res.status()).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ export type Seed = {
|
||||
formOptionId: string;
|
||||
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
|
||||
unsubscribeToken: string;
|
||||
feedback: { tokens: { user: string; late: string } };
|
||||
newsPromptUser: { id: string; token: string };
|
||||
noticeFirstUser: { id: string; token: string };
|
||||
retention: { toWarn: string; toAnonymise: string };
|
||||
|
||||
@@ -245,6 +245,27 @@ async function seed(url) {
|
||||
await prisma.securityEvent.create({ data: { userId: toWarn.id, type: 'login', createdAt: new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000) } });
|
||||
return { toWarn: toWarn.id, toAnonymise: toAnonymise.id };
|
||||
})(),
|
||||
// 2.1.0 Feedback page: a named form on the past event, sent two days ago to Thandi (the
|
||||
// seeded user, who answers in the test) and Lerato (who doesn't). The links' tokens.
|
||||
feedback: await (async () => {
|
||||
const { hashToken } = require(path.join(backend, 'src/utils/feedback'));
|
||||
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
||||
const form = await prisma.feedbackForm.create({
|
||||
data: {
|
||||
eventId: pastEvent.id, audience: 'all', anonymous: false, sendAt: twoDaysAgo, sentAt: twoDaysAgo,
|
||||
fields: { create: [
|
||||
{ type: 'rating', label: 'How was the service?', isRequired: true, order: 0 },
|
||||
{ type: 'text', label: 'What should we keep doing?', order: 1 },
|
||||
] },
|
||||
},
|
||||
});
|
||||
const tokens = { user: 'e2e-feedback-thandi-token', late: 'e2e-feedback-lerato-token' };
|
||||
await prisma.feedbackInvite.createMany({ data: [
|
||||
{ formId: form.id, userId: users.user.id, tokenHash: hashToken(tokens.user), sentAt: twoDaysAgo },
|
||||
{ formId: form.id, userId: latecomer.id, tokenHash: hashToken(tokens.late), sentAt: twoDaysAgo },
|
||||
] });
|
||||
return { tokens };
|
||||
})(),
|
||||
// The link a news email to the seeded user would carry (signed with JWT_SECRET).
|
||||
unsubscribeToken: require(path.join(backend, 'src/utils/privacy')).unsubscribeToken({ userId: users.user.id }),
|
||||
};
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "2.0.2",
|
||||
"version": "2.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "2.0.2",
|
||||
"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.0.2",
|
||||
"version": "2.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck, Inbox,
|
||||
Baby, HandHeart, Layers, Wrench,
|
||||
Baby, HandHeart, Layers, MessageSquareHeart, Wrench,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -38,6 +38,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
|
||||
{ href: "/dashboard/supervisor/kids", label: "Kids check-in setup", description: "Switch it on for an event and set up the rooms", icon: Baby },
|
||||
{ href: "/dashboard/supervisor/volunteers", label: "Volunteer rosters", description: "Serving roles, who serves where, check-ins and hours", icon: HandHeart },
|
||||
{ href: "/dashboard/supervisor/feedback", label: "Feedback", description: "What people said after events, and reminders", icon: MessageSquareHeart },
|
||||
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
|
||||
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
|
||||
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { apiV2Data, type V2Result } from "@/lib/apiV2";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { useExternalEffect } from "@/lib/hooks";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { AiFeedbackSummary } from "@/components/reports/AiFeedbackSummary";
|
||||
|
||||
type Row = V2Result<"/feedback", "get">["data"][number];
|
||||
type Detail = V2Result<"/events/{id}/feedback/results", "get">["data"];
|
||||
type Question = Detail["questions"][number];
|
||||
type Person = NonNullable<Detail["people"]>[number];
|
||||
type Tab = "results" | "responses" | "people";
|
||||
type Show = "all" | "sent" | "waiting";
|
||||
|
||||
// Dates arrive as ISO strings (the generated types call them unknown).
|
||||
const fmt = (d: unknown, time = true) =>
|
||||
typeof d === "string" || d instanceof Date ? new Date(d).toLocaleString("en-ZA", { day: "numeric", month: "short", year: "numeric", ...(time ? { hour: "2-digit", minute: "2-digit" } : {}) }) : "—";
|
||||
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
||||
|
||||
const STATUS: Record<Row["status"], { label: string; cls: string }> = {
|
||||
sent: { label: "Sent", cls: "bg-green-50 text-green-700 border-green-200" },
|
||||
scheduled: { label: "Scheduled", cls: "bg-blue-50 text-blue-700 border-blue-200" },
|
||||
not_scheduled: { label: "No send time", cls: "bg-gray-50 text-gray-600 border-gray-200" },
|
||||
};
|
||||
const StatusBadge = ({ status }: { status: Row["status"] }) => (
|
||||
<span className={`inline-block rounded-full border px-2 py-0.5 text-xs font-medium ${STATUS[status].cls}`}>{STATUS[status].label}</span>
|
||||
);
|
||||
const Stars = ({ value }: { value: number | null | undefined }) =>
|
||||
value == null ? <span className="text-gray-400">—</span> : <span className="inline-flex items-center gap-1 tabular-nums"><Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" aria-hidden />{value.toFixed(1)}</span>;
|
||||
|
||||
/** A horizontal bar per choice (or star), with its count. */
|
||||
function Bars({ items, total }: { items: { label: string; value: number }[]; total: number }) {
|
||||
return (
|
||||
<ul className="space-y-1.5">
|
||||
{items.map(i => (
|
||||
<li key={i.label} className="grid grid-cols-[minmax(3rem,8rem)_1fr_3rem] items-center gap-2 text-sm">
|
||||
<span className="truncate text-gray-700" title={i.label}>{i.label}</span>
|
||||
<span className="h-2.5 rounded-full bg-gray-100"><span className="block h-2.5 rounded-full bg-brand-500" style={{ width: `${total ? (i.value / total) * 100 : 0}%` }} /></span>
|
||||
<span className="text-right tabular-nums text-gray-600">{i.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionResult({ q }: { q: Question }) {
|
||||
return (
|
||||
<section className="rounded-xl border bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="font-medium text-gray-900">{q.label}</h3>
|
||||
<span className="text-xs text-gray-500">{plural(q.answered, "answer")}</span>
|
||||
</div>
|
||||
{q.type === "rating" && (
|
||||
<div className="grid gap-4 sm:grid-cols-[auto_1fr] sm:items-center">
|
||||
<div className="text-center"><div className="text-3xl font-semibold tabular-nums">{q.average != null ? q.average.toFixed(1) : "—"}</div><div className="text-xs text-gray-500">out of 5</div></div>
|
||||
<Bars items={[...(q.distribution || [])].reverse()} total={q.answered} />
|
||||
</div>
|
||||
)}
|
||||
{q.counts && <Bars items={q.counts} total={q.answered} />}
|
||||
{q.type === "numeric" && <p className="text-sm">Average: <strong className="tabular-nums">{q.average ?? "—"}</strong></p>}
|
||||
{q.texts && (q.texts.length === 0 ? <p className="text-sm text-gray-500">No answers yet.</p> : (
|
||||
<ul className="max-h-96 space-y-2 overflow-y-auto pr-1">
|
||||
{q.texts.map((t, i) => (
|
||||
<li key={i} className="rounded-lg bg-gray-50 px-3 py-2 text-sm text-gray-800">
|
||||
<p className="whitespace-pre-wrap">{t.value}</p>
|
||||
{t.name && <p className="mt-1 text-xs text-gray-500">— {t.name}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Supervisor → Feedback: every event's feedback, one event's results and answers, and reminders. */
|
||||
export default function FeedbackPage() {
|
||||
const { token } = useAuth();
|
||||
const [rows, setRows] = useState<Row[] | null>(null);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [detail, setDetail] = useState<Detail | null>(null);
|
||||
const [tab, setTab] = useState<Tab>("results");
|
||||
const [search, setSearch] = useState("");
|
||||
const [show, setShow] = useState<Show>("all");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setRows(await (apiV2Data("get", "/feedback", { authToken: token }) as Promise<Row[]>));
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not load feedback" });
|
||||
}
|
||||
}, [token, setMsg]);
|
||||
useExternalEffect(() => { loadList(); }, [loadList]);
|
||||
|
||||
// ?event=<id> opens that event (links from elsewhere, and the back button).
|
||||
useEffect(() => {
|
||||
const read = () => setEventId(new URLSearchParams(window.location.search).get("event") || "");
|
||||
read();
|
||||
window.addEventListener("popstate", read);
|
||||
return () => window.removeEventListener("popstate", read);
|
||||
}, []);
|
||||
const open = (id: string) => {
|
||||
window.history.pushState(null, "", id ? `?event=${encodeURIComponent(id)}` : window.location.pathname);
|
||||
setEventId(id);
|
||||
setTab("results");
|
||||
};
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
if (!token || !eventId) { setDetail(null); return; }
|
||||
try {
|
||||
setDetail(await (apiV2Data("get", "/events/{id}/feedback/results", { params: { id: eventId }, authToken: token }) as Promise<Detail>));
|
||||
} catch (e) {
|
||||
setDetail(null);
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not load this event's feedback" });
|
||||
}
|
||||
}, [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;
|
||||
const question = who
|
||||
? `Send ${who} a reminder with a new feedback link?`
|
||||
: `Remind ${plural(detail.counts.canRemind, "person", "people")} who haven't answered${detail.counts.waiting ? `, and send the form to ${plural(detail.counts.waiting, "person", "people")} who haven't had it yet` : ""}?`;
|
||||
if (!window.confirm(question)) return;
|
||||
setBusy(userIds ? userIds[0] : "all");
|
||||
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 });
|
||||
if (out.reminded || out.newlyInvited) refreshSoon();
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not send the reminders" });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const sendNow = async () => {
|
||||
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; 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 {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const shown = useMemo(() => (rows || []).filter(r =>
|
||||
(show === "all" || (show === "sent" ? r.status === "sent" : r.status !== "sent")) &&
|
||||
(!search.trim() || r.event.title.toLowerCase().includes(search.trim().toLowerCase()))), [rows, show, search]);
|
||||
|
||||
const message = msg && <div role="status" className={`mb-4 rounded-lg border p-3 text-sm ${msg.type === "ok" ? "border-green-200 bg-green-50 text-green-800" : "border-red-200 bg-red-50 text-red-800"}`}>{msg.text}</div>;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl p-6">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-50"><MessageSquareHeart className="h-5 w-5 text-brand-600" /></div>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="mt-0.5 text-2xl font-semibold text-gray-900">Feedback</h1>
|
||||
<p className="text-sm text-gray-500">What people said after your events, and who still has to answer</p>
|
||||
</div>
|
||||
</div>
|
||||
{message}
|
||||
|
||||
{!eventId && (
|
||||
<>
|
||||
<div className="mb-4 flex flex-wrap items-end gap-3">
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-xs text-gray-500">Search</span>
|
||||
<input type="search" value={search} onChange={e => setSearch(e.target.value)} placeholder="Event name" className="w-64 max-w-full rounded-lg border px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-xs text-gray-500">Show</span>
|
||||
<select value={show} onChange={e => setShow(e.target.value as Show)} className="rounded-lg border px-3 py-2 text-sm">
|
||||
<option value="all">All forms</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="waiting">Not sent yet</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{!rows && <p className="text-sm text-gray-500">Loading…</p>}
|
||||
{rows && rows.length === 0 && (
|
||||
<div className="rounded-xl border bg-white p-6 text-sm text-gray-600 shadow-sm">
|
||||
No event has a feedback form yet. Add one in the event editor's <strong>Feedback</strong> step (<Link className="text-brand-700 underline" href="/dashboard/supervisor/events">Manage events</Link>).
|
||||
</div>
|
||||
)}
|
||||
{rows && rows.length > 0 && shown.length === 0 && <p className="text-sm text-gray-500">No forms match.</p>}
|
||||
{shown.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-xl border bg-white shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-xs text-gray-500">
|
||||
<tr><th className="p-3">Event</th><th className="p-3">Form</th><th className="p-3">Answered</th><th className="p-3">Rating</th><th className="p-3">Last reminder</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map(r => (
|
||||
<tr key={r.event.id} className="cursor-pointer border-t hover:bg-gray-50" onClick={() => open(r.event.id)}>
|
||||
<td className="p-3">
|
||||
<button type="button" className="text-left font-medium text-gray-900 hover:text-brand-700" onClick={e => { e.stopPropagation(); open(r.event.id); }}>{r.event.title}</button>
|
||||
<div className="text-xs text-gray-500">{fmt(r.event.startDate, false)}{r.anonymous ? " · anonymous" : ""}</div>
|
||||
</td>
|
||||
<td className="p-3"><StatusBadge status={r.status} /><div className="mt-1 text-xs text-gray-500">{r.status === "sent" ? fmt(r.sentAt) : r.status === "scheduled" ? `Goes ${fmt(r.sendAt)}` : ""}</div></td>
|
||||
<td className="p-3 tabular-nums">{r.status === "sent" ? <>{r.answered} of {r.invited}{r.responseRate != null && <span className="text-gray-500"> ({r.responseRate}%)</span>}</> : <span className="text-gray-400">—</span>}</td>
|
||||
<td className="p-3"><Stars value={r.averageRating} /></td>
|
||||
<td className="p-3 text-gray-600">{r.lastReminderAt ? fmt(r.lastReminderAt) : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{eventId && (
|
||||
<>
|
||||
<button type="button" onClick={() => open("")} className="mb-3 inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"><ArrowLeft className="h-4 w-4" /> All feedback</button>
|
||||
{!detail && <p className="text-sm text-gray-500">Loading…</p>}
|
||||
{detail && (
|
||||
<>
|
||||
<div className="mb-4 rounded-xl border bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{detail.event.title}</h2>
|
||||
<p className="text-sm text-gray-500">{fmt(detail.event.startDate, false)} · {detail.form.audience === "all" ? "everyone registered" : "people who checked in"} · {detail.form.anonymous ? "anonymous answers" : "named answers"}</p>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
<StatusBadge status={detail.form.status} />{" "}
|
||||
{detail.form.status === "sent" && <>Sent {fmt(detail.form.sentAt)}. {detail.form.autoReminderSent ? "The automatic reminder has gone out." : `Automatic reminder ${fmt(detail.form.autoReminderAt)}.`}</>}
|
||||
{detail.form.status === "scheduled" && <>Goes out {fmt(detail.form.sendAt)}.</>}
|
||||
{detail.form.status === "not_scheduled" && <>No send time is set.</>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{detail.form.status !== "sent" && (
|
||||
<button type="button" onClick={sendNow} disabled={!!busy} className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"><Send className="h-4 w-4" /> {busy === "send" ? "Sending…" : "Send now"}</button>
|
||||
)}
|
||||
{detail.form.status === "sent" && (
|
||||
<button type="button" onClick={() => remind()} disabled={!!busy || detail.counts.canRemind + detail.counts.waiting === 0}
|
||||
title={detail.counts.canRemind + detail.counts.waiting === 0 ? (detail.counts.notAnswered ? "Everyone who hasn't answered was sent the form or a reminder in the last 24 hours" : "Everyone has answered") : undefined}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
<BellRing className="h-4 w-4" /> {busy === "all" ? "Sending…" : detail.counts.canRemind + detail.counts.waiting ? `Remind ${detail.counts.canRemind + detail.counts.waiting} who haven't answered` : "Remind those who haven't answered"}
|
||||
</button>
|
||||
)}
|
||||
<Link href={`/dashboard/supervisor/events?edit=${encodeURIComponent(detail.event.id)}`} className="inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm hover:bg-gray-50"><Pencil className="h-4 w-4" /> Edit form</Link>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{[
|
||||
["Asked", detail.counts.invited],
|
||||
["Answered", detail.counts.answered],
|
||||
["Response rate", detail.counts.responseRate != null ? `${detail.counts.responseRate}%` : "—"],
|
||||
["Average rating", detail.averageRating != null ? `${detail.averageRating.toFixed(1)} ★` : "—"],
|
||||
].map(([k, v]) => (
|
||||
<div key={String(k)} className="rounded-lg bg-gray-50 p-3"><dt className="text-xs text-gray-500">{k}</dt><dd className="text-xl font-semibold tabular-nums text-gray-900">{v}</dd></div>
|
||||
))}
|
||||
</dl>
|
||||
{detail.form.status === "sent" && (detail.counts.notAnswered > 0 || detail.counts.waiting > 0) && (
|
||||
<p className="mt-3 text-xs text-gray-500">
|
||||
{plural(detail.counts.notAnswered, "person hasn't", "people haven't")} answered
|
||||
{detail.counts.remindedRecently > 0 && ` (${detail.counts.remindedRecently} sent the form or a reminder in the last 24 hours)`}
|
||||
{detail.counts.waiting > 0 && `; ${plural(detail.counts.waiting, "person", "people")} can still be sent the form (e.g. checked in after it went out)`}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-1 border-b" role="tablist">
|
||||
{([["results", "Results"], ["responses", `Responses (${detail.responses.length})`], ...(detail.people ? [["people", `People (${detail.people.length})`]] : [])] as [Tab, string][]).map(([id, label]) => (
|
||||
<button key={id} role="tab" aria-selected={tab === id} onClick={() => setTab(id)} className={`-mb-px border-b-2 px-3 py-2 text-sm ${tab === id ? "border-brand-600 font-medium text-brand-700" : "border-transparent text-gray-500"}`}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "results" && (
|
||||
<div className="space-y-4">
|
||||
<AiFeedbackSummary eventId={detail.event.id} responses={detail.responses.length} />
|
||||
{detail.responses.length === 0 && <p className="text-sm text-gray-500">No answers yet.</p>}
|
||||
{detail.responses.length > 0 && detail.questions.map(q => <QuestionResult key={q.fieldId} q={q} />)}
|
||||
{detail.form.anonymous && <p className="text-xs text-gray-500">This form is anonymous: answers aren't linked to anyone, and who has or hasn't answered isn't shown.</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "responses" && (
|
||||
<div className="space-y-3">
|
||||
{detail.responses.length === 0 && <p className="text-sm text-gray-500">No answers yet.</p>}
|
||||
{detail.responses.map((r, i) => (
|
||||
<article key={r.id} className="rounded-xl border bg-white p-4 shadow-sm">
|
||||
<div className="mb-2 flex flex-wrap justify-between gap-2 text-sm">
|
||||
<strong className="text-gray-900">{r.name ?? `Response ${detail.responses.length - i}`}</strong>
|
||||
<span className="text-gray-500">{fmt(r.createdAt)}</span>
|
||||
</div>
|
||||
<dl className="space-y-2 text-sm">
|
||||
{detail.fields.map(f => (
|
||||
<div key={f.id}>
|
||||
<dt className="text-xs text-gray-500">{f.label}</dt>
|
||||
<dd className="whitespace-pre-wrap text-gray-800">{r.answers[f.id] == null ? <span className="text-gray-400">—</span> : f.type === "rating" ? `${"★".repeat(Number(r.answers[f.id]))}${"☆".repeat(5 - Number(r.answers[f.id]))}` : f.type === "yes_no" ? (r.answers[f.id] === "yes" ? "Yes" : "No") : r.answers[f.id]}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "people" && detail.people && (
|
||||
detail.people.length === 0 ? <p className="text-sm text-gray-500">The form hasn't been sent to anyone yet.</p> : (
|
||||
<div className="overflow-x-auto rounded-xl border bg-white shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-xs text-gray-500"><tr><th className="p-3">Person</th><th className="p-3">Sent</th><th className="p-3">Answered</th><th className="p-3">Reminders</th><th className="p-3" /></tr></thead>
|
||||
<tbody>
|
||||
{detail.people.map((p: Person) => (
|
||||
<tr key={p.userId} className="border-t">
|
||||
<td className="p-3"><div className="font-medium text-gray-900">{p.name}</div><div className="text-xs text-gray-500">{[p.email, p.phoneNumber].filter(Boolean).join(" · ") || "No contact details"}</div></td>
|
||||
<td className="p-3 text-gray-600">{fmt(p.sentAt)}</td>
|
||||
<td className="p-3">{p.respondedAt ? <span className="text-green-700">{fmt(p.respondedAt)}</span> : <span className="text-amber-700">Not yet</span>}</td>
|
||||
<td className="p-3 text-gray-600">{p.reminderCount ? <>{p.reminderCount} · last {fmt(p.remindedAt)}</> : "None"}</td>
|
||||
<td className="p-3 text-right">
|
||||
{!p.respondedAt && (
|
||||
<button type="button" onClick={() => remind([p.userId])} disabled={!!busy || !p.canRemind}
|
||||
title={p.canRemind ? undefined : "Sent the form or a reminder in the last 24 hours"}
|
||||
className="rounded-lg border px-2.5 py-1 text-xs hover:bg-gray-50 disabled:opacity-50">{busy === p.userId ? "Sending…" : "Remind"}</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck,
|
||||
Baby, HandHeart, Layers,
|
||||
Baby, HandHeart, Layers, MessageSquareHeart,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -35,6 +35,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
|
||||
{ href: "/dashboard/supervisor/kids", label: "Kids check-in setup", description: "Switch it on for an event and set up the rooms", icon: Baby },
|
||||
{ href: "/dashboard/supervisor/volunteers", label: "Volunteer rosters", description: "Serving roles, who serves where, check-ins and hours", icon: HandHeart },
|
||||
{ href: "/dashboard/supervisor/feedback", label: "Feedback", description: "What people said after events, and reminders", icon: MessageSquareHeart },
|
||||
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
|
||||
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
|
||||
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
||||
|
||||
@@ -38,6 +38,7 @@ import { userFormsHelpContent } from "./user-forms";
|
||||
import { kidsDeskHelpContent, kidsRoomsHelpContent, kidsSetupHelpContent, userKidsHelpContent } from "./kids";
|
||||
import { staffVolunteersHelpContent, userServingHelpContent, volunteerRostersHelpContent } from "./volunteers";
|
||||
import { seriesHelpContent } from "./series";
|
||||
import { supervisorFeedbackHelpContent } from "./supervisor-feedback";
|
||||
|
||||
type HelpRegistryEntry = {
|
||||
/** Unique, stable identifier for this guide. */
|
||||
@@ -194,6 +195,11 @@ const REGISTRY: HelpRegistryEntry[] = [
|
||||
matches: pathname => pathname === "/dashboard/supervisor/volunteers",
|
||||
content: volunteerRostersHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-feedback",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/feedback",
|
||||
content: supervisorFeedbackHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "staff-volunteers",
|
||||
matches: pathname => pathname === "/dashboard/staff/volunteers",
|
||||
|
||||
@@ -62,7 +62,7 @@ export const supervisorEventsHelpContent: HelpContent = {
|
||||
Upload downloadable files that appear on the public event page.
|
||||
</GuideItem>
|
||||
<GuideItem icon={MessageSquareHeart} title="Feedback" tone="violet">
|
||||
Send a feedback form automatically after the event: pick when (e.g. the morning after) and whether it goes to everyone who checked in or everyone registered. Questions can be star ratings, multiple choice, yes/no or text. Answers can be anonymous. Anyone who hasn't answered gets one reminder 3 days later. Results are in Reports → Feedback summary. Rating and multiple-choice questions can also be used on the attendee form.
|
||||
Send a feedback form automatically after the event: pick when (e.g. the morning after) and whether it goes to everyone who checked in or everyone registered. Questions can be star ratings, multiple choice, yes/no or text. Answers can be anonymous. Anyone who hasn't answered gets one reminder 3 days later. See the answers and remind people yourself on the Feedback page (Dashboard → Feedback); Reports → Feedback summary compares events. Rating and multiple-choice questions can also be used on the attendee form.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ClipboardCheck} title="Attendance" tone="green">
|
||||
For multi-session events: late joining (until which session, and a full, pro-rata or fixed late price for main tickets — pro-rata rounds to the nearest R10), the check-in window (the whole day of a session, or only from 2 hours before it until it ends), and reminders before each session. For any event: completion certificates — four designs, your wording and signature, and who qualifies. "Preview certificate" shows a sample.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import { BellRing, EyeOff, ListChecks, MessageSquareHeart, Pencil, Sparkles, Star, Users } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
/** Help content for /dashboard/supervisor/feedback. */
|
||||
export const supervisorFeedbackHelpContent: HelpContent = {
|
||||
title: "Feedback",
|
||||
subtitle: "What people said after your events, and who still has to answer.",
|
||||
tabs: [
|
||||
{
|
||||
key: "results",
|
||||
label: "Results",
|
||||
icon: Star,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p>Every event with a feedback form is listed, newest first: when the form went out (or goes out), how many answered and the average star rating. Open an event to see its feedback.</p>
|
||||
<GuideItem icon={Star} title="Results" tone="brand">
|
||||
Each question with its answers: the average and spread of star ratings, how many chose each option, and every written answer.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ListChecks} title="Responses" tone="gray">
|
||||
Each person's answers together, newest first. On a named form you see who gave them; looking at them is recorded in the access log.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Sparkles} title="Summarise feedback" tone="amber">
|
||||
On sites with AI, "Summarise feedback" gives the main themes, what people liked and what to improve. The answers are sent without names.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Pencil} title="Changing the form" tone="gray">
|
||||
"Edit form" opens the event's Feedback step. Once someone has answered, the questions can't change.
|
||||
</GuideItem>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "reminders",
|
||||
label: "Reminders",
|
||||
icon: BellRing,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p>Everyone who hasn't answered gets one reminder automatically, 3 days after the form goes out. You can remind people yourself too.</p>
|
||||
<GuideItem icon={BellRing} title="Remind those who haven't answered" tone="brand">
|
||||
Sends each of them a new link by email or WhatsApp. It also sends the form to anyone who can have it but hasn't yet, for example someone who checked in after it went out. Nobody gets the form or a reminder twice within 24 hours.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Users} title="People" tone="green">
|
||||
On a named form, the People tab shows who was asked, who has answered and the reminders each person has had, with a Remind button for one person.
|
||||
</GuideItem>
|
||||
<GuideItem icon={EyeOff} title="Anonymous forms" tone="gray">
|
||||
Answers aren't linked to anyone, and the page doesn't show who has or hasn't answered — on a small event that would give the answers away. You can still remind everyone who hasn't answered.
|
||||
</GuideItem>
|
||||
<GuideItem icon={MessageSquareHeart} title="Before it goes out" tone="amber">
|
||||
A form that hasn't gone out yet has a "Send now" button instead.
|
||||
</GuideItem>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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": [
|
||||
{
|
||||
@@ -69230,6 +69230,798 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/feedback": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Feedback"
|
||||
],
|
||||
"summary": "Every event with a feedback form",
|
||||
"description": "Newest event first, with how many were asked and answered, and the average star rating.\n\n**Who:** Supervisors and admins.",
|
||||
"operationId": "getFeedback",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"startDate": {},
|
||||
"endDate": {}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"title",
|
||||
"startDate",
|
||||
"endDate"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"sent",
|
||||
"scheduled",
|
||||
"not_scheduled"
|
||||
],
|
||||
"description": "sent, scheduled (a send time is set) or not_scheduled"
|
||||
},
|
||||
"sendAt": {
|
||||
"anyOf": [
|
||||
{},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sentAt": {
|
||||
"anyOf": [
|
||||
{},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"anonymous": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"invited": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"answered": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"responseRate": {
|
||||
"description": "%",
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"averageRating": {
|
||||
"description": "Every star rating on the form, 1–5",
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"lastReminderAt": {
|
||||
"anyOf": [
|
||||
{},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"event",
|
||||
"status",
|
||||
"sendAt",
|
||||
"sentAt",
|
||||
"anonymous",
|
||||
"invited",
|
||||
"answered",
|
||||
"responseRate",
|
||||
"averageRating",
|
||||
"lastReminderAt"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not signed in (UNAUTHENTICATED)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not allowed for this role (FORBIDDEN)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/events/{id}/feedback/results": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Feedback"
|
||||
],
|
||||
"summary": "An event's feedback: results, responses and who was asked",
|
||||
"description": "On an anonymous form, responses have no name and `people` is null. Opening a named form's answers is recorded in the access log.\n\n**Who:** Supervisors and admins.",
|
||||
"operationId": "getEventsIdFeedbackResults",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "The id",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"startDate": {},
|
||||
"endDate": {}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"title",
|
||||
"startDate",
|
||||
"endDate"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"form": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"sent",
|
||||
"scheduled",
|
||||
"not_scheduled"
|
||||
],
|
||||
"description": "sent, scheduled (a send time is set) or not_scheduled"
|
||||
},
|
||||
"sendAt": {
|
||||
"anyOf": [
|
||||
{},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sentAt": {
|
||||
"anyOf": [
|
||||
{},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"audience": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"checked_in",
|
||||
"all"
|
||||
]
|
||||
},
|
||||
"anonymous": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"intro": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"autoReminderAt": {
|
||||
"anyOf": [
|
||||
{},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "When the automatic reminder goes (3 days after sending)"
|
||||
},
|
||||
"autoReminderSent": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"sendAt",
|
||||
"sentAt",
|
||||
"audience",
|
||||
"anonymous",
|
||||
"intro",
|
||||
"autoReminderAt",
|
||||
"autoReminderSent"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"counts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"invited": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"answered": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"notAnswered": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"responseRate": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"canRemind": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Not answered, and not sent the form or a reminder in the last 24 hours"
|
||||
},
|
||||
"remindedRecently": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"waiting": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Would get the form but haven't been sent it yet (e.g. checked in after it went out)"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"invited",
|
||||
"answered",
|
||||
"notAnswered",
|
||||
"responseRate",
|
||||
"canRemind",
|
||||
"remindedRecently",
|
||||
"waiting"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"averageRating": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"questions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fieldId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"answered": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"average": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"distribution": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label",
|
||||
"value"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"counts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label",
|
||||
"value"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"texts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"value"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"fieldId",
|
||||
"type",
|
||||
"label",
|
||||
"answered"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"createdAt": {},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"answers": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Question id → answer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"createdAt",
|
||||
"name",
|
||||
"answers"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"people": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"userId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"phoneNumber": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sentAt": {},
|
||||
"remindedAt": {
|
||||
"anyOf": [
|
||||
{},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reminderCount": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"respondedAt": {
|
||||
"anyOf": [
|
||||
{},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"canRemind": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"userId",
|
||||
"name",
|
||||
"email",
|
||||
"phoneNumber",
|
||||
"sentAt",
|
||||
"remindedAt",
|
||||
"reminderCount",
|
||||
"respondedAt",
|
||||
"canRemind"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"event",
|
||||
"form",
|
||||
"counts",
|
||||
"averageRating",
|
||||
"questions",
|
||||
"fields",
|
||||
"responses",
|
||||
"people"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation failed (VALIDATION_FAILED)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not signed in (UNAUTHENTICATED)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not allowed for this role (FORBIDDEN)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found (NOT_FOUND)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/events/{id}/feedback/remind": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"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. Answers straight away; the emails go out in the background.\n\n**Who:** Supervisors and admins.",
|
||||
"operationId": "postEventsIdFeedbackRemind",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "The id",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"userIds": {
|
||||
"description": "Only these people (named forms)",
|
||||
"anyOf": [
|
||||
{
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reminded": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Being reminded"
|
||||
},
|
||||
"newlyInvited": {
|
||||
"type": "integer",
|
||||
"minimum": -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"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"reminded",
|
||||
"newlyInvited",
|
||||
"skipped",
|
||||
"queued",
|
||||
"message"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation failed (VALIDATION_FAILED)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not signed in (UNAUTHENTICATED)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not allowed for this role (FORBIDDEN)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found (NOT_FOUND)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
|
||||
Vendored
+348
-1
@@ -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;
|
||||
@@ -7236,6 +7238,72 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/feedback": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Every event with a feedback form
|
||||
* @description Newest event first, with how many were asked and answered, and the average star rating.
|
||||
*
|
||||
* **Who:** Supervisors and admins.
|
||||
*/
|
||||
get: operations["getFeedback"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/events/{id}/feedback/results": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* An event's feedback: results, responses and who was asked
|
||||
* @description On an anonymous form, responses have no name and `people` is null. Opening a named form's answers is recorded in the access log.
|
||||
*
|
||||
* **Who:** Supervisors and admins.
|
||||
*/
|
||||
get: operations["getEventsIdFeedbackResults"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/events/{id}/feedback/remind": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
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. Answers straight away; the emails go out in the background.
|
||||
*
|
||||
* **Who:** Supervisors and admins.
|
||||
*/
|
||||
post: operations["postEventsIdFeedbackRemind"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -38294,4 +38362,283 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getFeedback: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
event: {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: unknown;
|
||||
endDate: unknown;
|
||||
};
|
||||
/**
|
||||
* @description sent, scheduled (a send time is set) or not_scheduled
|
||||
* @enum {string}
|
||||
*/
|
||||
status: "sent" | "scheduled" | "not_scheduled";
|
||||
sendAt: unknown | null;
|
||||
sentAt: unknown | null;
|
||||
anonymous: boolean;
|
||||
invited: number;
|
||||
answered: number;
|
||||
/** @description % */
|
||||
responseRate: number | null;
|
||||
/** @description Every star rating on the form, 1–5 */
|
||||
averageRating: number | null;
|
||||
lastReminderAt: unknown | null;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Not signed in (UNAUTHENTICATED) */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not allowed for this role (FORBIDDEN) */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getEventsIdFeedbackResults: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description The id */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
event: {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: unknown;
|
||||
endDate: unknown;
|
||||
};
|
||||
form: {
|
||||
/**
|
||||
* @description sent, scheduled (a send time is set) or not_scheduled
|
||||
* @enum {string}
|
||||
*/
|
||||
status: "sent" | "scheduled" | "not_scheduled";
|
||||
sendAt: unknown | null;
|
||||
sentAt: unknown | null;
|
||||
/** @enum {string} */
|
||||
audience: "checked_in" | "all";
|
||||
anonymous: boolean;
|
||||
intro: string | null;
|
||||
/** @description When the automatic reminder goes (3 days after sending) */
|
||||
autoReminderAt: unknown | null;
|
||||
autoReminderSent: boolean;
|
||||
};
|
||||
counts: {
|
||||
invited: number;
|
||||
answered: number;
|
||||
notAnswered: number;
|
||||
responseRate: number | null;
|
||||
/** @description Not answered, and not sent the form or a reminder in the last 24 hours */
|
||||
canRemind: number;
|
||||
remindedRecently: number;
|
||||
/** @description Would get the form but haven't been sent it yet (e.g. checked in after it went out) */
|
||||
waiting: number;
|
||||
};
|
||||
averageRating: number | null;
|
||||
questions: {
|
||||
fieldId: string;
|
||||
type: string;
|
||||
label: string;
|
||||
answered: number;
|
||||
average?: number | null;
|
||||
distribution?: {
|
||||
label: string;
|
||||
value: number;
|
||||
}[];
|
||||
counts?: {
|
||||
label: string;
|
||||
value: number;
|
||||
}[];
|
||||
texts?: {
|
||||
value: string;
|
||||
name?: string;
|
||||
}[];
|
||||
}[];
|
||||
fields: {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
}[];
|
||||
responses: {
|
||||
id: string;
|
||||
createdAt: unknown;
|
||||
name: string | null;
|
||||
/** @description Question id → answer */
|
||||
answers: {
|
||||
[key: string]: string;
|
||||
};
|
||||
}[];
|
||||
people: {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phoneNumber: string | null;
|
||||
sentAt: unknown;
|
||||
remindedAt: unknown | null;
|
||||
reminderCount: number;
|
||||
respondedAt: unknown | null;
|
||||
canRemind: boolean;
|
||||
}[] | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation failed (VALIDATION_FAILED) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not signed in (UNAUTHENTICATED) */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not allowed for this role (FORBIDDEN) */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not found (NOT_FOUND) */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
postEventsIdFeedbackRemind: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description The id */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @description Only these people (named forms) */
|
||||
userIds?: string[] | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation failed (VALIDATION_FAILED) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not signed in (UNAUTHENTICATED) */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not allowed for this role (FORBIDDEN) */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not found (NOT_FOUND) */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export const COMMAND_PAGES: CommandPage[] = [
|
||||
{ group: "Pages", label: "Waitlist", href: "/dashboard/supervisor/waitlist", roles: S, keywords: "sold out offers" },
|
||||
{ group: "Pages", label: "Parental consent", href: "/dashboard/supervisor/consent", roles: S, keywords: "youth medical" },
|
||||
{ group: "Pages", label: "Volunteer rosters", href: "/dashboard/supervisor/volunteers", roles: SUP, keywords: "serving roles cover" },
|
||||
{ group: "Pages", label: "Feedback", href: "/dashboard/supervisor/feedback", roles: SUP, keywords: "survey responses ratings comments remind" },
|
||||
{ group: "Pages", label: "Volunteers today", href: "/dashboard/staff/volunteers", roles: S, keywords: "serving check-in qr" },
|
||||
{ group: "Pages", label: "Kids check-in setup", href: "/dashboard/supervisor/kids", roles: SUP, keywords: "rooms ratios" },
|
||||
{ group: "Pages", label: "Kids room screen", href: "/dashboard/staff/kids/rooms", roles: S, keywords: "children leaders on duty" },
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "2.0.2",
|
||||
"version": "2.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "2.0.2",
|
||||
"version": "2.1.1",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "2.0.2",
|
||||
"version": "2.1.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user