From c07e9c928afeacc8f9d7955a7d420a4ef8b96bde Mon Sep 17 00:00:00 2001 From: joshua Date: Mon, 27 Jul 2026 14:56:31 +0200 Subject: [PATCH] Add door check-in flow for Main Tickets Lets staff redeem a registration's Main Tickets by quantity at the door (via the Payment/registration flow) instead of scanning each QR code, and automatically emails/WhatsApps a check-in confirmation to the attendee. Also routes the At The Door "Open" button and post-payment flow dynamically: paid registrations jump straight to Check-In instead of a forced ticket print, since tickets are already sent automatically. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 10 + backend/src/controllers/ticketController.js | 11 +- backend/src/utils/notifications.js | 66 ++++ backend/src/utils/waMessages.js | 21 ++ .../dashboard/supervisor/at-the-door/page.tsx | 286 ++++++++++-------- 5 files changed, 274 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64aef9f..b02e008 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- At The Door: new "Check-In" tab (before "Tickets" in the tab order) for quickly redeeming a registration's Main Tickets by quantity (e.g. checking in 2 of 3 people on a booking) without scanning each QR code individually. Like the Payment tab, it only ever shows the currently opened registration — reached via the "Open" button, not a general search. +- Checking in a Main Ticket (via the new Check-In tab or the existing QR camera scanner) now automatically sends the ticket holder a confirmation email and WhatsApp message (respecting their notification preference) stating how many were checked in and how many remain. + +### Changed + +- At The Door: the "Open" button on a registration now jumps to the Payment tab only when a balance is still outstanding; fully paid registrations jump straight to the new Check-In tab instead. +- At The Door: recording a payment that fully settles a registration's balance now jumps straight to the Check-In tab instead of generating and print-previewing a paper ticket — tickets are already emailed/WhatsApped to the attendee automatically once the registration is paid, so a physical print is no longer forced on this path. + ## [1.2.0] - 2026-07-27 ### Added diff --git a/backend/src/controllers/ticketController.js b/backend/src/controllers/ticketController.js index b020501..2298b81 100644 --- a/backend/src/controllers/ticketController.js +++ b/backend/src/controllers/ticketController.js @@ -321,6 +321,7 @@ const scanTicket = async (req, res) => { e.title AS "eventTitle", eo.id AS "eventOptionId", eo.name AS "eventOptionName", + eo."isMainTicket" AS "isMainTicket", COALESCE( json_agg( json_build_object( @@ -352,7 +353,7 @@ const scanTicket = async (req, res) => { isUsed: r.isUsed, eventId: r.eventId, event: { title: r.eventTitle }, - registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName } }, + registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } }, usages: r.usages || [] }; @@ -398,6 +399,14 @@ const scanTicket = async (req, res) => { }) ]); + // Only Main Tickets trigger a check-in notification (add-on tickets scanned via + // the QR camera flow are unaffected) + if (ticket.registrationOption?.eventOption?.isMainTicket) { + require('../utils/notifications') + .sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining) + .catch(e => console.error('Failed to send check-in notification:', e)); + } + res.json({ message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`, ticketUsage, diff --git a/backend/src/utils/notifications.js b/backend/src/utils/notifications.js index 5239dae..3776a90 100644 --- a/backend/src/utils/notifications.js +++ b/backend/src/utils/notifications.js @@ -80,6 +80,17 @@ async function loadRegistrationFull(registrationId) { }); } +async function loadTicketFull(ticketId) { + return prisma.ticket.findUnique({ + where: { id: ticketId }, + include: { + user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } }, + event: true, + registrationOption: { include: { eventOption: true, variant: true } }, + }, + }); +} + async function loadPaymentFull(paymentId) { return prisma.payment.findUnique({ where: { id: paymentId }, @@ -488,6 +499,33 @@ function buildPaymentReceipt(payment) { return { subject, text, html: emailWrapper(body, { preheader }) }; } +// ─── Check-in confirmation (Main Tickets, door check-in) ─────────────────────── + +function buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining) { + const org = getOrg(); + const eventTitle = ticket.event?.title || 'the event'; + const optionName = ticket.registrationOption?.eventOption?.name || 'Main Ticket'; + const fullyCheckedIn = remaining <= 0; + const subject = `Checked in – ${eventTitle}`; + const preheader = `${qtyRedeemed} checked in for ${eventTitle}.`; + + const body = ` +

You're checked in! ✅

+

See you inside

+ +

Hi ${ticket.user?.name || 'there'},

+

+ ${qtyRedeemed} ${optionName}${qtyRedeemed === 1 ? '' : 's'} just checked in for ${eventTitle}. +

