Files
hope-events/backend/src/controllers/feedbackController.js
T
joshuaandClaude Opus 5.5 afcc14cbb3 Add post-event feedback forms
A Feedback step in the event wizard builds a survey that is sent at a
chosen time to everyone who checked in (or everyone registered), with
personal links and one reminder after 3 days. Answers can be anonymous.
Adds rating (1-5 stars) and multiple-choice field types, also usable on
attendee forms, and a Feedback summary report with response rate,
average ratings, choice breakdowns, comments and event comparison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:28:29 +02:00

180 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { ANSWERABLE, feedbackError, cleanFields, cleanAnswer, summarise, sendFeedbackInvites, 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]));
async function formWithStats(eventId) {
const form = await prisma.feedbackForm.findUnique({ where: { eventId }, include: { fields: { orderBy: { order: 'asc' } } } });
if (!form) return null;
const [invited, responses] = await Promise.all([
prisma.feedbackInvite.count({ where: { formId: form.id } }),
prisma.feedbackResponse.count({ where: { formId: form.id } }),
]);
return { ...form, invited, responses };
}
// @desc An event's feedback form (with invite/response counts)
// @route GET /api/feedback/event/:eventId
// @access Private/Supervisor
const getEventFeedback = async (req, res) => {
try {
res.json(await formWithStats(req.params.eventId));
} catch (error) {
fail(res, error);
}
};
// @desc Create or update an event's feedback form
// @route PUT /api/feedback/event/:eventId { sendAt, audience, anonymous, intro, fields[] }
// @access Private/Supervisor
const saveEventFeedback = async (req, res) => {
try {
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, select: { id: true, endDate: true } });
if (!event) throw feedbackError('Event not found', 404);
const { sendAt, audience, anonymous, intro, fields } = req.body || {};
const clean = cleanFields(fields);
let when = null;
if (sendAt) {
when = new Date(sendAt);
if (isNaN(when.getTime())) throw feedbackError('Enter a valid send time.');
}
const data = {
sendAt: when,
audience: audience === 'all' ? 'all' : 'checked_in',
anonymous: anonymous === true,
intro: intro ? String(intro).slice(0, 2000) : null,
};
const existing = await prisma.feedbackForm.findUnique({ where: { eventId: event.id }, include: { fields: { orderBy: { order: 'asc' } } } });
if (!existing) {
await prisma.feedbackForm.create({ data: { ...data, eventId: event.id, fields: { create: clean } } });
} else {
const answered = await prisma.feedbackResponse.count({ where: { formId: existing.id } });
const changed = shape(existing.fields) !== shape(clean);
if (answered > 0 && changed) throw feedbackError('People have already answered this feedback form, so its questions can’t be changed.', 409);
await prisma.$transaction(async (tx) => {
await tx.feedbackForm.update({ where: { id: existing.id }, data: existing.sentAt ? { ...data, sendAt: existing.sendAt } : data });
if (changed) {
await tx.feedbackField.deleteMany({ where: { formId: existing.id } });
await tx.feedbackField.createMany({ data: clean.map(f => ({ ...f, formId: existing.id, options: f.options ?? undefined })) });
}
});
}
res.json(await formWithStats(event.id));
} catch (error) {
fail(res, error);
}
};
// @route DELETE /api/feedback/event/:eventId
const deleteEventFeedback = async (req, res) => {
try {
await prisma.feedbackForm.deleteMany({ where: { eventId: req.params.eventId } });
res.json({ message: 'Feedback form removed' });
} catch (error) {
fail(res, error);
}
};
// @desc Send the feedback form now (instead of waiting for its send time)
// @route POST /api/feedback/event/:eventId/send-now
// @access Private/Supervisor
const sendNow = async (req, res) => {
try {
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.' });
} catch (error) {
fail(res, error);
}
};
// @desc The form behind a personal feedback link
// @route GET /api/feedback/public/:token
// @access Public (link holder)
const getPublicForm = async (req, res) => {
try {
const invite = await findInviteByToken(req.params.token);
if (!invite) throw feedbackError('This feedback link isn’t valid (it may have been replaced by a newer one).', 404);
const { form } = invite;
res.json({
event: form.event,
name: invite.user?.name || '',
intro: form.intro,
anonymous: form.anonymous,
responded: !!invite.respondedAt,
fields: form.fields.map(f => ({ id: f.id, type: f.type, label: f.label, isRequired: f.isRequired, helpText: f.helpText, options: f.options })),
});
} catch (error) {
fail(res, error);
}
};
// @desc Submit feedback (once per person)
// @route POST /api/feedback/public/:token { answers: { fieldId: value } }
// @access Public (link holder)
const submitPublicForm = async (req, res) => {
try {
const invite = await findInviteByToken(req.params.token);
if (!invite) throw feedbackError('This feedback link isn’t valid (it may have been replaced by a newer one).', 404);
if (invite.respondedAt) throw feedbackError('Thanks — you’ve already given feedback for this event.', 409);
const answers = req.body?.answers || {};
const rows = [];
for (const f of invite.form.fields.filter(x => ANSWERABLE.includes(x.type))) {
const value = cleanAnswer(f, answers[f.id]);
if (value !== null) rows.push({ fieldId: f.id, value });
}
if (rows.length === 0) throw feedbackError('Please answer at least one question.');
await prisma.$transaction(async (tx) => {
// Claim the invite first, so a double-submit can't create two responses.
const claimed = await tx.feedbackInvite.updateMany({ where: { id: invite.id, respondedAt: null }, data: { respondedAt: new Date() } });
if (claimed.count !== 1) throw feedbackError('Thanks — you’ve already given feedback for this event.', 409);
await tx.feedbackResponse.create({
data: { formId: invite.formId, userId: invite.form.anonymous ? null : invite.userId, answers: { create: rows } },
});
});
res.status(201).json({ message: 'Thank you for your feedback!' });
} catch (error) {
fail(res, error);
}
};
// @desc Feedback summary for one or more events (for the report, and comparisons)
// @route GET /api/feedback/summary?eventIds=a,b
// @access Private/Supervisor
const getSummary = async (req, res) => {
try {
const ids = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
const forms = await prisma.feedbackForm.findMany({
where: { eventId: { in: ids } },
include: {
event: { select: { id: true, title: true, startDate: true } },
fields: { orderBy: { order: 'asc' } },
responses: { include: { answers: true, user: { select: { name: true } } } },
_count: { select: { invites: true } },
},
});
const out = forms.map(form => {
const answers = form.responses.flatMap(r => r.answers.map(a => ({ fieldId: a.fieldId, value: a.value, ...(form.anonymous ? {} : { name: r.user?.name || '' }) })));
const invited = form._count.invites;
return {
eventId: form.event.id, title: form.event.title, startDate: form.event.startDate,
anonymous: form.anonymous, sentAt: form.sentAt,
invited, responses: form.responses.length,
responseRate: invited ? Math.round((form.responses.length / invited) * 100) : null,
questions: summarise(form.fields, answers),
};
}).sort((a, b) => new Date(a.startDate) - new Date(b.startDate));
res.json(out);
} catch (error) {
fail(res, error);
}
};
module.exports = { getEventFeedback, saveEventFeedback, deleteEventFeedback, sendNow, getPublicForm, submitPublicForm, getSummary };