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>
231 lines
12 KiB
JavaScript
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,
|
|
};
|