From fe75912f238c32035c0731cbc59342924170332e Mon Sep 17 00:00:00 2001 From: Michilis Date: Thu, 12 Feb 2026 03:01:58 +0000 Subject: [PATCH] Fix event capacity: no negative availability, sold-out enforcement, admin override - Backend: use calculateAvailableSeats so availableSeats is never negative - Backend: reject public booking when confirmed >= capacity; admin create/manual bypass capacity - Frontend: spotsLeft = max(0, capacity - bookedCount), isSoldOut when bookedCount >= capacity - Frontend: sold-out redirect on booking page, cap quantity by spotsLeft, never show negative Co-authored-by: Cursor --- backend/src/routes/events.ts | 16 ++--- backend/src/routes/tickets.ts | 59 +++++-------------- .../src/app/(public)/book/[eventId]/page.tsx | 27 +++++++-- .../events/[id]/EventDetailClient.tsx | 11 ++-- .../src/app/(public)/events/[id]/page.tsx | 2 +- frontend/src/app/(public)/events/page.tsx | 2 +- frontend/src/app/admin/events/[id]/page.tsx | 2 +- frontend/src/app/admin/page.tsx | 10 ++-- 8 files changed, 61 insertions(+), 68 deletions(-) diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index a74e893..3eb64fc 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } from '../db/index.js'; import { eq, desc, and, gte, sql } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; -import { generateId, getNow, convertBooleansForDb, toDbDate } from '../lib/utils.js'; +import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js'; interface UserContext { id: string; @@ -151,10 +151,11 @@ eventsRouter.get('/', async (c) => { ); const normalized = normalizeEvent(event); + const bookedCount = ticketCount?.count || 0; return { ...normalized, - bookedCount: ticketCount?.count || 0, - availableSeats: normalized.capacity - (ticketCount?.count || 0), + bookedCount, + availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), }; }) ); @@ -189,11 +190,12 @@ eventsRouter.get('/:id', async (c) => { ); const normalized = normalizeEvent(event); + const bookedCount = ticketCount?.count || 0; return c.json({ event: { ...normalized, - bookedCount: ticketCount?.count || 0, - availableSeats: normalized.capacity - (ticketCount?.count || 0), + bookedCount, + availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), }, }); }); @@ -277,7 +279,7 @@ eventsRouter.get('/next/upcoming', async (c) => { event: { ...normalized, bookedCount, - availableSeats: normalized.capacity - bookedCount, + availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), isFeatured: true, }, }); @@ -308,7 +310,7 @@ eventsRouter.get('/next/upcoming', async (c) => { event: { ...normalized, bookedCount, - availableSeats: normalized.capacity - bookedCount, + availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), isFeatured: false, }, }); diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 96fe952..f6d0809 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js'; import { eq, and, sql } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; -import { generateId, generateTicketCode, getNow } from '../lib/utils.js'; +import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js'; import emailService from '../lib/email.js'; import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js'; @@ -87,15 +87,16 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { ) ); - const availableSeats = event.capacity - (existingTicketCount?.count || 0); - - if (availableSeats <= 0) { + const confirmedCount = existingTicketCount?.count || 0; + const availableSeats = calculateAvailableSeats(event.capacity, confirmedCount); + + if (isEventSoldOut(event.capacity, confirmedCount)) { return c.json({ error: 'Event is sold out' }, 400); } - + if (ticketCount > availableSeats) { - return c.json({ - error: `Not enough seats available. Only ${availableSeats} spot(s) remaining.` + return c.json({ + error: `Not enough seats available. Only ${availableSeats} spot(s) remaining.`, }, 400); } @@ -968,26 +969,11 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) if (!event) { return c.json({ error: 'Event not found' }, 404); } - - // Check capacity - const ticketCount = await dbGet( - (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, data.eventId), - sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` - ) - ) - ); - - if ((ticketCount?.count || 0) >= event.capacity) { - return c.json({ error: 'Event is at capacity' }, 400); - } - + + // Admin create at door: bypass capacity check (allow over-capacity for walk-ins) + const now = getNow(); - + // For door sales, email might be empty - use a generated placeholder const attendeeEmail = data.email && data.email.trim() ? data.email.trim() @@ -1116,24 +1102,9 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']) if (!event) { return c.json({ error: 'Event not found' }, 404); } - - // Check capacity - const existingCount = await dbGet( - (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, data.eventId), - sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` - ) - ) - ); - - if ((existingCount?.count || 0) >= event.capacity) { - return c.json({ error: 'Event is at capacity' }, 400); - } - + + // Admin manual ticket: bypass capacity check (allow over-capacity for admin-created tickets) + const now = getNow(); const attendeeEmail = data.email.trim(); diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index 5b00a69..106bd7a 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -150,14 +150,32 @@ export default function BookingPage() { router.push('/events'); return; } - + // Redirect to external booking if enabled if (eventRes.event.externalBookingEnabled && eventRes.event.externalBookingUrl) { window.location.href = eventRes.event.externalBookingUrl; return; } - + + const bookedCount = eventRes.event.bookedCount ?? 0; + const capacity = eventRes.event.capacity ?? 0; + const soldOut = bookedCount >= capacity; + if (soldOut) { + toast.error(t('events.details.soldOut')); + router.push(`/events/${eventRes.event.id}`); + return; + } + + const spotsLeft = Math.max(0, capacity - bookedCount); setEvent(eventRes.event); + // Cap quantity by available spots (never allow requesting more than spotsLeft) + setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft))); + setAttendees((prev) => { + const newQty = Math.min(initialQuantity, Math.max(1, spotsLeft)); + const need = Math.max(0, newQty - 1); + if (need === prev.length) return prev; + return Array(need).fill(null).map((_, i) => prev[i] ?? { firstName: '', lastName: '' }); + }); setPaymentConfig(paymentRes.paymentOptions); // Set default payment method based on what's enabled @@ -513,7 +531,8 @@ export default function BookingPage() { return null; } - const isSoldOut = event.availableSeats === 0; + const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0)); + const isSoldOut = (event.bookedCount ?? 0) >= event.capacity; // Get title and description based on payment method const getSuccessContent = () => { @@ -1035,7 +1054,7 @@ export default function BookingPage() { {!event.externalBookingEnabled && (
- {event.availableSeats} {t('events.details.spotsLeft')} + {spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
)}
diff --git a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx index fe2700f..94731bd 100644 --- a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx +++ b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx @@ -41,8 +41,10 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail .catch(console.error); }, [eventId]); - // Max tickets is remaining capacity - const maxTickets = Math.max(1, event.availableSeats || 1); + // Spots left: never negative; sold out when confirmed >= capacity + const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0)); + const isSoldOut = (event.bookedCount ?? 0) >= event.capacity; + const maxTickets = isSoldOut ? 0 : Math.max(1, spotsLeft); const decreaseQuantity = () => { setTicketQuantity(prev => Math.max(1, prev - 1)); @@ -68,7 +70,6 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail }); }; - const isSoldOut = event.availableSeats === 0; const isCancelled = event.status === 'cancelled'; // Only calculate isPastEvent after mount to avoid hydration mismatch const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false; @@ -154,7 +155,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail {!event.externalBookingEnabled && (

