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

522 lines
15 KiB
TypeScript

/**
* Click Analytics API Routes
*
* Aggregates product click events by brand and campaign for analytics dashboards.
*
* Endpoints:
* GET /api/analytics/clicks/brands - Top brands by click engagement
* GET /api/analytics/clicks/campaigns - Top campaigns/specials by engagement
* GET /api/analytics/clicks/stores/:storeId/brands - Per-store brand engagement
* GET /api/analytics/clicks/summary - Overall click summary stats
*/
import { Router, Request, Response } from 'express';
import { pool } from '../db/pool';
import { authMiddleware } from '../auth/middleware';
const router = Router();
// All click analytics endpoints require authentication
router.use(authMiddleware);
/**
* GET /api/analytics/clicks/brands
* Get top brands by click engagement
*
* Query params:
* - state: Filter by store state (e.g., 'AZ')
* - store_id: Filter by specific store
* - brand_id: Filter by specific brand
* - days: Lookback window (default 30)
* - limit: Max results (default 25)
*/
router.get('/brands', async (req: Request, res: Response) => {
try {
const {
state,
store_id,
brand_id,
days = '30',
limit = '25'
} = req.query;
const daysNum = parseInt(days as string, 10) || 30;
const limitNum = Math.min(parseInt(limit as string, 10) || 25, 100);
// Build conditions and params
const conditions: string[] = [
'e.brand_id IS NOT NULL',
`e.occurred_at >= NOW() - INTERVAL '${daysNum} days'`
];
const params: any[] = [];
let paramIdx = 1;
if (state) {
conditions.push(`d.state = $${paramIdx++}`);
params.push(state);
}
if (store_id) {
conditions.push(`e.store_id = $${paramIdx++}`);
params.push(store_id);
}
if (brand_id) {
conditions.push(`e.brand_id = $${paramIdx++}`);
params.push(brand_id);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Query for brand engagement
const result = await pool.query(`
SELECT
e.brand_id,
e.brand_id as brand_name,
COUNT(*) as clicks,
COUNT(DISTINCT e.product_id) as unique_products,
COUNT(DISTINCT e.store_id) as unique_stores,
MIN(e.occurred_at) as first_click_at,
MAX(e.occurred_at) as last_click_at
FROM product_click_events e
LEFT JOIN dispensaries d ON e.store_id::int = d.id
${whereClause}
GROUP BY e.brand_id
ORDER BY clicks DESC
LIMIT ${limitNum}
`, params);
// Try to get actual brand names from products
const brandIds = result.rows.map(r => r.brand_id).filter(Boolean);
let brandNamesMap: Record<string, string> = {};
if (brandIds.length > 0) {
const brandNamesResult = await pool.query(`
SELECT DISTINCT brand_name_raw as brand_name
FROM store_products
WHERE brand_name_raw = ANY($1)
`, [brandIds]);
brandNamesResult.rows.forEach(r => {
brandNamesMap[r.brand_name] = r.brand_name;
});
}
const brands = result.rows.map(row => ({
brand_id: row.brand_id,
brand_name: brandNamesMap[row.brand_id] || row.brand_id,
clicks: parseInt(row.clicks, 10),
unique_products: parseInt(row.unique_products, 10),
unique_stores: parseInt(row.unique_stores, 10),
first_click_at: row.first_click_at,
last_click_at: row.last_click_at
}));
res.json({
filters: {
state: state || null,
store_id: store_id || null,
brand_id: brand_id || null,
days: daysNum
},
brands
});
} catch (error: any) {
console.error('[ClickAnalytics] Error fetching brand analytics:', error.message);
res.status(500).json({ error: 'Failed to fetch brand analytics' });
}
});
/**
* GET /api/analytics/clicks/products
* Get top products by click engagement
*
* Query params:
* - state: Filter by store state (e.g., 'AZ')
* - store_id: Filter by specific store
* - brand_id: Filter by specific brand
* - days: Lookback window (default 30)
* - limit: Max results (default 25)
*/
router.get('/products', async (req: Request, res: Response) => {
try {
const {
state,
store_id,
brand_id,
days = '30',
limit = '25'
} = req.query;
const daysNum = parseInt(days as string, 10) || 30;
const limitNum = Math.min(parseInt(limit as string, 10) || 25, 100);
// Build conditions and params
const conditions: string[] = [
'e.product_id IS NOT NULL',
`e.occurred_at >= NOW() - INTERVAL '${daysNum} days'`
];
const params: any[] = [];
let paramIdx = 1;
if (state) {
conditions.push(`d.state = $${paramIdx++}`);
params.push(state);
}
if (store_id) {
conditions.push(`e.store_id = $${paramIdx++}`);
params.push(store_id);
}
if (brand_id) {
conditions.push(`e.brand_id = $${paramIdx++}`);
params.push(brand_id);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Query for product engagement with product details from dutchie_products
const result = await pool.query(`
SELECT
e.product_id,
e.brand_id,
COUNT(*) as clicks,
COUNT(DISTINCT e.store_id) as unique_stores,
MIN(e.occurred_at) as first_click_at,
MAX(e.occurred_at) as last_click_at
FROM product_click_events e
LEFT JOIN dispensaries d ON e.store_id::int = d.id
${whereClause}
GROUP BY e.product_id, e.brand_id
ORDER BY clicks DESC
LIMIT ${limitNum}
`, params);
// Try to get product details from dutchie_products
const productIds = result.rows.map(r => r.product_id).filter(Boolean);
let productDetailsMap: Record<string, { name: string; brand: string; type: string; subcategory: string }> = {};
if (productIds.length > 0) {
// Try to match by external_id or id
const productDetailsResult = await pool.query(`
SELECT
provider_product_id as external_id,
id::text as product_id,
name_raw as name,
brand_name_raw as brand_name,
category_raw as type,
subcategory_raw as subcategory
FROM store_products
WHERE provider_product_id = ANY($1) OR id::text = ANY($1)
`, [productIds]);
productDetailsResult.rows.forEach(r => {
productDetailsMap[r.external_id] = {
name: r.name,
brand: r.brand_name,
type: r.type,
subcategory: r.subcategory
};
productDetailsMap[r.product_id] = {
name: r.name,
brand: r.brand_name,
type: r.type,
subcategory: r.subcategory
};
});
}
const products = result.rows.map(row => {
const details = productDetailsMap[row.product_id];
return {
product_id: row.product_id,
product_name: details?.name || `Product ${row.product_id}`,
brand_id: row.brand_id,
brand_name: details?.brand || row.brand_id || 'Unknown',
category: details?.type || null,
subcategory: details?.subcategory || null,
clicks: parseInt(row.clicks, 10),
unique_stores: parseInt(row.unique_stores, 10),
first_click_at: row.first_click_at,
last_click_at: row.last_click_at
};
});
res.json({
filters: {
state: state || null,
store_id: store_id || null,
brand_id: brand_id || null,
days: daysNum
},
products
});
} catch (error: any) {
console.error('[ClickAnalytics] Error fetching product analytics:', error.message);
res.status(500).json({ error: 'Failed to fetch product analytics' });
}
});
/**
* GET /api/analytics/clicks/campaigns
* Get top campaigns/specials by click engagement
*
* Query params:
* - state: Filter by store state
* - store_id: Filter by specific store
* - days: Lookback window (default 30)
* - limit: Max results (default 25)
*/
router.get('/campaigns', async (req: Request, res: Response) => {
try {
const {
state,
store_id,
days = '30',
limit = '25'
} = req.query;
const daysNum = parseInt(days as string, 10) || 30;
const limitNum = Math.min(parseInt(limit as string, 10) || 25, 100);
// Build conditions
const conditions: string[] = [
'e.campaign_id IS NOT NULL',
`e.occurred_at >= NOW() - INTERVAL '${daysNum} days'`
];
const params: any[] = [];
let paramIdx = 1;
if (state) {
conditions.push(`d.state = $${paramIdx++}`);
params.push(state);
}
if (store_id) {
conditions.push(`e.store_id = $${paramIdx++}`);
params.push(store_id);
}
const whereClause = `WHERE ${conditions.join(' AND ')}`;
// Query for campaign engagement with campaign details
const result = await pool.query(`
SELECT
e.campaign_id,
c.name as campaign_name,
c.slug as campaign_slug,
c.description as campaign_description,
c.active as is_active,
c.start_date,
c.end_date,
COUNT(*) as clicks,
COUNT(DISTINCT e.product_id) as unique_products,
COUNT(DISTINCT e.store_id) as unique_stores,
MIN(e.occurred_at) as first_event_at,
MAX(e.occurred_at) as last_event_at
FROM product_click_events e
LEFT JOIN dispensaries d ON e.store_id::int = d.id
LEFT JOIN campaigns c ON e.campaign_id = c.id
${whereClause}
GROUP BY e.campaign_id, c.name, c.slug, c.description, c.active, c.start_date, c.end_date
ORDER BY clicks DESC
LIMIT ${limitNum}
`, params);
const campaigns = result.rows.map(row => ({
campaign_id: row.campaign_id,
campaign_name: row.campaign_name || `Campaign ${row.campaign_id}`,
campaign_slug: row.campaign_slug,
campaign_description: row.campaign_description,
is_active: row.is_active,
start_date: row.start_date,
end_date: row.end_date,
clicks: parseInt(row.clicks, 10),
unique_products: parseInt(row.unique_products, 10),
unique_stores: parseInt(row.unique_stores, 10),
first_event_at: row.first_event_at,
last_event_at: row.last_event_at
}));
res.json({
filters: {
state: state || null,
store_id: store_id || null,
days: daysNum
},
campaigns
});
} catch (error: any) {
console.error('[ClickAnalytics] Error fetching campaign analytics:', error.message);
res.status(500).json({ error: 'Failed to fetch campaign analytics' });
}
});
/**
* GET /api/analytics/clicks/stores/:storeId/brands
* Get brand engagement for a specific store
*
* Query params:
* - days: Lookback window (default 30)
* - limit: Max results (default 25)
*/
router.get('/stores/:storeId/brands', async (req: Request, res: Response) => {
try {
const { storeId } = req.params;
const { days = '30', limit = '25' } = req.query;
const daysNum = parseInt(days as string, 10) || 30;
const limitNum = Math.min(parseInt(limit as string, 10) || 25, 100);
// Get store info
const storeResult = await pool.query(
'SELECT id, name, dba_name, city, state FROM dispensaries WHERE id = $1',
[storeId]
);
if (storeResult.rows.length === 0) {
return res.status(404).json({ error: 'Store not found' });
}
const store = storeResult.rows[0];
// Query brand engagement for this store
const result = await pool.query(`
SELECT
e.brand_id,
COUNT(*) as clicks,
COUNT(DISTINCT e.product_id) as unique_products,
MIN(e.occurred_at) as first_click_at,
MAX(e.occurred_at) as last_click_at
FROM product_click_events e
WHERE e.store_id = $1
AND e.brand_id IS NOT NULL
AND e.occurred_at >= NOW() - INTERVAL '${daysNum} days'
GROUP BY e.brand_id
ORDER BY clicks DESC
LIMIT ${limitNum}
`, [storeId]);
const brands = result.rows.map(row => ({
brand_id: row.brand_id,
brand_name: row.brand_id, // Use brand_id as name for now
clicks: parseInt(row.clicks, 10),
unique_products: parseInt(row.unique_products, 10),
first_click_at: row.first_click_at,
last_click_at: row.last_click_at
}));
res.json({
store: {
id: store.id,
name: store.dba_name || store.name,
city: store.city,
state: store.state
},
filters: {
days: daysNum
},
brands
});
} catch (error: any) {
console.error('[ClickAnalytics] Error fetching store brand analytics:', error.message);
res.status(500).json({ error: 'Failed to fetch store brand analytics' });
}
});
/**
* GET /api/analytics/clicks/summary
* Get overall click summary stats
*
* Query params:
* - state: Filter by store state
* - days: Lookback window (default 30)
*/
router.get('/summary', async (req: Request, res: Response) => {
try {
const { state, days = '30' } = req.query;
const daysNum = parseInt(days as string, 10) || 30;
const conditions: string[] = [`e.occurred_at >= NOW() - INTERVAL '${daysNum} days'`];
const params: any[] = [];
let paramIdx = 1;
if (state) {
conditions.push(`d.state = $${paramIdx++}`);
params.push(state);
}
const whereClause = `WHERE ${conditions.join(' AND ')}`;
// Get overall stats
const statsResult = await pool.query(`
SELECT
COUNT(*) as total_clicks,
COUNT(DISTINCT e.product_id) as unique_products,
COUNT(DISTINCT e.store_id) as unique_stores,
COUNT(DISTINCT e.brand_id) FILTER (WHERE e.brand_id IS NOT NULL) as unique_brands,
COUNT(*) FILTER (WHERE e.campaign_id IS NOT NULL) as campaign_clicks,
COUNT(DISTINCT e.campaign_id) FILTER (WHERE e.campaign_id IS NOT NULL) as unique_campaigns
FROM product_click_events e
LEFT JOIN dispensaries d ON e.store_id::int = d.id
${whereClause}
`, params);
// Get clicks by action
const actionResult = await pool.query(`
SELECT
action,
COUNT(*) as count
FROM product_click_events e
LEFT JOIN dispensaries d ON e.store_id::int = d.id
${whereClause}
GROUP BY action
ORDER BY count DESC
`, params);
// Get clicks by day (last 14 days for chart)
const dailyResult = await pool.query(`
SELECT
DATE(occurred_at) as date,
COUNT(*) as clicks
FROM product_click_events e
LEFT JOIN dispensaries d ON e.store_id::int = d.id
${whereClause}
GROUP BY DATE(occurred_at)
ORDER BY date DESC
LIMIT 14
`, params);
const stats = statsResult.rows[0];
res.json({
filters: {
state: state || null,
days: daysNum
},
summary: {
total_clicks: parseInt(stats.total_clicks, 10),
unique_products: parseInt(stats.unique_products, 10),
unique_stores: parseInt(stats.unique_stores, 10),
unique_brands: parseInt(stats.unique_brands, 10),
campaign_clicks: parseInt(stats.campaign_clicks, 10),
unique_campaigns: parseInt(stats.unique_campaigns, 10)
},
by_action: actionResult.rows.map(row => ({
action: row.action,
count: parseInt(row.count, 10)
})),
daily: dailyResult.rows.map(row => ({
date: row.date,
clicks: parseInt(row.clicks, 10)
})).reverse()
});
} catch (error: any) {
console.error('[ClickAnalytics] Error fetching click summary:', error.message);
res.status(500).json({ error: 'Failed to fetch click summary' });
}
});
export default router;