Add PostgreSQL support with SQLite/Postgres database compatibility layer

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

View File

@@ -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<any>(
(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<JWTPayload | null> {
}
}
export async function getAuthUser(c: Context) {
export async function getAuthUser(c: Context): Promise<any | null> {
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<any>(
(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<boolean> {
const result = await (db as any).select().from(users).limit(1).all();
const result = await dbAll(
(db as any).select().from(users).limit(1)
);
return !result || result.length === 0;
}

View File

@@ -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<any | null> {
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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<Record<string, any>> {
// Get global options
const globalOptions = await (db as any)
.select()
.from(paymentOptions)
.get();
const globalOptions = await dbGet<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(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({

View File

@@ -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<T extends Record<string, any>>(obj: T): T {
if (getDbType() !== 'postgres') {
return obj; // SQLite handles booleans automatically
}
const result = { ...obj };
for (const key in result) {
if (typeof result[key] === 'boolean') {
(result as any)[key] = result[key] ? 1 : 0;
}
}
return result;
}
export function formatCurrency(amount: number, currency: string = 'PYG'): string {