feat: add featured event with automatic fallback

- Add featured_event_id to site_settings (schema + migration)
- Backend: featured event logic in /events/next/upcoming with auto-unset when event ends
- Site settings: PUT supports featuredEventId, add PUT /featured-event for admin
- Admin events: Set as featured checkbox in editor, star toggle in list, featured badge
- Admin settings: Featured Event section with current event and remove/change links
- API: siteSettingsApi.setFeaturedEvent(), Event.isFeatured, SiteSettings.featuredEventId
- Homepage/linktree unchanged: still use getNextUpcoming (now returns featured or fallback)
This commit is contained in:
Michilis
2026-02-03 19:24:00 +00:00
parent 0fd8172e04
commit 0c142884c7
9 changed files with 421 additions and 78 deletions

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices } from '../db/index.js';
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';
@@ -198,10 +198,92 @@ eventsRouter.get('/:id', async (c) => {
});
});
// Get next upcoming event (public)
// Helper function to get ticket count for an event
async function getEventTicketCount(eventId: string): Promise<number> {
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, eventId),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
)
);
return ticketCount?.count || 0;
}
// Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming
eventsRouter.get('/next/upcoming', async (c) => {
const now = getNow();
// First, check if there's a featured event in site settings
const settings = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
let featuredEvent = null;
let shouldUnsetFeatured = false;
if (settings?.featuredEventId) {
// Get the featured event
featuredEvent = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, settings.featuredEventId))
);
if (featuredEvent) {
// Check if featured event is still valid:
// 1. Must be published
// 2. Must not have ended (endDatetime >= now, or startDatetime >= now if no endDatetime)
const eventEndTime = featuredEvent.endDatetime || featuredEvent.startDatetime;
const isPublished = featuredEvent.status === 'published';
const hasNotEnded = eventEndTime >= now;
if (!isPublished || !hasNotEnded) {
// Featured event is no longer valid - mark for unsetting
shouldUnsetFeatured = true;
featuredEvent = null;
}
} else {
// Featured event no longer exists
shouldUnsetFeatured = true;
}
}
// If we need to unset the featured event, do it asynchronously
if (shouldUnsetFeatured && settings) {
// Unset featured event in background (don't await to avoid blocking response)
(db as any)
.update(siteSettings)
.set({ featuredEventId: null, updatedAt: now })
.where(eq((siteSettings as any).id, settings.id))
.then(() => {
console.log('Featured event auto-cleared (event ended or unpublished)');
})
.catch((err: any) => {
console.error('Failed to clear featured event:', err);
});
}
// If we have a valid featured event, return it
if (featuredEvent) {
const bookedCount = await getEventTicketCount(featuredEvent.id);
const normalized = normalizeEvent(featuredEvent);
return c.json({
event: {
...normalized,
bookedCount,
availableSeats: normalized.capacity - bookedCount,
isFeatured: true,
},
});
}
// Fallback: get the next upcoming published event
const event = await dbGet<any>(
(db as any)
.select()
@@ -220,26 +302,14 @@ eventsRouter.get('/next/upcoming', async (c) => {
return c.json({ event: null });
}
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
// This ensures check-in doesn't affect capacity/spots_left
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
)
);
const bookedCount = await getEventTicketCount(event.id);
const normalized = normalizeEvent(event);
return c.json({
event: {
...normalized,
bookedCount: ticketCount?.count || 0,
availableSeats: normalized.capacity - (ticketCount?.count || 0),
bookedCount,
availableSeats: normalized.capacity - bookedCount,
isFeatured: false,
},
});
});