Files
Spanglish/backend/src/routes/legal-pages.ts
Michilis b9f46b02cc Email queue + async sending; legal settings and placeholders
- Add in-memory email queue with rate limiting (MAX_EMAILS_PER_HOUR)
- Bulk send to event attendees now queues and returns immediately
- Frontend shows 'Emails are being sent in the background'
- Legal pages, settings, and placeholders updates

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 21:03:49 +00:00

401 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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);
// 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<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;