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