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 };