diff --git a/CHANGELOG.md b/CHANGELOG.md index d24251f..c31f585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.14.0] - 2026-10-19 + +### Added + +- Group tickets (named attendees). A new "Named attendees" event setting (event wizard → Visibility) lets someone registering several people give each main-ticket place a name, with an optional email and phone. It's offered on the registration page, Manual registration, At the door and the self-service kiosk. + - Each named person gets their own single-person ticket, with their name on the ticket PDF, the scanner's confirm screen and the staff ticket list. Places left unnamed share one quantity ticket as before. + - The payer chooses whether every ticket comes to them or each person gets their own by email/WhatsApp; anyone without contact details falls back to the payer. + - The payer stays the account owner for billing, invoices and payments. + - An attendee whose email or phone matches an existing account is linked to it (never when the email and phone belong to two different accounts). Their ticket then appears in their own My Events, marked "Booked by …", and they can open and submit their own attendee form. +- My Events → registration popup has a new "People" section for adding or changing names and contact details, switching ticket delivery, and sending an attendee a link to fill in their own form (`/forms?registrationId=…&attendeeId=…`). An attendee whose ticket has been scanned can't be removed. +- Attendee forms now show one block per named person, labelled with their name, and each response is saved against that attendee. +- New endpoints `PUT /api/registrations/:id/attendees` and `POST /api/registrations/:id/attendees/:attendeeId/send-form-link`. `POST /api/registrations` and `/manual` accept `attendees` and `ticketDelivery`. + - Requires the `add_named_attendees` database migration. + +### Changed + +- Reducing the quantity of a main ticket removes the newest names beyond the new quantity. +- Check-in confirmations for a named ticket go to that person when the registration sends each person their own tickets. +- Unauthenticated guest registration now matches an existing account by phone as well as email, and is refused (409) when the email and phone belong to two different accounts, instead of silently reusing whichever account the email matched. +- Free registrations now issue tickets through the same generator as paid ones, instead of a separate copy of the logic. + +### Security + +- Registration details (`GET /api/registrations/:id`) hide attendees' email addresses and phone numbers from anyone other than the payer and staff, such as an attendee opening their form link. + ## [1.13.0] - 2026-10-19 ### Added diff --git a/FEATURES.md b/FEATURES.md index 8d15d9f..115894f 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -126,6 +126,14 @@ Items & Pricing → Sections → Form → Visibility → Attachments**. creating an account if needed. Includes a "Record Payment" tab. - **Duplicate-account protection** — if an email and phone belong to two different accounts, the registration is blocked instead of merging them. +- **Group tickets** — on events with "Named attendees" turned on, someone booking + several places can name each person (with optional email/phone). Each named person + gets their own ticket and their own attendee form; unnamed places share one ticket. + Tickets go all to the payer, or each to the person (falling back to the payer). + Attendees matching an existing account see their ticket in My Events. Names can be + added or changed later from My Events, and attendees can be sent a link to fill in + their own form. Available on the registration page, manual registration, At the + door and the kiosk. - **Coupon codes** — percentage, fixed-amount or free (comp) codes, entered under "Have a code?" on the registration page, in My Events (for unpaid registrations), manual registration, At the door and the kiosk. Codes can be limited to events, @@ -170,6 +178,8 @@ Items & Pricing → Sections → Form → Visibility → Attachments**. fully paid (or free), and sent as a PDF by email and/or WhatsApp. - **Quantity tickets** — one ticket can cover several people and be redeemed partially (e.g. check in 2 of 3). +- **Named tickets** — group bookings can give each person their own ticket, shown + with their name on the PDF, the scanner and the staff ticket list. - **QR scanner** — works in any mobile browser. A preview checks the ticket is for the selected event/section, then a confirmation screen shows holder, type and remaining quantity. diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index e176f29..804c91e 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -229,6 +229,8 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`, | PUT | `/api/registrations/:id` | staff+ | Update registration status | | GET | `/api/registrations/event/:eventId` | staff+ | Registrations for an event | | POST | `/api/registrations/manual` | supervisor+ | Manual registration (accepts `couponCode`) | +| PUT | `/api/registrations/:id/attendees` | user+ | Replace the named attendees (`{ attendees: [{ id?, eventOptionId, variantId?, name, email?, phone? }], ticketDelivery? }`); owner or staff | +| POST | `/api/registrations/:id/attendees/:attendeeId/send-form-link` | user+ | Email/WhatsApp an attendee the link to their own form | | POST | `/api/registrations/:id/coupon` | user+ | Apply a code to an existing registration (owner, or staff on someone's behalf) | | DELETE | `/api/registrations/:id/coupon` | staff+ | Remove the code (refused once a ticket on a paid registration has been scanned) | @@ -244,6 +246,12 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`, ``` Add `"couponCode": "STAFF50"` to apply a discount code. The code is checked before anything is saved (400 with the reason if it can't be used). If the last use is taken by someone else in the moment between checking and saving, the registration is still created at full price and the response includes `couponError`. +**Group tickets:** on events with `allowNamedAttendees`, `POST /api/registrations` and `/manual` accept `attendees: [{ eventOptionId, variantId?, name, email?, phone? }]` (main-ticket places only, no more than the places bought) and `ticketDelivery: "payer" | "each"`. +- Each named attendee gets their own quantity-1 ticket; unnamed places share one quantity ticket. +- An attendee whose email/phone matches one existing account is linked to it, so the ticket also appears in that person's `GET /api/tickets/mytickets`. +- With `"each"`, tickets are emailed/WhatsApped to attendees directly, falling back to the payer. +- Form responses can carry `attendeeId`. + Registration responses include `discountTotal` (the coupon discount), `subtotalComputed` (items before the discount), `totalDueComputed` (after it) and `couponRedemptions` (the active code). For unauthenticated (guest) registration on events with `requiresAuth: false`: diff --git a/backend/package-lock.json b/backend/package-lock.json index fa83b2b..27f6e03 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "event-management-backend", - "version": "1.13.0", + "version": "1.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "event-management-backend", - "version": "1.13.0", + "version": "1.14.0", "hasInstallScript": true, "dependencies": { "@prisma/client": "^5.4.2", diff --git a/backend/package.json b/backend/package.json index 0a3d052..09a6b0b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "event-management-backend", - "version": "1.13.0", + "version": "1.14.0", "description": "Event Management System Backend", "main": "src/index.js", "scripts": { diff --git a/backend/prisma/migrations/20261019115031_add_named_attendees/migration.sql b/backend/prisma/migrations/20261019115031_add_named_attendees/migration.sql new file mode 100644 index 0000000..a841185 --- /dev/null +++ b/backend/prisma/migrations/20261019115031_add_named_attendees/migration.sql @@ -0,0 +1,69 @@ +-- CreateEnum +CREATE TYPE "TicketDelivery" AS ENUM ('payer', 'each'); + +-- AlterTable +ALTER TABLE "Event" ADD COLUMN "allowNamedAttendees" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "FormResponse" ADD COLUMN "attendeeId" TEXT; + +-- AlterTable +ALTER TABLE "Registration" ADD COLUMN "ticketDelivery" "TicketDelivery" NOT NULL DEFAULT 'payer'; + +-- AlterTable +ALTER TABLE "Ticket" ADD COLUMN "attendeeId" TEXT, +ADD COLUMN "holderUserId" TEXT; + +-- CreateTable +CREATE TABLE "Attendee" ( + "id" TEXT NOT NULL, + "registrationId" TEXT NOT NULL, + "registrationOptionId" TEXT, + "eventOptionId" TEXT NOT NULL, + "variantId" TEXT, + "name" TEXT NOT NULL, + "email" TEXT, + "phone" TEXT, + "userId" TEXT, + "dateOfBirth" TIMESTAMP(3), + "gender" TEXT, + "order" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Attendee_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Attendee_registrationId_idx" ON "Attendee"("registrationId"); + +-- CreateIndex +CREATE INDEX "Attendee_registrationOptionId_idx" ON "Attendee"("registrationOptionId"); + +-- CreateIndex +CREATE INDEX "Attendee_userId_idx" ON "Attendee"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Ticket_attendeeId_key" ON "Ticket"("attendeeId"); + +-- CreateIndex +CREATE INDEX "Ticket_holderUserId_idx" ON "Ticket"("holderUserId"); + +-- AddForeignKey +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_holderUserId_fkey" FOREIGN KEY ("holderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FormResponse" ADD CONSTRAINT "FormResponse_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_registrationOptionId_fkey" FOREIGN KEY ("registrationOptionId") REFERENCES "RegistrationOption"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d5bd47f..60d0867 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -102,6 +102,9 @@ model User { couponRedemptions CouponRedemption[] @relation("CouponRedemptionUser") couponsApplied CouponRedemption[] @relation("CouponRedemptionAppliedBy") + attendeeProfiles Attendee[] + heldTickets Ticket[] @relation("TicketHolder") + twoFactorChallenges TwoFactorChallenge[] trustedDevices TrustedDevice[] } @@ -120,6 +123,8 @@ model Event { isHidden Boolean @default(false) requiresAuth Boolean @default(true) requiresRegistration Boolean @default(true) + // Lets a payer name each person on a group registration (one named ticket each). + allowNamedAttendees Boolean @default(false) contactName String? contactPhone String? contactEmail String? @@ -223,6 +228,8 @@ model Registration { // couponRedemptions. Stored here so every total-due calculation sees it no matter // which relations the caller loaded (see utils/pricing.js computeRegistrationTotalDue). discountTotal Float @default(0) + // Where named attendees' tickets go: all to the payer, or each to the attendee. + ticketDelivery TicketDelivery @default(payer) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Restrict) @@ -232,6 +239,7 @@ model Registration { formResponses FormResponse[] formDrafts FormDraft[] couponRedemptions CouponRedemption[] + attendees Attendee[] @@index([userId]) @@index([eventId]) @@ -254,6 +262,7 @@ model RegistrationOption { appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull) tickets Ticket[] tranches RegistrationOptionTranche[] + attendees Attendee[] @@index([registrationId]) @@index([eventOptionId]) @@ -320,6 +329,12 @@ model Ticket { quantity Int @default(1) isUsed Boolean @default(false) emailSent Boolean @default(false) + // Set on a named (group) ticket: the person it's for, and their account when it + // matched one. userId stays the payer. + attendeeId String? @unique + attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull) + holderUserId String? + holder User? @relation("TicketHolder", fields: [holderUserId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Restrict) @@ -327,6 +342,7 @@ model Ticket { event Event @relation(fields: [eventId], references: [id], onDelete: Restrict) usages TicketUsage[] + @@index([holderUserId]) @@index([eventId]) @@index([userId]) @@index([registrationOptionId]) @@ -499,6 +515,8 @@ model FormResponse { registrationId String createdAt DateTime @default(now()) registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade) + attendeeId String? + attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull) answers FormAnswer[] @@index([registrationId]) @@ -923,3 +941,38 @@ model CouponRedemption { @@index([userId]) @@index([appliedById]) } + +// ─── Group tickets ──────────────────────────────────────────────────────────── + +enum TicketDelivery { + payer // every ticket goes to the person who registered + each // each named attendee gets their own (falls back to the payer) +} + +// One named person on a group registration, for one main-ticket place. Gets their +// own quantity-1 ticket and their own attendee form response. +model Attendee { + id String @id @default(uuid()) + registrationId String + registrationOptionId String? + eventOptionId String + variantId String? + name String + email String? + phone String? + userId String? // existing account matched by email/phone + dateOfBirth DateTime? + gender String? + order Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade) + registrationOption RegistrationOption? @relation(fields: [registrationOptionId], references: [id], onDelete: SetNull) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + ticket Ticket? + formResponses FormResponse[] + + @@index([registrationId]) + @@index([registrationOptionId]) + @@index([userId]) +} diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js index 6802ef6..0f2c4c1 100644 --- a/backend/src/controllers/eventController.js +++ b/backend/src/controllers/eventController.js @@ -44,7 +44,7 @@ function toAbsoluteUrl(req, url) { // @access Private/Admin const createEvent = async (req, res) => { try { - const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body; + const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, contactName, contactPhone, contactEmail, location } = req.body; const data = { id: uuidv4(), @@ -62,6 +62,7 @@ const createEvent = async (req, res) => { isHidden: isHidden === true || isHidden === 'true', requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true, requiresRegistration: requiresRegistration === false || requiresRegistration === 'false' ? false : true, + allowNamedAttendees: allowNamedAttendees === true || allowNamedAttendees === 'true', contactName: contactName || null, contactPhone: contactPhone || null, contactEmail: contactEmail || null, @@ -537,7 +538,7 @@ const updateEvent = async (req, res) => { ip: getClientIp(req), }); - const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body; + const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, contactName, contactPhone, contactEmail, location } = req.body; const data = { title: title || event.title, @@ -552,6 +553,7 @@ const updateEvent = async (req, res) => { isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false), requiresAuth: requiresAuth !== undefined ? !(requiresAuth === false || requiresAuth === 'false') : (event.requiresAuth ?? true), requiresRegistration: requiresRegistration !== undefined ? !(requiresRegistration === false || requiresRegistration === 'false') : (event.requiresRegistration ?? true), + allowNamedAttendees: allowNamedAttendees !== undefined ? (allowNamedAttendees === true || allowNamedAttendees === 'true') : event.allowNamedAttendees, contactName: contactName !== undefined ? (contactName || null) : event.contactName, contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone, contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail, diff --git a/backend/src/controllers/registrationController.js b/backend/src/controllers/registrationController.js index 505f90e..85fcd74 100644 --- a/backend/src/controllers/registrationController.js +++ b/backend/src/controllers/registrationController.js @@ -8,6 +8,29 @@ const { assertEventOpen } = require('../utils/cashupUtils'); const { logAdminAction } = require('../utils/adminAudit'); const { getClientIp } = require('../utils/requestUtils'); const { buildCouponLines, previewCoupon, applyCouponToRegistration, recalculateRegistrationDiscount, releaseRegistrationCoupons, describeCoupon } = require('../utils/coupons'); +const { lineKey, validateAttendees, createAttendees, namedPlacesByKey } = require('../utils/attendees'); +const { findAccountByContact } = require('../utils/accountMatch'); + +// Named attendees for API responses, with whether their ticket has been issued/used. +const ATTENDEES_INCLUDE = { + orderBy: { order: 'asc' }, + include: { ticket: { select: { id: true, isUsed: true } } }, +}; + +const TICKET_DELIVERY = ['payer', 'each']; + +/** + * Places each line will have once `requested` is added to `existingLines` — used to + * check attendee names against what's actually being bought. + */ +function placesAfter(existingLines, requested) { + const out = {}; + for (const l of [...(existingLines || []), ...(requested || [])]) { + const k = lineKey(l.eventOptionId, l.variantId); + out[k] = (out[k] || 0) + Number(l.quantity || 0); + } + return out; +} // Active coupon on a registration, for API responses (the discount itself is // Registration.discountTotal). @@ -85,7 +108,7 @@ async function checkVariantStock(variant, requestedQty) { // @access Private const createRegistration = async (req, res) => { try { - const { eventId, options, guestName, guestEmail, guestPhone, couponCode } = req.body; + const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery } = req.body; // Check if event exists const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function'); @@ -144,7 +167,12 @@ const createRegistration = async (req, res) => { res.status(400); throw new Error('Name and email are required for unauthenticated registration'); } - const existingGuest = await prisma.user.findUnique({ where: { email: guestEmail } }); + const match = await findAccountByContact({ email: guestEmail, phone: guestPhone }); + if (match.conflict) { + res.status(409); + throw new Error('This email and phone number belong to two different accounts. Please log in, or check your details.'); + } + const existingGuest = match.user; if (existingGuest) { userId = existingGuest.id; } else { @@ -254,6 +282,19 @@ const createRegistration = async (req, res) => { include: { registrationOptions: true } }); + // Attendee names are checked against the places the registration will have after + // this purchase, counting names already given on an existing registration. + const cleanAttendees = validateAttendees( + event, + placesAfter(existingReg?.registrationOptions, resolvedOptions), + attendees, + existingReg ? await namedPlacesByKey(existingReg.id) : {} + ); + if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) { + res.status(400); + throw new Error("ticketDelivery must be 'payer' or 'each'"); + } + let registration; let isNewRegistration = false; if (existingReg) { @@ -358,6 +399,11 @@ const createRegistration = async (req, res) => { }); } + if (cleanAttendees.length > 0) await createAttendees(registration.id, cleanAttendees); + if (ticketDelivery) { + await prisma.registration.update({ where: { id: registration.id }, data: { ticketDelivery } }); + } + // Apply the coupon (or re-work an existing one over the merged items). let couponError = null; if (!isNewRegistration) await recalculateRegistrationDiscount(registration.id); @@ -370,7 +416,7 @@ const createRegistration = async (req, res) => { couponError = e.message; } } - if (couponCode || !isNewRegistration) { + if (couponCode || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) { registration = await prisma.registration.findUnique({ where: { id: registration.id }, include: { @@ -378,6 +424,7 @@ const createRegistration = async (req, res) => { event: true, user: { select: { id: true, name: true, email: true, phoneNumber: true } }, couponRedemptions: ACTIVE_COUPONS_INCLUDE, + attendees: ATTENDEES_INCLUDE, } }); } @@ -398,31 +445,9 @@ const createRegistration = async (req, res) => { hasRequiredForm = !!(form && form.isRequired); } catch {} if (!hasRequiredForm) { - // Batch-fetch existing tickets then create/update in parallel (1 read + N parallel writes vs N×2 sequential) - const optionIds = registration.registrationOptions.map(o => o.id); - const existingTickets = await prisma.ticket.findMany({ where: { registrationOptionId: { in: optionIds } } }); - const ticketByOptionId = Object.fromEntries(existingTickets.map(t => [t.registrationOptionId, t])); - await Promise.all(registration.registrationOptions.map(option => { - const exists = ticketByOptionId[option.id]; - if (exists) { - const newQty = option.quantity || 1; - if (newQty > (exists.quantity || 1)) { - return prisma.ticket.update({ where: { id: exists.id }, data: { quantity: newQty, updatedAt: new Date() } }); - } - return Promise.resolve(); - } - return prisma.ticket.create({ - data: { - id: uuidv4(), - qrCode: uuidv4(), - registrationOptionId: option.id, - userId: registration.userId, - eventId: registration.eventId, - quantity: option.quantity || 1, - updatedAt: new Date(), - } - }); - })); + // Same generator as paid registrations: a ticket per named attendee, and one + // shared quantity ticket for the rest of each line. + await generateTicketsForRegistration(registration.id); // Capture for chained email — tickets sent after registration confirmation freeTicketMockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } }; freeTicketMockRes = { status: () => freeTicketMockRes, json: () => {} }; @@ -466,6 +491,7 @@ const getRegistrations = async (req, res) => { try { const include = { couponRedemptions: ACTIVE_COUPONS_INCLUDE, + attendees: ATTENDEES_INCLUDE, payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, registrationOptions: { include: { @@ -544,6 +570,8 @@ const getUserRegistrations = async (req, res) => { } }, couponRedemptions: ACTIVE_COUPONS_INCLUDE, + attendees: ATTENDEES_INCLUDE, + formResponses: { select: { id: true, attendeeId: true } }, // Nest the event's form so the frontend can tell whether attendee forms are // required without a separate GET /api/events/:id per registration. event: { include: { form: { include: { fields: true } } } }, @@ -592,6 +620,7 @@ const getRegistrationById = async (req, res) => { payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, formResponses: { include: { answers: true } }, couponRedemptions: ACTIVE_COUPONS_INCLUDE, + attendees: ATTENDEES_INCLUDE, }; let registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include }); @@ -609,11 +638,17 @@ const getRegistrationById = async (req, res) => { } // Guests (no auth) can view by knowing the registrationId (UUID = unguessable) - // Authenticated users must be the owner or staff+ - if (req.user && registration.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') { + // Authenticated users must be the owner, staff+, or a named attendee on it + const isOwnerOrStaffViewer = req.user && (registration.userId === req.user.id || ['admin', 'supervisor', 'staff'].includes(req.user.role)); + const isAttendeeViewer = req.user && (registration.attendees || []).some(a => a.userId === req.user.id); + if (req.user && !isOwnerOrStaffViewer && !isAttendeeViewer) { res.status(403); throw new Error('Not authorized to view this registration'); } + // Only the payer and staff see attendees' contact details. + if (!isOwnerOrStaffViewer) { + registration.attendees = (registration.attendees || []).map(a => ({ ...a, email: null, phone: null })); + } res.json(attachComputedTotals(registration)); } catch (error) { @@ -826,6 +861,7 @@ const getRegistrationsByEvent = async (req, res) => { payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } }, couponRedemptions: ACTIVE_COUPONS_INCLUDE, + attendees: ATTENDEES_INCLUDE, }, orderBy: { createdAt: 'asc' } }); @@ -851,7 +887,7 @@ const getRegistrationsByEvent = async (req, res) => { const createManualRegistration = async (req, res) => { let userRecord; try { - const { eventId, options, user, notificationPreference: prefFromBody, skipActivationNotice, couponCode } = req.body; + const { eventId, options, user, notificationPreference: prefFromBody, skipActivationNotice, couponCode, attendees, ticketDelivery } = req.body; if (!eventId || !options || !user || !user.name || (!user.email && !user.phoneNumber)) { res.status(400); @@ -1049,6 +1085,17 @@ const createManualRegistration = async (req, res) => { include: { registrationOptions: true } }); + const cleanAttendees = validateAttendees( + event, + placesAfter(existingReg?.registrationOptions, resolvedManualOptions), + attendees, + existingReg ? await namedPlacesByKey(existingReg.id) : {} + ); + if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) { + res.status(400); + throw new Error("ticketDelivery must be 'payer' or 'each'"); + } + let registration; let isNewRegistration = false; @@ -1194,6 +1241,9 @@ const createManualRegistration = async (req, res) => { const registrationId = registration.id; + if (cleanAttendees.length > 0) await createAttendees(registrationId, cleanAttendees); + if (ticketDelivery) await prisma.registration.update({ where: { id: registrationId }, data: { ticketDelivery } }); + if (couponCode && !couponError) { logAdminAction({ actorId: req.user.id, @@ -1573,6 +1623,18 @@ const updateRegistrationOptions = async (req, res) => { } } + // Names on places that no longer exist go too (newest first). Tickets for them + // are tidied up by generateTicketsForRegistration; the floor check above already + // stops anything going below tickets issued. + await tx.attendee.deleteMany({ where: { registrationId, registrationOptionId: null } }); + const lines = await tx.registrationOption.findMany({ where: { registrationId }, include: { attendees: { orderBy: { order: 'desc' } } } }); + for (const line of lines) { + const extra = (line.attendees || []).length - (line.quantity || 0); + if (extra > 0) { + await tx.attendee.deleteMany({ where: { id: { in: line.attendees.slice(0, extra).map(a => a.id) } } }); + } + } + // Coupon: percentage/comp codes follow the new items, fixed codes keep their amount. const discount = await recalculateRegistrationDiscount(registrationId, tx); const dueAfterDiscount = Math.max(0, newTotalDue - discount); @@ -1601,6 +1663,7 @@ const updateRegistrationOptions = async (req, res) => { user: { select: { id: true, name: true, email: true, phoneNumber: true } }, payments: true, couponRedemptions: ACTIVE_COUPONS_INCLUDE, + attendees: ATTENDEES_INCLUDE, } }); @@ -1645,8 +1708,11 @@ const submitFormResponses = async (req, res) => { } }); if (!registration) { res.status(404); throw new Error('Registration not found'); } - // Guests (no auth) may submit by knowing the registrationId; authenticated users must be owner or staff+ - if (req.user && registration.userId !== userId && !['admin','supervisor','staff'].includes(req.user.role)) { + // Guests (no auth) may submit by knowing the registrationId; authenticated users must be + // owner, staff+, or submitting their own form as a named attendee + const ownAttendeeOnly = Array.isArray(responses) && responses.length > 0 && req.user + && (await prisma.attendee.count({ where: { registrationId, userId, id: { in: responses.map(r => r?.attendeeId).filter(Boolean) } } })) === responses.length; + if (req.user && registration.userId !== userId && !['admin','supervisor','staff'].includes(req.user.role) && !ownAttendeeOnly) { res.status(403); throw new Error('Not authorized'); } @@ -1668,11 +1734,20 @@ const submitFormResponses = async (req, res) => { if (toSubmit.length === 0) { res.status(400); throw new Error('No responses provided'); } if (toSubmit.length > mainTickets) { res.status(400); throw new Error(`Too many forms submitted. Expected at most ${mainTickets}`); } + // A response can be for a named attendee (group tickets); each attendee answers once. + const attendeeIds = toSubmit.map(r => r?.attendeeId).filter(Boolean); + if (attendeeIds.length > 0) { + const known = await prisma.attendee.findMany({ where: { id: { in: attendeeIds }, registrationId }, include: { formResponses: { select: { id: true } } } }); + if (known.length !== new Set(attendeeIds).size) { res.status(400); throw new Error('Unknown attendee for this registration'); } + const answered = known.find(a => a.formResponses.length > 0); + if (answered) { res.status(400); throw new Error(`${answered.name}'s form has already been submitted`); } + } + // Create responses and answers const created = []; for (const resp of toSubmit) { const respId = uuidv4(); - const fr = await prisma.formResponse.create({ data: { id: respId, registrationId } }); + const fr = await prisma.formResponse.create({ data: { id: respId, registrationId, attendeeId: resp?.attendeeId || null } }); const ansMap = resp?.answers || {}; for (const f of answerableFields) { const raw = ansMap[f.id]; @@ -2044,7 +2119,113 @@ const removeCoupon = async (req, res) => { } }; +const isOwnerOrStaff = (registration, user) => + registration.userId === user.id || ['admin', 'supervisor', 'staff'].includes(user.role); + +// @desc Replace the named attendees on a registration (and who gets the tickets) +// @route PUT /api/registrations/:id/attendees +// @access Private (owner or staff) +// Body: { attendees: [{ id?, eventOptionId, variantId?, name, email?, phone? }], ticketDelivery? } +// Attendees left out are removed — refused if their ticket has been scanned. +const updateAttendees = async (req, res) => { + try { + const { attendees = [], ticketDelivery } = req.body || {}; + const registration = await prisma.registration.findUnique({ + where: { id: req.params.id }, + include: { + registrationOptions: true, + attendees: { include: { ticket: { include: { usages: true } } } }, + event: { include: { eventOptions: true } }, + }, + }); + if (!registration) { res.status(404); throw new Error('Registration not found'); } + if (!isOwnerOrStaff(registration, req.user)) { res.status(403); throw new Error('Not authorized to edit this registration'); } + if (registration.status === 'cancelled') { res.status(400); throw new Error('This registration is cancelled.'); } + if (registration.event?.cashupStatus === 'closed') { res.status(400); throw new Error('This event is closed.'); } + if (registration.event?.endDate && new Date(registration.event.endDate) < new Date()) { + res.status(400); throw new Error('This event has already ended.'); + } + if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) { + res.status(400); throw new Error("ticketDelivery must be 'payer' or 'each'"); + } + + const clean = validateAttendees(registration.event, placesAfter(registration.registrationOptions, []), attendees); + const keepIds = new Set(attendees.map(a => a?.id).filter(Boolean)); + const existingById = new Map(registration.attendees.map(a => [a.id, a])); + for (const id of keepIds) if (!existingById.has(id)) { res.status(400); throw new Error('Unknown attendee for this registration'); } + const removed = registration.attendees.filter(a => !keepIds.has(a.id)); + const usedRemoved = removed.find(a => a.ticket && (a.ticket.isUsed || a.ticket.usages.length > 0)); + if (usedRemoved) { res.status(400); throw new Error(`${usedRemoved.name}'s ticket has already been scanned, so they can't be removed.`); } + + const toCreate = []; + await prisma.$transaction(async (tx) => { + if (removed.length) await tx.attendee.deleteMany({ where: { id: { in: removed.map(a => a.id) } } }); + for (let i = 0; i < attendees.length; i++) { + const a = attendees[i]; + const c = clean[i]; + if (a.id) { + const prev = existingById.get(a.id); + const contactChanged = prev.email !== c.email || prev.phone !== c.phone; + const match = contactChanged && (c.email || c.phone) ? await findAccountByContact({ email: c.email, phone: c.phone }, tx) : null; + await tx.attendee.update({ + where: { id: a.id }, + data: { + name: c.name, email: c.email, phone: c.phone, + ...(contactChanged ? { userId: match?.user?.id || null } : {}), + }, + }); + } else { + toCreate.push(c); + } + } + if (toCreate.length) await createAttendees(registration.id, toCreate, tx); + if (ticketDelivery) await tx.registration.update({ where: { id: registration.id }, data: { ticketDelivery } }); + }); + + // Paid registrations: re-sync tickets and send any new ones. + if (registration.status === 'paid') { + const created = await generateTicketsForRegistration(registration.id); + if (created.length > 0) { + const mockReq = { user: { id: registration.userId }, body: { ticketIds: created.map(t => t.id) } }; + const mockRes = { status: () => mockRes, json: () => {} }; + emailTickets(mockReq, mockRes).catch(e => console.error('[attendees] Failed to send new tickets:', e?.message || e)); + } + } + + const updated = await prisma.registration.findUnique({ + where: { id: registration.id }, + select: { id: true, ticketDelivery: true, attendees: ATTENDEES_INCLUDE }, + }); + res.json(updated); + } catch (error) { + res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message }); + } +}; + +// @desc Send an attendee a link to fill in their own attendee form +// @route POST /api/registrations/:id/attendees/:attendeeId/send-form-link +// @access Private (owner or staff) +const sendAttendeeFormLink = async (req, res) => { + try { + const attendee = await prisma.attendee.findFirst({ + where: { id: req.params.attendeeId, registrationId: req.params.id }, + include: { registration: { include: { event: true, user: { select: { id: true, name: true } } } } }, + }); + if (!attendee) { res.status(404); throw new Error('Attendee not found'); } + if (!isOwnerOrStaff(attendee.registration, req.user)) { res.status(403); throw new Error('Not authorized'); } + if (!attendee.email && !attendee.phone) { res.status(400); throw new Error(`Add an email or phone number for ${attendee.name} first.`); } + + const { sendAttendeeFormLinkMessage } = require('../utils/notifications'); + const sent = await sendAttendeeFormLinkMessage(attendee); + res.json({ message: sent.length ? `Form link sent to ${attendee.name} by ${sent.join(' and ')}.` : 'The form link could not be sent.' }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message }); + } +}; + module.exports = { + updateAttendees, + sendAttendeeFormLink, applyCoupon, removeCoupon, createRegistration, diff --git a/backend/src/controllers/ticketController.js b/backend/src/controllers/ticketController.js index 2298b81..37fc4ea 100644 --- a/backend/src/controllers/ticketController.js +++ b/backend/src/controllers/ticketController.js @@ -81,9 +81,10 @@ const getTickets = async (req, res) => { // @access Private const getUserTickets = async (req, res) => { try { + // Tickets this user paid for, plus named tickets someone else bought for them. const tickets = await prisma.ticket.findMany({ where: { - userId: req.user.id, + OR: [{ userId: req.user.id }, { holderUserId: req.user.id }], registrationOption: { registration: { status: { not: 'cancelled' } } } }, include: { @@ -94,6 +95,8 @@ const getUserTickets = async (req, res) => { registration: true } }, + attendee: { select: { id: true, name: true } }, + user: { select: { id: true, name: true } }, event: true, usages: { include: { @@ -261,6 +264,7 @@ const getScanPreview = async (req, res) => { e.title AS "eventTitle", u.id AS "userId", u.name AS "userName", + a.name AS "attendeeName", COALESCE( json_agg( json_build_object( @@ -276,9 +280,10 @@ const getScanPreview = async (req, res) => { LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId" LEFT JOIN "Event" e ON e.id = t."eventId" LEFT JOIN "User" u ON u.id = t."userId" + LEFT JOIN "Attendee" a ON a.id = t."attendeeId" LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id WHERE t."qrCode" = ${req.params.qrCode} - GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id + GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id `; if (!rows || rows.length === 0) { @@ -299,6 +304,7 @@ const getScanPreview = async (req, res) => { }, event: { id: r.evId, title: r.eventTitle }, user: { id: r.userId, name: r.userName }, + attendee: r.attendeeName ? { name: r.attendeeName } : null, usages: r.usages || [] }); } catch (error) { @@ -432,6 +438,7 @@ const getTicketsByEvent = async (req, res) => { include: { registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } }, user: { select: { id: true, name: true, email: true, phoneNumber: true } }, + attendee: { select: { id: true, name: true, email: true, phone: true } }, usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } } }, orderBy: { createdAt: 'desc' }, @@ -660,6 +667,13 @@ async function emailTicketsInternal(req, res, userOverride) { return res.json({ message: 'No valid contact details on file — ticket delivery skipped.' }); } + const ticketInclude = { + event: true, + registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } }, + attendee: true, + user: { select: { name: true } }, + usages: true, + }; let tickets; if (registrationId) { const registration = await prisma.registration.findUnique({ @@ -669,22 +683,53 @@ async function emailTicketsInternal(req, res, userOverride) { const registrationOptions = await prisma.registrationOption.findMany({ where: { registrationId } }); tickets = await prisma.ticket.findMany({ where: { registrationOptionId: { in: registrationOptions.map(o => o.id) }, userId }, - include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true }, + include: ticketInclude, orderBy: { createdAt: 'asc' } }); } else { + // The payer, or the named holder of a group ticket, can have it sent. tickets = await prisma.ticket.findMany({ - where: { id: { in: ticketIds }, userId }, - include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true }, + where: { id: { in: ticketIds }, OR: [{ userId }, { holderUserId: userId }] }, + include: ticketInclude, orderBy: { createdAt: 'asc' } }); } - // Dedup by registrationOptionId + // Group tickets set to "send each person their own": attendees with contact details + // get theirs directly; the rest (and attendees without contact details) go to the + // payer below. Not when the caller asked for a specific recipient or channel. + if (!userOverride && !req._noSplit) { + const own = tickets.filter(t => t.attendee && (t.attendee.email || t.attendee.phone) + && t.registrationOption?.registration?.ticketDelivery === 'each'); + for (const t of own) { + const a = t.attendee; + const hasEmail = !!(a.email && a.email.includes('@')); + const recipient = { + id: a.userId || null, + name: a.name, + email: hasEmail ? a.email : null, + phoneNumber: a.phone || null, + notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp', + }; + const silent = { status: () => silent, json: () => silent }; + try { + await emailTicketsInternal({ user: { id: userId }, body: { ticketIds: [t.id] }, _noSplit: true }, silent, recipient); + } catch (e) { + console.error(`[tickets] Failed to send ${a.name}'s ticket:`, e?.message || e); + } + } + const sentIds = new Set(own.map(t => t.id)); + tickets = tickets.filter(t => !sentIds.has(t.id)); + if (tickets.length === 0 && own.length > 0) { + return res.json({ message: `${own.length} ticket(s) sent to the people they're for`, ticketIds: own.map(t => t.id) }); + } + } + + // Dedup by registrationOptionId — except named tickets, which are one per person. { const seen = new Map(); const deduped = []; for (const t of tickets) { - const key = t.registrationOptionId; + const key = t.attendeeId ? `attendee:${t.attendeeId}` : t.registrationOptionId; if (!seen.has(key)) { seen.set(key, t); deduped.push(t); } else { const existing = seen.get(key); @@ -726,10 +771,10 @@ async function emailTicketsInternal(req, res, userOverride) { doc.font('Helvetica').fontSize(10); const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : ''; doc.text(`Ticket Type: ${ticket.registrationOption.eventOption.name}${variantSuffix}`, x + 10, y + 30, { width: ticketWidth - 20 }); - doc.text(`Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 }); + doc.text(ticket.attendee ? `Name: ${ticket.attendee.name}` : `Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 }); const eventDate = new Date(ticket.event.startDate); doc.text(`Date: ${new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate)}`, x + 10, y + 60, { width: ticketWidth - 20 }); - doc.text(`Purchased by: ${user.name}`, x + 10, y + 75, { width: ticketWidth - 20 }); + doc.text(`Purchased by: ${ticket.user?.name || user.name}`, x + 10, y + 75, { width: ticketWidth - 20 }); const qrCodeSize = Math.min(ticketWidth, ticketHeight) * 0.45; const qrX = x + (ticketWidth - qrCodeSize) / 2; const qrY = y + 90; doc.image(qrCodeMap[ticket.id], qrX, qrY, { width: qrCodeSize, height: qrCodeSize }); diff --git a/backend/src/index.js b/backend/src/index.js index 9723248..e0fa6f2 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -610,6 +610,10 @@ app.get('/docs', async (req, res) => { { method:'GET', path:'/api/registrations/event/:eventId', auth:'staff+', desc:'Get all registrations for a specific event', pathParams:{ ':eventId':'Event UUID' }, responses:[{ status:200, desc:'Success', body:[{ id:'reg-uuid-...', user:{ id:'user-uuid-...', name:'Jane Doe', email:'jane@example.com', phoneNumber:'+27821234567' }, status:'paid' }]}]}, + { method:'PUT', path:'/api/registrations/:id/attendees', auth:'user+', desc:'Replace the named attendees on a registration (group tickets) and choose ticket delivery (payer | each). Owner or staff. Removing an attendee whose ticket was scanned is refused.', + request:{ body:{ attendees:[{ id:'att-uuid-...', eventOptionId:'opt-uuid-...', name:'Sam Smith', email:'sam@example.com' },{ eventOptionId:'opt-uuid-...', name:'Alex Smith' }], ticketDelivery:'each' }}, + responses:[{ status:200, desc:'Saved', body:{ id:'reg-uuid-...', ticketDelivery:'each', attendees:[{ id:'att-uuid-...', name:'Sam Smith', ticket:{ id:'tkt-uuid-...', isUsed:false }}] }}]}, + { method:'POST', path:'/api/registrations/:id/attendees/:attendeeId/send-form-link', auth:'user+', desc:'Email/WhatsApp a named attendee the link to fill in their own attendee form' }, { method:'POST', path:'/api/registrations/manual', auth:'supervisor+', desc:'Manually create a registration (at-the-door / walk-in). Resolves early-bird tier pricing (including per-variant tiers) and stores priceSnapshot + appliedTierId on each option. Creates or finds a matching user by email/phone. If a matching user is found, updates their notificationPreference and backfills a missing phone number or guest placeholder email with the newly supplied value (without overwriting existing contact info). Generates a Yoco checkout link for unpaid registrations to include in the self-service notification email.', request:{ body:{ eventId:'ev-uuid-...', user:{ name:'Jane Doe', email:'jane@example.com', phoneNumber:'0821234567' }, options:[{ eventOptionId:'opt-uuid-...', quantity:1, variantId:'var-uuid-...' }], notificationPreference:'email' }}, responses:[{ status:201, desc:'Created', body:{ id:'reg-uuid-new', status:'pending' }}]}, diff --git a/backend/src/routes/registrationRoutes.js b/backend/src/routes/registrationRoutes.js index c94c5e4..6fa0402 100644 --- a/backend/src/routes/registrationRoutes.js +++ b/backend/src/routes/registrationRoutes.js @@ -14,6 +14,7 @@ const { getFormDraft, saveFormDraft, sendInvoice, applyCoupon, removeCoupon, + updateAttendees, sendAttendeeFormLink, } = require('../controllers/registrationController'); const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware'); @@ -25,6 +26,8 @@ router.delete('/:id', protect, cancelRegistration); router.post('/:id/send-invoice', protect, sendInvoice); router.post('/:id/coupon', protect, applyCoupon); router.delete('/:id/coupon', protect, staff, removeCoupon); +router.put('/:id/attendees', protect, updateAttendees); +router.post('/:id/attendees/:attendeeId/send-form-link', protect, sendAttendeeFormLink); // Registration detail + forms — optionalAuth so guests can access with just the registrationId router.get('/:id', optionalAuth, getRegistrationById); diff --git a/backend/src/utils/accountMatch.js b/backend/src/utils/accountMatch.js new file mode 100644 index 0000000..aa9d968 --- /dev/null +++ b/backend/src/utils/accountMatch.js @@ -0,0 +1,36 @@ +/** + * Finding an existing account from an email and/or phone number. + * + * Email and phone are looked up separately rather than with one OR query, so an email + * belonging to one account and a phone belonging to another can never be silently + * collapsed into whichever matches first — that would let a registration hijack or + * corrupt someone else's account. Phones are tried in both 27xxx and 0xxx forms. + */ + +const prisma = require('../config/db'); + +function hasValidEmail(email) { + return !!(email && typeof email === 'string' && email.includes('@')); +} + +/** + * @returns {Promise<{ phone: string, emailUser: object|null, phoneUser: object|null, + * conflict: boolean, user: object|null }>} + * `user` is the single matched account (null when none, or when email and phone point + * at two different accounts — `conflict` is then true). + */ +async function findAccountByContact({ email, phone: rawPhone }, db = prisma) { + const { normalizeZAPhone } = require('./whatsapp'); + const phone = normalizeZAPhone(rawPhone) || String(rawPhone || '').replace(/\D/g, ''); + const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null); + + const [emailUser, phoneUser] = await Promise.all([ + hasValidEmail(email) ? db.user.findUnique({ where: { email: String(email).trim() } }) : null, + phone ? db.user.findFirst({ where: { OR: [{ phoneNumber: phone }, ...(phoneAlt ? [{ phoneNumber: phoneAlt }] : [])] } }) : null, + ]); + + const conflict = !!(emailUser && phoneUser && emailUser.id !== phoneUser.id); + return { phone, emailUser, phoneUser, conflict, user: conflict ? null : (emailUser || phoneUser || null) }; +} + +module.exports = { findAccountByContact, hasValidEmail }; diff --git a/backend/src/utils/attendees.js b/backend/src/utils/attendees.js new file mode 100644 index 0000000..df275fc --- /dev/null +++ b/backend/src/utils/attendees.js @@ -0,0 +1,166 @@ +/** + * Group tickets — named attendees on a registration. + * + * Rules: + * - Only main-ticket places can be named, and only on events with + * allowNamedAttendees. Each named attendee gets their own quantity-1 ticket; any + * places left unnamed share one quantity ticket, as before. + * - The payer stays the registration owner (billing, invoices, payments). An attendee + * whose email/phone matches an existing account is linked to it, so the ticket also + * shows in that person's dashboard — unless email and phone point at two different + * accounts, in which case nothing is linked. + * - Registration.ticketDelivery = 'each' sends each attendee their own ticket by + * email/WhatsApp; attendees with no contact details fall back to the payer. + */ + +const prisma = require('../config/db'); +const { findAccountByContact } = require('./accountMatch'); + +const lineKey = (eventOptionId, variantId) => `${eventOptionId}::${variantId || ''}`; + +function attendeeError(message) { + const err = new Error(message); + err.statusCode = 400; + return err; +} + +/** + * Checks attendees against the event and the number of places on each line. Pure. + * Returns the cleaned list (trimmed strings, nulls for blanks). + * + * @param {object} event - with allowNamedAttendees and eventOptions[{ id, isMainTicket, name }] + * @param {object} placesByKey - total places each line will have, keyed by lineKey() + * @param {Array} attendees - [{ eventOptionId, variantId?, name, email?, phone? }] + * @param {object} [namedByKey] - places on those lines already named (and being kept) + */ +function validateAttendees(event, placesByKey, attendees, namedByKey = {}) { + if (!attendees || attendees.length === 0) return []; + if (!event.allowNamedAttendees) throw attendeeError('This event doesn\'t take attendee names.'); + if (!Array.isArray(attendees)) throw attendeeError('attendees must be a list'); + + const countByKey = {}; + return attendees.map((a, i) => { + const option = (event.eventOptions || []).find(o => o.id === a?.eventOptionId); + if (!option) throw attendeeError(`Attendee ${i + 1}: unknown ticket option.`); + if (!option.isMainTicket) throw attendeeError(`Attendee ${i + 1}: names can only be given for "${option.name}" main tickets.`); + const name = String(a.name || '').trim(); + if (!name) throw attendeeError(`Attendee ${i + 1}: enter a name.`); + const email = String(a.email || '').trim() || null; + if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) throw attendeeError(`Attendee ${i + 1}: "${email}" isn't a valid email address.`); + const phone = String(a.phone || '').trim() || null; + const key = lineKey(a.eventOptionId, a.variantId); + countByKey[key] = (countByKey[key] || 0) + 1; + if (countByKey[key] + (namedByKey[key] || 0) > (placesByKey[key] || 0)) { + throw attendeeError(`More names than "${option.name}" tickets.`); + } + return { eventOptionId: a.eventOptionId, variantId: a.variantId || null, name, email, phone }; + }); +} + +/** + * Works out the tickets one registration line should have, given its named attendees. + * Pure — returns operations for the caller to run. + * + * - Every attendee gets a quantity-1 ticket (holderUserId follows the attendee's account). + * - Unnamed places share one quantity ticket; a scanned one is never deleted or shrunk + * below what's been redeemed. + * - Tickets for attendees that no longer exist are removed unless already scanned. + * + * @param {object} line - { quantity } + * @param {Array} attendees - [{ id, userId }] + * @param {Array} tickets - existing [{ id, attendeeId, quantity, holderUserId, usages: [] }] + * @returns {{ create: Array<{attendeeId, holderUserId, quantity}>, update: Array<{id, data}>, remove: string[] }} + */ +function planLineTickets(line, attendees, tickets) { + const scanned = (t) => (t.usages || []).length > 0; + const redeemed = (t) => (t.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 0), 0); + const ops = { create: [], update: [], remove: [] }; + const attendeeIds = new Set(attendees.map(a => a.id)); + + const byAttendee = new Map(); + const orphanNamed = []; + const shared = []; + for (const t of tickets) { + if (t.attendeeId && attendeeIds.has(t.attendeeId)) byAttendee.set(t.attendeeId, t); + else if (t.attendeeId) orphanNamed.push(t); + else shared.push(t); + } + + // Tickets whose attendee was removed: drop unless already used (then keep, as shared). + let keptScannedOrphans = 0; + for (const t of orphanNamed) { + if (scanned(t)) keptScannedOrphans += t.quantity || 1; + else ops.remove.push(t.id); + } + + for (const a of attendees) { + const t = byAttendee.get(a.id); + if (!t) { + ops.create.push({ attendeeId: a.id, holderUserId: a.userId || null, quantity: 1 }); + } else if ((t.holderUserId || null) !== (a.userId || null) || (t.quantity || 1) !== 1) { + ops.update.push({ id: t.id, data: { holderUserId: a.userId || null, ...(scanned(t) ? {} : { quantity: 1 }) } }); + } + } + + // Unnamed places share one ticket. + const remainder = Math.max(0, (line.quantity || 0) - attendees.length - keptScannedOrphans); + const sorted = [...shared].sort((a, b) => (scanned(b) ? 1 : 0) - (scanned(a) ? 1 : 0)); + const [primary, ...extras] = sorted; + if (primary) { + const floor = scanned(primary) ? redeemed(primary) : 0; + if (remainder === 0 && !scanned(primary)) ops.remove.push(primary.id); + else { + const qty = Math.max(remainder, floor); + if (qty !== primary.quantity) ops.update.push({ id: primary.id, data: { quantity: qty } }); + } + } else if (remainder > 0) { + ops.create.push({ attendeeId: null, holderUserId: null, quantity: remainder }); + } + for (const t of extras) if (!scanned(t)) ops.remove.push(t.id); + + return ops; +} + +/** + * Saves new attendees on a registration, linking each to its registration line and to + * a matching account when there is exactly one. + */ +async function createAttendees(registrationId, attendees, db = prisma) { + if (!attendees || attendees.length === 0) return []; + const lines = await db.registrationOption.findMany({ where: { registrationId } }); + const existing = await db.attendee.findMany({ where: { registrationId }, select: { registrationOptionId: true } }); + const orderByLine = {}; + existing.forEach(a => { orderByLine[a.registrationOptionId] = (orderByLine[a.registrationOptionId] || 0) + 1; }); + + const created = []; + for (const a of attendees) { + const line = lines.find(l => l.eventOptionId === a.eventOptionId && (l.variantId || null) === (a.variantId || null)); + const match = (a.email || a.phone) ? await findAccountByContact({ email: a.email, phone: a.phone }, db) : { user: null }; + const order = orderByLine[line?.id] || 0; + orderByLine[line?.id] = order + 1; + created.push(await db.attendee.create({ + data: { + registrationId, + registrationOptionId: line?.id || null, + eventOptionId: a.eventOptionId, + variantId: a.variantId || null, + name: a.name, + email: a.email, + phone: a.phone, + userId: match.user?.id || null, + order, + }, + })); + } + return created; +} + +/** Named places already on a registration, per line key — for validating additions. */ +async function namedPlacesByKey(registrationId, db = prisma) { + const rows = await db.attendee.findMany({ where: { registrationId }, select: { eventOptionId: true, variantId: true } }); + const out = {}; + rows.forEach(r => { const k = lineKey(r.eventOptionId, r.variantId); out[k] = (out[k] || 0) + 1; }); + return out; +} + +module.exports = { lineKey, validateAttendees, planLineTickets, createAttendees, namedPlacesByKey, attendeeError }; diff --git a/backend/src/utils/notifications.js b/backend/src/utils/notifications.js index 032fa62..4686596 100644 --- a/backend/src/utils/notifications.js +++ b/backend/src/utils/notifications.js @@ -103,11 +103,31 @@ async function loadTicketFull(ticketId) { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } }, event: true, - registrationOption: { include: { eventOption: true, variant: true } }, + registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } }, + attendee: true, }, }); } +/** + * Who a ticket's messages go to: a named attendee with contact details when the + * registration sends each person their own tickets, otherwise the payer. + */ +function ticketRecipient(ticket) { + const a = ticket.attendee; + if (a && (a.email || a.phone) && ticket.registrationOption?.registration?.ticketDelivery === 'each') { + const hasEmail = !!(a.email && a.email.includes('@')); + return { + id: a.userId || null, + name: a.name, + email: hasEmail ? a.email : null, + phoneNumber: a.phone || null, + notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp', + }; + } + return ticket.user; +} + async function loadPaymentFull(paymentId) { return prisma.payment.findUnique({ where: { id: paymentId }, @@ -1154,7 +1174,7 @@ async function sendCheckInEmails(ticketId, qtyRedeemed, totalRedeemed, remaining try { const ticket = await loadTicketFull(ticketId); if (!ticket) return; - const user = ticket.user; + const user = ticketRecipient(ticket); const { shouldEmail, waText, waTextAny } = require('./notify'); const { buildWACheckIn } = require('./waMessages'); @@ -1446,7 +1466,44 @@ async function sendDailyEventSummaries(now = new Date()) { } } +// Sent when a payer (or staff) asks for a named attendee to fill in their own form. +// Always tries both channels the attendee has, regardless of anyone's preference. +async function sendAttendeeFormLinkMessage(attendee) { + const org = getOrg(); + const reg = attendee.registration; + const eventTitle = reg?.event?.title || 'the event'; + const link = `${org.url}/forms?registrationId=${encodeURIComponent(attendee.registrationId)}&attendeeId=${encodeURIComponent(attendee.id)}`; + const payer = reg?.user?.name || 'Someone'; + const sent = []; + + if (attendee.email && attendee.email.includes('@')) { + const html = emailWrapper(` +

