Files
cannaiq/backend/src/routes/campaigns.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

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;