diff --git a/backend/drizzle/0000_steady_wendell_vaughn.sql b/backend/drizzle/0000_steady_wendell_vaughn.sql new file mode 100644 index 0000000..6b7de57 --- /dev/null +++ b/backend/drizzle/0000_steady_wendell_vaughn.sql @@ -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`); \ No newline at end of file diff --git a/backend/drizzle/meta/0000_snapshot.json b/backend/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..5d39f01 --- /dev/null +++ b/backend/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1836 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cb538639-03b3-45bd-b4f2-851ccc69411d", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "contacts": { + "name": "contacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'new'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "email_logs": { + "name": "email_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_name": { + "name": "recipient_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_by": { + "name": "sent_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_logs_template_id_email_templates_id_fk": { + "name": "email_logs_template_id_email_templates_id_fk", + "tableFrom": "email_logs", + "tableTo": "email_templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "email_logs_event_id_events_id_fk": { + "name": "email_logs_event_id_events_id_fk", + "tableFrom": "email_logs", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "email_logs_sent_by_users_id_fk": { + "name": "email_logs_sent_by_users_id_fk", + "tableFrom": "email_logs", + "tableTo": "users", + "columnsFrom": [ + "sent_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "email_settings": { + "name": "email_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "email_settings_key_unique": { + "name": "email_settings_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "email_subscribers": { + "name": "email_subscribers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "email_subscribers_email_unique": { + "name": "email_subscribers_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "email_templates": { + "name": "email_templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_es": { + "name": "subject_es", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body_html_es": { + "name": "body_html_es", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body_text_es": { + "name": "body_text_es", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "variables": { + "name": "variables", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "email_templates_name_unique": { + "name": "email_templates_name_unique", + "columns": [ + "name" + ], + "isUnique": true + }, + "email_templates_slug_unique": { + "name": "email_templates_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "event_payment_overrides": { + "name": "event_payment_overrides", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tpago_enabled": { + "name": "tpago_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tpago_link": { + "name": "tpago_link", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tpago_instructions": { + "name": "tpago_instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tpago_instructions_es": { + "name": "tpago_instructions_es", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_transfer_enabled": { + "name": "bank_transfer_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_name": { + "name": "bank_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_holder": { + "name": "bank_account_holder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_number": { + "name": "bank_account_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_alias": { + "name": "bank_alias", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_phone": { + "name": "bank_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_notes": { + "name": "bank_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_notes_es": { + "name": "bank_notes_es", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lightning_enabled": { + "name": "lightning_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cash_enabled": { + "name": "cash_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cash_instructions": { + "name": "cash_instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cash_instructions_es": { + "name": "cash_instructions_es", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "event_payment_overrides_event_id_events_id_fk": { + "name": "event_payment_overrides_event_id_events_id_fk", + "tableFrom": "event_payment_overrides", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title_es": { + "name": "title_es", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description_es": { + "name": "description_es", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_datetime": { + "name": "start_datetime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_datetime": { + "name": "end_datetime", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_url": { + "name": "location_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price": { + "name": "price", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'PYG'" + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 50 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_booking_enabled": { + "name": "external_booking_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "external_booking_url": { + "name": "external_booking_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ruc_number": { + "name": "ruc_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legal_name": { + "name": "legal_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'PYG'" + }, + "pdf_url": { + "name": "pdf_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generated'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invoices_invoice_number_unique": { + "name": "invoices_invoice_number_unique", + "columns": [ + "invoice_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "invoices_payment_id_payments_id_fk": { + "name": "invoices_payment_id_payments_id_fk", + "tableFrom": "invoices", + "tableTo": "payments", + "columnsFrom": [ + "payment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_user_id_users_id_fk": { + "name": "invoices_user_id_users_id_fk", + "tableFrom": "invoices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "magic_link_tokens": { + "name": "magic_link_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "magic_link_tokens_token_unique": { + "name": "magic_link_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "magic_link_tokens_user_id_users_id_fk": { + "name": "magic_link_tokens_user_id_users_id_fk", + "tableFrom": "magic_link_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "related_id": { + "name": "related_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "related_type": { + "name": "related_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "payment_options": { + "name": "payment_options", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tpago_enabled": { + "name": "tpago_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "tpago_link": { + "name": "tpago_link", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tpago_instructions": { + "name": "tpago_instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tpago_instructions_es": { + "name": "tpago_instructions_es", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_transfer_enabled": { + "name": "bank_transfer_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "bank_name": { + "name": "bank_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_holder": { + "name": "bank_account_holder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_account_number": { + "name": "bank_account_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_alias": { + "name": "bank_alias", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_phone": { + "name": "bank_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_notes": { + "name": "bank_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_notes_es": { + "name": "bank_notes_es", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lightning_enabled": { + "name": "lightning_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cash_enabled": { + "name": "cash_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cash_instructions": { + "name": "cash_instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cash_instructions_es": { + "name": "cash_instructions_es", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_duplicate_bookings": { + "name": "allow_duplicate_bookings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "payment_options_updated_by_users_id_fk": { + "name": "payment_options_updated_by_users_id_fk", + "tableFrom": "payment_options", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "payments": { + "name": "payments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ticket_id": { + "name": "ticket_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'PYG'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_marked_paid_at": { + "name": "user_marked_paid_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_by_admin_id": { + "name": "paid_by_admin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_note": { + "name": "admin_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "payments_ticket_id_tickets_id_fk": { + "name": "payments_ticket_id_tickets_id_fk", + "tableFrom": "payments", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "site_settings": { + "name": "site_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'America/Asuncion'" + }, + "site_name": { + "name": "site_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Spanglish'" + }, + "site_description": { + "name": "site_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "site_description_es": { + "name": "site_description_es", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_phone": { + "name": "contact_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "facebook_url": { + "name": "facebook_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instagram_url": { + "name": "instagram_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "twitter_url": { + "name": "twitter_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linkedin_url": { + "name": "linkedin_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "maintenance_mode": { + "name": "maintenance_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "maintenance_message": { + "name": "maintenance_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "maintenance_message_es": { + "name": "maintenance_message_es", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "site_settings_updated_by_users_id_fk": { + "name": "site_settings_updated_by_users_id_fk", + "tableFrom": "site_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tickets": { + "name": "tickets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attendee_first_name": { + "name": "attendee_first_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attendee_last_name": { + "name": "attendee_last_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attendee_email": { + "name": "attendee_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attendee_phone": { + "name": "attendee_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attendee_ruc": { + "name": "attendee_ruc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferred_language": { + "name": "preferred_language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "checkin_at": { + "name": "checkin_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_in_by_admin_id": { + "name": "checked_in_by_admin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qr_code": { + "name": "qr_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_note": { + "name": "admin_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tickets_user_id_users_id_fk": { + "name": "tickets_user_id_users_id_fk", + "tableFrom": "tickets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tickets_event_id_events_id_fk": { + "name": "tickets_event_id_events_id_fk", + "tableFrom": "tickets", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tickets_checked_in_by_admin_id_users_id_fk": { + "name": "tickets_checked_in_by_admin_id_users_id_fk", + "tableFrom": "tickets", + "tableTo": "users", + "columnsFrom": [ + "checked_in_by_admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user_sessions": { + "name": "user_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_sessions_token_unique": { + "name": "user_sessions_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_sessions_user_id_users_id_fk": { + "name": "user_sessions_user_id_users_id_fk", + "tableFrom": "user_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "language_preference": { + "name": "language_preference", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_claimed": { + "name": "is_claimed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "google_id": { + "name": "google_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ruc_number": { + "name": "ruc_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_status": { + "name": "account_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json new file mode 100644 index 0000000..6549e1b --- /dev/null +++ b/backend/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1769975033554, + "tag": "0000_steady_wendell_vaughn", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 2d4e359..ca31dd6 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -1,3 +1,4 @@ +import 'dotenv/config'; import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3'; import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'; import Database from 'better-sqlite3'; @@ -29,5 +30,51 @@ if (dbType === 'postgres') { 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(query: any): Promise { + 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(query: any): Promise { + 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'; diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 0cf85c1..1775104 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -1,7 +1,10 @@ +import 'dotenv/config'; import { db } from './index.js'; import { sql } from 'drizzle-orm'; 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() { console.log('Running migrations...'); @@ -384,6 +387,23 @@ async function migrate() { 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 { // PostgreSQL migrations await (db as any).execute(sql` @@ -716,6 +736,23 @@ async function migrate() { 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!'); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 1c0851e..30762c8 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -249,6 +249,21 @@ export const sqliteEmailSettings = sqliteTable('email_settings', { 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 export const sqliteSiteSettings = sqliteTable('site_settings', { id: text('id').primaryKey(), @@ -512,6 +527,21 @@ export const pgEmailSettings = pgTable('email_settings', { 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 export const pgSiteSettings = pgTable('site_settings', { id: uuid('id').primaryKey(), @@ -556,6 +586,7 @@ export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqlit export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions; export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices; export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings; +export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages; // Type exports export type User = typeof sqliteUsers.$inferSelect; @@ -584,3 +615,5 @@ export type Invoice = typeof sqliteInvoices.$inferSelect; export type NewInvoice = typeof sqliteInvoices.$inferInsert; export type SiteSettings = typeof sqliteSiteSettings.$inferSelect; export type NewSiteSettings = typeof sqliteSiteSettings.$inferInsert; +export type LegalPage = typeof sqliteLegalPages.$inferSelect; +export type NewLegalPage = typeof sqliteLegalPages.$inferInsert; diff --git a/backend/src/index.ts b/backend/src/index.ts index 1acc953..ac85087 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -20,6 +20,7 @@ import emailsRoutes from './routes/emails.js'; import paymentOptionsRoutes from './routes/payment-options.js'; import dashboardRoutes from './routes/dashboard.js'; import siteSettingsRoutes from './routes/site-settings.js'; +import legalPagesRoutes from './routes/legal-pages.js'; import emailService from './lib/email.js'; const app = new Hono(); @@ -1714,6 +1715,7 @@ app.route('/api/emails', emailsRoutes); app.route('/api/payment-options', paymentOptionsRoutes); app.route('/api/dashboard', dashboardRoutes); app.route('/api/site-settings', siteSettingsRoutes); +app.route('/api/legal-pages', legalPagesRoutes); // 404 handler app.notFound((c) => { diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index 925c09f..2d9a631 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -3,7 +3,7 @@ import * as argon2 from 'argon2'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; 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 { generateId, getNow } from './utils.js'; @@ -72,16 +72,17 @@ export async function verifyMagicLinkToken( ): Promise<{ valid: boolean; userId?: string; error?: string }> { const now = getNow(); - const tokenRecord = await (db as any) - .select() - .from(magicLinkTokens) - .where( - and( - eq((magicLinkTokens as any).token, token), - eq((magicLinkTokens as any).type, type) + const tokenRecord = await dbGet( + (db as any) + .select() + .from(magicLinkTokens) + .where( + and( + eq((magicLinkTokens as any).token, token), + eq((magicLinkTokens as any).type, type) + ) ) - ) - .get(); + ); if (!tokenRecord) { return { valid: false, error: 'Invalid token' }; @@ -132,16 +133,17 @@ export async function createUserSession( export async function getUserSessions(userId: string) { const now = getNow(); - return (db as any) - .select() - .from(userSessions) - .where( - and( - eq((userSessions as any).userId, userId), - gt((userSessions as any).expiresAt, now) + return dbAll( + (db as any) + .select() + .from(userSessions) + .where( + and( + eq((userSessions as any).userId, userId), + gt((userSessions as any).expiresAt, now) + ) ) - ) - .all(); + ); } // Invalidate a specific session @@ -208,7 +210,7 @@ export async function verifyToken(token: string): Promise { } } -export async function getAuthUser(c: Context) { +export async function getAuthUser(c: Context): Promise { const authHeader = c.req.header('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return null; @@ -221,7 +223,9 @@ export async function getAuthUser(c: Context) { return null; } - const user = await (db as any).select().from(users).where(eq((users as any).id, payload.sub)).get(); + const user = await dbGet( + (db as any).select().from(users).where(eq((users as any).id, payload.sub)) + ); return user || null; } @@ -243,6 +247,8 @@ export function requireAuth(roles?: string[]) { } export async function isFirstUser(): Promise { - 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; } diff --git a/backend/src/lib/email.ts b/backend/src/lib/email.ts index 047958b..a49fa58 100644 --- a/backend/src/lib/email.ts +++ b/backend/src/lib/email.ts @@ -1,10 +1,9 @@ // Email service for Spanglish platform // 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 { nanoid } from 'nanoid'; -import { getNow } from './utils.js'; +import { getNow, generateId } from './utils.js'; import { replaceTemplateVariables, wrapInBaseTemplate, @@ -362,11 +361,12 @@ export const emailService = { * Get a template by slug */ async getTemplate(slug: string): Promise { - const template = await (db as any) - .select() - .from(emailTemplates) - .where(eq((emailTemplates as any).slug, slug)) - .get(); + const template = await dbGet( + (db as any) + .select() + .from(emailTemplates) + .where(eq((emailTemplates as any).slug, slug)) + ); return template || null; }, @@ -385,7 +385,7 @@ export const emailService = { console.log(`[Email] Creating template: ${template.name}`); await (db as any).insert(emailTemplates).values({ - id: nanoid(), + id: generateId(), name: template.name, slug: template.slug, subject: template.subject, @@ -470,7 +470,7 @@ export const emailService = { const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined; // Create log entry - const logId = nanoid(); + const logId = generateId(); const now = getNow(); await (db as any).insert(emailLogs).values({ @@ -525,21 +525,23 @@ export const emailService = { */ async sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> { // Get ticket with event info - const ticket = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).id, ticketId)) - .get(); + const ticket = await dbGet( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).id, ticketId)) + ); if (!ticket) { return { success: false, error: 'Ticket not found' }; } - 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)) + ); if (!event) { return { success: false, error: 'Event not found' }; @@ -580,31 +582,34 @@ export const emailService = { */ async sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> { // Get payment with ticket and event info - const payment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).id, paymentId)) - .get(); + const payment = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).id, paymentId)) + ); if (!payment) { return { success: false, error: 'Payment not found' }; } - const ticket = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).id, payment.ticketId)) - .get(); + const ticket = await dbGet( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).id, payment.ticketId)) + ); if (!ticket) { return { success: false, error: 'Ticket not found' }; } - 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)) + ); if (!event) { return { success: false, error: 'Event not found' }; @@ -643,17 +648,19 @@ export const emailService = { */ async getPaymentConfig(eventId: string): Promise> { // Get global options - const globalOptions = await (db as any) - .select() - .from(paymentOptions) - .get(); + const globalOptions = await dbGet( + (db as any) + .select() + .from(paymentOptions) + ); // Get event overrides - const overrides = await (db as any) - .select() - .from(eventPaymentOverrides) - .where(eq((eventPaymentOverrides as any).eventId, eventId)) - .get(); + const overrides = await dbGet( + (db as any) + .select() + .from(eventPaymentOverrides) + .where(eq((eventPaymentOverrides as any).eventId, eventId)) + ); // Defaults const defaults = { @@ -696,33 +703,36 @@ export const emailService = { */ async sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> { // Get ticket - const ticket = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).id, ticketId)) - .get(); + const ticket = await dbGet( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).id, ticketId)) + ); if (!ticket) { return { success: false, error: 'Ticket not found' }; } // Get event - const event = await (db as any) - .select() - .from(events) - .where(eq((events as any).id, ticket.eventId)) - .get(); + const event = await dbGet( + (db as any) + .select() + .from(events) + .where(eq((events as any).id, ticket.eventId)) + ); if (!event) { return { success: false, error: 'Event not found' }; } // Get payment - const payment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).ticketId, ticketId)) - .get(); + const payment = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).ticketId, ticketId)) + ); if (!payment) { return { success: false, error: 'Payment not found' }; @@ -797,33 +807,36 @@ export const emailService = { */ async sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> { // Get payment - const payment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).id, paymentId)) - .get(); + const payment = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).id, paymentId)) + ); if (!payment) { return { success: false, error: 'Payment not found' }; } // Get ticket - const ticket = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).id, payment.ticketId)) - .get(); + const ticket = await dbGet( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).id, payment.ticketId)) + ); if (!ticket) { return { success: false, error: 'Ticket not found' }; } // Get event - const event = await (db as any) - .select() - .from(events) - .where(eq((events as any).id, ticket.eventId)) - .get(); + const event = await dbGet( + (db as any) + .select() + .from(events) + .where(eq((events as any).id, ticket.eventId)) + ); if (!event) { return { success: false, error: 'Event not found' }; @@ -872,11 +885,12 @@ export const emailService = { const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params; // Get event - const event = await (db as any) - .select() - .from(events) - .where(eq((events as any).id, eventId)) - .get(); + const event = await dbGet( + (db as any) + .select() + .from(events) + .where(eq((events as any).id, eventId)) + ); if (!event) { 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(ticketQuery); if (eventTickets.length === 0) { return { success: true, sentCount: 0, failedCount: 0, errors: ['No recipients found'] }; @@ -971,7 +985,7 @@ export const emailService = { const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables); // Create log entry - const logId = nanoid(); + const logId = generateId(); const now = getNow(); await (db as any).insert(emailLogs).values({ diff --git a/backend/src/lib/utils.ts b/backend/src/lib/utils.ts index c1372c9..a116b91 100644 --- a/backend/src/lib/utils.ts +++ b/backend/src/lib/utils.ts @@ -1,15 +1,71 @@ 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 { - return nanoid(21); + return getDbType() === 'postgres' ? randomUUID() : nanoid(21); } export function generateTicketCode(): string { 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>(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 { diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index e870e8a..c88fa7d 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,5 +1,5 @@ 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 { requireAuth } from '../lib/auth.js'; import { getNow } from '../lib/utils.js'; @@ -11,74 +11,84 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => const now = getNow(); // Get upcoming events - const upcomingEvents = await (db as any) - .select() - .from(events) - .where( - and( - eq((events as any).status, 'published'), - gte((events as any).startDatetime, now) + const upcomingEvents = await dbAll( + (db as any) + .select() + .from(events) + .where( + and( + eq((events as any).status, 'published'), + gte((events as any).startDatetime, now) + ) ) - ) - .orderBy((events as any).startDatetime) - .limit(5) - .all(); + .orderBy((events as any).startDatetime) + .limit(5) + ); // Get recent tickets - const recentTickets = await (db as any) - .select() - .from(tickets) - .orderBy(desc((tickets as any).createdAt)) - .limit(10) - .all(); + const recentTickets = await dbAll( + (db as any) + .select() + .from(tickets) + .orderBy(desc((tickets as any).createdAt)) + .limit(10) + ); // Get total stats - const totalUsers = await (db as any) - .select({ count: sql`count(*)` }) - .from(users) - .get(); + const totalUsers = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(users) + ); - const totalEvents = await (db as any) - .select({ count: sql`count(*)` }) - .from(events) - .get(); + const totalEvents = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(events) + ); - const totalTickets = await (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .get(); + const totalTickets = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(tickets) + ); - const confirmedTickets = await (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where(eq((tickets as any).status, 'confirmed')) - .get(); + const confirmedTickets = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(tickets) + .where(eq((tickets as any).status, 'confirmed')) + ); - const pendingPayments = await (db as any) - .select({ count: sql`count(*)` }) - .from(payments) - .where(eq((payments as any).status, 'pending')) - .get(); + const pendingPayments = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(payments) + .where(eq((payments as any).status, 'pending')) + ); - const paidPayments = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).status, 'paid')) - .all(); + const paidPayments = await dbAll( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).status, 'paid')) + ); const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + (p.amount || 0), 0); - const newContacts = await (db as any) - .select({ count: sql`count(*)` }) - .from(contacts) - .where(eq((contacts as any).status, 'new')) - .get(); + const newContacts = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(contacts) + .where(eq((contacts as any).status, 'new')) + ); - const totalSubscribers = await (db as any) - .select({ count: sql`count(*)` }) - .from(emailSubscribers) - .where(eq((emailSubscribers as any).status, 'active')) - .get(); + const totalSubscribers = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(emailSubscribers) + .where(eq((emailSubscribers as any).status, 'active')) + ); return c.json({ dashboard: { @@ -101,37 +111,40 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => // Get analytics data (admin) adminRouter.get('/analytics', requireAuth(['admin']), async (c) => { // Get events with ticket counts - const allEvents = await (db as any).select().from(events).all(); + const allEvents = await dbAll((db as any).select().from(events)); const eventStats = await Promise.all( allEvents.map(async (event: any) => { - const ticketCount = await (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where(eq((tickets as any).eventId, event.id)) - .get(); + const ticketCount = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(tickets) + .where(eq((tickets as any).eventId, event.id)) + ); - const confirmedCount = await (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, event.id), - eq((tickets as any).status, 'confirmed') + const confirmedCount = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(tickets) + .where( + and( + eq((tickets as any).eventId, event.id), + eq((tickets as any).status, 'confirmed') + ) ) - ) - .get(); + ); - const checkedInCount = await (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, event.id), - eq((tickets as any).status, 'checked_in') + const checkedInCount = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(tickets) + .where( + and( + eq((tickets as any).eventId, event.id), + eq((tickets as any).status, 'checked_in') + ) ) - ) - .get(); + ); return { id: event.id, @@ -163,28 +176,31 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => { query = query.where(eq((tickets as any).eventId, eventId)); } - const ticketList = await query.all(); + const ticketList = await dbAll(query); // Get user and event details for each ticket const enrichedTickets = await Promise.all( ticketList.map(async (ticket: any) => { - const user = await (db as any) - .select() - .from(users) - .where(eq((users as any).id, ticket.userId)) - .get(); + const user = await dbGet( + (db as any) + .select() + .from(users) + .where(eq((users as any).id, ticket.userId)) + ); - 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)) + ); - const payment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).ticketId, ticket.id)) - .get(); + const payment = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).ticketId, ticket.id)) + ); return { ticketId: ticket.id, @@ -215,24 +231,26 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => { // Get all payments let query = (db as any).select().from(payments); - const allPayments = await query.all(); + const allPayments = await dbAll(query); // Enrich with event and ticket data const enrichedPayments = await Promise.all( allPayments.map(async (payment: any) => { - const ticket = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).id, payment.ticketId)) - .get(); + const ticket = await dbGet( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).id, payment.ticketId)) + ); if (!ticket) return null; - 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)) + ); // Apply filters if (eventId && ticket.eventId !== eventId) return null; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 45464a7..ad34c63 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; 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 { hashPassword, @@ -16,7 +16,7 @@ import { invalidateAllUserSessions, requireAuth, } 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'; // 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 - const existing = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get(); + const existing = await dbGet( + (db as any).select().from(users).where(eq((users as any).email, data.email)) + ); if (existing) { // If user exists but is unclaimed, allow claiming if (!existing.isClaimed || existing.accountStatus === 'unclaimed') { @@ -149,7 +151,7 @@ auth.post('/register', zValidator('json', registerSchema), async (c) => { phone: data.phone || null, role: firstUser ? 'admin' : 'user', languagePreference: data.languagePreference || null, - isClaimed: true, + isClaimed: toDbBool(true), googleId: null, rucNumber: null, accountStatus: 'active', @@ -189,7 +191,9 @@ auth.post('/login', zValidator('json', loginSchema), async (c) => { }, 429); } - const user = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get(); + const user = await dbGet( + (db as any).select().from(users).where(eq((users as any).email, data.email)) + ); if (!user) { recordFailedAttempt(data.email); 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) => { 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( + (db as any).select().from(users).where(eq((users as any).email, email)) + ); if (!user) { // 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); } - const user = await (db as any).select().from(users).where(eq((users as any).id, verification.userId)).get(); + const user = await dbGet( + (db as any).select().from(users).where(eq((users as any).id, verification.userId)) + ); if (!user || user.accountStatus === 'suspended') { 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) => { 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( + (db as any).select().from(users).where(eq((users as any).email, email)) + ); if (!user) { // 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) => { 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( + (db as any).select().from(users).where(eq((users as any).email, email)) + ); if (!user) { 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 updates: Record = { - isClaimed: true, + isClaimed: toDbBool(true), accountStatus: 'active', updatedAt: now, }; @@ -461,7 +473,9 @@ auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), asyn .set(updates) .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( + (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 refreshToken = await createRefreshToken(user.id); @@ -510,11 +524,15 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => { const { sub: googleId, email, name } = googleData; // 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( + (db as any).select().from(users).where(eq((users as any).email, email)) + ); if (!user) { // Check by google_id - user = await (db as any).select().from(users).where(eq((users as any).googleId, googleId)).get(); + user = await dbGet( + (db as any).select().from(users).where(eq((users as any).googleId, googleId)) + ); } const now = getNow(); @@ -530,7 +548,7 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => { .update(users) .set({ googleId, - isClaimed: true, + isClaimed: toDbBool(true), accountStatus: 'active', updatedAt: now, }) @@ -538,7 +556,9 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => { } // Refresh user data - user = await (db as any).select().from(users).where(eq((users as any).id, user.id)).get(); + user = await dbGet( + (db as any).select().from(users).where(eq((users as any).id, user.id)) + ); } else { // Create new user const firstUser = await isFirstUser(); @@ -552,7 +572,7 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => { phone: null, role: firstUser ? 'admin' : 'user', languagePreference: null, - isClaimed: true, + isClaimed: toDbBool(true), googleId, rucNumber: null, accountStatus: 'active', diff --git a/backend/src/routes/contacts.ts b/backend/src/routes/contacts.ts index efc7581..360997b 100644 --- a/backend/src/routes/contacts.ts +++ b/backend/src/routes/contacts.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; 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 { requireAuth } from '../lib/auth.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'); // Check if already subscribed - const existing = await (db as any) - .select() - .from(emailSubscribers) - .where(eq((emailSubscribers as any).email, data.email)) - .get(); + const existing = await dbGet( + (db as any) + .select() + .from(emailSubscribers) + .where(eq((emailSubscribers as any).email, data.email)) + ); if (existing) { 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) => { const { email } = c.req.valid('json'); - const existing = await (db as any) - .select() - .from(emailSubscribers) - .where(eq((emailSubscribers as any).email, email)) - .get(); + const existing = await dbGet( + (db as any).select().from(emailSubscribers).where(eq((emailSubscribers as any).email, email)) + ); if (!existing) { 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)); } - 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 }); }); @@ -124,11 +123,12 @@ contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => { contactsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => { const id = c.req.param('id'); - const contact = await (db as any) - .select() - .from(contacts) - .where(eq((contacts as any).id, id)) - .get(); + const contact = await dbGet( + (db as any) + .select() + .from(contacts) + .where(eq((contacts as any).id, id)) + ); if (!contact) { 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 data = c.req.valid('json'); - const existing = await (db as any) - .select() - .from(contacts) - .where(eq((contacts as any).id, id)) - .get(); + const existing = await dbGet( + (db as any).select().from(contacts).where(eq((contacts as any).id, id)) + ); if (!existing) { return c.json({ error: 'Contact not found' }, 404); @@ -157,11 +155,9 @@ contactsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json .set({ status: data.status }) .where(eq((contacts as any).id, id)); - const updated = await (db as any) - .select() - .from(contacts) - .where(eq((contacts as any).id, id)) - .get(); + const updated = await dbGet( + (db as any).select().from(contacts).where(eq((contacts as any).id, id)) + ); 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)); } - 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 }); }); diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index c3a4126..9895c5c 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; 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 { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, hashPassword, validatePassword } from '../lib/auth.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)); - const updatedUser = await (db as any) - .select() - .from(users) - .where(eq((users as any).id, user.id)) - .get(); + const updatedUser = await dbGet( + (db as any) + .select() + .from(users) + .where(eq((users as any).id, user.id)) + ); return c.json({ profile: { @@ -95,36 +96,40 @@ dashboard.put('/profile', zValidator('json', updateProfileSchema), async (c) => dashboard.get('/tickets', async (c) => { const user = (c as any).get('user') as AuthUser; - const userTickets = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).userId, user.id)) - .orderBy(desc((tickets as any).createdAt)) - .all(); + const userTickets = await dbAll( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).userId, user.id)) + .orderBy(desc((tickets as any).createdAt)) + ); // Get event details for each ticket const ticketsWithEvents = await Promise.all( userTickets.map(async (ticket: any) => { - 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)) + ); - const payment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).ticketId, ticket.id)) - .get(); + const payment = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).ticketId, ticket.id)) + ); // Check for invoice - let invoice = null; + let invoice: any = null; if (payment && payment.status === 'paid') { - invoice = await (db as any) - .select() - .from(invoices) - .where(eq((invoices as any).paymentId, payment.id)) - .get(); + invoice = await dbGet( + (db as any) + .select() + .from(invoices) + .where(eq((invoices as any).paymentId, payment.id)) + ); } return { @@ -168,40 +173,44 @@ dashboard.get('/tickets/:id', async (c) => { const user = (c as any).get('user') as AuthUser; const ticketId = c.req.param('id'); - const ticket = await (db as any) - .select() - .from(tickets) - .where( - and( - eq((tickets as any).id, ticketId), - eq((tickets as any).userId, user.id) + const ticket = await dbGet( + (db as any) + .select() + .from(tickets) + .where( + and( + eq((tickets as any).id, ticketId), + eq((tickets as any).userId, user.id) + ) ) - ) - .get(); + ); if (!ticket) { return c.json({ error: 'Ticket not found' }, 404); } - 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)) + ); - const payment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).ticketId, ticket.id)) - .get(); + const payment = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).ticketId, ticket.id)) + ); let invoice = null; if (payment && payment.status === 'paid') { - invoice = await (db as any) - .select() - .from(invoices) - .where(eq((invoices as any).paymentId, payment.id)) - .get(); + invoice = await dbGet( + (db as any) + .select() + .from(invoices) + .where(eq((invoices as any).paymentId, payment.id)) + ); } return c.json({ @@ -222,11 +231,12 @@ dashboard.get('/next-event', async (c) => { const now = getNow(); // Get user's tickets for upcoming events - const userTickets = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).userId, user.id)) - .all(); + const userTickets = await dbAll( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).userId, user.id)) + ); if (userTickets.length === 0) { return c.json({ nextEvent: null }); @@ -240,11 +250,12 @@ dashboard.get('/next-event', async (c) => { for (const ticket of userTickets) { if (ticket.status === 'cancelled') continue; - 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)) + ); if (!event) continue; @@ -253,11 +264,12 @@ dashboard.get('/next-event', async (c) => { if (!nextEvent || new Date(event.startDatetime) < new Date(nextEvent.startDatetime)) { nextEvent = event; nextTicket = ticket; - nextPayment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).ticketId, ticket.id)) - .get(); + nextPayment = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).ticketId, ticket.id)) + ); } } } @@ -282,11 +294,12 @@ dashboard.get('/payments', async (c) => { const user = (c as any).get('user') as AuthUser; // Get all user's tickets first - const userTickets = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).userId, user.id)) - .all(); + const userTickets = await dbAll( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).userId, user.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 const allPayments = []; for (const ticketId of ticketIds) { - const ticketPayments = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).ticketId, ticketId)) - .all(); + const ticketPayments = await dbAll( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).ticketId, ticketId)) + ); for (const payment of ticketPayments) { const ticket = userTickets.find((t: any) => t.id === payment.ticketId); const event = ticket - ? await (db as any) - .select() - .from(events) - .where(eq((events as any).id, ticket.eventId)) - .get() + ? await dbGet( + (db as any) + .select() + .from(events) + .where(eq((events as any).id, ticket.eventId)) + ) : null; - let invoice = null; + let invoice: any = null; if (payment.status === 'paid') { - invoice = await (db as any) - .select() - .from(invoices) - .where(eq((invoices as any).paymentId, payment.id)) - .get(); + invoice = await dbGet( + (db as any) + .select() + .from(invoices) + .where(eq((invoices as any).paymentId, payment.id)) + ); } allPayments.push({ @@ -355,36 +371,40 @@ dashboard.get('/payments', async (c) => { dashboard.get('/invoices', async (c) => { const user = (c as any).get('user') as AuthUser; - const userInvoices = await (db as any) - .select() - .from(invoices) - .where(eq((invoices as any).userId, user.id)) - .orderBy(desc((invoices as any).createdAt)) - .all(); + const userInvoices = await dbAll( + (db as any) + .select() + .from(invoices) + .where(eq((invoices as any).userId, user.id)) + .orderBy(desc((invoices as any).createdAt)) + ); // Get payment and event details for each invoice const invoicesWithDetails = await Promise.all( userInvoices.map(async (invoice: any) => { - const payment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).id, invoice.paymentId)) - .get(); - - let event = null; - if (payment) { - const ticket = await (db as any) + const payment = await dbGet( + (db as any) .select() - .from(tickets) - .where(eq((tickets as any).id, payment.ticketId)) - .get(); + .from(payments) + .where(eq((payments as any).id, invoice.paymentId)) + ); + + let event: any = null; + if (payment) { + const ticket = await dbGet( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).id, payment.ticketId)) + ); if (ticket) { - event = await (db as any) - .select() - .from(events) - .where(eq((events as any).id, ticket.eventId)) - .get(); + event = await dbGet( + (db as any) + .select() + .from(events) + .where(eq((events as any).id, ticket.eventId)) + ); } } @@ -511,11 +531,12 @@ dashboard.get('/summary', async (c) => { const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24)); // Get ticket count - const userTickets = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).userId, user.id)) - .all(); + const userTickets = await dbAll( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).userId, user.id)) + ); const totalTickets = userTickets.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) { if (ticket.status === 'cancelled') continue; - 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)) + ); if (event && new Date(event.startDatetime) > now) { upcomingTickets.push({ ticket, event }); @@ -540,16 +562,17 @@ dashboard.get('/summary', async (c) => { let pendingPayments = 0; for (const ticketId of ticketIds) { - const payment = await (db as any) - .select() - .from(payments) - .where( - and( - eq((payments as any).ticketId, ticketId), - eq((payments as any).status, 'pending_approval') + const payment = await dbGet( + (db as any) + .select() + .from(payments) + .where( + and( + eq((payments as any).ticketId, ticketId), + eq((payments as any).status, 'pending_approval') + ) ) - ) - .get(); + ); if (payment) pendingPayments++; } diff --git a/backend/src/routes/emails.ts b/backend/src/routes/emails.ts index 9506560..a8235f4 100644 --- a/backend/src/routes/emails.ts +++ b/backend/src/routes/emails.ts @@ -1,9 +1,8 @@ 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 { requireAuth } from '../lib/auth.js'; -import { getNow } from '../lib/utils.js'; -import { nanoid } from 'nanoid'; +import { getNow, generateId } from '../lib/utils.js'; import emailService from '../lib/email.js'; import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js'; @@ -13,11 +12,9 @@ const emailsRouter = new Hono(); // Get all email templates emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) => { - const templates = await (db as any) - .select() - .from(emailTemplates) - .orderBy(desc((emailTemplates as any).createdAt)) - .all(); + const templates = await dbAll( + (db as any).select().from(emailTemplates).orderBy(desc((emailTemplates as any).createdAt)) + ); // Parse variables JSON for each template 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) => { const { id } = c.req.param(); - const template = await (db as any) - .select() - .from(emailTemplates) - .where(eq((emailTemplates as any).id, id)) - .get(); + const template = await dbGet( + (db as any) + .select() + .from(emailTemplates) + .where(eq((emailTemplates as any).id, id)) + ); if (!template) { return c.json({ error: 'Template not found' }, 404); @@ -64,11 +62,9 @@ emailsRouter.post('/templates', requireAuth(['admin']), async (c) => { } // Check if slug already exists - const existing = await (db as any) - .select() - .from(emailTemplates) - .where(eq((emailTemplates as any).slug, slug)) - .get(); + const existing = await dbGet( + (db as any).select().from(emailTemplates).where(eq((emailTemplates as any).slug, slug)) + ); if (existing) { 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 template = { - id: nanoid(), + id: generateId(), name, slug, subject, @@ -111,11 +107,12 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => { const { id } = c.req.param(); const body = await c.req.json(); - const existing = await (db as any) - .select() - .from(emailTemplates) - .where(eq((emailTemplates as any).id, id)) - .get(); + const existing = await dbGet( + (db as any) + .select() + .from(emailTemplates) + .where(eq((emailTemplates as any).id, id)) + ); if (!existing) { return c.json({ error: 'Template not found' }, 404); @@ -148,11 +145,12 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => { .set(updateData) .where(eq((emailTemplates as any).id, id)); - const updated = await (db as any) - .select() - .from(emailTemplates) - .where(eq((emailTemplates as any).id, id)) - .get(); + const updated = await dbGet( + (db as any) + .select() + .from(emailTemplates) + .where(eq((emailTemplates as any).id, id)) + ); return c.json({ template: { @@ -169,11 +167,9 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => { emailsRouter.delete('/templates/:id', requireAuth(['admin']), async (c) => { const { id } = c.req.param(); - const template = await (db as any) - .select() - .from(emailTemplates) - .where(eq((emailTemplates as any).id, id)) - .get(); + const template = await dbGet( + (db as any).select().from(emailTemplates).where(eq((emailTemplates as any).id, id)) + ); if (!template) { 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)); } - const logs = await query - .orderBy(desc((emailLogs as any).createdAt)) - .limit(limit) - .offset(offset) - .all(); + const logs = await dbAll( + query + .orderBy(desc((emailLogs as any).createdAt)) + .limit(limit) + .offset(offset) + ); // Get total count let countQuery = (db as any) @@ -321,7 +318,7 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => { countQuery = countQuery.where(and(...conditions)); } - const totalResult = await countQuery.get(); + const totalResult = await dbGet(countQuery); const total = totalResult?.count || 0; return c.json({ @@ -339,11 +336,9 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => { emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) => { const { id } = c.req.param(); - const log = await (db as any) - .select() - .from(emailLogs) - .where(eq((emailLogs as any).id, id)) - .get(); + const log = await dbGet( + (db as any).select().from(emailLogs).where(eq((emailLogs as any).id, id)) + ); if (!log) { 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`count(*)` }).from(emailLogs).where(baseCondition) : (db as any).select({ count: sql`count(*)` }).from(emailLogs); - const total = (await totalQuery.get())?.count || 0; + const total = (await dbGet(totalQuery))?.count || 0; const sentCondition = baseCondition ? and(baseCondition, eq((emailLogs as any).status, 'sent')) : eq((emailLogs as any).status, 'sent'); - const sent = (await (db as any).select({ count: sql`count(*)` }).from(emailLogs).where(sentCondition).get())?.count || 0; + const sent = (await dbGet((db as any).select({ count: sql`count(*)` }).from(emailLogs).where(sentCondition)))?.count || 0; const failedCondition = baseCondition ? and(baseCondition, eq((emailLogs as any).status, 'failed')) : eq((emailLogs as any).status, 'failed'); - const failed = (await (db as any).select({ count: sql`count(*)` }).from(emailLogs).where(failedCondition).get())?.count || 0; + const failed = (await dbGet((db as any).select({ count: sql`count(*)` }).from(emailLogs).where(failedCondition)))?.count || 0; const pendingCondition = baseCondition ? and(baseCondition, eq((emailLogs as any).status, 'pending')) : eq((emailLogs as any).status, 'pending'); - const pending = (await (db as any).select({ count: sql`count(*)` }).from(emailLogs).where(pendingCondition).get())?.count || 0; + const pending = (await dbGet((db as any).select({ count: sql`count(*)` }).from(emailLogs).where(pendingCondition)))?.count || 0; return c.json({ stats: { diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index bba2275..02191b1 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -1,10 +1,10 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; 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 { requireAuth, getAuthUser } from '../lib/auth.js'; -import { generateId, getNow } from '../lib/utils.js'; +import { generateId, getNow, convertBooleansForDb, toDbDate } from '../lib/utils.js'; interface UserContext { id: string; @@ -15,6 +15,21 @@ interface 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 const validationHook = (result: any, c: any) => { 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({ title: z.string().min(1), titleEs: z.string().optional().nullable(), @@ -34,14 +70,15 @@ const baseEventSchema = z.object({ endDatetime: z.string().optional().nullable(), location: z.string().min(1), 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'), - 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'), // Accept relative paths (/uploads/...) or full URLs bannerUrl: z.string().optional().nullable().or(z.literal('')), - // External booking support - externalBookingEnabled: z.boolean().default(false), + // External booking support - accept boolean or number (0/1 from DB) + externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false), externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')), }); @@ -94,26 +131,28 @@ eventsRouter.get('/', async (c) => { ); } - const result = await query.orderBy(desc((events as any).startDatetime)).all(); + const result = await dbAll(query.orderBy(desc((events as any).startDatetime))); // Get ticket counts for each event const eventsWithCounts = await Promise.all( result.map(async (event: any) => { - const ticketCount = await (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, event.id), - eq((tickets as any).status, 'confirmed') + const ticketCount = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(tickets) + .where( + and( + eq((tickets as any).eventId, event.id), + eq((tickets as any).status, 'confirmed') + ) ) - ) - .get(); + ); + const normalized = normalizeEvent(event); return { - ...event, + ...normalized, bookedCount: ticketCount?.count || 0, - availableSeats: event.capacity - (ticketCount?.count || 0), + availableSeats: normalized.capacity - (ticketCount?.count || 0), }; }) ); @@ -125,29 +164,33 @@ eventsRouter.get('/', async (c) => { eventsRouter.get('/:id', async (c) => { 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( + (db as any).select().from(events).where(eq((events as any).id, id)) + ); if (!event) { return c.json({ error: 'Event not found' }, 404); } // Get ticket count - const ticketCount = await (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, id), - eq((tickets as any).status, 'confirmed') + const ticketCount = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(tickets) + .where( + and( + eq((tickets as any).eventId, id), + eq((tickets as any).status, 'confirmed') + ) ) - ) - .get(); + ); + const normalized = normalizeEvent(event); return c.json({ event: { - ...event, + ...normalized, bookedCount: ticketCount?.count || 0, - availableSeats: event.capacity - (ticketCount?.count || 0), + availableSeats: normalized.capacity - (ticketCount?.count || 0), }, }); }); @@ -156,39 +199,42 @@ eventsRouter.get('/:id', async (c) => { eventsRouter.get('/next/upcoming', async (c) => { const now = getNow(); - const event = await (db as any) - .select() - .from(events) - .where( - and( - eq((events as any).status, 'published'), - gte((events as any).startDatetime, now) + const event = await dbGet( + (db as any) + .select() + .from(events) + .where( + and( + eq((events as any).status, 'published'), + gte((events as any).startDatetime, now) + ) ) - ) - .orderBy((events as any).startDatetime) - .limit(1) - .get(); + .orderBy((events as any).startDatetime) + .limit(1) + ); if (!event) { return c.json({ event: null }); } - const ticketCount = await (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, event.id), - eq((tickets as any).status, 'confirmed') + const ticketCount = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(tickets) + .where( + and( + eq((tickets as any).eventId, event.id), + eq((tickets as any).status, 'confirmed') + ) ) - ) - .get(); + ); + const normalized = normalizeEvent(event); return c.json({ event: { - ...event, + ...normalized, 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 id = generateId(); + // Convert data for database compatibility + const dbData = convertBooleansForDb(data); + const newEvent = { id, - ...data, + ...dbData, + startDatetime: toDbDate(data.startDatetime), + endDatetime: data.endDatetime ? toDbDate(data.endDatetime) : null, createdAt: now, updatedAt: now, }; 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) @@ -217,46 +269,64 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', const id = c.req.param('id'); 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) { return c.json({ error: 'Event not found' }, 404); } const now = getNow(); + // Convert data for database compatibility + const updateData: Record = { ...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) .update(events) - .set({ ...data, updatedAt: now }) + .set(updateData) .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) eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => { 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) { return c.json({ error: 'Event not found' }, 404); } // Get all tickets for this event - const eventTickets = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).eventId, id)) - .all(); + const eventTickets = await dbAll( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).eventId, id)) + ); // Delete invoices and payments for all tickets of this event for (const ticket of eventTickets) { // Get payments for this ticket - const ticketPayments = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).ticketId, ticket.id)) - .all(); + const ticketPayments = await dbAll( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).ticketId, ticket.id)) + ); // Delete invoices for each payment 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) => { const id = c.req.param('id'); - const attendees = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).eventId, id)) - .all(); + const attendees = await dbAll( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).eventId, id)) + ); 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) => { 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) { return c.json({ error: 'Event not found' }, 404); } @@ -319,7 +392,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async ( descriptionEs: existing.descriptionEs, shortDescription: existing.shortDescription, shortDescriptionEs: existing.shortDescriptionEs, - startDatetime: existing.startDatetime, + startDatetime: existing.startDatetime, // Already in DB format from existing record endDatetime: existing.endDatetime, location: existing.location, locationUrl: existing.locationUrl, @@ -328,7 +401,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async ( capacity: existing.capacity, status: 'draft', bannerUrl: existing.bannerUrl, - externalBookingEnabled: existing.externalBookingEnabled || false, + externalBookingEnabled: existing.externalBookingEnabled ?? 0, // Already in DB format (0/1) externalBookingUrl: existing.externalBookingUrl, createdAt: now, updatedAt: now, @@ -336,7 +409,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async ( 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; diff --git a/backend/src/routes/legal-pages.ts b/backend/src/routes/legal-pages.ts new file mode 100644 index 0000000..6e5f0a0 --- /dev/null +++ b/backend/src/routes/legal-pages.ts @@ -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 = { + '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( + (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( + (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( + (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( + (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( + (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; diff --git a/backend/src/routes/lnbits.ts b/backend/src/routes/lnbits.ts index 1282724..4226e97 100644 --- a/backend/src/routes/lnbits.ts +++ b/backend/src/routes/lnbits.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; 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 { getNow } from '../lib/utils.js'; import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js'; @@ -157,11 +157,9 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) { const now = getNow(); // Check if already confirmed to avoid duplicate updates - const existingTicket = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).id, ticketId)) - .get(); + const existingTicket = await dbGet( + (db as any).select().from(tickets).where(eq((tickets as any).id, ticketId)) + ); if (existingTicket?.status === 'confirmed') { 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})`); // Get payment for sending receipt - const payment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).ticketId, ticketId)) - .get(); + const payment = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).ticketId, ticketId)) + ); // Send confirmation emails asynchronously Promise.all([ @@ -211,11 +210,9 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => { const ticketId = c.req.param('ticketId'); // Verify ticket exists - const ticket = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).id, ticketId)) - .get(); + const ticket = await dbGet( + (db as any).select().from(tickets).where(eq((tickets as any).id, ticketId)) + ); if (!ticket) { return c.json({ error: 'Ticket not found' }, 404); @@ -227,11 +224,9 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => { } // Get payment to start background checker - const payment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).ticketId, ticketId)) - .get(); + const payment = await dbGet( + (db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId)) + ); // Start background checker if not already running if (payment?.reference && !activeCheckers.has(ticketId)) { @@ -290,21 +285,23 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => { lnbitsRouter.get('/status/:ticketId', async (c) => { const ticketId = c.req.param('ticketId'); - const ticket = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).id, ticketId)) - .get(); + const ticket = await dbGet( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).id, ticketId)) + ); if (!ticket) { return c.json({ error: 'Ticket not found' }, 404); } - const payment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).ticketId, ticketId)) - .get(); + const payment = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).ticketId, ticketId)) + ); return c.json({ ticketStatus: ticket.status, diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index 96aa995..b0b75b6 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -1,5 +1,5 @@ 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 { requireAuth } from '../lib/auth.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) => { const id = c.req.param('id'); - const mediaRecord = await (db as any) - .select() - .from(media) - .where(eq((media as any).id, id)) - .get(); + const mediaRecord = await dbGet( + (db as any).select().from(media).where(eq((media as any).id, id)) + ); if (!mediaRecord) { 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) => { const id = c.req.param('id'); - const mediaRecord = await (db as any) - .select() - .from(media) - .where(eq((media as any).id, id)) - .get(); + const mediaRecord = await dbGet( + (db as any).select().from(media).where(eq((media as any).id, id)) + ); if (!mediaRecord) { 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)); } - const result = await query.all(); + const result = await dbAll(query); return c.json({ media: result }); }); diff --git a/backend/src/routes/payment-options.ts b/backend/src/routes/payment-options.ts index 369ecd2..c034aed 100644 --- a/backend/src/routes/payment-options.ts +++ b/backend/src/routes/payment-options.ts @@ -1,10 +1,10 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; 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 { requireAuth } from '../lib/auth.js'; -import { generateId, getNow } from '../lib/utils.js'; +import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js'; const paymentOptionsRouter = new Hono(); @@ -52,10 +52,9 @@ const updateEventOverridesSchema = z.object({ // Get global payment options paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => { - const options = await (db as any) - .select() - .from(paymentOptions) - .get(); + const options = await dbGet( + (db as any).select().from(paymentOptions) + ); // If no options exist yet, return defaults if (!options) { @@ -92,17 +91,21 @@ paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updateP const now = getNow(); // Check if options exist - const existing = await (db as any) - .select() - .from(paymentOptions) - .get(); + const existing = await dbGet( + (db as any) + .select() + .from(paymentOptions) + ); + // Convert boolean fields for database compatibility + const dbData = convertBooleansForDb(data); + if (existing) { // Update existing await (db as any) .update(paymentOptions) .set({ - ...data, + ...dbData, updatedAt: now, updatedBy: user.id, }) @@ -112,16 +115,17 @@ paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updateP const id = generateId(); await (db as any).insert(paymentOptions).values({ id, - ...data, + ...dbData, updatedAt: now, updatedBy: user.id, }); } - const updated = await (db as any) - .select() - .from(paymentOptions) - .get(); + const updated = await dbGet( + (db as any) + .select() + .from(paymentOptions) + ); 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'); // Get the event first to verify it exists - const event = await (db as any) - .select() - .from(events) - .where(eq((events as any).id, eventId)) - .get(); + const event = await dbGet( + (db as any) + .select() + .from(events) + .where(eq((events as any).id, eventId)) + ); if (!event) { return c.json({ error: 'Event not found' }, 404); } // Get global options - const globalOptions = await (db as any) - .select() - .from(paymentOptions) - .get(); + const globalOptions = await dbGet( + (db as any) + .select() + .from(paymentOptions) + ); // Get event overrides - const overrides = await (db as any) - .select() - .from(eventPaymentOverrides) - .where(eq((eventPaymentOverrides as any).eventId, eventId)) - .get(); + const overrides = await dbGet( + (db as any) + .select() + .from(eventPaymentOverrides) + .where(eq((eventPaymentOverrides as any).eventId, eventId)) + ); // Merge global with overrides (override takes precedence if not null) const defaults = { @@ -206,11 +213,9 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => { paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => { const eventId = c.req.param('eventId'); - const overrides = await (db as any) - .select() - .from(eventPaymentOverrides) - .where(eq((eventPaymentOverrides as any).eventId, eventId)) - .get(); + const overrides = await dbGet( + (db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, eventId)) + ); return c.json({ overrides: overrides || null }); }); @@ -222,28 +227,27 @@ paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'org const now = getNow(); // Verify event exists - const event = await (db as any) - .select() - .from(events) - .where(eq((events as any).id, eventId)) - .get(); + const event = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, eventId)) + ); if (!event) { return c.json({ error: 'Event not found' }, 404); } // Check if overrides exist - const existing = await (db as any) - .select() - .from(eventPaymentOverrides) - .where(eq((eventPaymentOverrides as any).eventId, eventId)) - .get(); + const existing = await dbGet( + (db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, eventId)) + ); + // Convert boolean fields for database compatibility + const dbData = convertBooleansForDb(data); + if (existing) { await (db as any) .update(eventPaymentOverrides) .set({ - ...data, + ...dbData, updatedAt: now, }) .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({ id, eventId, - ...data, + ...dbData, createdAt: now, updatedAt: now, }); } - const updated = await (db as any) - .select() - .from(eventPaymentOverrides) - .where(eq((eventPaymentOverrides as any).eventId, eventId)) - .get(); + const updated = await dbGet( + (db as any) + .select() + .from(eventPaymentOverrides) + .where(eq((eventPaymentOverrides as any).eventId, eventId)) + ); return c.json({ overrides: updated, message: 'Event payment overrides updated successfully' }); }); diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts index 683bcab..f35e3a9 100644 --- a/backend/src/routes/payments.ts +++ b/backend/src/routes/payments.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; 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 { requireAuth } from '../lib/auth.js'; import { getNow } from '../lib/utils.js'; @@ -30,11 +30,12 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => { const pendingApproval = c.req.query('pendingApproval'); // Get all payments with their associated tickets - let allPayments = await (db as any) - .select() - .from(payments) - .orderBy(desc((payments as any).createdAt)) - .all(); + let allPayments = await dbAll( + (db as any) + .select() + .from(payments) + .orderBy(desc((payments as any).createdAt)) + ); // Filter by status if (status) { @@ -54,19 +55,21 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => { // Enrich with ticket and event data const enrichedPayments = await Promise.all( allPayments.map(async (payment: any) => { - const ticket = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).id, payment.ticketId)) - .get(); - - let event = null; - if (ticket) { - event = await (db as any) + const ticket = await dbGet( + (db as any) .select() - .from(events) - .where(eq((events as any).id, ticket.eventId)) - .get(); + .from(tickets) + .where(eq((tickets as any).id, payment.ticketId)) + ); + + let event: any = null; + if (ticket) { + event = await dbGet( + (db as any) + .select() + .from(events) + .where(eq((events as any).id, ticket.eventId)) + ); } return { @@ -93,29 +96,32 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => { // Get payments pending approval (admin dashboard view) paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), async (c) => { - const pendingPayments = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).status, 'pending_approval')) - .orderBy(desc((payments as any).userMarkedPaidAt)) - .all(); + const pendingPayments = await dbAll( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).status, 'pending_approval')) + .orderBy(desc((payments as any).userMarkedPaidAt)) + ); // Enrich with ticket and event data const enrichedPayments = await Promise.all( pendingPayments.map(async (payment: any) => { - const ticket = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).id, payment.ticketId)) - .get(); - - let event = null; - if (ticket) { - event = await (db as any) + const ticket = await dbGet( + (db as any) .select() - .from(events) - .where(eq((events as any).id, ticket.eventId)) - .get(); + .from(tickets) + .where(eq((tickets as any).id, payment.ticketId)) + ); + + let event: any = null; + if (ticket) { + event = await dbGet( + (db as any) + .select() + .from(events) + .where(eq((events as any).id, ticket.eventId)) + ); } return { @@ -144,22 +150,24 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => { const id = c.req.param('id'); - const payment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).id, id)) - .get(); + const payment = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).id, id)) + ); if (!payment) { return c.json({ error: 'Payment not found' }, 404); } // Get associated ticket - const ticket = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).id, payment.ticketId)) - .get(); + const ticket = await dbGet( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).id, payment.ticketId)) + ); 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 user = (c as any).get('user'); - const existing = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).id, id)) - .get(); + const existing = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).id, id)) + ); if (!existing) { 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) - .select() - .from(payments) - .where(eq((payments as any).id, id)) - .get(); + const updated = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).id, id)) + ); return c.json({ payment: updated }); }); @@ -226,11 +236,12 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida const { adminNote } = c.req.valid('json'); const user = (c as any).get('user'); - const payment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).id, id)) - .get(); + const payment = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).id, id)) + ); if (!payment) { 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); }); - const updated = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).id, id)) - .get(); + const updated = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).id, id)) + ); 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 user = (c as any).get('user'); - const payment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).id, id)) - .get(); + const payment = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).id, id)) + ); if (!payment) { 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) - .select() - .from(payments) - .where(eq((payments as any).id, id)) - .get(); + const updated = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).id, id)) + ); 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 { adminNote } = body; - const payment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).id, id)) - .get(); + const payment = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).id, id)) + ); if (!payment) { 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)); - const updated = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).id, id)) - .get(); + const updated = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).id, id)) + ); 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) => { const id = c.req.param('id'); - const payment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).id, id)) - .get(); + const payment = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).id, id)) + ); if (!payment) { return c.json({ error: 'Payment not found' }, 404); @@ -426,7 +443,7 @@ paymentsRouter.post('/webhook', async (c) => { // Get payment statistics (admin) paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => { - const allPayments = await (db as any).select().from(payments).all(); + const allPayments = await dbAll((db as any).select().from(payments)); const stats = { total: allPayments.length, diff --git a/backend/src/routes/site-settings.ts b/backend/src/routes/site-settings.ts index 274dc1c..bc63fc7 100644 --- a/backend/src/routes/site-settings.ts +++ b/backend/src/routes/site-settings.ts @@ -1,10 +1,10 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; 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 { requireAuth } from '../lib/auth.js'; -import { generateId, getNow } from '../lib/utils.js'; +import { generateId, getNow, toDbBool } from '../lib/utils.js'; interface UserContext { id: string; @@ -34,7 +34,9 @@ const updateSiteSettingsSchema = z.object({ // Get site settings (public - needed for frontend timezone) 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) { // Return default settings if none exist @@ -95,7 +97,9 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit const now = getNow(); // Check if settings exist - const existing = await (db as any).select().from(siteSettings).limit(1).get(); + const existing = await dbGet( + (db as any).select().from(siteSettings).limit(1) + ); if (!existing) { // Create new settings record @@ -112,7 +116,7 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit instagramUrl: data.instagramUrl || null, twitterUrl: data.twitterUrl || null, linkedinUrl: data.linkedinUrl || null, - maintenanceMode: data.maintenanceMode || false, + maintenanceMode: toDbBool(data.maintenanceMode || false), maintenanceMessage: data.maintenanceMessage || null, maintenanceMessageEs: data.maintenanceMessageEs || null, updatedAt: now, @@ -125,18 +129,24 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit } // Update existing settings - const updateData = { + const updateData: Record = { ...data, updatedAt: now, 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) .update(siteSettings) .set(updateData) .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' }); }); diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 9eb41a7..ce7c0ff 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; 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 { requireAuth, getAuthUser } from '../lib/auth.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'); // Get event - const event = await (db as any).select().from(events).where(eq((events as any).id, data.eventId)).get(); + const event = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, data.eventId)) + ); if (!event) { return c.json({ error: 'Event not found' }, 404); } @@ -57,23 +59,26 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { } // Check capacity - const ticketCount = await (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, data.eventId), - eq((tickets as any).status, 'confirmed') + const ticketCount = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(tickets) + .where( + and( + eq((tickets as any).eventId, data.eventId), + eq((tickets as any).status, 'confirmed') + ) ) - ) - .get(); + ); if ((ticketCount?.count || 0) >= event.capacity) { return c.json({ error: 'Event is sold out' }, 400); } // 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( + (db as any).select().from(users).where(eq((users as any).email, data.email)) + ); const now = getNow(); @@ -98,24 +103,26 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { } // Check for duplicate booking (unless allowDuplicateBookings is enabled) - const globalOptions = await (db as any) - .select() - .from(paymentOptions) - .get(); + const globalOptions = await dbGet( + (db as any) + .select() + .from(paymentOptions) + ); const allowDuplicateBookings = globalOptions?.allowDuplicateBookings ?? false; if (!allowDuplicateBookings) { - const existingTicket = await (db as any) - .select() - .from(tickets) - .where( - and( - eq((tickets as any).userId, user.id), - eq((tickets as any).eventId, data.eventId) + const existingTicket = await dbGet( + (db as any) + .select() + .from(tickets) + .where( + and( + eq((tickets as any).userId, user.id), + eq((tickets as any).eventId, data.eventId) + ) ) - ) - .get(); + ); if (existingTicket && existingTicket.status !== 'cancelled') { 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 ticketsRouter.get('/:id/pdf', async (c) => { 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( + (db as any).select().from(tickets).where(eq((tickets as any).id, id)) + ); if (!ticket) { return c.json({ error: 'Ticket not found' }, 404); @@ -278,7 +287,9 @@ ticketsRouter.get('/:id/pdf', async (c) => { } // Get 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)) + ); if (!event) { return c.json({ error: 'Event not found' }, 404); @@ -316,17 +327,23 @@ ticketsRouter.get('/:id/pdf', async (c) => { ticketsRouter.get('/:id', async (c) => { 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( + (db as any).select().from(tickets).where(eq((tickets as any).id, id)) + ); if (!ticket) { return c.json({ error: 'Ticket not found' }, 404); } // 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 - 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({ ticket: { @@ -342,7 +359,9 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat const id = c.req.param('id'); 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( + (db as any).select().from(tickets).where(eq((tickets as any).id, id)) + ); if (!ticket) { 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)); } - 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 }); }); @@ -376,19 +397,21 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as } // Try to find ticket by QR code or ID - let ticket = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).qrCode, code)) - .get(); + let ticket = await dbGet( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).qrCode, code)) + ); // If not found by QR, try by ID if (!ticket) { - ticket = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).id, code)) - .get(); + ticket = await dbGet( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).id, code)) + ); } if (!ticket) { @@ -409,11 +432,12 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as } // Get event details - 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)) + ); // Determine validity status let validityStatus = 'invalid'; @@ -433,11 +457,12 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as // Get admin who checked in (if applicable) let checkedInBy = null; if (ticket.checkedInByAdminId) { - const admin = await (db as any) - .select() - .from(users) - .where(eq((users as any).id, ticket.checkedInByAdminId)) - .get(); + const admin = await dbGet( + (db as any) + .select() + .from(users) + .where(eq((users as any).id, ticket.checkedInByAdminId)) + ); checkedInBy = admin ? admin.name : null; } @@ -469,7 +494,9 @@ ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']), const id = c.req.param('id'); 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( + (db as any).select().from(tickets).where(eq((tickets as any).id, id)) + ); if (!ticket) { 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)); - 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)) + ); // 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( + (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)) + ); return c.json({ ticket: { @@ -517,7 +548,9 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff'] const id = c.req.param('id'); 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( + (db as any).select().from(tickets).where(eq((tickets as any).id, id)) + ); if (!ticket) { 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)); // Get payment for sending receipt - 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)) + ); // Send confirmation emails asynchronously (don't block the response) Promise.all([ @@ -565,7 +599,9 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff'] 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' }); }); @@ -575,18 +611,21 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff'] ticketsRouter.post('/:id/mark-payment-sent', async (c) => { 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( + (db as any).select().from(tickets).where(eq((tickets as any).id, id)) + ); if (!ticket) { return c.json({ error: 'Ticket not found' }, 404); } // Get the 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)) + ); if (!payment) { 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)); // Get updated payment - const updatedPayment = await (db as any) - .select() - .from(payments) - .where(eq((payments as any).id, payment.id)) - .get(); + const updatedPayment = await dbGet( + (db as any) + .select() + .from(payments) + .where(eq((payments as any).id, payment.id)) + ); // TODO: Send notification to admin about pending payment approval @@ -649,9 +689,11 @@ ticketsRouter.post('/:id/mark-payment-sent', async (c) => { // Cancel ticket ticketsRouter.post('/:id/cancel', async (c) => { 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( + (db as any).select().from(tickets).where(eq((tickets as any).id, id)) + ); if (!ticket) { 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) => { 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( + (db as any).select().from(tickets).where(eq((tickets as any).id, id)) + ); if (!ticket) { 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 }) .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' }); }); @@ -700,7 +746,9 @@ ticketsRouter.post('/:id/note', requireAuth(['admin', 'organizer', 'staff']), zV const id = c.req.param('id'); 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( + (db as any).select().from(tickets).where(eq((tickets as any).id, id)) + ); if (!ticket) { 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 }) .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' }); }); @@ -721,22 +771,25 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) const data = c.req.valid('json'); // Get event - const event = await (db as any).select().from(events).where(eq((events as any).id, data.eventId)).get(); + const event = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, data.eventId)) + ); if (!event) { return c.json({ error: 'Event not found' }, 404); } // Check capacity - const ticketCount = await (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, data.eventId), - sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` + const ticketCount = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(tickets) + .where( + and( + eq((tickets as any).eventId, data.eventId), + sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` + ) ) - ) - .get(); + ); if ((ticketCount?.count || 0) >= event.capacity) { 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`; // 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( + (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) + ); const adminFullName = data.lastName && data.lastName.trim() ? `${data.firstName} ${data.lastName}`.trim() @@ -774,16 +829,17 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) // Check for existing active ticket for this user and event (only if real email provided) if (data.email && data.email.trim() && !data.email.includes('@doorentry.local')) { - const existingTicket = await (db as any) - .select() - .from(tickets) - .where( - and( - eq((tickets as any).userId, user.id), - eq((tickets as any).eventId, data.eventId) + const existingTicket = await dbGet( + (db as any) + .select() + .from(tickets) + .where( + and( + eq((tickets as any).userId, user.id), + eq((tickets as any).eventId, data.eventId) + ) ) - ) - .get(); + ); if (existingTicket && existingTicket.status !== 'cancelled') { 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)); } - const result = await query.all(); + const result = await dbAll(query); return c.json({ tickets: result }); }); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index e2d90fb..7a6c64b 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; 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 { requireAuth } from '../lib/auth.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)); } - 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 }); }); @@ -55,19 +55,20 @@ usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', return c.json({ error: 'Forbidden' }, 403); } - const user = await (db as any) - .select({ - id: (users as any).id, - email: (users as any).email, - name: (users as any).name, - phone: (users as any).phone, - role: (users as any).role, - languagePreference: (users as any).languagePreference, - createdAt: (users as any).createdAt, - }) - .from(users) - .where(eq((users as any).id, id)) - .get(); + const user = await dbGet( + (db as any) + .select({ + id: (users as any).id, + email: (users as any).email, + name: (users as any).name, + phone: (users as any).phone, + role: (users as any).role, + languagePreference: (users as any).languagePreference, + createdAt: (users as any).createdAt, + }) + .from(users) + .where(eq((users as any).id, id)) + ); if (!user) { return c.json({ error: 'User not found' }, 404); @@ -92,7 +93,9 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', 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) { return c.json({ error: 'User not found' }, 404); } @@ -102,18 +105,19 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', .set({ ...data, updatedAt: getNow() }) .where(eq((users as any).id, id)); - const updated = await (db as any) - .select({ - id: (users as any).id, - email: (users as any).email, - name: (users as any).name, - phone: (users as any).phone, - role: (users as any).role, - languagePreference: (users as any).languagePreference, - }) - .from(users) - .where(eq((users as any).id, id)) - .get(); + const updated = await dbGet( + (db as any) + .select({ + id: (users as any).id, + email: (users as any).email, + name: (users as any).name, + phone: (users as any).phone, + role: (users as any).role, + languagePreference: (users as any).languagePreference, + }) + .from(users) + .where(eq((users as any).id, id)) + ); return c.json({ user: updated }); }); @@ -128,21 +132,23 @@ usersRouter.get('/:id/history', requireAuth(['admin', 'organizer', 'staff', 'mar return c.json({ error: 'Forbidden' }, 403); } - const userTickets = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).userId, id)) - .orderBy(desc((tickets as any).createdAt)) - .all(); + const userTickets = await dbAll( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).userId, id)) + .orderBy(desc((tickets as any).createdAt)) + ); // Get event details for each ticket const history = await Promise.all( userTickets.map(async (ticket: any) => { - 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)) + ); return { ...ticket, @@ -164,7 +170,9 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => { 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( + (db as any).select().from(users).where(eq((users as any).id, id)) + ); if (!existing) { return c.json({ error: 'User not found' }, 404); } @@ -176,11 +184,12 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => { try { // Get all tickets for this user - const userTickets = await (db as any) - .select() - .from(tickets) - .where(eq((tickets as any).userId, id)) - .all(); + const userTickets = await dbAll( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).userId, id)) + ); // Delete payments associated with user's tickets for (const ticket of userTickets) { @@ -202,16 +211,18 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => { // Get user statistics (admin) usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => { - const totalUsers = await (db as any) - .select({ count: sql`count(*)` }) - .from(users) - .get(); + const totalUsers = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(users) + ); - const adminCount = await (db as any) - .select({ count: sql`count(*)` }) - .from(users) - .where(eq((users as any).role, 'admin')) - .get(); + const adminCount = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(users) + .where(eq((users as any).role, 'admin')) + ); return c.json({ stats: { diff --git a/frontend/package.json b/frontend/package.json index 9e942c9..16fb5ed 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,10 @@ }, "dependencies": { "@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", "html5-qrcode": "^2.3.8", "next": "^14.2.4", diff --git a/frontend/public/images/carrousel/2026-01-29 13.09.59.jpg b/frontend/public/images/carrousel/2026-01-29 13.09.59.jpg deleted file mode 100644 index a986426..0000000 Binary files a/frontend/public/images/carrousel/2026-01-29 13.09.59.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-01-29 13.10.12.jpg b/frontend/public/images/carrousel/2026-01-29 13.10.12.jpg deleted file mode 100644 index 5ef68d4..0000000 Binary files a/frontend/public/images/carrousel/2026-01-29 13.10.12.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-01-29 13.10.16.jpg b/frontend/public/images/carrousel/2026-01-29 13.10.16.jpg deleted file mode 100644 index 7dbb84f..0000000 Binary files a/frontend/public/images/carrousel/2026-01-29 13.10.16.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-01-29 13.10.20.jpg b/frontend/public/images/carrousel/2026-01-29 13.10.20.jpg deleted file mode 100644 index 7e0563e..0000000 Binary files a/frontend/public/images/carrousel/2026-01-29 13.10.20.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-01 02.53.00.jpg b/frontend/public/images/carrousel/2026-02-01 02.53.00.jpg deleted file mode 100644 index e0c8e7d..0000000 Binary files a/frontend/public/images/carrousel/2026-02-01 02.53.00.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-01 02.53.30.jpg b/frontend/public/images/carrousel/2026-02-01 02.53.30.jpg deleted file mode 100644 index 5eda3f7..0000000 Binary files a/frontend/public/images/carrousel/2026-02-01 02.53.30.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-01 02.53.36.jpg b/frontend/public/images/carrousel/2026-02-01 02.53.36.jpg deleted file mode 100644 index eca6c29..0000000 Binary files a/frontend/public/images/carrousel/2026-02-01 02.53.36.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-01 02.53.41.jpg b/frontend/public/images/carrousel/2026-02-01 02.53.41.jpg deleted file mode 100644 index 6645467..0000000 Binary files a/frontend/public/images/carrousel/2026-02-01 02.53.41.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-01 02.53.45.jpg b/frontend/public/images/carrousel/2026-02-01 02.53.45.jpg deleted file mode 100644 index 573dcc6..0000000 Binary files a/frontend/public/images/carrousel/2026-02-01 02.53.45.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-01 02.53.48.jpg b/frontend/public/images/carrousel/2026-02-01 02.53.48.jpg deleted file mode 100644 index bd555df..0000000 Binary files a/frontend/public/images/carrousel/2026-02-01 02.53.48.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.33.56.jpg b/frontend/public/images/carrousel/2026-02-02 00.33.56.jpg new file mode 100644 index 0000000..94d4c43 Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 00.33.56.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.12.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.12.jpg new file mode 100644 index 0000000..f2ae9ba Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 00.34.12.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.15.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.15.jpg new file mode 100644 index 0000000..7df1aff Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 00.34.15.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.18.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.18.jpg new file mode 100644 index 0000000..a57b697 Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 00.34.18.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.21.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.21.jpg new file mode 100644 index 0000000..567f703 Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 00.34.21.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.23.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.23.jpg new file mode 100644 index 0000000..58433c2 Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 00.34.23.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.26.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.26.jpg new file mode 100644 index 0000000..34383a0 Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 00.34.26.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.28.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.28.jpg new file mode 100644 index 0000000..1117acd Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 00.34.28.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.31.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.31.jpg new file mode 100644 index 0000000..66c721f Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 00.34.31.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.33.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.33.jpg new file mode 100644 index 0000000..aa29cc9 Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 00.34.33.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.38.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.38.jpg new file mode 100644 index 0000000..7f3ced3 Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 00.34.38.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.40.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.40.jpg new file mode 100644 index 0000000..f5f43ef Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 00.34.40.jpg differ diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index e66d20a..dbe19ae 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -6,6 +6,7 @@ import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api'; +import { formatPrice } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; @@ -620,7 +621,7 @@ export default function BookingPage() { {locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}

