## 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>
684 lines
22 KiB
TypeScript
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;
|