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 { replaceLegalPlaceholders } from '../lib/legal-placeholders.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); // Replace legal placeholders before returning const processedContent = await replaceLegalPlaceholders(contentMarkdown, page.updatedAt); return c.json({ page: { id: page.id, slug: page.slug, title, contentMarkdown: processedContent, 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); // Replace legal placeholders in filesystem content too const processedContent = await replaceLegalPlaceholders(content); return c.json({ page: { slug, title, contentMarkdown: processedContent, 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;