+ + ${callout(`${totalRedeemed} of ${ticket.quantity || 1} checked in${ + fullyCheckedIn ? '' : `
${remaining} remaining on this ticket` + }`, fullyCheckedIn ? 'success' : 'info')}`; + + const text = `You're checked in!\n\nHi ${ticket.user?.name || 'there'},\n\n${qtyRedeemed} ${optionName}(s) just checked in for ${eventTitle}.\n\n${totalRedeemed} of ${ticket.quantity || 1} checked in${fullyCheckedIn ? '' : `, ${remaining} remaining`}.\n\n${org.name} — ${org.email}`; + return { subject, text, html: emailWrapper(body, { preheader }) }; +} + // ─── Donation applied to a registration ──────────────────────────────────────── // // Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply @@ -893,6 +931,32 @@ async function sendRefundEmail(refundPaymentId) { } } +async function sendCheckInEmails(ticketId, qtyRedeemed, totalRedeemed, remaining) { + try { + const ticket = await loadTicketFull(ticketId); + if (!ticket) return; + const user = ticket.user; + const { shouldEmail, waText, waTextAny } = require('./notify'); + const { buildWACheckIn } = require('./waMessages'); + + const sends = []; + const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid'); + if (hasValidEmail && shouldEmail(user)) { + const msg = buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining); + sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text })); + } + const waMsg = buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining); + if (hasValidEmail) { + sends.push(waText(user, waMsg)); + } else { + sends.push(waTextAny(user, waMsg)); + } + await Promise.all(sends); + } catch (e) { + console.error('Failed to send check-in emails:', e); + } +} + // Sent when staff apply a donation to someone's registration. Only the registrant is // notified (anonymously, per design) — the donor already received their donation-received // notification when the donation was originally made, so they are deliberately not emailed @@ -981,4 +1045,6 @@ module.exports = { sendRefundEmail, sendSelfServiceRegistrationEmails, sendDonationAssignmentEmails, + sendCheckInEmails, + buildCheckInConfirmation, }; diff --git a/backend/src/utils/waMessages.js b/backend/src/utils/waMessages.js index cd99fb8..374d03b 100644 --- a/backend/src/utils/waMessages.js +++ b/backend/src/utils/waMessages.js @@ -107,6 +107,26 @@ function buildWAPayment(payment) { ].join('\n'); } +// ─── Check-in confirmation (Main Tickets, door check-in) ─────────────────────── + +function buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining) { + const org = getOrg(); + const eventTitle = ticket.event?.title || 'the event'; + const name = ticket.user?.name || 'there'; + const fullyCheckedIn = remaining <= 0; + + return [ + `✅ *Checked In*`, + '', + `Hi ${name},`, + '', + `*${qtyRedeemed}* checked in for *${eventTitle}*.`, + `*${totalRedeemed} of ${ticket.quantity || 1}* checked in${fullyCheckedIn ? '' : `, *${remaining}* remaining`}.`, + '', + `_${org.name}_ | ${org.url}`, + ].join('\n'); +} + // ─── Login notification ─────────────────────────────────────────────────────── function buildWALogin({ name, when, location, userAgent }) { @@ -255,4 +275,5 @@ module.exports = { buildWAWelcome, buildWAAccountClosed, buildWATicketCaption, + buildWACheckIn, }; \ No newline at end of file diff --git a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx index 981f3df..76634cf 100644 --- a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx +++ b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx @@ -7,7 +7,15 @@ import { apiFetch } from "@/lib/api"; import { scoreUser } from "@/lib/fuzzyMatch"; import { useDismissingState } from "@/hooks/useDismissingState"; -type Mode = "registration" | "payment" | "tickets" | "refund"; +type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund"; + +const MODE_LABELS: Record = { + registration: "REGISTRATION", + payment: "PAYMENT", + checkin: "CHECK-IN", + tickets: "TICKETS", + refund: "REFUND", +}; // ─── Fuzzy search helpers ───────────────────────────────────────────────────── @@ -159,8 +167,7 @@ export default function AtTheDoorPage() { const [error, setError] = useDismissingState(null); const handleRegistrationCreated = (registration: any) => { - setActiveRegistration(registration); - setMode("payment"); // 🚀 Jump automatically + setActiveRegistration(registration); // 🚀 Jump automatically (see effect below) }; const handleRegistrationSelected = (registration: any) => { @@ -171,7 +178,9 @@ export default function AtTheDoorPage() { if (!activeRegistration) return; const id = setTimeout(() => { - setMode("payment"); + // Fully paid → head straight to check-in; otherwise there's still a + // balance to collect, so go capture payment first. + setMode(activeRegistration.status === "paid" ? "checkin" : "payment"); }, 0); return () => clearTimeout(id); @@ -317,28 +326,12 @@ export default function AtTheDoorPage() { const reg = registration || activeRegistration; - try { - const res = await apiFetch("/api/tickets/generate", { - method: "POST", - authToken: token, - body: { - registrationId: reg.id - } - }); - - const tickets = res?.tickets || []; - - if (tickets.length) { - printTickets(tickets); - } - - setInfo("Payment recorded & tickets printed"); - setActiveRegistration(null); - setMode("registration"); - - } catch (e: any) { - setError(e?.message || "Tickets failed to generate"); - } + // Tickets are generated and emailed/WhatsApped automatically server-side + // once a registration reaches "paid" (see paymentController) — no need to + // fetch/print them here, just move straight to checking the attendee in. + setActiveRegistration(reg); + setInfo("Payment recorded — tickets sent. Ready to check in."); + setMode("checkin"); }; const handleDonation = (user?: any) => { @@ -346,97 +339,6 @@ export default function AtTheDoorPage() { setShowDonationModal(true); }; - const buildTicketHtmlCard = (t: any) => { - const eventTitle = - t.event?.title || - t.registrationOption?.registration?.event?.title || - "Event"; - - const ticketType = ticketLabel(t); - const qty = t.quantity || 1; - const holder = - t.user?.name || - t.registrationOption?.registration?.user?.name || - ""; - const qrValue = t.qrCode || t.id; - const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrValue)}`; - - return `
-
-
${eventTitle}
-
${ticketType}
- ${holder ? `
${holder}
` : ""} -
Qty: ${qty}
-
-
QR
-
${t.id}
-
`; - }; - - const openPrintWindow = (cardsHtml: string) => { - const w = window.open("", "_blank"); - if (!w) return; - w.document.write(`${cardsHtml}`); - w.document.close(); - }; - - const printTickets = (tickets: any[]) => { - if (!tickets?.length) return; - // 4 per A4 page (2 columns × 2 rows) - const PAGE_SIZE = 4; - const pages: string[] = []; - for (let i = 0; i < tickets.length; i += PAGE_SIZE) { - const chunk = tickets.slice(i, i + PAGE_SIZE); - pages.push(`
${chunk.map(buildTicketHtmlCard).join("")}
`); - } - openPrintWindow(pages.join("")); - }; - - return (
@@ -482,7 +384,7 @@ export default function AtTheDoorPage() { {/* Mode Buttons */}
- {(["registration", "payment", "tickets", "refund"] as Mode[]).map(m => ( + {(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => ( ))}
@@ -522,6 +424,10 @@ export default function AtTheDoorPage() { /> )} + {mode === "checkin" && ( + + )} + {mode === "tickets" && ( )} @@ -1081,6 +987,148 @@ function DoorTicketsPanel({ token, eventId }: any) { ); } +function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: any) { + const [ticketsForEvent, setTicketsForEvent] = useState([]); + const [loading, setLoading] = useState(false); + const [qtyByTicket, setQtyByTicket] = useState>({}); + const [submittingId, setSubmittingId] = useState(null); + + const load = async () => { + if (!token || !eventId) return; + try { + setLoading(true); + const res = await apiFetch(`/api/tickets/event/${eventId}`, { authToken: token }); + const tickets = res?.tickets || res?.data || (Array.isArray(res) ? res : []); + setTicketsForEvent(tickets.filter((t: any) => t.registrationOption?.eventOption?.isMainTicket)); + } catch { + setTicketsForEvent([]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, [token, eventId]); + + if (!registration) { + return ( +
+ No registration selected +
+ ); + } + + const myTickets = ticketsForEvent + .filter(t => t.registrationOption?.registration?.id === registration.id) + .map(t => { + const totalRedeemed = (t.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0); + const remaining = (t.quantity || 1) - totalRedeemed; + return { ...t, totalRedeemed, remaining }; + }); + + const qtyFor = (t: any) => qtyByTicket[t.id] ?? (t.remaining > 0 ? t.remaining : 1); + const setQtyFor = (t: any, n: number) => + setQtyByTicket(q => ({ ...q, [t.id]: Math.max(1, Math.min(t.remaining || 1, n)) })); + + const commit = async (ticket: any) => { + const qty = qtyFor(ticket); + try { + setSubmittingId(ticket.id); + const res = await apiFetch( + `/api/tickets/scan/${encodeURIComponent(ticket.qrCode)}?eventId=${encodeURIComponent(eventId)}`, + { method: "POST", authToken: token, body: { qty } } + ); + setInfo(`${res?.qtyRedeemed ?? qty} checked in for ${registration.user?.name || "guest"}${ + res?.remaining > 0 ? ` — ${res.remaining} remaining` : " — fully checked in" + }. Confirmation sent.`); + await load(); + } catch (e: any) { + setError(e?.message || "Check-in failed"); + } finally { + setSubmittingId(null); + } + }; + + return ( +
+ +
Check-In
+ + {/* ✅ User */} +
+
+ {registration.user?.name || "Guest"} +
+
+ + {loading && ( +
Loading…
+ )} + + {!loading && myTickets.length === 0 && ( +
+ No Main Tickets on this registration +
+ )} + +
+ {myTickets.map(t => { + const qty = qtyFor(t); + return ( +
+
{ticketLabel(t)}
+ +
+
+
TOTAL
+
{t.quantity || 1}
+
+ +
+
CHECKED IN
+
{t.totalRedeemed}
+
+ +
+
REMAINING
+
{t.remaining}
+
+
+ + {t.remaining > 0 ? ( +
+ +
{qty}
+ + + +
+ ) : ( +
Fully checked in ✓
+ )} +
+ ); + })} +
+
+ ); +} + function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) { if (!open) return null;