Compare commits

..
Author SHA1 Message Date
joshua 3693d5f2c8 Merge branch 'fix/door-tickets-corrections' 2026-11-02 12:24:00 +02:00
joshuaandClaude Opus 5.5 62b10c3db5 Fix: door and manual registrations get their tickets; admin Corrections (1.52.0)
On events with a required attendee form, tickets waited for the forms, but
At the door and Manual registration never asked for them: payment was
recorded and nobody could be checked in.

- The form pops up at the door and in Manual registration; "Fill in later"
  (any staff) issues the tickets once paid and sends the payer a link
  (Registration.formWaivedAt, POST /api/v2/registrations/{id}/form-later).
- One form per person on picked-session bookings; a required form with no
  questions no longer holds tickets back; every caller asks the ticket
  generator (formsOutstanding).
- Kiosk asks for the forms the server says are missing; headings show as text.
- Admin Corrections page and /api/v2/corrections: issue missing tickets,
  pay later (ticketsReleasedAt), make it free (complimentaryDiscount),
  check in or undo after the event.
- Migration 20261102120000_corrections; unit and e2e tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:01:10 +02:00
joshua 7ce25bf40b Merge branch 'docs/handover-roadmap-tidy' 2026-10-30 09:00:49 +02:00
43 changed files with 3940 additions and 95 deletions
+47
View File
@@ -7,6 +7,53 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.52.0] - 2026-11-02 — urgent
### Fixed
- **Paid door and manual registrations no longer end up without tickets.** On an event with a
required attendee form, tickets wait until a form is in for each person — but At the door and
Manual registration never asked for the form, so payment was recorded and nobody could be checked
in. The form now pops up when someone is registered, selected or pays at the door (and after
registering or paying in Manual registration): fill it in, or **Fill in later**, which issues the
tickets once it's paid and sends the payer a link to the form (`POST
/api/v2/registrations/{id}/form-later`, staff; `Registration.formWaivedAt`). Closing the popup
counts as Fill in later. The door's check-in panel says when a form is holding tickets back.
- **Picked-session bookings needed a form per session.** A person booked on three sessions needed
three forms before their tickets were issued; each person now needs one (`formPlaces` in
`utils/ticketUtils.js`).
- **A required form with only statements and headings held tickets back for ever** (nothing to
answer). Only a required form with a question counts now (`formNeedsAnswers`). Every place that
decided this on its own (coupon making it free, manual and online registration) now asks the
ticket generator (`formsOutstanding`).
- **Self-service kiosk:** it asked for one form per main ticket picked, so a visitor adding to an
existing registration (or booking picked sessions) could finish with the forms still short and
no tickets. It now asks for the forms the server says are missing (`formsNeeded` on `POST
/api/v2/registrations/manual`). Headings on the form showed as an empty text box; they're shown
as text now, with the statements.
- Recording a payment answers `formsNeeded` when a form still holds the tickets back.
### Added
- **Corrections** (admins, `/dashboard/admin/corrections`): every registration at an event —
past and closed ones too — with what needs putting right. Issue all missing tickets at once
(forms left for later); fill in a form; issue tickets before payment ("pay later", the balance
is still owed — `Registration.ticketsReleasedAt`); make it free (the balance is written off as a
complimentary discount, `Registration.complimentaryDiscount`, part of `discountTotal`, shown as
"Complimentary" on invoices and receipts; not on closed events); and check a ticket in or undo it
after the event, for any session (a manual check-in, no message sent). Sending the tickets is a
choice. `/api/v2/corrections/…` (admin): `GET events/{id}`, `POST events/{id}/issue-missing`,
`POST registrations/{id}/tickets`, `POST registrations/{id}/complimentary`,
`POST tickets/{id}/check-in`. Code in `src/services/corrections.ts`.
- Audit actions `attendee_form_waived`, `tickets_issued_override`, `registration_complimentary`
and `checkin_corrected`. Migration `20261102120000_corrections`.
### Upgrading
- Update before your next event with a required attendee form. After updating, open Admin →
Corrections for a past event to issue any tickets that went missing (leave "Send them" off if
the event is over).
## [1.51.17] - 2026-10-30 — security release
### Security
+15 -1
View File
@@ -327,6 +327,12 @@ Feedback → Attendance**.
Date, plus Statement and Heading blocks; required/optional, help text,
reordering. The whole form can be marked required.
- **Drafts** — partially filled forms are auto-saved.
- **Required forms and tickets** — a required form (with at least one question)
holds a registration's tickets back until a form is in for each person (a
picked-session booking counts each person once). At the door and in Manual
registration the form pops up to fill in with the attendee; **Fill in later**
(any staff) issues the tickets anyway once it's paid and sends the payer a link
to the form. The kiosk asks the visitor for exactly the forms still missing.
- **Responses** — view, filter (event, user, past/inactive) and print one
response per A4 page. Supervisors can edit responses.
@@ -492,6 +498,14 @@ logout controls. Disconnected sessions are recovered automatically.
current filters, apply one with a click, star one as the default for the page
(not applied over a deep link). Per person, on the server.
- **Cashup** — see below.
- **Corrections** (`/dashboard/admin/corrections`, admins only) — for any event,
past and closed ones too: registrations missing tickets, with forms to fill in,
or owing money. **Issue all missing tickets** at once (forms left for later),
fill in a form, **issue tickets — pay later** (balance still owed), **make it
free** (the balance written off as a complimentary discount, so income stays
what was paid; not on closed events), and **check people in (or undo) after the
event** for any session. Optionally send the tickets. Every correction is in
the audit log with the reason given.
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
refunds, donation assign/unassign, manual registrations, staff
cancellations, event create/update/delete, settings changes, and coupon codes
@@ -938,4 +952,4 @@ can't start, the same jobs run on in-process timers.
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/kids`, `/serving`, `/reset-password`) | User | My Events |
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`, `/my-groups`, `/kids`, `/kids/rooms`, `/volunteers`) | Staff | Staff tools |
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/kids`, `/volunteers`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/corrections`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
+15
View File
@@ -12,6 +12,21 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 1.52.0 — urgent
- **Paying at the door always gives people their tickets now.** On events with a required
attendee form, people registered at the door or in Manual registration were paid but got no
tickets, so they couldn't be checked in. The form now pops up to fill in with them — or tap
**Fill in later**: the tickets are issued straight away and they're sent a link to fill in the
form afterwards.
- **New: Corrections (for admins).** Pick any event, even one that's over, to see who's missing
tickets, still has a form to fill in or owes money. Issue all missing tickets in one click, give
someone their tickets now and let them pay later, make a ticket free, or check people in after
the event.
- The self-service kiosk asks for exactly the forms still needed, and shows headings on the form
as text instead of an empty box.
- People booked on several sessions now fill in one form each, not one per session.
## 1.51.17 — security update
- **Better protection for people's accounts.** The forgot-password and sign-in pages no longer
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.51.17",
"version": "1.52.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.51.17",
"version": "1.52.0",
"hasInstallScript": true,
"dependencies": {
"@cj-tech-master/excelts": "^10.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.51.17",
"version": "1.52.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,12 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'attendee_form_waived';
ALTER TYPE "AdminAuditAction" ADD VALUE 'tickets_issued_override';
ALTER TYPE "AdminAuditAction" ADD VALUE 'registration_complimentary';
ALTER TYPE "AdminAuditAction" ADD VALUE 'checkin_corrected';
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "formWaivedAt" TIMESTAMP(3),
ADD COLUMN "formWaivedById" TEXT,
ADD COLUMN "ticketsReleasedAt" TIMESTAMP(3),
ADD COLUMN "ticketsReleasedById" TEXT,
ADD COLUMN "complimentaryDiscount" DOUBLE PRECISION NOT NULL DEFAULT 0;
+13
View File
@@ -505,6 +505,15 @@ model Registration {
bundleDiscount Float @default(0)
// Where named attendees' tickets go: all to the payer, or each to the attendee.
ticketDelivery TicketDelivery @default(payer)
// Corrections (1.52.0). The attendee form can come later: tickets don't wait for it ("Fill in
// later" at the door or in Manual registrations, or an admin in Corrections).
formWaivedAt DateTime?
formWaivedById String?
// An admin issued the tickets before it was paid ("pay later"): the balance is still owed.
ticketsReleasedAt DateTime?
ticketsReleasedById String?
// A balance an admin wrote off ("make it free"); part of discountTotal (utils/coupons.js).
complimentaryDiscount Float @default(0)
analyticsSessionId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -778,6 +787,10 @@ enum AdminAuditAction {
event_sessions_changed
attendance_marked
attendance_unmarked
attendee_form_waived
tickets_issued_override
registration_complimentary
checkin_corrected
certificate_issued
certificate_revoked
job_run_manual
+6 -2
View File
@@ -1,6 +1,6 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { generateTicketsForRegistration, formsOutstanding } = require('../utils/ticketUtils');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
const { computeDonationRemaining } = require('../utils/donationUtils');
const { logAdminAction } = require('../utils/adminAudit');
@@ -238,7 +238,9 @@ const createPayment = async (req, res) => {
// If it's a registration payment, update registration status
let updatedRegistration = null;
let generatedTickets = [];
// Attendee forms still holding the tickets back (the door offers them, or "Fill in later").
let formsNeeded = 0;
if (registrationId) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
@@ -284,6 +286,7 @@ const createPayment = async (req, res) => {
} catch (error) {
console.error('Error generating tickets:', error);
}
formsNeeded = await formsOutstanding(registrationId);
}
}
@@ -318,6 +321,7 @@ const createPayment = async (req, res) => {
...updatedRegistration
},
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined,
formsNeeded,
donationSplit: donationSplit || undefined
};
res.status(201).json(paymentWithUpdatedRegistration);
+345
View File
@@ -0,0 +1,345 @@
/**
* Corrections (1.52.0): putting registrations right when the normal flow didn't finish — tickets
* held back by a required attendee form, tickets needed before it's paid, a balance to write off,
* and check-ins after the event. Admins only, from Corrections; "Fill in later" (leaveFormForLater)
* is for any staff at the door and in Manual registrations.
*
* Every change is in the audit log with who did it and the reason given.
*/
import { prisma as typedPrisma } from '../v2/legacy';
import { fail } from './errors';
/* eslint-disable @typescript-eslint/no-explicit-any -- the records are Prisma's with includes */
const prisma = typedPrisma as any;
const { generateTicketsForRegistration, formsOutstanding, formPlaces, formNeedsAnswers } = require('../utils/ticketUtils') as {
generateTicketsForRegistration(id: string): Promise<any[]>;
formsOutstanding(registration: string | object): Promise<number>;
formPlaces(registrationOptions: any[]): number;
formNeedsAnswers(form: any): boolean;
};
const { computeRegistrationTotalDue } = require('../utils/pricing') as { computeRegistrationTotalDue(registration: any, at: Date): number };
const { recalculateRegistrationDiscount } = require('../utils/coupons') as { recalculateRegistrationDiscount(id: string): Promise<number> };
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
const sessions = require('../utils/sessions') as {
activeSessions(sessions: any[]): any[];
sessionLabel(session: any, sessions: any[]): string;
lineCovers(line: any, sessionId: string): boolean;
lineSessions(line: any): string[] | null;
redeemedForSession(usages: any[], sessionId: string | null): number;
};
export type Actor = { id: string; role: string };
type Audit = { actor: Actor; ip?: string };
/** The key a single-date event's check-ins are counted under. */
export const WHOLE_EVENT = 'event';
const round2 = (n: number) => Math.round(n * 100) / 100;
const realEmail = (email: string | null | undefined) => (email && !email.endsWith('@guest.local') ? email : null);
const REGISTRATION_INCLUDE = {
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
payments: { select: { amount: true, createdAt: true } },
formResponses: { select: { id: true } },
attendees: { select: { id: true, name: true } },
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { name: true } },
tranches: true,
tickets: {
include: { usages: { select: { quantityRedeemed: true, sessionId: true } }, attendee: { select: { name: true } }, holder: { select: { name: true } } },
orderBy: { createdAt: 'asc' },
},
},
},
};
async function emailTicketsTo(registrationId: string, userId: string) {
const { emailTickets } = require('../controllers/ticketController') as { emailTickets(req: any, res: any): Promise<unknown> };
const res: any = { status: () => res, json: () => {} };
try {
await emailTickets({ user: { id: userId }, body: { registrationId } }, res);
} catch (e: any) {
console.error('[corrections] Failed to send tickets:', e?.message || e);
}
}
async function loadEvent(eventId: string) {
const event = await prisma.event.findUnique({
where: { id: eventId },
select: {
id: true, title: true, startDate: true, endDate: true, scheduleType: true, cashupStatus: true,
sessions: { select: { id: true, label: true, startsAt: true, endsAt: true, cancelledAt: true } },
form: { include: { fields: { select: { type: true } } } },
},
});
return event || fail(404, 'Event not found');
}
/** One registration as Corrections shows it: money, forms, tickets and their check-ins. */
function correctionRow(reg: any, event: any, formRequired: boolean) {
const now = new Date();
const totalDue = round2(computeRegistrationTotalDue(reg, now));
const paid = round2((reg.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0));
const active = sessions.activeSessions(event.sessions || []);
const multi = event.scheduleType === 'sessions';
let places = 0;
let ticketPlaces = 0;
const tickets: any[] = [];
for (const ro of reg.registrationOptions || []) {
places += ro.quantity || 0;
const session = ro.sessionId ? active.find((s: any) => s.id === ro.sessionId) : null;
const label = [ro.eventOption?.name, ro.variant?.name].filter(Boolean).join(' — ')
+ (session ? ` (${sessions.sessionLabel(session, event.sessions)})` : '');
for (const t of ro.tickets || []) {
ticketPlaces += t.quantity || 0;
const covered = multi ? (sessions.lineSessions(ro) ?? active.map((s: any) => s.id)).filter((id: string) => active.some((s: any) => s.id === id)) : [WHOLE_EVENT];
const checkedIn: Record<string, number> = {};
for (const key of covered) checkedIn[key] = sessions.redeemedForSession(t.usages, multi ? key : null);
tickets.push({
id: t.id,
qrCode: t.qrCode,
label,
isMainTicket: !!ro.eventOption?.isMainTicket,
name: t.attendee?.name || t.holder?.name || reg.user?.name || '',
quantity: t.quantity || 1,
sessionIds: covered,
checkedIn,
});
}
}
const ticketsDue = reg.status === 'paid' || !!reg.ticketsReleasedAt;
const formsNeeded = formRequired && !reg.formWaivedAt ? Math.max(0, formPlaces(reg.registrationOptions) - (reg.formResponses || []).length) : 0;
return {
id: reg.id,
status: reg.status,
createdAt: reg.createdAt,
user: { id: reg.user?.id, name: reg.user?.name || '', email: realEmail(reg.user?.email), phoneNumber: reg.user?.phoneNumber || null },
totalDue,
paid,
balance: round2(Math.max(0, totalDue - paid)),
places,
ticketPlaces,
missingTickets: ticketsDue && ticketPlaces < places,
formsNeeded,
formWaived: !!reg.formWaivedAt,
ticketsReleased: !!reg.ticketsReleasedAt,
complimentary: Number(reg.complimentaryDiscount || 0),
tickets,
};
}
function eventSummary(event: any) {
const active = sessions.activeSessions(event.sessions || []);
return {
id: event.id,
title: event.title,
startDate: event.startDate,
endDate: event.endDate,
closed: event.cashupStatus === 'closed',
formRequired: formNeedsAnswers(event.form),
sessions: event.scheduleType === 'sessions'
? active.map((s: any) => ({ id: s.id, label: sessions.sessionLabel(s, event.sessions), startsAt: s.startsAt }))
: [],
};
}
/** An event's registrations (not cancelled) with what may need putting right. */
export async function eventCorrections(eventId: string) {
const event = await loadEvent(eventId);
const formRequired = formNeedsAnswers(event.form);
const regs = await prisma.registration.findMany({
where: { eventId, status: { not: 'cancelled' } },
include: REGISTRATION_INCLUDE,
orderBy: { createdAt: 'asc' },
});
return { event: eventSummary(event), registrations: regs.map((r: any) => correctionRow(r, event, formRequired)) };
}
async function registrationRow(registrationId: string) {
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
if (!reg) return fail(404, 'Registration not found');
const event = await loadEvent(reg.eventId);
return correctionRow(reg, event, formNeedsAnswers(event.form));
}
async function activeRegistration(registrationId: string) {
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { registrationOptions: { include: { eventOption: true } } } });
if (!reg) return fail(404, 'Registration not found');
if (reg.status === 'cancelled') return fail(400, 'This registration is cancelled.');
return reg;
}
async function waiveForm(reg: any, actorId: string) {
if (reg.formWaivedAt || (await formsOutstanding(reg)) === 0) return false;
await prisma.registration.update({ where: { id: reg.id }, data: { formWaivedAt: new Date(), formWaivedById: actorId, updatedAt: new Date() } });
return true;
}
/**
* "Fill in later" at the door / in Manual registrations: the tickets stop waiting for the
* attendee form, and the payer is sent a link to fill it in (`notify`).
*/
export async function leaveFormForLater(registrationId: string, { actor, ip }: Audit, { notify = true }: { notify?: boolean } = {}) {
const reg = await activeRegistration(registrationId);
const waived = await waiveForm(reg, actor.id);
const created = await generateTicketsForRegistration(reg.id);
if (created.length > 0) await emailTicketsTo(reg.id, reg.userId);
let sentTo: string[] = [];
if (waived) {
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'attendee_form_waived', targetType: 'Registration', targetId: reg.id, metadata: { eventId: reg.eventId, from: 'door' }, ip });
if (notify) {
const { sendFormReminderMessage } = require('../utils/notifications') as { sendFormReminderMessage(id: string): Promise<string[]> };
sentTo = await sendFormReminderMessage(reg.id).catch(() => []);
}
}
return { ticketsIssued: created.length, sentTo };
}
type IssueOptions = { skipForm?: boolean; payLater?: boolean; send?: boolean; reason?: string | null };
/**
* Admin: issue a registration's tickets now — leaving the attendee form for later (`skipForm`)
* and/or before it's paid (`payLater`, the balance is still owed).
*/
export async function issueTickets(registrationId: string, { actor, ip }: Audit, opts: IssueOptions) {
const reg = await activeRegistration(registrationId);
if (reg.status !== 'paid' && !reg.ticketsReleasedAt && !opts.payLater) {
return fail(400, 'This registration isn\'t paid yet. Choose "pay later" or "make it free" to issue its tickets.');
}
const outstanding = opts.skipForm ? 0 : await formsOutstanding(reg);
if (outstanding > 0) {
return fail(400, `${outstanding} attendee form${outstanding === 1 ? ' is' : 's are'} still to fill in. Fill ${outstanding === 1 ? 'it' : 'them'} in, or choose to leave the form for later.`);
}
const waived = opts.skipForm ? await waiveForm(reg, actor.id) : false;
const released = !!opts.payLater && reg.status !== 'paid' && !reg.ticketsReleasedAt;
if (released) {
await prisma.registration.update({ where: { id: reg.id }, data: { ticketsReleasedAt: new Date(), ticketsReleasedById: actor.id, updatedAt: new Date() } });
}
const created = await generateTicketsForRegistration(reg.id);
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'tickets_issued_override', targetType: 'Registration', targetId: reg.id,
metadata: { eventId: reg.eventId, formLeftForLater: waived, payLater: released, ticketsCreated: created.length, reason: opts.reason || null },
ip,
});
if (opts.send && created.length > 0) await emailTicketsTo(reg.id, reg.userId);
if (waived) logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'attendee_form_waived', targetType: 'Registration', targetId: reg.id, metadata: { eventId: reg.eventId, from: 'corrections', reason: opts.reason || null }, ip });
return { ticketsIssued: created.length, registration: await registrationRow(reg.id) };
}
/**
* Admin: write off what's still owed ("make it free") — it comes off as a complimentary
* discount, so income stays what was really paid — and issue the tickets.
*/
export async function makeComplimentary(registrationId: string, { actor, ip }: Audit, opts: Omit<IssueOptions, 'payLater'>) {
const reg = await activeRegistration(registrationId);
try {
await assertEventOpen(reg.eventId);
} catch (e: any) {
return fail(400, e?.message || 'This event is closed.');
}
const full = await prisma.registration.findUnique({
where: { id: reg.id },
include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true },
});
const due = computeRegistrationTotalDue(full, new Date());
const paid = (full.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
const balance = round2(due - paid);
if (!(balance > 0.004)) return fail(400, 'Nothing is owed on this registration.');
await prisma.registration.update({ where: { id: reg.id }, data: { complimentaryDiscount: round2(Number(full.complimentaryDiscount || 0) + balance), updatedAt: new Date() } });
await recalculateRegistrationDiscount(reg.id);
await prisma.registration.update({ where: { id: reg.id }, data: { status: 'paid', updatedAt: new Date() } });
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'registration_complimentary', targetType: 'Registration', targetId: reg.id,
metadata: { eventId: reg.eventId, amount: balance, reason: opts.reason || null }, ip,
});
const waived = opts.skipForm ? await waiveForm(reg, actor.id) : false;
if (waived) logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'attendee_form_waived', targetType: 'Registration', targetId: reg.id, metadata: { eventId: reg.eventId, from: 'corrections', reason: opts.reason || null }, ip });
const created = await generateTicketsForRegistration(reg.id);
if (opts.send && created.length > 0) await emailTicketsTo(reg.id, reg.userId);
return { amount: balance, ticketsIssued: created.length, registration: await registrationRow(reg.id) };
}
/**
* Admin: every paid registration (or one released unpaid) at an event that's missing tickets
* gets them — forms still outstanding are left for later.
*/
export async function issueMissingTickets(eventId: string, { actor, ip }: Audit, { send = false }: { send?: boolean } = {}) {
const { registrations } = await eventCorrections(eventId);
const missing = registrations.filter((r: any) => r.missingTickets);
let tickets = 0;
let fixed = 0;
const failed: { id: string; name: string; message: string }[] = [];
for (const row of missing) {
try {
const reg = await activeRegistration(row.id);
await waiveForm(reg, actor.id);
const created = await generateTicketsForRegistration(reg.id);
tickets += created.length;
if (created.length > 0) fixed += 1;
if (send && created.length > 0) await emailTicketsTo(reg.id, reg.userId);
} catch (e: any) {
failed.push({ id: row.id, name: row.user.name, message: e?.message || 'Failed' });
}
}
if (missing.length > 0) {
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'tickets_issued_override', targetType: 'Event', targetId: eventId,
metadata: { registrations: fixed, ticketsCreated: tickets, formLeftForLater: true, sent: send, failed: failed.length }, ip,
});
}
return { registrations: fixed, tickets, failed };
}
type CheckInOptions = { sessionId?: string | null; people?: number | null; present?: boolean; reason?: string | null };
/**
* Admin: check a ticket in (or undo it) whatever the time — after the event or session has
* ended, outside the check-in window, or once the event is closed. Recorded as a manual
* check-in; no check-in message is sent.
*/
export async function correctCheckIn(ticketId: string, { actor, ip }: Audit, opts: CheckInOptions) {
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: { usages: true, event: { select: { id: true, scheduleType: true } }, registrationOption: { select: { sessionId: true, sessionIds: true, registration: { select: { status: true } } } } },
});
if (!ticket) return fail(404, 'Ticket not found');
if (ticket.registrationOption?.registration?.status === 'cancelled') return fail(400, 'This registration is cancelled.');
const multi = ticket.event.scheduleType === 'sessions';
let sessionId: string | null = null;
if (multi) {
const session = opts.sessionId ? await prisma.eventSession.findUnique({ where: { id: opts.sessionId } }) : null;
if (!session || session.eventId !== ticket.eventId) return fail(400, 'Choose one of this event\'s sessions.');
if (session.cancelledAt) return fail(400, 'That session was cancelled.');
if (ticket.registrationOption && !sessions.lineCovers(ticket.registrationOption, session.id)) return fail(400, 'That ticket isn\'t booked for this session.');
sessionId = session.id;
}
const forSession = ticket.usages.filter((u: any) => (u.sessionId || null) === sessionId);
const redeemed = sessions.redeemedForSession(ticket.usages, sessionId);
const quantity = ticket.quantity || 1;
let changed = 0;
if (opts.present === false) {
if (forSession.length === 0) return fail(400, 'This ticket isn\'t checked in.');
await prisma.ticketUsage.deleteMany({ where: { id: { in: forSession.map((u: any) => u.id) } } });
if (!multi) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: false, updatedAt: new Date() } });
changed = redeemed;
} else {
const left = quantity - redeemed;
if (left <= 0) return fail(400, 'Everyone on this ticket is already checked in.');
const people = Math.min(Math.max(1, Math.trunc(Number(opts.people)) || left), left);
await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: actor.id, quantityRedeemed: people, sessionId, manual: true } });
if (!multi && redeemed + people >= quantity) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: true, updatedAt: new Date() } });
changed = people;
}
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'checkin_corrected', targetType: 'Ticket', targetId: ticket.id,
metadata: { eventId: ticket.eventId, sessionId, present: opts.present !== false, people: changed, reason: opts.reason || null }, ip,
});
const after = await prisma.ticketUsage.findMany({ where: { ticketId: ticket.id }, select: { quantityRedeemed: true, sessionId: true } });
return { checkedIn: sessions.redeemedForSession(after, sessionId), quantity };
}
+18 -20
View File
@@ -18,7 +18,10 @@ import {
/* eslint-disable @typescript-eslint/no-explicit-any -- moved from JavaScript; the records are Prisma's with includes */
// The queries below build their includes and data step by step, as the JavaScript did.
const prisma = typedPrisma as any;
const { generateTicketsForRegistration } = require('../utils/ticketUtils') as { generateTicketsForRegistration(id: string): Promise<any[]> };
const { generateTicketsForRegistration, formsOutstanding } = require('../utils/ticketUtils') as {
generateTicketsForRegistration(id: string): Promise<any[]>;
formsOutstanding(registration: string | object): Promise<number>;
};
const { emailTickets } = require('../controllers/ticketController') as { emailTickets(req: any, res: any): Promise<unknown> };
const { hashPassword } = require('../config/auth') as { hashPassword(p: string): Promise<string> };
const { computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing') as {
@@ -111,13 +114,9 @@ async function syncStatusAfterDiscount(registrationId: string) {
if (status === 'paid' && reg.status !== 'paid') {
(async () => {
try {
let hasRequiredForm = false;
try {
const form = await prisma.eventForm.findUnique({ where: { eventId: reg.eventId } });
hasRequiredForm = !!(form && form.isRequired);
} catch {}
if (hasRequiredForm) return;
await generateTicketsForRegistration(registrationId);
// Waits while a required attendee form is outstanding (see utils/ticketUtils).
const created = await generateTicketsForRegistration(registrationId);
if (created.length === 0) return;
const mockReq = { user: { id: reg.userId }, body: { registrationId } };
const mockRes = { status: () => mockRes, json: () => {} };
await emailTickets(mockReq, mockRes);
@@ -840,13 +839,6 @@ export const createManualRegistration = as400(async (call: Call): Promise<any> =
});
}
// Auto mark paid and generate tickets if free and no required form (new registrations only)
let hasRequiredForm = false;
try {
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } });
hasRequiredForm = !!(form && form.isRequired);
} catch {}
// Auto-mark paid and generate tickets for free registrations (new OR existing that became free)
if (registration.status !== 'paid') try {
const totalDue = computeRegistrationTotalDue(registration, new Date());
@@ -856,7 +848,12 @@ export const createManualRegistration = as400(async (call: Call): Promise<any> =
}
} catch {}
// Generate/sync tickets for all paid registrations that have no required form
// Attendee forms still to fill in (a required form holds the tickets back until they're in,
// or until staff choose "Fill in later" — POST /registrations/:id/form-later).
const formsNeeded = await formsOutstanding(registration.id);
const hasRequiredForm = formsNeeded > 0;
// Generate/sync tickets for all paid registrations that aren't waiting for a form
if (registration.status === 'paid' && !hasRequiredForm) try {
await generateTicketsForRegistration(registration.id);
} catch {}
@@ -912,7 +909,7 @@ export const createManualRegistration = as400(async (call: Call): Promise<any> =
ip: call.ip,
});
return { ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) };
return { ...attachComputedTotals(registration), formsNeeded, ...(couponError ? { couponError } : {}) };
});
// @desc Update registration options (add/remove items) with constraints
@@ -1359,11 +1356,12 @@ export const submitFormResponses = as400(async (call: Call): Promise<any> => {
created.push(fr);
}
// After submitting responses, if registration is already paid, generate and email tickets now.
// After submitting responses, if registration is already paid (or its tickets were released
// unpaid), generate and email tickets now.
let generatedTickets = [];
try {
const regAfter = await prisma.registration.findUnique({ where: { id: registrationId } });
if (regAfter && regAfter.status === 'paid') {
if (regAfter && (regAfter.status === 'paid' || regAfter.ticketsReleasedAt)) {
generatedTickets = await generateTicketsForRegistration(registrationId);
if (generatedTickets && generatedTickets.length > 0) {
try {
@@ -1449,7 +1447,7 @@ export const replaceFormResponses = as400(async (call: Call): Promise<any> => {
// Attempt ticket generation and email if already paid
try {
const regAfter = await prisma.registration.findUnique({ where: { id: registrationId }, include: { user: { select: { id: true } } } });
if (regAfter && regAfter.status === 'paid') {
if (regAfter && (regAfter.status === 'paid' || regAfter.ticketsReleasedAt)) {
const newTickets = await generateTicketsForRegistration(registrationId);
if (newTickets && newTickets.length > 0) {
try {
+5 -3
View File
@@ -8,7 +8,10 @@ import { prisma } from '../v2/legacy';
import { fail, ServiceError } from './errors';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const { generateTicketsForRegistration } = require('../utils/ticketUtils') as { generateTicketsForRegistration(id: string): Promise<unknown> };
const { generateTicketsForRegistration, formsOutstanding } = require('../utils/ticketUtils') as {
generateTicketsForRegistration(id: string): Promise<unknown>;
formsOutstanding(registration: string | object): Promise<number>;
};
const pricing = require('../utils/pricing') as {
resolveOptionPrice(option: any, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
resolveVariantTierPrice(option: any, variantId: string, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
@@ -410,8 +413,7 @@ async function create(input: CreateRegistrationInput, actor: Actor) {
if (registration.status !== 'paid') {
await prisma.registration.update({ where: { id: registrationId }, data: { status: 'paid', updatedAt: new Date() } });
}
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } }).catch(() => null);
if (!(form && form.isRequired)) {
if ((await formsOutstanding(registrationId)) === 0) {
// Same generator as paid registrations: a ticket per named attendee, and one
// shared quantity ticket for the rest of each line.
await generateTicketsForRegistration(registrationId);
+3 -2
View File
@@ -256,7 +256,8 @@ async function registrationBundles(registration, db = prisma) {
* left. Percentage and comp codes are recalculated over the new items, fixed codes keep
* their amount (capped at what's still in scope). `keepCouponAmounts` keeps every
* coupon's amount (capped the same way): used when only prices changed, e.g. an
* early-bird tier expiring. Updates bundleDiscount and discountTotal and returns
* early-bird tier expiring. A balance an admin wrote off (`complimentaryDiscount`, 1.52.0)
* comes off last, as a fixed amount. Updates bundleDiscount and discountTotal and returns
* discountTotal. Pass `tx` to run inside the caller's transaction.
*/
async function recalculateRegistrationDiscount(registrationId, db = prisma, { keepCouponAmounts = false } = {}) {
@@ -280,7 +281,7 @@ async function recalculateRegistrationDiscount(registrationId, db = prisma, { ke
}
total += amount;
}
total = round2(total);
total = round2(total + Number(registration.complimentaryDiscount || 0));
if (Math.abs(total - registration.discountTotal) > 0.001 || Math.abs(bundles.discount - registration.bundleDiscount) > 0.001) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total, bundleDiscount: bundles.discount } });
}
+39
View File
@@ -1512,10 +1512,49 @@ async function sendAttendeeFormLinkMessage(attendee) {
return sent;
}
// "Fill in later" (1.52.0): the attendee form was left for later at the door, so the payer is
// sent a link to the forms still to do — by email and/or WhatsApp as they prefer.
async function sendFormReminderMessage(registrationId) {
const org = getOrg();
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { event: { select: { title: true } }, user: true } });
if (!reg || !reg.user) return [];
const { shouldEmail, waText, canWhatsApp } = require('./notify');
const eventTitle = reg.event?.title || 'the event';
const link = `${org.url}/forms?registrationId=${encodeURIComponent(reg.id)}`;
const sent = [];
if (reg.user.email && !reg.user.email.endsWith('@guest.local') && shouldEmail(reg.user)) {
const html = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">Please fill in your attendee form</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${reg.user.name}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">Thanks for registering for <strong>${eventTitle}</strong>. We still need the attendee form — it only takes a minute.</p>
${ctaButton('Fill in the form', link)}
${fallbackLink(link)}
`, { preheader: `Your attendee form for ${eventTitle}` });
try {
await sendMail({ to: reg.user.email, subject: `Your attendee form for ${eventTitle}`, html, text: `Hi ${reg.user.name},
Thanks for registering for ${eventTitle}. We still need the attendee form:
${link}
${org.name}` });
sent.push('email');
} catch (e) {
console.warn('[form reminder] email failed:', e?.message || e);
}
}
if (canWhatsApp(reg.user)) {
await waText(reg.user, `Hi ${reg.user.name}, thanks for registering for *${eventTitle}*. We still need your attendee form: ${link}`);
sent.push('WhatsApp');
}
return sent;
}
module.exports = {
getEventNotifyEmails,
buildEventNotifyRecipientList,
sendAttendeeFormLinkMessage,
sendFormReminderMessage,
sendRegistrationEmails,
sendRegistrationUpdatedEmails,
sendPaymentEmails,
+10 -4
View File
@@ -96,10 +96,10 @@ function buildLineItems(registrationOptions) {
/**
* The registration's coupon discount and a label naming the code(s), e.g. "Code STAFF50".
* Needs `couponRedemptions[].coupon.code` loaded for the label; the amount is
* Registration.discountTotal less the bundle part, both always present.
* Registration.discountTotal less the bundle and complimentary parts, all always present.
*/
function couponDiscountLine(registration) {
const amount = Math.round((Number(registration?.discountTotal || 0) - Number(registration?.bundleDiscount || 0)) * 100) / 100;
const amount = Math.round((Number(registration?.discountTotal || 0) - Number(registration?.bundleDiscount || 0) - Number(registration?.complimentaryDiscount || 0)) * 100) / 100;
if (!(amount > 0.001)) return null;
const codes = (registration.couponRedemptions || []).map(r => r.coupon?.code).filter(Boolean);
return { amount, label: codes.length ? `Code ${codes.join(', ')}` : 'Discount code' };
@@ -118,9 +118,15 @@ function bundleDiscountLine(registration) {
return { amount, label: names.length ? `Bundle: ${names.join(', ')}` : 'Bundle deal' };
}
/** Bundle saving then coupon discount, as display lines ({ label, amount }). */
/** A balance written off by an admin (Corrections → Make it free, 1.52.0). */
function complimentaryLine(registration) {
const amount = Number(registration?.complimentaryDiscount || 0);
return amount > 0.001 ? { amount, label: 'Complimentary' } : null;
}
/** Bundle saving, coupon discount, then anything made complimentary, as display lines ({ label, amount }). */
function discountLines(registration) {
return [bundleDiscountLine(registration), couponDiscountLine(registration)].filter(Boolean);
return [bundleDiscountLine(registration), couponDiscountLine(registration), complimentaryLine(registration)].filter(Boolean);
}
/** Draws a two-column item table starting at `y`; returns the y position after the last row. */
+53 -14
View File
@@ -2,8 +2,55 @@ const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { planLineTickets } = require('./attendees');
/** Form fields that are only text to read (a statement, a heading). */
const DISPLAY_FIELDS = ['statement', 'paragraph'];
/** A required form with at least one question to answer — the only kind tickets wait for. */
const formNeedsAnswers = (form) => !!(form && form.isRequired && (form.fields || []).some(f => !DISPLAY_FIELDS.includes(f.type)));
/**
* Generate tickets for a registration when payment status is "paid".
* How many attendee forms a registration's places need: one per main-ticket place. A picked-
* session booking has a line per session for the same people, so it counts once (its biggest
* line), not once per session.
*/
function formPlaces(registrationOptions) {
let total = 0;
const picked = new Map();
for (const ro of registrationOptions || []) {
if (!ro.eventOption?.isMainTicket) continue;
const qty = ro.quantity || 0;
if (!ro.sessionId) { total += qty; continue; }
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
picked.set(key, Math.max(picked.get(key) || 0, qty));
}
for (const qty of picked.values()) total += qty;
return total;
}
/**
* The attendee forms a registration still needs before its tickets can be issued: 0 when the
* event's form isn't required (or has no questions), when the form was left for later
* (`formWaivedAt`), or when enough are in. `registration` is an id or a record with
* registrationOptions[].eventOption.
*/
async function formsOutstanding(registration) {
try {
const reg = typeof registration === 'string'
? await prisma.registration.findUnique({ where: { id: registration }, include: { registrationOptions: { include: { eventOption: true } } } })
: registration;
if (!reg || reg.formWaivedAt) return 0;
const form = await prisma.eventForm.findUnique({ where: { eventId: reg.eventId }, include: { fields: true } });
if (!formNeedsAnswers(form)) return 0;
const submitted = await prisma.formResponse.count({ where: { registrationId: reg.id } });
return Math.max(0, formPlaces(reg.registrationOptions) - submitted);
} catch (_) {
return 0; // form models unavailable — don't block
}
}
/**
* Generate tickets for a registration when payment status is "paid" (or an admin released
* them unpaid) and no required attendee form is outstanding (see formsOutstanding).
*
* Rules:
* - Each named attendee (group tickets) gets their own quantity-1 ticket; the rest of a
@@ -34,19 +81,11 @@ const generateTicketsForRegistration = async (registrationId) => {
});
if (!registration) throw new Error('Registration not found');
if (registration.status !== 'paid') return [];
// Paid, or an admin issued the tickets before payment ("pay later", 1.52.0).
if (registration.status !== 'paid' && !(registration.ticketsReleasedAt && registration.status !== 'cancelled')) return [];
// If event has a required form, ensure sufficient responses exist
try {
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } });
if (form && form.isRequired) {
const requiredCount = (registration.registrationOptions || [])
.filter(ro => ro.eventOption?.isMainTicket)
.reduce((s, ro) => s + (ro.quantity || 0), 0);
const responsesCount = await prisma.formResponse.count({ where: { registrationId } });
if (responsesCount < requiredCount) return [];
}
} catch (_) { /* form models unavailable — don't block */ }
// A required attendee form comes first, unless it's been left for later.
if ((await formsOutstanding(registration)) > 0) return [];
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId, session) ──
// Key on both fields so that different variants of the same option are never merged
@@ -133,4 +172,4 @@ const generateTicketsForRegistration = async (registrationId) => {
}
};
module.exports = { generateTicketsForRegistration };
module.exports = { generateTicketsForRegistration, formsOutstanding, formPlaces, formNeedsAnswers, DISPLAY_FIELDS };
+1
View File
@@ -16,6 +16,7 @@ import './resources/registrations';
import './resources/registrationsManage';
import './resources/money';
import './resources/attendance';
import './resources/corrections';
import './resources/kidsVolunteers';
import './resources/peopleMessaging';
import './resources/adminPrivacy';
+114
View File
@@ -0,0 +1,114 @@
/**
* Corrections (1.52.0): see services/corrections.ts. Admins only, except "Fill in later" for
* staff at the door and in Manual registrations.
*/
import { z } from 'zod';
import type { Request } from 'express';
import { defineRoute, type ApiUser } from '../route';
import { single } from '../paging';
import { id, idParams } from '../schemas';
import * as corrections from '../../services/corrections';
/* eslint-disable @typescript-eslint/no-explicit-any -- campus scope middleware is JavaScript */
const campusScope = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string }): any;
};
const audit = (req: Request, user: ApiUser | null) => {
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
return { actor: { id: user!.id, role: user!.role }, ip: getClientIp(req) };
};
const reason = z.string().trim().max(500).nullish().describe('Why — kept in the audit log');
const send = z.boolean().optional().describe('Send the new tickets to the attendee (off by default)');
const ticketRow = z.object({
id,
qrCode: z.string(),
label: z.string().describe('Ticket option, variant and (a picked session) the session'),
isMainTicket: z.boolean(),
name: z.string().describe('Who it\'s for'),
quantity: z.number().int(),
sessionIds: z.array(z.string()).describe(`The sessions it covers (\`${corrections.WHOLE_EVENT}\` on a single-date event)`),
checkedIn: z.record(z.string(), z.number().int()).describe('People checked in, per session'),
});
const registrationRow = z.object({
id,
status: z.string(),
createdAt: z.coerce.date(),
user: z.object({ id, name: z.string(), email: z.string().nullable(), phoneNumber: z.string().nullable() }),
totalDue: z.number(),
paid: z.number(),
balance: z.number(),
places: z.number().int().describe('Places booked (every line)'),
ticketPlaces: z.number().int().describe('Places with a ticket'),
missingTickets: z.boolean().describe('Paid (or released unpaid) but not every place has a ticket'),
formsNeeded: z.number().int().describe('Attendee forms still holding the tickets back'),
formWaived: z.boolean().describe('The form was left for later'),
ticketsReleased: z.boolean().describe('Tickets issued before it was paid'),
complimentary: z.number().describe('Balance written off'),
tickets: z.array(ticketRow),
});
export const eventCorrections = defineRoute({
method: 'get', path: '/corrections/events/:id', tag: 'Corrections', summary: 'An event\'s registrations, with what needs putting right',
description: 'Every registration that isn\'t cancelled — closed and past events too — with its balance, forms still to fill in, tickets and check-ins.',
access: 'admin', params: idParams,
response: single(z.object({
event: z.object({
id, title: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), closed: z.boolean(),
formRequired: z.boolean(), sessions: z.array(z.object({ id, label: z.string(), startsAt: z.coerce.date() })),
}),
registrations: z.array(registrationRow),
})),
async handler({ params }) { return { data: await corrections.eventCorrections(params.id) }; },
});
export const issueMissing = defineRoute({
method: 'post', path: '/corrections/events/:id/issue-missing', tag: 'Corrections', summary: 'Issue every missing ticket at an event',
description: 'For each paid registration (or one released unpaid) without all its tickets. Attendee forms still outstanding are left for later.',
access: 'admin', params: idParams, body: z.object({ send }),
response: single(z.object({ registrations: z.number().int(), tickets: z.number().int(), failed: z.array(z.object({ id, name: z.string(), message: z.string() })) })),
async handler({ params, body, user, req }) { return { data: await corrections.issueMissingTickets(params.id, audit(req, user), body) }; },
});
export const issueTickets = defineRoute({
method: 'post', path: '/corrections/registrations/:id/tickets', tag: 'Corrections', summary: 'Issue a registration\'s tickets now',
description: '`skipForm` leaves the attendee form for later; `payLater` issues them before it\'s paid (the balance is still owed).',
access: 'admin', params: idParams,
body: z.object({ skipForm: z.boolean().optional(), payLater: z.boolean().optional(), send, reason }),
response: single(z.object({ ticketsIssued: z.number().int(), registration: registrationRow })),
async handler({ params, body, user, req }) { return { data: await corrections.issueTickets(params.id, audit(req, user), body) }; },
});
export const complimentary = defineRoute({
method: 'post', path: '/corrections/registrations/:id/complimentary', tag: 'Corrections', summary: 'Make it free',
description: 'What\'s still owed is written off as a complimentary discount (income stays what was really paid), it\'s marked paid and its tickets are issued. Not on a closed event.',
access: 'admin', params: idParams,
body: z.object({ skipForm: z.boolean().optional(), send, reason }),
response: single(z.object({ amount: z.number(), ticketsIssued: z.number().int(), registration: registrationRow })),
async handler({ params, body, user, req }) { return { data: await corrections.makeComplimentary(params.id, audit(req, user), body) }; },
});
export const checkIn = defineRoute({
method: 'post', path: '/corrections/tickets/:id/check-in', tag: 'Corrections', summary: 'Check a ticket in (or undo it) after the fact',
description: 'Any time — after the event or session, or once the event is closed. `present: false` undoes the check-in for that session. Recorded as a manual check-in; no message is sent.',
access: 'admin', params: idParams,
body: z.object({
sessionId: id.nullish().describe('Multi-day events: the session'),
people: z.number().int().min(1).max(1000).nullish().describe('How many (default: everyone left on the ticket)'),
present: z.boolean().optional().describe('false undoes it'),
reason,
}),
response: single(z.object({ checkedIn: z.number().int(), quantity: z.number().int() })),
async handler({ params, body, user, req }) { return { data: await corrections.correctCheckIn(params.id, audit(req, user), body) }; },
});
export const formLater = defineRoute({
method: 'post', path: '/registrations/:id/form-later', tag: 'Registrations', summary: 'Fill in the attendee form later',
description: 'At the door: the tickets stop waiting for the required attendee form (issued now if it\'s paid) and the payer is sent a link to fill it in (`notify: false` to not).',
access: 'staff', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams,
body: z.object({ notify: z.boolean().optional() }),
response: single(z.object({ ticketsIssued: z.number().int(), sentTo: z.array(z.string()) })),
async handler({ params, body, user, req }) { return { data: await corrections.leaveFormForLater(params.id, audit(req, user), body) }; },
});
+195
View File
@@ -0,0 +1,195 @@
/**
* 1.52.0: tickets no longer go missing at the door. A required attendee form only holds tickets
* back until it's filled in or left for later, picked-session bookings need one form per person
* (not per session), a form with no questions holds nothing back, and admins can issue tickets
* unpaid, make a balance free and check people in after the event (services/corrections.ts).
*/
const mockDb: any = {
registration: { findUnique: jest.fn(), findMany: jest.fn(), update: jest.fn() },
registrationOption: { findMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), delete: jest.fn() },
eventForm: { findUnique: jest.fn() },
formResponse: { count: jest.fn() },
ticket: { create: jest.fn(), update: jest.fn(), deleteMany: jest.fn(), findMany: jest.fn(), findUnique: jest.fn() },
ticketUsage: { create: jest.fn(), deleteMany: jest.fn(), findMany: jest.fn() },
eventSession: { findUnique: jest.fn() },
event: { findUnique: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
const mockAudit = jest.fn();
jest.mock('../src/utils/adminAudit', () => ({ logAdminAction: (...a: unknown[]) => mockAudit(...a) }));
const mockReminder = jest.fn(async () => ['email']);
jest.mock('../src/utils/notifications', () => ({ sendFormReminderMessage: (...a: unknown[]) => (mockReminder as jest.Mock)(...a) }));
const mockEmailTickets = jest.fn(async () => undefined);
jest.mock('../src/controllers/ticketController', () => ({ emailTickets: (...a: unknown[]) => (mockEmailTickets as jest.Mock)(...a) }));
jest.mock('../src/utils/cashupUtils', () => ({ assertEventOpen: jest.fn(async () => undefined) }));
const mockRecalc = jest.fn(async () => 0);
jest.mock('../src/utils/coupons', () => ({ recalculateRegistrationDiscount: (...a: unknown[]) => (mockRecalc as jest.Mock)(...a) }));
const mockDue = jest.fn(() => 300);
jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: (...a: unknown[]) => (mockDue as jest.Mock)(...a) }));
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tickets = require('../src/utils/ticketUtils');
import * as corrections from '../src/services/corrections';
const admin = { actor: { id: 'adm', role: 'admin' }, ip: '1.2.3.4' };
const main = { isMainTicket: true };
const QUESTION_FORM = { isRequired: true, fields: [{ type: 'statement' }, { type: 'text' }] };
/** A registration as generateTicketsForRegistration loads it: one line of `qty` main tickets. */
const reg = (extra: object = {}, qty = 2) => ({
id: 'r1', userId: 'u1', eventId: 'ev1', status: 'paid', formWaivedAt: null, ticketsReleasedAt: null,
registrationOptions: [{ id: 'ro1', eventOptionId: 'adult', variantId: null, sessionId: null, quantity: qty, eventOption: main, tickets: [] }],
...extra,
});
beforeEach(() => {
jest.clearAllMocks();
mockDb.eventForm.findUnique.mockResolvedValue(null);
mockDb.formResponse.count.mockResolvedValue(0);
mockDb.registrationOption.findMany.mockResolvedValue([{ id: 'ro1', quantity: 2, tickets: [], attendees: [] }]);
mockDb.ticket.create.mockImplementation(async ({ data }: any) => data);
});
describe('which forms hold tickets back', () => {
test('a picked-session booking counts its people once, not once per session', () => {
const lines = [
{ eventOptionId: 'day', variantId: null, sessionId: 's1', quantity: 2, eventOption: main },
{ eventOptionId: 'day', variantId: null, sessionId: 's2', quantity: 2, eventOption: main },
{ eventOptionId: 'day', variantId: null, sessionId: 's3', quantity: 1, eventOption: main },
{ eventOptionId: 'adult', variantId: null, sessionId: null, quantity: 3, eventOption: main },
{ eventOptionId: 'tshirt', variantId: null, sessionId: null, quantity: 4, eventOption: { isMainTicket: false } },
];
expect(tickets.formPlaces(lines)).toBe(5);
});
test('only a required form with a question to answer counts', () => {
expect(tickets.formNeedsAnswers(QUESTION_FORM)).toBe(true);
expect(tickets.formNeedsAnswers({ isRequired: true, fields: [{ type: 'statement' }, { type: 'paragraph' }] })).toBe(false);
expect(tickets.formNeedsAnswers({ isRequired: false, fields: [{ type: 'text' }] })).toBe(false);
expect(tickets.formNeedsAnswers(null)).toBe(false);
});
test('forms still needed: places less the forms in; none once left for later', async () => {
mockDb.eventForm.findUnique.mockResolvedValue(QUESTION_FORM);
mockDb.formResponse.count.mockResolvedValue(1);
expect(await tickets.formsOutstanding(reg({}, 3))).toBe(2);
expect(await tickets.formsOutstanding(reg({ formWaivedAt: new Date() }, 3))).toBe(0);
});
});
describe('generating tickets', () => {
test('a required form not filled in holds them back', async () => {
mockDb.registration.findUnique.mockResolvedValue(reg());
mockDb.eventForm.findUnique.mockResolvedValue(QUESTION_FORM);
expect(await tickets.generateTicketsForRegistration('r1')).toEqual([]);
expect(mockDb.ticket.create).not.toHaveBeenCalled();
});
test('…until the form is left for later', async () => {
mockDb.registration.findUnique.mockResolvedValue(reg({ formWaivedAt: new Date() }));
mockDb.eventForm.findUnique.mockResolvedValue(QUESTION_FORM);
expect(await tickets.generateTicketsForRegistration('r1')).toHaveLength(1);
});
test('a form with only statements and headings holds nothing back', async () => {
mockDb.registration.findUnique.mockResolvedValue(reg());
mockDb.eventForm.findUnique.mockResolvedValue({ isRequired: true, fields: [{ type: 'statement' }] });
expect(await tickets.generateTicketsForRegistration('r1')).toHaveLength(1);
});
test('unpaid: none, unless an admin released them (and never when cancelled)', async () => {
mockDb.registration.findUnique.mockResolvedValueOnce(reg({ status: 'pending' }));
expect(await tickets.generateTicketsForRegistration('r1')).toEqual([]);
mockDb.registration.findUnique.mockResolvedValueOnce(reg({ status: 'partial_paid', ticketsReleasedAt: new Date() }));
expect(await tickets.generateTicketsForRegistration('r1')).toHaveLength(1);
mockDb.registration.findUnique.mockResolvedValueOnce(reg({ status: 'cancelled', ticketsReleasedAt: new Date() }));
expect(await tickets.generateTicketsForRegistration('r1')).toEqual([]);
});
});
describe('Fill in later', () => {
test('stops the tickets waiting, issues and sends them, and sends the form link', async () => {
let waived: Date | null = null;
mockDb.registration.findUnique.mockImplementation(async () => reg({ formWaivedAt: waived }));
mockDb.registration.update.mockImplementation(async ({ data }: any) => { waived = data.formWaivedAt; });
mockDb.eventForm.findUnique.mockResolvedValue(QUESTION_FORM);
const out = await corrections.leaveFormForLater('r1', { actor: { id: 'st', role: 'staff' } });
expect(waived).toBeInstanceOf(Date);
expect(out).toEqual({ ticketsIssued: 1, sentTo: ['email'] });
expect(mockEmailTickets).toHaveBeenCalledTimes(1);
expect(mockReminder).toHaveBeenCalledWith('r1');
expect(mockAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'attendee_form_waived', targetId: 'r1' }));
});
});
describe('issuing tickets as an admin', () => {
test('unpaid needs "pay later"', async () => {
mockDb.registration.findUnique.mockResolvedValue(reg({ status: 'pending' }));
await expect(corrections.issueTickets('r1', admin, {})).rejects.toThrow('isn\'t paid yet');
});
test('a form still outstanding is refused unless it\'s left for later — and nothing is changed', async () => {
mockDb.registration.findUnique.mockResolvedValue(reg({ status: 'pending' }));
mockDb.eventForm.findUnique.mockResolvedValue(QUESTION_FORM);
await expect(corrections.issueTickets('r1', admin, { payLater: true })).rejects.toThrow('2 attendee forms are still to fill in');
expect(mockDb.registration.update).not.toHaveBeenCalled();
});
test('pay later releases them with the balance still owed', async () => {
mockDb.registration.findUnique.mockResolvedValue(reg({ status: 'pending' }));
mockDb.event.findUnique.mockResolvedValue({ id: 'ev1', scheduleType: 'single', sessions: [], form: null });
await corrections.issueTickets('r1', admin, { payLater: true, reason: 'Pays on Friday' }).catch(() => undefined);
expect(mockDb.registration.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ ticketsReleasedAt: expect.any(Date), ticketsReleasedById: 'adm' }) }));
expect(mockDb.registration.update.mock.calls[0][0].data.status).toBeUndefined();
expect(mockAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'tickets_issued_override', metadata: expect.objectContaining({ payLater: true, reason: 'Pays on Friday' }) }));
});
test('make it free writes the balance off as a discount and marks it paid', async () => {
mockDb.registration.findUnique.mockImplementation(async (q: any) => (q.include?.payments
? { ...reg({ status: 'partial_paid' }), complimentaryDiscount: 0, payments: [{ amount: 100 }] }
: reg({ status: 'partial_paid' })));
mockDb.event.findUnique.mockResolvedValue({ id: 'ev1', scheduleType: 'single', sessions: [], form: null });
const out = await corrections.makeComplimentary('r1', admin, { reason: 'Speaker' }).catch((e: Error) => e);
expect(mockDb.registration.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ complimentaryDiscount: 200 }) }));
expect(mockRecalc).toHaveBeenCalledWith('r1');
expect(mockDb.registration.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'paid' }) }));
expect(mockAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'registration_complimentary', metadata: expect.objectContaining({ amount: 200 }) }));
expect(out).toMatchObject({ amount: 200 });
});
});
describe('check-in after the event', () => {
const ticket = (extra: object = {}) => ({
id: 't1', eventId: 'ev1', quantity: 2, usages: [], event: { id: 'ev1', scheduleType: 'single' },
registrationOption: { sessionId: null, sessionIds: [], registration: { status: 'paid' } }, ...extra,
});
test('checks everyone left in as a manual check-in, with no window or closed-event check', async () => {
mockDb.ticket.findUnique.mockResolvedValue(ticket({ usages: [{ id: 'u0', quantityRedeemed: 1, sessionId: null }] }));
mockDb.ticketUsage.findMany.mockResolvedValue([{ quantityRedeemed: 1, sessionId: null }, { quantityRedeemed: 1, sessionId: null }]);
const out = await corrections.correctCheckIn('t1', admin, {});
expect(mockDb.ticketUsage.create).toHaveBeenCalledWith({ data: { ticketId: 't1', scannedById: 'adm', quantityRedeemed: 1, sessionId: null, manual: true } });
expect(mockDb.ticket.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ isUsed: true }) }));
expect(out).toEqual({ checkedIn: 2, quantity: 2 });
expect(mockAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'checkin_corrected' }));
});
test('undo removes the check-ins; a full ticket can\'t be checked in again', async () => {
mockDb.ticket.findUnique.mockResolvedValue(ticket({ usages: [{ id: 'u0', quantityRedeemed: 2, sessionId: null }] }));
await expect(corrections.correctCheckIn('t1', admin, {})).rejects.toThrow('already checked in');
mockDb.ticketUsage.findMany.mockResolvedValue([]);
await corrections.correctCheckIn('t1', admin, { present: false });
expect(mockDb.ticketUsage.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ['u0'] } } });
});
test('multi-day: the session must be the event\'s and on the ticket', async () => {
mockDb.ticket.findUnique.mockResolvedValue(ticket({ event: { id: 'ev1', scheduleType: 'sessions' }, registrationOption: { sessionId: 's1', sessionIds: [], registration: { status: 'paid' } } }));
await expect(corrections.correctCheckIn('t1', admin, {})).rejects.toThrow('Choose one of this event');
mockDb.eventSession.findUnique.mockResolvedValue({ id: 's2', eventId: 'ev1', cancelledAt: null });
await expect(corrections.correctCheckIn('t1', admin, { sessionId: 's2' })).rejects.toThrow('isn\'t booked for this session');
mockDb.eventSession.findUnique.mockResolvedValue({ id: 's1', eventId: 'ev1', cancelledAt: null });
mockDb.ticketUsage.findMany.mockResolvedValue([{ quantityRedeemed: 2, sessionId: 's1' }]);
await corrections.correctCheckIn('t1', admin, { sessionId: 's1' });
expect(mockDb.ticketUsage.create).toHaveBeenCalledWith({ data: expect.objectContaining({ sessionId: 's1', manual: true, quantityRedeemed: 2 }) });
});
});
+2 -1
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-30, after **1.51.17** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-11-02, after **1.52.0** (and Hub 1.5.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -14,6 +14,7 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.52.0 | **Urgent fix + Corrections:** required attendee forms no longer leave paid door/manual registrations without tickets — popup with "Fill in later" (`Registration.formWaivedAt`, `POST /api/v2/registrations/{id}/form-later`); one form per person on picked sessions; forms with no questions don't hold tickets; kiosk asks for the forms the server says are missing. Admin **Corrections** page (`src/services/corrections.ts`, `/api/v2/corrections/…`): issue missing tickets, pay later (`ticketsReleasedAt`), make it free (`complimentaryDiscount`), check in after the event. Migration `20261102120000_corrections`. e2e `e2e/corrections.spec.ts` |
| 1.51.17 | **Security:** forgot-password and sign-in don't reveal whether an account exists (same answer, status and timing); forgot-password rate-limited |
| 1.51.16 | **Incoming webhooks and message links on v2** — `src/v2/links.ts` (`/api/v2/webhooks/…`, one-click unsubscribe, 308s for old links); provider URLs and email links point at v2 |
| 1.51.15 | **Privacy and admin on v2** — `resources/adminPrivacy.ts`; every frontend call on v2; impersonation end/log allowed on v2 while read-only |
+126
View File
@@ -0,0 +1,126 @@
import { test, expect, loginAs, api, seed, pickEvent } from "./fixtures";
import type { Page } from "@playwright/test";
// 1.52.0: a required attendee form no longer leaves paid door registrations without tickets,
// and admins can put things right afterwards (Corrections).
type Reg = { id: string; status: string; formsNeeded?: number; registrationOptions: { tickets?: { id: string }[] }[] };
type Row = { id: string; user: { name: string }; missingTickets: boolean; formsNeeded: number; balance: number; status: string; tickets: { id: string; checkedIn: Record<string, number> }[] };
const ticketsOn = (r: Reg) => r.registrationOptions.reduce((n, o) => n + (o.tickets || []).length, 0);
async function register(page: Page, name: string, email: string): Promise<Reg> {
const res = await api<{ data: Reg }>(page, "supervisor", "POST", "/api/v2/registrations/manual", {
eventId: seed().events.form, options: [{ eventOptionId: seed().formOptionId, quantity: 1 }],
user: { name, email }, skipActivationNotice: true,
});
return res.data;
}
const full = async (page: Page, id: string) => (await api<{ data: Reg }>(page, "supervisor", "GET", `/api/v2/registrations/${id}/full`)).data;
const pay = (page: Page, id: string, amount = 200) => api<{ data: { formsNeeded?: number } }>(page, "supervisor", "POST", "/api/v2/payments", { registrationId: id, amount, method: "cash" });
const rows = async (page: Page, eventId: string) => (await api<{ data: { registrations: Row[] } }>(page, "admin", "GET", `/api/v2/corrections/events/${eventId}`)).data.registrations;
test.describe("required attendee forms at the door", () => {
test("paid at the door: the form holds the tickets back until it's filled in or left for later", async ({ page }) => {
const reg = await register(page, "Door Formless", "door-formless@e2e.test");
expect(reg.formsNeeded).toBe(1);
const paid = await pay(page, reg.id);
expect(paid.data.formsNeeded).toBe(1);
expect(ticketsOn(await full(page, reg.id))).toBe(0);
const later = await api<{ data: { ticketsIssued: number } }>(page, "staff", "POST", `/api/v2/registrations/${reg.id}/form-later`, { notify: false });
expect(later.data.ticketsIssued).toBe(1);
expect(ticketsOn(await full(page, reg.id))).toBe(1);
});
test("at the door the form pops up; filling it in issues the tickets once paid", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/at-the-door");
await page.waitForLoadState("networkidle");
await pickEvent(page, "E2E Camp Weekend");
await page.waitForLoadState("networkidle");
await page.getByPlaceholder("Name, email or phone…").fill("Door Camper");
await page.getByText('New attendee "Door Camper"…').click();
await page.getByPlaceholder("Email address").fill("door-camper@e2e.test");
await page.getByRole("button", { name: "Next — Choose Items" }).click();
await page.getByRole("button", { name: "Confirm Registration" }).click();
const popup = page.getByRole("dialog", { name: /Attendee form/ });
await expect(popup).toBeVisible();
await expect(popup.getByText("About you")).toBeVisible();
await popup.getByRole("button", { name: "Save form" }).click();
await expect(popup.getByText('"Emergency contact" is required.')).toBeVisible();
await popup.getByLabel(/Emergency contact/).fill("Mom 082 000 0000");
await popup.getByRole("button", { name: "Save form" }).click();
await expect(popup).toBeHidden();
await expect(page.getByText("Attendee form saved.")).toBeVisible();
await page.getByRole("button", { name: "Full" }).click();
await page.getByRole("button", { name: "Capture Payment" }).click();
await expect(page.getByText("Payment recorded — tickets sent.")).toBeVisible();
await expect(page.getByRole("button", { name: /^Check In 1/ })).toBeVisible();
});
test("self-service kiosk: the visitor fills in the form and is registered", async ({ page }) => {
await page.goto("/self-service");
await page.getByPlaceholder("supervisor@example.com").fill(seed().users.supervisor.email);
await page.getByPlaceholder("••••••••").fill("E2e-password-123");
await page.getByRole("button", { name: "Sign In" }).click();
await pickEvent(page, "E2E Camp Weekend");
await page.getByRole("button", { name: "Start Kiosk" }).click();
await page.getByPlaceholder("john@example.com").fill("kiosk-camper@e2e.test");
await page.getByPlaceholder("John Smith").fill("Kiosk Camper");
await page.getByRole("button", { name: /^Add one / }).first().click();
await page.getByRole("button", { name: "Next — Fill in Form" }).click();
await expect(page.getByRole("heading", { name: "Attendee Information" })).toBeVisible();
// A heading is text to read, not a box to fill in.
await expect(page.getByText("About you")).toBeVisible();
await expect(page.locator("textarea")).toHaveCount(0);
await page.getByLabel(/Emergency contact/).fill("Dad 082 111 1111");
await page.getByRole("button", { name: "Complete Registration" }).click();
await expect(page.getByRole("heading", { name: "You're Registered!" })).toBeVisible();
});
});
test.describe("Corrections (admins)", () => {
test("issue every missing ticket at an event in one go", async ({ page }) => {
const reg = await register(page, "Missing Tickets", "missing-tickets@e2e.test");
await pay(page, reg.id);
expect((await rows(page, seed().events.form)).find(r => r.id === reg.id)).toMatchObject({ missingTickets: true, formsNeeded: 1 });
const out = await api<{ data: { registrations: number; tickets: number } }>(page, "admin", "POST", `/api/v2/corrections/events/${seed().events.form}/issue-missing`, {});
expect(out.data.tickets).toBeGreaterThanOrEqual(1);
expect((await rows(page, seed().events.form)).find(r => r.id === reg.id)).toMatchObject({ missingTickets: false, formsNeeded: 0 });
});
test("tickets before payment, and making a balance free", async ({ page }) => {
const later = await register(page, "Pays Later", "pays-later@e2e.test");
const res = await api<{ data: { ticketsIssued: number; registration: Row } }>(page, "admin", "POST", `/api/v2/corrections/registrations/${later.id}/tickets`, { payLater: true, skipForm: true, reason: "Pays on Friday" });
expect(res.data.ticketsIssued).toBe(1);
expect(res.data.registration).toMatchObject({ status: "pending", balance: 200 });
const comp = await register(page, "On The House", "on-the-house@e2e.test");
await pay(page, comp.id, 50);
const free = await api<{ data: { amount: number; registration: Row } }>(page, "admin", "POST", `/api/v2/corrections/registrations/${comp.id}/complimentary`, { skipForm: true, reason: "Speaker" });
expect(free.data.amount).toBe(150);
expect(free.data.registration).toMatchObject({ status: "paid", balance: 0 });
expect(free.data.registration.tickets).toHaveLength(1);
// Not for staff or supervisors.
await expect(api(page, "supervisor", "GET", `/api/v2/corrections/events/${seed().events.form}`)).rejects.toThrow("403");
});
test("check someone in after the event is over", async ({ page }) => {
await loginAs(page, "admin");
await page.goto(`/dashboard/admin/corrections?event=${seed().events.past}`);
await page.getByRole("button", { name: /^Not checked in/ }).click();
const card = page.locator("div.border.rounded-xl", { hasText: "Lerato Late" });
await expect(card.getByText("Checked in: 0/2")).toBeVisible();
await card.getByRole("button", { name: "Check in" }).click();
await expect(page.getByText("Lerato Late: checked in.")).toBeVisible();
const [row] = (await rows(page, seed().events.past)).filter(r => r.user.name === "Lerato Late");
expect(row.tickets[0].checkedIn.event).toBe(2);
});
});
+2 -1
View File
@@ -5,9 +5,10 @@ import path from "path";
export type Role = "admin" | "supervisor" | "staff" | "user";
export type Seed = {
users: Record<Role, { id: string; name: string; email: string; token: string }>;
events: { free: string; paid: string; course: string; dropIn: string; conference: string; kids: string; serving: string };
events: { free: string; paid: string; course: string; dropIn: string; conference: string; kids: string; serving: string; form: string; past: string };
campuses: Record<"north" | "south", { id: string; slug: string }>;
northTicketQr: string;
formOptionId: string;
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
unsubscribeToken: string;
newsPromptUser: { id: string; token: string };
+33 -1
View File
@@ -187,11 +187,43 @@ async function seed(url) {
await generateTicketsForRegistration(northReg.id);
const northTicket = await prisma.ticket.findFirst({ where: { registrationOption: { registrationId: northReg.id } } });
// Corrections (corrections.spec.ts): a paid camp with a required attendee form, and an event
// that ended yesterday with someone who was never checked in. Hidden, like kids/serving.
const formEvent = await prisma.event.create({
data: {
title: 'E2E Camp Weekend', description: 'A camp with a required attendee form.', startDate: at(5, 7), endDate: at(6, 15),
price: 200, isHidden: true, createdById: users.supervisor.id,
eventOptions: { create: [{ name: 'Camp ticket', price: 200, isMainTicket: true }] },
form: { create: { isRequired: true, fields: { create: [
{ type: 'paragraph', label: 'About you', order: 0 },
{ type: 'text', label: 'Emergency contact', isRequired: true, order: 1 },
] } } },
},
include: { eventOptions: true },
});
const pastEvent = await prisma.event.create({
data: {
title: 'E2E Last Sunday', description: 'It ended yesterday.', startDate: at(-1, 7), endDate: at(-1, 10),
price: 0, isHidden: true, createdById: users.supervisor.id,
eventOptions: { create: [{ name: 'Entry', price: 0, isMainTicket: true }] },
},
include: { eventOptions: true },
});
const latecomer = await mk('user', 'Lerato Late', 'late@e2e.test', '0820000009');
const lateReg = await prisma.registration.create({
data: {
userId: latecomer.id, eventId: pastEvent.id, status: 'paid',
registrationOptions: { create: [{ eventOptionId: pastEvent.eventOptions[0].id, quantity: 2, priceSnapshot: 0, tranches: { create: [{ quantity: 2, priceSnapshot: 0 }] } }] },
},
});
await generateTicketsForRegistration(lateReg.id);
const out = {
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id, kids: kids.id, serving: serving.id },
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id, kids: kids.id, serving: serving.id, form: formEvent.id, past: pastEvent.id },
campuses: { north: { id: north.id, slug: north.slug }, south: { id: south.id, slug: south.slug } },
northTicketQr: northTicket.qrCode,
formOptionId: formEvent.eventOptions[0].id,
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
// An account that has never answered the news question (asked once after login).
newsPromptUser: await (async () => {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.51.17",
"version": "1.52.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.51.17",
"version": "1.52.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.51.17",
"version": "1.52.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -80,6 +80,10 @@ const ACTION_LABELS: Record<string, string> = {
registrations_bulk: "Bulk action on registrations",
attendance_marked: "Attendance marked",
attendance_unmarked: "Attendance unmarked",
attendee_form_waived: "Attendee form left for later",
tickets_issued_override: "Tickets issued by an admin (correction)",
registration_complimentary: "Balance made complimentary",
checkin_corrected: "Check-in corrected after the fact",
certificate_issued: "Certificate issued",
certificate_revoked: "Certificate revoked",
event_sessions_changed: "Event sessions changed",
@@ -0,0 +1,408 @@
"use client";
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Wrench, AlertTriangle, CheckCircle2, Ticket, FileText } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { useDismissingState } from "@/hooks/useDismissingState";
import { fetchEvent, fetchManagedEvents } from "@/lib/api";
import { apiV2Data } from "@/lib/apiV2";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { AttendeeFormModal, type AttendeeFormDef } from "@/components/shared/AttendeeFormModal";
import type { Event } from "@/types";
// GET /api/v2/corrections/events/{id} (services/corrections.ts)
type CorrectionTicket = {
id: string; qrCode: string; label: string; isMainTicket: boolean; name: string; quantity: number;
sessionIds: string[]; checkedIn: Record<string, number>;
};
type CorrectionRow = {
id: string; status: string; createdAt: string;
user: { id: string; name: string; email: string | null; phoneNumber: string | null };
totalDue: number; paid: number; balance: number; places: number; ticketPlaces: number;
missingTickets: boolean; formsNeeded: number; formWaived: boolean; ticketsReleased: boolean; complimentary: number;
tickets: CorrectionTicket[];
};
type CorrectionEvent = { id: string; title: string; startDate: string; endDate: string; closed: boolean; formRequired: boolean; sessions: { id: string; label: string; startsAt: string }[] };
type Filter = "attention" | "unpaid" | "not_checked_in" | "all";
const WHOLE_EVENT = "event";
const rand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const notCheckedIn = (r: CorrectionRow) => r.tickets.some(t => t.isMainTicket && t.sessionIds.some(s => (t.checkedIn[s] || 0) < t.quantity));
const needsAttention = (r: CorrectionRow) => r.missingTickets || r.formsNeeded > 0 || (r.balance > 0 && r.tickets.length === 0);
/** One registration: what's wrong with it, and the fixes. */
function RegistrationCard({ row, event, form, token, onChanged, onMessage }: {
row: CorrectionRow; event: CorrectionEvent; form: AttendeeFormDef; token: string;
onChanged: () => void; onMessage: (ok: boolean, text: string) => void;
}) {
const [action, setAction] = useState<"none" | "tickets" | "free">("none");
const [skipForm, setSkipForm] = useState(row.formsNeeded > 0);
const [send, setSend] = useState(false);
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [showForm, setShowForm] = useState(false);
const unpaid = row.status !== "paid" && !row.ticketsReleased;
const run = async () => {
setBusy(true);
try {
if (action === "free") {
const res = await (apiV2Data("post", "/corrections/registrations/{id}/complimentary", {
params: { id: row.id }, authToken: token, body: { skipForm, send, reason: reason || null },
}) as Promise<{ amount: number; ticketsIssued: number }>);
onMessage(true, `${row.user.name}: ${rand(res.amount)} made complimentary${res.ticketsIssued ? ` — ${res.ticketsIssued} ticket(s) issued` : ""}.`);
} else {
const res = await (apiV2Data("post", "/corrections/registrations/{id}/tickets", {
params: { id: row.id }, authToken: token, body: { skipForm, payLater: unpaid, send, reason: reason || null },
}) as Promise<{ ticketsIssued: number }>);
onMessage(true, `${row.user.name}: ${res.ticketsIssued ? `${res.ticketsIssued} ticket(s) issued` : "tickets are up to date"}${unpaid ? " — the balance is still owed" : ""}.`);
}
setAction("none");
setReason("");
onChanged();
} catch (e) {
onMessage(false, errorMessage(e) || "That didn't work.");
} finally {
setBusy(false);
}
};
const chips: { text: string; tone: string }[] = [];
if (row.missingTickets) chips.push({ text: `Missing tickets (${row.ticketPlaces} of ${row.places})`, tone: "bg-red-50 text-red-700" });
if (row.formsNeeded > 0) chips.push({ text: `${row.formsNeeded} form${row.formsNeeded === 1 ? "" : "s"} to fill in`, tone: "bg-amber-50 text-amber-700" });
if (row.balance > 0) chips.push({ text: `Owes ${rand(row.balance)}`, tone: "bg-orange-50 text-orange-700" });
if (row.formWaived) chips.push({ text: "Form left for later", tone: "bg-gray-100 text-gray-600" });
if (row.ticketsReleased) chips.push({ text: "Tickets issued before payment", tone: "bg-blue-50 text-blue-700" });
if (row.complimentary > 0) chips.push({ text: `Complimentary ${rand(row.complimentary)}`, tone: "bg-emerald-50 text-emerald-700" });
return (
<div className="border rounded-xl bg-white shadow-sm p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold text-gray-900">{row.user.name || "Guest"}</div>
<div className="text-xs text-gray-500 break-all">{[row.user.email, row.user.phoneNumber].filter(Boolean).join(" · ") || "No contact details"}</div>
</div>
<div className="text-right text-xs text-gray-600">
<div><span className="font-medium capitalize">{row.status.replace("_", " ")}</span> · {rand(row.paid)} of {rand(row.totalDue)}</div>
<div className="text-gray-400">#{row.id.slice(0, 8)}</div>
</div>
</div>
{chips.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{chips.map(c => <span key={c.text} className={`text-[11px] px-2 py-0.5 rounded-full ${c.tone}`}>{c.text}</span>)}
</div>
)}
<div className="flex flex-wrap gap-2 mt-3">
{row.formsNeeded > 0 && (
<button type="button" onClick={() => setShowForm(true)} className="px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 inline-flex items-center gap-1">
<FileText className="w-3.5 h-3.5" /> Fill in form
</button>
)}
{(row.missingTickets || unpaid || row.formsNeeded > 0) && (
<button type="button" onClick={() => { setAction(action === "tickets" ? "none" : "tickets"); setSkipForm(row.formsNeeded > 0); }} className={`px-3 py-1.5 text-xs rounded border inline-flex items-center gap-1 ${action === "tickets" ? "bg-brand-600 text-white border-brand-600" : "bg-white hover:bg-gray-50"}`}>
<Ticket className="w-3.5 h-3.5" /> {unpaid ? "Issue tickets — pay later" : "Issue tickets"}
</button>
)}
{row.balance > 0 && !event.closed && (
<button type="button" onClick={() => { setAction(action === "free" ? "none" : "free"); setSkipForm(row.formsNeeded > 0); }} className={`px-3 py-1.5 text-xs rounded border ${action === "free" ? "bg-emerald-600 text-white border-emerald-600" : "bg-white hover:bg-gray-50"}`}>
Make it free ({rand(row.balance)})
</button>
)}
</div>
{action !== "none" && (
<div className="mt-3 border rounded-lg p-3 bg-gray-50 space-y-2 text-sm">
<p className="text-xs text-gray-600">
{action === "free"
? `${rand(row.balance)} is written off as a complimentary discount — income stays ${rand(row.paid)} — and the registration is marked paid.`
: unpaid
? `The tickets are issued now. ${rand(row.balance)} is still owed and shows as outstanding until it's paid.`
: "Issues the tickets this registration is missing."}
</p>
{row.formsNeeded > 0 && (
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={skipForm} onChange={e => setSkipForm(e.target.checked)} />
Leave the attendee form for later (otherwise the tickets wait for it)
</label>
)}
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={send} onChange={e => setSend(e.target.checked)} />
Send the tickets to {row.user.name || "the attendee"}
</label>
<label className="block text-xs">
<span className="text-gray-600">Reason (kept in the audit log)</span>
<input className="mt-1 w-full border rounded px-2 py-1.5 text-sm bg-white" value={reason} onChange={e => setReason(e.target.value)} placeholder={action === "free" ? "e.g. Speaker's spouse" : "e.g. Paid at the door, form on paper"} maxLength={500} />
</label>
<div className="flex gap-2">
<button type="button" disabled={busy} onClick={run} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white disabled:opacity-50">{busy ? "Working…" : action === "free" ? "Make it free" : "Issue tickets"}</button>
<button type="button" disabled={busy} onClick={() => setAction("none")} className="px-3 py-1.5 text-xs rounded border bg-white">Cancel</button>
</div>
</div>
)}
{row.tickets.length > 0 && (
<div className="mt-3 space-y-2">
{row.tickets.map(t => <TicketCheckIn key={t.id} ticket={t} event={event} token={token} onChanged={onChanged} onMessage={onMessage} />)}
</div>
)}
{showForm && (
<AttendeeFormModal
registrationId={row.id}
form={form}
needed={row.formsNeeded}
token={token}
attendeeName={row.user.name}
onDone={({ later, ticketsIssued }) => {
setShowForm(false);
onMessage(true, `${row.user.name}: ${later ? "form left for later" : "form saved"}${ticketsIssued ? ` — ${ticketsIssued} ticket(s) issued` : ""}.`);
onChanged();
}}
/>
)}
</div>
);
}
/** A ticket's check-ins per session, with check in / undo whatever the time. */
function TicketCheckIn({ ticket, event, token, onChanged, onMessage }: {
ticket: CorrectionTicket; event: CorrectionEvent; token: string;
onChanged: () => void; onMessage: (ok: boolean, text: string) => void;
}) {
const [busyKey, setBusyKey] = useState<string | null>(null);
const label = (key: string) => key === WHOLE_EVENT ? "Checked in" : (event.sessions.find(s => s.id === key)?.label || "Session");
const change = async (key: string, present: boolean) => {
setBusyKey(key);
try {
await apiV2Data("post", "/corrections/tickets/{id}/check-in", {
params: { id: ticket.id }, authToken: token,
body: { sessionId: key === WHOLE_EVENT ? null : key, present },
});
onMessage(true, `${ticket.name}: ${present ? "checked in" : "check-in undone"}${key === WHOLE_EVENT ? "" : ` for ${label(key)}`}.`);
onChanged();
} catch (e) {
onMessage(false, errorMessage(e) || "That didn't work.");
} finally {
setBusyKey(null);
}
};
return (
<div className="border rounded-lg px-3 py-2 text-xs">
<div className="flex flex-wrap justify-between gap-1">
<span className="font-medium text-gray-800">{ticket.label}{ticket.quantity > 1 ? ` ×${ticket.quantity}` : ""}</span>
<span className="text-gray-500">{ticket.name}</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-2">
{ticket.sessionIds.map(key => {
const n = ticket.checkedIn[key] || 0;
const full = n >= ticket.quantity;
return (
<span key={key} className="inline-flex items-center gap-1.5 border rounded-full pl-2 pr-1 py-0.5">
{full ? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-600" /> : null}
<span className={full ? "text-emerald-700" : "text-gray-600"}>{label(key)}: {n}/{ticket.quantity}</span>
{!full && (
<button type="button" disabled={busyKey === key} onClick={() => change(key, true)} className="px-2 py-0.5 rounded-full bg-brand-600 text-white disabled:opacity-50">
Check in
</button>
)}
{n > 0 && (
<button type="button" disabled={busyKey === key} onClick={() => change(key, false)} className="px-2 py-0.5 rounded-full border bg-white disabled:opacity-50">
Undo
</button>
)}
</span>
);
})}
</div>
</div>
);
}
function CorrectionsContent() {
const { user, token, loading } = useAuth();
const router = useRouter();
const search = useSearchParams();
const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState(search.get("event") || "");
// Kept with the event they're for, so changing events never shows the last one's.
const [loaded, setLoaded] = useState<{ event: CorrectionEvent; registrations: CorrectionRow[] } | null>(null);
const [formOf, setFormOf] = useState<{ eventId: string; form: AttendeeFormDef } | null>(null);
const data = loaded && loaded.event.id === eventId ? loaded : null;
const form = formOf?.eventId === eventId ? formOf.form : null;
const [busy, setBusy] = useState(false);
const [filter, setFilter] = useState<Filter>("attention");
const [query, setQuery] = useState("");
const [sendMissing, setSendMissing] = useState(false);
const [message, setMessage] = useDismissingState<{ ok: boolean; text: string } | null>(null);
const isAdmin = user?.role === "admin";
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
// Every event — past and closed ones too, newest first.
useEffect(() => {
if (!token || !isAdmin) return;
fetchManagedEvents<Event>(token, { past: true, inactive: true })
.then(evs => setEvents([...(evs || [])].sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime())))
.catch(() => setEvents([]));
}, [token, isAdmin]);
const load = useCallback(async () => {
if (!token || !eventId) return;
try {
setLoaded(await (apiV2Data("get", "/corrections/events/{id}", { params: { id: eventId }, authToken: token }) as Promise<{ event: CorrectionEvent; registrations: CorrectionRow[] }>));
} catch (e) {
setMessage({ ok: false, text: errorMessage(e) || "Couldn't load the event." });
}
}, [token, eventId, setMessage]);
useEffect(() => { load(); }, [load]);
useEffect(() => {
if (!eventId) return;
fetchEvent<Event>(eventId, { authToken: token || undefined })
.then(ev => setFormOf({ eventId, form: ev?.form }))
.catch(() => setFormOf({ eventId, form: null }));
}, [eventId, token]);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return (data?.registrations || []).filter(r => {
if (filter === "attention" && !needsAttention(r)) return false;
if (filter === "unpaid" && !(r.balance > 0)) return false;
if (filter === "not_checked_in" && !notCheckedIn(r)) return false;
if (!q) return true;
return [r.user.name, r.user.email, r.user.phoneNumber, r.id].some(v => String(v || "").toLowerCase().includes(q));
});
}, [data, filter, query]);
const counts = useMemo(() => {
const regs = data?.registrations || [];
return {
attention: regs.filter(needsAttention).length,
unpaid: regs.filter(r => r.balance > 0).length,
not_checked_in: regs.filter(notCheckedIn).length,
all: regs.length,
missing: regs.filter(r => r.missingTickets).length,
};
}, [data]);
const issueMissing = async () => {
if (!token || !eventId) return;
setBusy(true);
try {
const res = await (apiV2Data("post", "/corrections/events/{id}/issue-missing", { params: { id: eventId }, authToken: token, body: { send: sendMissing } }) as Promise<{ registrations: number; tickets: number; failed: { name: string; message: string }[] }>);
setMessage({
ok: res.failed.length === 0,
text: `${res.tickets} ticket(s) issued for ${res.registrations} registration(s).${res.failed.length ? ` Not fixed: ${res.failed.map(f => `${f.name} (${f.message})`).join("; ")}` : ""}`,
});
await load();
} catch (e) {
setMessage({ ok: false, text: errorMessage(e) || "That didn't work." });
} finally {
setBusy(false);
}
};
const onMessage = useCallback((ok: boolean, text: string) => setMessage({ ok, text }), [setMessage]);
if (!loading && user && !isAdmin) {
return <div className="max-w-4xl mx-auto p-6"><div className="p-3 border rounded bg-yellow-50 text-sm">Corrections are for admins only.</div></div>;
}
const FILTERS: { key: Filter; label: string }[] = [
{ key: "attention", label: "Needs attention" },
{ key: "unpaid", label: "Owes money" },
{ key: "not_checked_in", label: "Not checked in" },
{ key: "all", label: "All" },
];
return (
<div className="max-w-4xl mx-auto w-full p-4 sm:p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Wrench className="w-5 h-5 text-brand-600" />
</div>
<div>
<BackLink href="/dashboard/admin" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Corrections</h1>
</div>
</div>
<p className="text-sm text-gray-600 mb-4">
Put registrations right after the fact: issue tickets held back by an attendee form or still unpaid, make a balance free, and check people in after the event. Everything is kept in the audit log.
</p>
<EventPicker className="w-full sm:w-96 mb-4" events={events} value={eventId} onChange={id => { setEventId(id); setFilter("attention"); }} placeholder="Choose an event (past events too)" />
{message && (
<div role="status" className={`p-3 mb-3 border rounded text-sm ${message.ok ? "bg-emerald-50 text-emerald-800" : "bg-red-50 text-red-700"}`}>{message.text}</div>
)}
{data && (
<>
<div className="flex flex-wrap items-center gap-2 mb-3 text-xs">
{data.event.closed && <span className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">Closed (cashed up) — money can&rsquo;t change; tickets and check-ins can</span>}
{data.event.formRequired && <span className="px-2 py-0.5 rounded-full bg-amber-50 text-amber-700">Required attendee form</span>}
{new Date(data.event.endDate) < new Date() && <span className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">Event is over</span>}
</div>
{counts.missing > 0 && (
<div className="border border-red-200 bg-red-50 rounded-xl p-4 mb-4">
<div className="flex items-start gap-2 text-sm text-red-800">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<strong>{counts.missing} paid registration{counts.missing === 1 ? " is" : "s are"} missing tickets.</strong> Issue them all now — attendee forms still outstanding are left for later.
</div>
</div>
<div className="flex flex-wrap items-center gap-3 mt-3">
<button type="button" disabled={busy} onClick={issueMissing} className="px-3 py-1.5 text-sm rounded bg-red-600 text-white disabled:opacity-50">
{busy ? "Issuing…" : `Issue all missing tickets (${counts.missing})`}
</button>
<label className="flex items-center gap-2 text-xs text-red-900">
<input type="checkbox" checked={sendMissing} onChange={e => setSendMissing(e.target.checked)} />
Send them to the attendees
</label>
</div>
</div>
)}
<div className="flex flex-wrap gap-2 mb-3">
{FILTERS.map(f => (
<button key={f.key} type="button" onClick={() => setFilter(f.key)} className={`px-3 py-1.5 text-xs rounded-full border ${filter === f.key ? "bg-brand-600 text-white border-brand-600" : "bg-white hover:bg-gray-50"}`}>
{f.label} ({counts[f.key]})
</button>
))}
<input className="ml-auto border rounded px-3 py-1.5 text-sm w-full sm:w-56" placeholder="Search name, email, phone" value={query} onChange={e => setQuery(e.target.value)} aria-label="Search registrations" />
</div>
<div className="space-y-3">
{rows.map(r => (
<RegistrationCard key={r.id} row={r} event={data.event} form={form} token={token!} onChanged={load} onMessage={onMessage} />
))}
{rows.length === 0 && (
<div className="text-sm text-gray-500 border rounded-xl p-6 text-center bg-white">
{filter === "attention" ? "Nothing needs putting right at this event." : "No registrations match."}
</div>
)}
</div>
</>
)}
</div>
);
}
export default function CorrectionsPage() {
return (
<Suspense fallback={null}>
<CorrectionsContent />
</Suspense>
);
}
+2 -1
View File
@@ -8,7 +8,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck, Inbox,
Baby, HandHeart, Layers,
Baby, HandHeart, Layers, Wrench,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -48,6 +48,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/admin/forms", label: "Attendee forms", description: "View submitted attendee forms", icon: FileText },
{ href: "/dashboard/supervisor/email-attendees", label: "Email attendees", description: "Send a message to attendees of an event", icon: Mail },
{ href: "/dashboard/supervisor/whatsapp-attendees", label: "WhatsApp attendees", description: "Send a WhatsApp message to event attendees", icon: MessageCircle },
{ href: "/dashboard/admin/corrections", label: "Corrections", description: "Missing tickets, free tickets, forms and late check-ins", icon: Wrench },
{ href: "/dashboard/admin/cashup", label: "Post-event Cashup", description: "Set costs, reconcile takings, and close out events", icon: Wallet },
{ href: "/dashboard/admin/audit-log", label: "Audit log", description: "Review refunds, manual registrations, event and settings changes", icon: History },
{ href: "/dashboard/admin/data-requests", label: "Data requests", description: "POPIA: people asking to correct or delete their information", icon: Inbox },
@@ -19,6 +19,7 @@ import { bookableSessions, choosesSessions, isPickOption, todaysSession } from "
import { SessionPicker } from "@/components/events/SessionPicker";
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
import { EventPicker } from "@/components/shared/EventPicker";
import { AttendeeFormModal, formsStillNeeded } from "@/components/shared/AttendeeFormModal";
import { apiV2, apiV2Data } from "@/lib/apiV2";
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
@@ -198,12 +199,37 @@ export default function AtTheDoorPage() {
const [info, setInfo] = useDismissingState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
// A required attendee form holds the tickets back: it pops up to fill in now, or "Fill in
// later" (tickets are issued anyway and the attendee gets a link).
const [formPrompt, setFormPrompt] = useState<{ registration: Registration; needed: number } | null>(null);
const loadFull = (id: string) => apiV2Data("get", "/registrations/{id}/full", { params: { id }, authToken: token }) as Promise<Registration>;
const promptFormFor = (full: Registration) => {
const needed = formsStillNeeded(doorEvent?.form, full);
if (needed > 0 && full.status !== "cancelled") setFormPrompt({ registration: full, needed });
return needed;
};
const handleRegistrationCreated = (registration: Registration) => {
setActiveRegistration(registration); // 🚀 Jump automatically (see effect below)
if ((registration.formsNeeded || 0) > 0) setFormPrompt({ registration, needed: registration.formsNeeded || 0 });
};
const handleRegistrationSelected = (registration: Registration) => {
setActiveRegistration(registration);
// The search list doesn't carry the forms, so look at the whole registration.
loadFull(registration.id).then(promptFormFor).catch(() => {});
};
const handleFormDone = async ({ later, ticketsIssued, sentTo }: { later: boolean; ticketsIssued: number; sentTo?: string[] }) => {
const reg = formPrompt?.registration;
setFormPrompt(null);
const issued = ticketsIssued > 0 ? " Tickets issued." : "";
setInfo(later
? `Form left for later${sentTo && sentTo.length ? ` — a link was sent by ${sentTo.join(" and ")}` : ""}.${issued}`
: `Attendee form saved.${issued}`);
if (reg) {
try { setActiveRegistration(await loadFull(reg.id)); } catch {}
}
};
useEffect(() => {
@@ -404,9 +430,14 @@ export default function AtTheDoorPage() {
// 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.
// Unless a required attendee form holds them back: that pops up first.
setActiveRegistration(reg);
setInfo("Payment recorded — tickets sent. Ready to check in.");
setMode("checkin");
if (reg && promptFormFor(reg) > 0) {
setInfo("Payment recorded — fill in the attendee form (or choose Fill in later) to issue the tickets.");
return;
}
setInfo("Payment recorded — tickets sent. Ready to check in.");
};
const handleDonation = (user?: UserSummary) => {
@@ -499,7 +530,12 @@ export default function AtTheDoorPage() {
)}
{mode === "checkin" && (
<DoorCheckInPanel token={token} eventId={eventId} registration={activeRegistration} setError={setError} setInfo={setInfo} />
<DoorCheckInPanel
token={token} eventId={eventId} registration={activeRegistration} setError={setError} setInfo={setInfo}
onFillForm={activeRegistration ? () => loadFull(activeRegistration.id).then(full => {
if (promptFormFor(full) === 0) setError("No attendee form is outstanding. If the tickets are still missing, an admin can issue them in Corrections.");
}).catch(e => setError(errorMessage(e) || "Couldn't load the registration")) : undefined}
/>
)}
{mode === "tickets" && (
@@ -550,6 +586,17 @@ export default function AtTheDoorPage() {
seed={newAttendeeSeed}
onConfirm={handleNewAttendeeConfirm}
/>
{formPrompt && (
<AttendeeFormModal
key={formPrompt.registration.id}
registrationId={formPrompt.registration.id}
form={doorEvent?.form}
needed={formPrompt.needed}
token={token}
attendeeName={formPrompt.registration.user?.name}
onDone={handleFormDone}
/>
)}
</div>
);
}
@@ -1027,12 +1074,14 @@ function DoorTicketsPanel({ token, eventId }: { token: string | null; eventId: s
);
}
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo, onFillForm }: {
token: string | null;
eventId: string;
registration: Registration | null;
setError: (e: string | null) => void;
setInfo: (s: string | null) => void;
/** No tickets yet: open the attendee form that holds them back. */
onFillForm?: () => void;
}) {
const [ticketsForEvent, setTicketsForEvent] = useState<TicketRecord[]>([]);
const [loading, setLoading] = useState(false);
@@ -1053,7 +1102,8 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
}
}, [token, eventId]);
useEffect(() => { load(); }, [load]);
// Again when the registration changes: its tickets may have just been issued.
useEffect(() => { load(); }, [load, registration]);
if (!registration) {
return (
@@ -1117,9 +1167,18 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
)}
{!loading && myTickets.length === 0 && (
<div className="text-sm text-gray-500">
No Main Tickets on this registration
</div>
registration.status === "paid" && onFillForm ? (
<div className="text-sm border border-amber-200 bg-amber-50 rounded-lg p-3 text-amber-900">
No tickets yet. If the event has an attendee form, it&rsquo;s holding them back.
<button type="button" onClick={onFillForm} className="block mt-2 px-3 py-1.5 rounded bg-amber-600 text-white text-xs font-medium">
Fill in the attendee form
</button>
</div>
) : (
<div className="text-sm text-gray-500">
{registration.status === "paid" ? "No Main Tickets on this registration" : "No tickets yet — they're issued once it's paid."}
</div>
)
)}
<div className="space-y-4">
@@ -167,6 +167,21 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
}
};
// The tickets stop waiting for the form (issued now if it's paid); the attendee gets a link.
const later = async () => {
if (!token) return;
try {
setSubmitting(true);
setError(null); setInfo(null);
const res = await (apiV2Data("post", "/registrations/{id}/form-later", { params: { id: registration.id }, authToken: token, body: {} }) as Promise<{ ticketsIssued: number; sentTo: string[] }>);
setInfo(`Form left for later${res?.sentTo?.length ? ` — a link was sent by ${res.sentTo.join(' and ')}` : ''}.${res?.ticketsIssued ? ' Tickets issued.' : ''}`);
} catch (e) {
setError(errorMessage(e) || 'Failed to leave the form for later');
} finally {
setSubmitting(false);
}
};
if (!form || !Array.isArray(form.fields) || count === 0) return null;
return (
<div className="mt-6 border rounded p-3 bg-gray-50">
@@ -212,7 +227,10 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
</div>
))}
</div>
<button className="mt-3 px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={submitting || !canSubmit} onClick={submit}>{submitting ? 'Submitting…' : 'Submit forms'}</button>
<div className="mt-3 flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={submitting || !canSubmit} onClick={submit}>{submitting ? 'Submitting…' : 'Submit forms'}</button>
{form.isRequired && <button className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50 disabled:opacity-50" disabled={submitting} onClick={later}>Fill in later</button>}
</div>
</div>
);
}
@@ -18,6 +18,7 @@ import type { Event, EventOption, PublicBundle, Registration, RegistrationOption
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
import { AttendeeFormModal, type AttendeeFormDef } from "@/components/shared/AttendeeFormModal";
// ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -91,6 +92,15 @@ export default function ManualRegistrationPage() {
const [paySubmitting, setPaySubmitting] = useState(false);
const [payMessage, setPayMessage] = useDismissingState<string | null>(null);
const [payError, setPayError] = useDismissingState<string | null>(null);
// A required attendee form holding the tickets back: fill it in now, or "Fill in later".
const [formPrompt, setFormPrompt] = useState<{ registrationId: string; needed: number; form: AttendeeFormDef; name?: string } | null>(null);
const promptForm = async (registrationId: string, needed: number, eventId: string, name?: string) => {
if (!(needed > 0)) return;
try {
const ev = await fetchEvent<Event>(eventId, { authToken: token || undefined });
setFormPrompt({ registrationId, needed, form: ev?.form, name });
} catch {}
};
// Load all users for client-side fuzzy matching
useEffect(() => {
@@ -164,7 +174,7 @@ export default function ManualRegistrationPage() {
if (!payRegistrationId) { setPayError("Please select a registration"); return; }
try {
setPaySubmitting(true);
await apiV2Data("post", "/payments", {
const paid = await (apiV2Data("post", "/payments", {
authToken: token,
body: {
amount: amt,
@@ -174,8 +184,11 @@ export default function ManualRegistrationPage() {
isDonation: false,
paidAt: payPaidAtLocal ? new Date(payPaidAtLocal).toISOString() : undefined,
},
});
setPayMessage(`Payment recorded (R ${amt.toFixed(2)}).`);
}) as Promise<{ formsNeeded?: number; registration?: { eventId?: string } }>);
setPayMessage(`Payment recorded (R ${amt.toFixed(2)}).${(paid?.formsNeeded || 0) > 0 ? " The attendee form is holding the tickets back." : ""}`);
const paidReg = allRegistrations.find(r => r.id === payRegistrationId);
const paidEventId = paid?.registration?.eventId || paidReg?.eventId;
if (paidEventId) promptForm(payRegistrationId, paid?.formsNeeded || 0, paidEventId, paidReg?.user?.name);
setPayAmount("");
setPayPaidAtLocal("");
// Refresh registrations so the displayed outstanding balance updates
@@ -362,6 +375,7 @@ export default function ManualRegistrationPage() {
setMessage(res?.couponError
? `Manual registration created, but the code wasn't applied: ${res.couponError}`
: coupon ? `Manual registration created with code ${coupon.code}.` : "Manual registration created successfully.");
if ((res?.formsNeeded || 0) > 0) setFormPrompt({ registrationId: res.id, needed: res.formsNeeded || 0, form: fullEvent?.form, name: res.user?.name || guest.name });
// Pre-load the Record Payment tab with this registration so it's ready to go the
// moment a supervisor clicks over — the tab itself is never switched to automatically.
@@ -771,6 +785,24 @@ export default function ManualRegistrationPage() {
</div>
</div>
)}
{formPrompt && (
<AttendeeFormModal
key={formPrompt.registrationId}
registrationId={formPrompt.registrationId}
form={formPrompt.form}
needed={formPrompt.needed}
token={token}
attendeeName={formPrompt.name}
onDone={({ later, ticketsIssued, sentTo }) => {
setFormPrompt(null);
const issued = ticketsIssued > 0 ? " Tickets issued." : "";
const text = later
? `Attendee form left for later${sentTo && sentTo.length ? ` — a link was sent by ${sentTo.join(" and ")}` : ""}.${issued}`
: `Attendee form saved.${issued}`;
if (tab === "payment") setPayMessage(text); else setMessage(text);
}}
/>
)}
</div>
);
}
+5 -2
View File
@@ -1033,11 +1033,14 @@ export default function UserDashboardPage() {
{renderGroup('Early bird', earlyBirdRows)}
{renderGroup('Standard price', standardRows)}
<BundleSavings applications={activeReg.bundleApplications} className="mt-2" />
{Number(activeReg.discountTotal || 0) - Number(activeReg.bundleDiscount || 0) > 0.005 && (
{Number(activeReg.discountTotal || 0) - Number(activeReg.bundleDiscount || 0) - Number(activeReg.complimentaryDiscount || 0) > 0.005 && (
<div className="text-sm text-green-700 mt-2">
Discount code{activeCoupon?.coupon?.code ? <> <span className="font-mono font-semibold">{activeCoupon.coupon.code}</span></> : null} — −{formatRand(Number(activeReg.discountTotal) - Number(activeReg.bundleDiscount || 0))}
Discount code{activeCoupon?.coupon?.code ? <> <span className="font-mono font-semibold">{activeCoupon.coupon.code}</span></> : null} — −{formatRand(Number(activeReg.discountTotal) - Number(activeReg.bundleDiscount || 0) - Number(activeReg.complimentaryDiscount || 0))}
</div>
)}
{Number(activeReg.complimentaryDiscount || 0) > 0.005 && (
<div className="text-sm text-green-700 mt-2">Complimentary — −{formatRand(Number(activeReg.complimentaryDiscount))}</div>
)}
{canModifyActive && activeBill && activeBill.outstanding > 0 && !activeCoupon && (
<div className="mt-3">
<div className="flex gap-2">
+33 -22
View File
@@ -197,6 +197,9 @@ export default function SelfServicePage() {
// ── Event form (attendee form) state ────────────────────────────
const [currentRegistrationId, setCurrentRegistrationId] = useState<string | null>(null);
const [currentTicketIndex, setCurrentTicketIndex] = useState(0);
// Attendee forms the server says this registration still needs (it counts places already
// on an existing registration, and a picked-session booking once).
const [formsToFill, setFormsToFill] = useState(0);
const [allFormAnswers, setAllFormAnswers] = useState<Record<string, string>[]>([]);
const [currentTicketAnswers, setCurrentTicketAnswers] = useState<Record<string, string>>({});
const [formSubmitLoading, setFormSubmitLoading] = useState(false);
@@ -264,16 +267,24 @@ export default function SelfServicePage() {
}), [quantities]);
const bundleQuote = useBundleQuote({ eventId: selectedEventId || "", basket: kioskBasket, token: supervisorToken, forUserId: matchedAccount?.id ?? null });
// Does the selected event have a required form?
// Does the selected event have a required form? (Statements and headings aren't questions.)
const eventHasRequiredForm = useMemo(() => {
return !!(selectedEvent?.form?.isRequired && (selectedEvent.form.fields || []).some(f => f.type !== 'statement'));
return !!(selectedEvent?.form?.isRequired && (selectedEvent.form.fields || []).some(f => f.type !== 'statement' && f.type !== 'paragraph'));
}, [selectedEvent]);
// Answerable form fields (exclude statement-type display fields)
// Answerable form fields (exclude statement and heading display fields)
const answerableFields = useMemo(() => {
if (!selectedEvent?.form) return [];
return (selectedEvent.form.fields || [])
.filter(f => f.type !== 'statement')
.filter(f => f.type !== 'statement' && f.type !== 'paragraph')
.sort((a, b) => (a.order || 0) - (b.order || 0));
}, [selectedEvent]);
// Statements and headings, shown above the questions
const formTexts = useMemo(() => {
if (!selectedEvent?.form) return [];
return (selectedEvent.form.fields || [])
.filter(f => f.type === 'statement' || f.type === 'paragraph')
.sort((a, b) => (a.order || 0) - (b.order || 0));
}, [selectedEvent]);
@@ -486,11 +497,13 @@ export default function SelfServicePage() {
}) as Promise<Registration>);
setCurrentRegistrationId(result?.id || null);
const needed = result?.formsNeeded ?? (eventHasRequiredForm ? mainTicketCount : 0);
setFormsToFill(needed);
setRegistrationIsFree(result?.status === "paid" || (result?.totalDueComputed ?? total) === 0);
setConfirmationName(visitorName.trim());
// If event has a required form and there are main tickets, show form step
if (eventHasRequiredForm && mainTicketCount > 0 && result?.id) {
// If the required form still holds the tickets back, show the form step
if (needed > 0 && result?.id) {
setCurrentTicketIndex(0);
setAllFormAnswers([]);
setCurrentTicketAnswers({});
@@ -528,7 +541,7 @@ export default function SelfServicePage() {
const collectedAnswers = [...allFormAnswers, currentTicketAnswers];
// If more tickets remain, advance to the next one
if (currentTicketIndex < mainTicketCount - 1) {
if (currentTicketIndex < formsToFill - 1) {
setAllFormAnswers(collectedAnswers);
setCurrentTicketAnswers({});
setCurrentTicketIndex((i) => i + 1);
@@ -606,6 +619,7 @@ export default function SelfServicePage() {
setFormError(null);
setCurrentRegistrationId(null);
setCurrentTicketIndex(0);
setFormsToFill(0);
setAllFormAnswers([]);
setCurrentTicketAnswers({});
setFormSubmitError(null);
@@ -1219,13 +1233,13 @@ export default function SelfServicePage() {
<div className="text-center mb-6">
<p className="text-sm text-gray-500 uppercase tracking-wide font-medium">{selectedEvent.title}</p>
<h2 className="text-2xl font-bold text-gray-900 mt-1">Attendee Information</h2>
{mainTicketCount > 1 && (
{formsToFill > 1 && (
<div className="mt-3">
<p className="text-base font-semibold text-brand-700">
Attendee {currentTicketIndex + 1} of {mainTicketCount}
Attendee {currentTicketIndex + 1} of {formsToFill}
</p>
<div className="flex gap-1.5 justify-center mt-2">
{Array.from({ length: mainTicketCount }).map((_, i) => (
{Array.from({ length: formsToFill }).map((_, i) => (
<div
key={i}
className={`h-2 rounded-full transition-all ${
@@ -1240,12 +1254,18 @@ export default function SelfServicePage() {
</div>
</div>
)}
{mainTicketCount <= 1 && (
{formsToFill <= 1 && (
<p className="text-sm text-gray-500 mt-1">This form is required to complete your registration.</p>
)}
</div>
<form onSubmit={handleFormSubmit} className="space-y-5">
{formTexts.map((field) => (
<div key={field.id} className={field.type === "paragraph" ? "text-base font-semibold text-gray-800" : "text-sm text-gray-600"}>
{field.label}
{field.helpText && <p className="text-sm font-normal text-gray-500 mt-1">{field.helpText}</p>}
</div>
))}
{answerableFields.map((field) => (
<div key={field.id}>
<label htmlFor={`kiosk-field-${field.id}`} className="block text-sm font-medium text-gray-700 mb-1">
@@ -1292,15 +1312,6 @@ export default function SelfServicePage() {
required={field.isRequired}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
) : field.type === "paragraph" ? (
<textarea
id={`kiosk-field-${field.id}`}
value={currentTicketAnswers[field.id] || ""}
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
required={field.isRequired}
rows={3}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500 resize-none"
/>
) : (
<input
id={`kiosk-field-${field.id}`}
@@ -1327,8 +1338,8 @@ export default function SelfServicePage() {
>
{formSubmitLoading
? "Submitting…"
: currentTicketIndex < mainTicketCount - 1
? `Next — Attendee ${currentTicketIndex + 2} of ${mainTicketCount}`
: currentTicketIndex < formsToFill - 1
? `Next — Attendee ${currentTicketIndex + 2} of ${formsToFill}`
: "Complete Registration"}
</button>
</form>
@@ -0,0 +1,167 @@
"use client";
import React, { useMemo, useState } from "react";
import { FileText } from "lucide-react";
import { ModalOverlay } from "@/components/shared/ModalOverlay";
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
import { apiV2Data } from "@/lib/apiV2";
import { errorMessage } from "@/lib/utils";
import type { Registration } from "@/types";
export type AttendeeFormField = { id?: string; type: string; label: string; isRequired?: boolean; helpText?: string | null; options?: unknown; order?: number };
export type AttendeeFormDef = { isRequired?: boolean; fields?: AttendeeFormField[] } | null | undefined;
/** Fields that are only text to read (a statement, a heading). */
const DISPLAY_FIELDS = ["statement", "paragraph"];
/** A required form with at least one question — the only kind tickets wait for. */
export const formNeedsAnswers = (form: AttendeeFormDef) =>
!!(form?.isRequired && (form.fields || []).some(f => !DISPLAY_FIELDS.includes(f.type)));
/**
* Attendee forms a registration still needs before its tickets are issued (as the server works
* it out, utils/ticketUtils.js formsOutstanding): one per main-ticket place — a picked-session
* booking once, not once per session — less the forms in. 0 once the form was left for later.
*/
export function formsStillNeeded(form: AttendeeFormDef, reg: Registration | null | undefined): number {
if (!reg || reg.formWaivedAt || !formNeedsAnswers(form)) return 0;
let places = 0;
const picked = new Map<string, number>();
for (const ro of reg.registrationOptions || []) {
if (!ro.eventOption?.isMainTicket) continue;
if (!ro.sessionId) { places += ro.quantity || 0; continue; }
const key = `${ro.eventOptionId}::${ro.variantId || ""}`;
picked.set(key, Math.max(picked.get(key) || 0, ro.quantity || 0));
}
picked.forEach(q => { places += q; });
return Math.max(0, places - (reg.formResponses || []).length);
}
/**
* The required attendee form at the door, in Manual registrations and in Corrections (1.52.0):
* one form per attendee still to fill in (`needed`), or "Fill in later" — the tickets are issued
* anyway and the payer is sent a link to fill it in. Closing the popup counts as "later", so a
* registration never stays without tickets because the form was skipped.
*/
export function AttendeeFormModal({ registrationId, form, needed, token, attendeeName, onDone }: {
registrationId: string;
form: AttendeeFormDef;
/** Attendee forms still to fill in. */
needed: number;
token: string | null;
attendeeName?: string;
/** `later`: the form was left for later. `ticketsIssued`: tickets made just now. */
onDone: (result: { later: boolean; ticketsIssued: number; sentTo?: string[] }) => void;
}) {
const fields = useMemo(() => [...(form?.fields || [])].filter(f => !!f.id).sort((a, b) => (a.order || 0) - (b.order || 0)), [form]);
const questions = fields.filter(f => !DISPLAY_FIELDS.includes(f.type));
const [index, setIndex] = useState(0);
const [answers, setAnswers] = useState<Record<string, string>[]>(() => Array.from({ length: Math.max(1, needed) }, () => ({})));
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const current = answers[index] || {};
const setAnswer = (fieldId: string, value: string) =>
setAnswers(prev => prev.map((a, i) => (i === index ? { ...a, [fieldId]: value } : a)));
const later = async () => {
if (busy) return;
setBusy(true);
setErr(null);
try {
const res = await (apiV2Data("post", "/registrations/{id}/form-later", { params: { id: registrationId }, authToken: token, body: {} }) as Promise<{ ticketsIssued: number; sentTo: string[] }>);
onDone({ later: true, ticketsIssued: res?.ticketsIssued || 0, sentTo: res?.sentTo || [] });
} catch (e) {
setErr(errorMessage(e) || "Couldn't leave the form for later");
setBusy(false);
}
};
const next = async (e: React.FormEvent) => {
e.preventDefault();
const missing = questions.find(f => f.isRequired && !String(current[f.id!] || "").trim());
if (missing) { setErr(`"${missing.label}" is required.`); return; }
setErr(null);
if (index < needed - 1) { setIndex(i => i + 1); return; }
setBusy(true);
try {
const res = await (apiV2Data("post", "/registrations/{id}/form-responses", {
params: { id: registrationId }, authToken: token, body: { responses: answers.slice(0, needed).map(a => ({ answers: a })) },
}) as Promise<{ generatedTickets?: unknown[] }>);
onDone({ later: false, ticketsIssued: Array.isArray(res?.generatedTickets) ? res.generatedTickets.length : 0 });
} catch (e2) {
setErr(errorMessage(e2) || "Couldn't save the form");
setBusy(false);
}
};
const input = (f: AttendeeFormField) => {
const id = `door-form-${f.id}`;
const value = current[f.id!] || "";
const cls = "w-full border rounded px-3 py-2 text-sm";
if (isExtraFieldType(f.type)) return <ExtraFieldInput field={f} idPrefix={`door${index}`} value={value} onChange={v => setAnswer(f.id!, v)} />;
if (f.type === "yes_no") {
return (
<select id={id} className={cls} value={value} onChange={e => setAnswer(f.id!, e.target.value)}>
<option value="">Select…</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
);
}
return <input id={id} className={cls} type={f.type === "numeric" ? "number" : f.type === "date" ? "date" : "text"} value={value} onChange={e => setAnswer(f.id!, e.target.value)} />;
};
return (
<ModalOverlay labelledBy="attendee-form-title" onClose={busy ? undefined : later} className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-white rounded-2xl w-full max-w-lg shadow-xl max-h-[90vh] flex flex-col">
<form onSubmit={next} className="p-6 space-y-4 overflow-y-auto">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-amber-50 flex items-center justify-center shrink-0">
<FileText className="w-5 h-5 text-amber-600" />
</div>
<div>
<h2 id="attendee-form-title" className="text-lg font-semibold text-gray-900">Attendee form{attendeeName ? ` — ${attendeeName}` : ""}</h2>
<p className="text-xs text-gray-500">
This event needs an attendee form before tickets are issued.
{needed > 1 ? ` Attendee ${index + 1} of ${needed}.` : ""}
</p>
</div>
</div>
{fields.map(f => DISPLAY_FIELDS.includes(f.type) ? (
<div key={f.id} className={f.type === "paragraph" ? "text-sm font-semibold text-gray-800" : "text-sm text-gray-600"}>
{f.label}
{f.helpText && <p className="text-xs font-normal text-gray-500 mt-0.5">{f.helpText}</p>}
</div>
) : (
<div key={f.id}>
<label htmlFor={`door-form-${f.id}`} className="block text-sm font-medium mb-1">
{f.label}{f.isRequired && <span className="text-red-500 ml-0.5">*</span>}
</label>
{input(f)}
{f.helpText && <p className="text-xs text-gray-500 mt-0.5">{f.helpText}</p>}
</div>
))}
{err && <p role="alert" className="text-sm text-red-600">{err}</p>}
<div className="flex flex-col-reverse sm:flex-row gap-2 pt-2">
<button type="button" onClick={later} disabled={busy} className="flex-1 py-2 border rounded text-sm disabled:opacity-50">
Fill in later
</button>
{index > 0 && (
<button type="button" onClick={() => setIndex(i => i - 1)} disabled={busy} className="py-2 px-4 border rounded text-sm disabled:opacity-50">
Back
</button>
)}
<button type="submit" disabled={busy} className="flex-1 py-2 rounded bg-emerald-600 text-white text-sm font-medium disabled:opacity-50">
{busy ? "Saving…" : index < needed - 1 ? `Next — attendee ${index + 2}` : "Save form"}
</button>
</div>
<p className="text-[11px] text-gray-500">&ldquo;Fill in later&rdquo; issues the tickets now (once it&rsquo;s paid) and sends the attendee a link to fill in the form.</p>
</form>
</div>
</ModalOverlay>
);
}
@@ -0,0 +1,47 @@
import React from "react";
import { Wrench, Ticket, FileText, Gift, CheckSquare, History } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for /dashboard/admin/corrections. */
export const adminCorrectionsHelpContent: HelpContent = {
title: "Corrections",
subtitle: "Put registrations right after the fact — admins only.",
tabs: [
{
key: "fix",
label: "What you can fix",
icon: Wrench,
content: (
<div className="space-y-4">
<GuideItem icon={Ticket} title="Missing tickets" tone="rose">
A paid registration without all its tickets — usually because a required attendee form was never filled in. &quot;Issue all missing tickets&quot; fixes every one at the event at once and leaves the forms for later. Tick &quot;Send them&quot; to email/WhatsApp the tickets (leave it off after the event).
</GuideItem>
<GuideItem icon={FileText} title="Attendee forms" tone="amber">
Fill in the form for someone, or issue their tickets with the form left for later.
</GuideItem>
<GuideItem icon={Ticket} title="Issue tickets — pay later" tone="brand">
Tickets for a registration that isn&apos;t paid yet. The balance is still owed and shows as outstanding until it&apos;s paid.
</GuideItem>
<GuideItem icon={Gift} title="Make it free" tone="green">
Writes off what&apos;s still owed as a complimentary discount — your income stays what was really paid — and issues the tickets. Not on a closed (cashed-up) event.
</GuideItem>
<GuideItem icon={CheckSquare} title="Check in after the event" tone="violet">
Check a ticket in (or undo it) for any session, after the event is over or closed. It counts as a manual check-in and no message is sent.
</GuideItem>
</div>
),
},
{
key: "log",
label: "Good to know",
icon: History,
content: (
<div className="space-y-4">
<p>Every correction is in the Audit log with who did it and the reason you give.</p>
<p>&quot;Needs attention&quot; lists registrations missing tickets, with forms still to fill in, or owing money without tickets. Past and closed events are in the event list too.</p>
</div>
),
},
],
};
+6
View File
@@ -18,6 +18,7 @@ import { dashboardAdminCashupHelpContent, dashboardAdminCashupDetailHelpContent
import { supervisorEventsHelpContent } from "./supervisor-events";
import { supervisorEventOptionsHelpContent } from "./supervisor-event-options";
import { supervisorManualHelpContent, supervisorManualRegistrationLegacyHelpContent } from "./supervisor-manual";
import { adminCorrectionsHelpContent } from "./admin-corrections";
import { supervisorAtTheDoorHelpContent } from "./supervisor-at-the-door";
import { supervisorPaymentsHelpContent } from "./supervisor-payments";
import { supervisorFormsHelpContent } from "./supervisor-forms";
@@ -233,6 +234,11 @@ const REGISTRY: HelpRegistryEntry[] = [
matches: pathname => pathname === "/dashboard/admin/privacy",
content: adminPrivacyHelpContent,
},
{
slug: "admin-corrections",
matches: pathname => pathname === "/dashboard/admin/corrections",
content: adminCorrectionsHelpContent,
},
{
slug: "admin-data-requests",
matches: pathname => pathname === "/dashboard/admin/data-requests",
@@ -41,6 +41,7 @@ export const supervisorAtTheDoorHelpContent: HelpContent = {
content: (
<div className="space-y-4">
<p>Selecting a registration automatically jumps to Payment mode if it&apos;s unpaid, or Check-in mode if it&apos;s already fully paid.</p>
<p>If the event has a required attendee form that hasn&apos;t been filled in, it pops up when you register someone, select them or take their payment — the tickets wait for it. Fill it in with them, or choose <strong>Fill in later</strong>: the tickets are issued as soon as it&apos;s paid and they&apos;re sent a link to fill in the form. Closing the popup counts as Fill in later.</p>
<p>When editing an existing registration&apos;s items, quantities can&apos;t be reduced below what&apos;s already been printed or paid for.</p>
<p>The item picker has a &quot;Have a code?&quot; field for discount codes. On an existing registration the code is applied when you save.</p>
<p>On events where people pick sessions, tick the sessions under a per-session ticket. For a new attendee, a place in today&apos;s session is selected for you when there&apos;s room. Sessions already booked on a registration can&apos;t be changed when editing it — book extra sessions as a new registration.</p>
@@ -15,6 +15,7 @@ export const supervisorManualHelpContent: HelpContent = {
content: (
<div className="space-y-4">
<p>Pick an event, then either search for an existing user or enter guest details (name, and at least one of email or phone). Choose ticket options and quantities — early-bird and staff pricing are applied automatically, and a discount code can be added under &quot;Have a code?&quot; — then create the registration. On events that take names, tick &quot;Registering for other people?&quot; to name each person. A new guest account is created inactive, and an activation link is sent immediately (by email if provided, otherwise WhatsApp) so they can set their own password.</p>
<p>If the event has a required attendee form, it pops up after you create the registration (and after a payment, if it&apos;s still outstanding). Fill it in now, or choose <strong>Fill in later</strong> — the tickets are issued once it&apos;s paid and the attendee is sent a link to the form.</p>
<p className="text-xs text-gray-500">After creating a registration, the Record Payment tab is pre-filled with it — switch tabs to take payment right away.</p>
</div>
),
File diff suppressed because it is too large Load Diff
+710
View File
@@ -3570,6 +3570,138 @@ export interface paths {
patch: operations["patchAllocation-groupsId"];
trace?: never;
};
"/corrections/events/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* An event's registrations, with what needs putting right
* @description Every registration that isn't cancelled — closed and past events too — with its balance, forms still to fill in, tickets and check-ins.
*
* **Who:** Admins only.
*/
get: operations["getCorrectionsEventsId"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/corrections/events/{id}/issue-missing": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Issue every missing ticket at an event
* @description For each paid registration (or one released unpaid) without all its tickets. Attendee forms still outstanding are left for later.
*
* **Who:** Admins only.
*/
post: operations["postCorrectionsEventsIdIssue-missing"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/corrections/registrations/{id}/tickets": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Issue a registration's tickets now
* @description `skipForm` leaves the attendee form for later; `payLater` issues them before it's paid (the balance is still owed).
*
* **Who:** Admins only.
*/
post: operations["postCorrectionsRegistrationsIdTickets"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/corrections/registrations/{id}/complimentary": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Make it free
* @description What's still owed is written off as a complimentary discount (income stays what was really paid), it's marked paid and its tickets are issued. Not on a closed event.
*
* **Who:** Admins only.
*/
post: operations["postCorrectionsRegistrationsIdComplimentary"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/corrections/tickets/{id}/check-in": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Check a ticket in (or undo it) after the fact
* @description Any time — after the event or session, or once the event is closed. `present: false` undoes the check-in for that session. Recorded as a manual check-in; no message is sent.
*
* **Who:** Admins only.
*/
post: operations["postCorrectionsTicketsIdCheck-in"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/registrations/{id}/form-later": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Fill in the attendee form later
* @description At the door: the tickets stop waiting for the required attendee form (issued now if it's paid) and the payer is sent a link to fill it in (`notify: false` to not).
*
* **Who:** Staff, supervisors and admins.
*/
post: operations["postRegistrationsIdForm-later"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/me/family": {
parameters: {
query?: never;
@@ -19582,6 +19714,584 @@ export interface operations {
};
};
};
getCorrectionsEventsId: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
event: {
id: string;
title: string;
startDate: unknown;
endDate: unknown;
closed: boolean;
formRequired: boolean;
sessions: {
id: string;
label: string;
startsAt: unknown;
}[];
};
registrations: {
id: string;
status: string;
createdAt: unknown;
user: {
id: string;
name: string;
email: string | null;
phoneNumber: string | null;
};
totalDue: number;
paid: number;
balance: number;
/** @description Places booked (every line) */
places: number;
/** @description Places with a ticket */
ticketPlaces: number;
/** @description Paid (or released unpaid) but not every place has a ticket */
missingTickets: boolean;
/** @description Attendee forms still holding the tickets back */
formsNeeded: number;
/** @description The form was left for later */
formWaived: boolean;
/** @description Tickets issued before it was paid */
ticketsReleased: boolean;
/** @description Balance written off */
complimentary: number;
tickets: {
id: string;
qrCode: string;
/** @description Ticket option, variant and (a picked session) the session */
label: string;
isMainTicket: boolean;
/** @description Who it's for */
name: string;
quantity: number;
/** @description The sessions it covers (`event` on a single-date event) */
sessionIds: string[];
/** @description People checked in, per session */
checkedIn: {
[key: string]: number;
};
}[];
}[];
};
};
};
};
/** @description Validation failed (VALIDATION_FAILED) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not signed in (UNAUTHENTICATED) */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not allowed for this role (FORBIDDEN) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not found (NOT_FOUND) */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
"postCorrectionsEventsIdIssue-missing": {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description Send the new tickets to the attendee (off by default) */
send?: boolean;
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
registrations: number;
tickets: number;
failed: {
id: string;
name: string;
message: string;
}[];
};
};
};
};
/** @description Validation failed (VALIDATION_FAILED) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not signed in (UNAUTHENTICATED) */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not allowed for this role (FORBIDDEN) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not found (NOT_FOUND) */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
postCorrectionsRegistrationsIdTickets: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
skipForm?: boolean;
payLater?: boolean;
/** @description Send the new tickets to the attendee (off by default) */
send?: boolean;
/** @description Why — kept in the audit log */
reason?: string | null;
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
ticketsIssued: number;
registration: {
id: string;
status: string;
createdAt: unknown;
user: {
id: string;
name: string;
email: string | null;
phoneNumber: string | null;
};
totalDue: number;
paid: number;
balance: number;
/** @description Places booked (every line) */
places: number;
/** @description Places with a ticket */
ticketPlaces: number;
/** @description Paid (or released unpaid) but not every place has a ticket */
missingTickets: boolean;
/** @description Attendee forms still holding the tickets back */
formsNeeded: number;
/** @description The form was left for later */
formWaived: boolean;
/** @description Tickets issued before it was paid */
ticketsReleased: boolean;
/** @description Balance written off */
complimentary: number;
tickets: {
id: string;
qrCode: string;
/** @description Ticket option, variant and (a picked session) the session */
label: string;
isMainTicket: boolean;
/** @description Who it's for */
name: string;
quantity: number;
/** @description The sessions it covers (`event` on a single-date event) */
sessionIds: string[];
/** @description People checked in, per session */
checkedIn: {
[key: string]: number;
};
}[];
};
};
};
};
};
/** @description Validation failed (VALIDATION_FAILED) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not signed in (UNAUTHENTICATED) */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not allowed for this role (FORBIDDEN) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not found (NOT_FOUND) */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
postCorrectionsRegistrationsIdComplimentary: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
skipForm?: boolean;
/** @description Send the new tickets to the attendee (off by default) */
send?: boolean;
/** @description Why — kept in the audit log */
reason?: string | null;
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
amount: number;
ticketsIssued: number;
registration: {
id: string;
status: string;
createdAt: unknown;
user: {
id: string;
name: string;
email: string | null;
phoneNumber: string | null;
};
totalDue: number;
paid: number;
balance: number;
/** @description Places booked (every line) */
places: number;
/** @description Places with a ticket */
ticketPlaces: number;
/** @description Paid (or released unpaid) but not every place has a ticket */
missingTickets: boolean;
/** @description Attendee forms still holding the tickets back */
formsNeeded: number;
/** @description The form was left for later */
formWaived: boolean;
/** @description Tickets issued before it was paid */
ticketsReleased: boolean;
/** @description Balance written off */
complimentary: number;
tickets: {
id: string;
qrCode: string;
/** @description Ticket option, variant and (a picked session) the session */
label: string;
isMainTicket: boolean;
/** @description Who it's for */
name: string;
quantity: number;
/** @description The sessions it covers (`event` on a single-date event) */
sessionIds: string[];
/** @description People checked in, per session */
checkedIn: {
[key: string]: number;
};
}[];
};
};
};
};
};
/** @description Validation failed (VALIDATION_FAILED) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not signed in (UNAUTHENTICATED) */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not allowed for this role (FORBIDDEN) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not found (NOT_FOUND) */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
"postCorrectionsTicketsIdCheck-in": {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description Multi-day events: the session */
sessionId?: string | null;
/** @description How many (default: everyone left on the ticket) */
people?: number | null;
/** @description false undoes it */
present?: boolean;
/** @description Why — kept in the audit log */
reason?: string | null;
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
checkedIn: number;
quantity: number;
};
};
};
};
/** @description Validation failed (VALIDATION_FAILED) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not signed in (UNAUTHENTICATED) */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not allowed for this role (FORBIDDEN) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not found (NOT_FOUND) */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
"postRegistrationsIdForm-later": {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
notify?: boolean;
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
ticketsIssued: number;
sentTo: string[];
};
};
};
};
/** @description Validation failed (VALIDATION_FAILED) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not signed in (UNAUTHENTICATED) */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not allowed for this role (FORBIDDEN) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not found (NOT_FOUND) */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
getMeFamily: {
parameters: {
query?: never;
+1
View File
@@ -46,6 +46,7 @@ export const COMMAND_PAGES: CommandPage[] = [
{ group: "Pages", label: "Offline scanning", href: "/dashboard/supervisor/offline-scans", roles: SUP, keywords: "devices sync" },
{ group: "Pages", label: "Attendee forms", href: "/dashboard/supervisor/forms", roles: S, keywords: "answers questions responses" },
{ group: "Pages", label: "Audit log", href: "/dashboard/admin/audit-log", roles: ADM, keywords: "history who changed" },
{ group: "Pages", label: "Corrections", href: "/dashboard/admin/corrections", roles: ADM, keywords: "fix missing tickets issue free complimentary late check in form override" },
{ group: "Pages", label: "Privacy (POPIA)", href: "/dashboard/admin/privacy", roles: ADM, keywords: "notice operators retention breaches readiness" },
{ group: "Pages", label: "Privacy requests", href: "/dashboard/admin/data-requests", roles: ADM, keywords: "delete download correction popia" },
{ group: "Pages", label: "My profile & security", href: "/dashboard/user/profile", roles: S, keywords: "password two-factor" },
+8 -1
View File
@@ -559,9 +559,16 @@ export type Registration = {
userId: string;
eventId: string;
status: RegistrationStatus;
// All discounts: bundle deals (bundleDiscount) plus the coupon.
// All discounts: bundle deals (bundleDiscount), the coupon, and a balance an admin made
// complimentary (complimentaryDiscount, 1.52.0).
discountTotal?: number;
bundleDiscount?: number;
complimentaryDiscount?: number;
// Corrections (1.52.0): the attendee form was left for later / tickets issued before payment.
formWaivedAt?: string | null;
ticketsReleasedAt?: string | null;
// Attendee forms still holding the tickets back (answered by POST /registrations/manual).
formsNeeded?: number;
bundleApplications?: BundleApplication[];
ticketDelivery?: "payer" | "each";
createdAt: string;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.51.17",
"version": "1.52.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.51.17",
"version": "1.52.0",
"license": "ISC",
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.51.17",
"version": "1.52.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",