- 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
394 lines
11 KiB
TypeScript
394 lines
11 KiB
TypeScript
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;
|