Compare commits
13 Commits
d44ac949b5
...
1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 15655e3987 | |||
|
|
5263fa6834 | ||
|
|
923c86a3b3 | ||
| d8b3864411 | |||
|
|
4aaffe99c7 | ||
| 194cbd6ca8 | |||
|
|
a11da5a977 | ||
| d5445c2282 | |||
|
|
6bc7e13e78 | ||
| dcfefc8371 | |||
|
|
c3897efd02 | ||
| b5f14335c4 | |||
|
|
62bf048680 |
@@ -19,7 +19,7 @@ GOOGLE_CLIENT_ID=
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
API_URL=http://localhost:3001
|
||||
FRONTEND_URL=http://localhost:3002
|
||||
FRONTEND_URL=http://localhost:3019
|
||||
|
||||
# Revalidation secret (shared with frontend for on-demand cache revalidation)
|
||||
# Must match the REVALIDATE_SECRET in frontend/.env
|
||||
@@ -72,3 +72,4 @@ SMTP_TLS_REJECT_UNAUTHORIZED=true
|
||||
# Maximum number of emails that can be sent per hour (default: 30)
|
||||
# If the limit is reached, queued emails will pause and resume automatically
|
||||
MAX_EMAILS_PER_HOUR=30
|
||||
|
||||
|
||||
22
backend/src/lib/revalidate.ts
Normal file
22
backend/src/lib/revalidate.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Trigger frontend cache revalidation (fire-and-forget)
|
||||
// Revalidates both the sitemap and the next-event data (homepage, llms.txt)
|
||||
export function revalidateFrontendCache() {
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
|
||||
const secret = process.env.REVALIDATE_SECRET;
|
||||
if (!secret) {
|
||||
console.warn('REVALIDATE_SECRET not set, skipping frontend revalidation');
|
||||
return;
|
||||
}
|
||||
fetch(`${frontendUrl}/api/revalidate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) console.error('Frontend revalidation failed:', res.status);
|
||||
else console.log('Frontend revalidation triggered (sitemap + next-event)');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Frontend revalidation error:', err.message);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
|
||||
import { eq, and, gte, sql, desc } from 'drizzle-orm';
|
||||
import { eq, and, gte, sql, desc, inArray } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
|
||||
@@ -222,6 +222,211 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
||||
return c.json({ tickets: enrichedTickets });
|
||||
});
|
||||
|
||||
// Export attendees for a specific event (admin) — CSV download
|
||||
adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
const status = c.req.query('status') || 'all'; // confirmed | checked_in | confirmed_pending | all
|
||||
const q = c.req.query('q') || '';
|
||||
|
||||
// Verify event exists
|
||||
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);
|
||||
}
|
||||
|
||||
// Build query for tickets belonging to this event
|
||||
let conditions: any[] = [eq((tickets as any).eventId, eventId)];
|
||||
|
||||
if (status === 'confirmed') {
|
||||
conditions.push(eq((tickets as any).status, 'confirmed'));
|
||||
} else if (status === 'checked_in') {
|
||||
conditions.push(eq((tickets as any).status, 'checked_in'));
|
||||
} else if (status === 'confirmed_pending') {
|
||||
conditions.push(inArray((tickets as any).status, ['confirmed', 'pending']));
|
||||
} else {
|
||||
// "all" — include everything
|
||||
}
|
||||
|
||||
let ticketList = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(conditions.length === 1 ? conditions[0] : and(...conditions))
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
);
|
||||
|
||||
// Apply text search filter in-memory
|
||||
if (q) {
|
||||
const query = q.toLowerCase();
|
||||
ticketList = ticketList.filter((t: any) => {
|
||||
const fullName = `${t.attendeeFirstName || ''} ${t.attendeeLastName || ''}`.toLowerCase();
|
||||
return (
|
||||
fullName.includes(query) ||
|
||||
(t.attendeeEmail || '').toLowerCase().includes(query) ||
|
||||
(t.attendeePhone || '').toLowerCase().includes(query) ||
|
||||
t.id.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Enrich each ticket with payment data
|
||||
const rows = await Promise.all(
|
||||
ticketList.map(async (ticket: any) => {
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
);
|
||||
|
||||
const fullName = [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' ');
|
||||
const isCheckedIn = ticket.status === 'checked_in';
|
||||
|
||||
return {
|
||||
'Ticket ID': ticket.id,
|
||||
'Full Name': fullName,
|
||||
'Email': ticket.attendeeEmail || '',
|
||||
'Phone': ticket.attendeePhone || '',
|
||||
'Status': ticket.status,
|
||||
'Checked In': isCheckedIn ? 'true' : 'false',
|
||||
'Check-in Time': ticket.checkinAt || '',
|
||||
'Payment Status': payment?.status || '',
|
||||
'Booked At': ticket.createdAt || '',
|
||||
'Notes': ticket.adminNote || '',
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Generate CSV
|
||||
const csvEscape = (value: string) => {
|
||||
if (value == null) return '';
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
'Ticket ID', 'Full Name', 'Email', 'Phone',
|
||||
'Status', 'Checked In', 'Check-in Time', 'Payment Status',
|
||||
'Booked At', 'Notes',
|
||||
];
|
||||
|
||||
const headerLine = columns.map(csvEscape).join(',');
|
||||
const dataLines = rows.map((row: any) =>
|
||||
columns.map((col) => csvEscape(row[col])).join(',')
|
||||
);
|
||||
|
||||
const csvContent = '\uFEFF' + [headerLine, ...dataLines].join('\r\n'); // BOM for UTF-8
|
||||
|
||||
// Build filename: event-slug-attendees-YYYY-MM-DD.csv
|
||||
const slug = (event.title || 'event')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const filename = `${slug}-attendees-${dateStr}.csv`;
|
||||
|
||||
c.header('Content-Type', 'text/csv; charset=utf-8');
|
||||
c.header('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return c.body(csvContent);
|
||||
});
|
||||
|
||||
// Legacy alias — keep old path working
|
||||
adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => {
|
||||
const newUrl = new URL(c.req.url);
|
||||
newUrl.pathname = newUrl.pathname.replace('/export', '/attendees/export');
|
||||
return c.redirect(newUrl.toString(), 301);
|
||||
});
|
||||
|
||||
// Export tickets for a specific event (admin) — CSV download (confirmed/checked_in only)
|
||||
adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
const status = c.req.query('status') || 'all'; // confirmed | checked_in | all
|
||||
const q = c.req.query('q') || '';
|
||||
|
||||
// Verify event exists
|
||||
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);
|
||||
}
|
||||
|
||||
// Only confirmed/checked_in for tickets export
|
||||
let conditions: any[] = [
|
||||
eq((tickets as any).eventId, eventId),
|
||||
inArray((tickets as any).status, ['confirmed', 'checked_in']),
|
||||
];
|
||||
|
||||
if (status === 'confirmed') {
|
||||
conditions = [eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'confirmed')];
|
||||
} else if (status === 'checked_in') {
|
||||
conditions = [eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'checked_in')];
|
||||
}
|
||||
|
||||
let ticketList = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
);
|
||||
|
||||
// Apply text search filter
|
||||
if (q) {
|
||||
const query = q.toLowerCase();
|
||||
ticketList = ticketList.filter((t: any) => {
|
||||
const fullName = `${t.attendeeFirstName || ''} ${t.attendeeLastName || ''}`.toLowerCase();
|
||||
return (
|
||||
fullName.includes(query) ||
|
||||
t.id.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const csvEscape = (value: string) => {
|
||||
if (value == null) return '';
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At'];
|
||||
|
||||
const rows = ticketList.map((ticket: any) => ({
|
||||
'Ticket ID': ticket.id,
|
||||
'Booking ID': ticket.bookingId || '',
|
||||
'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '),
|
||||
'Status': ticket.status,
|
||||
'Check-in Time': ticket.checkinAt || '',
|
||||
'Booked At': ticket.createdAt || '',
|
||||
}));
|
||||
|
||||
const headerLine = columns.map(csvEscape).join(',');
|
||||
const dataLines = rows.map((row: any) =>
|
||||
columns.map((col: string) => csvEscape(row[col])).join(',')
|
||||
);
|
||||
|
||||
const csvContent = '\uFEFF' + [headerLine, ...dataLines].join('\r\n');
|
||||
|
||||
const slug = (event.title || 'event')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const filename = `${slug}-tickets-${dateStr}.csv`;
|
||||
|
||||
c.header('Content-Type', 'text/csv; charset=utf-8');
|
||||
c.header('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return c.body(csvContent);
|
||||
});
|
||||
|
||||
// Export financial data (admin)
|
||||
adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
|
||||
const startDate = c.req.query('startDate');
|
||||
|
||||
@@ -5,6 +5,7 @@ import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, ema
|
||||
import { eq, desc, and, gte, sql } from 'drizzle-orm';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -15,29 +16,6 @@ interface UserContext {
|
||||
|
||||
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||
|
||||
// Trigger frontend cache revalidation (fire-and-forget)
|
||||
// Revalidates both the sitemap and the next-event data (homepage, llms.txt)
|
||||
function revalidateFrontendCache() {
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
|
||||
const secret = process.env.REVALIDATE_SECRET;
|
||||
if (!secret) {
|
||||
console.warn('REVALIDATE_SECRET not set, skipping frontend revalidation');
|
||||
return;
|
||||
}
|
||||
fetch(`${frontendUrl}/api/revalidate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) console.error('Frontend revalidation failed:', res.status);
|
||||
else console.log('Frontend revalidation triggered (sitemap + next-event)');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Frontend revalidation error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to normalize event data for API response
|
||||
// PostgreSQL decimal returns strings, booleans are stored as integers
|
||||
function normalizeEvent(event: any) {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -172,6 +173,11 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
(db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id))
|
||||
);
|
||||
|
||||
// Revalidate frontend cache if featured event changed
|
||||
if (data.featuredEventId !== undefined) {
|
||||
revalidateFrontendCache();
|
||||
}
|
||||
|
||||
return c.json({ settings: updated, message: 'Settings updated successfully' });
|
||||
});
|
||||
|
||||
@@ -216,6 +222,9 @@ siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('js
|
||||
|
||||
await (db as any).insert(siteSettings).values(newSettings);
|
||||
|
||||
// Revalidate frontend cache so homepage shows the updated featured event
|
||||
revalidateFrontendCache();
|
||||
|
||||
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
|
||||
}
|
||||
|
||||
@@ -229,6 +238,9 @@ siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('js
|
||||
})
|
||||
.where(eq((siteSettings as any).id, existing.id));
|
||||
|
||||
// Revalidate frontend cache so homepage shows the updated featured event
|
||||
revalidateFrontendCache();
|
||||
|
||||
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
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 { eq, and, or, sql } from 'drizzle-orm';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||
@@ -490,6 +490,125 @@ ticketsRouter.get('/:id/pdf', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get event check-in stats for scanner (lightweight endpoint for staff)
|
||||
ticketsRouter.get('/stats/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const eventId = c.req.query('eventId');
|
||||
|
||||
if (!eventId) {
|
||||
return c.json({ error: 'eventId is required' }, 400);
|
||||
}
|
||||
|
||||
// Get event info
|
||||
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);
|
||||
}
|
||||
|
||||
// Count checked-in tickets
|
||||
const checkedInCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
eq((tickets as any).status, 'checked_in')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Count confirmed + checked_in (total active)
|
||||
const totalActiveCount = 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 c.json({
|
||||
eventId,
|
||||
capacity: event.capacity,
|
||||
checkedIn: checkedInCount?.count || 0,
|
||||
totalActive: totalActiveCount?.count || 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Live search tickets (GET - for scanner live search)
|
||||
ticketsRouter.get('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const q = c.req.query('q')?.trim() || '';
|
||||
const eventId = c.req.query('eventId');
|
||||
|
||||
if (q.length < 2) {
|
||||
return c.json({ tickets: [] });
|
||||
}
|
||||
|
||||
const searchTerm = `%${q.toLowerCase()}%`;
|
||||
|
||||
// Search by name (ILIKE), email (ILIKE), ticket ID (exact or partial)
|
||||
const nameEmailConditions = [
|
||||
sql`LOWER(${(tickets as any).attendeeEmail}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeFirstName}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeLastName}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeFirstName} || ' ' || COALESCE(${(tickets as any).attendeeLastName}, '')) LIKE ${searchTerm}`,
|
||||
// Ticket ID exact or partial match (cast UUID to text for LOWER)
|
||||
sql`LOWER(CAST(${(tickets as any).id} AS TEXT)) LIKE ${searchTerm}`,
|
||||
sql`LOWER(CAST(${(tickets as any).qrCode} AS TEXT)) LIKE ${searchTerm}`,
|
||||
];
|
||||
|
||||
let whereClause: any = and(
|
||||
or(...nameEmailConditions),
|
||||
// Exclude cancelled tickets by default
|
||||
sql`${(tickets as any).status} != 'cancelled'`
|
||||
);
|
||||
|
||||
if (eventId) {
|
||||
whereClause = and(whereClause, eq((tickets as any).eventId, eventId));
|
||||
}
|
||||
|
||||
const matchingTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(whereClause)
|
||||
.limit(20)
|
||||
);
|
||||
|
||||
// Enrich with event details
|
||||
const results = await Promise.all(
|
||||
matchingTickets.map(async (ticket: any) => {
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
return {
|
||||
ticket_id: ticket.id,
|
||||
name: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||
email: ticket.attendeeEmail,
|
||||
status: ticket.status,
|
||||
checked_in: ticket.status === 'checked_in',
|
||||
checkinAt: ticket.checkinAt,
|
||||
event_id: ticket.eventId,
|
||||
qrCode: ticket.qrCode,
|
||||
event: event ? {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
startDatetime: event.startDatetime,
|
||||
location: event.location,
|
||||
} : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return c.json({ tickets: results });
|
||||
});
|
||||
|
||||
// Get ticket by ID
|
||||
ticketsRouter.get('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
@@ -554,6 +673,65 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
|
||||
return c.json({ ticket: updated });
|
||||
});
|
||||
|
||||
// Search tickets by name/email (for scanner manual search)
|
||||
ticketsRouter.post('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const { query, eventId } = body;
|
||||
|
||||
if (!query || typeof query !== 'string' || query.trim().length < 2) {
|
||||
return c.json({ error: 'Search query must be at least 2 characters' }, 400);
|
||||
}
|
||||
|
||||
const searchTerm = `%${query.trim().toLowerCase()}%`;
|
||||
|
||||
const conditions = [
|
||||
sql`LOWER(${(tickets as any).attendeeEmail}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeFirstName}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeLastName}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeFirstName} || ' ' || COALESCE(${(tickets as any).attendeeLastName}, '')) LIKE ${searchTerm}`,
|
||||
];
|
||||
|
||||
let whereClause = or(...conditions);
|
||||
|
||||
if (eventId) {
|
||||
whereClause = and(whereClause, eq((tickets as any).eventId, eventId));
|
||||
}
|
||||
|
||||
const matchingTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(whereClause)
|
||||
.limit(20)
|
||||
);
|
||||
|
||||
// Enrich with event details
|
||||
const results = await Promise.all(
|
||||
matchingTickets.map(async (ticket: any) => {
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
return {
|
||||
id: ticket.id,
|
||||
qrCode: ticket.qrCode,
|
||||
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
attendeePhone: ticket.attendeePhone,
|
||||
status: ticket.status,
|
||||
checkinAt: ticket.checkinAt,
|
||||
event: event ? {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
startDatetime: event.startDatetime,
|
||||
location: event.location,
|
||||
} : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return c.json({ tickets: results });
|
||||
});
|
||||
|
||||
// Validate ticket by QR code (for scanner)
|
||||
ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
|
||||
@@ -8,9 +8,9 @@ Type=simple
|
||||
User=spanglish
|
||||
Group=spanglish
|
||||
WorkingDirectory=/home/spanglish/Spanglish/backend
|
||||
EnvironmentFile=/home/spanglish/Spanglish/backend/.env
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=3018
|
||||
EnvironmentFile=/home/spanglish/Spanglish/backend/.env
|
||||
ExecStart=/usr/bin/node dist/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,14 +37,56 @@ export default function AdminLayout({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { t, locale } = useLanguage();
|
||||
const { user, isAdmin, isLoading, logout } = useAuth();
|
||||
const { user, hasAdminAccess, isLoading, logout } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
type Role = 'admin' | 'organizer' | 'staff' | 'marketing';
|
||||
const userRole = (user?.role || 'user') as Role;
|
||||
|
||||
const navigationWithRoles: { name: string; href: string; icon: typeof HomeIcon; allowedRoles: Role[] }[] = [
|
||||
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
|
||||
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
|
||||
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon, allowedRoles: ['admin'] },
|
||||
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
|
||||
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon, allowedRoles: ['admin'] },
|
||||
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon, allowedRoles: ['admin'] },
|
||||
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] },
|
||||
];
|
||||
|
||||
const allowedPathsForRole = new Set(
|
||||
navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole)).map((item) => item.href)
|
||||
);
|
||||
const defaultAdminRoute =
|
||||
userRole === 'staff' ? '/admin/scanner' : userRole === 'marketing' ? '/admin/contacts' : '/admin';
|
||||
|
||||
// All hooks must be called unconditionally before any early returns
|
||||
useEffect(() => {
|
||||
if (!isLoading && (!user || !isAdmin)) {
|
||||
if (!isLoading && (!user || !hasAdminAccess)) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [user, isAdmin, isLoading, router]);
|
||||
}, [user, hasAdminAccess, isLoading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !hasAdminAccess) return;
|
||||
if (!pathname.startsWith('/admin')) return;
|
||||
if (pathname === '/admin' && (userRole === 'staff' || userRole === 'marketing')) {
|
||||
router.replace(defaultAdminRoute);
|
||||
return;
|
||||
}
|
||||
const isPathAllowed = (path: string) => {
|
||||
if (allowedPathsForRole.has(path)) return true;
|
||||
return Array.from(allowedPathsForRole).some((allowed) => path.startsWith(allowed + '/'));
|
||||
};
|
||||
if (!isPathAllowed(pathname)) {
|
||||
router.replace(defaultAdminRoute);
|
||||
}
|
||||
}, [pathname, userRole, defaultAdminRoute, router, user, hasAdminAccess]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -54,31 +96,29 @@ export default function AdminLayout({
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || !isAdmin) {
|
||||
if (!user || !hasAdminAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon },
|
||||
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon },
|
||||
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon },
|
||||
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon },
|
||||
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon },
|
||||
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon },
|
||||
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon },
|
||||
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon },
|
||||
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon },
|
||||
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon },
|
||||
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon },
|
||||
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon },
|
||||
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon },
|
||||
];
|
||||
const visibleNav = navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole));
|
||||
const navigation = visibleNav;
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
// Scanner page gets fullscreen layout without sidebar
|
||||
const isScannerPage = pathname === '/admin/scanner';
|
||||
|
||||
if (isScannerPage) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-secondary-gray">
|
||||
{/* Mobile sidebar backdrop */}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,7 @@ interface LlmsEvent {
|
||||
async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
|
||||
next: { tags: ['next-event'] },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
@@ -41,7 +41,7 @@ async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
|
||||
async function getUpcomingEvents(): Promise<LlmsEvent[]> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/events?status=published&upcoming=true`, {
|
||||
next: { tags: ['next-event'] },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
@@ -115,7 +115,7 @@ function getEventStatus(event: LlmsEvent): string {
|
||||
async function getHomepageFaqs(): Promise<LlmsFaq[]> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/faq?homepage=true`, {
|
||||
next: { revalidate: 3600 },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
@@ -128,6 +128,8 @@ async function getHomepageFaqs(): Promise<LlmsFaq[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const [nextEvent, upcomingEvents, faqs] = await Promise.all([
|
||||
getNextUpcomingEvent(),
|
||||
|
||||
@@ -43,7 +43,7 @@ function MobileNavLink({ href, children, onClick }: { href: string; children: Re
|
||||
|
||||
export default function Header() {
|
||||
const { t } = useLanguage();
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
const { user, hasAdminAccess, logout } = useAuth();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const touchStartX = useRef<number>(0);
|
||||
@@ -148,7 +148,7 @@ export default function Header() {
|
||||
{t('nav.dashboard')}
|
||||
</Button>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
{hasAdminAccess && (
|
||||
<Link href="/admin">
|
||||
<Button variant="ghost" size="sm">
|
||||
{t('nav.admin')}
|
||||
@@ -270,7 +270,7 @@ export default function Header() {
|
||||
{t('nav.dashboard')}
|
||||
</Button>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
{hasAdminAccess && (
|
||||
<Link href="/admin" onClick={closeMenu}>
|
||||
<Button variant="outline" className="w-full justify-center">
|
||||
{t('nav.admin')}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface AuthContextType {
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
hasAdminAccess: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
loginWithGoogle: (credential: string) => Promise<void>;
|
||||
loginWithMagicLink: (token: string) => Promise<void>;
|
||||
@@ -177,6 +178,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const isAdmin = user?.role === 'admin' || user?.role === 'organizer';
|
||||
const hasAdminAccess = user?.role === 'admin' || user?.role === 'organizer' || user?.role === 'staff' || user?.role === 'marketing';
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
@@ -185,6 +187,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
token,
|
||||
isLoading,
|
||||
isAdmin,
|
||||
hasAdminAccess,
|
||||
login,
|
||||
loginWithGoogle,
|
||||
loginWithMagicLink,
|
||||
|
||||
@@ -92,6 +92,27 @@ export const ticketsApi = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code, eventId }),
|
||||
}),
|
||||
|
||||
// Search tickets by name/email (for scanner manual search)
|
||||
search: (query: string, eventId?: string) =>
|
||||
fetchApi<{ tickets: TicketSearchResult[] }>('/api/tickets/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query, eventId }),
|
||||
}),
|
||||
|
||||
// Get event check-in stats (for scanner header counter)
|
||||
getCheckinStats: (eventId: string) =>
|
||||
fetchApi<{ eventId: string; capacity: number; checkedIn: number; totalActive: number }>(
|
||||
`/api/tickets/stats/checkin?eventId=${eventId}`
|
||||
),
|
||||
|
||||
// Live search tickets (GET - for scanner live search with debounce)
|
||||
searchLive: (q: string, eventId?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('q', q);
|
||||
if (eventId) params.set('eventId', eventId);
|
||||
return fetchApi<{ tickets: LiveSearchResult[] }>(`/api/tickets/search?${params}`);
|
||||
},
|
||||
|
||||
checkin: (id: string) =>
|
||||
fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; message: string }>(`/api/tickets/${id}/checkin`, {
|
||||
@@ -351,6 +372,49 @@ export const adminApi = {
|
||||
if (params?.eventId) query.set('eventId', params.eventId);
|
||||
return fetchApi<{ payments: ExportedPayment[]; summary: FinancialSummary }>(`/api/admin/export/financial?${query}`);
|
||||
},
|
||||
/** Download attendee export as a file (CSV). Returns a Blob. */
|
||||
exportAttendees: async (eventId: string, params?: { status?: string; format?: string; q?: string }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.format) query.set('format', params.format);
|
||||
if (params?.q) query.set('q', params.q);
|
||||
const token = typeof window !== 'undefined'
|
||||
? localStorage.getItem('spanglish-token')
|
||||
: null;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(`${API_BASE}/api/admin/events/${eventId}/attendees/export?${query}`, { headers });
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
|
||||
throw new Error(errorData.error || 'Export failed');
|
||||
}
|
||||
const disposition = res.headers.get('Content-Disposition') || '';
|
||||
const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
|
||||
const filename = filenameMatch ? filenameMatch[1] : `attendees-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
const blob = await res.blob();
|
||||
return { blob, filename };
|
||||
},
|
||||
/** Download tickets export as CSV. Returns a Blob. */
|
||||
exportTicketsCSV: async (eventId: string, params?: { status?: string; q?: string }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.q) query.set('q', params.q);
|
||||
const token = typeof window !== 'undefined'
|
||||
? localStorage.getItem('spanglish-token')
|
||||
: null;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(`${API_BASE}/api/admin/events/${eventId}/tickets/export?${query}`, { headers });
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
|
||||
throw new Error(errorData.error || 'Export failed');
|
||||
}
|
||||
const disposition = res.headers.get('Content-Disposition') || '';
|
||||
const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
|
||||
const filename = filenameMatch ? filenameMatch[1] : `tickets-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
const blob = await res.blob();
|
||||
return { blob, filename };
|
||||
},
|
||||
};
|
||||
|
||||
// Emails API
|
||||
@@ -508,6 +572,39 @@ export interface TicketValidationResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TicketSearchResult {
|
||||
id: string;
|
||||
qrCode: string;
|
||||
attendeeName: string;
|
||||
attendeeEmail?: string;
|
||||
attendeePhone?: string;
|
||||
status: string;
|
||||
checkinAt?: string;
|
||||
event?: {
|
||||
id: string;
|
||||
title: string;
|
||||
startDatetime: string;
|
||||
location: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface LiveSearchResult {
|
||||
ticket_id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
status: string;
|
||||
checked_in: boolean;
|
||||
checkinAt?: string;
|
||||
event_id: string;
|
||||
qrCode: string;
|
||||
event?: {
|
||||
id: string;
|
||||
title: string;
|
||||
startDatetime: string;
|
||||
location: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
id: string;
|
||||
ticketId: string;
|
||||
|
||||
Reference in New Issue
Block a user