132 lines
3.2 KiB
JavaScript
132 lines
3.2 KiB
JavaScript
/**
|
|
* Analytics Routes
|
|
* Track events and conversions for marketing analytics
|
|
*/
|
|
|
|
import express from 'express'
|
|
import { asyncHandler, ValidationError } from '../middleware/errorHandler.mjs'
|
|
import { respond } from '../utils/response.mjs'
|
|
import { db, Collections } from '../services/database.mjs'
|
|
import { log } from '../middleware/logger.mjs'
|
|
|
|
const router = express.Router()
|
|
|
|
// Whitelist of allowed event types
|
|
const ALLOWED_EVENT_TYPES = [
|
|
'page_view',
|
|
'signup',
|
|
'trial_start',
|
|
'purchase',
|
|
'email_connected',
|
|
'onboarding_step',
|
|
'provider_connected',
|
|
'demo_used',
|
|
'suggested_rules_generated',
|
|
'rule_created',
|
|
'rules_applied',
|
|
'limit_reached',
|
|
'upgrade_clicked',
|
|
'referral_shared',
|
|
'sort_completed',
|
|
'account_deleted',
|
|
]
|
|
|
|
// Fields that should never be stored (PII)
|
|
const PII_FIELDS = ['email', 'password', 'emailContent', 'emailBody', 'subject', 'from', 'to', 'snippet', 'content']
|
|
|
|
function stripPII(metadata) {
|
|
if (!metadata || typeof metadata !== 'object') return {}
|
|
|
|
const cleaned = {}
|
|
for (const [key, value] of Object.entries(metadata)) {
|
|
if (PII_FIELDS.includes(key.toLowerCase())) {
|
|
continue // Skip PII fields
|
|
}
|
|
if (typeof value === 'string' && value.includes('@')) {
|
|
// Skip if looks like email
|
|
continue
|
|
}
|
|
if (typeof value === 'object' && value !== null) {
|
|
cleaned[key] = stripPII(value)
|
|
} else {
|
|
cleaned[key] = value
|
|
}
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
/**
|
|
* POST /api/analytics/track
|
|
* Track analytics events (page views, conversions, etc.)
|
|
*
|
|
* Body:
|
|
* {
|
|
* type: 'page_view' | 'signup' | 'trial_start' | 'purchase' | 'email_connected',
|
|
* userId?: string,
|
|
* tracking?: {
|
|
* utm_source?: string,
|
|
* utm_medium?: string,
|
|
* utm_campaign?: string,
|
|
* utm_term?: string,
|
|
* utm_content?: string,
|
|
* },
|
|
* metadata?: Record<string, any>,
|
|
* timestamp?: string,
|
|
* page?: string,
|
|
* referrer?: string,
|
|
* }
|
|
*/
|
|
router.post('/track', asyncHandler(async (req, res) => {
|
|
const {
|
|
type,
|
|
userId,
|
|
tracking,
|
|
metadata,
|
|
timestamp,
|
|
page,
|
|
referrer,
|
|
sessionId,
|
|
} = req.body
|
|
|
|
// Validate event type
|
|
if (!type || !ALLOWED_EVENT_TYPES.includes(type)) {
|
|
throw new ValidationError(`Invalid event type. Allowed: ${ALLOWED_EVENT_TYPES.join(', ')}`)
|
|
}
|
|
|
|
// Strip PII from metadata
|
|
const cleanedMetadata = stripPII(metadata || {})
|
|
|
|
// Prepare event data
|
|
const eventData = {
|
|
userId: userId || null,
|
|
eventType: type,
|
|
metadataJson: JSON.stringify(cleanedMetadata),
|
|
timestamp: timestamp || new Date().toISOString(),
|
|
sessionId: sessionId || null,
|
|
}
|
|
|
|
// Store in database
|
|
try {
|
|
await db.create(Collections.ANALYTICS_EVENTS, eventData)
|
|
log.info(`Analytics event tracked: ${type}`, { userId, sessionId })
|
|
} catch (err) {
|
|
log.warn('Failed to store analytics event', { error: err.message, type })
|
|
// Don't fail the request if analytics storage fails
|
|
}
|
|
|
|
// Log in development
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.log('📊 Analytics Event:', {
|
|
type,
|
|
userId,
|
|
sessionId,
|
|
metadata: cleanedMetadata,
|
|
})
|
|
}
|
|
|
|
// Return success (client doesn't need to wait)
|
|
respond.success(res, { received: true })
|
|
}))
|
|
|
|
export default router
|