## 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>
522 lines
15 KiB
TypeScript
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;
|