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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`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<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`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();
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<UserGroupIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span>{event.availableSeats} {t('events.details.spotsLeft')}</span>
|
||||
<span>{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -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 && (
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
{event.availableSeats} {t('events.details.spotsLeft')}
|
||||
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -257,7 +258,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
<div>
|
||||
<p className="font-medium text-sm">{t('events.details.capacity')}</p>
|
||||
<p className="text-gray-600">
|
||||
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function EventsPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="w-4 h-4" />
|
||||
<span>
|
||||
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
{Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -619,7 +619,7 @@ export default function AdminEventDetailPage() {
|
||||
<div>
|
||||
<p className="font-medium">Capacity</p>
|
||||
<p className="text-gray-600">{confirmedCount + checkedInCount} / {event.capacity} spots filled</p>
|
||||
<p className="text-sm text-gray-500">{event.capacity - confirmedCount - checkedInCount} spots remaining</p>
|
||||
<p className="text-sm text-gray-500">{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<Link
|
||||
@@ -130,7 +130,7 @@ export default function AdminDashboardPage() {
|
||||
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
|
||||
<div>
|
||||
<span className="text-sm font-medium">{event.title}</span>
|
||||
<p className="text-xs text-gray-500">Only {availableSeats} spots left ({percentFull}% full)</p>
|
||||
<p className="text-xs text-gray-500">Only {spotsLeft} spots left ({percentFull}% full)</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge badge-warning">Low capacity</span>
|
||||
@@ -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 => (
|
||||
<Link
|
||||
key={event.id}
|
||||
|
||||
Reference in New Issue
Block a user