## 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>
221 lines
6.6 KiB
TypeScript
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;
|