diff --git a/backend/scripts/migrate-users-nullable-password.sql b/backend/scripts/migrate-users-nullable-password.sql deleted file mode 100644 index d7d2525..0000000 --- a/backend/scripts/migrate-users-nullable-password.sql +++ /dev/null @@ -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. diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index f1f5015..7c17078 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -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, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 3b8f9d7..075c008 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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(), }); diff --git a/backend/src/lib/email.ts b/backend/src/lib/email.ts index 9cd86e0..0ad4d95 100644 --- a/backend/src/lib/email.ts +++ b/backend/src/lib/email.ts @@ -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 */ diff --git a/backend/src/lib/emailTemplates.ts b/backend/src/lib/emailTemplates.ts index 17ccf90..f0779a0 100644 --- a/backend/src/lib/emailTemplates.ts +++ b/backend/src/lib/emailTemplates.ts @@ -673,6 +673,13 @@ El Equipo de Spanglish`, If the button doesn't work: {{tpagoLink}}
+Your spot will be confirmed once we verify the payment.
-If you have any questions, just reply to this email.
See you soon,
Spanglish
Tu lugar será confirmado una vez que verifiquemos el pago.
-Si tienes alguna pregunta, simplemente responde a este email.
¡Nos vemos pronto!
Spanglish
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,
Spanglish
Confirmaremos tu lugar tan pronto como recibamos el pago.
-Si necesitas ayuda, responde a este email.
¡Nos vemos en el evento!
Spanglish
Hi {{attendeeName}},
+Thanks for your interest in Spanglish.
+Unfortunately, we were unable to find or confirm the payment for your booking for:
+ +Because of this, the booking has been cancelled.
+ +You're always welcome to make a new booking if spots are still available.
+ + {{#if newBookingUrl}} + + {{/if}} + +Warm regards,
Spanglish
Hola {{attendeeName}},
+Gracias por tu interés en Spanglish.
+Lamentablemente, no pudimos encontrar o confirmar el pago de tu reserva para:
+ +Por esta razón, la reserva ha sido cancelada.
+ +Siempre eres bienvenido/a a hacer una nueva reserva si aún hay lugares disponibles.
+ + {{#if newBookingUrl}} + + {{/if}} + +Saludos cordiales,
Spanglish
{bookingResult.qrCode}
+ {locale === 'es' ? 'Verificación manual' : 'Manual verification'} +
++ {locale === 'es' + ? 'El equipo de Spanglish revisará el pago manualmente. Tu reserva solo será confirmada después de recibir un email de confirmación de nuestra parte.' + : 'The Spanglish team will review the payment manually. Your booking is only confirmed after you receive a confirmation email from us.'} +
++ {locale === 'es' + ? 'Solo haz clic aquí después de haber completado el pago.' + : 'Only click this after you have actually completed the payment.'} +
+ {/* I Have Paid Button */}Redirect users to an external booking platform
+Must be a valid HTTPS URL
++ {t('linktree.tiktok.title')} +
+{t('linktree.tiktok.subtitle')}
+