Booking flow: required terms and privacy checkbox with i18n
Also includes admin, dashboard, and API updates; PWA icon assets; and assorted layout and utility changes on dev.
This commit is contained in:
@@ -173,6 +173,11 @@ async function migrate() {
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_id TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Migration: Add is_guest column to tickets
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries)
|
||||
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
|
||||
@@ -533,8 +538,8 @@ async function migrate() {
|
||||
description_es TEXT,
|
||||
short_description VARCHAR(300),
|
||||
short_description_es VARCHAR(300),
|
||||
start_datetime TIMESTAMP NOT NULL,
|
||||
end_datetime TIMESTAMP,
|
||||
start_datetime TIMESTAMPTZ NOT NULL,
|
||||
end_datetime TIMESTAMPTZ,
|
||||
location VARCHAR(500) NOT NULL,
|
||||
location_url VARCHAR(500),
|
||||
price DECIMAL(10, 2) NOT NULL DEFAULT 0,
|
||||
@@ -565,6 +570,15 @@ async function migrate() {
|
||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description_es VARCHAR(300)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Migrate event datetime columns from TIMESTAMP to TIMESTAMPTZ for
|
||||
// unambiguous UTC storage (eliminates pg driver timezone interpretation).
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE events ALTER COLUMN start_datetime TYPE TIMESTAMPTZ USING start_datetime AT TIME ZONE 'UTC'`);
|
||||
} catch (e) { /* already timestamptz or other issue */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE events ALTER COLUMN end_datetime TYPE TIMESTAMPTZ USING end_datetime AT TIME ZONE 'UTC'`);
|
||||
} catch (e) { /* already timestamptz or other issue */ }
|
||||
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id UUID PRIMARY KEY,
|
||||
@@ -599,6 +613,11 @@ async function migrate() {
|
||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_id UUID`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Migration: Add is_guest column to tickets
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id UUID PRIMARY KEY,
|
||||
|
||||
@@ -99,6 +99,7 @@ export const sqliteTickets = sqliteTable('tickets', {
|
||||
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
|
||||
qrCode: text('qr_code'),
|
||||
adminNote: text('admin_note'),
|
||||
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: text('created_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -392,8 +393,8 @@ export const pgEvents = pgTable('events', {
|
||||
descriptionEs: pgText('description_es'),
|
||||
shortDescription: varchar('short_description', { length: 300 }),
|
||||
shortDescriptionEs: varchar('short_description_es', { length: 300 }),
|
||||
startDatetime: timestamp('start_datetime').notNull(),
|
||||
endDatetime: timestamp('end_datetime'),
|
||||
startDatetime: timestamp('start_datetime', { withTimezone: true }).notNull(),
|
||||
endDatetime: timestamp('end_datetime', { withTimezone: true }),
|
||||
location: varchar('location', { length: 500 }).notNull(),
|
||||
locationUrl: varchar('location_url', { length: 500 }),
|
||||
price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||
@@ -423,6 +424,7 @@ export const pgTickets = pgTable('tickets', {
|
||||
checkedInByAdminId: uuid('checked_in_by_admin_id').references(() => pgUsers.id), // Who performed the check-in
|
||||
qrCode: varchar('qr_code', { length: 255 }),
|
||||
adminNote: pgText('admin_note'),
|
||||
isGuest: pgInteger('is_guest').notNull().default(0),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,49 @@ export function toDbDate(date: Date | string): string | Date {
|
||||
return getDbType() === 'postgres' ? d : d.toISOString();
|
||||
}
|
||||
|
||||
const NAIVE_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
|
||||
|
||||
/**
|
||||
* Parse a datetime string that represents wall-clock time in a given timezone
|
||||
* and return the corresponding UTC Date.
|
||||
*
|
||||
* Naive strings (no "Z" or offset) are interpreted as wall-clock time in
|
||||
* `timezone`. Strings that already carry a timezone indicator are parsed
|
||||
* directly so existing UTC ISO values still work.
|
||||
*/
|
||||
export function parseEventDatetime(
|
||||
datetime: string,
|
||||
timezone: string = 'America/Asuncion',
|
||||
): Date {
|
||||
if (!NAIVE_DATETIME_RE.test(datetime)) {
|
||||
return new Date(datetime);
|
||||
}
|
||||
|
||||
// Treat the digits as UTC so we have a stable reference instant.
|
||||
const fakeUTC = new Date(datetime + 'Z');
|
||||
|
||||
// Ask Intl what that UTC instant looks like in both UTC and the target tz.
|
||||
const utcStr = fakeUTC.toLocaleString('en-US', { timeZone: 'UTC' });
|
||||
const tzStr = fakeUTC.toLocaleString('en-US', { timeZone: timezone });
|
||||
|
||||
// The gap between the two tells us the tz offset at this point in time.
|
||||
const offsetMs = new Date(utcStr).getTime() - new Date(tzStr).getTime();
|
||||
|
||||
return new Date(fakeUTC.getTime() + offsetMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a datetime string to the appropriate DB format, interpreting naive
|
||||
* strings as wall-clock time in `timezone` (defaults to America/Asuncion).
|
||||
*/
|
||||
export function toDbDateTz(
|
||||
datetime: string,
|
||||
timezone: string = 'America/Asuncion',
|
||||
): string | Date {
|
||||
const d = parseEventDatetime(datetime, timezone);
|
||||
return getDbType() === 'postgres' ? d : d.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a boolean value to the appropriate format for the database type.
|
||||
* - SQLite: returns boolean (true/false) for mode: 'boolean'
|
||||
|
||||
@@ -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, inArray } from 'drizzle-orm';
|
||||
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
|
||||
@@ -129,7 +129,8 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
eq((tickets as any).status, 'confirmed')
|
||||
eq((tickets as any).status, 'confirmed'),
|
||||
ne((tickets as any).isGuest, 1)
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -141,7 +142,8 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
eq((tickets as any).status, 'checked_in')
|
||||
eq((tickets as any).status, 'checked_in'),
|
||||
ne((tickets as any).isGuest, 1)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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, calculateAvailableSeats } from '../lib/utils.js';
|
||||
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
|
||||
interface UserContext {
|
||||
@@ -201,6 +201,13 @@ eventsRouter.get('/:id', async (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
async function getSiteTimezone(): Promise<string> {
|
||||
const settings = await dbGet<any>(
|
||||
(db as any).select().from(siteSettings).limit(1)
|
||||
);
|
||||
return settings?.timezone || 'America/Asuncion';
|
||||
}
|
||||
|
||||
// Helper function to get ticket count for an event
|
||||
async function getEventTicketCount(eventId: string): Promise<number> {
|
||||
const ticketCount = await dbGet<any>(
|
||||
@@ -316,6 +323,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
const user = c.get('user');
|
||||
const now = getNow();
|
||||
const id = generateId();
|
||||
const tz = await getSiteTimezone();
|
||||
|
||||
// Convert data for database compatibility
|
||||
const dbData = convertBooleansForDb(data);
|
||||
@@ -323,8 +331,8 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
const newEvent = {
|
||||
id,
|
||||
...dbData,
|
||||
startDatetime: toDbDate(data.startDatetime),
|
||||
endDatetime: data.endDatetime ? toDbDate(data.endDatetime) : null,
|
||||
startDatetime: toDbDateTz(data.startDatetime, tz),
|
||||
endDatetime: data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -351,14 +359,15 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
const tz = await getSiteTimezone();
|
||||
// Convert data for database compatibility
|
||||
const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now };
|
||||
// Convert datetime fields if present
|
||||
if (data.startDatetime) {
|
||||
updateData.startDatetime = toDbDate(data.startDatetime);
|
||||
updateData.startDatetime = toDbDateTz(data.startDatetime, tz);
|
||||
}
|
||||
if (data.endDatetime !== undefined) {
|
||||
updateData.endDatetime = data.endDatetime ? toDbDate(data.endDatetime) : null;
|
||||
updateData.endDatetime = data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null;
|
||||
}
|
||||
|
||||
await (db as any)
|
||||
|
||||
@@ -1394,6 +1394,142 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff'])
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// Admin invite guest ticket (free, confirmed, not counted in revenue)
|
||||
ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
|
||||
eventId: z.string(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().optional().or(z.literal('')),
|
||||
email: z.string().email().optional().or(z.literal('')),
|
||||
phone: z.string().optional().or(z.literal('')),
|
||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||
adminNote: z.string().max(1000).optional(),
|
||||
})), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, data.eventId))
|
||||
);
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
const adminUser = (c as any).get('user');
|
||||
|
||||
// Find or create user (use placeholder email if none provided)
|
||||
const attendeeEmail = data.email && data.email.trim()
|
||||
? data.email.trim()
|
||||
: `guest-${generateId()}@guestinvite.local`;
|
||||
|
||||
const fullName = data.lastName && data.lastName.trim()
|
||||
? `${data.firstName} ${data.lastName}`.trim()
|
||||
: data.firstName;
|
||||
|
||||
let user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
const userId = generateId();
|
||||
user = {
|
||||
id: userId,
|
||||
email: attendeeEmail,
|
||||
password: '',
|
||||
name: fullName,
|
||||
phone: data.phone || null,
|
||||
role: 'user',
|
||||
languagePreference: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await (db as any).insert(users).values(user);
|
||||
}
|
||||
|
||||
// Check for existing active ticket (only for real emails, not placeholder)
|
||||
if (data.email && data.email.trim()) {
|
||||
const existingTicket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).userId, user.id),
|
||||
eq((tickets as any).eventId, data.eventId)
|
||||
)
|
||||
)
|
||||
);
|
||||
if (existingTicket && existingTicket.status !== 'cancelled') {
|
||||
return c.json({ error: 'This person already has a ticket for this event' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const ticketId = generateId();
|
||||
const qrCode = generateTicketCode();
|
||||
|
||||
const newTicket = {
|
||||
id: ticketId,
|
||||
userId: user.id,
|
||||
eventId: data.eventId,
|
||||
attendeeFirstName: data.firstName,
|
||||
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
||||
attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null,
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: 'confirmed',
|
||||
isGuest: true,
|
||||
qrCode,
|
||||
checkinAt: null,
|
||||
adminNote: data.adminNote || null,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
await (db as any).insert(tickets).values(newTicket);
|
||||
|
||||
// Create a $0 payment record to track the invite
|
||||
const paymentId = generateId();
|
||||
const newPayment = {
|
||||
id: paymentId,
|
||||
ticketId,
|
||||
provider: 'cash',
|
||||
amount: 0,
|
||||
currency: event.currency,
|
||||
status: 'paid',
|
||||
reference: 'Guest invite',
|
||||
paidAt: now,
|
||||
paidByAdminId: adminUser?.id || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await (db as any).insert(payments).values(newPayment);
|
||||
|
||||
// Send booking confirmation email if a real email was provided
|
||||
if (data.email && data.email.trim()) {
|
||||
emailService.sendBookingConfirmation(ticketId).then(result => {
|
||||
if (result.success) {
|
||||
console.log(`[Email] Booking confirmation sent for guest ticket ${ticketId}`);
|
||||
} else {
|
||||
console.error(`[Email] Failed to send booking confirmation for guest ticket ${ticketId}:`, result.error);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[Email] Exception sending booking confirmation for guest ticket:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
ticket: {
|
||||
...newTicket,
|
||||
event: {
|
||||
title: event.title,
|
||||
startDatetime: event.startDatetime,
|
||||
location: event.location,
|
||||
},
|
||||
},
|
||||
payment: newPayment,
|
||||
message: 'Guest ticket created successfully',
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// Get all tickets (admin) - includes payment for each ticket
|
||||
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const eventId = c.req.query('eventId');
|
||||
|
||||
Reference in New Issue
Block a user