Files
hope-events/backend/src/utils/feedback.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

231 lines
12 KiB
JavaScript

/**
* Post-event feedback.
*
* A FeedbackForm is sent once, at its sendAt, to everyone who checked in (or everyone
* registered), with one reminder 3 days later to those who haven't answered. Each person
* gets a personal link; only an HMAC of its token is stored, and a reminder issues a new
* link. Anonymous forms record who has answered (so reminders skip them) but never link
* the answers to the person.
*/
const crypto = require('crypto');
const prisma = require('../config/db');
const ANSWERABLE = ['text', 'numeric', 'date', 'yes_no', 'rating', 'multiple_choice'];
const ALL_TYPES = [...ANSWERABLE, 'statement', 'paragraph'];
const REMINDER_AFTER_MS = 3 * 864e5;
function feedbackError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`feedback:${token}`).digest('hex');
const newToken = () => crypto.randomBytes(24).toString('hex');
function feedbackLink(token) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
return `${base}/feedback/${token}`;
}
// ─── Pure helpers ─────────────────────────────────────────────────────────────
/** Checks and tidies the questions from the form builder. Pure. */
function cleanFields(fields) {
if (!Array.isArray(fields)) throw feedbackError('fields must be a list');
const out = fields.filter(f => f && String(f.label || '').trim()).map((f, i) => {
if (!ALL_TYPES.includes(f.type)) throw feedbackError(`Question ${i + 1}: unknown type "${f.type}".`);
const label = String(f.label).trim().slice(0, 500);
let options = null;
if (f.type === 'multiple_choice') {
options = [...new Set((Array.isArray(f.options) ? f.options : []).map(o => String(o || '').trim()).filter(Boolean))].slice(0, 20);
if (options.length < 2) throw feedbackError(`"${label}": a multiple-choice question needs at least two choices.`);
}
return {
type: f.type, label, order: i, options,
isRequired: ANSWERABLE.includes(f.type) && !!f.isRequired,
helpText: f.helpText ? String(f.helpText).slice(0, 1000) : null,
};
});
if (out.length > 50) throw feedbackError('A feedback form can have at most 50 questions.');
if (!out.some(f => ANSWERABLE.includes(f.type))) throw feedbackError('Add at least one question.');
return out;
}
/** The stored value for one answer (null when blank); throws when it's invalid. Pure. */
function cleanAnswer(field, raw) {
if (raw === undefined || raw === null || String(raw).trim() === '') {
if (field.isRequired) throw feedbackError(`Please answer: ${field.label}`);
return null;
}
const v = String(raw).trim();
switch (field.type) {
case 'rating': {
const n = Number(v);
if (!Number.isInteger(n) || n < 1 || n > 5) throw feedbackError(`"${field.label}": choose 1 to 5 stars.`);
return String(n);
}
case 'multiple_choice':
if (!(field.options || []).includes(v)) throw feedbackError(`"${field.label}": choose one of the options.`);
return v;
case 'yes_no':
if (!['yes', 'no'].includes(v.toLowerCase())) throw feedbackError(`"${field.label}": answer yes or no.`);
return v.toLowerCase();
case 'numeric':
if (!Number.isFinite(Number(v))) throw feedbackError(`"${field.label}": enter a number.`);
return v;
case 'date':
if (isNaN(new Date(v).getTime())) throw feedbackError(`"${field.label}": enter a valid date.`);
return v.slice(0, 10);
default:
return v.slice(0, 4000);
}
}
/**
* Per-question summary. Pure.
* @param {Array} fields - [{ id, type, label, options }]
* @param {Array} answers - [{ fieldId, value, name? }] (name: respondent, for named forms)
*/
function summarise(fields, answers) {
const by = new Map();
for (const a of answers) by.set(a.fieldId, [...(by.get(a.fieldId) || []), a]);
const round1 = (n) => Math.round(n * 10) / 10;
return fields.filter(f => ANSWERABLE.includes(f.type)).map(f => {
const list = by.get(f.id) || [];
const base = { fieldId: f.id, type: f.type, label: f.label, answered: list.length };
if (f.type === 'rating') {
const nums = list.map(a => Number(a.value)).filter(n => n >= 1 && n <= 5);
const distribution = [1, 2, 3, 4, 5].map(star => ({ label: `${star}★`, value: nums.filter(n => n === star).length }));
return { ...base, average: nums.length ? round1(nums.reduce((s, n) => s + n, 0) / nums.length) : null, distribution };
}
if (f.type === 'multiple_choice' || f.type === 'yes_no') {
const choices = f.type === 'yes_no' ? ['yes', 'no'] : (f.options || []);
return { ...base, counts: choices.map(c => ({ label: f.type === 'yes_no' ? (c === 'yes' ? 'Yes' : 'No') : c, value: list.filter(a => a.value === c).length })) };
}
if (f.type === 'numeric') {
const nums = list.map(a => Number(a.value)).filter(Number.isFinite);
return { ...base, average: nums.length ? round1(nums.reduce((s, n) => s + n, 0) / nums.length) : null };
}
return { ...base, texts: list.map(a => ({ value: a.value, ...(a.name ? { name: a.name } : {}) })) };
});
}
// ─── Sending ──────────────────────────────────────────────────────────────────
/** People to ask: those who checked in (a scan on their ticket), or everyone registered. */
async function feedbackRecipients(form) {
const tickets = await prisma.ticket.findMany({
where: {
eventId: form.eventId,
registrationOption: { registration: { status: { not: 'cancelled' } } },
...(form.audience === 'all' ? {} : { usages: { some: {} } }),
},
select: { userId: true, holderUserId: true, attendee: { select: { userId: true } } },
});
const ids = new Set(tickets.map(t => t.holderUserId || t.attendee?.userId || t.userId));
if (form.audience === 'all') {
const regs = await prisma.registration.findMany({ where: { eventId: form.eventId, status: { not: 'cancelled' } }, select: { userId: true } });
regs.forEach(r => ids.add(r.userId));
}
if (ids.size === 0) return [];
return prisma.user.findMany({ where: { id: { in: [...ids] } }, select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } });
}
async function sendFeedbackMessage(user, event, token, { reminder = false } = {}) {
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
const { waTextAny } = require('./notify');
const { isValidZAPhone } = require('./whatsapp');
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const link = feedbackLink(token);
const subject = `${reminder ? 'Reminder: ' : ''}How was ${event.title}?`;
const emailOk = !!(user.email && user.email.includes('@') && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid'));
const sent = [];
if (emailOk) {
const html = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">${reminder ? 'A quick reminder — ' : ''}How was ${event.title}?</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${user.name || 'there'}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">Thank you for coming to <strong>${event.title}</strong>. We'd love to hear what you thought — it only takes a minute.</p>
${ctaButton('Give feedback', link)}
${fallbackLink(link)}
`, { preheader: `Tell us about ${event.title}` });
try {
await sendMail({ to: user.email, subject, html, text: `Hi ${user.name || 'there'},\n\nThank you for coming to ${event.title}. We'd love to hear what you thought: ${link}` });
sent.push('email');
} catch (e) {
console.warn('[feedback] email failed:', e?.message || e);
}
}
const wantsWA = ['whatsapp', 'both'].includes(user.notificationPreference) || !emailOk;
if (wantsWA && user.phoneNumber && isValidZAPhone(user.phoneNumber)) {
await waTextAny(user, `${reminder ? '⏰ ' : ''}Hi ${user.name || 'there'}, thank you for coming to *${event.title}*! We'd love your feedback — it only takes a minute: ${link}`);
sent.push('WhatsApp');
}
return sent;
}
/** Sends the form to everyone not yet invited. Returns how many were invited. */
async function sendFeedbackInvites(formId) {
const form = await prisma.feedbackForm.findUnique({ where: { id: formId }, include: { event: true, invites: { select: { userId: true } } } });
if (!form) return 0;
const already = new Set(form.invites.map(i => i.userId));
const people = (await feedbackRecipients(form)).filter(u => !already.has(u.id));
for (const user of people) {
const token = newToken();
try {
await prisma.feedbackInvite.create({ data: { formId: form.id, userId: user.id, tokenHash: hashToken(token) } });
} catch (e) {
if (e.code === 'P2002') continue; // invited by a concurrent run
throw e;
}
await sendFeedbackMessage(user, form.event, token).catch(e => console.warn('[feedback] send failed:', e?.message || e));
}
return people.length;
}
/** The single reminder: a fresh link for everyone who hasn't answered. */
async function sendFeedbackReminders(formId) {
const form = await prisma.feedbackForm.findUnique({ where: { id: formId }, include: { event: true } });
const invites = await prisma.feedbackInvite.findMany({
where: { formId, respondedAt: null, remindedAt: null },
include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } },
});
for (const inv of invites) {
const token = newToken();
await prisma.feedbackInvite.update({ where: { id: inv.id }, data: { tokenHash: hashToken(token), remindedAt: new Date() } });
await sendFeedbackMessage(inv.user, form.event, token, { reminder: true }).catch(e => console.warn('[feedback] reminder failed:', e?.message || e));
}
return invites.length;
}
/** Background job: send forms whose time has come, and reminders 3 days after. */
async function runFeedbackJob(now = new Date()) {
let sent = 0, reminded = 0;
const due = await prisma.feedbackForm.findMany({ where: { sentAt: null, sendAt: { lte: now } }, select: { id: true } });
for (const f of due) {
// Claim it first so two servers (or overlapping runs) never both send.
const claimed = await prisma.feedbackForm.updateMany({ where: { id: f.id, sentAt: null }, data: { sentAt: now } });
if (claimed.count === 1) sent += await sendFeedbackInvites(f.id);
}
const remind = await prisma.feedbackForm.findMany({ where: { reminderSentAt: null, sentAt: { lte: new Date(now.getTime() - REMINDER_AFTER_MS) } }, select: { id: true } });
for (const f of remind) {
const claimed = await prisma.feedbackForm.updateMany({ where: { id: f.id, reminderSentAt: null }, data: { reminderSentAt: now } });
if (claimed.count === 1) reminded += await sendFeedbackReminders(f.id);
}
return { sent, reminded };
}
async function findInviteByToken(token) {
if (!token || typeof token !== 'string' || token.length > 200) return null;
return prisma.feedbackInvite.findUnique({
where: { tokenHash: hashToken(token) },
include: { user: { select: { id: true, name: true } }, form: { include: { event: { select: { id: true, title: true, startDate: true, endDate: true } }, fields: { orderBy: { order: 'asc' } } } } },
});
}
module.exports = {
ANSWERABLE, ALL_TYPES, REMINDER_AFTER_MS, feedbackError, hashToken, feedbackLink,
cleanFields, cleanAnswer, summarise,
feedbackRecipients, sendFeedbackInvites, sendFeedbackReminders, runFeedbackJob, findInviteByToken,
};