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>
This commit is contained in:
@@ -10,6 +10,25 @@ 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();
|
||||
|
||||
@@ -160,10 +179,12 @@ router.get('/pages', authMiddleware, async (req: Request, res: Response) => {
|
||||
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) as brand_count
|
||||
COUNT(DISTINCT p.brand_name_raw) as brand_count
|
||||
FROM dispensaries d
|
||||
LEFT JOIN dutchie_products p ON p.dispensary_id = d.id
|
||||
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 = {
|
||||
@@ -199,11 +220,13 @@ router.post('/sync-state-pages', authMiddleware, async (req: Request, res: Respo
|
||||
try {
|
||||
const pool = getPool();
|
||||
|
||||
// Get all states that have dispensaries
|
||||
// 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
|
||||
@@ -245,6 +268,45 @@ router.post('/sync-state-pages', authMiddleware, async (req: Request, res: Respo
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -257,16 +319,20 @@ router.get('/state/:stateCode', async (req: Request, res: Response) => {
|
||||
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) as brand_count
|
||||
COUNT(DISTINCT p.brand_name_raw) as brand_count
|
||||
FROM dispensaries d
|
||||
LEFT JOIN dutchie_products p ON p.dispensary_id = d.id
|
||||
WHERE d.state = $1`, [code]);
|
||||
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, COUNT(*) as product_count
|
||||
FROM dutchie_products p JOIN dispensaries d ON p.dispensary_id = d.id
|
||||
WHERE d.state = $1 AND p.brand_name IS NOT NULL
|
||||
GROUP BY brand_name ORDER BY product_count DESC LIMIT 10`, [code]);
|
||||
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({
|
||||
@@ -359,4 +425,259 @@ router.get('/public/content', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user