Add PostgreSQL support with SQLite/Postgres database compatibility layer

- Add dbGet/dbAll helper functions for database-agnostic queries
- Add toDbBool/convertBooleansForDb for boolean type conversion
- Add toDbDate/getNow for timestamp type handling
- Add generateId that returns UUID for Postgres, nanoid for SQLite
- Update all routes to use compatibility helpers
- Add normalizeEvent to return clean number types from Postgres decimal
- Add formatPrice utility for consistent price display
- Add legal pages admin interface with RichTextEditor
- Update carousel images
- Add drizzle migration files for PostgreSQL
This commit is contained in:
Michilis
2026-02-02 03:46:35 +00:00
parent 9410e83b89
commit bafd1425c4
61 changed files with 5015 additions and 881 deletions

View File

@@ -0,0 +1,270 @@
CREATE TABLE `audit_logs` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text,
`action` text NOT NULL,
`target` text,
`target_id` text,
`details` text,
`timestamp` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `contacts` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`message` text NOT NULL,
`status` text DEFAULT 'new' NOT NULL,
`created_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `email_logs` (
`id` text PRIMARY KEY NOT NULL,
`template_id` text,
`event_id` text,
`recipient_email` text NOT NULL,
`recipient_name` text,
`subject` text NOT NULL,
`body_html` text,
`status` text DEFAULT 'pending' NOT NULL,
`error_message` text,
`sent_at` text,
`sent_by` text,
`created_at` text NOT NULL,
FOREIGN KEY (`template_id`) REFERENCES `email_templates`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`event_id`) REFERENCES `events`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`sent_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `email_settings` (
`id` text PRIMARY KEY NOT NULL,
`key` text NOT NULL,
`value` text NOT NULL,
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `email_subscribers` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`name` text,
`status` text DEFAULT 'active' NOT NULL,
`created_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `email_templates` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`slug` text NOT NULL,
`subject` text NOT NULL,
`subject_es` text,
`body_html` text NOT NULL,
`body_html_es` text,
`body_text` text,
`body_text_es` text,
`description` text,
`variables` text,
`is_system` integer DEFAULT false NOT NULL,
`is_active` integer DEFAULT true NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `event_payment_overrides` (
`id` text PRIMARY KEY NOT NULL,
`event_id` text NOT NULL,
`tpago_enabled` integer,
`tpago_link` text,
`tpago_instructions` text,
`tpago_instructions_es` text,
`bank_transfer_enabled` integer,
`bank_name` text,
`bank_account_holder` text,
`bank_account_number` text,
`bank_alias` text,
`bank_phone` text,
`bank_notes` text,
`bank_notes_es` text,
`lightning_enabled` integer,
`cash_enabled` integer,
`cash_instructions` text,
`cash_instructions_es` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`event_id`) REFERENCES `events`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `events` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`title_es` text,
`description` text NOT NULL,
`description_es` text,
`start_datetime` text NOT NULL,
`end_datetime` text,
`location` text NOT NULL,
`location_url` text,
`price` real DEFAULT 0 NOT NULL,
`currency` text DEFAULT 'PYG' NOT NULL,
`capacity` integer DEFAULT 50 NOT NULL,
`status` text DEFAULT 'draft' NOT NULL,
`banner_url` text,
`external_booking_enabled` integer DEFAULT false NOT NULL,
`external_booking_url` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `invoices` (
`id` text PRIMARY KEY NOT NULL,
`payment_id` text NOT NULL,
`user_id` text NOT NULL,
`invoice_number` text NOT NULL,
`ruc_number` text,
`legal_name` text,
`amount` real NOT NULL,
`currency` text DEFAULT 'PYG' NOT NULL,
`pdf_url` text,
`status` text DEFAULT 'generated' NOT NULL,
`created_at` text NOT NULL,
FOREIGN KEY (`payment_id`) REFERENCES `payments`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `magic_link_tokens` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`token` text NOT NULL,
`type` text NOT NULL,
`expires_at` text NOT NULL,
`used_at` text,
`created_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `media` (
`id` text PRIMARY KEY NOT NULL,
`file_url` text NOT NULL,
`type` text NOT NULL,
`related_id` text,
`related_type` text,
`created_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `payment_options` (
`id` text PRIMARY KEY NOT NULL,
`tpago_enabled` integer DEFAULT false NOT NULL,
`tpago_link` text,
`tpago_instructions` text,
`tpago_instructions_es` text,
`bank_transfer_enabled` integer DEFAULT false NOT NULL,
`bank_name` text,
`bank_account_holder` text,
`bank_account_number` text,
`bank_alias` text,
`bank_phone` text,
`bank_notes` text,
`bank_notes_es` text,
`lightning_enabled` integer DEFAULT true NOT NULL,
`cash_enabled` integer DEFAULT true NOT NULL,
`cash_instructions` text,
`cash_instructions_es` text,
`allow_duplicate_bookings` integer DEFAULT false NOT NULL,
`updated_at` text NOT NULL,
`updated_by` text,
FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `payments` (
`id` text PRIMARY KEY NOT NULL,
`ticket_id` text NOT NULL,
`provider` text NOT NULL,
`amount` real NOT NULL,
`currency` text DEFAULT 'PYG' NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`reference` text,
`user_marked_paid_at` text,
`paid_at` text,
`paid_by_admin_id` text,
`admin_note` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`ticket_id`) REFERENCES `tickets`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `site_settings` (
`id` text PRIMARY KEY NOT NULL,
`timezone` text DEFAULT 'America/Asuncion' NOT NULL,
`site_name` text DEFAULT 'Spanglish' NOT NULL,
`site_description` text,
`site_description_es` text,
`contact_email` text,
`contact_phone` text,
`facebook_url` text,
`instagram_url` text,
`twitter_url` text,
`linkedin_url` text,
`maintenance_mode` integer DEFAULT false NOT NULL,
`maintenance_message` text,
`maintenance_message_es` text,
`updated_at` text NOT NULL,
`updated_by` text,
FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `tickets` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`event_id` text NOT NULL,
`attendee_first_name` text NOT NULL,
`attendee_last_name` text,
`attendee_email` text,
`attendee_phone` text,
`attendee_ruc` text,
`preferred_language` text,
`status` text DEFAULT 'pending' NOT NULL,
`checkin_at` text,
`checked_in_by_admin_id` text,
`qr_code` text,
`admin_note` text,
`created_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`event_id`) REFERENCES `events`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`checked_in_by_admin_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `user_sessions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`token` text NOT NULL,
`user_agent` text,
`ip_address` text,
`last_active_at` text NOT NULL,
`expires_at` text NOT NULL,
`created_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`password` text,
`name` text NOT NULL,
`phone` text,
`role` text DEFAULT 'user' NOT NULL,
`language_preference` text,
`is_claimed` integer DEFAULT true NOT NULL,
`google_id` text,
`ruc_number` text,
`account_status` text DEFAULT 'active' NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `email_settings_key_unique` ON `email_settings` (`key`);--> statement-breakpoint
CREATE UNIQUE INDEX `email_subscribers_email_unique` ON `email_subscribers` (`email`);--> statement-breakpoint
CREATE UNIQUE INDEX `email_templates_name_unique` ON `email_templates` (`name`);--> statement-breakpoint
CREATE UNIQUE INDEX `email_templates_slug_unique` ON `email_templates` (`slug`);--> statement-breakpoint
CREATE UNIQUE INDEX `invoices_invoice_number_unique` ON `invoices` (`invoice_number`);--> statement-breakpoint
CREATE UNIQUE INDEX `magic_link_tokens_token_unique` ON `magic_link_tokens` (`token`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_sessions_token_unique` ON `user_sessions` (`token`);--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1769975033554,
"tag": "0000_steady_wendell_vaughn",
"breakpoints": true
}
]
}

View File

@@ -1,3 +1,4 @@
import 'dotenv/config';
import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3'; import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3';
import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'; import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
@@ -29,5 +30,51 @@ if (dbType === 'postgres') {
db = drizzleSqlite(sqlite, { schema }); db = drizzleSqlite(sqlite, { schema });
} }
export { db }; // ==================== Database Compatibility Helpers ====================
// These functions abstract the differences between SQLite and PostgreSQL Drizzle drivers:
// - SQLite uses .get() for single result, .all() for multiple
// - PostgreSQL returns arrays directly (no .get()/.all() methods)
/**
* Get a single result from a query (works with both SQLite and PostgreSQL)
* @param query - A Drizzle query builder (e.g., db.select().from(table).where(...))
* @returns The first result or null
*/
export async function dbGet<T>(query: any): Promise<T | null> {
if (dbType === 'postgres') {
const results = await query;
return results[0] || null;
}
// SQLite - use .get()
return query.get() || null;
}
/**
* Get all results from a query (works with both SQLite and PostgreSQL)
* @param query - A Drizzle query builder (e.g., db.select().from(table).where(...))
* @returns Array of results
*/
export async function dbAll<T>(query: any): Promise<T[]> {
if (dbType === 'postgres') {
return await query;
}
// SQLite - use .all()
return query.all();
}
/**
* Check if using PostgreSQL
*/
export function isPostgres(): boolean {
return dbType === 'postgres';
}
/**
* Check if using SQLite
*/
export function isSqlite(): boolean {
return dbType === 'sqlite';
}
export { db, dbType };
export * from './schema.js'; export * from './schema.js';

View File

@@ -1,7 +1,10 @@
import 'dotenv/config';
import { db } from './index.js'; import { db } from './index.js';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
const dbType = process.env.DB_TYPE || 'sqlite'; const dbType = process.env.DB_TYPE || 'sqlite';
console.log(`Database type: ${dbType}`);
console.log(`Database URL: ${process.env.DATABASE_URL?.substring(0, 30)}...`);
async function migrate() { async function migrate() {
console.log('Running migrations...'); console.log('Running migrations...');
@@ -384,6 +387,23 @@ async function migrate() {
updated_by TEXT REFERENCES users(id) updated_by TEXT REFERENCES users(id)
) )
`); `);
// Legal pages table for admin-editable legal content
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS legal_pages (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
title_es TEXT,
content_text TEXT NOT NULL,
content_text_es TEXT,
content_markdown TEXT NOT NULL,
content_markdown_es TEXT,
updated_at TEXT NOT NULL,
updated_by TEXT REFERENCES users(id),
created_at TEXT NOT NULL
)
`);
} else { } else {
// PostgreSQL migrations // PostgreSQL migrations
await (db as any).execute(sql` await (db as any).execute(sql`
@@ -716,6 +736,23 @@ async function migrate() {
updated_by UUID REFERENCES users(id) updated_by UUID REFERENCES users(id)
) )
`); `);
// Legal pages table for admin-editable legal content
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS legal_pages (
id UUID PRIMARY KEY,
slug VARCHAR(100) NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
title_es VARCHAR(255),
content_text TEXT NOT NULL,
content_text_es TEXT,
content_markdown TEXT NOT NULL,
content_markdown_es TEXT,
updated_at TIMESTAMP NOT NULL,
updated_by UUID REFERENCES users(id),
created_at TIMESTAMP NOT NULL
)
`);
} }
console.log('Migrations completed successfully!'); console.log('Migrations completed successfully!');

View File

