- 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)
236 lines
8.2 KiB
TypeScript
236 lines
8.2 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { zValidator } from '@hono/zod-validator';
|
|
import { z } from 'zod';
|
|
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';
|
|
|
|
interface UserContext {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
role: string;
|
|
}
|
|
|
|
const siteSettingsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
|
|
|
// Validation schema for updating site settings
|
|
const updateSiteSettingsSchema = z.object({
|
|
timezone: z.string().optional(),
|
|
siteName: z.string().optional(),
|
|
siteDescription: z.string().optional().nullable(),
|
|
siteDescriptionEs: z.string().optional().nullable(),
|
|
contactEmail: z.string().email().optional().nullable().or(z.literal('')),
|
|
contactPhone: z.string().optional().nullable(),
|
|
facebookUrl: z.string().url().optional().nullable().or(z.literal('')),
|
|
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(),
|
|
});
|
|
|
|
// Get site settings (public - needed for frontend timezone)
|
|
siteSettingsRouter.get('/', async (c) => {
|
|
const settings = await dbGet(
|
|
(db as any).select().from(siteSettings).limit(1)
|
|
);
|
|
|
|
if (!settings) {
|
|
// Return default settings if none exist
|
|
return c.json({
|
|
settings: {
|
|
timezone: 'America/Asuncion',
|
|
siteName: 'Spanglish',
|
|
siteDescription: null,
|
|
siteDescriptionEs: null,
|
|
contactEmail: null,
|
|
contactPhone: null,
|
|
facebookUrl: null,
|
|
instagramUrl: null,
|
|
twitterUrl: null,
|
|
linkedinUrl: null,
|
|
featuredEventId: null,
|
|
maintenanceMode: false,
|
|
maintenanceMessage: null,
|
|
maintenanceMessageEs: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
return c.json({ settings });
|
|
});
|
|
|
|
// Get available timezones
|
|
siteSettingsRouter.get('/timezones', async (c) => {
|
|
// Common timezones for Americas (especially relevant for Paraguay)
|
|
const timezones = [
|
|
{ value: 'America/Asuncion', label: 'Paraguay (Asunción) - UTC-4/-3' },
|
|
{ value: 'America/Sao_Paulo', label: 'Brazil (São Paulo) - UTC-3' },
|
|
{ value: 'America/Buenos_Aires', label: 'Argentina (Buenos Aires) - UTC-3' },
|
|
{ value: 'America/Santiago', label: 'Chile (Santiago) - UTC-4/-3' },
|
|
{ value: 'America/Lima', label: 'Peru (Lima) - UTC-5' },
|
|
{ value: 'America/Bogota', label: 'Colombia (Bogotá) - UTC-5' },
|
|
{ value: 'America/Caracas', label: 'Venezuela (Caracas) - UTC-4' },
|
|
{ value: 'America/La_Paz', label: 'Bolivia (La Paz) - UTC-4' },
|
|
{ value: 'America/Montevideo', label: 'Uruguay (Montevideo) - UTC-3' },
|
|
{ value: 'America/New_York', label: 'US Eastern - UTC-5/-4' },
|
|
{ value: 'America/Chicago', label: 'US Central - UTC-6/-5' },
|
|
{ value: 'America/Denver', label: 'US Mountain - UTC-7/-6' },
|
|
{ value: 'America/Los_Angeles', label: 'US Pacific - UTC-8/-7' },
|
|
{ value: 'America/Mexico_City', label: 'Mexico (Mexico City) - UTC-6/-5' },
|
|
{ value: 'Europe/London', label: 'UK (London) - UTC+0/+1' },
|
|
{ value: 'Europe/Madrid', label: 'Spain (Madrid) - UTC+1/+2' },
|
|
{ value: 'Europe/Paris', label: 'France (Paris) - UTC+1/+2' },
|
|
{ value: 'Europe/Berlin', label: 'Germany (Berlin) - UTC+1/+2' },
|
|
{ value: 'UTC', label: 'UTC (Coordinated Universal Time)' },
|
|
];
|
|
|
|
return c.json({ timezones });
|
|
});
|
|
|
|
// Update site settings (admin only)
|
|
siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSiteSettingsSchema), async (c) => {
|
|
const data = c.req.valid('json');
|
|
const user = c.get('user');
|
|
const now = getNow();
|
|
|
|
// Check if settings exist
|
|
const existing = await dbGet<any>(
|
|
(db as any).select().from(siteSettings).limit(1)
|
|
);
|
|
|
|
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',
|
|
siteName: data.siteName || 'Spanglish',
|
|
siteDescription: data.siteDescription || null,
|
|
siteDescriptionEs: data.siteDescriptionEs || null,
|
|
contactEmail: data.contactEmail || null,
|
|
contactPhone: data.contactPhone || null,
|
|
facebookUrl: data.facebookUrl || null,
|
|
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,
|
|
updatedAt: now,
|
|
updatedBy: user.id,
|
|
};
|
|
|
|
await (db as any).insert(siteSettings).values(newSettings);
|
|
|
|
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,
|
|
updatedAt: now,
|
|
updatedBy: user.id,
|
|
};
|
|
// Convert maintenanceMode boolean to appropriate format for database
|
|
if (typeof data.maintenanceMode === 'boolean') {
|
|
updateData.maintenanceMode = toDbBool(data.maintenanceMode);
|
|
}
|
|
|
|
await (db as any)
|
|
.update(siteSettings)
|
|
.set(updateData)
|
|
.where(eq((siteSettings as any).id, existing.id));
|
|
|
|
const updated = await dbGet(
|
|
(db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id))
|
|
);
|
|
|
|
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;
|