- {event.availableSeats} {t('events.details.spotsLeft')} + {spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}

)} @@ -257,7 +258,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail

{t('events.details.capacity')}

- {event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')} + {spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}

diff --git a/frontend/src/app/(public)/events/[id]/page.tsx b/frontend/src/app/(public)/events/[id]/page.tsx index 8cb8cec..05fb4df 100644 --- a/frontend/src/app/(public)/events/[id]/page.tsx +++ b/frontend/src/app/(public)/events/[id]/page.tsx @@ -118,7 +118,7 @@ function generateEventJsonLd(event: Event) { '@type': 'Offer', price: event.price, priceCurrency: event.currency, - availability: event.availableSeats && event.availableSeats > 0 + availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0 ? 'https://schema.org/InStock' : 'https://schema.org/SoldOut', url: `${siteUrl}/events/${event.id}`, diff --git a/frontend/src/app/(public)/events/page.tsx b/frontend/src/app/(public)/events/page.tsx index d70d3ba..8985852 100644 --- a/frontend/src/app/(public)/events/page.tsx +++ b/frontend/src/app/(public)/events/page.tsx @@ -140,7 +140,7 @@ export default function EventsPage() {
- {event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')} + {Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')}
)} diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx index 31d83fc..c190559 100644 --- a/frontend/src/app/admin/events/[id]/page.tsx +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -619,7 +619,7 @@ export default function AdminEventDetailPage() {

Capacity

{confirmedCount + checkedInCount} / {event.capacity} spots filled

-

{event.capacity - confirmedCount - checkedInCount} spots remaining

+

{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining

diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index e6d5cb0..f705969 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -113,12 +113,12 @@ export default function AdminDashboardPage() { {/* Low capacity warnings */} {data?.upcomingEvents .filter(event => { - const availableSeats = event.availableSeats ?? (event.capacity - (event.bookedCount || 0)); + const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0)); const percentFull = ((event.bookedCount || 0) / event.capacity) * 100; - return percentFull >= 80 && availableSeats > 0; + return percentFull >= 80 && spotsLeft > 0; }) .map(event => { - const availableSeats = event.availableSeats ?? (event.capacity - (event.bookedCount || 0)); + const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0)); const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100); return (
{event.title} -

Only {availableSeats} spots left ({percentFull}% full)

+

Only {spotsLeft} spots left ({percentFull}% full)

Low capacity @@ -140,7 +140,7 @@ export default function AdminDashboardPage() { {/* Sold out events */} {data?.upcomingEvents - .filter(event => (event.availableSeats ?? (event.capacity - (event.bookedCount || 0))) === 0) + .filter(event => Math.max(0, event.capacity - (event.bookedCount || 0)) === 0) .map(event => (