/** * 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 = {}; 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 = {}; 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;