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>
180 lines
8.2 KiB
JavaScript
180 lines
8.2 KiB
JavaScript
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 };
|