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
270
backend/drizzle/0000_steady_wendell_vaughn.sql
Normal 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`);
|
||||||
1836
backend/drizzle/meta/0000_snapshot.json
Normal file
13
backend/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1769975033554,
|
||||||
|
"tag": "0000_steady_wendell_vaughn",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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!');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,7 +72,8 @@ 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(magicLinkTokens)
|
.from(magicLinkTokens)
|
||||||
.where(
|
.where(
|
||||||
@@ -81,7 +82,7 @@ export async function verifyMagicLinkToken(
|
|||||||
eq((magicLinkTokens as any).type, type)
|
eq((magicLinkTokens as any).type, type)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.get();
|
);
|
||||||
|
|
||||||
if (!tokenRecord) {
|
if (!tokenRecord) {
|
||||||
return { valid: false, error: 'Invalid token' };
|
return { valid: false, error: 'Invalid token' };
|
||||||
@@ -132,7 +133,8 @@ 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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(userSessions)
|
.from(userSessions)
|
||||||
.where(
|
.where(
|
||||||
@@ -141,7 +143,7 @@ export async function getUserSessions(userId: string) {
|
|||||||
gt((userSessions as any).expiresAt, now)
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(emailTemplates)
|
.from(emailTemplates)
|
||||||
.where(eq((emailTemplates as any).slug, slug))
|
.where(eq((emailTemplates as any).slug, slug))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).id, ticketId))
|
.where(eq((tickets as any).id, ticketId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).id, paymentId))
|
.where(eq((payments as any).id, paymentId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).id, payment.ticketId))
|
.where(eq((tickets as any).id, payment.ticketId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(paymentOptions)
|
.from(paymentOptions)
|
||||||
.get();
|
);
|
||||||
|
|
||||||
// Get event overrides
|
// Get event overrides
|
||||||
const overrides = await (db as any)
|
const overrides = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(eventPaymentOverrides)
|
.from(eventPaymentOverrides)
|
||||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
// 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).id, ticketId))
|
.where(eq((tickets as any).id, ticketId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).ticketId, ticketId))
|
.where(eq((payments as any).ticketId, ticketId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).id, paymentId))
|
.where(eq((payments as any).id, paymentId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).id, payment.ticketId))
|
.where(eq((tickets as any).id, payment.ticketId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, eventId))
|
.where(eq((events as any).id, eventId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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({
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,7 +11,8 @@ 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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(
|
.where(
|
||||||
@@ -22,63 +23,72 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
)
|
)
|
||||||
.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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.orderBy(desc((tickets as any).createdAt))
|
.orderBy(desc((tickets as any).createdAt))
|
||||||
.limit(10)
|
.limit(10)
|
||||||
.all();
|
);
|
||||||
|
|
||||||
// Get total stats
|
// Get total stats
|
||||||
const totalUsers = await (db as any)
|
const totalUsers = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(users)
|
.from(users)
|
||||||
.get();
|
);
|
||||||
|
|
||||||
const totalEvents = await (db as any)
|
const totalEvents = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(events)
|
.from(events)
|
||||||
.get();
|
);
|
||||||
|
|
||||||
const totalTickets = await (db as any)
|
const totalTickets = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.get();
|
);
|
||||||
|
|
||||||
const confirmedTickets = await (db as any)
|
const confirmedTickets = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).status, 'confirmed'))
|
.where(eq((tickets as any).status, 'confirmed'))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
const pendingPayments = await (db as any)
|
const pendingPayments = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).status, 'pending'))
|
.where(eq((payments as any).status, 'pending'))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
const paidPayments = await (db as any)
|
const paidPayments = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).status, 'paid'))
|
.where(eq((payments as any).status, 'paid'))
|
||||||
.all();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(contacts)
|
.from(contacts)
|
||||||
.where(eq((contacts as any).status, 'new'))
|
.where(eq((contacts as any).status, 'new'))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
const totalSubscribers = await (db as any)
|
const totalSubscribers = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(emailSubscribers)
|
.from(emailSubscribers)
|
||||||
.where(eq((emailSubscribers as any).status, 'active'))
|
.where(eq((emailSubscribers as any).status, 'active'))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
dashboard: {
|
dashboard: {
|
||||||
@@ -101,17 +111,19 @@ 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>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).eventId, event.id))
|
.where(eq((tickets as any).eventId, event.id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
const confirmedCount = await (db as any)
|
const confirmedCount = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(
|
.where(
|
||||||
@@ -120,9 +132,10 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
|||||||
eq((tickets as any).status, 'confirmed')
|
eq((tickets as any).status, 'confirmed')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.get();
|
);
|
||||||
|
|
||||||
const checkedInCount = await (db as any)
|
const checkedInCount = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(
|
.where(
|
||||||
@@ -131,7 +144,7 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
|||||||
eq((tickets as any).status, 'checked_in')
|
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq((users as any).id, ticket.userId))
|
.where(eq((users as any).id, ticket.userId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
const event = await (db as any)
|
const event = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
const payment = await (db as any)
|
const payment = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).ticketId, ticket.id))
|
.where(eq((payments as any).ticketId, ticket.id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).id, payment.ticketId))
|
.where(eq((tickets as any).id, payment.ticketId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
if (!ticket) return null;
|
if (!ticket) return null;
|
||||||
|
|
||||||
const event = await (db as any)
|
const event = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters
|
||||||
if (eventId && ticket.eventId !== eventId) return null;
|
if (eventId && ticket.eventId !== eventId) return null;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(emailSubscribers)
|
.from(emailSubscribers)
|
||||||
.where(eq((emailSubscribers as any).email, data.email))
|
.where(eq((emailSubscribers as any).email, data.email))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(contacts)
|
.from(contacts)
|
||||||
.where(eq((contacts as any).id, id))
|
.where(eq((contacts as any).id, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq((users as any).id, user.id))
|
.where(eq((users as any).id, user.id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).userId, user.id))
|
.where(eq((tickets as any).userId, user.id))
|
||||||
.orderBy(desc((tickets as any).createdAt))
|
.orderBy(desc((tickets as any).createdAt))
|
||||||
.all();
|
);
|
||||||
|
|
||||||
// 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
const payment = await (db as any)
|
const payment = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).ticketId, ticket.id))
|
.where(eq((payments as any).ticketId, ticket.id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
// 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(eq((invoices as any).paymentId, payment.id))
|
.where(eq((invoices as any).paymentId, payment.id))
|
||||||
.get();
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -168,7 +173,8 @@ 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(
|
.where(
|
||||||
@@ -177,31 +183,34 @@ dashboard.get('/tickets/:id', async (c) => {
|
|||||||
eq((tickets as any).userId, user.id)
|
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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
const payment = await (db as any)
|
const payment = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).ticketId, ticket.id))
|
.where(eq((payments as any).ticketId, ticket.id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
let invoice = null;
|
let invoice = null;
|
||||||
if (payment && payment.status === 'paid') {
|
if (payment && payment.status === 'paid') {
|
||||||
invoice = await (db as any)
|
invoice = await dbGet(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(eq((invoices as any).paymentId, payment.id))
|
.where(eq((invoices as any).paymentId, payment.id))
|
||||||
.get();
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).userId, user.id))
|
.where(eq((tickets as any).userId, user.id))
|
||||||
.all();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).ticketId, ticket.id))
|
.where(eq((payments as any).ticketId, ticket.id))
|
||||||
.get();
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).userId, user.id))
|
.where(eq((tickets as any).userId, user.id))
|
||||||
.all();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).ticketId, ticketId))
|
.where(eq((payments as any).ticketId, ticketId))
|
||||||
.all();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
.get()
|
)
|
||||||
: 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(eq((invoices as any).paymentId, payment.id))
|
.where(eq((invoices as any).paymentId, payment.id))
|
||||||
.get();
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(eq((invoices as any).userId, user.id))
|
.where(eq((invoices as any).userId, user.id))
|
||||||
.orderBy(desc((invoices as any).createdAt))
|
.orderBy(desc((invoices as any).createdAt))
|
||||||
.all();
|
);
|
||||||
|
|
||||||
// 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).id, invoice.paymentId))
|
.where(eq((payments as any).id, invoice.paymentId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
let event = null;
|
let event: any = null;
|
||||||
if (payment) {
|
if (payment) {
|
||||||
const ticket = await (db as any)
|
const ticket = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).id, payment.ticketId))
|
.where(eq((tickets as any).id, payment.ticketId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
if (ticket) {
|
if (ticket) {
|
||||||
event = await (db as any)
|
event = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
.get();
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).userId, user.id))
|
.where(eq((tickets as any).userId, user.id))
|
||||||
.all();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
if (event && new Date(event.startDatetime) > now) {
|
if (event && new Date(event.startDatetime) > now) {
|
||||||
upcomingTickets.push({ ticket, event });
|
upcomingTickets.push({ ticket, event });
|
||||||
@@ -540,7 +562,8 @@ 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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(
|
.where(
|
||||||
@@ -549,7 +572,7 @@ dashboard.get('/summary', async (c) => {
|
|||||||
eq((payments as any).status, 'pending_approval')
|
eq((payments as any).status, 'pending_approval')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.get();
|
);
|
||||||
|
|
||||||
if (payment) pendingPayments++;
|
if (payment) pendingPayments++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(emailTemplates)
|
.from(emailTemplates)
|
||||||
.where(eq((emailTemplates as any).id, id))
|
.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);
|
||||||
@@ -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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(emailTemplates)
|
.from(emailTemplates)
|
||||||
.where(eq((emailTemplates as any).id, id))
|
.where(eq((emailTemplates as any).id, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(emailTemplates)
|
.from(emailTemplates)
|
||||||
.where(eq((emailTemplates as any).id, id))
|
.where(eq((emailTemplates as any).id, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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(
|
||||||
|
query
|
||||||
.orderBy(desc((emailLogs as any).createdAt))
|
.orderBy(desc((emailLogs as any).createdAt))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.all();
|
);
|
||||||
|
|
||||||
// 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: {
|
||||||
|
|||||||
@@ -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,12 +131,13 @@ 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>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(
|
.where(
|
||||||
@@ -108,12 +146,13 @@ eventsRouter.get('/', async (c) => {
|
|||||||
eq((tickets as any).status, 'confirmed')
|
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,14 +164,17 @@ 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>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(
|
.where(
|
||||||
@@ -141,13 +183,14 @@ eventsRouter.get('/:id', async (c) => {
|
|||||||
eq((tickets as any).status, 'confirmed')
|
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,7 +199,8 @@ 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(
|
.where(
|
||||||
@@ -167,13 +211,14 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
|||||||
)
|
)
|
||||||
.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>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(
|
.where(
|
||||||
@@ -182,13 +227,14 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
|||||||
eq((tickets as any).status, 'confirmed')
|
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).eventId, id))
|
.where(eq((tickets as any).eventId, id))
|
||||||
.all();
|
);
|
||||||
|
|
||||||
// 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).ticketId, ticket.id))
|
.where(eq((payments as any).ticketId, ticket.id))
|
||||||
.all();
|
);
|
||||||
|
|
||||||
// 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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).eventId, id))
|
.where(eq((tickets as any).eventId, id))
|
||||||
.all();
|
);
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
393
backend/src/routes/legal-pages.ts
Normal 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;
|
||||||
@@ -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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).ticketId, ticketId))
|
.where(eq((payments as any).ticketId, ticketId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
// 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).id, ticketId))
|
.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payment = await (db as any)
|
const payment = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).ticketId, ticketId))
|
.where(eq((payments as any).ticketId, ticketId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
ticketStatus: ticket.status,
|
ticketStatus: ticket.status,
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(paymentOptions)
|
.from(paymentOptions)
|
||||||
.get();
|
);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(paymentOptions)
|
.from(paymentOptions)
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, eventId))
|
.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get global options
|
// Get global options
|
||||||
const globalOptions = await (db as any)
|
const globalOptions = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(paymentOptions)
|
.from(paymentOptions)
|
||||||
.get();
|
);
|
||||||
|
|
||||||
// Get event overrides
|
// Get event overrides
|
||||||
const overrides = await (db as any)
|
const overrides = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(eventPaymentOverrides)
|
.from(eventPaymentOverrides)
|
||||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
// 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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(eventPaymentOverrides)
|
.from(eventPaymentOverrides)
|
||||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
return c.json({ overrides: updated, message: 'Event payment overrides updated successfully' });
|
return c.json({ overrides: updated, message: 'Event payment overrides updated successfully' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.orderBy(desc((payments as any).createdAt))
|
.orderBy(desc((payments as any).createdAt))
|
||||||
.all();
|
);
|
||||||
|
|
||||||
// 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).id, payment.ticketId))
|
.where(eq((tickets as any).id, payment.ticketId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
let event = null;
|
let event: any = null;
|
||||||
if (ticket) {
|
if (ticket) {
|
||||||
event = await (db as any)
|
event = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
.get();
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).status, 'pending_approval'))
|
.where(eq((payments as any).status, 'pending_approval'))
|
||||||
.orderBy(desc((payments as any).userMarkedPaidAt))
|
.orderBy(desc((payments as any).userMarkedPaidAt))
|
||||||
.all();
|
);
|
||||||
|
|
||||||
// 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).id, payment.ticketId))
|
.where(eq((tickets as any).id, payment.ticketId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
let event = null;
|
let event: any = null;
|
||||||
if (ticket) {
|
if (ticket) {
|
||||||
event = await (db as any)
|
event = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
.get();
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).id, id))
|
.where(eq((payments as any).id, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).id, payment.ticketId))
|
.where(eq((tickets as any).id, payment.ticketId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).id, id))
|
.where(eq((payments as any).id, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).id, id))
|
.where(eq((payments as any).id, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).id, id))
|
.where(eq((payments as any).id, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).id, id))
|
.where(eq((payments as any).id, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).id, id))
|
.where(eq((payments as any).id, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).id, id))
|
.where(eq((payments as any).id, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).id, id))
|
.where(eq((payments as any).id, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).id, id))
|
.where(eq((payments as any).id, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).id, id))
|
.where(eq((payments as any).id, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,7 +59,8 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check capacity
|
// Check capacity
|
||||||
const ticketCount = await (db as any)
|
const ticketCount = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(
|
.where(
|
||||||
@@ -66,14 +69,16 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
eq((tickets as any).status, 'confirmed')
|
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,15 +103,17 @@ 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(paymentOptions)
|
.from(paymentOptions)
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(
|
.where(
|
||||||
@@ -115,7 +122,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
eq((tickets as any).eventId, data.eventId)
|
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).qrCode, code))
|
.where(eq((tickets as any).qrCode, code))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
// 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).id, code))
|
.where(eq((tickets as any).id, code))
|
||||||
.get();
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
// 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq((users as any).id, ticket.checkedInByAdminId))
|
.where(eq((users as any).id, ticket.checkedInByAdminId))
|
||||||
.get();
|
);
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).ticketId, id))
|
.where(eq((payments as any).ticketId, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
// 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).ticketId, id))
|
.where(eq((payments as any).ticketId, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).id, payment.id))
|
.where(eq((payments as any).id, payment.id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
// 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,13 +771,16 @@ 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>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(
|
.where(
|
||||||
@@ -736,7 +789,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
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,7 +829,8 @@ 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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(
|
.where(
|
||||||
@@ -783,7 +839,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
eq((tickets as any).eventId, data.eventId)
|
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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,7 +55,8 @@ 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(
|
||||||
|
(db as any)
|
||||||
.select({
|
.select({
|
||||||
id: (users as any).id,
|
id: (users as any).id,
|
||||||
email: (users as any).email,
|
email: (users as any).email,
|
||||||
@@ -67,7 +68,7 @@ usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
|||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq((users as any).id, id))
|
.where(eq((users as any).id, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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,7 +105,8 @@ 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(
|
||||||
|
(db as any)
|
||||||
.select({
|
.select({
|
||||||
id: (users as any).id,
|
id: (users as any).id,
|
||||||
email: (users as any).email,
|
email: (users as any).email,
|
||||||
@@ -113,7 +117,7 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
|||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq((users as any).id, id))
|
.where(eq((users as any).id, id))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).userId, id))
|
.where(eq((tickets as any).userId, id))
|
||||||
.orderBy(desc((tickets as any).createdAt))
|
.orderBy(desc((tickets as any).createdAt))
|
||||||
.all();
|
);
|
||||||
|
|
||||||
// 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(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
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>(
|
||||||
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).userId, id))
|
.where(eq((tickets as any).userId, id))
|
||||||
.all();
|
);
|
||||||
|
|
||||||
// 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>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(users)
|
.from(users)
|
||||||
.get();
|
);
|
||||||
|
|
||||||
const adminCount = await (db as any)
|
const adminCount = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq((users as any).role, 'admin'))
|
.where(eq((users as any).role, 'admin'))
|
||||||
.get();
|
);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
stats: {
|
stats: {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 116 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.33.56.jpg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.12.jpg
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.15.jpg
Normal file
|
After Width: | Height: | Size: 209 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.18.jpg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.21.jpg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.23.jpg
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.26.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.28.jpg
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.31.jpg
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.33.jpg
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.38.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.40.jpg
Normal file
|
After Width: | Height: | Size: 121 KiB |
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
556
frontend/src/app/admin/legal-pages/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
417
frontend/src/components/ui/RichTextEditor.tsx
Normal 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(/&/g, '&');
|
||||||
|
md = md.replace(/</g, '<');
|
||||||
|
md = md.replace(/>/g, '>');
|
||||||
|
md = md.replace(/"/g, '"');
|
||||||
|
md = md.replace(/'/g, "'");
|
||||||
|
md = md.replace(/ /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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||