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:
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, siteSettings } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, dbGet, siteSettings, events } from '../db/index.js';
|
||||
import { eq, and, gte } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
||||
|
||||
@@ -27,6 +27,7 @@ const updateSiteSettingsSchema = z.object({
|
||||
instagramUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||
twitterUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||
linkedinUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||
featuredEventId: z.string().optional().nullable(),
|
||||
maintenanceMode: z.boolean().optional(),
|
||||
maintenanceMessage: z.string().optional().nullable(),
|
||||
maintenanceMessageEs: z.string().optional().nullable(),
|
||||
@@ -52,6 +53,7 @@ siteSettingsRouter.get('/', async (c) => {
|
||||
instagramUrl: null,
|
||||
twitterUrl: null,
|
||||
linkedinUrl: null,
|
||||
featuredEventId: null,
|
||||
maintenanceMode: false,
|
||||
maintenanceMessage: null,
|
||||
maintenanceMessageEs: null,
|
||||
@@ -104,6 +106,17 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
if (!existing) {
|
||||
// Create new settings record
|
||||
const id = generateId();
|
||||
|
||||
// Validate featured event if provided
|
||||
if (data.featuredEventId) {
|
||||
const featuredEvent = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, data.featuredEventId))
|
||||
);
|
||||
if (!featuredEvent || featuredEvent.status !== 'published') {
|
||||
return c.json({ error: 'Featured event must exist and be published' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const newSettings = {
|
||||
id,
|
||||
timezone: data.timezone || 'America/Asuncion',
|
||||
@@ -116,6 +129,7 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
instagramUrl: data.instagramUrl || null,
|
||||
twitterUrl: data.twitterUrl || null,
|
||||
linkedinUrl: data.linkedinUrl || null,
|
||||
featuredEventId: data.featuredEventId || null,
|
||||
maintenanceMode: toDbBool(data.maintenanceMode || false),
|
||||
maintenanceMessage: data.maintenanceMessage || null,
|
||||
maintenanceMessageEs: data.maintenanceMessageEs || null,
|
||||
@@ -128,6 +142,16 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
return c.json({ settings: newSettings, message: 'Settings created successfully' }, 201);
|
||||
}
|
||||
|
||||
// Validate featured event if provided
|
||||
if (data.featuredEventId) {
|
||||
const featuredEvent = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, data.featuredEventId))
|
||||
);
|
||||
if (!featuredEvent || featuredEvent.status !== 'published') {
|
||||
return c.json({ error: 'Featured event must exist and be published' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Update existing settings
|
||||
const updateData: Record<string, any> = {
|
||||
...data,
|
||||
@@ -151,4 +175,61 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
return c.json({ settings: updated, message: 'Settings updated successfully' });
|
||||
});
|
||||
|
||||
// Set featured event (admin only) - convenience endpoint for event editor
|
||||
siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('json', z.object({
|
||||
eventId: z.string().nullable(),
|
||||
})), async (c) => {
|
||||
const { eventId } = c.req.valid('json');
|
||||
const user = c.get('user');
|
||||
const now = getNow();
|
||||
|
||||
// Validate event if provided
|
||||
if (eventId) {
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||
);
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
if (event.status !== 'published') {
|
||||
return c.json({ error: 'Event must be published to be featured' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Get or create settings
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(siteSettings).limit(1)
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
// Create new settings record with featured event
|
||||
const id = generateId();
|
||||
const newSettings = {
|
||||
id,
|
||||
timezone: 'America/Asuncion',
|
||||
siteName: 'Spanglish',
|
||||
featuredEventId: eventId,
|
||||
maintenanceMode: 0,
|
||||
updatedAt: now,
|
||||
updatedBy: user.id,
|
||||
};
|
||||
|
||||
await (db as any).insert(siteSettings).values(newSettings);
|
||||
|
||||
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
|
||||
}
|
||||
|
||||
// Update existing settings
|
||||
await (db as any)
|
||||
.update(siteSettings)
|
||||
.set({
|
||||
featuredEventId: eventId,
|
||||
updatedAt: now,
|
||||
updatedBy: user.id,
|
||||
})
|
||||
.where(eq((siteSettings as any).id, existing.id));
|
||||
|
||||
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
|
||||
});
|
||||
|
||||
export default siteSettingsRouter;
|
||||
|
||||
@@ -25,7 +25,7 @@ const createTicketSchema = z.object({
|
||||
phone: z.string().min(6).optional().or(z.literal('')),
|
||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||
paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
||||
ruc: z.string().regex(/^[0-9]{6,8}-[0-9]{1}$/, 'Invalid RUC format').optional(),
|
||||
ruc: z.string().regex(/^\d{6,10}$/, 'Invalid RUC format').optional().or(z.literal('')),
|
||||
// Optional: array of attendees for multi-ticket booking
|
||||
attendees: z.array(attendeeSchema).optional(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user