Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3693d5f2c8 | ||
|
|
62b10c3db5 | ||
|
|
7ce25bf40b | ||
|
|
376b93a438 | ||
|
|
0cd78ab1b7 | ||
|
|
d49aebb1b0 | ||
|
|
98e5d05548 | ||
|
|
14ece27690 |
@@ -7,6 +7,72 @@ 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
|
||||
|
||||
- **Forgot password no longer reveals whether an account exists.** It answered "Email not found"
|
||||
(404) for an unknown address, so anyone could check which emails have accounts. It now gives
|
||||
the same answer and status for every address, sent before any work that only happens for a
|
||||
real account (so the response time doesn't tell either); the reset link is made and sent
|
||||
afterwards. Applies to `POST /api/users/forgot` and `POST /api/v2/auth/password/forgot`.
|
||||
- **Sign-in no longer reveals it either.** An unknown email answered "User does not exist" (with a
|
||||
different status on v1); it now gets exactly the answer a wrong password gets, after a password
|
||||
check against a stand-in hash so it takes as long.
|
||||
- Forgot password is rate-limited like sign-in, so it can't be used to flood someone's inbox with
|
||||
reset emails.
|
||||
|
||||
### Upgrading
|
||||
|
||||
- Update as soon as you can. Nothing else changes; the forgot-password page shows the new message.
|
||||
|
||||
## [1.51.16] - 2026-10-30
|
||||
|
||||
### Added
|
||||
|
||||
+15
-1
@@ -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 |
|
||||
|
||||
@@ -12,6 +12,26 @@ 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
|
||||
let anyone find out whether an email address has an account here.
|
||||
|
||||
## 1.51.16
|
||||
|
||||
- **The move to the new server connection is complete.** Payment confirmations from Yoco and
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.16",
|
||||
"version": "1.52.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.16",
|
||||
"version": "1.52.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@cj-tech-master/excelts": "^10.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.16",
|
||||
"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;
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -539,55 +539,52 @@ const requestPasswordReset = async (req, res) => {
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
// Explicitly check existence as requested
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'Email not found' });
|
||||
}
|
||||
|
||||
// Create token valid for 1 hour
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
|
||||
// If PasswordReset model isn't available (migration not run), return clear error
|
||||
if (!prisma.passwordReset || typeof prisma.passwordReset.create !== 'function' || typeof prisma.passwordReset.updateMany !== 'function') {
|
||||
console.warn('[PasswordReset] Prisma model not available. Run Prisma migrations to enable password reset tokens.');
|
||||
return res.status(500).json({ message: 'Password reset is not available. Please contact support.' });
|
||||
}
|
||||
|
||||
// Invalidate previous tokens (optional)
|
||||
await prisma.passwordReset.updateMany({
|
||||
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
|
||||
data: { used: true }
|
||||
});
|
||||
|
||||
await prisma.passwordReset.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
userId: user.id,
|
||||
token,
|
||||
expiresAt,
|
||||
used: false
|
||||
}
|
||||
});
|
||||
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
|
||||
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
|
||||
// Security: always email
|
||||
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
|
||||
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
|
||||
// Also WhatsApp if preferred (security message — sent in addition to email)
|
||||
const { waText } = require('../utils/notify');
|
||||
waText(user, emailContent.text).catch(() => {});
|
||||
|
||||
return res.json({ message: 'If that email exists, a password reset link has been sent.' });
|
||||
// Security (1.51.17): the same answer, with the same status, whether or not an account has
|
||||
// this email — and it's sent before any work that only happens for real accounts, so the
|
||||
// response time doesn't tell either. The link is made and sent afterwards.
|
||||
res.json({ message: GENERIC_RESET_MESSAGE });
|
||||
if (user) sendPasswordResetLink(user).catch(e => console.warn('[password reset] Failed:', e?.message || e));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const GENERIC_RESET_MESSAGE = 'If an account uses that email, we\'ve sent it a link to reset the password.';
|
||||
|
||||
/** A one-hour reset link for this account, by email (and WhatsApp when they prefer it). */
|
||||
async function sendPasswordResetLink(user) {
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
|
||||
// Invalidate previous tokens (optional)
|
||||
await prisma.passwordReset.updateMany({
|
||||
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
|
||||
data: { used: true }
|
||||
});
|
||||
|
||||
await prisma.passwordReset.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
userId: user.id,
|
||||
token,
|
||||
expiresAt,
|
||||
used: false
|
||||
}
|
||||
});
|
||||
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
|
||||
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
|
||||
// Security: always email
|
||||
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
|
||||
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
|
||||
// Also WhatsApp if preferred (security message — sent in addition to email)
|
||||
const { waText } = require('../utils/notify');
|
||||
waText(user, emailContent.text).catch(() => {});
|
||||
}
|
||||
|
||||
// @desc Reset password using token
|
||||
// @route POST /api/users/reset
|
||||
// @access Public
|
||||
|
||||
@@ -33,7 +33,7 @@ router.post('/', registerUser);
|
||||
router.post('/login', loginLimiter, loginUser);
|
||||
router.post('/login/2fa', loginLimiter, verifyLoginTwoFactor);
|
||||
router.post('/login/2fa/resend', loginLimiter, resendLoginTwoFactor);
|
||||
router.post('/forgot', requestPasswordReset);
|
||||
router.post('/forgot', loginLimiter, requestPasswordReset);
|
||||
router.post('/reset', resetPassword);
|
||||
router.post('/activate', activateAccount);
|
||||
|
||||
|
||||
@@ -207,6 +207,10 @@ export type LoginResult =
|
||||
| { status: 'signed_in'; session: Session }
|
||||
| { status: 'code_required'; challenge: { challengeId: string; method: string; destination: string | null }; enrolling: boolean };
|
||||
|
||||
let standIn: Promise<string> | null = null;
|
||||
/** A hash nobody's password matches, so a sign-in with an unknown email does the same work. */
|
||||
const standInHash = () => (standIn ??= auth.hashPassword(uuidv4()));
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCK_TIME = 5 * 60 * 1000;
|
||||
|
||||
@@ -219,7 +223,12 @@ export async function logIn(input: { identifier: string; password: string; devic
|
||||
// SA local format (0xx) → international (27xx)
|
||||
const normalizedPhone = whatsapp().normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
|
||||
const user = await prisma.user.findFirst({ where: { OR: [{ email: rawInput }, ...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : [])] } });
|
||||
if (!user) return fail(401, 'User does not exist');
|
||||
// Security (1.51.17): an unknown email answers like a wrong password — same message and
|
||||
// status, and a password check against a stand-in hash so it takes as long.
|
||||
if (!user) {
|
||||
await auth.comparePassword(input.password, await standInHash());
|
||||
return fail(401, 'Invalid email or password', undefined, { v1Status: 400 });
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
// Resend the activation link on each attempt, in case the earlier one expired.
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
|
||||
@@ -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) }; },
|
||||
});
|
||||
@@ -44,7 +44,7 @@ const qs = (req: Request) => (req.originalUrl.includes('?') ? req.originalUrl.sl
|
||||
// ─── Accounts ────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'post', path: '/auth/login/code/resend', tag: 'Accounts', summary: 'Send the sign-in code again', access: 'public', middleware: [loginLimiter], body: loose, v1: users.resendLoginTwoFactor });
|
||||
v1Route({ method: 'post', path: '/auth/password/forgot', tag: 'Accounts', summary: 'Email me a link to reset my password', description: 'NOT_FOUND when no account has that email (as v1 does).', access: 'public', body: z.looseObject({ email: z.string().max(200) }), v1: users.requestPasswordReset });
|
||||
v1Route({ method: 'post', path: '/auth/password/forgot', tag: 'Accounts', summary: 'Email me a link to reset my password', description: 'Always the same answer, so it doesn\'t say whether an account has that email. Rate-limited like sign-in.', access: 'public', middleware: [loginLimiter], body: z.looseObject({ email: z.string().max(200) }), v1: users.requestPasswordReset });
|
||||
v1Route({ method: 'post', path: '/auth/password/reset', tag: 'Accounts', summary: 'Set a new password with a reset link', access: 'public', body: loose, v1: users.resetPassword });
|
||||
v1Route({ method: 'post', path: '/auth/activate', tag: 'Accounts', summary: 'Activate an account with its link', access: 'public', body: loose, v1: users.activateAccount });
|
||||
v1Route({ method: 'post', path: '/me/sessions/revoke', tag: 'Me', summary: 'Sign out everywhere else', access: 'user', body: loose, v1: users.revokeMySession });
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 1.51.17 security fix: nothing public says whether an account exists. Forgot-password answers
|
||||
* the same for every email (before doing any per-account work), and sign-in with an unknown email
|
||||
* answers exactly like a wrong password.
|
||||
*/
|
||||
const mockDb = {
|
||||
user: { findUnique: jest.fn(), findFirst: jest.fn(), update: jest.fn() },
|
||||
passwordReset: { updateMany: jest.fn(), create: jest.fn() },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
const mockCompare = jest.fn(async () => false);
|
||||
jest.mock('../src/config/auth', () => ({ comparePassword: (...a: unknown[]) => (mockCompare as jest.Mock)(...a), hashPassword: async () => 'stand-in-hash', generateToken: () => 't' }));
|
||||
const mockSendMail = jest.fn(async () => undefined);
|
||||
jest.mock('../src/utils/email', () => ({ sendMail: (...a: unknown[]) => (mockSendMail as jest.Mock)(...a), buildPasswordResetEmail: () => ({ html: '', text: 'reset' }) }));
|
||||
jest.mock('../src/utils/notify', () => ({ waText: async () => undefined }));
|
||||
jest.mock('../src/utils/securityEvents', () => ({ logSecurityEvent: async () => undefined, getRecentSecurityEvents: async () => [] }));
|
||||
jest.mock('../src/utils/whatsapp', () => ({ normalizeZAPhone: () => null, isValidZAPhone: () => false }));
|
||||
|
||||
import { logIn } from '../src/services/auth';
|
||||
import { ServiceError } from '../src/services/errors';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { requestPasswordReset } = require('../src/controllers/userController');
|
||||
|
||||
type Answer = { status: number; body: unknown };
|
||||
function call(handler: (req: unknown, res: unknown) => Promise<void>, body: object): Promise<Answer> {
|
||||
return new Promise(resolve => {
|
||||
let status = 200;
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
status(n: number) { status = n; this.statusCode = n; return this; },
|
||||
json(b: unknown) { resolve({ status, body: b }); return this; },
|
||||
};
|
||||
handler({ body, headers: {}, ip: '127.0.0.1' }, res);
|
||||
});
|
||||
}
|
||||
|
||||
const realUser = { id: 'u1', name: 'Thandi', email: 'thandi@example.org', isActive: true, password: 'real-hash', failedAttempts: 0, lockUntil: null, role: 'user' };
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('forgot password', () => {
|
||||
test('answers the same, with the same status, for an email with and without an account', async () => {
|
||||
mockDb.user.findUnique.mockResolvedValueOnce(null);
|
||||
const unknown = await call(requestPasswordReset, { email: 'nobody@example.org' });
|
||||
mockDb.user.findUnique.mockResolvedValueOnce(realUser);
|
||||
const known = await call(requestPasswordReset, { email: realUser.email });
|
||||
expect(unknown).toEqual(known);
|
||||
expect(known.status).toBe(200);
|
||||
});
|
||||
|
||||
test('only a real account gets a link, made after the answer is sent', async () => {
|
||||
mockDb.user.findUnique.mockResolvedValueOnce(null);
|
||||
await call(requestPasswordReset, { email: 'nobody@example.org' });
|
||||
await new Promise(r => setImmediate(r));
|
||||
expect(mockDb.passwordReset.create).not.toHaveBeenCalled();
|
||||
expect(mockSendMail).not.toHaveBeenCalled();
|
||||
|
||||
mockDb.user.findUnique.mockResolvedValueOnce(realUser);
|
||||
const answered = call(requestPasswordReset, { email: realUser.email });
|
||||
await answered;
|
||||
await new Promise(r => setImmediate(r));
|
||||
expect(mockDb.passwordReset.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockSendMail).toHaveBeenCalledWith(expect.objectContaining({ to: realUser.email }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('sign-in', () => {
|
||||
const failure = async (p: Promise<unknown>) => { try { await p; } catch (e) { return e as ServiceError; } throw new Error('expected a refusal'); };
|
||||
|
||||
test('an unknown email is refused exactly like a wrong password', async () => {
|
||||
mockDb.user.findFirst.mockResolvedValueOnce(null);
|
||||
const unknown = await failure(logIn({ identifier: 'nobody@example.org', password: 'x' }, { ip: '1', userAgent: 'jest' }));
|
||||
mockDb.user.findFirst.mockResolvedValueOnce(realUser);
|
||||
const wrong = await failure(logIn({ identifier: realUser.email, password: 'x' }, { ip: '1', userAgent: 'jest' }));
|
||||
expect({ message: unknown.message, status: unknown.statusCode, v1: unknown.v1Status }).toEqual({ message: wrong.message, status: wrong.statusCode, v1: wrong.v1Status });
|
||||
});
|
||||
|
||||
test('an unknown email still does a password check, so it takes as long', async () => {
|
||||
mockDb.user.findFirst.mockResolvedValueOnce(null);
|
||||
await failure(logIn({ identifier: 'nobody@example.org', password: 'guess' }, { ip: '1', userAgent: 'jest' }));
|
||||
expect(mockCompare).toHaveBeenCalledWith('guess', 'stand-in-hash');
|
||||
});
|
||||
});
|
||||
@@ -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 }) });
|
||||
});
|
||||
});
|
||||
+48
-32
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-30, after **1.51.16** (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,9 +14,8 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.51.3 | **Express 5** (qs query parser and `req.body = {}` kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on `/api/v2` (`legacyErrorsToV2`, `reason`); v1 `Deprecation` + `successor-version` headers (`src/v2/deprecation.ts`) |
|
||||
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
|
||||
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
|
||||
| 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 |
|
||||
| 1.51.14 | **People and messaging on v2** — `resources/peopleMessaging.ts`; `/event-listing` for the public pages; `defineRoute` refuses duplicates |
|
||||
@@ -28,6 +27,11 @@ encrypted settings unreadable or break existing installs.
|
||||
| 1.51.8 | **Rest of event set-up on v2**: event in full, calendar, alias, ticket options and variants, attachments, sections, costs, image uploads — `src/services/eventDetails.ts`, `src/services/setup.ts`; frontend `fetchEvent()` and `lib/eventSetup.ts`; v2 `file` and `upload` routes |
|
||||
| 1.51.7 | **Setting events up on v2** (`/events/manage`, create, `PATCH`, deactivate, copy-from, notify-recipients) via `src/services/events.ts`; `fetchManagedEvents()` for every picker; **pg-boss 11** in schema `pgboss_v11` |
|
||||
| 1.51.6 | Command palette lists every report (from `ReportCatalog`) and every dashboard page (e2e drift check); exceljs → ExcelTS 10 (`@cj-tech-master/excelts`, no dependencies; e2e `e2e/excel.spec.ts`); backend `engines.node` >= 22 |
|
||||
| 1.51.5 | **Next.js 16** and React 19.3 (Turbopack), **ESLint 10** with Next 16's flat configs (`npm run lint` = `eslint src`); React Compiler-era rules as warnings for now; empty `middleware.ts` removed |
|
||||
| 1.51.4 | **Security and dependency updates**: Next.js 15.5.28, @sentry/nextjs, zod, multer, nodemailer and others for published advisories; minimum versions raised in package.json; cron-parser 5 |
|
||||
| 1.51.3 | **Express 5** (qs query parser and `req.body = {}` kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on `/api/v2` (`legacyErrorsToV2`, `reason`); v1 `Deprecation` + `successor-version` headers (`src/v2/deprecation.ts`) |
|
||||
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
|
||||
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
|
||||
| 1.51.0 | **TypeScript backend + `/api/v2` foundations** (§14): tsconfig (`allowJs`), build to `dist/`, launcher `src/index.js` → `src/app.js`; v2 route declarations with zod → validation, response stripping and OpenAPI 3.1 at `/docs/v2`; first endpoints events, event, me, my registrations. Tests `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts` |
|
||||
| 1.50.2 | Searchable **event picker** (`components/shared/EventPicker.tsx`) on staff/supervisor pages and the kiosk; searchable reports events list; **How webhooks work** guide on Site settings → Webhooks (examples from `EVENT_TYPES[].example`). e2e `e2e/event-picker.spec.ts` |
|
||||
| 1.50.1 | Long lists: audit-log search/role filter/all actions and inclusive To date, data-request search and paging, webhook-delivery status filter and Load older, supervisor Payments loads only what it needs (`unpaid`, `userId`, `from`, `donations` filters). `components/shared/Pager.tsx`; e2e `e2e/long-lists.spec.ts` |
|
||||
@@ -43,9 +47,9 @@ encrypted settings unreadable or break existing installs.
|
||||
| 1.47.2 | **Series: year on year** report (§6 part 3): `GET /api/series/:id/compare` (`compareSeries` in `utils/series.js`), report `seriesComparison` in `ReportsV2`, "Compare" on the Event series page |
|
||||
| 1.47.1 | **Event series** (§6 part 2): Event series page, Series box on events, public `/series/<slug>`, "Next in this series", follow (News & announcements email from the `series` job when a new event goes live). Migration `20261028140000_event_series` |
|
||||
| 1.47.0 | **Copy an event** (§6 part 1): "Copy" opens the event editor filled in from the source (campus, sessions, options, tiers, sections, form, recipients, feedback, certificates); "New start" moves every date; allocation groups and (admins) costs copied by `POST /api/events/:id/copy-from`. No registrations, coupons, attachments, kids check-in or volunteer roles |
|
||||
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
|
||||
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
|
||||
| 1.46.2 | Licence page: what's new (from WHATS-NEW.md via the Hub), withdraw update request, "Not now", urgent updates, provider notices banner (Hub 1.4.0: releases from git tags, Notices page) |
|
||||
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
|
||||
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
|
||||
| 1.45.1 | Privacy notice (built-in text) and terms of use brought up to date with kids check-in, sessions, certificates, campuses, the Hub heartbeat and where data is stored. Installs with a published notice must publish a new version themselves |
|
||||
| 1.45.0 | **Kids check-in** (§4), fully digital: families/children/authorised adults kept between events (My account → Kids), staff desk with pickup codes and a "write on the sticker" panel, rooms with age ranges and ratios, room screen (on duty, call parent, alerts), check-out by code (wrong code alerts, override with reason), offline desk with reserved codes, attendance through a free kids ticket; 6-month retention rule for children |
|
||||
| 1.44.0 | **Campus-scoped staff** (§12 part 2): Admin → Users "Limit to campuses"; `utils/campusScope.js` on every staff/supervisor route (and owner-or-staff routes), lists filtered, scanner names the other campus, church-wide events view/scan/door-only for scoped staff; route test `tests/campusScopeRoutes.test.js` |
|
||||
@@ -59,10 +63,6 @@ encrypted settings unreadable or break existing installs.
|
||||
| 1.40.0 | POPIA toolkit part 3 — readiness checklist with score, retention policies + nightly `retention` job (inactive accounts anonymised after 5 years with a 30-day warning, on by default), breach register with notification drafts, children's/medical flag in the access log, `{retention}` in the notice |
|
||||
| 1.39.0 | POPIA toolkit part 2 — Admin → Privacy: privacy notice editor (built-in text as the start, placeholders, drafts, numbered versions, re-acceptance after login), operator register, consent log, access log; Information Officer on the contact page |
|
||||
| 1.38.0 | POPIA toolkit part 1 — Privacy page (news opt-in + history, download my data, correction and deletion requests), Event update vs News & announcements on every bulk send, unsubscribe links + one-click headers, WhatsApp STOP, one-time news question, Admin → Data requests, thorough anonymising |
|
||||
| 1.32.0 | Early-bird ribbon on event cards |
|
||||
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
|
||||
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, `scripts/deploy.sh` |
|
||||
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
|
||||
| 1.37.4 | Deploy script: no tag = latest main; per-install `ecosystem.config.js` out of git (`ecosystem.config.example.js`) |
|
||||
| 1.37.3 | Fix: the production build failed on unescaped quotes in a help text |
|
||||
| 1.37.2 | Payment status checks (PayFast transaction history; job every 15 min; Payments → Reconcile → Waiting for payment), PayFast Test connection |
|
||||
@@ -70,24 +70,37 @@ encrypted settings unreadable or break existing installs.
|
||||
| 1.37.0 | PayFast (signed form, ITN checks), `ProviderCheckout` with amount checks, the "Pay online" page (`/pay/<id>`) where payers choose a provider |
|
||||
| 1.36.0 | Payment provider interface (`backend/src/payments`) with Yoco behind it, `ProviderTransaction`, `Payment.provider`, Site settings → Payments (on/off and order per provider), R15 minimum |
|
||||
| 1.35.1 | Waitlist per session, session picker at the door, "All sessions" tickets let people tick sessions at one price; fixes (per-session stock limits, offline service worker serving stale scripts) |
|
||||
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
|
||||
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow (removed in 1.46.1), `scripts/deploy.sh` |
|
||||
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
|
||||
| 1.32.0 | Early-bird ribbon on event cards |
|
||||
|
||||
## Next up
|
||||
|
||||
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
|
||||
a test server (both done 2026-10-28).
|
||||
|
||||
1. **2.0.0** — AI helpers, and v1 removed. Every endpoint is on v2 since 1.51.16. Keep
|
||||
answering the v1 webhook paths and the old links in sent messages (`src/v2/links.ts`) after
|
||||
v1 goes: providers and old emails still use them.
|
||||
2. **Servers run Node 24** (Joshua, 2026-10-29), so the backend may need Node 22+. Still to do:
|
||||
move from ExcelTS — deprecated in favour of `documonster` by the same author (pre-1.0 in Oct
|
||||
2026) — once documonster is stable. After 1.51.7 has run everywhere, the old `pgboss` schema
|
||||
can be dropped (`DROP SCHEMA pgboss CASCADE;`).
|
||||
Languages (Afrikaans, §10) are removed from scope (2026-10-28).
|
||||
2. AI rules decided 2026-10-29 (see §15 in the roadmap): the AI buttons show only when the
|
||||
licence's monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub
|
||||
charges the real cost plus a markup % (a default and a per-licence override) and counts the cap
|
||||
in charged rand. AI endpoints are `/api/v2` only.
|
||||
1. **2.0.0** — AI helpers (§15), v1 removed, a migration guide, and marketing site/docs for
|
||||
selling. Every endpoint is on v2 since 1.51.16. Keep answering the v1 webhook paths and the
|
||||
old links in sent messages (`src/v2/links.ts`) after v1 goes: providers and old emails still
|
||||
use them.
|
||||
2. **AI rules** (decided 2026-10-29, roadmap §15): the AI buttons show only when the licence's
|
||||
monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub charges the
|
||||
real cost plus a markup % (a default and a per-licence override) and counts the cap in charged
|
||||
rand. AI endpoints are `/api/v2` only. Check Claude Haiku 5.5's current prices before setting
|
||||
the markup and caps.
|
||||
3. **Clean-ups before or with 2.0:**
|
||||
- Turn the React Compiler lint rules back to errors (see [Next.js 16](#nextjs-16-1515)) — every
|
||||
screen is on v2 now, but they're still `warn` in `frontend/eslint.config.mjs`.
|
||||
- `node-fetch` is still listed in `backend/package.json` though nothing in `src/` uses it —
|
||||
remove it.
|
||||
- Password hashing upgrade with rehash-on-login (roadmap §14) isn't done: still `bcryptjs`.
|
||||
Decide whether it's in 2.0.
|
||||
4. **Later:** move from ExcelTS — deprecated in favour of `documonster` by the same author
|
||||
(pre-1.0 in Oct 2026) — once documonster is stable. After 1.51.7 has run everywhere, the old
|
||||
`pgboss` schema can be dropped (`DROP SCHEMA pgboss CASCADE;`).
|
||||
|
||||
Languages (Afrikaans, §10) and Wallet passes (§7) are removed from scope (2026-10-28).
|
||||
|
||||
## Working on this repo
|
||||
|
||||
@@ -175,8 +188,8 @@ a test server (both done 2026-10-28).
|
||||
up in its transaction history (`findPayments`); Yoco's Checkout API documents no lookup, so
|
||||
Yoco checkouts are "can't check". Tests: `backend/tests/paymentStatus.test.js`.
|
||||
- **Adding a provider:** write `providers/<id>.js` with the same interface, add it to
|
||||
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/v2/webhooks/<id>` (in `src/v2/links.ts`; add the v1 path in `routes/webhookRoutes.js` only for providers already registered there)
|
||||
(`routes/webhookRoutes.js`), and give it a settings card in
|
||||
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/v2/webhooks/<id>` (in `src/v2/links.ts`; add the v1 path in `routes/webhookRoutes.js` only for providers already registered there),
|
||||
and give it a settings card in
|
||||
`frontend/src/components/admin/PaymentsTab.tsx`. New providers start switched off.
|
||||
- `Payment.method` stays the wallet type (card, apple_pay…) because cashups bucket on it;
|
||||
the provider is `Payment.provider`.
|
||||
@@ -193,7 +206,8 @@ a test server (both done 2026-10-28).
|
||||
- Bulk sends: `broadcastController`, `whatsappBroadcastController`, and `emailEventAttendees` /
|
||||
`whatsappEventAttendees` in `eventController` take `kind`; scheduled payloads carry it.
|
||||
- WhatsApp STOP: `handleIncomingMessage` in `whatsappController.js` (WAWP `message` event on
|
||||
`/api/v2/webhooks/whatsapp?secret=…` — older set-ups use `/api/whatsapp/webhook`, which still works; secret in AppSetting `WAWP_WEBHOOK_SECRET`).
|
||||
`/api/v2/webhooks/whatsapp?secret=…` — older set-ups use `/api/whatsapp/webhook` or `/api/webhooks/whatsapp`, which run the same handler
|
||||
since 1.51.16; secret in AppSetting `WAWP_WEBHOOK_SECRET`).
|
||||
- Frontend: `app/dashboard/user/privacy`, `app/unsubscribe`, `app/dashboard/admin/data-requests`,
|
||||
`components/shared/NewsPrompt.tsx` (dashboard layout), `MessageKindPicker.tsx`.
|
||||
- **Anything new that stores personal data** must be added to `anonymiseUser` and
|
||||
@@ -466,7 +480,8 @@ a test server (both done 2026-10-28).
|
||||
- A list that grows for ever must page on the server (`page`/`limit`, `total`, `pages`) or load
|
||||
older items with a `before` cursor, and offer a search. `components/shared/Pager.tsx` renders
|
||||
"21–40 of 312 · Prev · Next". Already paged: users, registrations (Manage registrations), events,
|
||||
payments, audit log, consent/access logs, data requests, My payments.
|
||||
payments, audit log, consent/access logs, data requests, My payments. (Paths below are the v1
|
||||
names; each has a v2 successor in `SUCCESSORS`, `src/v2/deprecation.ts`.)
|
||||
- **Audit-log actions:** a new `AdminAuditAction` needs a label in `ACTION_LABELS`
|
||||
(`app/dashboard/admin/audit-log/page.tsx`) — the filter is built from that list.
|
||||
- **Still loading everything (fine for now, watch as installs grow):** user pickers that call
|
||||
@@ -547,8 +562,9 @@ a test server (both done 2026-10-28).
|
||||
- Turbopack for dev and build (the default). Lint with `npm run lint` / `npx eslint src` (`next lint`
|
||||
is gone). There's no middleware/proxy file — add `src/proxy.ts` (Next 16's name) only if needed.
|
||||
- **React Compiler lint rules are warnings, not errors** (`eslint.config.mjs`): 181 existing places,
|
||||
mostly `useEffect(() => { load(); }, [...])`. Fix them in a screen when it's moved onto v2
|
||||
(1.51.6 – 1.51.11), then turn the rules back to errors. Don't add new ones.
|
||||
mostly `useEffect(() => { load(); }, [...])`. The plan was to fix them as each screen moved onto
|
||||
v2; every screen is on v2 now (1.51.15) but the rules are still `warn` — fix what's left and turn
|
||||
them back to errors before or with 2.0. Don't add new ones.
|
||||
- `eslint-config-next` 16's bundled plugins (react, import, jsx-a11y) don't list ESLint 10 as a peer
|
||||
yet; they work, and npm notes the override. Revisit when they publish ESLint 10 support.
|
||||
|
||||
@@ -593,13 +609,14 @@ a test server (both done 2026-10-28).
|
||||
## Event picker and webhook guide (1.50.2)
|
||||
|
||||
- **Choosing an event:** use `EventPicker` (`components/shared/EventPicker.tsx`), not a `<select>`.
|
||||
Give it the whole list (`/api/events/all?includePast=true`); it opens on upcoming events, keeps
|
||||
Give it the whole list (`fetchManagedEvents(token, { past: true })`, v2 `GET /events/manage`
|
||||
since 1.51.7); it opens on upcoming events, keeps
|
||||
past ones behind "Show past events", and searches all of them. `emptyLabel` adds a clearable
|
||||
choice ("All events"), `graceHours` keeps just-ended events current (the scanner uses 12),
|
||||
`describe` adds detail per row, and `defaultEventId(events)` picks the next upcoming one. In e2e,
|
||||
`pickEvent(page, title)` from `e2e/fixtures.ts`.
|
||||
- `/api/events/all` still returns full events (options, form, sessions) — fine for a few hundred;
|
||||
a slim `?fields=` mode would help if a church gets into the thousands.
|
||||
- The managed events list still returns full events (options, form, sessions) — fine for a few
|
||||
hundred; a slim `?fields=` mode would help if a church gets into the thousands.
|
||||
- **Webhook examples:** a new webhook event type needs an entry in `EXAMPLES` in
|
||||
`utils/webhooks.js`; `tests/webhooks.test.js` fails if an example and `buildPayload` differ in
|
||||
shape.
|
||||
@@ -610,7 +627,6 @@ a test server (both done 2026-10-28).
|
||||
into WAWP (events `message` + `session.status`), and only for senders WhatsApp identifies by
|
||||
phone number — a sender known only by a "LID" is logged and ignored (they can still use the
|
||||
link in the message).
|
||||
- `/api/webhooks/whatsapp` runs the real WAWP handler since 1.51.16 (it was an empty stub).
|
||||
- There's no log of individual messages sent, so "Download my data" can't include "messages
|
||||
sent to you" yet.
|
||||
|
||||
|
||||
+40
-45
@@ -11,41 +11,35 @@ sold — trustworthy (POPIA), flexible (multi-day events, several payment
|
||||
providers), maintainable (tests, CI, typed backend, versioned API), and with
|
||||
**AI helpers** as the headline feature (§15).
|
||||
|
||||
**Status (2026-10-27):** all features in this document are approved. Shipped
|
||||
so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events
|
||||
phase A (sessions, late joining, per-session check-in, attendance register,
|
||||
reminders, certificates); 1.34.0 job queue (pg-boss), Playwright E2E tests, Gitea CI
|
||||
workflow (waiting for a runner) and deploy script; 1.35.0 multi-day events phase B
|
||||
(pick sessions, per-session capacity), with 1.35.1 adding the per-session waitlist, the session
|
||||
picker at the door and session choice on "All sessions" tickets; 1.36.0 payment provider interface
|
||||
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page;
|
||||
1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
|
||||
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23);
|
||||
1.38.0 POPIA toolkit part 1 (privacy page, unsubscribe, WhatsApp STOP, data requests); 1.39.0 part 2 (privacy notice editor and versions, operator register, consent and access logs); 1.40.0 part 3 (retention policies and job, breach register, children’s-data flags, readiness page).
|
||||
1.41.0 the Hub connection (licence, heartbeat, read-only mode, `/health`, install script) with Hub 1.0.0 (`joshua/events-hub`);
|
||||
1.41.1–1.41.2 licence and job-time fixes; 1.41.3 renamed **Hope Events → Cross Events** (Hub 1.0.1:
|
||||
Cross Events Hub); 1.42.0 **Request update** button instead of the deploy command, with Hub 1.1.0
|
||||
(update requests); 1.42.1 the production Hub key built in. Later versions moved up by one
|
||||
(2026-10-27). Multi-campus is split in two — 1.43.0 campuses, 1.44.0 campus-scoped staff — so
|
||||
everything after moves up by one more (2026-10-27). 1.43.0 **campuses** shipped (settings, events
|
||||
per campus, public filter and campus pages, home campus, filters, campus broadcasts and summaries);
|
||||
1.44.0 **campus-scoped staff** shipped (Admin → Users "Limit to campuses", every staff route checked).
|
||||
Wallet passes are removed from scope (2026-10-28), so everything after volunteer rosters moves
|
||||
up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticker panel, rooms with
|
||||
ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or session, sign-ups and
|
||||
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
|
||||
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) 1.47.2 **year-on-year
|
||||
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. 1.49.0 **dashboard (Today, Waiting for you) and Ctrl+K palette**
|
||||
shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is done. Languages
|
||||
(Afrikaans) are removed from scope (2026-10-28), so everything after moves up by one. 1.50.0 **accessibility
|
||||
pass** shipped. 1.50.1 **pagination and search on long lists** and 1.50.2 **event picker and webhook guide** shipped. 1.51.0 **TypeScript backend and `/api/v2`
|
||||
foundations**, 1.51.1 **v2 registrations and payments**, 1.51.2 **v2 accounts and check-in** and 1.51.3 **Express 5,
|
||||
scanner on v2, v1 deprecation headers** shipped. 1.51.4 **security and dependency updates**, 1.51.5 **Next.js 16** and 1.51.6 **the complete command palette and
|
||||
ExcelTS** , 1.51.7 **setting events up on v2, pg-boss 11** and 1.51.8 **the rest of event set-up on v2** and 1.51.9
|
||||
**series, passes, deals and campuses on v2** 1.51.10 **managing registrations on v2**, 1.51.11 **money on v2** and 1.51.12 **tickets and
|
||||
attendance on v2** 1.51.13 **kids and volunteers on v2**, 1.51.14 **people and messaging on v2** and 1.51.15
|
||||
**privacy and admin on v2** and 1.51.16 **incoming webhooks and links on v2** shipped — every
|
||||
endpoint is on v2. **Next: 2.0.0.**
|
||||
**Status (2026-10-30):** all features in this document are approved, and everything before 2.0.0
|
||||
has shipped (1.32.0 – 1.51.17). **Next: 2.0.0** — AI helpers (§15) and v1 removed. The full
|
||||
version list is in [HANDOVER.md](./HANDOVER.md) and the [build order](#suggested-build-order) below.
|
||||
|
||||
- **Multi-day events** (§1): 1.33.0 phase A (sessions, late joining, per-session check-in,
|
||||
attendance register, reminders, certificates); 1.35.0 phase B (pick sessions, per-session
|
||||
capacity); 1.35.1 per-session waitlist, session picker at the door.
|
||||
- **Platform:** 1.34.0 job queue (pg-boss), Playwright E2E tests and deploy script (the Gitea CI
|
||||
workflow was removed in 1.46.1 — no runner).
|
||||
- **Payments** (§2): 1.36.0 provider interface (Yoco behind it); 1.37.0 – 1.37.2 PayFast, "Pay
|
||||
online", payment status checks. SnapScan and Zapper dropped (2026-10-23).
|
||||
- **POPIA toolkit** (§3): 1.38.0 – 1.40.0.
|
||||
- **Hub** (§13): 1.41.0 connection (licence, heartbeat, read-only mode, `/health`, install
|
||||
script) with Hub 1.0.0; 1.41.3 renamed **Hope Events → Cross Events**; 1.42.0 **Request
|
||||
update** with Hub 1.1.0; 1.42.1 production Hub key built in.
|
||||
- **Multi-campus** (§12): 1.43.0 campuses; 1.44.0 campus-scoped staff.
|
||||
- **Kids check-in** (§4) 1.45.0; **volunteer rosters** (§5) 1.46.0.
|
||||
- **Event series and cloning** (§6): 1.47.0 copy an event, 1.47.1 series, 1.47.2 year-on-year
|
||||
report, 1.47.3 season passes.
|
||||
- **Outbound webhooks** (§8) 1.48.0.
|
||||
- **Dashboard, palette, bulk actions, saved filters** (§9): 1.49.0 – 1.49.2.
|
||||
- **Accessibility** (§11) 1.50.0; 1.50.1 long lists; 1.50.2 event picker and webhook guide.
|
||||
- **TypeScript backend and `/api/v2`** (§14): 1.51.0 – 1.51.3 foundations, registrations and
|
||||
payments, accounts and check-in, Express 5; 1.51.4 security and dependency updates; 1.51.5
|
||||
Next.js 16; 1.51.6 command palette and ExcelTS; 1.51.7 – 1.51.16 every remaining area on v2 —
|
||||
**every endpoint is on v2**.
|
||||
- 1.51.17 security fix: forgot password and sign-in don't reveal whether an account exists.
|
||||
|
||||
Removed from scope (2026-10-28): Wallet passes (§7) and languages (§10).
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -833,13 +827,13 @@ keep adding to.
|
||||
|
||||
| Item | What |
|
||||
|------|------|
|
||||
| CI (Gitea Actions) | Lint, typecheck, Jest, build on every push; deploy on tag |
|
||||
| Playwright E2E | Register → pay (sandbox) → ticket → scan → cashup; multi-day check-in; kiosk |
|
||||
| Job queue | Move `setInterval` jobs out of `index.js` into `pg-boss` (uses Postgres): retries, no double runs, job history in admin |
|
||||
| Split big controllers | `registrationController` (2.3k lines), `eventController`, `paymentController` → services by domain. Done **as each feature touches them**, not as a big-bang rewrite |
|
||||
| TypeScript backend | Incremental (`allowJs`), new code in TS first; share zod schemas/types with the frontend |
|
||||
| API v2 | `/api/v2` with consistent errors, pagination and an OpenAPI spec (replaces the hand-written API docs). `/api` (v1) kept during the transition |
|
||||
| Dependencies | Express 5, native `fetch` instead of `node-fetch`, password hashing upgrade with rehash-on-login |
|
||||
| ~~CI (Gitea Actions)~~ | Added in 1.34.0, **removed in 1.46.1** (no machine to run a runner): lint, tests and deploy are done by hand |
|
||||
| Playwright E2E ✅ | Register → pay (sandbox) → ticket → scan → cashup; multi-day check-in; kiosk (1.34.0) |
|
||||
| Job queue ✅ | Move `setInterval` jobs out of `index.js` into `pg-boss` (uses Postgres): retries, no double runs, job history in admin (1.34.0; pg-boss 11 in 1.51.7) |
|
||||
| Split big controllers | `registrationController` (2.3k lines), `eventController`, `paymentController` → services by domain. Done **as each feature touches them**, not as a big-bang rewrite. Partly done (1.51.1 – 1.51.10); the handlers behind `v1Route` are a 2.x clean-up |
|
||||
| TypeScript backend ✅ | Incremental (`allowJs`), new code in TS first; share zod schemas/types with the frontend (1.51.0) |
|
||||
| API v2 ✅ | `/api/v2` with consistent errors, pagination and an OpenAPI spec (replaces the hand-written API docs). `/api` (v1) kept during the transition; every endpoint on v2 since 1.51.16 |
|
||||
| Dependencies | Express 5 ✅ (1.51.3); native `fetch` ✅ (nothing uses `node-fetch` any more — it's still in `backend/package.json`, to remove); password hashing upgrade with rehash-on-login — **not done** (still `bcryptjs`), decide whether it's in 2.0 |
|
||||
|
||||
---
|
||||
|
||||
@@ -868,7 +862,8 @@ never saves, sends or publishes by itself.
|
||||
|
||||
- **One provider: Claude (Anthropic), model Claude Haiku 5.5** — the cheapest
|
||||
current model ($0.10 per million input tokens, $0.50 per million output, for
|
||||
prompts under 100K tokens; prices as of 2026-10). Good enough for drafting and
|
||||
prompts under 100K tokens; prices as of 2026-10 — **check them against Anthropic's pricing
|
||||
page before setting the markup and caps**). Good enough for drafting and
|
||||
summarising; a helper can move to a bigger model later if quality needs it.
|
||||
- **Rough cost:** a helper call is ~2,000–10,000 tokens in and ~500 out, so well under
|
||||
US$0.002 per click — about R1.50–R3 per thousand clicks. Fixed instructions are
|
||||
@@ -1004,7 +999,7 @@ income justify the hardware:
|
||||
|-------|---------|----------|-----|
|
||||
| 0 | 1.32.0 ✅ | **Early-bird ribbon** on event cards (shipped 2026-10-22) | Quick win |
|
||||
| 1 | 1.33.0 ✅ | **Multi-day events — phase A** (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work |
|
||||
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
|
||||
| 2 | 1.34.0 ✅ | CI (removed in 1.46.1) + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
|
||||
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
|
||||
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
|
||||
| 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
@@ -1014,7 +1009,7 @@ income justify the hardware:
|
||||
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
|
||||
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (Afrikaans removed from scope) | Polish before the big release |
|
||||
| 11 | 1.51.0 ✅, 1.51.1 ✅, 1.51.2 ✅, 1.51.3 ✅ | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 registrations and payments as TS services, typed frontend calls; 1.51.2 check-in and accounts; 1.51.3 Express 5, the scanner on v2, v1 deprecation headers, v2-shaped shared middleware | The breaking-change groundwork |
|
||||
| 11b | 1.51.4 ✅ – 1.51.16 ✅ | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 managing registrations (the staff lists, status, at the door, bulk actions, items, coupons, attendees, attendee forms); 1.51.11 money, staff side (payments, cashup, provider transactions, waitlist, sponsorship, consent, form builder, feedback); 1.51.12 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.13 kids check-in and volunteer rosters; 1.51.14 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.15 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.16 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each puts its area's endpoints on v2 (1.51.1 – 1.51.10 by moving the logic into TS services; from 1.51.11 by `v1Route`, which runs the v1 handler behind a v2 route — moving those handlers into TS services is a 2.x clean-up that doesn't change the API), its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
|
||||
| 11b | 1.51.4 ✅ – 1.51.16 ✅ | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 managing registrations (the staff lists, status, at the door, bulk actions, items, coupons, attendees, attendee forms); 1.51.11 money, staff side (payments, cashup, provider transactions, waitlist, sponsorship, consent, form builder, feedback); 1.51.12 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.13 kids check-in and volunteer rosters; 1.51.14 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.15 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.16 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each puts its area's endpoints on v2 (1.51.1 – 1.51.10 by moving the logic into TS services; from 1.51.11 by `v1Route`, which runs the v1 handler behind a v2 route — moving those handlers into TS services is a 2.x clean-up that doesn't change the API), its screens onto `apiV2`, and its v1 routes into `SUCCESSORS`. Then 1.51.17, a security fix (forgot password and sign-in don't reveal accounts) | 2.0 can then remove v1 |
|
||||
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 removed (all of it is on v2 by then; the old webhook URLs and links already sent in messages keep forwarding to v2), migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
|
||||
+10
-3
@@ -307,9 +307,16 @@ test.describe("API v2", () => {
|
||||
|
||||
const users = (await (await request.get(`${API}/api/v2/users?page=1&limit=2`, { headers: sup })).json()).data;
|
||||
expect(users).toMatchObject({ page: 1, limit: 2 });
|
||||
// As v1: an unknown email is NOT_FOUND.
|
||||
const forgot = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: "nobody-e2e@example.com" } });
|
||||
expect((await forgot.json()).error.code).toBe("NOT_FOUND");
|
||||
// Forgot-password doesn't say whether an account exists (1.51.17): the same answer either way.
|
||||
const unknown = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: "nobody-e2e@example.com" } });
|
||||
const known = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: seed().users.user.email } });
|
||||
expect(unknown.status()).toBe(200);
|
||||
expect(await unknown.json()).toEqual(await known.json());
|
||||
// Nor does sign-in: an unknown email is refused like a wrong password.
|
||||
const noAccount = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: "nobody-e2e@example.com", password: "Wrong-password-1" } });
|
||||
const wrongPassword = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: seed().users.user.email, password: "Wrong-password-1" } });
|
||||
expect(noAccount.status()).toBe(wrongPassword.status());
|
||||
expect(await noAccount.json()).toEqual(await wrongPassword.json());
|
||||
});
|
||||
|
||||
test("privacy and admin: settings, my data, the audit log, webhooks, backups", async ({ request }) => {
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.16",
|
||||
"version": "1.52.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.16",
|
||||
"version": "1.52.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.16",
|
||||
"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’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>
|
||||
);
|
||||
}
|
||||
@@ -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’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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">“Fill in later” issues the tickets now (once it’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. "Issue all missing tickets" fixes every one at the event at once and leaves the forms for later. Tick "Send them" 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't paid yet. The balance is still owed and shows as outstanding until it's paid.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Gift} title="Make it free" tone="green">
|
||||
Writes off what'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>"Needs attention" 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>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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's unpaid, or Check-in mode if it's already fully paid.</p>
|
||||
<p>If the event has a required attendee form that hasn'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's paid and they're sent a link to fill in the form. Closing the popup counts as Fill in later.</p>
|
||||
<p>When editing an existing registration's items, quantities can't be reduced below what's already been printed or paid for.</p>
|
||||
<p>The item picker has a "Have a code?" 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's session is selected for you when there's room. Sessions already booked on a registration can'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 "Have a code?" — then create the registration. On events that take names, tick "Registering for other people?" 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's still outstanding). Fill it in now, or choose <strong>Fill in later</strong> — the tickets are issued once it'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
Vendored
+711
-1
@@ -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;
|
||||
@@ -4509,7 +4641,7 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* Email me a link to reset my password
|
||||
* @description NOT_FOUND when no account has that email (as v1 does).
|
||||
* @description Always the same answer, so it doesn't say whether an account has that email. Rate-limited like sign-in.
|
||||
*
|
||||
* **Who:** Anyone.
|
||||
*/
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.16",
|
||||
"version": "1.52.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.51.16",
|
||||
"version": "1.52.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.16",
|
||||
"version": "1.52.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user