From fe75912f238c32035c0731cbc59342924170332e Mon Sep 17 00:00:00 2001 From: Michilis Date: Thu, 12 Feb 2026 03:01:58 +0000 Subject: [PATCH 1/2] 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 => ( Date: Thu, 12 Feb 2026 03:17:30 +0000 Subject: [PATCH 2/2] Add edit user details on admin users page - Backend: extend PUT /api/users/:id with email and accountStatus; admin-only for role/email/accountStatus; return isClaimed, rucNumber, accountStatus in user responses - Frontend: add Edit button and modal on /admin/users to edit name, email, phone, role, language preference, account status Co-authored-by: Cursor --- backend/src/routes/users.ts | 20 +++- frontend/src/app/admin/users/page.tsx | 153 +++++++++++++++++++++++++- 2 files changed, 171 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index ebeeb43..bc558b1 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -17,9 +17,11 @@ const usersRouter = new Hono<{ Variables: { user: UserContext } }>(); const updateUserSchema = z.object({ name: z.string().min(2).optional(), + email: z.string().email().optional(), phone: z.string().optional(), role: z.enum(['admin', 'organizer', 'staff', 'marketing', 'user']).optional(), languagePreference: z.enum(['en', 'es']).optional(), + accountStatus: z.enum(['active', 'unclaimed', 'suspended']).optional(), }); // Get all users (admin only) @@ -33,6 +35,9 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => { phone: (users as any).phone, role: (users as any).role, languagePreference: (users as any).languagePreference, + isClaimed: (users as any).isClaimed, + rucNumber: (users as any).rucNumber, + accountStatus: (users as any).accountStatus, createdAt: (users as any).createdAt, }).from(users); @@ -64,6 +69,9 @@ usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', phone: (users as any).phone, role: (users as any).role, languagePreference: (users as any).languagePreference, + isClaimed: (users as any).isClaimed, + rucNumber: (users as any).rucNumber, + accountStatus: (users as any).accountStatus, createdAt: (users as any).createdAt, }) .from(users) @@ -88,10 +96,16 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', return c.json({ error: 'Forbidden' }, 403); } - // Only admin can change roles + // Only admin can change roles, email, and account status if (data.role && currentUser.role !== 'admin') { delete data.role; } + if (data.email && currentUser.role !== 'admin') { + delete data.email; + } + if (data.accountStatus && currentUser.role !== 'admin') { + delete data.accountStatus; + } const existing = await dbGet( (db as any).select().from(users).where(eq((users as any).id, id)) @@ -114,6 +128,10 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', phone: (users as any).phone, role: (users as any).role, languagePreference: (users as any).languagePreference, + isClaimed: (users as any).isClaimed, + rucNumber: (users as any).rucNumber, + accountStatus: (users as any).accountStatus, + createdAt: (users as any).createdAt, }) .from(users) .where(eq((users as any).id, id)) diff --git a/frontend/src/app/admin/users/page.tsx b/frontend/src/app/admin/users/page.tsx index 249efe0..b1599a7 100644 --- a/frontend/src/app/admin/users/page.tsx +++ b/frontend/src/app/admin/users/page.tsx @@ -5,7 +5,8 @@ import { useLanguage } from '@/context/LanguageContext'; import { usersApi, User } from '@/lib/api'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; -import { TrashIcon } from '@heroicons/react/24/outline'; +import Input from '@/components/ui/Input'; +import { TrashIcon, PencilSquareIcon } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; export default function AdminUsersPage() { @@ -13,6 +14,16 @@ export default function AdminUsersPage() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [roleFilter, setRoleFilter] = useState(''); + const [editingUser, setEditingUser] = useState(null); + const [editForm, setEditForm] = useState({ + name: '', + email: '', + phone: '', + role: '' as User['role'], + languagePreference: '' as string, + accountStatus: '' as string, + }); + const [saving, setSaving] = useState(false); useEffect(() => { loadUsers(); @@ -51,6 +62,51 @@ export default function AdminUsersPage() { } }; + const openEditModal = (user: User) => { + setEditingUser(user); + setEditForm({ + name: user.name, + email: user.email, + phone: user.phone || '', + role: user.role, + languagePreference: user.languagePreference || '', + accountStatus: user.accountStatus || 'active', + }); + }; + + const handleEditSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editingUser) return; + + if (!editForm.name.trim() || editForm.name.trim().length < 2) { + toast.error('Name must be at least 2 characters'); + return; + } + if (!editForm.email.trim()) { + toast.error('Email is required'); + return; + } + + setSaving(true); + try { + await usersApi.update(editingUser.id, { + name: editForm.name.trim(), + email: editForm.email.trim(), + phone: editForm.phone.trim() || undefined, + role: editForm.role, + languagePreference: editForm.languagePreference || undefined, + accountStatus: editForm.accountStatus || undefined, + } as Partial); + toast.success('User updated successfully'); + setEditingUser(null); + loadUsers(); + } catch (error: any) { + toast.error(error.message || 'Failed to update user'); + } finally { + setSaving(false); + } + }; + const formatDate = (dateStr: string) => { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { year: 'numeric', @@ -162,6 +218,13 @@ export default function AdminUsersPage() {
+
+ + {/* Edit User Modal */} + {editingUser && ( +
+ +

Edit User

+ +
+ setEditForm({ ...editForm, name: e.target.value })} + required + minLength={2} + /> + + setEditForm({ ...editForm, email: e.target.value })} + required + /> + + setEditForm({ ...editForm, phone: e.target.value })} + placeholder="Optional" + /> + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ )} ); }