- {event?.price?.toLocaleString()} {event?.currency} + {event?.price !== undefined ? formatPrice(event.price, event.currency) : ''}

@@ -924,7 +925,7 @@ export default function BookingPage() { {event.price === 0 ? t('events.details.free') - : `${event.price.toLocaleString()} ${event.currency}`} + : formatPrice(event.price, event.currency)} diff --git a/frontend/src/app/(public)/booking/[ticketId]/page.tsx b/frontend/src/app/(public)/booking/[ticketId]/page.tsx index b25bfcd..3e5b291 100644 --- a/frontend/src/app/(public)/booking/[ticketId]/page.tsx +++ b/frontend/src/app/(public)/booking/[ticketId]/page.tsx @@ -5,6 +5,7 @@ import { useParams, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api'; +import { formatPrice } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { @@ -341,7 +342,7 @@ export default function BookingPaymentPage() {
- {ticket.event.price?.toLocaleString()} {ticket.event.currency} + {ticket.event.price !== undefined ? formatPrice(ticket.event.price, ticket.event.currency) : ''}
@@ -374,7 +375,7 @@ export default function BookingPaymentPage() { {locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}

- {ticket.event?.price?.toLocaleString()} {ticket.event?.currency} + {ticket.event?.price !== undefined ? formatPrice(ticket.event.price, ticket.event.currency) : ''}

diff --git a/frontend/src/app/(public)/components/NextEventSection.tsx b/frontend/src/app/(public)/components/NextEventSection.tsx index c24989c..d306795 100644 --- a/frontend/src/app/(public)/components/NextEventSection.tsx +++ b/frontend/src/app/(public)/components/NextEventSection.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; import { eventsApi, Event } from '@/lib/api'; +import { formatPrice } from '@/lib/utils'; import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline'; @@ -91,7 +92,7 @@ export default function NextEventSection() { {nextEvent.price === 0 ? t('events.details.free') - : `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`} + : formatPrice(nextEvent.price, nextEvent.currency)} {!nextEvent.externalBookingEnabled && (

diff --git a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx index 9a6e7b5..55d8a7c 100644 --- a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx +++ b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import Image from 'next/image'; import { useLanguage } from '@/context/LanguageContext'; import { eventsApi, Event } from '@/lib/api'; +import { formatPrice } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import ShareButtons from '@/components/ShareButtons'; @@ -186,7 +187,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail

{event.price === 0 ? t('events.details.free') - : `${event.price.toLocaleString()} ${event.currency}`} + : formatPrice(event.price, event.currency)}

diff --git a/frontend/src/app/(public)/events/page.tsx b/frontend/src/app/(public)/events/page.tsx index 4f7910e..d70d3ba 100644 --- a/frontend/src/app/(public)/events/page.tsx +++ b/frontend/src/app/(public)/events/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; import { eventsApi, Event } from '@/lib/api'; +import { formatPrice } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline'; @@ -149,7 +150,7 @@ export default function EventsPage() { {event.price === 0 ? t('events.details.free') - : `${event.price.toLocaleString()} ${event.currency}`} + : formatPrice(event.price, event.currency)} +
+

+ {locale === 'es' ? 'Editar Página Legal' : 'Edit Legal Page'} +

+

+ {editingPage.slug} +

+
+ +
+ + +
+ + + +
+ {/* Language tabs and View mode toggle */} +
+ {/* Language tabs */} +
+ + +
+ + {/* View mode toggle */} +
+ + + +
+
+ + {/* Title for current language */} +
+ + setCurrentTitle(e.target.value)} + placeholder={editLanguage === 'en' ? 'Title' : 'Título'} + /> +
+ + {/* Content editor and preview */} +
+ + + {viewMode === 'edit' && ( + <> +

+ {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.' + } +

+ + + )} + + {viewMode === 'preview' && ( + <> +

+ {locale === 'es' + ? 'Así se verá el contenido en la página pública.' + : 'This is how the content will look on the public page.' + } +

+
+
+ {locale === 'es' ? 'Vista previa' : 'Preview'} +
+ +
+ + )} + + {viewMode === 'split' && ( +
+
+

+ {locale === 'es' ? 'Editor' : 'Editor'} +

+ +
+
+

+ {locale === 'es' ? 'Vista previa' : 'Preview'} +

+
+ +
+
+
+ )} +
+ + {/* Info */} +
+

+ {locale === 'es' ? 'Nota:' : 'Note:'} +

+
    +
  • + {locale === 'es' + ? 'El slug (URL) no se puede cambiar: ' + : 'The slug (URL) cannot be changed: ' + } + /legal/{editingPage.slug} +
  • +
  • + {locale === 'es' + ? 'Usa la barra de herramientas para encabezados, listas, negritas y cursivas.' + : 'Use the toolbar for headings, lists, bold, and italics.' + } +
  • +
  • + {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.' + } +
  • +
+
+
+
+ + ); + } + + // List view + return ( +
+
+
+

