/** * Events API Routes - Product click tracking * * Tracks user interactions with products for analytics and campaign measurement. * * Endpoints: * POST /api/events/product-click - Record a product click event * GET /api/events/product-clicks - Get product click events (admin) */ import { Router, Request, Response } from 'express'; import { pool } from '../db/pool'; import { authMiddleware, optionalAuthMiddleware } from '../auth/middleware'; const router = Router(); // Valid action types const VALID_ACTIONS = ['view', 'open_store', 'open_product', 'compare', 'other']; interface ProductClickEventPayload { product_id: string; store_id?: string; brand_id?: string; campaign_id?: string; dispensary_name?: string; action: 'view' | 'open_store' | 'open_product' | 'compare' | 'other'; source: string; page_type?: string; // Page where event occurred (e.g., StoreDetailPage, BrandsIntelligence) url_path?: string; // URL path for debugging occurred_at?: string; // Visitor location (from frontend IP geolocation) visitor_city?: string; visitor_state?: string; visitor_lat?: number; visitor_lng?: number; } /** * POST /api/events/product-click * Record a product click event * * Fire-and-forget from frontend - returns quickly with minimal validation */ router.post('/product-click', optionalAuthMiddleware, async (req: Request, res: Response) => { try { const payload: ProductClickEventPayload = req.body; // Basic validation if (!payload.product_id || typeof payload.product_id !== 'string') { return res.status(400).json({ status: 'error', error: 'product_id is required' }); } if (!payload.action || !VALID_ACTIONS.includes(payload.action)) { return res.status(400).json({ status: 'error', error: `action must be one of: ${VALID_ACTIONS.join(', ')}` }); } if (!payload.source || typeof payload.source !== 'string') { return res.status(400).json({ status: 'error', error: 'source is required' }); } // Get user ID from auth context if available const userId = (req as any).user?.id || null; // Get IP and user agent from request const ipAddress = req.ip || req.headers['x-forwarded-for'] || null; const userAgent = req.headers['user-agent'] || null; // Parse occurred_at or use current time const occurredAt = payload.occurred_at ? new Date(payload.occurred_at) : new Date(); // Detect device type from user agent (simple heuristic) let deviceType = 'desktop'; if (userAgent) { const ua = userAgent.toLowerCase(); if (/mobile|android|iphone|ipad|ipod|blackberry|windows phone/i.test(ua)) { deviceType = /ipad|tablet/i.test(ua) ? 'tablet' : 'mobile'; } } // Insert the event with enhanced fields await pool.query( `INSERT INTO product_click_events (product_id, store_id, brand_id, campaign_id, dispensary_name, action, source, user_id, ip_address, user_agent, occurred_at, event_type, page_type, url_path, device_type, visitor_city, visitor_state, visitor_lat, visitor_lng) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`, [ payload.product_id, payload.store_id || null, payload.brand_id || null, payload.campaign_id || null, payload.dispensary_name || null, payload.action, payload.source, userId, ipAddress, userAgent, occurredAt, 'product_click', // event_type payload.page_type || null, payload.url_path || null, deviceType, payload.visitor_city || null, payload.visitor_state || null, payload.visitor_lat || null, payload.visitor_lng || null ] ); res.json({ status: 'ok' }); } catch (error: any) { console.error('[Events] Error recording product click:', error.message); // Still return ok to not break frontend - events are non-critical res.json({ status: 'ok' }); } }); /** * GET /api/events/product-clicks * Get product click events (admin only) * * Query params: * - product_id: Filter by product * - store_id: Filter by store * - brand_id: Filter by brand * - campaign_id: Filter by campaign * - action: Filter by action type * - source: Filter by source * - from: Start date (ISO) * - to: End date (ISO) * - limit: Max results (default 100) * - offset: Pagination offset */ router.get('/product-clicks', authMiddleware, async (req: Request, res: Response) => { try { const { product_id, store_id, brand_id, campaign_id, action, source, from, to, limit = '100', offset = '0' } = req.query; const conditions: string[] = []; const params: any[] = []; let paramIndex = 1; if (product_id) { conditions.push(`product_id = $${paramIndex++}`); params.push(product_id); } if (store_id) { conditions.push(`store_id = $${paramIndex++}`); params.push(store_id); } if (brand_id) { conditions.push(`brand_id = $${paramIndex++}`); params.push(brand_id); } if (campaign_id) { conditions.push(`campaign_id = $${paramIndex++}`); params.push(campaign_id); } if (action) { conditions.push(`action = $${paramIndex++}`); params.push(action); } if (source) { conditions.push(`source = $${paramIndex++}`); params.push(source); } 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 total count const countResult = await pool.query( `SELECT COUNT(*) as total FROM product_click_events ${whereClause}`, params ); const total = parseInt(countResult.rows[0].total, 10); // Get events params.push(parseInt(limit as string, 10)); params.push(parseInt(offset as string, 10)); const result = await pool.query( `SELECT * FROM product_click_events ${whereClause} ORDER BY occurred_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex}`, params ); res.json({ events: result.rows, total, limit: parseInt(limit as string, 10), offset: parseInt(offset as string, 10) }); } catch (error: any) { console.error('[Events] Error fetching product clicks:', error.message); res.status(500).json({ error: 'Failed to fetch product click events' }); } }); export default router;