@@ -249,6 +249,21 @@ export const sqliteEmailSettings = sqliteTable('email_settings', {
updatedAt: text('updated_at').notNull(), updatedAt: text('updated_at').notNull(),
}); });
// Legal Pages table for admin-editable legal content
export const sqliteLegalPages = sqliteTable('legal_pages', {
id: text('id').primaryKey(),
slug: text('slug').notNull().unique(),
title: text('title').notNull(), // English title
titleEs: text('title_es'), // Spanish title
contentText: text('content_text').notNull(), // Plain text edited by admin (English)
contentTextEs: text('content_text_es'), // Plain text edited by admin (Spanish)
contentMarkdown: text('content_markdown').notNull(), // Generated markdown for public display (English)
contentMarkdownEs: text('content_markdown_es'), // Generated markdown for public display (Spanish)
updatedAt: text('updated_at').notNull(),
updatedBy: text('updated_by').references(() => sqliteUsers.id),
createdAt: text('created_at').notNull(),
});
// Site Settings table for global website configuration // Site Settings table for global website configuration
export const sqliteSiteSettings = sqliteTable('site_settings', { export const sqliteSiteSettings = sqliteTable('site_settings', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
@@ -512,6 +527,21 @@ export const pgEmailSettings = pgTable('email_settings', {
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
}); });
// Legal Pages table for admin-editable legal content
export const pgLegalPages = pgTable('legal_pages', {
id: uuid('id').primaryKey(),
slug: varchar('slug', { length: 100 }).notNull().unique(),
title: varchar('title', { length: 255 }).notNull(), // English title
titleEs: varchar('title_es', { length: 255 }), // Spanish title
contentText: pgText('content_text').notNull(), // Plain text edited by admin (English)
contentTextEs: pgText('content_text_es'), // Plain text edited by admin (Spanish)
contentMarkdown: pgText('content_markdown').notNull(), // Generated markdown for public display (English)
contentMarkdownEs: pgText('content_markdown_es'), // Generated markdown for public display (Spanish)
updatedAt: timestamp('updated_at').notNull(),
updatedBy: uuid('updated_by').references(() => pgUsers.id),
createdAt: timestamp('created_at').notNull(),
});
// Site Settings table for global website configuration // Site Settings table for global website configuration
export const pgSiteSettings = pgTable('site_settings', { export const pgSiteSettings = pgTable('site_settings', {
id: uuid('id').primaryKey(), id: uuid('id').primaryKey(),
@@ -556,6 +586,7 @@ export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqlit
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions; export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices; export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings; export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
// Type exports // Type exports
export type User = typeof sqliteUsers.$inferSelect; export type User = typeof sqliteUsers.$inferSelect;
@@ -584,3 +615,5 @@ export type Invoice = typeof sqliteInvoices.$inferSelect;
export type NewInvoice = typeof sqliteInvoices.$inferInsert; export type NewInvoice = typeof sqliteInvoices.$inferInsert;
export type SiteSettings = typeof sqliteSiteSettings.$inferSelect; export type SiteSettings = typeof sqliteSiteSettings.$inferSelect;
export type NewSiteSettings = typeof sqliteSiteSettings.$inferInsert; export type NewSiteSettings = typeof sqliteSiteSettings.$inferInsert;
export type LegalPage = typeof sqliteLegalPages.$inferSelect;
export type NewLegalPage = typeof sqliteLegalPages.$inferInsert;

View File

@@ -20,6 +20,7 @@ import emailsRoutes from './routes/emails.js';
import paymentOptionsRoutes from './routes/payment-options.js'; import paymentOptionsRoutes from './routes/payment-options.js';
import dashboardRoutes from './routes/dashboard.js'; import dashboardRoutes from './routes/dashboard.js';
import siteSettingsRoutes from './routes/site-settings.js'; import siteSettingsRoutes from './routes/site-settings.js';
import legalPagesRoutes from './routes/legal-pages.js';
import emailService from './lib/email.js'; import emailService from './lib/email.js';
const app = new Hono(); const app = new Hono();
@@ -1714,6 +1715,7 @@ app.route('/api/emails', emailsRoutes);
app.route('/api/payment-options', paymentOptionsRoutes); app.route('/api/payment-options', paymentOptionsRoutes);
app.route('/api/dashboard', dashboardRoutes); app.route('/api/dashboard', dashboardRoutes);
app.route('/api/site-settings', siteSettingsRoutes); app.route('/api/site-settings', siteSettingsRoutes);
app.route('/api/legal-pages', legalPagesRoutes);
// 404 handler // 404 handler
app.notFound((c) => { app.notFound((c) => {

View File

@@ -3,7 +3,7 @@ import * as argon2 from 'argon2';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import crypto from 'crypto'; import crypto from 'crypto';
import { Context } from 'hono'; import { Context } from 'hono';
import { db, users, magicLinkTokens, userSessions } from '../db/index.js'; import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
import { eq, and, gt } from 'drizzle-orm'; import { eq, and, gt } from 'drizzle-orm';
import { generateId, getNow } from './utils.js'; import { generateId, getNow } from './utils.js';
@@ -72,16 +72,17 @@ export async function verifyMagicLinkToken(
): Promise<{ valid: boolean; userId?: string; error?: string }> { ): Promise<{ valid: boolean; userId?: string; error?: string }> {
const now = getNow(); const now = getNow();
const tokenRecord = await (db as any) const tokenRecord = await dbGet<any>(
.select() (db as any)
.from(magicLinkTokens) .select()
.where( .from(magicLinkTokens)
and( .where(
eq((magicLinkTokens as any).token, token), and(
eq((magicLinkTokens as any).type, type) eq((magicLinkTokens as any).token, token),
eq((magicLinkTokens as any).type, type)
)
) )
) );
.get();
if (!tokenRecord) { if (!tokenRecord) {
return { valid: false, error: 'Invalid token' }; return { valid: false, error: 'Invalid token' };
@@ -132,16 +133,17 @@ export async function createUserSession(
export async function getUserSessions(userId: string) { export async function getUserSessions(userId: string) {
const now = getNow(); const now = getNow();
return (db as any) return dbAll(
.select() (db as any)
.from(userSessions) .select()
.where( .from(userSessions)
and( .where(
eq((userSessions as any).userId, userId), and(
gt((userSessions as any).expiresAt, now) eq((userSessions as any).userId, userId),
gt((userSessions as any).expiresAt, now)
)
) )
) );
.all();
} }
// Invalidate a specific session // Invalidate a specific session
@@ -208,7 +210,7 @@ export async function verifyToken(token: string): Promise<JWTPayload | null> {
} }
} }
export async function getAuthUser(c: Context) { export async function getAuthUser(c: Context): Promise<any | null> {
const authHeader = c.req.header('Authorization'); const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) { if (!authHeader?.startsWith('Bearer ')) {
return null; return null;
@@ -221,7 +223,9 @@ export async function getAuthUser(c: Context) {
return null; return null;
} }
const user = await (db as any).select().from(users).where(eq((users as any).id, payload.sub)).get(); const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, payload.sub))
);
return user || null; return user || null;
} }
@@ -243,6 +247,8 @@ export function requireAuth(roles?: string[]) {
} }
export async function isFirstUser(): Promise<boolean> { export async function isFirstUser(): Promise<boolean> {
const result = await (db as any).select().from(users).limit(1).all(); const result = await dbAll(
(db as any).select().from(users).limit(1)
);
return !result || result.length === 0; return !result || result.length === 0;
} }

View File

@@ -1,10 +1,9 @@
// Email service for Spanglish platform // Email service for Spanglish platform
// Supports multiple email providers: Resend, SMTP (Nodemailer) // Supports multiple email providers: Resend, SMTP (Nodemailer)
import { db, emailTemplates, emailLogs, events, tickets, payments, users, paymentOptions, eventPaymentOverrides } from '../db/index.js'; import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets, payments, users, paymentOptions, eventPaymentOverrides } from '../db/index.js';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { nanoid } from 'nanoid'; import { getNow, generateId } from './utils.js';
import { getNow } from './utils.js';
import { import {
replaceTemplateVariables, replaceTemplateVariables,
wrapInBaseTemplate, wrapInBaseTemplate,
@@ -362,11 +361,12 @@ export const emailService = {
* Get a template by slug * Get a template by slug
*/ */
async getTemplate(slug: string): Promise<any | null> { async getTemplate(slug: string): Promise<any | null> {
const template = await (db as any) const template = await dbGet(
.select() (db as any)
.from(emailTemplates) .select()
.where(eq((emailTemplates as any).slug, slug)) .from(emailTemplates)
.get(); .where(eq((emailTemplates as any).slug, slug))
);
return template || null; return template || null;
}, },
@@ -385,7 +385,7 @@ export const emailService = {
console.log(`[Email] Creating template: ${template.name}`); console.log(`[Email] Creating template: ${template.name}`);
await (db as any).insert(emailTemplates).values({ await (db as any).insert(emailTemplates).values({
id: nanoid(), id: generateId(),
name: template.name, name: template.name,
slug: template.slug, slug: template.slug,
subject: template.subject, subject: template.subject,
@@ -470,7 +470,7 @@ export const emailService = {
const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined; const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined;
// Create log entry // Create log entry
const logId = nanoid(); const logId = generateId();
const now = getNow(); const now = getNow();
await (db as any).insert(emailLogs).values({ await (db as any).insert(emailLogs).values({
@@ -525,21 +525,23 @@ export const emailService = {
*/ */
async sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> { async sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> {
// Get ticket with event info // Get ticket with event info
const ticket = await (db as any) const ticket = await dbGet<any>(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).id, ticketId)) .from(tickets)
.get(); .where(eq((tickets as any).id, ticketId))
);
if (!ticket) { if (!ticket) {
return { success: false, error: 'Ticket not found' }; return { success: false, error: 'Ticket not found' };
} }
const event = await (db as any) const event = await dbGet<any>(
.select() (db as any)
.from(events) .select()
.where(eq((events as any).id, ticket.eventId)) .from(events)
.get(); .where(eq((events as any).id, ticket.eventId))
);
if (!event) { if (!event) {
return { success: false, error: 'Event not found' }; return { success: false, error: 'Event not found' };
@@ -580,31 +582,34 @@ export const emailService = {
*/ */
async sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> { async sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> {
// Get payment with ticket and event info // Get payment with ticket and event info
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).id, paymentId)) .from(payments)
.get(); .where(eq((payments as any).id, paymentId))
);
if (!payment) { if (!payment) {
return { success: false, error: 'Payment not found' }; return { success: false, error: 'Payment not found' };
} }
const ticket = await (db as any) const ticket = await dbGet<any>(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).id, payment.ticketId)) .from(tickets)
.get(); .where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) { if (!ticket) {
return { success: false, error: 'Ticket not found' }; return { success: false, error: 'Ticket not found' };
} }
const event = await (db as any) const event = await dbGet<any>(
.select() (db as any)
.from(events) .select()
.where(eq((events as any).id, ticket.eventId)) .from(events)
.get(); .where(eq((events as any).id, ticket.eventId))
);
if (!event) { if (!event) {
return { success: false, error: 'Event not found' }; return { success: false, error: 'Event not found' };
@@ -643,17 +648,19 @@ export const emailService = {
*/ */
async getPaymentConfig(eventId: string): Promise<Record<string, any>> { async getPaymentConfig(eventId: string): Promise<Record<string, any>> {
// Get global options // Get global options
const globalOptions = await (db as any) const globalOptions = await dbGet<any>(
.select() (db as any)
.from(paymentOptions) .select()
.get(); .from(paymentOptions)
);
// Get event overrides // Get event overrides
const overrides = await (db as any) const overrides = await dbGet<any>(
.select() (db as any)
.from(eventPaymentOverrides) .select()
.where(eq((eventPaymentOverrides as any).eventId, eventId)) .from(eventPaymentOverrides)
.get(); .where(eq((eventPaymentOverrides as any).eventId, eventId))
);
// Defaults // Defaults
const defaults = { const defaults = {
@@ -696,33 +703,36 @@ export const emailService = {
*/ */
async sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> { async sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> {
// Get ticket // Get ticket
const ticket = await (db as any) const ticket = await dbGet<any>(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).id, ticketId)) .from(tickets)
.get(); .where(eq((tickets as any).id, ticketId))
);
if (!ticket) { if (!ticket) {
return { success: false, error: 'Ticket not found' }; return { success: false, error: 'Ticket not found' };
} }
// Get event // Get event
const event = await (db as any) const event = await dbGet<any>(
.select() (db as any)
.from(events) .select()
.where(eq((events as any).id, ticket.eventId)) .from(events)
.get(); .where(eq((events as any).id, ticket.eventId))
);
if (!event) { if (!event) {
return { success: false, error: 'Event not found' }; return { success: false, error: 'Event not found' };
} }
// Get payment // Get payment
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).ticketId, ticketId)) .from(payments)
.get(); .where(eq((payments as any).ticketId, ticketId))
);
if (!payment) { if (!payment) {
return { success: false, error: 'Payment not found' }; return { success: false, error: 'Payment not found' };
@@ -797,33 +807,36 @@ export const emailService = {
*/ */
async sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> { async sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> {
// Get payment // Get payment
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).id, paymentId)) .from(payments)
.get(); .where(eq((payments as any).id, paymentId))
);
if (!payment) { if (!payment) {
return { success: false, error: 'Payment not found' }; return { success: false, error: 'Payment not found' };
} }
// Get ticket // Get ticket
const ticket = await (db as any) const ticket = await dbGet<any>(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).id, payment.ticketId)) .from(tickets)
.get(); .where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) { if (!ticket) {
return { success: false, error: 'Ticket not found' }; return { success: false, error: 'Ticket not found' };
} }
// Get event // Get event
const event = await (db as any) const event = await dbGet<any>(
.select() (db as any)
.from(events) .select()
.where(eq((events as any).id, ticket.eventId)) .from(events)
.get(); .where(eq((events as any).id, ticket.eventId))
);
if (!event) { if (!event) {
return { success: false, error: 'Event not found' }; return { success: false, error: 'Event not found' };
@@ -872,11 +885,12 @@ export const emailService = {
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params; const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
// Get event // Get event
const event = await (db as any) const event = await dbGet<any>(
.select() (db as any)
.from(events) .select()
.where(eq((events as any).id, eventId)) .from(events)
.get(); .where(eq((events as any).id, eventId))
);
if (!event) { if (!event) {
return { success: false, sentCount: 0, failedCount: 0, errors: ['Event not found'] }; return { success: false, sentCount: 0, failedCount: 0, errors: ['Event not found'] };
@@ -897,7 +911,7 @@ export const emailService = {
); );
} }
const eventTickets = await ticketQuery.all(); const eventTickets = await dbAll<any>(ticketQuery);
if (eventTickets.length === 0) { if (eventTickets.length === 0) {
return { success: true, sentCount: 0, failedCount: 0, errors: ['No recipients found'] }; return { success: true, sentCount: 0, failedCount: 0, errors: ['No recipients found'] };
@@ -971,7 +985,7 @@ export const emailService = {
const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables); const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables);
// Create log entry // Create log entry
const logId = nanoid(); const logId = generateId();
const now = getNow(); const now = getNow();
await (db as any).insert(emailLogs).values({ await (db as any).insert(emailLogs).values({

View File

@@ -1,15 +1,71 @@
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { randomUUID } from 'crypto';
/**
* Get database type (reads env var each time to handle module loading order)
*/
function getDbType(): string {
return process.env.DB_TYPE || 'sqlite';
}
/**
* Generate a unique ID appropriate for the database type.
* - SQLite: returns nanoid (21-char alphanumeric)
* - PostgreSQL: returns UUID v4
*/
export function generateId(): string { export function generateId(): string {
return nanoid(21); return getDbType() === 'postgres' ? randomUUID() : nanoid(21);
} }
export function generateTicketCode(): string { export function generateTicketCode(): string {
return `TKT-${nanoid(8).toUpperCase()}`; return `TKT-${nanoid(8).toUpperCase()}`;
} }
export function getNow(): string { /**
return new Date().toISOString(); * Get current timestamp in the format appropriate for the database type.
* - SQLite: returns ISO string
* - PostgreSQL: returns Date object
*/
export function getNow(): string | Date {
const now = new Date();
return getDbType() === 'postgres' ? now : now.toISOString();
}
/**
* Convert a date value to the appropriate format for the database type.
* - SQLite: returns ISO string
* - PostgreSQL: returns Date object
*/
export function toDbDate(date: Date | string): string | Date {
const d = typeof date === 'string' ? new Date(date) : date;
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'
* - PostgreSQL: returns integer (1/0) for pgInteger columns
*/
export function toDbBool(value: boolean): boolean | number {
return getDbType() === 'postgres' ? (value ? 1 : 0) : value;
}
/**
* Convert all boolean values in an object to the appropriate database format.
* Useful for converting request data before database insert/update.
*/
export function convertBooleansForDb<T extends Record<string, any>>(obj: T): T {
if (getDbType() !== 'postgres') {
return obj; // SQLite handles booleans automatically
}
const result = { ...obj };
for (const key in result) {
if (typeof result[key] === 'boolean') {
(result as any)[key] = result[key] ? 1 : 0;
}
}
return result;
} }
export function formatCurrency(amount: number, currency: string = 'PYG'): string { export function formatCurrency(amount: number, currency: string = 'PYG'): string {

View File

@@ -1,5 +1,5 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { db, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js'; 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 } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js'; import { getNow } from '../lib/utils.js';
@@ -11,74 +11,84 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
const now = getNow(); const now = getNow();
// Get upcoming events // Get upcoming events
const upcomingEvents = await (db as any) const upcomingEvents = await dbAll(
.select() (db as any)
.from(events) .select()
.where( .from(events)
and( .where(
eq((events as any).status, 'published'), and(
gte((events as any).startDatetime, now) eq((events as any).status, 'published'),
gte((events as any).startDatetime, now)
)
) )
) .orderBy((events as any).startDatetime)
.orderBy((events as any).startDatetime) .limit(5)
.limit(5) );
.all();
// Get recent tickets // Get recent tickets
const recentTickets = await (db as any) const recentTickets = await dbAll(
.select() (db as any)
.from(tickets) .select()
.orderBy(desc((tickets as any).createdAt)) .from(tickets)
.limit(10) .orderBy(desc((tickets as any).createdAt))
.all(); .limit(10)
);
// Get total stats // Get total stats
const totalUsers = await (db as any) const totalUsers = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(users) .select({ count: sql<number>`count(*)` })
.get(); .from(users)
);
const totalEvents = await (db as any) const totalEvents = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(events) .select({ count: sql<number>`count(*)` })
.get(); .from(events)
);
const totalTickets = await (db as any) const totalTickets = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(tickets) .select({ count: sql<number>`count(*)` })
.get(); .from(tickets)
);
const confirmedTickets = await (db as any) const confirmedTickets = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(tickets) .select({ count: sql<number>`count(*)` })
.where(eq((tickets as any).status, 'confirmed')) .from(tickets)
.get(); .where(eq((tickets as any).status, 'confirmed'))
);
const pendingPayments = await (db as any) const pendingPayments = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(payments) .select({ count: sql<number>`count(*)` })
.where(eq((payments as any).status, 'pending')) .from(payments)
.get(); .where(eq((payments as any).status, 'pending'))
);
const paidPayments = await (db as any) const paidPayments = await dbAll<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).status, 'paid')) .from(payments)
.all(); .where(eq((payments as any).status, 'paid'))
);
const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + (p.amount || 0), 0); const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + (p.amount || 0), 0);
const newContacts = await (db as any) const newContacts = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(contacts) .select({ count: sql<number>`count(*)` })
.where(eq((contacts as any).status, 'new')) .from(contacts)
.get(); .where(eq((contacts as any).status, 'new'))
);
const totalSubscribers = await (db as any) const totalSubscribers = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(emailSubscribers) .select({ count: sql<number>`count(*)` })
.where(eq((emailSubscribers as any).status, 'active')) .from(emailSubscribers)
.get(); .where(eq((emailSubscribers as any).status, 'active'))
);
return c.json({ return c.json({
dashboard: { dashboard: {
@@ -101,37 +111,40 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
// Get analytics data (admin) // Get analytics data (admin)
adminRouter.get('/analytics', requireAuth(['admin']), async (c) => { adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
// Get events with ticket counts // Get events with ticket counts
const allEvents = await (db as any).select().from(events).all(); const allEvents = await dbAll<any>((db as any).select().from(events));
const eventStats = await Promise.all( const eventStats = await Promise.all(
allEvents.map(async (event: any) => { allEvents.map(async (event: any) => {
const ticketCount = await (db as any) const ticketCount = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(tickets) .select({ count: sql<number>`count(*)` })
.where(eq((tickets as any).eventId, event.id)) .from(tickets)
.get(); .where(eq((tickets as any).eventId, event.id))
);
const confirmedCount = await (db as any) const confirmedCount = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(tickets) .select({ count: sql<number>`count(*)` })
.where( .from(tickets)
and( .where(
eq((tickets as any).eventId, event.id), and(
eq((tickets as any).status, 'confirmed') eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'confirmed')
)
) )
) );
.get();
const checkedInCount = await (db as any) const checkedInCount = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(tickets) .select({ count: sql<number>`count(*)` })
.where( .from(tickets)
and( .where(
eq((tickets as any).eventId, event.id), and(
eq((tickets as any).status, 'checked_in') eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'checked_in')
)
) )
) );
.get();
return { return {
id: event.id, id: event.id,
@@ -163,28 +176,31 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
query = query.where(eq((tickets as any).eventId, eventId)); query = query.where(eq((tickets as any).eventId, eventId));
} }
const ticketList = await query.all(); const ticketList = await dbAll<any>(query);
// Get user and event details for each ticket // Get user and event details for each ticket
const enrichedTickets = await Promise.all( const enrichedTickets = await Promise.all(
ticketList.map(async (ticket: any) => { ticketList.map(async (ticket: any) => {
const user = await (db as any) const user = await dbGet<any>(
.select() (db as any)
.from(users) .select()
.where(eq((users as any).id, ticket.userId)) .from(users)
.get(); .where(eq((users as any).id, ticket.userId))
);
const event = await (db as any) const event = await dbGet<any>(
.select() (db as any)
.from(events) .select()
.where(eq((events as any).id, ticket.eventId)) .from(events)
.get(); .where(eq((events as any).id, ticket.eventId))
);
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).ticketId, ticket.id)) .from(payments)
.get(); .where(eq((payments as any).ticketId, ticket.id))
);
return { return {
ticketId: ticket.id, ticketId: ticket.id,
@@ -215,24 +231,26 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
// Get all payments // Get all payments
let query = (db as any).select().from(payments); let query = (db as any).select().from(payments);
const allPayments = await query.all(); const allPayments = await dbAll<any>(query);
// Enrich with event and ticket data // Enrich with event and ticket data
const enrichedPayments = await Promise.all( const enrichedPayments = await Promise.all(
allPayments.map(async (payment: any) => { allPayments.map(async (payment: any) => {
const ticket = await (db as any) const ticket = await dbGet<any>(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).id, payment.ticketId)) .from(tickets)
.get(); .where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) return null; if (!ticket) return null;
const event = await (db as any) const event = await dbGet<any>(
.select() (db as any)
.from(events) .select()
.where(eq((events as any).id, ticket.eventId)) .from(events)
.get(); .where(eq((events as any).id, ticket.eventId))
);
// Apply filters // Apply filters
if (eventId && ticket.eventId !== eventId) return null; if (eventId && ticket.eventId !== eventId) return null;

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, users, magicLinkTokens, User } from '../db/index.js'; import { db, dbGet, users, magicLinkTokens, User } from '../db/index.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { import {
hashPassword, hashPassword,
@@ -16,7 +16,7 @@ import {
invalidateAllUserSessions, invalidateAllUserSessions,
requireAuth, requireAuth,
} from '../lib/auth.js'; } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js'; import { generateId, getNow, toDbBool } from '../lib/utils.js';
import { sendEmail } from '../lib/email.js'; import { sendEmail } from '../lib/email.js';
// User type that includes all fields (some added in schema updates) // User type that includes all fields (some added in schema updates)
@@ -121,7 +121,9 @@ auth.post('/register', zValidator('json', registerSchema), async (c) => {
} }
// Check if email exists // Check if email exists
const existing = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get(); const existing = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
if (existing) { if (existing) {
// If user exists but is unclaimed, allow claiming // If user exists but is unclaimed, allow claiming
if (!existing.isClaimed || existing.accountStatus === 'unclaimed') { if (!existing.isClaimed || existing.accountStatus === 'unclaimed') {
@@ -149,7 +151,7 @@ auth.post('/register', zValidator('json', registerSchema), async (c) => {
phone: data.phone || null, phone: data.phone || null,
role: firstUser ? 'admin' : 'user', role: firstUser ? 'admin' : 'user',
languagePreference: data.languagePreference || null, languagePreference: data.languagePreference || null,
isClaimed: true, isClaimed: toDbBool(true),
googleId: null, googleId: null,
rucNumber: null, rucNumber: null,
accountStatus: 'active', accountStatus: 'active',
@@ -189,7 +191,9 @@ auth.post('/login', zValidator('json', loginSchema), async (c) => {
}, 429); }, 429);
} }
const user = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get(); const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
if (!user) { if (!user) {
recordFailedAttempt(data.email); recordFailedAttempt(data.email);
return c.json({ error: 'Invalid credentials' }, 401); return c.json({ error: 'Invalid credentials' }, 401);
@@ -243,7 +247,9 @@ auth.post('/login', zValidator('json', loginSchema), async (c) => {
auth.post('/magic-link/request', zValidator('json', magicLinkRequestSchema), async (c) => { auth.post('/magic-link/request', zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json'); const { email } = c.req.valid('json');
const user = await (db as any).select().from(users).where(eq((users as any).email, email)).get(); const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) { if (!user) {
// Don't reveal if email exists // Don't reveal if email exists
@@ -288,7 +294,9 @@ auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async
return c.json({ error: verification.error }, 400); return c.json({ error: verification.error }, 400);
} }
const user = await (db as any).select().from(users).where(eq((users as any).id, verification.userId)).get(); const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
);
if (!user || user.accountStatus === 'suspended') { if (!user || user.accountStatus === 'suspended') {
return c.json({ error: 'Invalid token' }, 400); return c.json({ error: 'Invalid token' }, 400);
@@ -317,7 +325,9 @@ auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async
auth.post('/password-reset/request', zValidator('json', passwordResetRequestSchema), async (c) => { auth.post('/password-reset/request', zValidator('json', passwordResetRequestSchema), async (c) => {
const { email } = c.req.valid('json'); const { email } = c.req.valid('json');
const user = await (db as any).select().from(users).where(eq((users as any).email, email)).get(); const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) { if (!user) {
// Don't reveal if email exists // Don't reveal if email exists
@@ -389,7 +399,9 @@ auth.post('/password-reset/confirm', zValidator('json', passwordResetSchema), as
auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema), async (c) => { auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json'); const { email } = c.req.valid('json');
const user = await (db as any).select().from(users).where(eq((users as any).email, email)).get(); const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) { if (!user) {
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' }); return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
@@ -439,7 +451,7 @@ auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), asyn
const now = getNow(); const now = getNow();
const updates: Record<string, any> = { const updates: Record<string, any> = {
isClaimed: true, isClaimed: toDbBool(true),
accountStatus: 'active', accountStatus: 'active',
updatedAt: now, updatedAt: now,
}; };
@@ -461,7 +473,9 @@ auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), asyn
.set(updates) .set(updates)
.where(eq((users as any).id, verification.userId)); .where(eq((users as any).id, verification.userId));
const user = await (db as any).select().from(users).where(eq((users as any).id, verification.userId)).get(); const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
);
const authToken = await createToken(user.id, user.email, user.role); const authToken = await createToken(user.id, user.email, user.role);
const refreshToken = await createRefreshToken(user.id); const refreshToken = await createRefreshToken(user.id);
@@ -510,11 +524,15 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
const { sub: googleId, email, name } = googleData; const { sub: googleId, email, name } = googleData;
// Check if user exists by email or google_id // Check if user exists by email or google_id
let user = await (db as any).select().from(users).where(eq((users as any).email, email)).get(); let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) { if (!user) {
// Check by google_id // Check by google_id
user = await (db as any).select().from(users).where(eq((users as any).googleId, googleId)).get(); user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).googleId, googleId))
);
} }
const now = getNow(); const now = getNow();
@@ -530,7 +548,7 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
.update(users) .update(users)
.set({ .set({
googleId, googleId,
isClaimed: true, isClaimed: toDbBool(true),
accountStatus: 'active', accountStatus: 'active',
updatedAt: now, updatedAt: now,
}) })
@@ -538,7 +556,9 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
} }
// Refresh user data // Refresh user data
user = await (db as any).select().from(users).where(eq((users as any).id, user.id)).get(); user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, user.id))
);
} else { } else {
// Create new user // Create new user
const firstUser = await isFirstUser(); const firstUser = await isFirstUser();
@@ -552,7 +572,7 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
phone: null, phone: null,
role: firstUser ? 'admin' : 'user', role: firstUser ? 'admin' : 'user',
languagePreference: null, languagePreference: null,
isClaimed: true, isClaimed: toDbBool(true),
googleId, googleId,
rucNumber: null, rucNumber: null,
accountStatus: 'active', accountStatus: 'active',

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, contacts, emailSubscribers } from '../db/index.js'; import { db, dbGet, dbAll, contacts, emailSubscribers } from '../db/index.js';
import { eq, desc } from 'drizzle-orm'; import { eq, desc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js'; import { generateId, getNow } from '../lib/utils.js';
@@ -48,11 +48,12 @@ contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c)
const data = c.req.valid('json'); const data = c.req.valid('json');
// Check if already subscribed // Check if already subscribed
const existing = await (db as any) const existing = await dbGet<any>(
.select() (db as any)
.from(emailSubscribers) .select()
.where(eq((emailSubscribers as any).email, data.email)) .from(emailSubscribers)
.get(); .where(eq((emailSubscribers as any).email, data.email))
);
if (existing) { if (existing) {
if (existing.status === 'unsubscribed') { if (existing.status === 'unsubscribed') {
@@ -87,11 +88,9 @@ contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c)
contactsRouter.post('/unsubscribe', zValidator('json', z.object({ email: z.string().email() })), async (c) => { contactsRouter.post('/unsubscribe', zValidator('json', z.object({ email: z.string().email() })), async (c) => {
const { email } = c.req.valid('json'); const { email } = c.req.valid('json');
const existing = await (db as any) const existing = await dbGet<any>(
.select() (db as any).select().from(emailSubscribers).where(eq((emailSubscribers as any).email, email))
.from(emailSubscribers) );
.where(eq((emailSubscribers as any).email, email))
.get();
if (!existing) { if (!existing) {
return c.json({ error: 'Email not found' }, 404); return c.json({ error: 'Email not found' }, 404);
@@ -115,7 +114,7 @@ contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
query = query.where(eq((contacts as any).status, status)); query = query.where(eq((contacts as any).status, status));
} }
const result = await query.orderBy(desc((contacts as any).createdAt)).all(); const result = await dbAll(query.orderBy(desc((contacts as any).createdAt)));
return c.json({ contacts: result }); return c.json({ contacts: result });
}); });
@@ -124,11 +123,12 @@ contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
contactsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => { contactsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const contact = await (db as any) const contact = await dbGet(
.select() (db as any)
.from(contacts) .select()
.where(eq((contacts as any).id, id)) .from(contacts)
.get(); .where(eq((contacts as any).id, id))
);
if (!contact) { if (!contact) {
return c.json({ error: 'Contact not found' }, 404); return c.json({ error: 'Contact not found' }, 404);
@@ -142,11 +142,9 @@ contactsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
const id = c.req.param('id'); const id = c.req.param('id');
const data = c.req.valid('json'); const data = c.req.valid('json');
const existing = await (db as any) const existing = await dbGet<any>(
.select() (db as any).select().from(contacts).where(eq((contacts as any).id, id))
.from(contacts) );
.where(eq((contacts as any).id, id))
.get();
if (!existing) { if (!existing) {
return c.json({ error: 'Contact not found' }, 404); return c.json({ error: 'Contact not found' }, 404);
@@ -157,11 +155,9 @@ contactsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
.set({ status: data.status }) .set({ status: data.status })
.where(eq((contacts as any).id, id)); .where(eq((contacts as any).id, id));
const updated = await (db as any) const updated = await dbGet<any>(
.select() (db as any).select().from(contacts).where(eq((contacts as any).id, id))
.from(contacts) );
.where(eq((contacts as any).id, id))
.get();
return c.json({ contact: updated }); return c.json({ contact: updated });
}); });
@@ -185,7 +181,7 @@ contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), asy
query = query.where(eq((emailSubscribers as any).status, status)); query = query.where(eq((emailSubscribers as any).status, status));
} }
const result = await query.orderBy(desc((emailSubscribers as any).createdAt)).all(); const result = await dbAll(query.orderBy(desc((emailSubscribers as any).createdAt)));
return c.json({ subscribers: result }); return c.json({ subscribers: result });
}); });

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, users, tickets, payments, events, invoices, User } from '../db/index.js'; import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js';
import { eq, desc, and, gt, sql } from 'drizzle-orm'; import { eq, desc, and, gt, sql } from 'drizzle-orm';
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, hashPassword, validatePassword } from '../lib/auth.js'; import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, hashPassword, validatePassword } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js'; import { generateId, getNow } from '../lib/utils.js';
@@ -70,11 +70,12 @@ dashboard.put('/profile', zValidator('json', updateProfileSchema), async (c) =>
}) })
.where(eq((users as any).id, user.id)); .where(eq((users as any).id, user.id));
const updatedUser = await (db as any) const updatedUser = await dbGet<any>(
.select() (db as any)
.from(users) .select()
.where(eq((users as any).id, user.id)) .from(users)
.get(); .where(eq((users as any).id, user.id))
);
return c.json({ return c.json({
profile: { profile: {
@@ -95,36 +96,40 @@ dashboard.put('/profile', zValidator('json', updateProfileSchema), async (c) =>
dashboard.get('/tickets', async (c) => { dashboard.get('/tickets', async (c) => {
const user = (c as any).get('user') as AuthUser; const user = (c as any).get('user') as AuthUser;
const userTickets = await (db as any) const userTickets = await dbAll<any>(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).userId, user.id)) .from(tickets)
.orderBy(desc((tickets as any).createdAt)) .where(eq((tickets as any).userId, user.id))
.all(); .orderBy(desc((tickets as any).createdAt))
);
// Get event details for each ticket // Get event details for each ticket
const ticketsWithEvents = await Promise.all( const ticketsWithEvents = await Promise.all(
userTickets.map(async (ticket: any) => { userTickets.map(async (ticket: any) => {
const event = await (db as any) const event = await dbGet<any>(
.select() (db as any)
.from(events) .select()
.where(eq((events as any).id, ticket.eventId)) .from(events)
.get(); .where(eq((events as any).id, ticket.eventId))
);
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).ticketId, ticket.id)) .from(payments)
.get(); .where(eq((payments as any).ticketId, ticket.id))
);
// Check for invoice // Check for invoice
let invoice = null; let invoice: any = null;
if (payment && payment.status === 'paid') { if (payment && payment.status === 'paid') {
invoice = await (db as any) invoice = await dbGet<any>(
.select() (db as any)
.from(invoices) .select()
.where(eq((invoices as any).paymentId, payment.id)) .from(invoices)
.get(); .where(eq((invoices as any).paymentId, payment.id))
);
} }
return { return {
@@ -168,40 +173,44 @@ dashboard.get('/tickets/:id', async (c) => {
const user = (c as any).get('user') as AuthUser; const user = (c as any).get('user') as AuthUser;
const ticketId = c.req.param('id'); const ticketId = c.req.param('id');
const ticket = await (db as any) const ticket = await dbGet<any>(
.select() (db as any)
.from(tickets) .select()
.where( .from(tickets)
and( .where(
eq((tickets as any).id, ticketId), and(
eq((tickets as any).userId, user.id) eq((tickets as any).id, ticketId),
eq((tickets as any).userId, user.id)
)
) )
) );
.get();
if (!ticket) { if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404); return c.json({ error: 'Ticket not found' }, 404);
} }
const event = await (db as any) const event = await dbGet(
.select() (db as any)
.from(events) .select()
.where(eq((events as any).id, ticket.eventId)) .from(events)
.get(); .where(eq((events as any).id, ticket.eventId))
);
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).ticketId, ticket.id)) .from(payments)
.get(); .where(eq((payments as any).ticketId, ticket.id))
);
let invoice = null; let invoice = null;
if (payment && payment.status === 'paid') { if (payment && payment.status === 'paid') {
invoice = await (db as any) invoice = await dbGet(
.select() (db as any)
.from(invoices) .select()
.where(eq((invoices as any).paymentId, payment.id)) .from(invoices)
.get(); .where(eq((invoices as any).paymentId, payment.id))
);
} }
return c.json({ return c.json({
@@ -222,11 +231,12 @@ dashboard.get('/next-event', async (c) => {
const now = getNow(); const now = getNow();
// Get user's tickets for upcoming events // Get user's tickets for upcoming events
const userTickets = await (db as any) const userTickets = await dbAll<any>(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).userId, user.id)) .from(tickets)
.all(); .where(eq((tickets as any).userId, user.id))
);
if (userTickets.length === 0) { if (userTickets.length === 0) {
return c.json({ nextEvent: null }); return c.json({ nextEvent: null });
@@ -240,11 +250,12 @@ dashboard.get('/next-event', async (c) => {
for (const ticket of userTickets) { for (const ticket of userTickets) {
if (ticket.status === 'cancelled') continue; if (ticket.status === 'cancelled') continue;
const event = await (db as any) const event = await dbGet<any>(
.select() (db as any)
.from(events) .select()
.where(eq((events as any).id, ticket.eventId)) .from(events)
.get(); .where(eq((events as any).id, ticket.eventId))
);
if (!event) continue; if (!event) continue;
@@ -253,11 +264,12 @@ dashboard.get('/next-event', async (c) => {
if (!nextEvent || new Date(event.startDatetime) < new Date(nextEvent.startDatetime)) { if (!nextEvent || new Date(event.startDatetime) < new Date(nextEvent.startDatetime)) {
nextEvent = event; nextEvent = event;
nextTicket = ticket; nextTicket = ticket;
nextPayment = await (db as any) nextPayment = await dbGet(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).ticketId, ticket.id)) .from(payments)
.get(); .where(eq((payments as any).ticketId, ticket.id))
);
} }
} }
} }
@@ -282,11 +294,12 @@ dashboard.get('/payments', async (c) => {
const user = (c as any).get('user') as AuthUser; const user = (c as any).get('user') as AuthUser;
// Get all user's tickets first // Get all user's tickets first
const userTickets = await (db as any) const userTickets = await dbAll<any>(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).userId, user.id)) .from(tickets)
.all(); .where(eq((tickets as any).userId, user.id))
);
const ticketIds = userTickets.map((t: any) => t.id); const ticketIds = userTickets.map((t: any) => t.id);
@@ -297,29 +310,32 @@ dashboard.get('/payments', async (c) => {
// Get all payments for user's tickets // Get all payments for user's tickets
const allPayments = []; const allPayments = [];
for (const ticketId of ticketIds) { for (const ticketId of ticketIds) {
const ticketPayments = await (db as any) const ticketPayments = await dbAll<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).ticketId, ticketId)) .from(payments)
.all(); .where(eq((payments as any).ticketId, ticketId))
);
for (const payment of ticketPayments) { for (const payment of ticketPayments) {
const ticket = userTickets.find((t: any) => t.id === payment.ticketId); const ticket = userTickets.find((t: any) => t.id === payment.ticketId);
const event = ticket const event = ticket
? await (db as any) ? await dbGet<any>(
.select() (db as any)
.from(events) .select()
.where(eq((events as any).id, ticket.eventId)) .from(events)
.get() .where(eq((events as any).id, ticket.eventId))
)
: null; : null;
let invoice = null; let invoice: any = null;
if (payment.status === 'paid') { if (payment.status === 'paid') {
invoice = await (db as any) invoice = await dbGet<any>(
.select() (db as any)
.from(invoices) .select()
.where(eq((invoices as any).paymentId, payment.id)) .from(invoices)
.get(); .where(eq((invoices as any).paymentId, payment.id))
);
} }
allPayments.push({ allPayments.push({
@@ -355,36 +371,40 @@ dashboard.get('/payments', async (c) => {
dashboard.get('/invoices', async (c) => { dashboard.get('/invoices', async (c) => {
const user = (c as any).get('user') as AuthUser; const user = (c as any).get('user') as AuthUser;
const userInvoices = await (db as any) const userInvoices = await dbAll<any>(
.select() (db as any)
.from(invoices) .select()
.where(eq((invoices as any).userId, user.id)) .from(invoices)
.orderBy(desc((invoices as any).createdAt)) .where(eq((invoices as any).userId, user.id))
.all(); .orderBy(desc((invoices as any).createdAt))
);
// Get payment and event details for each invoice // Get payment and event details for each invoice
const invoicesWithDetails = await Promise.all( const invoicesWithDetails = await Promise.all(
userInvoices.map(async (invoice: any) => { userInvoices.map(async (invoice: any) => {
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any)
.from(payments)
.where(eq((payments as any).id, invoice.paymentId))
.get();
let event = null;
if (payment) {
const ticket = await (db as any)
.select() .select()
.from(tickets) .from(payments)
.where(eq((tickets as any).id, payment.ticketId)) .where(eq((payments as any).id, invoice.paymentId))
.get(); );
let event: any = null;
if (payment) {
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
);
if (ticket) { if (ticket) {
event = await (db as any) event = await dbGet<any>(
.select() (db as any)
.from(events) .select()
.where(eq((events as any).id, ticket.eventId)) .from(events)
.get(); .where(eq((events as any).id, ticket.eventId))
);
} }
} }
@@ -511,11 +531,12 @@ dashboard.get('/summary', async (c) => {
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24)); const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
// Get ticket count // Get ticket count
const userTickets = await (db as any) const userTickets = await dbAll<any>(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).userId, user.id)) .from(tickets)
.all(); .where(eq((tickets as any).userId, user.id))
);
const totalTickets = userTickets.length; const totalTickets = userTickets.length;
const confirmedTickets = userTickets.filter((t: any) => t.status === 'confirmed' || t.status === 'checked_in').length; const confirmedTickets = userTickets.filter((t: any) => t.status === 'confirmed' || t.status === 'checked_in').length;
@@ -524,11 +545,12 @@ dashboard.get('/summary', async (c) => {
for (const ticket of userTickets) { for (const ticket of userTickets) {
if (ticket.status === 'cancelled') continue; if (ticket.status === 'cancelled') continue;
const event = await (db as any) const event = await dbGet<any>(
.select() (db as any)
.from(events) .select()
.where(eq((events as any).id, ticket.eventId)) .from(events)
.get(); .where(eq((events as any).id, ticket.eventId))
);
if (event && new Date(event.startDatetime) > now) { if (event && new Date(event.startDatetime) > now) {
upcomingTickets.push({ ticket, event }); upcomingTickets.push({ ticket, event });
@@ -540,16 +562,17 @@ dashboard.get('/summary', async (c) => {
let pendingPayments = 0; let pendingPayments = 0;
for (const ticketId of ticketIds) { for (const ticketId of ticketIds) {
const payment = await (db as any) const payment = await dbGet(
.select() (db as any)
.from(payments) .select()
.where( .from(payments)
and( .where(
eq((payments as any).ticketId, ticketId), and(
eq((payments as any).status, 'pending_approval') eq((payments as any).ticketId, ticketId),
eq((payments as any).status, 'pending_approval')
)
) )
) );
.get();
if (payment) pendingPayments++; if (payment) pendingPayments++;
} }

View File

@@ -1,9 +1,8 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { db, emailTemplates, emailLogs, events, tickets } from '../db/index.js'; import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
import { eq, desc, and, sql } from 'drizzle-orm'; import { eq, desc, and, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js'; import { getNow, generateId } from '../lib/utils.js';
import { nanoid } from 'nanoid';
import emailService from '../lib/email.js'; import emailService from '../lib/email.js';
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js'; import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
@@ -13,11 +12,9 @@ const emailsRouter = new Hono();
// Get all email templates // Get all email templates
emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) => { emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) => {
const templates = await (db as any) const templates = await dbAll<any>(
.select() (db as any).select().from(emailTemplates).orderBy(desc((emailTemplates as any).createdAt))
.from(emailTemplates) );
.orderBy(desc((emailTemplates as any).createdAt))
.all();
// Parse variables JSON for each template // Parse variables JSON for each template
const parsedTemplates = templates.map((t: any) => ({ const parsedTemplates = templates.map((t: any) => ({
@@ -34,11 +31,12 @@ emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) =>
emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c) => { emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c) => {
const { id } = c.req.param(); const { id } = c.req.param();
const template = await (db as any) const template = await dbGet<any>(
.select() (db as any)
.from(emailTemplates) .select()
.where(eq((emailTemplates as any).id, id)) .from(emailTemplates)
.get(); .where(eq((emailTemplates as any).id, id))
);
if (!template) { if (!template) {
return c.json({ error: 'Template not found' }, 404); return c.json({ error: 'Template not found' }, 404);
@@ -64,11 +62,9 @@ emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
} }
// Check if slug already exists // Check if slug already exists
const existing = await (db as any) const existing = await dbGet<any>(
.select() (db as any).select().from(emailTemplates).where(eq((emailTemplates as any).slug, slug))
.from(emailTemplates) );
.where(eq((emailTemplates as any).slug, slug))
.get();
if (existing) { if (existing) {
return c.json({ error: 'Template with this slug already exists' }, 400); return c.json({ error: 'Template with this slug already exists' }, 400);
@@ -76,7 +72,7 @@ emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
const now = getNow(); const now = getNow();
const template = { const template = {
id: nanoid(), id: generateId(),
name, name,
slug, slug,
subject, subject,
@@ -111,11 +107,12 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
const { id } = c.req.param(); const { id } = c.req.param();
const body = await c.req.json(); const body = await c.req.json();
const existing = await (db as any) const existing = await dbGet<any>(
.select() (db as any)
.from(emailTemplates) .select()
.where(eq((emailTemplates as any).id, id)) .from(emailTemplates)
.get(); .where(eq((emailTemplates as any).id, id))
);
if (!existing) { if (!existing) {
return c.json({ error: 'Template not found' }, 404); return c.json({ error: 'Template not found' }, 404);
@@ -148,11 +145,12 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
.set(updateData) .set(updateData)
.where(eq((emailTemplates as any).id, id)); .where(eq((emailTemplates as any).id, id));
const updated = await (db as any) const updated = await dbGet<any>(
.select() (db as any)
.from(emailTemplates) .select()
.where(eq((emailTemplates as any).id, id)) .from(emailTemplates)
.get(); .where(eq((emailTemplates as any).id, id))
);
return c.json({ return c.json({
template: { template: {
@@ -169,11 +167,9 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
emailsRouter.delete('/templates/:id', requireAuth(['admin']), async (c) => { emailsRouter.delete('/templates/:id', requireAuth(['admin']), async (c) => {
const { id } = c.req.param(); const { id } = c.req.param();
const template = await (db as any) const template = await dbGet<any>(
.select() (db as any).select().from(emailTemplates).where(eq((emailTemplates as any).id, id))
.from(emailTemplates) );
.where(eq((emailTemplates as any).id, id))
.get();
if (!template) { if (!template) {
return c.json({ error: 'Template not found' }, 404); return c.json({ error: 'Template not found' }, 404);
@@ -306,11 +302,12 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
query = query.where(and(...conditions)); query = query.where(and(...conditions));
} }
const logs = await query const logs = await dbAll(
.orderBy(desc((emailLogs as any).createdAt)) query
.limit(limit) .orderBy(desc((emailLogs as any).createdAt))
.offset(offset) .limit(limit)
.all(); .offset(offset)
);
// Get total count // Get total count
let countQuery = (db as any) let countQuery = (db as any)
@@ -321,7 +318,7 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
countQuery = countQuery.where(and(...conditions)); countQuery = countQuery.where(and(...conditions));
} }
const totalResult = await countQuery.get(); const totalResult = await dbGet<any>(countQuery);
const total = totalResult?.count || 0; const total = totalResult?.count || 0;
return c.json({ return c.json({
@@ -339,11 +336,9 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) => { emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) => {
const { id } = c.req.param(); const { id } = c.req.param();
const log = await (db as any) const log = await dbGet<any>(
.select() (db as any).select().from(emailLogs).where(eq((emailLogs as any).id, id))
.from(emailLogs) );
.where(eq((emailLogs as any).id, id))
.get();
if (!log) { if (!log) {
return c.json({ error: 'Email log not found' }, 404); return c.json({ error: 'Email log not found' }, 404);
@@ -362,22 +357,22 @@ emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
? (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(baseCondition) ? (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(baseCondition)
: (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs); : (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs);
const total = (await totalQuery.get())?.count || 0; const total = (await dbGet<any>(totalQuery))?.count || 0;
const sentCondition = baseCondition const sentCondition = baseCondition
? and(baseCondition, eq((emailLogs as any).status, 'sent')) ? and(baseCondition, eq((emailLogs as any).status, 'sent'))
: eq((emailLogs as any).status, 'sent'); : eq((emailLogs as any).status, 'sent');
const sent = (await (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(sentCondition).get())?.count || 0; const sent = (await dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(sentCondition)))?.count || 0;
const failedCondition = baseCondition const failedCondition = baseCondition
? and(baseCondition, eq((emailLogs as any).status, 'failed')) ? and(baseCondition, eq((emailLogs as any).status, 'failed'))
: eq((emailLogs as any).status, 'failed'); : eq((emailLogs as any).status, 'failed');
const failed = (await (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(failedCondition).get())?.count || 0; const failed = (await dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(failedCondition)))?.count || 0;
const pendingCondition = baseCondition const pendingCondition = baseCondition
? and(baseCondition, eq((emailLogs as any).status, 'pending')) ? and(baseCondition, eq((emailLogs as any).status, 'pending'))
: eq((emailLogs as any).status, 'pending'); : eq((emailLogs as any).status, 'pending');
const pending = (await (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(pendingCondition).get())?.count || 0; const pending = (await dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(pendingCondition)))?.count || 0;
return c.json({ return c.json({
stats: { stats: {

View File

@@ -1,10 +1,10 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices } from '../db/index.js'; import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices } from '../db/index.js';
import { eq, desc, and, gte, sql } from 'drizzle-orm'; import { eq, desc, and, gte, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js'; import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js'; import { generateId, getNow, convertBooleansForDb, toDbDate } from '../lib/utils.js';
interface UserContext { interface UserContext {
id: string; id: string;
@@ -15,6 +15,21 @@ interface UserContext {
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>(); const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
// Helper to normalize event data for API response
// PostgreSQL decimal returns strings, booleans are stored as integers
function normalizeEvent(event: any) {
if (!event) return event;
return {
...event,
// Convert price from string/decimal to clean number
price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price),
// Convert capacity from string to number if needed
capacity: typeof event.capacity === 'string' ? parseInt(event.capacity, 10) : Number(event.capacity),
// Convert boolean integers to actual booleans for frontend
externalBookingEnabled: Boolean(event.externalBookingEnabled),
};
}
// Custom validation error handler // Custom validation error handler
const validationHook = (result: any, c: any) => { const validationHook = (result: any, c: any) => {
if (!result.success) { if (!result.success) {
@@ -23,6 +38,27 @@ const validationHook = (result: any, c: any) => {
} }
}; };
// Helper to parse price from string (handles both "45000" and "41,44" formats)
const parsePrice = (val: unknown): number => {
if (typeof val === 'number') return val;
if (typeof val === 'string') {
// Replace comma with dot for decimal parsing (European format)
const normalized = val.replace(',', '.');
const parsed = parseFloat(normalized);
return isNaN(parsed) ? 0 : parsed;
}
return 0;
};
// Helper to normalize boolean (handles true/false and 0/1)
const normalizeBoolean = (val: unknown): boolean => {
if (typeof val === 'boolean') return val;
if (typeof val === 'number') return val !== 0;
if (val === 'true') return true;
if (val === 'false') return false;
return false;
};
const baseEventSchema = z.object({ const baseEventSchema = z.object({
title: z.string().min(1), title: z.string().min(1),
titleEs: z.string().optional().nullable(), titleEs: z.string().optional().nullable(),
@@ -34,14 +70,15 @@ const baseEventSchema = z.object({
endDatetime: z.string().optional().nullable(), endDatetime: z.string().optional().nullable(),
location: z.string().min(1), location: z.string().min(1),
locationUrl: z.string().url().optional().nullable().or(z.literal('')), locationUrl: z.string().url().optional().nullable().or(z.literal('')),
price: z.number().min(0).default(0), // Accept price as number or string (handles "45000" and "41,44" formats)
price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0),
currency: z.string().default('PYG'), currency: z.string().default('PYG'),
capacity: z.number().min(1).default(50), capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
status: z.enum(['draft', 'published', 'cancelled', 'completed', 'archived']).default('draft'), status: z.enum(['draft', 'published', 'cancelled', 'completed', 'archived']).default('draft'),
// Accept relative paths (/uploads/...) or full URLs // Accept relative paths (/uploads/...) or full URLs
bannerUrl: z.string().optional().nullable().or(z.literal('')), bannerUrl: z.string().optional().nullable().or(z.literal('')),
// External booking support // External booking support - accept boolean or number (0/1 from DB)
externalBookingEnabled: z.boolean().default(false), externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false),
externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')), externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')),
}); });
@@ -94,26 +131,28 @@ eventsRouter.get('/', async (c) => {
); );
} }
const result = await query.orderBy(desc((events as any).startDatetime)).all(); const result = await dbAll(query.orderBy(desc((events as any).startDatetime)));
// Get ticket counts for each event // Get ticket counts for each event
const eventsWithCounts = await Promise.all( const eventsWithCounts = await Promise.all(
result.map(async (event: any) => { result.map(async (event: any) => {
const ticketCount = await (db as any) const ticketCount = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(tickets) .select({ count: sql<number>`count(*)` })
.where( .from(tickets)
and( .where(
eq((tickets as any).eventId, event.id), and(
eq((tickets as any).status, 'confirmed') eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'confirmed')
)
) )
) );
.get();
const normalized = normalizeEvent(event);
return { return {
...event, ...normalized,
bookedCount: ticketCount?.count || 0, bookedCount: ticketCount?.count || 0,
availableSeats: event.capacity - (ticketCount?.count || 0), availableSeats: normalized.capacity - (ticketCount?.count || 0),
}; };
}) })
); );
@@ -125,29 +164,33 @@ eventsRouter.get('/', async (c) => {
eventsRouter.get('/:id', async (c) => { eventsRouter.get('/:id', async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const event = await (db as any).select().from(events).where(eq((events as any).id, id)).get(); const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, id))
);
if (!event) { if (!event) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
// Get ticket count // Get ticket count
const ticketCount = await (db as any) const ticketCount = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(tickets) .select({ count: sql<number>`count(*)` })
.where( .from(tickets)
and( .where(
eq((tickets as any).eventId, id), and(
eq((tickets as any).status, 'confirmed') eq((tickets as any).eventId, id),
eq((tickets as any).status, 'confirmed')
)
) )
) );
.get();
const normalized = normalizeEvent(event);
return c.json({ return c.json({
event: { event: {
...event, ...normalized,
bookedCount: ticketCount?.count || 0, bookedCount: ticketCount?.count || 0,
availableSeats: event.capacity - (ticketCount?.count || 0), availableSeats: normalized.capacity - (ticketCount?.count || 0),
}, },
}); });
}); });
@@ -156,39 +199,42 @@ eventsRouter.get('/:id', async (c) => {
eventsRouter.get('/next/upcoming', async (c) => { eventsRouter.get('/next/upcoming', async (c) => {
const now = getNow(); const now = getNow();
const event = await (db as any) const event = await dbGet<any>(
.select() (db as any)
.from(events) .select()
.where( .from(events)
and( .where(
eq((events as any).status, 'published'), and(
gte((events as any).startDatetime, now) eq((events as any).status, 'published'),
gte((events as any).startDatetime, now)
)
) )
) .orderBy((events as any).startDatetime)
.orderBy((events as any).startDatetime) .limit(1)
.limit(1) );
.get();
if (!event) { if (!event) {
return c.json({ event: null }); return c.json({ event: null });
} }
const ticketCount = await (db as any) const ticketCount = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(tickets) .select({ count: sql<number>`count(*)` })
.where( .from(tickets)
and( .where(
eq((tickets as any).eventId, event.id), and(
eq((tickets as any).status, 'confirmed') eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'confirmed')
)
) )
) );
.get();
const normalized = normalizeEvent(event);
return c.json({ return c.json({
event: { event: {
...event, ...normalized,
bookedCount: ticketCount?.count || 0, bookedCount: ticketCount?.count || 0,
availableSeats: event.capacity - (ticketCount?.count || 0), availableSeats: normalized.capacity - (ticketCount?.count || 0),
}, },
}); });
}); });
@@ -200,16 +246,22 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
const now = getNow(); const now = getNow();
const id = generateId(); const id = generateId();
// Convert data for database compatibility
const dbData = convertBooleansForDb(data);
const newEvent = { const newEvent = {
id, id,
...data, ...dbData,
startDatetime: toDbDate(data.startDatetime),
endDatetime: data.endDatetime ? toDbDate(data.endDatetime) : null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
await (db as any).insert(events).values(newEvent); await (db as any).insert(events).values(newEvent);
return c.json({ event: newEvent }, 201); // Return normalized event data
return c.json({ event: normalizeEvent(newEvent) }, 201);
}); });
// Update event (admin/organizer only) // Update event (admin/organizer only)
@@ -217,46 +269,64 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
const id = c.req.param('id'); const id = c.req.param('id');
const data = c.req.valid('json'); const data = c.req.valid('json');
const existing = await (db as any).select().from(events).where(eq((events as any).id, id)).get(); const existing = await dbGet(
(db as any).select().from(events).where(eq((events as any).id, id))
);
if (!existing) { if (!existing) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
const now = getNow(); const now = getNow();
// 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);
}
if (data.endDatetime !== undefined) {
updateData.endDatetime = data.endDatetime ? toDbDate(data.endDatetime) : null;
}
await (db as any) await (db as any)
.update(events) .update(events)
.set({ ...data, updatedAt: now }) .set(updateData)
.where(eq((events as any).id, id)); .where(eq((events as any).id, id));
const updated = await (db as any).select().from(events).where(eq((events as any).id, id)).get(); const updated = await dbGet(
(db as any).select().from(events).where(eq((events as any).id, id))
);
return c.json({ event: updated }); return c.json({ event: normalizeEvent(updated) });
}); });
// Delete event (admin only) // Delete event (admin only)
eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => { eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const existing = await (db as any).select().from(events).where(eq((events as any).id, id)).get(); const existing = await dbGet(
(db as any).select().from(events).where(eq((events as any).id, id))
);
if (!existing) { if (!existing) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
// Get all tickets for this event // Get all tickets for this event
const eventTickets = await (db as any) const eventTickets = await dbAll<any>(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).eventId, id)) .from(tickets)
.all(); .where(eq((tickets as any).eventId, id))
);
// Delete invoices and payments for all tickets of this event // Delete invoices and payments for all tickets of this event
for (const ticket of eventTickets) { for (const ticket of eventTickets) {
// Get payments for this ticket // Get payments for this ticket
const ticketPayments = await (db as any) const ticketPayments = await dbAll<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).ticketId, ticket.id)) .from(payments)
.all(); .where(eq((payments as any).ticketId, ticket.id))
);
// Delete invoices for each payment // Delete invoices for each payment
for (const payment of ticketPayments) { for (const payment of ticketPayments) {
@@ -289,11 +359,12 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']), async (c) => { eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const attendees = await (db as any) const attendees = await dbAll(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).eventId, id)) .from(tickets)
.all(); .where(eq((tickets as any).eventId, id))
);
return c.json({ attendees }); return c.json({ attendees });
}); });
@@ -302,7 +373,9 @@ eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']),
eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (c) => { eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const existing = await (db as any).select().from(events).where(eq((events as any).id, id)).get(); const existing = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, id))
);
if (!existing) { if (!existing) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
@@ -319,7 +392,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
descriptionEs: existing.descriptionEs, descriptionEs: existing.descriptionEs,
shortDescription: existing.shortDescription, shortDescription: existing.shortDescription,
shortDescriptionEs: existing.shortDescriptionEs, shortDescriptionEs: existing.shortDescriptionEs,
startDatetime: existing.startDatetime, startDatetime: existing.startDatetime, // Already in DB format from existing record
endDatetime: existing.endDatetime, endDatetime: existing.endDatetime,
location: existing.location, location: existing.location,
locationUrl: existing.locationUrl, locationUrl: existing.locationUrl,
@@ -328,7 +401,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
capacity: existing.capacity, capacity: existing.capacity,
status: 'draft', status: 'draft',
bannerUrl: existing.bannerUrl, bannerUrl: existing.bannerUrl,
externalBookingEnabled: existing.externalBookingEnabled || false, externalBookingEnabled: existing.externalBookingEnabled ?? 0, // Already in DB format (0/1)
externalBookingUrl: existing.externalBookingUrl, externalBookingUrl: existing.externalBookingUrl,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -336,7 +409,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
await (db as any).insert(events).values(duplicatedEvent); await (db as any).insert(events).values(duplicatedEvent);
return c.json({ event: duplicatedEvent, message: 'Event duplicated successfully' }, 201); return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201);
}); });
export default eventsRouter; export default eventsRouter;

View File

@@ -0,0 +1,393 @@
import { Hono } from 'hono';
import { db, dbGet, dbAll, legalPages } from '../db/index.js';
import { eq, desc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow, generateId } from '../lib/utils.js';
import fs from 'fs';
import path from 'path';
const legalPagesRouter = new Hono();
// Helper: Convert plain text to simple markdown
// Preserves paragraphs and line breaks, nothing fancy
function textToMarkdown(text: string): string {
if (!text) return '';
// Split into paragraphs (double newlines)
const paragraphs = text.split(/\n\s*\n/);
// Process each paragraph
const processed = paragraphs.map(para => {
// Replace single newlines with double spaces + newline for markdown line breaks
return para.trim().replace(/\n/g, ' \n');
});
// Join paragraphs with double newlines
return processed.join('\n\n');
}
// Helper: Convert markdown to plain text for editing
function markdownToText(markdown: string): string {
if (!markdown) return '';
let text = markdown;
// Remove markdown heading markers (# ## ###)
text = text.replace(/^#{1,6}\s+/gm, '');
// Remove horizontal rules
text = text.replace(/^---+$/gm, '');
// Remove bold/italic markers
text = text.replace(/\*\*([^*]+)\*\*/g, '$1');
text = text.replace(/\*([^*]+)\*/g, '$1');
text = text.replace(/__([^_]+)__/g, '$1');
text = text.replace(/_([^_]+)_/g, '$1');
// Remove list markers (preserve text)
text = text.replace(/^\s*[\*\-\+]\s+/gm, '');
text = text.replace(/^\s*\d+\.\s+/gm, '');
// Remove link formatting [text](url) -> text
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
// Remove double-space line breaks
text = text.replace(/ \n/g, '\n');
// Normalize multiple newlines
text = text.replace(/\n{3,}/g, '\n\n');
return text.trim();
}
// Helper: Extract title from markdown content
function extractTitleFromMarkdown(content: string): string {
if (!content) return 'Untitled';
const match = content.match(/^#\s+(.+?)(?:\s*[-]\s*.+)?$/m);
if (match) {
return match[1].trim();
}
// Fallback to first line
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
return firstLine || 'Untitled';
}
// Helper: Get legal directory path
function getLegalDir(): string {
// When running from backend, legal folder is in frontend
const possiblePaths = [
path.join(process.cwd(), '../frontend/legal'),
path.join(process.cwd(), 'frontend/legal'),
path.join(process.cwd(), 'legal'),
];
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
return p;
}
}
return possiblePaths[0]; // Default
}
// Helper: Convert filename to slug
function fileNameToSlug(fileName: string): string {
return fileName.replace('.md', '').replace(/_/g, '-');
}
// Title map for localization
const titleMap: Record<string, { en: string; es: string }> = {
'privacy-policy': { en: 'Privacy Policy', es: 'Política de Privacidad' },
'terms-policy': { en: 'Terms & Conditions', es: 'Términos y Condiciones' },
'refund-cancelation-policy': { en: 'Refund & Cancellation Policy', es: 'Política de Reembolso y Cancelación' },
};
// Helper: Get localized content with fallback
// If requested locale content is missing, fallback to the other locale
function getLocalizedContent(page: any, locale: string = 'en'): { title: string; contentMarkdown: string } {
const isSpanish = locale === 'es';
// Title: prefer requested locale, fallback to other
let title: string;
if (isSpanish) {
title = page.titleEs || page.title;
} else {
title = page.title || page.titleEs;
}
// Content: prefer requested locale, fallback to other
let contentMarkdown: string;
if (isSpanish) {
contentMarkdown = page.contentMarkdownEs || page.contentMarkdown;
} else {
contentMarkdown = page.contentMarkdown || page.contentMarkdownEs;
}
return { title, contentMarkdown };
}
// ==================== Public Routes ====================
// Get all legal pages (public, for footer/navigation)
legalPagesRouter.get('/', async (c) => {
const locale = c.req.query('locale') || 'en';
const pages = await dbAll<any>(
(db as any)
.select({
id: (legalPages as any).id,
slug: (legalPages as any).slug,
title: (legalPages as any).title,
titleEs: (legalPages as any).titleEs,
updatedAt: (legalPages as any).updatedAt,
})
.from(legalPages)
.orderBy((legalPages as any).slug)
);
// Return pages with localized title
const localizedPages = pages.map((page: any) => ({
id: page.id,
slug: page.slug,
title: locale === 'es' ? (page.titleEs || page.title) : (page.title || page.titleEs),
updatedAt: page.updatedAt,
}));
return c.json({ pages: localizedPages });
});
// Get single legal page (public, for rendering)
legalPagesRouter.get('/:slug', async (c) => {
const { slug } = c.req.param();
const locale = c.req.query('locale') || 'en';
// First try to get from database
const page = await dbGet<any>(
(db as any).select().from(legalPages).where(eq((legalPages as any).slug, slug))
);
if (page) {
// Get localized content with fallback
const { title, contentMarkdown } = getLocalizedContent(page, locale);
return c.json({
page: {
id: page.id,
slug: page.slug,
title,
contentMarkdown,
updatedAt: page.updatedAt,
source: 'database',
}
});
}
// Fallback to filesystem
const legalDir = getLegalDir();
const fileName = slug.replace(/-/g, '_') + '.md';
const filePath = path.join(legalDir, fileName);
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8');
const titles = titleMap[slug];
const title = locale === 'es'
? (titles?.es || titles?.en || slug)
: (titles?.en || titles?.es || slug);
return c.json({
page: {
slug,
title,
contentMarkdown: content,
source: 'filesystem',
}
});
}
return c.json({ error: 'Legal page not found' }, 404);
});
// ==================== Admin Routes ====================
// Get all legal pages for admin (with full content)
legalPagesRouter.get('/admin/list', requireAuth(['admin']), async (c) => {
const pages = await dbAll<any>(
(db as any)
.select()
.from(legalPages)
.orderBy((legalPages as any).slug)
);
// Add flags to indicate which languages have content
const pagesWithFlags = pages.map((page: any) => ({
...page,
hasEnglish: Boolean(page.contentText),
hasSpanish: Boolean(page.contentTextEs),
}));
return c.json({ pages: pagesWithFlags });
});
// Get single legal page for editing (admin)
legalPagesRouter.get('/admin/:slug', requireAuth(['admin']), async (c) => {
const { slug } = c.req.param();
const page = await dbGet<any>(
(db as any).select().from(legalPages).where(eq((legalPages as any).slug, slug))
);
if (!page) {
return c.json({ error: 'Legal page not found' }, 404);
}
return c.json({
page: {
...page,
hasEnglish: Boolean(page.contentText),
hasSpanish: Boolean(page.contentTextEs),
}
});
});
// Update legal page (admin only)
// Note: No creation or deletion - only updates are allowed
// Accepts markdown content from rich text editor
legalPagesRouter.put('/admin/:slug', requireAuth(['admin']), async (c) => {
const { slug } = c.req.param();
const user = (c as any).get('user');
const body = await c.req.json();
// Accept both contentText (legacy plain text) and contentMarkdown (from rich text editor)
const { contentText, contentTextEs, contentMarkdown, contentMarkdownEs, title, titleEs } = body;
// Determine content - prefer markdown if provided, fall back to contentText
const enContent = contentMarkdown !== undefined ? contentMarkdown : contentText;
const esContent = contentMarkdownEs !== undefined ? contentMarkdownEs : contentTextEs;
// At least one content field is required
if (!enContent && !esContent) {
return c.json({ error: 'At least one language content is required' }, 400);
}
const existing = await dbGet(
(db as any)
.select()
.from(legalPages)
.where(eq((legalPages as any).slug, slug))
);
if (!existing) {
return c.json({ error: 'Legal page not found' }, 404);
}
const updateData: any = {
updatedAt: getNow(),
updatedBy: user?.id || null,
};
// Update English content if provided
if (enContent !== undefined) {
// Store markdown directly (from rich text editor)
updateData.contentMarkdown = enContent;
// Derive plain text from markdown
updateData.contentText = markdownToText(enContent);
}
// Update Spanish content if provided
if (esContent !== undefined) {
updateData.contentMarkdownEs = esContent || null;
updateData.contentTextEs = esContent ? markdownToText(esContent) : null;
}
// Allow updating titles
if (title !== undefined) {
updateData.title = title;
}
if (titleEs !== undefined) {
updateData.titleEs = titleEs || null;
}
await (db as any)
.update(legalPages)
.set(updateData)
.where(eq((legalPages as any).slug, slug));
const updated = await dbGet<any>(
(db as any).select().from(legalPages).where(eq((legalPages as any).slug, slug))
);
return c.json({
page: {
...updated,
hasEnglish: Boolean(updated.contentText),
hasSpanish: Boolean(updated.contentTextEs),
},
message: 'Legal page updated successfully'
});
});
// Seed legal pages from filesystem (admin only)
// This imports markdown files and converts them to plain text for editing
// Files are imported as English content - Spanish can be added manually
legalPagesRouter.post('/admin/seed', requireAuth(['admin']), async (c) => {
const user = (c as any).get('user');
// Check if table already has data
const existingPages = await dbAll(
(db as any)
.select({ id: (legalPages as any).id })
.from(legalPages)
.limit(1)
);
if (existingPages.length > 0) {
return c.json({
message: 'Legal pages already seeded. Use update to modify pages.',
seeded: 0
});
}
const legalDir = getLegalDir();
if (!fs.existsSync(legalDir)) {
return c.json({ error: `Legal directory not found: ${legalDir}` }, 400);
}
const files = fs.readdirSync(legalDir).filter(f => f.endsWith('.md'));
const seededPages: string[] = [];
const now = getNow();
for (const file of files) {
const filePath = path.join(legalDir, file);
const contentMarkdown = fs.readFileSync(filePath, 'utf-8');
const slug = fileNameToSlug(file);
const contentText = markdownToText(contentMarkdown);
const titles = titleMap[slug];
const title = titles?.en || extractTitleFromMarkdown(contentMarkdown);
const titleEs = titles?.es || null;
await (db as any).insert(legalPages).values({
id: generateId(),
slug,
title,
titleEs,
contentText, // English plain text
contentTextEs: null, // Spanish to be added manually
contentMarkdown, // English markdown
contentMarkdownEs: null, // Spanish to be generated when contentTextEs is set
updatedAt: now,
updatedBy: user?.id || null,
createdAt: now,
});
seededPages.push(slug);
}
return c.json({
message: `Successfully seeded ${seededPages.length} legal pages (English content imported, Spanish can be added via editor)`,
seeded: seededPages.length,
pages: seededPages,
});
});
export default legalPagesRouter;

View File

@@ -1,6 +1,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming'; import { streamSSE } from 'hono/streaming';
import { db, tickets, payments } from '../db/index.js'; import { db, dbGet, tickets, payments } from '../db/index.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getNow } from '../lib/utils.js'; import { getNow } from '../lib/utils.js';
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js'; import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
@@ -157,11 +157,9 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
const now = getNow(); const now = getNow();
// Check if already confirmed to avoid duplicate updates // Check if already confirmed to avoid duplicate updates
const existingTicket = await (db as any) const existingTicket = await dbGet<any>(
.select() (db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
.from(tickets) );
.where(eq((tickets as any).id, ticketId))
.get();
if (existingTicket?.status === 'confirmed') { if (existingTicket?.status === 'confirmed') {
console.log(`Ticket ${ticketId} already confirmed, skipping update`); console.log(`Ticket ${ticketId} already confirmed, skipping update`);
@@ -188,11 +186,12 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
console.log(`Ticket ${ticketId} confirmed via Lightning payment (hash: ${paymentHash})`); console.log(`Ticket ${ticketId} confirmed via Lightning payment (hash: ${paymentHash})`);
// Get payment for sending receipt // Get payment for sending receipt
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).ticketId, ticketId)) .from(payments)
.get(); .where(eq((payments as any).ticketId, ticketId))
);
// Send confirmation emails asynchronously // Send confirmation emails asynchronously
Promise.all([ Promise.all([
@@ -211,11 +210,9 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
const ticketId = c.req.param('ticketId'); const ticketId = c.req.param('ticketId');
// Verify ticket exists // Verify ticket exists
const ticket = await (db as any) const ticket = await dbGet<any>(
.select() (db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
.from(tickets) );
.where(eq((tickets as any).id, ticketId))
.get();
if (!ticket) { if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404); return c.json({ error: 'Ticket not found' }, 404);
@@ -227,11 +224,9 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
} }
// Get payment to start background checker // Get payment to start background checker
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
.from(payments) );
.where(eq((payments as any).ticketId, ticketId))
.get();
// Start background checker if not already running // Start background checker if not already running
if (payment?.reference && !activeCheckers.has(ticketId)) { if (payment?.reference && !activeCheckers.has(ticketId)) {
@@ -290,21 +285,23 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
lnbitsRouter.get('/status/:ticketId', async (c) => { lnbitsRouter.get('/status/:ticketId', async (c) => {
const ticketId = c.req.param('ticketId'); const ticketId = c.req.param('ticketId');
const ticket = await (db as any) const ticket = await dbGet<any>(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).id, ticketId)) .from(tickets)
.get(); .where(eq((tickets as any).id, ticketId))
);
if (!ticket) { if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404); return c.json({ error: 'Ticket not found' }, 404);
} }
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).ticketId, ticketId)) .from(payments)
.get(); .where(eq((payments as any).ticketId, ticketId))
);
return c.json({ return c.json({
ticketStatus: ticket.status, ticketStatus: ticket.status,

View File

@@ -1,5 +1,5 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { db, media } from '../db/index.js'; import { db, dbGet, dbAll, media } from '../db/index.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js'; import { generateId, getNow } from '../lib/utils.js';
@@ -85,11 +85,9 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
mediaRouter.get('/:id', async (c) => { mediaRouter.get('/:id', async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const mediaRecord = await (db as any) const mediaRecord = await dbGet<any>(
.select() (db as any).select().from(media).where(eq((media as any).id, id))
.from(media) );
.where(eq((media as any).id, id))
.get();
if (!mediaRecord) { if (!mediaRecord) {
return c.json({ error: 'Media not found' }, 404); return c.json({ error: 'Media not found' }, 404);
@@ -102,11 +100,9 @@ mediaRouter.get('/:id', async (c) => {
mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => { mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const mediaRecord = await (db as any) const mediaRecord = await dbGet<any>(
.select() (db as any).select().from(media).where(eq((media as any).id, id))
.from(media) );
.where(eq((media as any).id, id))
.get();
if (!mediaRecord) { if (!mediaRecord) {
return c.json({ error: 'Media not found' }, 404); return c.json({ error: 'Media not found' }, 404);
@@ -142,7 +138,7 @@ mediaRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
query = query.where(eq((media as any).relatedId, relatedId)); query = query.where(eq((media as any).relatedId, relatedId));
} }
const result = await query.all(); const result = await dbAll(query);
return c.json({ media: result }); return c.json({ media: result });
}); });

View File

@@ -1,10 +1,10 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, paymentOptions, eventPaymentOverrides, events } from '../db/index.js'; import { db, dbGet, paymentOptions, eventPaymentOverrides, events } from '../db/index.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js'; import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js';
const paymentOptionsRouter = new Hono(); const paymentOptionsRouter = new Hono();
@@ -52,10 +52,9 @@ const updateEventOverridesSchema = z.object({
// Get global payment options // Get global payment options
paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => { paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
const options = await (db as any) const options = await dbGet<any>(
.select() (db as any).select().from(paymentOptions)
.from(paymentOptions) );
.get();
// If no options exist yet, return defaults // If no options exist yet, return defaults
if (!options) { if (!options) {
@@ -92,17 +91,21 @@ paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updateP
const now = getNow(); const now = getNow();
// Check if options exist // Check if options exist
const existing = await (db as any) const existing = await dbGet<any>(
.select() (db as any)
.from(paymentOptions) .select()
.get(); .from(paymentOptions)
);
// Convert boolean fields for database compatibility
const dbData = convertBooleansForDb(data);
if (existing) { if (existing) {
// Update existing // Update existing
await (db as any) await (db as any)
.update(paymentOptions) .update(paymentOptions)
.set({ .set({
...data, ...dbData,
updatedAt: now, updatedAt: now,
updatedBy: user.id, updatedBy: user.id,
}) })
@@ -112,16 +115,17 @@ paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updateP
const id = generateId(); const id = generateId();
await (db as any).insert(paymentOptions).values({ await (db as any).insert(paymentOptions).values({
id, id,
...data, ...dbData,
updatedAt: now, updatedAt: now,
updatedBy: user.id, updatedBy: user.id,
}); });
} }
const updated = await (db as any) const updated = await dbGet(
.select() (db as any)
.from(paymentOptions) .select()
.get(); .from(paymentOptions)
);
return c.json({ paymentOptions: updated, message: 'Payment options updated successfully' }); return c.json({ paymentOptions: updated, message: 'Payment options updated successfully' });
}); });
@@ -131,28 +135,31 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
const eventId = c.req.param('eventId'); const eventId = c.req.param('eventId');
// Get the event first to verify it exists // Get the event first to verify it exists
const event = await (db as any) const event = await dbGet(
.select() (db as any)
.from(events) .select()
.where(eq((events as any).id, eventId)) .from(events)
.get(); .where(eq((events as any).id, eventId))
);
if (!event) { if (!event) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
// Get global options // Get global options
const globalOptions = await (db as any) const globalOptions = await dbGet<any>(
.select() (db as any)
.from(paymentOptions) .select()
.get(); .from(paymentOptions)
);
// Get event overrides // Get event overrides
const overrides = await (db as any) const overrides = await dbGet<any>(
.select() (db as any)
.from(eventPaymentOverrides) .select()
.where(eq((eventPaymentOverrides as any).eventId, eventId)) .from(eventPaymentOverrides)
.get(); .where(eq((eventPaymentOverrides as any).eventId, eventId))
);
// Merge global with overrides (override takes precedence if not null) // Merge global with overrides (override takes precedence if not null)
const defaults = { const defaults = {
@@ -206,11 +213,9 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => { paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.param('eventId'); const eventId = c.req.param('eventId');
const overrides = await (db as any) const overrides = await dbGet<any>(
.select() (db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, eventId))
.from(eventPaymentOverrides) );
.where(eq((eventPaymentOverrides as any).eventId, eventId))
.get();
return c.json({ overrides: overrides || null }); return c.json({ overrides: overrides || null });
}); });
@@ -222,28 +227,27 @@ paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'org
const now = getNow(); const now = getNow();
// Verify event exists // Verify event exists
const event = await (db as any) const event = await dbGet<any>(
.select() (db as any).select().from(events).where(eq((events as any).id, eventId))
.from(events) );
.where(eq((events as any).id, eventId))
.get();
if (!event) { if (!event) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
// Check if overrides exist // Check if overrides exist
const existing = await (db as any) const existing = await dbGet<any>(
.select() (db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, eventId))
.from(eventPaymentOverrides) );
.where(eq((eventPaymentOverrides as any).eventId, eventId))
.get();
// Convert boolean fields for database compatibility
const dbData = convertBooleansForDb(data);
if (existing) { if (existing) {
await (db as any) await (db as any)
.update(eventPaymentOverrides) .update(eventPaymentOverrides)
.set({ .set({
...data, ...dbData,
updatedAt: now, updatedAt: now,
}) })
.where(eq((eventPaymentOverrides as any).id, existing.id)); .where(eq((eventPaymentOverrides as any).id, existing.id));
@@ -252,17 +256,18 @@ paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'org
await (db as any).insert(eventPaymentOverrides).values({ await (db as any).insert(eventPaymentOverrides).values({
id, id,
eventId, eventId,
...data, ...dbData,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
} }
const updated = await (db as any) const updated = await dbGet(
.select() (db as any)
.from(eventPaymentOverrides) .select()
.where(eq((eventPaymentOverrides as any).eventId, eventId)) .from(eventPaymentOverrides)
.get(); .where(eq((eventPaymentOverrides as any).eventId, eventId))
);
return c.json({ overrides: updated, message: 'Event payment overrides updated successfully' }); return c.json({ overrides: updated, message: 'Event payment overrides updated successfully' });
}); });

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, payments, tickets, events } from '../db/index.js'; import { db, dbGet, dbAll, payments, tickets, events } from '../db/index.js';
import { eq, desc, and, or, sql } from 'drizzle-orm'; import { eq, desc, and, or, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js'; import { getNow } from '../lib/utils.js';
@@ -30,11 +30,12 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
const pendingApproval = c.req.query('pendingApproval'); const pendingApproval = c.req.query('pendingApproval');
// Get all payments with their associated tickets // Get all payments with their associated tickets
let allPayments = await (db as any) let allPayments = await dbAll<any>(
.select() (db as any)
.from(payments) .select()
.orderBy(desc((payments as any).createdAt)) .from(payments)
.all(); .orderBy(desc((payments as any).createdAt))
);
// Filter by status // Filter by status
if (status) { if (status) {
@@ -54,19 +55,21 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
// Enrich with ticket and event data // Enrich with ticket and event data
const enrichedPayments = await Promise.all( const enrichedPayments = await Promise.all(
allPayments.map(async (payment: any) => { allPayments.map(async (payment: any) => {
const ticket = await (db as any) const ticket = await dbGet<any>(
.select() (db as any)
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
.get();
let event = null;
if (ticket) {
event = await (db as any)
.select() .select()
.from(events) .from(tickets)
.where(eq((events as any).id, ticket.eventId)) .where(eq((tickets as any).id, payment.ticketId))
.get(); );
let event: any = null;
if (ticket) {
event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
} }
return { return {
@@ -93,29 +96,32 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
// Get payments pending approval (admin dashboard view) // Get payments pending approval (admin dashboard view)
paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), async (c) => { paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), async (c) => {
const pendingPayments = await (db as any) const pendingPayments = await dbAll<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).status, 'pending_approval')) .from(payments)
.orderBy(desc((payments as any).userMarkedPaidAt)) .where(eq((payments as any).status, 'pending_approval'))
.all(); .orderBy(desc((payments as any).userMarkedPaidAt))
);
// Enrich with ticket and event data // Enrich with ticket and event data
const enrichedPayments = await Promise.all( const enrichedPayments = await Promise.all(
pendingPayments.map(async (payment: any) => { pendingPayments.map(async (payment: any) => {
const ticket = await (db as any) const ticket = await dbGet<any>(
.select() (db as any)
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
.get();
let event = null;
if (ticket) {
event = await (db as any)
.select() .select()
.from(events) .from(tickets)
.where(eq((events as any).id, ticket.eventId)) .where(eq((tickets as any).id, payment.ticketId))
.get(); );
let event: any = null;
if (ticket) {
event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
} }
return { return {
@@ -144,22 +150,24 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy
paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => { paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).id, id)) .from(payments)
.get(); .where(eq((payments as any).id, id))
);
if (!payment) { if (!payment) {
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
} }
// Get associated ticket // Get associated ticket
const ticket = await (db as any) const ticket = await dbGet(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).id, payment.ticketId)) .from(tickets)
.get(); .where(eq((tickets as any).id, payment.ticketId))
);
return c.json({ payment: { ...payment, ticket } }); return c.json({ payment: { ...payment, ticket } });
}); });
@@ -170,11 +178,12 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
const data = c.req.valid('json'); const data = c.req.valid('json');
const user = (c as any).get('user'); const user = (c as any).get('user');
const existing = await (db as any) const existing = await dbGet<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).id, id)) .from(payments)
.get(); .where(eq((payments as any).id, id))
);
if (!existing) { if (!existing) {
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
@@ -211,11 +220,12 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
}); });
} }
const updated = await (db as any) const updated = await dbGet(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).id, id)) .from(payments)
.get(); .where(eq((payments as any).id, id))
);
return c.json({ payment: updated }); return c.json({ payment: updated });
}); });
@@ -226,11 +236,12 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
const { adminNote } = c.req.valid('json'); const { adminNote } = c.req.valid('json');
const user = (c as any).get('user'); const user = (c as any).get('user');
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).id, id)) .from(payments)
.get(); .where(eq((payments as any).id, id))
);
if (!payment) { if (!payment) {
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
@@ -269,11 +280,12 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
console.error('[Email] Failed to send confirmation emails:', err); console.error('[Email] Failed to send confirmation emails:', err);
}); });
const updated = await (db as any) const updated = await dbGet(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).id, id)) .from(payments)
.get(); .where(eq((payments as any).id, id))
);
return c.json({ payment: updated, message: 'Payment approved successfully' }); return c.json({ payment: updated, message: 'Payment approved successfully' });
}); });
@@ -284,11 +296,12 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
const { adminNote } = c.req.valid('json'); const { adminNote } = c.req.valid('json');
const user = (c as any).get('user'); const user = (c as any).get('user');
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).id, id)) .from(payments)
.get(); .where(eq((payments as any).id, id))
);
if (!payment) { if (!payment) {
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
@@ -327,11 +340,12 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
}); });
} }
const updated = await (db as any) const updated = await dbGet(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).id, id)) .from(payments)
.get(); .where(eq((payments as any).id, id))
);
return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' }); return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
}); });
@@ -342,11 +356,12 @@ paymentsRouter.post('/:id/note', requireAuth(['admin', 'organizer']), async (c)
const body = await c.req.json(); const body = await c.req.json();
const { adminNote } = body; const { adminNote } = body;
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).id, id)) .from(payments)
.get(); .where(eq((payments as any).id, id))
);
if (!payment) { if (!payment) {
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
@@ -362,11 +377,12 @@ paymentsRouter.post('/:id/note', requireAuth(['admin', 'organizer']), async (c)
}) })
.where(eq((payments as any).id, id)); .where(eq((payments as any).id, id));
const updated = await (db as any) const updated = await dbGet(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).id, id)) .from(payments)
.get(); .where(eq((payments as any).id, id))
);
return c.json({ payment: updated, message: 'Note updated' }); return c.json({ payment: updated, message: 'Note updated' });
}); });
@@ -375,11 +391,12 @@ paymentsRouter.post('/:id/note', requireAuth(['admin', 'organizer']), async (c)
paymentsRouter.post('/:id/refund', requireAuth(['admin']), async (c) => { paymentsRouter.post('/:id/refund', requireAuth(['admin']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).id, id)) .from(payments)
.get(); .where(eq((payments as any).id, id))
);
if (!payment) { if (!payment) {
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
@@ -426,7 +443,7 @@ paymentsRouter.post('/webhook', async (c) => {
// Get payment statistics (admin) // Get payment statistics (admin)
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => { paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const allPayments = await (db as any).select().from(payments).all(); const allPayments = await dbAll<any>((db as any).select().from(payments));
const stats = { const stats = {
total: allPayments.length, total: allPayments.length,

View File

@@ -1,10 +1,10 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, siteSettings } from '../db/index.js'; import { db, dbGet, siteSettings } from '../db/index.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js'; import { generateId, getNow, toDbBool } from '../lib/utils.js';
interface UserContext { interface UserContext {
id: string; id: string;
@@ -34,7 +34,9 @@ const updateSiteSettingsSchema = z.object({
// Get site settings (public - needed for frontend timezone) // Get site settings (public - needed for frontend timezone)
siteSettingsRouter.get('/', async (c) => { siteSettingsRouter.get('/', async (c) => {
const settings = await (db as any).select().from(siteSettings).limit(1).get(); const settings = await dbGet(
(db as any).select().from(siteSettings).limit(1)
);
if (!settings) { if (!settings) {
// Return default settings if none exist // Return default settings if none exist
@@ -95,7 +97,9 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
const now = getNow(); const now = getNow();
// Check if settings exist // Check if settings exist
const existing = await (db as any).select().from(siteSettings).limit(1).get(); const existing = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
if (!existing) { if (!existing) {
// Create new settings record // Create new settings record
@@ -112,7 +116,7 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
instagramUrl: data.instagramUrl || null, instagramUrl: data.instagramUrl || null,
twitterUrl: data.twitterUrl || null, twitterUrl: data.twitterUrl || null,
linkedinUrl: data.linkedinUrl || null, linkedinUrl: data.linkedinUrl || null,
maintenanceMode: data.maintenanceMode || false, maintenanceMode: toDbBool(data.maintenanceMode || false),
maintenanceMessage: data.maintenanceMessage || null, maintenanceMessage: data.maintenanceMessage || null,
maintenanceMessageEs: data.maintenanceMessageEs || null, maintenanceMessageEs: data.maintenanceMessageEs || null,
updatedAt: now, updatedAt: now,
@@ -125,18 +129,24 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
} }
// Update existing settings // Update existing settings
const updateData = { const updateData: Record<string, any> = {
...data, ...data,
updatedAt: now, updatedAt: now,
updatedBy: user.id, 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) await (db as any)
.update(siteSettings) .update(siteSettings)
.set(updateData) .set(updateData)
.where(eq((siteSettings as any).id, existing.id)); .where(eq((siteSettings as any).id, existing.id));
const updated = await (db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id)).get(); 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' }); return c.json({ settings: updated, message: 'Settings updated successfully' });
}); });

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, tickets, events, users, payments, paymentOptions } from '../db/index.js'; import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions } from '../db/index.js';
import { eq, and, sql } from 'drizzle-orm'; import { eq, and, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js'; import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow } from '../lib/utils.js'; import { generateId, generateTicketCode, getNow } from '../lib/utils.js';
@@ -47,7 +47,9 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
const data = c.req.valid('json'); const data = c.req.valid('json');
// Get event // Get event
const event = await (db as any).select().from(events).where(eq((events as any).id, data.eventId)).get(); const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, data.eventId))
);
if (!event) { if (!event) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
@@ -57,23 +59,26 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
} }
// Check capacity // Check capacity
const ticketCount = await (db as any) const ticketCount = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(tickets) .select({ count: sql<number>`count(*)` })
.where( .from(tickets)
and( .where(
eq((tickets as any).eventId, data.eventId), and(
eq((tickets as any).status, 'confirmed') eq((tickets as any).eventId, data.eventId),
eq((tickets as any).status, 'confirmed')
)
) )
) );
.get();
if ((ticketCount?.count || 0) >= event.capacity) { if ((ticketCount?.count || 0) >= event.capacity) {
return c.json({ error: 'Event is sold out' }, 400); return c.json({ error: 'Event is sold out' }, 400);
} }
// Find or create user // Find or create user
let user = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get(); let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
const now = getNow(); const now = getNow();
@@ -98,24 +103,26 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
} }
// Check for duplicate booking (unless allowDuplicateBookings is enabled) // Check for duplicate booking (unless allowDuplicateBookings is enabled)
const globalOptions = await (db as any) const globalOptions = await dbGet<any>(
.select() (db as any)
.from(paymentOptions) .select()
.get(); .from(paymentOptions)
);
const allowDuplicateBookings = globalOptions?.allowDuplicateBookings ?? false; const allowDuplicateBookings = globalOptions?.allowDuplicateBookings ?? false;
if (!allowDuplicateBookings) { if (!allowDuplicateBookings) {
const existingTicket = await (db as any) const existingTicket = await dbGet<any>(
.select() (db as any)
.from(tickets) .select()
.where( .from(tickets)
and( .where(
eq((tickets as any).userId, user.id), and(
eq((tickets as any).eventId, data.eventId) eq((tickets as any).userId, user.id),
eq((tickets as any).eventId, data.eventId)
)
) )
) );
.get();
if (existingTicket && existingTicket.status !== 'cancelled') { if (existingTicket && existingTicket.status !== 'cancelled') {
return c.json({ error: 'You have already booked this event' }, 400); return c.json({ error: 'You have already booked this event' }, 400);
@@ -251,9 +258,11 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
// Download ticket as PDF // Download ticket as PDF
ticketsRouter.get('/:id/pdf', async (c) => { ticketsRouter.get('/:id/pdf', async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const user = await getAuthUser(c); const user: any = await getAuthUser(c);
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
);
if (!ticket) { if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404); return c.json({ error: 'Ticket not found' }, 404);
@@ -278,7 +287,9 @@ ticketsRouter.get('/:id/pdf', async (c) => {
} }
// Get event // Get event
const event = await (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)).get(); const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
);
if (!event) { if (!event) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
@@ -316,17 +327,23 @@ ticketsRouter.get('/:id/pdf', async (c) => {
ticketsRouter.get('/:id', async (c) => { ticketsRouter.get('/:id', async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
);
if (!ticket) { if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404); return c.json({ error: 'Ticket not found' }, 404);
} }
// Get associated event // Get associated event
const event = await (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)).get(); const event = await dbGet(
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
);
// Get payment // Get payment
const payment = await (db as any).select().from(payments).where(eq((payments as any).ticketId, id)).get(); const payment = await dbGet(
(db as any).select().from(payments).where(eq((payments as any).ticketId, id))
);
return c.json({ return c.json({
ticket: { ticket: {
@@ -342,7 +359,9 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
const id = c.req.param('id'); const id = c.req.param('id');
const data = c.req.valid('json'); const data = c.req.valid('json');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
);
if (!ticket) { if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404); return c.json({ error: 'Ticket not found' }, 404);
@@ -361,7 +380,9 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
await (db as any).update(tickets).set(updates).where(eq((tickets as any).id, id)); await (db as any).update(tickets).set(updates).where(eq((tickets as any).id, id));
} }
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); const updated = await dbGet(
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
);
return c.json({ ticket: updated }); return c.json({ ticket: updated });
}); });
@@ -376,19 +397,21 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as
} }
// Try to find ticket by QR code or ID // Try to find ticket by QR code or ID
let ticket = await (db as any) let ticket = await dbGet<any>(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).qrCode, code)) .from(tickets)
.get(); .where(eq((tickets as any).qrCode, code))
);
// If not found by QR, try by ID // If not found by QR, try by ID
if (!ticket) { if (!ticket) {
ticket = await (db as any) ticket = await dbGet<any>(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).id, code)) .from(tickets)
.get(); .where(eq((tickets as any).id, code))
);
} }
if (!ticket) { if (!ticket) {
@@ -409,11 +432,12 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as
} }
// Get event details // Get event details
const event = await (db as any) const event = await dbGet<any>(
.select() (db as any)
.from(events) .select()
.where(eq((events as any).id, ticket.eventId)) .from(events)
.get(); .where(eq((events as any).id, ticket.eventId))
);
// Determine validity status // Determine validity status
let validityStatus = 'invalid'; let validityStatus = 'invalid';
@@ -433,11 +457,12 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as
// Get admin who checked in (if applicable) // Get admin who checked in (if applicable)
let checkedInBy = null; let checkedInBy = null;
if (ticket.checkedInByAdminId) { if (ticket.checkedInByAdminId) {
const admin = await (db as any) const admin = await dbGet<any>(
.select() (db as any)
.from(users) .select()
.where(eq((users as any).id, ticket.checkedInByAdminId)) .from(users)
.get(); .where(eq((users as any).id, ticket.checkedInByAdminId))
);
checkedInBy = admin ? admin.name : null; checkedInBy = admin ? admin.name : null;
} }
@@ -469,7 +494,9 @@ ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']),
const id = c.req.param('id'); const id = c.req.param('id');
const adminUser = (c as any).get('user'); const adminUser = (c as any).get('user');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
);
if (!ticket) { if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404); return c.json({ error: 'Ticket not found' }, 404);
@@ -494,10 +521,14 @@ ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']),
}) })
.where(eq((tickets as any).id, id)); .where(eq((tickets as any).id, id));
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); const updated = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
);
// Get event for response // Get event for response
const event = await (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)).get(); const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
);
return c.json({ return c.json({
ticket: { ticket: {
@@ -517,7 +548,9 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
const id = c.req.param('id'); const id = c.req.param('id');
const user = (c as any).get('user'); const user = (c as any).get('user');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
);
if (!ticket) { if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404); return c.json({ error: 'Ticket not found' }, 404);
@@ -551,11 +584,12 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
.where(eq((payments as any).ticketId, id)); .where(eq((payments as any).ticketId, id));
// Get payment for sending receipt // Get payment for sending receipt
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).ticketId, id)) .from(payments)
.get(); .where(eq((payments as any).ticketId, id))
);
// Send confirmation emails asynchronously (don't block the response) // Send confirmation emails asynchronously (don't block the response)
Promise.all([ Promise.all([
@@ -565,7 +599,9 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
console.error('[Email] Failed to send confirmation emails:', err); console.error('[Email] Failed to send confirmation emails:', err);
}); });
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); const updated = await dbGet(
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
);
return c.json({ ticket: updated, message: 'Payment marked as received' }); return c.json({ ticket: updated, message: 'Payment marked as received' });
}); });
@@ -575,18 +611,21 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
ticketsRouter.post('/:id/mark-payment-sent', async (c) => { ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
);
if (!ticket) { if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404); return c.json({ error: 'Ticket not found' }, 404);
} }
// Get the payment // Get the payment
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).ticketId, id)) .from(payments)
.get(); .where(eq((payments as any).ticketId, id))
);
if (!payment) { if (!payment) {
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
@@ -632,11 +671,12 @@ ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
.where(eq((payments as any).id, payment.id)); .where(eq((payments as any).id, payment.id));
// Get updated payment // Get updated payment
const updatedPayment = await (db as any) const updatedPayment = await dbGet(
.select() (db as any)
.from(payments) .select()
.where(eq((payments as any).id, payment.id)) .from(payments)
.get(); .where(eq((payments as any).id, payment.id))
);
// TODO: Send notification to admin about pending payment approval // TODO: Send notification to admin about pending payment approval
@@ -649,9 +689,11 @@ ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
// Cancel ticket // Cancel ticket
ticketsRouter.post('/:id/cancel', async (c) => { ticketsRouter.post('/:id/cancel', async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const user = await getAuthUser(c); const user: any = await getAuthUser(c);
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
);
if (!ticket) { if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404); return c.json({ error: 'Ticket not found' }, 404);
@@ -675,7 +717,9 @@ ticketsRouter.post('/:id/cancel', async (c) => {
ticketsRouter.post('/:id/remove-checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => { ticketsRouter.post('/:id/remove-checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
);
if (!ticket) { if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404); return c.json({ error: 'Ticket not found' }, 404);
@@ -690,7 +734,9 @@ ticketsRouter.post('/:id/remove-checkin', requireAuth(['admin', 'organizer', 'st
.set({ status: 'confirmed', checkinAt: null }) .set({ status: 'confirmed', checkinAt: null })
.where(eq((tickets as any).id, id)); .where(eq((tickets as any).id, id));
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); const updated = await dbGet(
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
);
return c.json({ ticket: updated, message: 'Check-in removed successfully' }); return c.json({ ticket: updated, message: 'Check-in removed successfully' });
}); });
@@ -700,7 +746,9 @@ ticketsRouter.post('/:id/note', requireAuth(['admin', 'organizer', 'staff']), zV
const id = c.req.param('id'); const id = c.req.param('id');
const { note } = c.req.valid('json'); const { note } = c.req.valid('json');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
);
if (!ticket) { if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404); return c.json({ error: 'Ticket not found' }, 404);
@@ -711,7 +759,9 @@ ticketsRouter.post('/:id/note', requireAuth(['admin', 'organizer', 'staff']), zV
.set({ adminNote: note || null }) .set({ adminNote: note || null })
.where(eq((tickets as any).id, id)); .where(eq((tickets as any).id, id));
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); const updated = await dbGet(
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
);
return c.json({ ticket: updated, message: 'Note updated successfully' }); return c.json({ ticket: updated, message: 'Note updated successfully' });
}); });
@@ -721,22 +771,25 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
const data = c.req.valid('json'); const data = c.req.valid('json');
// Get event // Get event
const event = await (db as any).select().from(events).where(eq((events as any).id, data.eventId)).get(); const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, data.eventId))
);
if (!event) { if (!event) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
// Check capacity // Check capacity
const ticketCount = await (db as any) const ticketCount = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(tickets) .select({ count: sql<number>`count(*)` })
.where( .from(tickets)
and( .where(
eq((tickets as any).eventId, data.eventId), and(
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` eq((tickets as any).eventId, data.eventId),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
) )
) );
.get();
if ((ticketCount?.count || 0) >= event.capacity) { if ((ticketCount?.count || 0) >= event.capacity) {
return c.json({ error: 'Event is at capacity' }, 400); return c.json({ error: 'Event is at capacity' }, 400);
@@ -750,7 +803,9 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
: `door-${generateId()}@doorentry.local`; : `door-${generateId()}@doorentry.local`;
// Find or create user // Find or create user
let user = await (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)).get(); let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
);
const adminFullName = data.lastName && data.lastName.trim() const adminFullName = data.lastName && data.lastName.trim()
? `${data.firstName} ${data.lastName}`.trim() ? `${data.firstName} ${data.lastName}`.trim()
@@ -774,16 +829,17 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
// Check for existing active ticket for this user and event (only if real email provided) // Check for existing active ticket for this user and event (only if real email provided)
if (data.email && data.email.trim() && !data.email.includes('@doorentry.local')) { if (data.email && data.email.trim() && !data.email.includes('@doorentry.local')) {
const existingTicket = await (db as any) const existingTicket = await dbGet<any>(
.select() (db as any)
.from(tickets) .select()
.where( .from(tickets)
and( .where(
eq((tickets as any).userId, user.id), and(
eq((tickets as any).eventId, data.eventId) eq((tickets as any).userId, user.id),
eq((tickets as any).eventId, data.eventId)
)
) )
) );
.get();
if (existingTicket && existingTicket.status !== 'cancelled') { if (existingTicket && existingTicket.status !== 'cancelled') {
return c.json({ error: 'This person already has a ticket for this event' }, 400); return c.json({ error: 'This person already has a ticket for this event' }, 400);
@@ -869,7 +925,7 @@ ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
query = query.where(and(...conditions)); query = query.where(and(...conditions));
} }
const result = await query.all(); const result = await dbAll(query);
return c.json({ tickets: result }); return c.json({ tickets: result });
}); });

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, users, tickets, events, payments } from '../db/index.js'; import { db, dbGet, dbAll, users, tickets, events, payments } from '../db/index.js';
import { eq, desc, sql } from 'drizzle-orm'; import { eq, desc, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js'; import { getNow } from '../lib/utils.js';
@@ -40,7 +40,7 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
query = query.where(eq((users as any).role, role)); query = query.where(eq((users as any).role, role));
} }
const result = await query.orderBy(desc((users as any).createdAt)).all(); const result = await dbAll(query.orderBy(desc((users as any).createdAt)));
return c.json({ users: result }); return c.json({ users: result });
}); });
@@ -55,19 +55,20 @@ usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
return c.json({ error: 'Forbidden' }, 403); return c.json({ error: 'Forbidden' }, 403);
} }
const user = await (db as any) const user = await dbGet(
.select({ (db as any)
id: (users as any).id, .select({
email: (users as any).email, id: (users as any).id,
name: (users as any).name, email: (users as any).email,
phone: (users as any).phone, name: (users as any).name,
role: (users as any).role, phone: (users as any).phone,
languagePreference: (users as any).languagePreference, role: (users as any).role,
createdAt: (users as any).createdAt, languagePreference: (users as any).languagePreference,
}) createdAt: (users as any).createdAt,
.from(users) })
.where(eq((users as any).id, id)) .from(users)
.get(); .where(eq((users as any).id, id))
);
if (!user) { if (!user) {
return c.json({ error: 'User not found' }, 404); return c.json({ error: 'User not found' }, 404);
@@ -92,7 +93,9 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
delete data.role; delete data.role;
} }
const existing = await (db as any).select().from(users).where(eq((users as any).id, id)).get(); const existing = await dbGet(
(db as any).select().from(users).where(eq((users as any).id, id))
);
if (!existing) { if (!existing) {
return c.json({ error: 'User not found' }, 404); return c.json({ error: 'User not found' }, 404);
} }
@@ -102,18 +105,19 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
.set({ ...data, updatedAt: getNow() }) .set({ ...data, updatedAt: getNow() })
.where(eq((users as any).id, id)); .where(eq((users as any).id, id));
const updated = await (db as any) const updated = await dbGet(
.select({ (db as any)
id: (users as any).id, .select({
email: (users as any).email, id: (users as any).id,
name: (users as any).name, email: (users as any).email,
phone: (users as any).phone, name: (users as any).name,
role: (users as any).role, phone: (users as any).phone,
languagePreference: (users as any).languagePreference, role: (users as any).role,
}) languagePreference: (users as any).languagePreference,
.from(users) })
.where(eq((users as any).id, id)) .from(users)
.get(); .where(eq((users as any).id, id))
);
return c.json({ user: updated }); return c.json({ user: updated });
}); });
@@ -128,21 +132,23 @@ usersRouter.get('/:id/history', requireAuth(['admin', 'organizer', 'staff', 'mar
return c.json({ error: 'Forbidden' }, 403); return c.json({ error: 'Forbidden' }, 403);
} }
const userTickets = await (db as any) const userTickets = await dbAll<any>(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).userId, id)) .from(tickets)
.orderBy(desc((tickets as any).createdAt)) .where(eq((tickets as any).userId, id))
.all(); .orderBy(desc((tickets as any).createdAt))
);
// Get event details for each ticket // Get event details for each ticket
const history = await Promise.all( const history = await Promise.all(
userTickets.map(async (ticket: any) => { userTickets.map(async (ticket: any) => {
const event = await (db as any) const event = await dbGet(
.select() (db as any)
.from(events) .select()
.where(eq((events as any).id, ticket.eventId)) .from(events)
.get(); .where(eq((events as any).id, ticket.eventId))
);
return { return {
...ticket, ...ticket,
@@ -164,7 +170,9 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
return c.json({ error: 'Cannot delete your own account' }, 400); return c.json({ error: 'Cannot delete your own account' }, 400);
} }
const existing = await (db as any).select().from(users).where(eq((users as any).id, id)).get(); const existing = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, id))
);
if (!existing) { if (!existing) {
return c.json({ error: 'User not found' }, 404); return c.json({ error: 'User not found' }, 404);
} }
@@ -176,11 +184,12 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
try { try {
// Get all tickets for this user // Get all tickets for this user
const userTickets = await (db as any) const userTickets = await dbAll<any>(
.select() (db as any)
.from(tickets) .select()
.where(eq((tickets as any).userId, id)) .from(tickets)
.all(); .where(eq((tickets as any).userId, id))
);
// Delete payments associated with user's tickets // Delete payments associated with user's tickets
for (const ticket of userTickets) { for (const ticket of userTickets) {
@@ -202,16 +211,18 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
// Get user statistics (admin) // Get user statistics (admin)
usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => { usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const totalUsers = await (db as any) const totalUsers = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(users) .select({ count: sql<number>`count(*)` })
.get(); .from(users)
);
const adminCount = await (db as any) const adminCount = await dbGet<any>(
.select({ count: sql<number>`count(*)` }) (db as any)
.from(users) .select({ count: sql<number>`count(*)` })
.where(eq((users as any).role, 'admin')) .from(users)
.get(); .where(eq((users as any).role, 'admin'))
);
return c.json({ return c.json({
stats: { stats: {

View File

@@ -10,6 +10,10 @@
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.1.4", "@heroicons/react": "^2.1.4",
"@tiptap/extension-placeholder": "^3.18.0",
"@tiptap/pm": "^3.18.0",
"@tiptap/react": "^3.18.0",
"@tiptap/starter-kit": "^3.18.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"next": "^14.2.4", "next": "^14.2.4",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -6,6 +6,7 @@ import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api'; import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
import { formatPrice } from '@/lib/utils';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
@@ -620,7 +621,7 @@ export default function BookingPage() {
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'} {locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
</p> </p>
<p className="text-2xl font-bold text-primary-dark"> <p className="text-2xl font-bold text-primary-dark">
{event?.price?.toLocaleString()} {event?.currency} {event?.price !== undefined ? formatPrice(event.price, event.currency) : ''}
</p> </p>
</div> </div>
@@ -924,7 +925,7 @@ export default function BookingPage() {
<span className="font-bold text-lg"> <span className="font-bold text-lg">
{event.price === 0 {event.price === 0
? t('events.details.free') ? t('events.details.free')
: `${event.price.toLocaleString()} ${event.currency}`} : formatPrice(event.price, event.currency)}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { useParams, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api'; import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
import { formatPrice } from '@/lib/utils';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { import {
@@ -341,7 +342,7 @@ export default function BookingPaymentPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<CurrencyDollarIcon className="w-5 h-5 text-primary-yellow" /> <CurrencyDollarIcon className="w-5 h-5 text-primary-yellow" />
<span className="font-bold text-lg"> <span className="font-bold text-lg">
{ticket.event.price?.toLocaleString()} {ticket.event.currency} {ticket.event.price !== undefined ? formatPrice(ticket.event.price, ticket.event.currency) : ''}
</span> </span>
</div> </div>
</div> </div>
@@ -374,7 +375,7 @@ export default function BookingPaymentPage() {
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'} {locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
</p> </p>
<p className="text-2xl font-bold text-primary-dark"> <p className="text-2xl font-bold text-primary-dark">
{ticket.event?.price?.toLocaleString()} {ticket.event?.currency} {ticket.event?.price !== undefined ? formatPrice(ticket.event.price, ticket.event.currency) : ''}
</p> </p>
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api'; import { eventsApi, Event } from '@/lib/api';
import { formatPrice } from '@/lib/utils';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline'; import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline';
@@ -91,7 +92,7 @@ export default function NextEventSection() {
<span className="text-3xl font-bold text-primary-dark"> <span className="text-3xl font-bold text-primary-dark">
{nextEvent.price === 0 {nextEvent.price === 0
? t('events.details.free') ? t('events.details.free')
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`} : formatPrice(nextEvent.price, nextEvent.currency)}
</span> </span>
{!nextEvent.externalBookingEnabled && ( {!nextEvent.externalBookingEnabled && (
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">

View File

@@ -5,6 +5,7 @@ import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api'; import { eventsApi, Event } from '@/lib/api';
import { formatPrice } from '@/lib/utils';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import ShareButtons from '@/components/ShareButtons'; import ShareButtons from '@/components/ShareButtons';
@@ -186,7 +187,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
<p className="text-4xl font-bold text-primary-dark"> <p className="text-4xl font-bold text-primary-dark">
{event.price === 0 {event.price === 0
? t('events.details.free') ? t('events.details.free')
: `${event.price.toLocaleString()} ${event.currency}`} : formatPrice(event.price, event.currency)}
</p> </p>
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api'; import { eventsApi, Event } from '@/lib/api';
import { formatPrice } from '@/lib/utils';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline'; import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
@@ -149,7 +150,7 @@ export default function EventsPage() {
<span className="font-bold text-xl text-primary-dark"> <span className="font-bold text-xl text-primary-dark">
{event.price === 0 {event.price === 0
? t('events.details.free') ? t('events.details.free')
: `${event.price.toLocaleString()} ${event.currency}`} : formatPrice(event.price, event.currency)}
</span> </span>
<Button size="sm"> <Button size="sm">
{t('common.moreInfo')} {t('common.moreInfo')}

View File

@@ -1,10 +1,11 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getLegalPage, getAllLegalSlugs } from '@/lib/legal'; import { getLegalPageAsync, getAllLegalSlugs } from '@/lib/legal';
import LegalPageLayout from '@/components/layout/LegalPageLayout'; import LegalPageLayout from '@/components/layout/LegalPageLayout';
interface PageProps { interface PageProps {
params: { slug: string }; params: Promise<{ slug: string }>;
searchParams: Promise<{ locale?: string }>;
} }
// Generate static params for all legal pages // Generate static params for all legal pages
@@ -13,11 +14,24 @@ export async function generateStaticParams() {
return slugs.map((slug) => ({ slug })); return slugs.map((slug) => ({ slug }));
} }
// Enable dynamic rendering to always fetch fresh content from DB
export const dynamic = 'force-dynamic';
export const revalidate = 60; // Revalidate every 60 seconds
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py'; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
// Validate and normalize locale
function getValidLocale(locale?: string): 'en' | 'es' {
if (locale === 'es') return 'es';
return 'en'; // Default to English
}
// Generate metadata for SEO // Generate metadata for SEO
export async function generateMetadata({ params }: PageProps): Promise<Metadata> { export async function generateMetadata({ params, searchParams }: PageProps): Promise<Metadata> {
const legalPage = getLegalPage(params.slug); const resolvedParams = await params;
const resolvedSearchParams = await searchParams;
const locale = getValidLocale(resolvedSearchParams.locale);
const legalPage = await getLegalPageAsync(resolvedParams.slug, locale);
if (!legalPage) { if (!legalPage) {
return { return {
@@ -33,13 +47,20 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
follow: true, follow: true,
}, },
alternates: { alternates: {
canonical: `${siteUrl}/legal/${params.slug}`, canonical: `${siteUrl}/legal/${resolvedParams.slug}`,
languages: {
'en': `${siteUrl}/legal/${resolvedParams.slug}`,
'es': `${siteUrl}/legal/${resolvedParams.slug}?locale=es`,
},
}, },
}; };
} }
export default function LegalPage({ params }: PageProps) { export default async function LegalPage({ params, searchParams }: PageProps) {
const legalPage = getLegalPage(params.slug); const resolvedParams = await params;
const resolvedSearchParams = await searchParams;
const locale = getValidLocale(resolvedSearchParams.locale);
const legalPage = await getLegalPageAsync(resolvedParams.slug, locale);
if (!legalPage) { if (!legalPage) {
notFound(); notFound();

View File

@@ -23,6 +23,7 @@ import {
XMarkIcon, XMarkIcon,
BanknotesIcon, BanknotesIcon,
QrCodeIcon, QrCodeIcon,
DocumentTextIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { useState } from 'react'; import { useState } from 'react';
@@ -67,6 +68,7 @@ export default function AdminLayout({
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon }, { name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon },
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon }, { name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon },
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon }, { 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: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon }, { name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon },
]; ];

View File

@@ -0,0 +1,556 @@
'use client';
import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { useLanguage } from '@/context/LanguageContext';
import { legalPagesApi, LegalPage } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import {
DocumentTextIcon,
PencilSquareIcon,
ArrowPathIcon,
XMarkIcon,
CheckIcon,
ArrowLeftIcon,
CheckCircleIcon,
ExclamationCircleIcon,
EyeIcon,
PencilIcon,
} from '@heroicons/react/24/outline';
// Dynamically import rich text editor to avoid SSR issues
const RichTextEditor = dynamic(
() => import('@/components/ui/RichTextEditor'),
{
ssr: false,
loading: () => (
<div className="border border-secondary-light-gray rounded-btn bg-gray-50 h-[400px] flex items-center justify-center">
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full" />
</div>
),
}
);
const RichTextPreview = dynamic(
() => import('@/components/ui/RichTextEditor').then(mod => ({ default: mod.RichTextPreview })),
{
ssr: false,
loading: () => (
<div className="border border-secondary-light-gray rounded-btn bg-gray-50 h-[400px] flex items-center justify-center">
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full" />
</div>
),
}
);
type EditLanguage = 'en' | 'es';
type ViewMode = 'edit' | 'preview' | 'split';
export default function AdminLegalPagesPage() {
const { locale } = useLanguage();
const [pages, setPages] = useState<LegalPage[]>([]);
const [loading, setLoading] = useState(true);
const [seeding, setSeeding] = useState(false);
// Editor state
const [editingPage, setEditingPage] = useState<LegalPage | null>(null);
const [editLanguage, setEditLanguage] = useState<EditLanguage>('en');
const [editContentEn, setEditContentEn] = useState('');
const [editContentEs, setEditContentEs] = useState('');
const [editTitleEn, setEditTitleEn] = useState('');
const [editTitleEs, setEditTitleEs] = useState('');
const [saving, setSaving] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('edit');
useEffect(() => {
loadPages();
}, []);
const loadPages = async () => {
try {
setLoading(true);
const response = await legalPagesApi.getAdminList();
setPages(response.pages);
} catch (error) {
console.error('Failed to load legal pages:', error);
toast.error(locale === 'es' ? 'Error al cargar páginas legales' : 'Failed to load legal pages');
} finally {
setLoading(false);
}
};
const handleSeed = async () => {
try {
setSeeding(true);
const response = await legalPagesApi.seed();
toast.success(response.message);
if (response.seeded > 0) {
await loadPages();
}
} catch (error: any) {
console.error('Failed to seed legal pages:', error);
toast.error(error.message || (locale === 'es' ? 'Error al importar páginas' : 'Failed to import pages'));
} finally {
setSeeding(false);
}
};
const handleEdit = (page: LegalPage) => {
setEditingPage(page);
// Load from contentMarkdown to preserve formatting (fallback to contentText)
setEditContentEn(page.contentMarkdown || page.contentText || '');
setEditContentEs(page.contentMarkdownEs || page.contentTextEs || '');
setEditTitleEn(page.title || '');
setEditTitleEs(page.titleEs || '');
// Default to English tab, or Spanish if only Spanish exists
setEditLanguage(page.hasEnglish || !page.hasSpanish ? 'en' : 'es');
};
const handleCancelEdit = () => {
setEditingPage(null);
setEditContentEn('');
setEditContentEs('');
setEditTitleEn('');
setEditTitleEs('');
setEditLanguage('en');
};
const handleSave = async () => {
if (!editingPage) return;
// Validate - at least one language must have content
if (!editContentEn.trim() && !editContentEs.trim()) {
toast.error(locale === 'es'
? 'Al menos una versión de idioma debe tener contenido'
: 'At least one language version must have content'
);
return;
}
try {
setSaving(true);
const response = await legalPagesApi.update(editingPage.slug, {
contentMarkdown: editContentEn.trim() || undefined,
contentMarkdownEs: editContentEs.trim() || undefined,
title: editTitleEn.trim() || undefined,
titleEs: editTitleEs.trim() || undefined,
});
toast.success(response.message || (locale === 'es' ? 'Página actualizada' : 'Page updated successfully'));
// Update local state
setPages(prev => prev.map(p =>
p.slug === editingPage.slug ? response.page : p
));
handleCancelEdit();
} catch (error: any) {
console.error('Failed to save legal page:', error);
toast.error(error.message || (locale === 'es' ? 'Error al guardar' : 'Failed to save'));
} finally {
setSaving(false);
}
};
const formatDate = (dateStr: string) => {
try {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return dateStr;
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
// Editor view
if (editingPage) {
const currentContent = editLanguage === 'en' ? editContentEn : editContentEs;
const setCurrentContent = editLanguage === 'en' ? setEditContentEn : setEditContentEs;
const currentTitle = editLanguage === 'en' ? editTitleEn : editTitleEs;
const setCurrentTitle = editLanguage === 'en' ? setEditTitleEn : setEditTitleEs;
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<button
onClick={handleCancelEdit}
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
>
<ArrowLeftIcon className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold font-heading">
{locale === 'es' ? 'Editar Página Legal' : 'Edit Legal Page'}
</h1>
<p className="text-gray-500 text-sm mt-1">
{editingPage.slug}
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleCancelEdit}
disabled={saving}
>
<XMarkIcon className="w-4 h-4 mr-1" />
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</Button>
<Button
onClick={handleSave}
isLoading={saving}
>
<CheckIcon className="w-4 h-4 mr-1" />
{locale === 'es' ? 'Guardar Todo' : 'Save All'}
</Button>
</div>
</div>
<Card>
<div className="p-6 space-y-6">
{/* Language tabs and View mode toggle */}
<div className="flex justify-between items-center border-b border-gray-200 pb-3">
{/* Language tabs */}
<div className="flex gap-4">
<button
onClick={() => setEditLanguage('en')}
className={clsx(
'pb-3 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 -mb-3',
editLanguage === 'en'
? 'border-primary-yellow text-primary-dark'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
)}
>
English
{editContentEn.trim() ? (
<CheckCircleIcon className="w-4 h-4 text-green-500" />
) : (
<ExclamationCircleIcon className="w-4 h-4 text-amber-500" />
)}
</button>
<button
onClick={() => setEditLanguage('es')}
className={clsx(
'pb-3 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 -mb-3',
editLanguage === 'es'
? 'border-primary-yellow text-primary-dark'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
)}
>
Español (Paraguay)
{editContentEs.trim() ? (
<CheckCircleIcon className="w-4 h-4 text-green-500" />
) : (
<ExclamationCircleIcon className="w-4 h-4 text-amber-500" />
)}
</button>
</div>
{/* View mode toggle */}
<div className="flex bg-gray-100 rounded-lg p-1">
<button
onClick={() => setViewMode('edit')}
className={clsx(
'px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1.5',
viewMode === 'edit'
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-600 hover:text-gray-900'
)}
>
<PencilIcon className="w-4 h-4" />
{locale === 'es' ? 'Editar' : 'Edit'}
</button>
<button
onClick={() => setViewMode('split')}
className={clsx(
'px-3 py-1.5 text-sm rounded-md transition-colors hidden lg:flex items-center gap-1.5',
viewMode === 'split'
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-600 hover:text-gray-900'
)}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
</svg>
Split
</button>
<button
onClick={() => setViewMode('preview')}
className={clsx(
'px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1.5',
viewMode === 'preview'
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-600 hover:text-gray-900'
)}
>
<EyeIcon className="w-4 h-4" />
{locale === 'es' ? 'Vista previa' : 'Preview'}
</button>
</div>
</div>
{/* Title for current language */}
<div>
<label className="block text-sm font-medium mb-2">
{editLanguage === 'en'
? (locale === 'es' ? 'Título (Inglés)' : 'Title (English)')
: (locale === 'es' ? 'Título (Español)' : 'Title (Spanish)')
}
</label>
<Input
value={currentTitle}
onChange={(e) => setCurrentTitle(e.target.value)}
placeholder={editLanguage === 'en' ? 'Title' : 'Título'}
/>
</div>
{/* Content editor and preview */}
<div>
<label className="block text-sm font-medium mb-2">
{editLanguage === 'en'
? (locale === 'es' ? 'Contenido (Inglés)' : 'Content (English)')
: (locale === 'es' ? 'Contenido (Español)' : 'Content (Spanish)')
}
</label>
{viewMode === 'edit' && (
<>
<p className="text-xs text-gray-500 mb-2">
{locale === 'es'
? 'Usa la barra de herramientas para dar formato. Los cambios se guardan como texto plano.'
: 'Use the toolbar to format text. Changes are saved as plain text.'
}
</p>
<RichTextEditor
content={currentContent}
onChange={setCurrentContent}
placeholder={editLanguage === 'en'
? 'Write content here...'
: 'Escribe el contenido aquí...'
}
/>
</>
)}
{viewMode === 'preview' && (
<>
<p className="text-xs text-gray-500 mb-2">
{locale === 'es'
? 'Así se verá el contenido en la página pública.'
: 'This is how the content will look on the public page.'
}
</p>
<div className="border border-secondary-light-gray rounded-btn bg-white">
<div className="bg-gray-50 px-4 py-2 border-b border-gray-200 text-sm text-gray-500">
{locale === 'es' ? 'Vista previa' : 'Preview'}
</div>
<RichTextPreview content={currentContent} />
</div>
</>
)}
{viewMode === 'split' && (
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500 mb-2">
{locale === 'es' ? 'Editor' : 'Editor'}
</p>
<RichTextEditor
content={currentContent}
onChange={setCurrentContent}
placeholder={editLanguage === 'en'
? 'Write content here...'
: 'Escribe el contenido aquí...'
}
/>
</div>
<div>
<p className="text-xs text-gray-500 mb-2">
{locale === 'es' ? 'Vista previa' : 'Preview'}
</p>
<div className="border border-secondary-light-gray rounded-btn bg-white h-full">
<RichTextPreview content={currentContent} className="h-full" />
</div>
</div>
</div>
)}
</div>
{/* Info */}
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-600">
<p className="font-medium mb-2">
{locale === 'es' ? 'Nota:' : 'Note:'}
</p>
<ul className="list-disc list-inside space-y-1">
<li>
{locale === 'es'
? 'El slug (URL) no se puede cambiar: '
: 'The slug (URL) cannot be changed: '
}
<code className="bg-gray-200 px-1 rounded">/legal/{editingPage.slug}</code>
</li>
<li>
{locale === 'es'
? 'Usa la barra de herramientas para encabezados, listas, negritas y cursivas.'
: 'Use the toolbar for headings, lists, bold, and italics.'
}
</li>
<li>
{locale === 'es'
? 'Si falta una versión de idioma, se mostrará la otra versión disponible.'
: 'If a language version is missing, the other available version will be shown.'
}
</li>
</ul>
</div>
</div>
</Card>
</div>
);
}
// List view
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold font-heading">
{locale === 'es' ? 'Páginas Legales' : 'Legal Pages'}
</h1>
<p className="text-gray-500 text-sm mt-1">
{locale === 'es'
? 'Administra el contenido de las páginas legales del sitio.'
: 'Manage the content of the site\'s legal pages.'
}
</p>
</div>
{pages.length === 0 && (
<Button
onClick={handleSeed}
isLoading={seeding}
variant="outline"
>
<ArrowPathIcon className="w-4 h-4 mr-2" />
{locale === 'es' ? 'Importar desde archivos' : 'Import from files'}
</Button>
)}
</div>
{pages.length === 0 ? (
<Card>
<div className="p-12 text-center">
<DocumentTextIcon className="w-12 h-12 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-medium mb-2">
{locale === 'es' ? 'No hay páginas legales' : 'No legal pages found'}
</h3>
<p className="text-gray-500 mb-4">
{locale === 'es'
? 'Haz clic en "Importar desde archivos" para cargar las páginas legales existentes.'
: 'Click "Import from files" to load existing legal pages.'
}
</p>
<Button onClick={handleSeed} isLoading={seeding}>
<ArrowPathIcon className="w-4 h-4 mr-2" />
{locale === 'es' ? 'Importar Páginas' : 'Import Pages'}
</Button>
</div>
</Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{locale === 'es' ? 'Página' : 'Page'}
</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
Slug
</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{locale === 'es' ? 'Idiomas' : 'Languages'}
</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{locale === 'es' ? 'Última actualización' : 'Last Updated'}
</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">
{locale === 'es' ? 'Acciones' : 'Actions'}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{pages.map((page) => (
<tr key={page.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div>
<p className="font-medium">{page.title}</p>
{page.titleEs && (
<p className="text-sm text-gray-500">{page.titleEs}</p>
)}
</div>
</td>
<td className="px-6 py-4">
<code className="bg-gray-100 px-2 py-1 rounded text-sm">
{page.slug}
</code>
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<span className={clsx(
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
page.hasEnglish
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-500'
)}>
EN {page.hasEnglish ? '✓' : '—'}
</span>
<span className={clsx(
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
page.hasSpanish
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-500'
)}>
ES {page.hasSpanish ? '✓' : '—'}
</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatDate(page.updatedAt)}
</td>
<td className="px-6 py-4 text-right">
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(page)}
>
<PencilSquareIcon className="w-4 h-4 mr-1" />
{locale === 'es' ? 'Editar' : 'Edit'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
);
}

View File

@@ -99,3 +99,57 @@
text-wrap: balance; text-wrap: balance;
} }
} }
/* TipTap Rich Text Editor Styles */
.ProseMirror {
outline: none;
}
.ProseMirror p.is-editor-empty:first-child::before {
@apply text-gray-400 pointer-events-none float-left h-0;
content: attr(data-placeholder);
}
.ProseMirror > * + * {
margin-top: 0.75em;
}
.ProseMirror h1 {
@apply text-2xl font-bold mt-6 mb-3;
}
.ProseMirror h2 {
@apply text-xl font-bold mt-5 mb-2;
}
.ProseMirror h3 {
@apply text-lg font-semibold mt-4 mb-2;
}
.ProseMirror ul {
@apply list-disc list-inside my-3;
}
.ProseMirror ol {
@apply list-decimal list-inside my-3;
}
.ProseMirror li {
@apply my-1;
}
.ProseMirror blockquote {
@apply border-l-4 border-gray-300 pl-4 my-4 italic text-gray-600;
}
.ProseMirror hr {
@apply border-t border-gray-300 my-6;
}
.ProseMirror strong {
@apply font-bold;
}
.ProseMirror em {
@apply italic;
}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api'; import { eventsApi, Event } from '@/lib/api';
import { formatPrice } from '@/lib/utils';
import { import {
CalendarIcon, CalendarIcon,
MapPinIcon, MapPinIcon,
@@ -100,7 +101,7 @@ export default function LinktreePage() {
<span className="font-bold text-primary-yellow"> <span className="font-bold text-primary-yellow">
{nextEvent.price === 0 {nextEvent.price === 0
? t('events.details.free') ? t('events.details.free')
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`} : formatPrice(nextEvent.price, nextEvent.currency)}
</span> </span>
{!nextEvent.externalBookingEnabled && ( {!nextEvent.externalBookingEnabled && (
<span className="text-sm text-gray-400"> <span className="text-sm text-gray-400">

View File

@@ -0,0 +1,417 @@
'use client';
import { useEditor, EditorContent, Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import { useEffect, useRef } from 'react';
import clsx from 'clsx';
interface RichTextEditorProps {
content: string; // Markdown content
onChange: (content: string) => void; // Returns markdown
placeholder?: string;
className?: string;
editable?: boolean;
}
// Convert markdown to HTML for TipTap
function markdownToHtml(markdown: string): string {
if (!markdown) return '<p></p>';
let html = markdown;
// Convert horizontal rules first (before other processing)
html = html.replace(/^---+$/gm, '<hr>');
// Convert headings (must be done before other inline formatting)
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Convert bold and italic
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
// Convert unordered lists
const lines = html.split('\n');
let inList = false;
let listType = '';
const processedLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const bulletMatch = line.match(/^[\*\-\+]\s+(.+)$/);
const numberedMatch = line.match(/^\d+\.\s+(.+)$/);
if (bulletMatch) {
if (!inList || listType !== 'ul') {
if (inList) processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
processedLines.push('<ul>');
inList = true;
listType = 'ul';
}
processedLines.push(`<li>${bulletMatch[1]}</li>`);
} else if (numberedMatch) {
if (!inList || listType !== 'ol') {
if (inList) processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
processedLines.push('<ol>');
inList = true;
listType = 'ol';
}
processedLines.push(`<li>${numberedMatch[1]}</li>`);
} else {
if (inList) {
processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
inList = false;
listType = '';
}
processedLines.push(line);
}
}
if (inList) {
processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
}
html = processedLines.join('\n');
// Convert blockquotes
html = html.replace(/^>\s*(.+)$/gm, '<blockquote><p>$1</p></blockquote>');
// Convert paragraphs (lines that aren't already HTML tags)
const finalLines = html.split('\n');
const result: string[] = [];
let paragraph: string[] = [];
for (const line of finalLines) {
const trimmed = line.trim();
if (!trimmed) {
// Empty line - close paragraph if open
if (paragraph.length > 0) {
result.push(`<p>${paragraph.join('<br>')}</p>`);
paragraph = [];
}
} else if (trimmed.startsWith('<h') || trimmed.startsWith('<ul') || trimmed.startsWith('<ol') ||
trimmed.startsWith('<li') || trimmed.startsWith('</ul') || trimmed.startsWith('</ol') ||
trimmed.startsWith('<hr') || trimmed.startsWith('<blockquote')) {
// HTML tag - close paragraph and add tag
if (paragraph.length > 0) {
result.push(`<p>${paragraph.join('<br>')}</p>`);
paragraph = [];
}
result.push(trimmed);
} else {
// Regular text - add to paragraph
paragraph.push(trimmed);
}
}
if (paragraph.length > 0) {
result.push(`<p>${paragraph.join('<br>')}</p>`);
}
return result.join('') || '<p></p>';
}
// Convert HTML from TipTap back to markdown
function htmlToMarkdown(html: string): string {
if (!html) return '';
let md = html;
// Convert headings
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
// Convert bold and italic
md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
// Convert lists
md = md.replace(/<ul[^>]*>/gi, '\n');
md = md.replace(/<\/ul>/gi, '\n');
md = md.replace(/<ol[^>]*>/gi, '\n');
md = md.replace(/<\/ol>/gi, '\n');
md = md.replace(/<li[^>]*>(.*?)<\/li>/gi, '* $1\n');
// Convert blockquotes
md = md.replace(/<blockquote[^>]*><p[^>]*>(.*?)<\/p><\/blockquote>/gi, '> $1\n\n');
md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gi, '> $1\n\n');
// Convert horizontal rules
md = md.replace(/<hr[^>]*\/?>/gi, '\n---\n\n');
// Convert paragraphs
md = md.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
// Convert line breaks
md = md.replace(/<br[^>]*\/?>/gi, '\n');
// Remove any remaining HTML tags
md = md.replace(/<[^>]+>/g, '');
// Decode HTML entities
md = md.replace(/&amp;/g, '&');
md = md.replace(/&lt;/g, '<');
md = md.replace(/&gt;/g, '>');
md = md.replace(/&quot;/g, '"');
md = md.replace(/&#39;/g, "'");
md = md.replace(/&nbsp;/g, ' ');
// Clean up extra newlines
md = md.replace(/\n{3,}/g, '\n\n');
return md.trim();
}
// Toolbar button component
function ToolbarButton({
onClick,
isActive = false,
disabled = false,
children,
title,
}: {
onClick: () => void;
isActive?: boolean;
disabled?: boolean;
children: React.ReactNode;
title?: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={clsx(
'p-2 rounded transition-colors',
isActive
? 'bg-primary-yellow text-primary-dark'
: 'text-gray-600 hover:bg-gray-100',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
{children}
</button>
);
}
// Toolbar component
function Toolbar({ editor }: { editor: Editor | null }) {
if (!editor) return null;
return (
<div className="flex flex-wrap gap-1 p-2 border-b border-gray-200 bg-gray-50">
{/* Text formatting */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Bold (Ctrl+B)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 12h9a4 4 0 014 4 4 4 0 01-4 4H6z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Italic (Ctrl+I)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 4h4m-2 0v16m-4 0h8" transform="skewX(-10)" />
</svg>
</ToolbarButton>
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
{/* Headings */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive('heading', { level: 1 })}
title="Heading 1"
>
<span className="text-sm font-bold">H1</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<span className="text-sm font-bold">H2</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive('heading', { level: 3 })}
title="Heading 3"
>
<span className="text-sm font-bold">H3</span>
</ToolbarButton>
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
{/* Lists */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Bullet List"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
<circle cx="2" cy="6" r="1" fill="currentColor" />
<circle cx="2" cy="12" r="1" fill="currentColor" />
<circle cx="2" cy="18" r="1" fill="currentColor" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Numbered List"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 6h13M7 12h13M7 18h13" />
<text x="1" y="8" fontSize="6" fill="currentColor">1</text>
<text x="1" y="14" fontSize="6" fill="currentColor">2</text>
<text x="1" y="20" fontSize="6" fill="currentColor">3</text>
</svg>
</ToolbarButton>
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
{/* Block elements */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title="Quote"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal Rule"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
</svg>
</ToolbarButton>
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
{/* Undo/Redo */}
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo (Ctrl+Z)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo (Ctrl+Shift+Z)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6" />
</svg>
</ToolbarButton>
</div>
);
}
export default function RichTextEditor({
content,
onChange,
placeholder = 'Start writing...',
className = '',
editable = true,
}: RichTextEditorProps) {
const lastContentRef = useRef(content);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Placeholder.configure({
placeholder,
}),
],
content: markdownToHtml(content),
editable,
onUpdate: ({ editor }) => {
// Convert HTML back to markdown
const markdown = htmlToMarkdown(editor.getHTML());
lastContentRef.current = markdown;
onChange(markdown);
},
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none focus:outline-none min-h-[400px] p-4',
},
},
});
// Update content when prop changes (e.g., switching languages)
useEffect(() => {
if (editor && content !== lastContentRef.current) {
lastContentRef.current = content;
const html = markdownToHtml(content);
editor.commands.setContent(html);
}
}, [content, editor]);
return (
<div className={clsx('border border-secondary-light-gray rounded-btn overflow-hidden bg-white', className)}>
{editable && <Toolbar editor={editor} />}
<EditorContent editor={editor} />
</div>
);
}
// Read-only preview component
export function RichTextPreview({
content,
className = '',
}: {
content: string; // Markdown content
className?: string;
}) {
const editor = useEditor({
extensions: [StarterKit],
content: markdownToHtml(content),
editable: false,
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none p-4',
},
},
});
useEffect(() => {
if (editor) {
editor.commands.setContent(markdownToHtml(content));
}
}, [content, editor]);
return (
<div className={clsx('border border-secondary-light-gray rounded-btn bg-gray-50', className)}>
<EditorContent editor={editor} />
</div>
);
}

View File

@@ -967,3 +967,74 @@ export const siteSettingsApi = {
getTimezones: () => getTimezones: () =>
fetchApi<{ timezones: TimezoneOption[] }>('/api/site-settings/timezones'), fetchApi<{ timezones: TimezoneOption[] }>('/api/site-settings/timezones'),
}; };
// ==================== Legal Pages Types ====================
export interface LegalPage {
id: string;
slug: string;
title: string;
titleEs?: string | null;
contentText: string;
contentTextEs?: string | null;
contentMarkdown: string;
contentMarkdownEs?: string | null;
updatedAt: string;
updatedBy?: string | null;
createdAt: string;
source?: 'database' | 'filesystem';
hasEnglish?: boolean;
hasSpanish?: boolean;
}
export interface LegalPagePublic {
id?: string;
slug: string;
title: string;
contentMarkdown: string;
updatedAt?: string;
source?: 'database' | 'filesystem';
}
export interface LegalPageListItem {
id: string;
slug: string;
title: string;
updatedAt: string;
hasEnglish?: boolean;
hasSpanish?: boolean;
}
// ==================== Legal Pages API ====================
export const legalPagesApi = {
// Public endpoints
getAll: (locale?: string) =>
fetchApi<{ pages: LegalPageListItem[] }>(`/api/legal-pages${locale ? `?locale=${locale}` : ''}`),
getBySlug: (slug: string, locale?: string) =>
fetchApi<{ page: LegalPagePublic }>(`/api/legal-pages/${slug}${locale ? `?locale=${locale}` : ''}`),
// Admin endpoints
getAdminList: () =>
fetchApi<{ pages: LegalPage[] }>('/api/legal-pages/admin/list'),
getAdminPage: (slug: string) =>
fetchApi<{ page: LegalPage }>(`/api/legal-pages/admin/${slug}`),
update: (slug: string, data: {
contentMarkdown?: string;
contentMarkdownEs?: string;
title?: string;
titleEs?: string;
}) =>
fetchApi<{ page: LegalPage; message: string }>(`/api/legal-pages/admin/${slug}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
seed: () =>
fetchApi<{ message: string; seeded: number; pages?: string[] }>('/api/legal-pages/admin/seed', {
method: 'POST',
}),
};

View File

@@ -16,8 +16,11 @@ export interface LegalPageMeta {
// Map file names to display titles // Map file names to display titles
const titleMap: Record<string, { en: string; es: string }> = { const titleMap: Record<string, { en: string; es: string }> = {
'privacy_policy': { en: 'Privacy Policy', es: 'Política de Privacidad' }, 'privacy_policy': { en: 'Privacy Policy', es: 'Política de Privacidad' },
'privacy-policy': { en: 'Privacy Policy', es: 'Política de Privacidad' },
'terms_policy': { en: 'Terms & Conditions', es: 'Términos y Condiciones' }, 'terms_policy': { en: 'Terms & Conditions', es: 'Términos y Condiciones' },
'terms-policy': { en: 'Terms & Conditions', es: 'Términos y Condiciones' },
'refund_cancelation_policy': { en: 'Refund & Cancellation Policy', es: 'Política de Reembolso y Cancelación' }, 'refund_cancelation_policy': { en: 'Refund & Cancellation Policy', es: 'Política de Reembolso y Cancelación' },
'refund-cancelation-policy': { en: 'Refund & Cancellation Policy', es: 'Política de Reembolso y Cancelación' },
}; };
// Convert file name to URL-friendly slug // Convert file name to URL-friendly slug
@@ -70,8 +73,8 @@ export function getAllLegalPagesMeta(locale: string = 'en'): LegalPageMeta[] {
}); });
} }
// Get a specific legal page content // Get a specific legal page content from filesystem (fallback)
export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | null { export function getLegalPageFromFilesystem(slug: string, locale: string = 'en'): LegalPage | null {
const legalDir = getLegalDir(); const legalDir = getLegalDir();
const fileName = slugToFileName(slug); const fileName = slugToFileName(slug);
const filePath = path.join(legalDir, fileName); const filePath = path.join(legalDir, fileName);
@@ -82,7 +85,7 @@ export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | n
const content = fs.readFileSync(filePath, 'utf-8'); const content = fs.readFileSync(filePath, 'utf-8');
const baseFileName = fileName.replace('.md', ''); const baseFileName = fileName.replace('.md', '');
const titles = titleMap[baseFileName]; const titles = titleMap[baseFileName] || titleMap[slug];
const title = titles ? titles[locale as 'en' | 'es'] || titles.en : baseFileName.replace(/_/g, ' '); const title = titles ? titles[locale as 'en' | 'es'] || titles.en : baseFileName.replace(/_/g, ' ');
// Try to extract last updated date from content // Try to extract last updated date from content
@@ -96,3 +99,43 @@ export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | n
lastUpdated, lastUpdated,
}; };
} }
// Get a specific legal page content - tries API first, falls back to filesystem
export async function getLegalPageAsync(slug: string, locale: string = 'en'): Promise<LegalPage | null> {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
// Try to fetch from API with locale parameter
try {
const response = await fetch(`${apiUrl}/api/legal-pages/${slug}?locale=${locale}`, {
next: { revalidate: 60 }, // Cache for 60 seconds
});
if (response.ok) {
const data = await response.json();
const page = data.page;
if (page) {
// Extract last updated from content or use updatedAt
const lastUpdatedMatch = page.contentMarkdown?.match(/Last updated:\s*(.+)/i);
const lastUpdated = lastUpdatedMatch ? lastUpdatedMatch[1].trim() : page.updatedAt;
return {
slug: page.slug,
title: page.title, // API already returns localized title with fallback
content: page.contentMarkdown, // API already returns localized content with fallback
lastUpdated,
};
}
}
} catch (error) {
console.warn('Failed to fetch legal page from API, falling back to filesystem:', error);
}
// Fallback to filesystem
return getLegalPageFromFilesystem(slug, locale);
}
// Legacy sync function for backwards compatibility
export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | null {
return getLegalPageFromFilesystem(slug, locale);
}

36
frontend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,36 @@
/**
* Format price - shows decimals only if needed
* Uses space as thousands separator (common in Paraguay)
* Examples:
* 45000 PYG -> "45 000 PYG" (no decimals)
* 41.44 PYG -> "41,44 PYG" (with decimals)
*/
export function formatPrice(price: number, currency: string = 'PYG'): string {
const hasDecimals = price % 1 !== 0;
// Format the integer and decimal parts separately
const intPart = Math.floor(Math.abs(price));
const decPart = Math.abs(price) - intPart;
// Format integer part with space as thousands separator
const intFormatted = intPart.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
// Build final string
let result = price < 0 ? '-' : '';
result += intFormatted;
// Add decimals only if present
if (hasDecimals) {
const decStr = decPart.toFixed(2).substring(2); // Get just the decimal digits
result += ',' + decStr;
}
return `${result} ${currency}`;
}
/**
* Format currency amount (alias for formatPrice for backward compatibility)
*/
export function formatCurrency(amount: number, currency: string = 'PYG'): string {
return formatPrice(amount, currency);
}