## 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>
333 lines
10 KiB
TypeScript
Executable File
333 lines
10 KiB
TypeScript
Executable File
import { Router } from 'express';
|
|
import { authMiddleware, requireRole } from '../auth/middleware';
|
|
import { pool } from '../db/pool';
|
|
|
|
const router = Router();
|
|
router.use(authMiddleware);
|
|
|
|
// Get all campaigns
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const result = await pool.query(`
|
|
SELECT c.*, COUNT(cp.product_id) as product_count
|
|
FROM campaigns c
|
|
LEFT JOIN campaign_products cp ON c.id = cp.campaign_id
|
|
GROUP BY c.id
|
|
ORDER BY c.created_at DESC
|
|
`);
|
|
|
|
res.json({ campaigns: result.rows });
|
|
} catch (error) {
|
|
console.error('Error fetching campaigns:', error);
|
|
res.status(500).json({ error: 'Failed to fetch campaigns' });
|
|
}
|
|
});
|
|
|
|
// Get single campaign with products
|
|
router.get('/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const campaignResult = await pool.query(`
|
|
SELECT * FROM campaigns WHERE id = $1
|
|
`, [id]);
|
|
|
|
if (campaignResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Campaign not found' });
|
|
}
|
|
|
|
const productsResult = await pool.query(`
|
|
SELECT
|
|
p.id,
|
|
p.dispensary_id,
|
|
p.name_raw as name,
|
|
p.brand_name_raw as brand,
|
|
p.category_raw as category,
|
|
p.subcategory_raw as subcategory,
|
|
p.price_rec as price,
|
|
p.thc_percent,
|
|
p.cbd_percent,
|
|
p.strain_type,
|
|
p.primary_image_url as image_url,
|
|
p.stock_status,
|
|
p.is_in_stock as in_stock,
|
|
cp.display_order
|
|
FROM store_products p
|
|
JOIN campaign_products cp ON p.id = cp.product_id
|
|
WHERE cp.campaign_id = $1
|
|
ORDER BY cp.display_order
|
|
`, [id]);
|
|
|
|
res.json({
|
|
campaign: campaignResult.rows[0],
|
|
products: productsResult.rows
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching campaign:', error);
|
|
res.status(500).json({ error: 'Failed to fetch campaign' });
|
|
}
|
|
});
|
|
|
|
// Create campaign
|
|
router.post('/', requireRole('superadmin', 'admin'), async (req, res) => {
|
|
try {
|
|
const { name, slug, description, display_style, active, start_date, end_date } = req.body;
|
|
|
|
if (!name || !slug) {
|
|
return res.status(400).json({ error: 'Name and slug required' });
|
|
}
|
|
|
|
const result = await pool.query(`
|
|
INSERT INTO campaigns (name, slug, description, display_style, active, start_date, end_date)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING *
|
|
`, [name, slug, description, display_style || 'grid', active !== false, start_date, end_date]);
|
|
|
|
res.status(201).json({ campaign: result.rows[0] });
|
|
} catch (error: any) {
|
|
console.error('Error creating campaign:', error);
|
|
if (error.code === '23505') {
|
|
return res.status(409).json({ error: 'Campaign slug already exists' });
|
|
}
|
|
res.status(500).json({ error: 'Failed to create campaign' });
|
|
}
|
|
});
|
|
|
|
// Update campaign
|
|
router.put('/:id', requireRole('superadmin', 'admin'), async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { name, slug, description, display_style, active, start_date, end_date } = req.body;
|
|
|
|
const result = await pool.query(`
|
|
UPDATE campaigns
|
|
SET name = COALESCE($1, name),
|
|
slug = COALESCE($2, slug),
|
|
description = COALESCE($3, description),
|
|
display_style = COALESCE($4, display_style),
|
|
active = COALESCE($5, active),
|
|
start_date = COALESCE($6, start_date),
|
|
end_date = COALESCE($7, end_date),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $8
|
|
RETURNING *
|
|
`, [name, slug, description, display_style, active, start_date, end_date, id]);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Campaign not found' });
|
|
}
|
|
|
|
res.json({ campaign: result.rows[0] });
|
|
} catch (error: any) {
|
|
console.error('Error updating campaign:', error);
|
|
if (error.code === '23505') {
|
|
return res.status(409).json({ error: 'Campaign slug already exists' });
|
|
}
|
|
res.status(500).json({ error: 'Failed to update campaign' });
|
|
}
|
|
});
|
|
|
|
// Delete campaign
|
|
router.delete('/:id', requireRole('superadmin'), async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const result = await pool.query(`
|
|
DELETE FROM campaigns WHERE id = $1 RETURNING id
|
|
`, [id]);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Campaign not found' });
|
|
}
|
|
|
|
res.json({ message: 'Campaign deleted successfully' });
|
|
} catch (error) {
|
|
console.error('Error deleting campaign:', error);
|
|
res.status(500).json({ error: 'Failed to delete campaign' });
|
|
}
|
|
});
|
|
|
|
// Add product to campaign
|
|
router.post('/:id/products', requireRole('superadmin', 'admin'), async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { product_id, display_order } = req.body;
|
|
|
|
if (!product_id) {
|
|
return res.status(400).json({ error: 'Product ID required' });
|
|
}
|
|
|
|
const result = await pool.query(`
|
|
INSERT INTO campaign_products (campaign_id, product_id, display_order)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (campaign_id, product_id)
|
|
DO UPDATE SET display_order = $3
|
|
RETURNING *
|
|
`, [id, product_id, display_order || 0]);
|
|
|
|
res.status(201).json({ campaign_product: result.rows[0] });
|
|
} catch (error) {
|
|
console.error('Error adding product to campaign:', error);
|
|
res.status(500).json({ error: 'Failed to add product to campaign' });
|
|
}
|
|
});
|
|
|
|
// Remove product from campaign
|
|
router.delete('/:id/products/:product_id', requireRole('superadmin', 'admin'), async (req, res) => {
|
|
try {
|
|
const { id, product_id } = req.params;
|
|
|
|
const result = await pool.query(`
|
|
DELETE FROM campaign_products
|
|
WHERE campaign_id = $1 AND product_id = $2
|
|
RETURNING *
|
|
`, [id, product_id]);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Product not in campaign' });
|
|
}
|
|
|
|
res.json({ message: 'Product removed from campaign' });
|
|
} catch (error) {
|
|
console.error('Error removing product from campaign:', error);
|
|
res.status(500).json({ error: 'Failed to remove product from campaign' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/campaigns/:id/click-summary
|
|
* Get product click event summary for a campaign
|
|
*
|
|
* Query params:
|
|
* - from: Start date (ISO)
|
|
* - to: End date (ISO)
|
|
*/
|
|
router.get('/:id/click-summary', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { from, to } = req.query;
|
|
|
|
// Check campaign exists
|
|
const campaignResult = await pool.query(
|
|
'SELECT id, name FROM campaigns WHERE id = $1',
|
|
[id]
|
|
);
|
|
if (campaignResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Campaign not found' });
|
|
}
|
|
|
|
// Build date filter conditions
|
|
const conditions: string[] = ['campaign_id = $1'];
|
|
const params: any[] = [id];
|
|
let paramIndex = 2;
|
|
|
|
if (from) {
|
|
conditions.push(`occurred_at >= $${paramIndex++}`);
|
|
params.push(new Date(from as string));
|
|
}
|
|
if (to) {
|
|
conditions.push(`occurred_at <= $${paramIndex++}`);
|
|
params.push(new Date(to as string));
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
|
|
// Get overall stats
|
|
const statsResult = await pool.query(`
|
|
SELECT
|
|
COUNT(*) as total_clicks,
|
|
COUNT(DISTINCT product_id) as unique_products,
|
|
COUNT(DISTINCT store_id) as unique_stores,
|
|
COUNT(DISTINCT brand_id) as unique_brands,
|
|
COUNT(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL) as unique_users
|
|
FROM product_click_events
|
|
${whereClause}
|
|
`, params);
|
|
|
|
// Get clicks by action type
|
|
const byActionResult = await pool.query(`
|
|
SELECT
|
|
action,
|
|
COUNT(*) as count
|
|
FROM product_click_events
|
|
${whereClause}
|
|
GROUP BY action
|
|
ORDER BY count DESC
|
|
`, params);
|
|
|
|
// Get clicks by source
|
|
const bySourceResult = await pool.query(`
|
|
SELECT
|
|
source,
|
|
COUNT(*) as count
|
|
FROM product_click_events
|
|
${whereClause}
|
|
GROUP BY source
|
|
ORDER BY count DESC
|
|
`, params);
|
|
|
|
// Get top products (by click count)
|
|
const topProductsResult = await pool.query(`
|
|
SELECT
|
|
product_id,
|
|
COUNT(*) as click_count
|
|
FROM product_click_events
|
|
${whereClause}
|
|
GROUP BY product_id
|
|
ORDER BY click_count DESC
|
|
LIMIT 10
|
|
`, params);
|
|
|
|
// Get daily click counts (last 30 days by default)
|
|
const dailyParams = [...params];
|
|
let dailyWhereClause = whereClause;
|
|
if (!from) {
|
|
// Default to last 30 days
|
|
conditions.push(`occurred_at >= NOW() - INTERVAL '30 days'`);
|
|
dailyWhereClause = `WHERE ${conditions.join(' AND ')}`;
|
|
}
|
|
|
|
const dailyResult = await pool.query(`
|
|
SELECT
|
|
DATE(occurred_at) as date,
|
|
COUNT(*) as click_count
|
|
FROM product_click_events
|
|
${dailyWhereClause}
|
|
GROUP BY DATE(occurred_at)
|
|
ORDER BY date ASC
|
|
`, dailyParams);
|
|
|
|
res.json({
|
|
campaign: campaignResult.rows[0],
|
|
summary: {
|
|
totalClicks: parseInt(statsResult.rows[0].total_clicks, 10),
|
|
uniqueProducts: parseInt(statsResult.rows[0].unique_products, 10),
|
|
uniqueStores: parseInt(statsResult.rows[0].unique_stores, 10),
|
|
uniqueBrands: parseInt(statsResult.rows[0].unique_brands, 10),
|
|
uniqueUsers: parseInt(statsResult.rows[0].unique_users, 10)
|
|
},
|
|
byAction: byActionResult.rows.map(row => ({
|
|
action: row.action,
|
|
count: parseInt(row.count, 10)
|
|
})),
|
|
bySource: bySourceResult.rows.map(row => ({
|
|
source: row.source,
|
|
count: parseInt(row.count, 10)
|
|
})),
|
|
topProducts: topProductsResult.rows.map(row => ({
|
|
productId: row.product_id,
|
|
clickCount: parseInt(row.click_count, 10)
|
|
})),
|
|
daily: dailyResult.rows.map(row => ({
|
|
date: row.date,
|
|
clickCount: parseInt(row.click_count, 10)
|
|
}))
|
|
});
|
|
} catch (error: any) {
|
|
console.error('[Campaigns] Error fetching click summary:', error.message);
|
|
res.status(500).json({ error: 'Failed to fetch campaign click summary' });
|
|
}
|
|
});
|
|
|
|
export default router;
|