Please fill in your form

+

Hi ${attendee.name},

+

${payer} registered you for ${eventTitle}. Please fill in your attendee form.

+ ${ctaButton('Fill in my form', link)} + ${fallbackLink(link)} + `, { preheader: `Your attendee form for ${eventTitle}` }); + try { + await sendMail({ to: attendee.email, subject: `Your attendee form for ${eventTitle}`, html, text: `Hi ${attendee.name},\n\n${payer} registered you for ${eventTitle}. Please fill in your attendee form:\n${link}\n\n${org.name}` }); + sent.push('email'); + } catch (e) { + console.warn('[attendee form link] email failed:', e?.message || e); + } + } + if (attendee.phone) { + const { waTextAny } = require('./notify'); + const { isValidZAPhone } = require('./whatsapp'); + if (isValidZAPhone(attendee.phone)) { + await waTextAny({ name: attendee.name, phoneNumber: attendee.phone }, `Hi ${attendee.name}, ${payer} registered you for *${eventTitle}*. Please fill in your attendee form: ${link}`); + sent.push('WhatsApp'); + } + } + return sent; +} + module.exports = { + sendAttendeeFormLinkMessage, sendRegistrationEmails, sendRegistrationUpdatedEmails, sendPaymentEmails, diff --git a/backend/src/utils/ticketUtils.js b/backend/src/utils/ticketUtils.js index 0d22cd5..512e25e 100644 --- a/backend/src/utils/ticketUtils.js +++ b/backend/src/utils/ticketUtils.js @@ -1,11 +1,14 @@ const prisma = require('../config/db'); const { v4: uuidv4 } = require('uuid'); +const { planLineTickets } = require('./attendees'); /** * Generate tickets for a registration when payment status is "paid". * * Rules: - * - Exactly ONE ticket per registrationOption (keyed by eventOptionId). + * - Each named attendee (group tickets) gets their own quantity-1 ticket; the rest of a + * line's places share ONE quantity ticket per registrationOption (see + * utils/attendees.js planLineTickets). * - If duplicate registrationOptions exist for the same eventOptionId (old data), * consolidate them: merge quantities, keep the one with scan history, delete the rest. * - If duplicate ticket records exist for the same registrationOption (old data), @@ -90,55 +93,40 @@ const generateTicketsForRegistration = async (registrationId) => { }); })); - // ── Step 2: For each unique option, ensure exactly one ticket with correct qty ── + // ── Step 2: For each unique option, sync its tickets: one per named attendee, plus + // one shared quantity ticket for any unnamed places ── // Re-read fresh list (some options may have been deleted above) const freshOptions = await prisma.registrationOption.findMany({ where: { registrationId }, include: { - tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } } + tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } }, + attendees: { orderBy: { order: 'asc' } }, } }); // Each option owns disjoint tickets, so resolve them concurrently instead of one at a time. const perOptionResults = await Promise.all(freshOptions.map(async (option) => { - const targetQty = option.quantity || 1; - const existingTickets = option.tickets || []; - - if (existingTickets.length === 0) { - // Create one ticket - return prisma.ticket.create({ - data: { - id: uuidv4(), - qrCode: uuidv4(), - registrationOptionId: option.id, - userId: registration.userId, - eventId: registration.eventId, - quantity: targetQty, - updatedAt: new Date() - } - }); - } - - // Pick primary: prefer scanned, otherwise oldest - const withUsages = existingTickets.filter(t => (t.usages || []).length > 0); - const primary = withUsages.length > 0 ? withUsages[0] : existingTickets[0]; - - const updates = []; - // Update quantity on primary if needed - if (primary.quantity !== targetQty) { - updates.push(prisma.ticket.update({ where: { id: primary.id }, data: { quantity: targetQty, updatedAt: new Date() } })); - } - - // Delete unscanned duplicates - const dups = existingTickets.filter(t => t.id !== primary.id && (t.usages || []).length === 0); - if (dups.length > 0) { - updates.push(prisma.ticket.deleteMany({ where: { id: { in: dups.map(t => t.id) } } })); - } - if (updates.length > 0) await Promise.all(updates); - return null; + const plan = planLineTickets({ quantity: option.quantity || 1 }, option.attendees || [], option.tickets || []); + await Promise.all([ + plan.remove.length ? prisma.ticket.deleteMany({ where: { id: { in: plan.remove } } }) : null, + ...plan.update.map(u => prisma.ticket.update({ where: { id: u.id }, data: { ...u.data, updatedAt: new Date() } })), + ]); + return Promise.all(plan.create.map(c => prisma.ticket.create({ + data: { + id: uuidv4(), + qrCode: uuidv4(), + registrationOptionId: option.id, + userId: registration.userId, + eventId: registration.eventId, + quantity: c.quantity, + attendeeId: c.attendeeId, + holderUserId: c.holderUserId, + updatedAt: new Date() + } + }))); })); - return perOptionResults.filter(Boolean); + return perOptionResults.flat(); } catch (error) { console.error('Error generating tickets:', error); throw error; diff --git a/backend/tests/attendees.test.js b/backend/tests/attendees.test.js new file mode 100644 index 0000000..181afe3 --- /dev/null +++ b/backend/tests/attendees.test.js @@ -0,0 +1,104 @@ +const { validateAttendees, planLineTickets, lineKey } = require('../src/utils/attendees'); + +const EVENT = { + allowNamedAttendees: true, + eventOptions: [ + { id: 'kid', name: 'Camper', isMainTicket: true }, + { id: 'shirt', name: 'T-shirt', isMainTicket: false }, + ], +}; +const places = (n) => ({ [lineKey('kid', null)]: n }); + +describe('validateAttendees', () => { + test('cleans up names and contact details', () => { + const out = validateAttendees(EVENT, places(2), [ + { eventOptionId: 'kid', name: ' Sam ', email: ' ', phone: '0821234567' }, + { eventOptionId: 'kid', name: 'Alex', email: 'alex@example.com' }, + ]); + expect(out).toEqual([ + { eventOptionId: 'kid', variantId: null, name: 'Sam', email: null, phone: '0821234567' }, + { eventOptionId: 'kid', variantId: null, name: 'Alex', email: 'alex@example.com', phone: null }, + ]); + }); + + test('nothing to check when no attendees are given', () => { + expect(validateAttendees({ ...EVENT, allowNamedAttendees: false }, places(1), [])).toEqual([]); + }); + + test('refuses names on events that do not take them', () => { + expect(() => validateAttendees({ ...EVENT, allowNamedAttendees: false }, places(1), [{ eventOptionId: 'kid', name: 'Sam' }])).toThrow(/doesn't take attendee names/); + }); + + test('only main tickets can be named', () => { + expect(() => validateAttendees(EVENT, { [lineKey('shirt', null)]: 1 }, [{ eventOptionId: 'shirt', name: 'Sam' }])).toThrow(/main tickets/); + }); + + test('needs a name and a valid email', () => { + expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: ' ' }])).toThrow(/enter a name/); + expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: 'Sam', email: 'nope' }])).toThrow(/valid email/); + }); + + test('no more names than places, counting names already given', () => { + expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: 'A' }, { eventOptionId: 'kid', name: 'B' }])).toThrow(/More names/); + expect(() => validateAttendees(EVENT, places(3), [{ eventOptionId: 'kid', name: 'C' }], places(2))).not.toThrow(); + expect(() => validateAttendees(EVENT, places(2), [{ eventOptionId: 'kid', name: 'C' }], places(2))).toThrow(/More names/); + }); +}); + +describe('planLineTickets', () => { + const scan = (q = 1) => [{ quantityRedeemed: q }]; + + test('no attendees: one shared ticket for the whole quantity', () => { + expect(planLineTickets({ quantity: 3 }, [], [])).toEqual({ create: [{ attendeeId: null, holderUserId: null, quantity: 3 }], update: [], remove: [] }); + }); + + test('every attendee gets their own ticket; unnamed places share one', () => { + const plan = planLineTickets({ quantity: 3 }, [{ id: 'a1', userId: 'u9' }, { id: 'a2', userId: null }], []); + expect(plan.create).toEqual([ + { attendeeId: 'a1', holderUserId: 'u9', quantity: 1 }, + { attendeeId: 'a2', holderUserId: null, quantity: 1 }, + { attendeeId: null, holderUserId: null, quantity: 1 }, + ]); + }); + + test('naming every place replaces the shared ticket', () => { + const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }, { id: 'a2' }], [{ id: 't0', attendeeId: null, quantity: 2, usages: [] }]); + expect(plan.remove).toEqual(['t0']); + expect(plan.create).toHaveLength(2); + }); + + test('a scanned shared ticket is kept and never shrunk below what was used', () => { + const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }, { id: 'a2' }], [{ id: 't0', attendeeId: null, quantity: 2, usages: scan(1) }]); + expect(plan.remove).toEqual([]); + expect(plan.update).toEqual([{ id: 't0', data: { quantity: 1 } }]); + }); + + test('removing an attendee removes their unscanned ticket', () => { + const plan = planLineTickets({ quantity: 1 }, [{ id: 'a1' }], [ + { id: 't1', attendeeId: 'a1', quantity: 1, usages: [] }, + { id: 't2', attendeeId: 'gone', quantity: 1, usages: [] }, + ]); + expect(plan).toEqual({ create: [], update: [], remove: ['t2'] }); + }); + + test("keeps a scanned ticket whose attendee was removed, and counts it as a used place", () => { + const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }], [ + { id: 't1', attendeeId: 'a1', quantity: 1, usages: [] }, + { id: 't2', attendeeId: 'gone', quantity: 1, usages: scan() }, + ]); + expect(plan).toEqual({ create: [], update: [], remove: [] }); + }); + + test('follows the attendee to a newly linked account', () => { + const plan = planLineTickets({ quantity: 1 }, [{ id: 'a1', userId: 'u2' }], [{ id: 't1', attendeeId: 'a1', quantity: 1, holderUserId: null, usages: [] }]); + expect(plan.update).toEqual([{ id: 't1', data: { holderUserId: 'u2', quantity: 1 } }]); + }); + + test('existing single shared ticket is resized, duplicates dropped', () => { + const plan = planLineTickets({ quantity: 4 }, [], [ + { id: 't1', attendeeId: null, quantity: 2, usages: [] }, + { id: 't2', attendeeId: null, quantity: 2, usages: [] }, + ]); + expect(plan).toEqual({ create: [], update: [{ id: 't1', data: { quantity: 4 } }], remove: ['t2'] }); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d32f4b9..3d6eb36 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hope-events-frontend", - "version": "1.13.0", + "version": "1.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hope-events-frontend", - "version": "1.13.0", + "version": "1.14.0", "dependencies": { "@hookform/resolvers": "^5.2.1", "@radix-ui/react-accordion": "^1.2.11", diff --git a/frontend/package.json b/frontend/package.json index 3780e35..3a09d70 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hope-events-frontend", - "version": "1.13.0", + "version": "1.14.0", "private": true, "scripts": { "dev": "next dev --turbopack", diff --git a/frontend/src/app/dashboard/staff/event-tickets/page.tsx b/frontend/src/app/dashboard/staff/event-tickets/page.tsx index 9d4ab47..eb2d2a2 100644 --- a/frontend/src/app/dashboard/staff/event-tickets/page.tsx +++ b/frontend/src/app/dashboard/staff/event-tickets/page.tsx @@ -145,7 +145,7 @@ function EventTicketsContent() { const optVariant = t.registrationOption?.variant?.name; const type = optVariant ? `${optBase} — ${optVariant}` : optBase; const qty = t.quantity || 1; - const holder = t.user?.name || t.userId || ""; + const holder = t.attendee?.name ? `${t.attendee.name}${t.user?.name ? ` (booked by ${t.user.name})` : ""}` : (t.user?.name || t.userId || ""); const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&data=${encodeURIComponent( t.qrCode || t.id )}`; diff --git a/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx b/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx index 6d3aa47..8fd390c 100644 --- a/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx +++ b/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx @@ -375,7 +375,10 @@ export default function TicketScanningPage() {
Ticket type: {confirmModal.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{confirmModal.ticket?.registrationOption?.variant?.name ? ` — ${confirmModal.ticket.registrationOption.variant.name}` : ''}
Event: {confirmModal.ticket?.event?.title || '—'}
-
Holder: {confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}
+
Holder: {confirmModal.ticket?.attendee?.name || confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}
+ {confirmModal.ticket?.attendee?.name && confirmModal.ticket?.user?.name && ( +
Booked by {confirmModal.ticket.user.name}
+ )}
Qty on ticket: {confirmModal.ticket?.quantity || 1}
Remaining: {confirmModal.remaining}
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 c0d8807..8fa98eb 100644 --- a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx +++ b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx @@ -7,6 +7,7 @@ import { apiFetch } from "@/lib/api"; import { scoreUser } from "@/lib/fuzzyMatch"; import { useDismissingState } from "@/hooks/useDismissingState"; import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField"; +import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload } from "@/components/shared/AttendeeNamesEditor"; import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react"; type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund"; @@ -102,6 +103,10 @@ export default function AtTheDoorPage() { const [showOptionsModal, setShowOptionsModal] = useState(false); const [pendingUser, setPendingUser] = useState(null); const [doorCoupon, setDoorCoupon] = useState(null); + const [doorAllowsNames, setDoorAllowsNames] = useState(false); + const [doorNaming, setDoorNaming] = useState(false); + const [doorEntries, setDoorEntries] = useState({}); + const [doorDelivery, setDoorDelivery] = useState("payer"); const [pendingEditReg, setPendingEditReg] = useState(null); const [confirming, setConfirming] = useState(false); const [quantities, setQuantities] = useState>({}); @@ -155,6 +160,7 @@ export default function AtTheDoorPage() { }); setEventOptions(ev.options || ev.eventOptions || []); + setDoorAllowsNames(!!ev.allowNamedAttendees); } catch { setEventOptions([]); } @@ -266,6 +272,9 @@ export default function AtTheDoorPage() { options: opts, notificationPreference: pendingUser.notifPref, ...(doorCoupon ? { couponCode: doorCoupon.code } : {}), + ...(doorAllowsNames && doorNaming && attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries).length > 0 + ? { attendees: attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries), ticketDelivery: doorDelivery } + : {}), } }); setShowOptionsModal(false); @@ -302,6 +311,9 @@ export default function AtTheDoorPage() { setPendingUser({ name, email: email || null, phone: phone || null, notifPref }); setPendingEditReg(null); setDoorCoupon(null); + setDoorNaming(false); + setDoorEntries({}); + setDoorDelivery("payer"); setShowNewAttendeeModal(false); setShowOptionsModal(true); }; @@ -489,6 +501,11 @@ export default function AtTheDoorPage() { setCoupon={setDoorCoupon} couponForUserId={pendingEditReg ? pendingEditReg.userId : null} existingCode={(pendingEditReg?.couponRedemptions || [])[0]?.coupon?.code || null} + names={doorAllowsNames && !pendingEditReg ? { + enabled: doorNaming, onEnabledChange: setDoorNaming, + entries: doorEntries, onEntriesChange: setDoorEntries, + delivery: doorDelivery, onDeliveryChange: setDoorDelivery, + } : null} /> ) @@ -1287,6 +1304,16 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu One or more items are below the quantity already issued as tickets. Please increase the selection. )} + {names && ( +
+ quantities[v ? `${o}::${v}` : o] || 0} + {...names} + payerLabel="the person registering" + /> +
+ )} {existingCode ? (
Code {existingCode} is applied to this registration.
) : ( diff --git a/frontend/src/app/dashboard/supervisor/events/page.tsx b/frontend/src/app/dashboard/supervisor/events/page.tsx index 16770dd..093f107 100644 --- a/frontend/src/app/dashboard/supervisor/events/page.tsx +++ b/frontend/src/app/dashboard/supervisor/events/page.tsx @@ -769,7 +769,7 @@ interface EventDraft { registrationDeadline: string; goLiveAt: string; price: string; picture: string; redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean; requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string; - location: string; + location: string; allowNamedAttendees: boolean; } const blankDraft = (location = ""): EventDraft => ({ @@ -777,7 +777,7 @@ const blankDraft = (location = ""): EventDraft => ({ registrationDeadline: "", goLiveAt: "", price: "", picture: "", redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true, requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "", - location, + location, allowNamedAttendees: false, }); const blankOptions = (): OptionDraft[] => [ @@ -812,6 +812,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "", isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false, requiresRegistration: ev.requiresRegistration !== false, + allowNamedAttendees: !!ev.allowNamedAttendees, contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "", location: ev.location || "", } : blankDraft(settings.org_address || "")); @@ -1085,6 +1086,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { picture: draft.picture || undefined, isHidden: draft.isHidden, requiresAuth: draft.requiresAuth, redirectUrl: draft.redirectUrl?.trim().replace(/\s+/g, "-") || undefined, requiresRegistration: draft.requiresRegistration, + allowNamedAttendees: draft.allowNamedAttendees, contactName: draft.contactName || undefined, contactPhone: draft.contactPhone || undefined, contactEmail: draft.contactEmail || undefined, @@ -1339,6 +1341,10 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { upd({ requiresAuth: !e.target.checked })} /> Allow guest registration — attendees can register without creating an account + {mode === "edit" && (