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:
Kelly
2025-12-09 00:05:34 -07:00
parent 9711d594db
commit 2f483b3084
83 changed files with 16700 additions and 1277 deletions

View File

@@ -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;