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:
Michilis
2026-02-12 03:01:58 +00:00
parent 8315029091
commit fe75912f23
8 changed files with 61 additions and 68 deletions

View File

@@ -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();