+ {locale === 'es' ? 'Páginas Legales' : 'Legal Pages'} +

+

+ {locale === 'es' + ? 'Administra el contenido de las páginas legales del sitio.' + : 'Manage the content of the site\'s legal pages.' + } +

+
+ {pages.length === 0 && ( + + )} +
+ + {pages.length === 0 ? ( + +
+ +

+ {locale === 'es' ? 'No hay páginas legales' : 'No legal pages found'} +

+

+ {locale === 'es' + ? 'Haz clic en "Importar desde archivos" para cargar las páginas legales existentes.' + : 'Click "Import from files" to load existing legal pages.' + } +

+ +
+
+ ) : ( + +
+ + + + + + + + + + + + {pages.map((page) => ( + + + + + + + + ))} + +
+ {locale === 'es' ? 'Página' : 'Page'} + + Slug + + {locale === 'es' ? 'Idiomas' : 'Languages'} + + {locale === 'es' ? 'Última actualización' : 'Last Updated'} + + {locale === 'es' ? 'Acciones' : 'Actions'} +
+
+

{page.title}

+ {page.titleEs && ( +

{page.titleEs}

+ )} +
+
+ + {page.slug} + + +
+ + EN {page.hasEnglish ? '✓' : '—'} + + + ES {page.hasSpanish ? '✓' : '—'} + +
+
+ {formatDate(page.updatedAt)} + + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 8a0b09a..ad88586 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -99,3 +99,57 @@ 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; +} diff --git a/frontend/src/app/linktree/page.tsx b/frontend/src/app/linktree/page.tsx index c87a0cd..e75daf2 100644 --- a/frontend/src/app/linktree/page.tsx +++ b/frontend/src/app/linktree/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; import { eventsApi, Event } from '@/lib/api'; +import { formatPrice } from '@/lib/utils'; import { CalendarIcon, MapPinIcon, @@ -100,7 +101,7 @@ export default function LinktreePage() { {nextEvent.price === 0 ? t('events.details.free') - : `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`} + : formatPrice(nextEvent.price, nextEvent.currency)} {!nextEvent.externalBookingEnabled && ( diff --git a/frontend/src/components/ui/RichTextEditor.tsx b/frontend/src/components/ui/RichTextEditor.tsx new file mode 100644 index 0000000..5a98611 --- /dev/null +++ b/frontend/src/components/ui/RichTextEditor.tsx @@ -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 '

'; + + let html = markdown; + + // Convert horizontal rules first (before other processing) + html = html.replace(/^---+$/gm, '
'); + + // Convert headings (must be done before other inline formatting) + html = html.replace(/^### (.+)$/gm, '

$1

'); + html = html.replace(/^## (.+)$/gm, '

$1

'); + html = html.replace(/^# (.+)$/gm, '

$1

'); + + // Convert bold and italic + html = html.replace(/\*\*(.+?)\*\*/g, '$1'); + html = html.replace(/\*(.+?)\*/g, '$1'); + html = html.replace(/__(.+?)__/g, '$1'); + html = html.replace(/_(.+?)_/g, '$1'); + + // 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' ? '' : ''); + processedLines.push('
    '); + inList = true; + listType = 'ul'; + } + processedLines.push(`
  • ${bulletMatch[1]}
  • `); + } else if (numberedMatch) { + if (!inList || listType !== 'ol') { + if (inList) processedLines.push(listType === 'ul' ? '
' : ''); + processedLines.push('
    '); + inList = true; + listType = 'ol'; + } + processedLines.push(`
  1. ${numberedMatch[1]}
  2. `); + } else { + if (inList) { + processedLines.push(listType === 'ul' ? '' : '
'); + inList = false; + listType = ''; + } + processedLines.push(line); + } + } + if (inList) { + processedLines.push(listType === 'ul' ? '' : ''); + } + + html = processedLines.join('\n'); + + // Convert blockquotes + html = html.replace(/^>\s*(.+)$/gm, '

$1

'); + + // 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(`

${paragraph.join('
')}

`); + paragraph = []; + } + } else if (trimmed.startsWith(' 0) { + result.push(`

${paragraph.join('
')}

`); + paragraph = []; + } + result.push(trimmed); + } else { + // Regular text - add to paragraph + paragraph.push(trimmed); + } + } + if (paragraph.length > 0) { + result.push(`

${paragraph.join('
')}

`); + } + + return result.join('') || '

'; +} + +// Convert HTML from TipTap back to markdown +function htmlToMarkdown(html: string): string { + if (!html) return ''; + + let md = html; + + // Convert headings + md = md.replace(/]*>(.*?)<\/h1>/gi, '# $1\n\n'); + md = md.replace(/]*>(.*?)<\/h2>/gi, '## $1\n\n'); + md = md.replace(/]*>(.*?)<\/h3>/gi, '### $1\n\n'); + + // Convert bold and italic + md = md.replace(/]*>(.*?)<\/strong>/gi, '**$1**'); + md = md.replace(/]*>(.*?)<\/b>/gi, '**$1**'); + md = md.replace(/]*>(.*?)<\/em>/gi, '*$1*'); + md = md.replace(/]*>(.*?)<\/i>/gi, '*$1*'); + + // Convert lists + md = md.replace(/]*>/gi, '\n'); + md = md.replace(/<\/ul>/gi, '\n'); + md = md.replace(/]*>/gi, '\n'); + md = md.replace(/<\/ol>/gi, '\n'); + md = md.replace(/]*>(.*?)<\/li>/gi, '* $1\n'); + + // Convert blockquotes + md = md.replace(/]*>]*>(.*?)<\/p><\/blockquote>/gi, '> $1\n\n'); + md = md.replace(/]*>(.*?)<\/blockquote>/gi, '> $1\n\n'); + + // Convert horizontal rules + md = md.replace(/]*\/?>/gi, '\n---\n\n'); + + // Convert paragraphs + md = md.replace(/]*>(.*?)<\/p>/gi, '$1\n\n'); + + // Convert line breaks + md = md.replace(/]*\/?>/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 ( + + ); +} + +// Toolbar component +function Toolbar({ editor }: { editor: Editor | null }) { + if (!editor) return null; + + return ( +
+ {/* Text formatting */} + editor.chain().focus().toggleBold().run()} + isActive={editor.isActive('bold')} + title="Bold (Ctrl+B)" + > + + + + + + + editor.chain().focus().toggleItalic().run()} + isActive={editor.isActive('italic')} + title="Italic (Ctrl+I)" + > + + + + + +
+ + {/* Headings */} + editor.chain().focus().toggleHeading({ level: 1 }).run()} + isActive={editor.isActive('heading', { level: 1 })} + title="Heading 1" + > + H1 + + + editor.chain().focus().toggleHeading({ level: 2 }).run()} + isActive={editor.isActive('heading', { level: 2 })} + title="Heading 2" + > + H2 + + + editor.chain().focus().toggleHeading({ level: 3 }).run()} + isActive={editor.isActive('heading', { level: 3 })} + title="Heading 3" + > + H3 + + +
+ + {/* Lists */} + editor.chain().focus().toggleBulletList().run()} + isActive={editor.isActive('bulletList')} + title="Bullet List" + > + + + + + + + + + editor.chain().focus().toggleOrderedList().run()} + isActive={editor.isActive('orderedList')} + title="Numbered List" + > + + + 1 + 2 + 3 + + + +
+ + {/* Block elements */} + editor.chain().focus().toggleBlockquote().run()} + isActive={editor.isActive('blockquote')} + title="Quote" + > + + + + + + editor.chain().focus().setHorizontalRule().run()} + title="Horizontal Rule" + > + + + + + +
+ + {/* Undo/Redo */} + editor.chain().focus().undo().run()} + disabled={!editor.can().undo()} + title="Undo (Ctrl+Z)" + > + + + + + + editor.chain().focus().redo().run()} + disabled={!editor.can().redo()} + title="Redo (Ctrl+Shift+Z)" + > + + + + +
+ ); +} + +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 ( +
+ {editable && } + +
+ ); +} + +// 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 ( +
+ +
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 89ab2df..e74aa80 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -967,3 +967,74 @@ export const siteSettingsApi = { getTimezones: () => 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', + }), +}; diff --git a/frontend/src/lib/legal.ts b/frontend/src/lib/legal.ts index a0c4631..c4b54b1 100644 --- a/frontend/src/lib/legal.ts +++ b/frontend/src/lib/legal.ts @@ -16,8 +16,11 @@ export interface LegalPageMeta { // Map file names to display titles const titleMap: Record = { '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' }, '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 @@ -70,8 +73,8 @@ export function getAllLegalPagesMeta(locale: string = 'en'): LegalPageMeta[] { }); } -// Get a specific legal page content -export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | null { +// Get a specific legal page content from filesystem (fallback) +export function getLegalPageFromFilesystem(slug: string, locale: string = 'en'): LegalPage | null { const legalDir = getLegalDir(); const fileName = slugToFileName(slug); 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 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, ' '); // Try to extract last updated date from content @@ -96,3 +99,43 @@ export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | n lastUpdated, }; } + +// Get a specific legal page content - tries API first, falls back to filesystem +export async function getLegalPageAsync(slug: string, locale: string = 'en'): Promise { + 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); +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..7e21523 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -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); +}