Add PostgreSQL support with SQLite/Postgres database compatibility layer

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

View File

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