Files
cannaiq/backend/src/routes/seo.ts
Kelly 2f483b3084 feat: SEO template library, discovery pipeline, and orchestrator enhancements
## SEO Template Library
- Add complete template library with 7 page types (state, city, category, brand, product, search, regeneration)
- Add Template Library tab in SEO Orchestrator with accordion-based editors
- Add template preview, validation, and variable injection engine
- Add API endpoints: /api/seo/templates, preview, validate, generate, regenerate

## Discovery Pipeline
- Add promotion.ts for discovery location validation and promotion
- Add discover-all-states.ts script for multi-state discovery
- Add promotion log migration (067)
- Enhance discovery routes and types

## Orchestrator & Admin
- Add crawl_enabled filter to stores page
- Add API permissions page
- Add job queue management
- Add price analytics routes
- Add markets and intelligence routes
- Enhance dashboard and worker monitoring

## Infrastructure
- Add migrations for worker definitions, SEO settings, field alignment
- Add canonical pipeline for scraper v2
- Update hydration and sync orchestrator
- Enhance multi-state query service

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 00:05:34 -07:00

684 lines
22 KiB
TypeScript

/**
* 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<string, string> = {
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;