Files
cannaiq/backend/src/routes/events.ts
Kelly 56cc171287 feat: Stealth worker system with mandatory proxy rotation
## Worker System
- Role-agnostic workers that can handle any task type
- Pod-based architecture with StatefulSet (5-15 pods, 5 workers each)
- Custom pod names (Aethelgard, Xylos, Kryll, etc.)
- Worker registry with friendly names and resource monitoring
- Hub-and-spoke visualization on JobQueue page

## Stealth & Anti-Detection (REQUIRED)
- Proxies are MANDATORY - workers fail to start without active proxies
- CrawlRotator initializes on worker startup
- Loads proxies from `proxies` table
- Auto-rotates proxy + fingerprint on 403 errors
- 12 browser fingerprints (Chrome, Firefox, Safari, Edge)
- Locale/timezone matching for geographic consistency

## Task System
- Renamed product_resync → product_refresh
- Task chaining: store_discovery → entry_point → product_discovery
- Priority-based claiming with FOR UPDATE SKIP LOCKED
- Heartbeat and stale task recovery

## UI Updates
- JobQueue: Pod visualization, resource monitoring on hover
- WorkersDashboard: Simplified worker list
- Removed unused filters from task list

## Other
- IP2Location service for visitor analytics
- Findagram consumer features scaffolding
- Documentation updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 00:44:59 -07:00

221 lines
6.6 KiB
TypeScript

/**
* 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;