/** * SEO API Routes - Content generation and management for CannaiQ marketing pages * * All content returned by these endpoints is sanitized to ensure * enterprise-safe phrasing with no forbidden terminology. */ import { Router, Request, Response } from 'express'; import { getPool } from '../db/pool'; import { authMiddleware } from '../auth/middleware'; import { ContentValidator } from '../utils/ContentValidator'; import { generateSeoPageWithClaude } from '../services/seoGenerator'; import { getAllSettings, setSetting, setMultipleSettings, resetToDefaults, ensureSettingsExist, DEFAULT_SETTINGS, } from '../seo/settings'; import { applyTemplateVariables, getTemplateForPageType, generatePreview, generatePageContent, regenerateContent, getAllTemplates, validateTemplate, MOCK_DATA, PageType, } from '../seo/template-engine'; const router = Router(); /** * GET /api/seo/page - Get SEO page content by slug (public, sanitized) */ router.get('/page', async (req: Request, res: Response) => { try { const { slug } = req.query; if (!slug || typeof slug !== 'string') { return res.status(400).json({ error: 'slug query parameter required' }); } const pool = getPool(); const result = await pool.query( `SELECT id, slug, type, meta_title, meta_description, status, updated_at FROM seo_pages WHERE slug = $1 AND status = 'live'`, [slug] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Page not found' }); } const page = result.rows[0]; // Always sanitize content before returning (safety net) const content = { metaTitle: page.meta_title, metaDescription: page.meta_description, }; const sanitizedContent = ContentValidator.sanitizeContent(content); res.json({ id: page.id, slug: page.slug, type: page.type, content: sanitizedContent, updatedAt: page.updated_at, }); } catch (error: any) { console.error('[SEO] Error fetching page:', error.message); res.status(500).json({ error: 'Failed to fetch SEO page' }); } }); /** * POST /api/seo/page - Create/update SEO page (admin, auto-sanitizes) */ router.post('/page', authMiddleware, async (req: Request, res: Response) => { try { const { slug, type, metaTitle, metaDescription, status = 'draft' } = req.body; if (!slug || !type) { return res.status(400).json({ error: 'slug and type required' }); } // Validate and sanitize content const content = { metaTitle, metaDescription }; const validation = ContentValidator.validate(content); if (!validation.valid) { console.warn(`[SEO] Forbidden terms sanitized for ${slug}:`, validation.forbiddenTerms); } const sanitized = validation.sanitized as { metaTitle?: string; metaDescription?: string }; const pool = getPool(); // Always store sanitized content const result = await pool.query( `INSERT INTO seo_pages (slug, type, page_key, meta_title, meta_description, status, updated_at) VALUES ($1, $2, $3, $4, $5, $6, NOW()) ON CONFLICT (slug) DO UPDATE SET type = EXCLUDED.type, meta_title = EXCLUDED.meta_title, meta_description = EXCLUDED.meta_description, status = EXCLUDED.status, updated_at = NOW() RETURNING id, slug, type, status, updated_at`, [slug, type, slug, sanitized.metaTitle || null, sanitized.metaDescription || null, status] ); res.json({ success: true, page: result.rows[0], sanitized: !validation.valid, forbiddenTermsRemoved: validation.forbiddenTerms, }); } catch (error: any) { console.error('[SEO] Error saving page:', error.message); res.status(500).json({ error: 'Failed to save SEO page' }); } }); /** * POST /api/seo/validate - Validate content for forbidden terms */ router.post('/validate', async (req: Request, res: Response) => { try { const { content } = req.body; if (!content) { return res.status(400).json({ error: 'content is required' }); } const validation = ContentValidator.validate(content); res.json({ valid: validation.valid, forbiddenTerms: validation.forbiddenTerms, sanitized: validation.sanitized, approvedPhrases: ContentValidator.getApprovedPhrases(), }); } catch (error: any) { res.status(500).json({ error: 'Failed to validate content' }); } }); /** * GET /api/seo/pages - List all SEO pages with optional filters */ router.get('/pages', authMiddleware, async (req: Request, res: Response) => { try { const { type, search } = req.query; const pool = getPool(); let query = ` SELECT p.id, p.type, p.slug, p.page_key, p.primary_keyword, p.status, p.last_generated_at, p.last_reviewed_at, p.created_at, p.updated_at FROM seo_pages p WHERE 1=1 `; const params: any[] = []; if (type && typeof type === 'string') { params.push(type); query += ` AND p.type = $${params.length}`; } if (search && typeof search === 'string') { params.push(`%${search}%`); query += ` AND (p.slug ILIKE $${params.length} OR p.page_key ILIKE $${params.length} OR p.primary_keyword ILIKE $${params.length})`; } query += ` ORDER BY p.type, p.slug`; const result = await pool.query(query, params); // Get metrics for state pages const pages = await Promise.all(result.rows.map(async (page) => { let metrics = null; if (page.type === 'state') { const stateCode = page.slug.replace('dispensaries-', '').toUpperCase(); const metricsResult = await pool.query(` SELECT COUNT(DISTINCT d.id) as dispensary_count, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT p.brand_name_raw) as brand_count FROM dispensaries d LEFT JOIN store_products p ON p.dispensary_id = d.id WHERE d.state = $1 AND d.menu_type = 'dutchie' AND d.platform_dispensary_id IS NOT NULL `, [stateCode]); const m = metricsResult.rows[0]; metrics = { dispensaryCount: parseInt(m.dispensary_count, 10) || 0, productCount: parseInt(m.product_count, 10) || 0, brandCount: parseInt(m.brand_count, 10) || 0, }; } return { id: page.id, type: page.type, slug: page.slug, pageKey: page.page_key, primaryKeyword: page.primary_keyword, status: page.status, lastGeneratedAt: page.last_generated_at, lastReviewedAt: page.last_reviewed_at, metrics, }; })); res.json({ pages }); } catch (error: any) { console.error('[SEO] Error listing pages:', error.message); res.status(500).json({ error: 'Failed to list SEO pages' }); } }); /** * POST /api/seo/sync-state-pages - Create SEO pages for all states with dispensaries */ router.post('/sync-state-pages', authMiddleware, async (req: Request, res: Response) => { try { const pool = getPool(); // Get all states that have active/crawlable dispensaries const statesResult = await pool.query(` SELECT DISTINCT state, COUNT(*) as dispensary_count FROM dispensaries WHERE state IS NOT NULL AND state != '' AND menu_type = 'dutchie' AND platform_dispensary_id IS NOT NULL GROUP BY state HAVING COUNT(*) > 0 ORDER BY state `); const states = statesResult.rows; let created = 0; let updated = 0; for (const { state, dispensary_count } of states) { const slug = `dispensaries-${state.toLowerCase()}`; const pageKey = `state-${state.toLowerCase()}`; const primaryKeyword = `${state} dispensaries`; const result = await pool.query(` INSERT INTO seo_pages (type, slug, page_key, primary_keyword, status, created_at, updated_at) VALUES ('state', $1, $2, $3, 'pending_generation', NOW(), NOW()) ON CONFLICT (slug) DO UPDATE SET updated_at = NOW() RETURNING (xmax = 0) as is_new `, [slug, pageKey, primaryKeyword]); if (result.rows[0]?.is_new) { created++; } else { updated++; } } res.json({ message: `Synced ${states.length} state pages`, created, updated, states: states.map(s => s.state), }); } catch (error: any) { console.error('[SEO] Error syncing state pages:', error.message); res.status(500).json({ error: 'Failed to sync state pages' }); } }); /** * GET /api/seo/state-metrics - Get all state metrics for SEO dashboard */ router.get('/state-metrics', authMiddleware, async (req: Request, res: Response) => { try { const pool = getPool(); const result = await pool.query(` SELECT d.state as state_code, COALESCE(s.name, d.state) as state_name, COUNT(DISTINCT d.id) as dispensary_count, COUNT(DISTINCT sp.id) as product_count, COUNT(DISTINCT sp.brand_name_raw) FILTER (WHERE sp.brand_name_raw IS NOT NULL) as brand_count FROM dispensaries d LEFT JOIN states s ON d.state = s.code LEFT JOIN store_products sp ON sp.dispensary_id = d.id WHERE d.state IS NOT NULL AND d.state != '' AND d.menu_type = 'dutchie' AND d.platform_dispensary_id IS NOT NULL GROUP BY d.state, s.name ORDER BY dispensary_count DESC `); const states = result.rows.map(row => ({ stateCode: row.state_code, stateName: row.state_name || row.state_code, dispensaryCount: parseInt(row.dispensary_count, 10) || 0, productCount: parseInt(row.product_count, 10) || 0, brandCount: parseInt(row.brand_count, 10) || 0, })); res.json({ states }); } catch (error: any) { console.error('[SEO] Error fetching state metrics:', error.message); res.status(500).json({ error: 'Failed to fetch state metrics' }); } }); /** * GET /api/seo/state/:stateCode - State SEO data with metrics */ router.get('/state/:stateCode', async (req: Request, res: Response) => { try { const { stateCode } = req.params; const code = stateCode.toUpperCase(); const pool = getPool(); const metricsResult = await pool.query(` SELECT COUNT(DISTINCT d.id) as dispensary_count, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT p.brand_name_raw) as brand_count FROM dispensaries d LEFT JOIN store_products p ON p.dispensary_id = d.id WHERE d.state = $1 AND d.menu_type = 'dutchie' AND d.platform_dispensary_id IS NOT NULL`, [code]); const brandsResult = await pool.query(` SELECT brand_name_raw as brand_name, COUNT(*) as product_count FROM store_products p JOIN dispensaries d ON p.dispensary_id = d.id WHERE d.state = $1 AND p.brand_name_raw IS NOT NULL AND d.menu_type = 'dutchie' AND d.platform_dispensary_id IS NOT NULL GROUP BY brand_name_raw ORDER BY product_count DESC LIMIT 10`, [code]); const metrics = metricsResult.rows[0]; const response = ContentValidator.sanitizeContent({ stateCode: code, metrics: { dispensaryCount: parseInt(metrics.dispensary_count, 10) || 0, productCount: parseInt(metrics.product_count, 10) || 0, brandCount: parseInt(metrics.brand_count, 10) || 0, }, topBrands: brandsResult.rows.map(r => ({ name: r.brand_name, productCount: parseInt(r.product_count, 10), })), }); res.json(response); } catch (error: any) { console.error('[SEO] Error fetching state data:', error.message); res.status(500).json({ error: 'Failed to fetch state SEO data' }); } }); /** * POST /api/seo/pages/:id/generate - Generate SEO content for a page */ router.post('/pages/:id/generate', authMiddleware, async (req: Request, res: Response) => { try { const { id } = req.params; const pageId = parseInt(id, 10); if (isNaN(pageId)) { return res.status(400).json({ error: 'Invalid page ID' }); } const content = await generateSeoPageWithClaude(pageId); res.json({ success: true, content }); } catch (error: any) { console.error('[SEO] Error generating page:', error.message); res.status(500).json({ error: error.message || 'Failed to generate SEO content' }); } }); /** * GET /api/seo/public/content - Get full SEO page content by slug (public) */ router.get('/public/content', async (req: Request, res: Response) => { try { const { slug } = req.query; if (!slug || typeof slug !== 'string') { return res.status(400).json({ error: 'slug query parameter required' }); } const pool = getPool(); // Find page and content const result = await pool.query(` SELECT p.id, p.slug, p.type, p.status, c.blocks, c.meta_title, c.meta_description, c.h1, c.canonical_url, c.og_title, c.og_description, c.og_image_url FROM seo_pages p LEFT JOIN seo_page_contents c ON c.page_id = p.id WHERE p.slug = $1 AND p.status = 'live' `, [slug]); if (result.rows.length === 0) { return res.status(404).json({ error: 'Page not found or not published' }); } const row = result.rows[0]; const sanitized = ContentValidator.sanitizeContent({ meta: { title: row.meta_title, description: row.meta_description, h1: row.h1, canonicalUrl: row.canonical_url, ogTitle: row.og_title, ogDescription: row.og_description, ogImageUrl: row.og_image_url }, blocks: row.blocks || [] }); res.json({ slug: row.slug, type: row.type, ...sanitized }); } catch (error: any) { console.error('[SEO] Error fetching public content:', error.message); res.status(500).json({ error: 'Failed to fetch SEO content' }); } }); // ============================================================================ // SEO Settings Endpoints // ============================================================================ /** * GET /api/seo/settings - Get all SEO settings */ router.get('/settings', authMiddleware, async (req: Request, res: Response) => { try { // Ensure settings exist on first access await ensureSettingsExist(); const settings = await getAllSettings(); res.json({ settings }); } catch (error: any) { console.error('[SEO] Error fetching settings:', error.message); res.status(500).json({ error: 'Failed to fetch SEO settings' }); } }); /** * POST /api/seo/settings - Save a single setting */ router.post('/settings', authMiddleware, async (req: Request, res: Response) => { try { const { key, value } = req.body; if (!key || typeof key !== 'string') { return res.status(400).json({ error: 'key is required' }); } if (value === undefined) { return res.status(400).json({ error: 'value is required' }); } await setSetting(key, value); res.json({ success: true, key, value }); } catch (error: any) { console.error('[SEO] Error saving setting:', error.message); res.status(500).json({ error: 'Failed to save SEO setting' }); } }); /** * POST /api/seo/settings/bulk - Save multiple settings at once */ router.post('/settings/bulk', authMiddleware, async (req: Request, res: Response) => { try { const { settings } = req.body; if (!settings || typeof settings !== 'object') { return res.status(400).json({ error: 'settings object is required' }); } await setMultipleSettings(settings); res.json({ success: true, count: Object.keys(settings).length }); } catch (error: any) { console.error('[SEO] Error saving bulk settings:', error.message); res.status(500).json({ error: 'Failed to save SEO settings' }); } }); /** * POST /api/seo/settings/reset - Reset all settings to defaults */ router.post('/settings/reset', authMiddleware, async (req: Request, res: Response) => { try { const settings = await resetToDefaults(); res.json({ success: true, message: 'Settings reset to defaults', settings, }); } catch (error: any) { console.error('[SEO] Error resetting settings:', error.message); res.status(500).json({ error: 'Failed to reset SEO settings' }); } }); /** * GET /api/seo/settings/defaults - Get default settings (without modifying DB) */ router.get('/settings/defaults', authMiddleware, async (req: Request, res: Response) => { res.json({ settings: DEFAULT_SETTINGS }); }); /** * GET /api/seo/settings/preview - Preview merged prompt with sample variables */ router.post('/settings/preview', authMiddleware, async (req: Request, res: Response) => { try { const { template, variables } = req.body; if (!template || typeof template !== 'string') { return res.status(400).json({ error: 'template is required' }); } // Sample variables for preview const sampleVariables: Record = { page_type: 'state', subject: 'Arizona Dispensaries', focus_areas: 'local stores, product variety, pricing', tone: 'informational', length: 'medium', state_name: 'Arizona', state_code: 'AZ', state_code_lower: 'az', dispensary_count: '150', improvement_areas: 'SEO keywords, local relevance', ...variables, }; let preview = template; for (const [key, value] of Object.entries(sampleVariables)) { preview = preview.replace(new RegExp(`{{${key}}}`, 'g'), value); } res.json({ preview, variables: sampleVariables }); } catch (error: any) { console.error('[SEO] Error generating preview:', error.message); res.status(500).json({ error: 'Failed to generate preview' }); } }); // ============================================================================ // Template Library Endpoints // ============================================================================ /** * GET /api/seo/templates - Get all templates with metadata */ router.get('/templates', authMiddleware, async (req: Request, res: Response) => { try { const templates = await getAllTemplates(); res.json({ templates }); } catch (error: any) { console.error('[SEO] Error fetching templates:', error.message); res.status(500).json({ error: 'Failed to fetch templates' }); } }); /** * POST /api/seo/templates/preview - Preview a template with mock data by page type */ router.post('/templates/preview', authMiddleware, async (req: Request, res: Response) => { try { const { pageType, customTemplate } = req.body; if (!pageType || typeof pageType !== 'string') { return res.status(400).json({ error: 'pageType is required' }); } const result = await generatePreview(pageType, customTemplate); res.json(result); } catch (error: any) { console.error('[SEO] Error generating template preview:', error.message); res.status(500).json({ error: 'Failed to generate template preview' }); } }); /** * POST /api/seo/templates/validate - Validate a template string */ router.post('/templates/validate', authMiddleware, async (req: Request, res: Response) => { try { const { template } = req.body; if (!template || typeof template !== 'string') { return res.status(400).json({ error: 'template is required' }); } const validation = validateTemplate(template); res.json(validation); } catch (error: any) { console.error('[SEO] Error validating template:', error.message); res.status(500).json({ error: 'Failed to validate template' }); } }); /** * POST /api/seo/templates/generate - Generate content using a template */ router.post('/templates/generate', authMiddleware, async (req: Request, res: Response) => { try { const { pageType, data } = req.body; if (!pageType || typeof pageType !== 'string') { return res.status(400).json({ error: 'pageType is required' }); } if (!data || typeof data !== 'object') { return res.status(400).json({ error: 'data object is required' }); } const result = await generatePageContent(pageType, data); res.json(result); } catch (error: any) { console.error('[SEO] Error generating from template:', error.message); res.status(500).json({ error: 'Failed to generate content from template' }); } }); /** * POST /api/seo/templates/regenerate - Regenerate content with improvements */ router.post('/templates/regenerate', authMiddleware, async (req: Request, res: Response) => { try { const { pageType, originalContent, newData, improvementAreas } = req.body; if (!pageType || typeof pageType !== 'string') { return res.status(400).json({ error: 'pageType is required' }); } if (!originalContent || typeof originalContent !== 'string') { return res.status(400).json({ error: 'originalContent is required' }); } const result = await regenerateContent( pageType, originalContent, newData || {}, improvementAreas ); res.json(result); } catch (error: any) { console.error('[SEO] Error regenerating content:', error.message); res.status(500).json({ error: 'Failed to regenerate content' }); } }); /** * GET /api/seo/templates/variables/:pageType - Get available variables for a page type */ router.get('/templates/variables/:pageType', authMiddleware, async (req: Request, res: Response) => { try { const { pageType } = req.params; const normalizedType = (pageType?.toLowerCase().trim() || 'state') as PageType; const mockData = MOCK_DATA[normalizedType] || MOCK_DATA.state; res.json({ pageType: normalizedType, variables: Object.keys(mockData), sampleValues: mockData, }); } catch (error: any) { console.error('[SEO] Error fetching template variables:', error.message); res.status(500).json({ error: 'Failed to fetch template variables' }); } }); export default router;