Update site changes

This commit is contained in:
Michilis
2026-01-31 22:32:54 +00:00
parent d3c69f2936
commit 6df3baf0be
25 changed files with 764 additions and 137 deletions

View File

@@ -1,39 +0,0 @@
-- Migration: Make password column nullable for Google OAuth users
-- Run this on your production SQLite database:
-- sqlite3 /path/to/spanglish.db < migrate-users-nullable-password.sql
-- SQLite doesn't support ALTER COLUMN, so we need to recreate the table
-- Step 1: Create new table with correct schema (password is nullable)
CREATE TABLE users_new (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password TEXT, -- Now nullable for Google OAuth users
name TEXT NOT NULL,
phone TEXT,
role TEXT NOT NULL DEFAULT 'user',
language_preference TEXT,
is_claimed INTEGER NOT NULL DEFAULT 1,
google_id TEXT,
ruc_number TEXT,
account_status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Step 2: Copy all existing data
INSERT INTO users_new (id, email, password, name, phone, role, language_preference, is_claimed, google_id, ruc_number, account_status, created_at, updated_at)
SELECT id, email, password, name, phone, role, language_preference, is_claimed, google_id, ruc_number, account_status, created_at, updated_at
FROM users;
-- Step 3: Drop old table
DROP TABLE users;
-- Step 4: Rename new table
ALTER TABLE users_new RENAME TO users;
-- Step 5: Recreate indexes
CREATE UNIQUE INDEX IF NOT EXISTS users_email_idx ON users(email);
CREATE INDEX IF NOT EXISTS users_google_id_idx ON users(google_id);
-- Done! Google OAuth users can now be created without passwords.

View File

@@ -83,11 +83,21 @@ async function migrate() {
capacity INTEGER NOT NULL DEFAULT 50,
status TEXT NOT NULL DEFAULT 'draft',
banner_url TEXT,
external_booking_enabled INTEGER NOT NULL DEFAULT 0,
external_booking_url TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
`);
// Add external booking columns to events if they don't exist (for existing databases)
try {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_enabled INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_url TEXT`);
} catch (e) { /* column may already exist */ }
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
@@ -414,11 +424,21 @@ async function migrate() {
capacity INTEGER NOT NULL DEFAULT 50,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
banner_url VARCHAR(500),
external_booking_enabled INTEGER NOT NULL DEFAULT 0,
external_booking_url VARCHAR(500),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Add external booking columns to events if they don't exist (for existing databases)
try {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_enabled INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_url VARCHAR(500)`);
} catch (e) { /* column may already exist */ }
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS tickets (
id UUID PRIMARY KEY,

View File

@@ -75,6 +75,8 @@ export const sqliteEvents = sqliteTable('events', {
capacity: integer('capacity').notNull().default(50),
status: text('status', { enum: ['draft', 'published', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
bannerUrl: text('banner_url'),
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
externalBookingUrl: text('external_booking_url'),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
@@ -315,6 +317,8 @@ export const pgEvents = pgTable('events', {
capacity: pgInteger('capacity').notNull().default(50),
status: varchar('status', { length: 20 }).notNull().default('draft'),
bannerUrl: varchar('banner_url', { length: 500 }),
externalBookingEnabled: pgInteger('external_booking_enabled').notNull().default(0),
externalBookingUrl: varchar('external_booking_url', { length: 500 }),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});

View File

@@ -786,6 +786,74 @@ export const emailService = {
});
},
/**
* Send payment rejection email
* This email is sent when admin rejects a TPago or Bank Transfer payment
*/
async sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> {
// Get payment
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, paymentId))
.get();
if (!payment) {
return { success: false, error: 'Payment not found' };
}
// Get ticket
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
.get();
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
// Get event
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
if (!event) {
return { success: false, error: 'Event not found' };
}
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Generate a new booking URL for the event
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
const newBookingUrl = `${frontendUrl}/book/${event.id}`;
console.log(`[Email] Sending payment rejection email to ${ticket.attendeeEmail}`);
return this.sendTemplateEmail({
templateSlug: 'payment-rejected',
to: ticket.attendeeEmail,
toName: attendeeFullName,
locale,
eventId: event.id,
variables: {
attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id,
eventTitle,
eventDate: this.formatDate(event.startDatetime, locale),
eventTime: this.formatTime(event.startDatetime, locale),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
newBookingUrl,
},
});
},
/**
* Send custom email to event attendees
*/

View File

@@ -673,6 +673,13 @@ El Equipo de Spanglish`,
If the button doesn't work: <a href="{{tpagoLink}}" style="color: #3b82f6; word-break: break-all;">{{tpagoLink}}</a>
</p>
<div class="note" style="background-color: #fef3c7; border-left-color: #f59e0b;">
<strong>Important - Manual Verification Process:</strong><br>
Please make sure you complete the payment before clicking "I have paid".<br>
The Spanglish team will review the payment manually.<br>
<strong>Your booking is only confirmed after you receive a confirmation email from us.</strong>
</div>
<div class="note">
<strong>After completing the payment:</strong><br>
Return to the website and click <strong>"I have paid"</strong> or click the button below to notify us.
@@ -685,8 +692,6 @@ El Equipo de Spanglish`,
Or use this link: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a>
</p>
<p>Your spot will be confirmed once we verify the payment.</p>
<p>If you have any questions, just reply to this email.</p>
<p>See you soon,<br>Spanglish</p>
`,
@@ -711,6 +716,13 @@ El Equipo de Spanglish`,
Si el botón no funciona: <a href="{{tpagoLink}}" style="color: #3b82f6; word-break: break-all;">{{tpagoLink}}</a>
</p>
<div class="note" style="background-color: #fef3c7; border-left-color: #f59e0b;">
<strong>Importante - Proceso de Verificación Manual:</strong><br>
Por favor asegúrate de completar el pago antes de hacer clic en "Ya pagué".<br>
El equipo de Spanglish revisará el pago manualmente.<br>
<strong>Tu reserva solo será confirmada después de que recibas un email de confirmación de nuestra parte.</strong>
</div>
<div class="note">
<strong>Después de completar el pago:</strong><br>
Vuelve al sitio web y haz clic en <strong>"Ya pagué"</strong> o haz clic en el botón de abajo para notificarnos.
@@ -723,8 +735,6 @@ El Equipo de Spanglish`,
O usa este enlace: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a>
</p>
<p>Tu lugar será confirmado una vez que verifiquemos el pago.</p>
<p>Si tienes alguna pregunta, simplemente responde a este email.</p>
<p>¡Nos vemos pronto!<br>Spanglish</p>
`,
@@ -743,12 +753,15 @@ Please complete your payment using TPago at the link below:
👉 Pay with card: {{tpagoLink}}
⚠️ IMPORTANT - Manual Verification Process:
Please make sure you complete the payment before clicking "I have paid".
The Spanglish team will review the payment manually.
Your booking is only confirmed after you receive a confirmation email from us.
After completing the payment, return to the website and click "I have paid" or use this link to notify us:
{{bookingUrl}}
Your spot will be confirmed once we verify the payment.
If you have any questions, just reply to this email.
See you soon,
@@ -768,12 +781,15 @@ Por favor completa tu pago usando TPago en el siguiente enlace:
👉 Pagar con tarjeta: {{tpagoLink}}
⚠️ IMPORTANTE - Proceso de Verificación Manual:
Por favor asegúrate de completar el pago antes de hacer clic en "Ya pagué".
El equipo de Spanglish revisará el pago manualmente.
Tu reserva solo será confirmada después de que recibas un email de confirmación de nuestra parte.
Después de completar el pago, vuelve al sitio web y haz clic en "Ya pagué" o usa este enlace para notificarnos:
{{bookingUrl}}
Tu lugar será confirmado una vez que verifiquemos el pago.
Si tienes alguna pregunta, simplemente responde a este email.
¡Nos vemos pronto!
@@ -824,6 +840,13 @@ Spanglish`,
<div class="event-detail"><strong>Reference:</strong> <span style="font-family: monospace; background: #fff; padding: 2px 6px; border-radius: 4px;">{{paymentReference}}</span></div>
</div>
<div class="note" style="background-color: #fef3c7; border-left-color: #f59e0b;">
<strong>Important - Manual Verification Process:</strong><br>
Please make sure you complete the payment before clicking "I have paid".<br>
The Spanglish team will review the payment manually.<br>
<strong>Your booking is only confirmed after you receive a confirmation email from us.</strong>
</div>
<div class="note">
<strong>After making the transfer:</strong><br>
Return to the website and click <strong>"I have paid"</strong> or click the button below to notify us.
@@ -836,8 +859,6 @@ Spanglish`,
Or use this link: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a>
</p>
<p>We'll confirm your spot as soon as the payment is received.</p>
<p>If you need help, reply to this email.</p>
<p>See you at the event,<br>Spanglish</p>
`,
@@ -872,6 +893,13 @@ Spanglish`,
<div class="event-detail"><strong>Referencia:</strong> <span style="font-family: monospace; background: #fff; padding: 2px 6px; border-radius: 4px;">{{paymentReference}}</span></div>
</div>
<div class="note" style="background-color: #fef3c7; border-left-color: #f59e0b;">
<strong>Importante - Proceso de Verificación Manual:</strong><br>
Por favor asegúrate de completar el pago antes de hacer clic en "Ya pagué".<br>
El equipo de Spanglish revisará el pago manualmente.<br>
<strong>Tu reserva solo será confirmada después de que recibas un email de confirmación de nuestra parte.</strong>
</div>
<div class="note">
<strong>Después de realizar la transferencia:</strong><br>
Vuelve al sitio web y haz clic en <strong>"Ya pagué"</strong> o haz clic en el botón de abajo para notificarnos.
@@ -884,8 +912,6 @@ Spanglish`,
O usa este enlace: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a>
</p>
<p>Confirmaremos tu lugar tan pronto como recibamos el pago.</p>
<p>Si necesitas ayuda, responde a este email.</p>
<p>¡Nos vemos en el evento!<br>Spanglish</p>
`,
@@ -907,12 +933,15 @@ Bank Transfer Details:
- Phone: {{bankPhone}}
- Reference: {{paymentReference}}
⚠️ IMPORTANT - Manual Verification Process:
Please make sure you complete the payment before clicking "I have paid".
The Spanglish team will review the payment manually.
Your booking is only confirmed after you receive a confirmation email from us.
After making the transfer, return to the website and click "I have paid" or use this link to notify us:
{{bookingUrl}}
We'll confirm your spot as soon as the payment is received.
If you need help, reply to this email.
See you at the event,
@@ -935,12 +964,15 @@ Datos de Transferencia:
- Teléfono: {{bankPhone}}
- Referencia: {{paymentReference}}
Después de realizar la transferencia, vuelve al sitio web y haz clic en "Ya pagué" o usa este enlace para notificarnos:
⚠️ IMPORTANTE - Proceso de Verificación Manual:
Por favor asegúrate de completar el pago antes de hacer clic en "Ya pagué".
El equipo de Spanglish revisará el pago manualmente.
Tu reserva solo será confirmada después de que recibas un email de confirmación de nuestra parte.
Después de completar el pago, vuelve al sitio web y haz clic en "Ya pagué" o usa este enlace para notificarnos:
{{bookingUrl}}
Confirmaremos tu lugar tan pronto como recibamos el pago.
Si necesitas ayuda, responde a este email.
¡Nos vemos en el evento!
@@ -960,6 +992,117 @@ Spanglish`,
],
isSystem: true,
},
{
name: 'Payment Rejected',
slug: 'payment-rejected',
subject: 'Payment not found for your Spanglish booking',
subjectEs: 'No encontramos el pago de tu reserva en Spanglish',
bodyHtml: `
<h2>About Your Booking</h2>
<p>Hi {{attendeeName}},</p>
<p>Thanks for your interest in Spanglish.</p>
<p>Unfortunately, we were unable to find or confirm the payment for your booking for:</p>
<div class="event-card">
<h3>{{eventTitle}}</h3>
<div class="event-detail"><strong>📅 Date:</strong> {{eventDate}}</div>
<div class="event-detail"><strong>📍 Location:</strong> {{eventLocation}}</div>
</div>
<p>Because of this, the booking has been cancelled.</p>
<div class="note">
<strong>Did you already pay?</strong><br>
If you believe this was a mistake or if you have already made the payment, please reply to this email and we'll be happy to check it with you.
</div>
<p>You're always welcome to make a new booking if spots are still available.</p>
{{#if newBookingUrl}}
<p style="text-align: center; margin: 24px 0;">
<a href="{{newBookingUrl}}" class="btn">Make a New Booking</a>
</p>
{{/if}}
<p>Warm regards,<br>Spanglish</p>
`,
bodyHtmlEs: `
<h2>Sobre Tu Reserva</h2>
<p>Hola {{attendeeName}},</p>
<p>Gracias por tu interés en Spanglish.</p>
<p>Lamentablemente, no pudimos encontrar o confirmar el pago de tu reserva para:</p>
<div class="event-card">
<h3>{{eventTitle}}</h3>
<div class="event-detail"><strong>📅 Fecha:</strong> {{eventDate}}</div>
<div class="event-detail"><strong>📍 Ubicación:</strong> {{eventLocation}}</div>
</div>
<p>Por esta razón, la reserva ha sido cancelada.</p>
<div class="note">
<strong>¿Ya realizaste el pago?</strong><br>
Si crees que esto fue un error o si ya realizaste el pago, por favor responde a este correo y con gusto lo revisaremos contigo.
</div>
<p>Siempre eres bienvenido/a a hacer una nueva reserva si aún hay lugares disponibles.</p>
{{#if newBookingUrl}}
<p style="text-align: center; margin: 24px 0;">
<a href="{{newBookingUrl}}" class="btn">Hacer una Nueva Reserva</a>
</p>
{{/if}}
<p>Saludos cordiales,<br>Spanglish</p>
`,
bodyText: `About Your Booking
Hi {{attendeeName}},
Thanks for your interest in Spanglish.
Unfortunately, we were unable to find or confirm the payment for your booking for:
{{eventTitle}}
📅 Date: {{eventDate}}
📍 Location: {{eventLocation}}
Because of this, the booking has been cancelled.
If you believe this was a mistake or if you have already made the payment, please reply to this email and we'll be happy to check it with you.
You're always welcome to make a new booking if spots are still available.
Warm regards,
Spanglish`,
bodyTextEs: `Sobre Tu Reserva
Hola {{attendeeName}},
Gracias por tu interés en Spanglish.
Lamentablemente, no pudimos encontrar o confirmar el pago de tu reserva para:
{{eventTitle}}
📅 Fecha: {{eventDate}}
📍 Ubicación: {{eventLocation}}
Por esta razón, la reserva ha sido cancelada.
Si crees que esto fue un error o si ya realizaste el pago, por favor responde a este correo y con gusto lo revisaremos contigo.
Siempre eres bienvenido/a a hacer una nueva reserva si aún hay lugares disponibles.
Saludos cordiales,
Spanglish`,
description: 'Sent when admin rejects a TPago or Bank Transfer payment',
variables: [
...commonVariables,
...bookingVariables,
{ name: 'newBookingUrl', description: 'URL to make a new booking (optional)', example: 'https://spanglish.com/book/event123' },
],
isSystem: true,
},
];
// Helper function to replace template variables

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, events, tickets } from '../db/index.js';
import { db, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices } from '../db/index.js';
import { eq, desc, and, gte, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
@@ -23,7 +23,7 @@ const validationHook = (result: any, c: any) => {
}
};
const createEventSchema = z.object({
const baseEventSchema = z.object({
title: z.string().min(1),
titleEs: z.string().optional().nullable(),
description: z.string().min(1),
@@ -38,9 +38,38 @@ const createEventSchema = z.object({
status: z.enum(['draft', 'published', 'cancelled', 'completed', 'archived']).default('draft'),
// Accept relative paths (/uploads/...) or full URLs
bannerUrl: z.string().optional().nullable().or(z.literal('')),
// External booking support
externalBookingEnabled: z.boolean().default(false),
externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')),
});
const updateEventSchema = createEventSchema.partial();
const createEventSchema = baseEventSchema.refine(
(data) => {
// If external booking is enabled, URL must be provided and must start with https://
if (data.externalBookingEnabled) {
return !!(data.externalBookingUrl && data.externalBookingUrl.startsWith('https://'));
}
return true;
},
{
message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled',
path: ['externalBookingUrl'],
}
);
const updateEventSchema = baseEventSchema.partial().refine(
(data) => {
// If external booking is enabled, URL must be provided and must start with https://
if (data.externalBookingEnabled) {
return !!(data.externalBookingUrl && data.externalBookingUrl.startsWith('https://'));
}
return true;
},
{
message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled',
path: ['externalBookingUrl'],
}
);
// Get all events (public)
eventsRouter.get('/', async (c) => {
@@ -211,6 +240,44 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
return c.json({ error: 'Event not found' }, 404);
}
// Get all tickets for this event
const eventTickets = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).eventId, id))
.all();
// Delete invoices and payments for all tickets of this event
for (const ticket of eventTickets) {
// Get payments for this ticket
const ticketPayments = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
.all();
// Delete invoices for each payment
for (const payment of ticketPayments) {
await (db as any).delete(invoices).where(eq((invoices as any).paymentId, payment.id));
}
// Delete payments for this ticket
await (db as any).delete(payments).where(eq((payments as any).ticketId, ticket.id));
}
// Delete all tickets for this event
await (db as any).delete(tickets).where(eq((tickets as any).eventId, id));
// Delete event payment overrides
await (db as any).delete(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, id));
// Set eventId to null on email logs (they reference this event but can exist without it)
await (db as any)
.update(emailLogs)
.set({ eventId: null })
.where(eq((emailLogs as any).eventId, id));
// Finally delete the event
await (db as any).delete(events).where(eq((events as any).id, id));
return c.json({ message: 'Event deleted successfully' });
@@ -257,6 +324,8 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
capacity: existing.capacity,
status: 'draft',
bannerUrl: existing.bannerUrl,
externalBookingEnabled: existing.externalBookingEnabled || false,
externalBookingUrl: existing.externalBookingUrl,
createdAt: now,
updatedAt: now,
};

View File

@@ -311,7 +311,21 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
})
.where(eq((payments as any).id, id));
// Note: We don't cancel the ticket automatically - admin can do that separately if needed
// Cancel the ticket - booking is no longer valid after rejection
await (db as any)
.update(tickets)
.set({
status: 'cancelled',
updatedAt: now,
})
.where(eq((tickets as any).id, payment.ticketId));
// Send rejection email asynchronously (for manual payment methods only)
if (['bank_transfer', 'tpago'].includes(payment.provider)) {
emailService.sendPaymentRejectionEmail(id).catch(err => {
console.error('[Email] Failed to send payment rejection email:', err);
});
}
const updated = await (db as any)
.select()
@@ -319,7 +333,7 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
.where(eq((payments as any).id, id))
.get();
return c.json({ payment: updated, message: 'Payment rejected' });
return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
});
// Update admin note