Fix Mixpanel analytics review findings FRE-5281
P0: Fix validation bypass - validated properties now override raw properties P1: Add unit tests for shared-analytics package (3 test files) P1: Refactor spamshield to use shared-analytics, deprecate duplicate P2: Normalize phone numbers to E.164 before hashing P2: Add graceful error handling for missing env vars in config P3: Add singleton pattern to MixpanelService P3: Include timestamp in validated properties schema
This commit is contained in:
@@ -6,7 +6,10 @@
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint src/"
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@segment/analytics-node": "^1.0.0",
|
||||
@@ -14,6 +17,8 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
113
packages/shared-analytics/src/__tests__/analytics.config.test.ts
Normal file
113
packages/shared-analytics/src/__tests__/analytics.config.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
describe('eventPropertiesSchema', () => {
|
||||
let eventPropertiesSchema: z.ZodType;
|
||||
|
||||
beforeEach(async () => {
|
||||
const config = await import('../config/analytics.config');
|
||||
eventPropertiesSchema = config.eventPropertiesSchema;
|
||||
});
|
||||
|
||||
it('validates valid properties', () => {
|
||||
const result = eventPropertiesSchema.parse({
|
||||
userId: 'user-123',
|
||||
sessionId: 'session-456',
|
||||
platform: 'web',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
expect(result.userId).toBe('user-123');
|
||||
expect(result.platform).toBe('web');
|
||||
});
|
||||
|
||||
it('accepts empty properties', () => {
|
||||
const result = eventPropertiesSchema.parse({});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('accepts null properties', () => {
|
||||
const result = eventPropertiesSchema.parse(null);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('validates timestamp as Date object', () => {
|
||||
const now = new Date();
|
||||
const result = eventPropertiesSchema.parse({ timestamp: now });
|
||||
expect(result.timestamp).toBe(now);
|
||||
});
|
||||
|
||||
it('validates timestamp as ISO string', () => {
|
||||
const isoString = '2026-01-01T00:00:00.000Z';
|
||||
const result = eventPropertiesSchema.parse({ timestamp: isoString });
|
||||
expect(result.timestamp).toBe(isoString);
|
||||
});
|
||||
|
||||
it('allows extra properties via passthrough', () => {
|
||||
const result = eventPropertiesSchema.parse({
|
||||
plan: 'pro',
|
||||
referrer: 'google',
|
||||
mrr: 29.99,
|
||||
});
|
||||
|
||||
expect(result.plan).toBe('pro');
|
||||
expect(result.referrer).toBe('google');
|
||||
expect(result.mrr).toBe(29.99);
|
||||
});
|
||||
|
||||
it('validates platform enum', () => {
|
||||
expect(() => eventPropertiesSchema.parse({ platform: 'web' })).not.toThrow();
|
||||
expect(() => eventPropertiesSchema.parse({ platform: 'mobile' })).not.toThrow();
|
||||
expect(() => eventPropertiesSchema.parse({ platform: 'desktop' })).not.toThrow();
|
||||
expect(() => eventPropertiesSchema.parse({ platform: 'api' })).not.toThrow();
|
||||
|
||||
expect(() => eventPropertiesSchema.parse({ platform: 'invalid' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventType enum', () => {
|
||||
let EventType: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const config = await import('../config/analytics.config');
|
||||
EventType = config.EventType;
|
||||
});
|
||||
|
||||
it('contains all user events', () => {
|
||||
expect(EventType.USER_SIGNED_UP).toBe('user_signed_up');
|
||||
expect(EventType.USER_LOGGED_IN).toBe('user_logged_in');
|
||||
expect(EventType.USER_LOGGED_OUT).toBe('user_logged_out');
|
||||
expect(EventType.USER_UPGRADED).toBe('user_upgraded');
|
||||
expect(EventType.USER_DOWNGRADED).toBe('user_downgraded');
|
||||
});
|
||||
|
||||
it('contains all subscription events', () => {
|
||||
expect(EventType.SUBSCRIPTION_CREATED).toBe('subscription_created');
|
||||
expect(EventType.SUBSCRIPTION_UPDATED).toBe('subscription_updated');
|
||||
expect(EventType.SUBSCRIPTION_CANCELLED).toBe('subscription_cancelled');
|
||||
expect(EventType.SUBSCRIPTION_RENEWED).toBe('subscription_renewed');
|
||||
});
|
||||
|
||||
it('contains all spam events', () => {
|
||||
expect(EventType.CALL_ANALYZED).toBe('call_analyzed');
|
||||
expect(EventType.SMS_ANALYZED).toBe('sms_analyzed');
|
||||
expect(EventType.SPAM_BLOCKED).toBe('spam_blocked');
|
||||
expect(EventType.SPAM_FLAGGED).toBe('spam_flagged');
|
||||
expect(EventType.SPAM_FEEDBACK_SUBMITTED).toBe('spam_feedback_submitted');
|
||||
});
|
||||
|
||||
it('contains all KPI events', () => {
|
||||
expect(EventType.MRR_UPDATED).toBe('mrr_updated');
|
||||
expect(EventType.CONVERSION_OCCURRED).toBe('conversion_occurred');
|
||||
expect(EventType.CHURN_OCCURRED).toBe('churn_occurred');
|
||||
expect(EventType.REFERRAL_SENT).toBe('referral_sent');
|
||||
expect(EventType.REFERRAL_CONVERTED).toBe('referral_converted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAnalyticsConfigured', () => {
|
||||
it('exports configuration status flag', async () => {
|
||||
const config = await import('../config/analytics.config');
|
||||
expect(typeof config.isAnalyticsConfigured).toBe('boolean');
|
||||
});
|
||||
});
|
||||
180
packages/shared-analytics/src/__tests__/mixpanel.service.test.ts
Normal file
180
packages/shared-analytics/src/__tests__/mixpanel.service.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
describe('MixpanelService', () => {
|
||||
let MixpanelService: any;
|
||||
let mixpanelService: any;
|
||||
let mockAnalytics: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('MIXPANEL_TOKEN', 'test-token');
|
||||
vi.stubEnv('GA4_MEASUREMENT_ID', 'G-TEST123');
|
||||
|
||||
mockAnalytics = {
|
||||
track: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
group: vi.fn(),
|
||||
flush: vi.fn(),
|
||||
};
|
||||
|
||||
vi.doMock('@segment/analytics-node', () => ({
|
||||
Analytics: vi.fn(() => mockAnalytics),
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
const config = await import('../config/analytics.config');
|
||||
if (config.analyticsEnv) {
|
||||
vi.clearAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it('implements singleton pattern via getInstance', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
MixpanelService = mod.MixpanelService;
|
||||
|
||||
const instance1 = MixpanelService.getInstance();
|
||||
const instance2 = MixpanelService.getInstance();
|
||||
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
it('exports singleton instance as mixpanelService', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
const instance = MixpanelService.getInstance();
|
||||
expect(mixpanelService).toBe(instance);
|
||||
});
|
||||
|
||||
it('validated properties override raw properties (P0 fix)', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.track(
|
||||
mod.EventType.USER_SIGNED_UP,
|
||||
'user-123',
|
||||
{
|
||||
platform: 'web',
|
||||
version: 'malicious-value',
|
||||
}
|
||||
);
|
||||
|
||||
const trackCall = mockAnalytics.track.mock.calls[0];
|
||||
const properties = trackCall[0].properties;
|
||||
|
||||
expect(properties.platform).toBe('web');
|
||||
expect(properties.version).toBe('malicious-value');
|
||||
expect(properties.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it('adds timestamp to all tracked events', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.track(mod.EventType.USER_SIGNED_UP, 'user-123', {});
|
||||
|
||||
const trackCall = mockAnalytics.track.mock.calls[0];
|
||||
expect(trackCall[0].properties.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('tracks user signup event correctly', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.userSignedUp('user-123', 'pro', 'google');
|
||||
|
||||
const trackCall = mockAnalytics.track.mock.calls[0];
|
||||
expect(trackCall[0].event).toBe('user_signed_up');
|
||||
expect(trackCall[0].distinctId).toBe('user-123');
|
||||
expect(trackCall[0].properties.plan).toBe('pro');
|
||||
expect(trackCall[0].properties.referrer).toBe('google');
|
||||
});
|
||||
|
||||
it('tracks user upgrade event correctly', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.userUpgraded('user-123', 'free', 'pro', 29.99);
|
||||
|
||||
const trackCall = mockAnalytics.track.mock.calls[0];
|
||||
expect(trackCall[0].event).toBe('user_upgraded');
|
||||
expect(trackCall[0].properties.fromTier).toBe('free');
|
||||
expect(trackCall[0].properties.toTier).toBe('pro');
|
||||
expect(trackCall[0].properties.mrr).toBe(29.99);
|
||||
});
|
||||
|
||||
it('tracks spam blocked event with hashed phone number', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.spamBlocked('user-123', '+14155552671', 0.95, 'ml');
|
||||
|
||||
const trackCall = mockAnalytics.track.mock.calls[0];
|
||||
expect(trackCall[0].event).toBe('spam_blocked');
|
||||
expect(trackCall[0].properties.phoneNumber).toMatch(/^sha256_/);
|
||||
expect(trackCall[0].properties.confidence).toBe(0.95);
|
||||
expect(trackCall[0].properties.method).toBe('ml');
|
||||
});
|
||||
|
||||
it('does not send raw phone number in spam blocked events', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
const rawPhone = '+14155552671';
|
||||
await mixpanelService.spamBlocked('user-123', rawPhone, 0.95, 'ml');
|
||||
|
||||
const trackCall = mockAnalytics.track.mock.calls[0];
|
||||
expect(trackCall[0].properties.phoneNumber).not.toBe(rawPhone);
|
||||
});
|
||||
|
||||
it('calls identify correctly', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.identify('user-123', { name: 'John Doe', plan: 'pro' });
|
||||
|
||||
expect(mockAnalytics.identify).toHaveBeenCalledWith({
|
||||
distinctId: 'user-123',
|
||||
traits: { name: 'John Doe', plan: 'pro' },
|
||||
});
|
||||
});
|
||||
|
||||
it('calls group correctly', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.group('org-123', 'organization', { name: 'Acme Corp' });
|
||||
|
||||
expect(mockAnalytics.group).toHaveBeenCalledWith({
|
||||
groupKey: 'organization',
|
||||
groupId: 'org-123',
|
||||
traits: { name: 'Acme Corp' },
|
||||
});
|
||||
});
|
||||
|
||||
it('calls flush correctly', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.flush();
|
||||
|
||||
expect(mockAnalytics.flush).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles exposure detected event', async () => {
|
||||
const mod = await import('../services/mixpanel.service');
|
||||
mixpanelService = mod.mixpanelService;
|
||||
|
||||
await mixpanelService.exposureDetected('user-123', 'breach', 'high', 'haveibeenpwned');
|
||||
|
||||
const trackCall = mockAnalytics.track.mock.calls[0];
|
||||
expect(trackCall[0].event).toBe('exposure_detected');
|
||||
expect(trackCall[0].properties.exposureType).toBe('breach');
|
||||
expect(trackCall[0].properties.severity).toBe('high');
|
||||
expect(trackCall[0].properties.source).toBe('haveibeenpwned');
|
||||
});
|
||||
});
|
||||
74
packages/shared-analytics/src/__tests__/phone-hash.test.ts
Normal file
74
packages/shared-analytics/src/__tests__/phone-hash.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { hashPhoneNumber } from '../utils/phone-hash';
|
||||
import crypto from 'crypto';
|
||||
|
||||
describe('hashPhoneNumber', () => {
|
||||
it('produces deterministic hash for same phone number', () => {
|
||||
const hash1 = hashPhoneNumber('+14155552671');
|
||||
const hash2 = hashPhoneNumber('+14155552671');
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
it('normalizes different formats to same hash - US numbers', () => {
|
||||
const hash1 = hashPhoneNumber('+14155552671');
|
||||
const hash2 = hashPhoneNumber('4155552671');
|
||||
const hash3 = hashPhoneNumber('(415) 555-2671');
|
||||
const hash4 = hashPhoneNumber('415-555-2671');
|
||||
const hash5 = hashPhoneNumber('415.555.2671');
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
expect(hash1).toBe(hash3);
|
||||
expect(hash1).toBe(hash4);
|
||||
expect(hash1).toBe(hash5);
|
||||
});
|
||||
|
||||
it('normalizes international numbers', () => {
|
||||
const hash1 = hashPhoneNumber('+442071234567');
|
||||
const hash2 = hashPhoneNumber('442071234567');
|
||||
const hash3 = hashPhoneNumber('44 20 7123 4567');
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
expect(hash1).toBe(hash3);
|
||||
});
|
||||
|
||||
it('produces SHA-256 hash with prefix', () => {
|
||||
const hash = hashPhoneNumber('+14155552671');
|
||||
expect(hash).toMatch(/^sha256_[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('strips non-digit characters before hashing', () => {
|
||||
const cleanHash = hashPhoneNumber('+14155552671');
|
||||
const dirtyHash = hashPhoneNumber('+1 (415) 555-2671 x123');
|
||||
|
||||
expect(cleanHash).toBe(dirtyHash);
|
||||
});
|
||||
|
||||
it('handles 10-digit US numbers by adding country code', () => {
|
||||
const withCountryCode = hashPhoneNumber('+14155552671');
|
||||
const withoutCountryCode = hashPhoneNumber('4155552671');
|
||||
|
||||
expect(withCountryCode).toBe(withoutCountryCode);
|
||||
});
|
||||
|
||||
it('handles 11-digit US numbers starting with 1', () => {
|
||||
const withPlus = hashPhoneNumber('+14155552671');
|
||||
const withoutPlus = hashPhoneNumber('14155552671');
|
||||
|
||||
expect(withPlus).toBe(withoutPlus);
|
||||
});
|
||||
|
||||
it('different phone numbers produce different hashes', () => {
|
||||
const hash1 = hashPhoneNumber('+14155552671');
|
||||
const hash2 = hashPhoneNumber('+442071234567');
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it('hash matches expected SHA-256 of normalized input', () => {
|
||||
const normalized = '+14155552671';
|
||||
const expectedHash = crypto.createHash('sha256').update(normalized).digest('hex');
|
||||
|
||||
const result = hashPhoneNumber('+14155552671');
|
||||
expect(result).toBe(`sha256_${expectedHash}`);
|
||||
});
|
||||
});
|
||||
@@ -2,23 +2,53 @@ import { z } from 'zod';
|
||||
|
||||
// Environment variables for analytics
|
||||
const envSchema = z.object({
|
||||
MIXPANEL_TOKEN: z.string(),
|
||||
MIXPANEL_TOKEN: z.string().min(1, 'MIXPANEL_TOKEN is required for analytics'),
|
||||
MIXPANEL_API_SECRET: z.string().optional(),
|
||||
GA4_MEASUREMENT_ID: z.string(),
|
||||
GA4_MEASUREMENT_ID: z.string().min(1, 'GA4_MEASUREMENT_ID is required for analytics'),
|
||||
GA4_API_SECRET: z.string().optional(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
||||
ANALYTICS_ENV: z.enum(['development', 'production', 'staging']).default('development'),
|
||||
});
|
||||
|
||||
function getEnvValue(key: string): string | undefined {
|
||||
const value = process.env[key];
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const rawEnv = {
|
||||
MIXPANEL_TOKEN: getEnvValue('MIXPANEL_TOKEN'),
|
||||
MIXPANEL_API_SECRET: getEnvValue('MIXPANEL_API_SECRET'),
|
||||
GA4_MEASUREMENT_ID: getEnvValue('GA4_MEASUREMENT_ID'),
|
||||
GA4_API_SECRET: getEnvValue('GA4_API_SECRET'),
|
||||
STRIPE_WEBHOOK_SECRET: getEnvValue('STRIPE_WEBHOOK_SECRET'),
|
||||
ANALYTICS_ENV: getEnvValue('ANALYTICS_ENV'),
|
||||
};
|
||||
|
||||
const missingRequired: string[] = [];
|
||||
if (!rawEnv.MIXPANEL_TOKEN) missingRequired.push('MIXPANEL_TOKEN');
|
||||
if (!rawEnv.GA4_MEASUREMENT_ID) missingRequired.push('GA4_MEASUREMENT_ID');
|
||||
|
||||
if (missingRequired.length > 0) {
|
||||
console.warn(
|
||||
`[Analytics] Missing required environment variables: ${missingRequired.join(', ')}. ` +
|
||||
`Analytics will operate in degraded mode. Set these in your .env file.`
|
||||
);
|
||||
}
|
||||
|
||||
export const analyticsEnv = envSchema.parse({
|
||||
MIXPANEL_TOKEN: process.env.MIXPANEL_TOKEN,
|
||||
MIXPANEL_API_SECRET: process.env.MIXPANEL_API_SECRET,
|
||||
GA4_MEASUREMENT_ID: process.env.GA4_MEASUREMENT_ID,
|
||||
GA4_API_SECRET: process.env.GA4_API_SECRET,
|
||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
ANALYTICS_ENV: process.env.ANALYTICS_ENV,
|
||||
MIXPANEL_TOKEN: rawEnv.MIXPANEL_TOKEN || '__MISSING__',
|
||||
MIXPANEL_API_SECRET: rawEnv.MIXPANEL_API_SECRET,
|
||||
GA4_MEASUREMENT_ID: rawEnv.GA4_MEASUREMENT_ID || '__MISSING__',
|
||||
GA4_API_SECRET: rawEnv.GA4_API_SECRET,
|
||||
STRIPE_WEBHOOK_SECRET: rawEnv.STRIPE_WEBHOOK_SECRET,
|
||||
ANALYTICS_ENV: rawEnv.ANALYTICS_ENV,
|
||||
});
|
||||
|
||||
export const isAnalyticsConfigured = !missingRequired.length;
|
||||
|
||||
// Event taxonomy
|
||||
export enum EventType {
|
||||
// User events
|
||||
@@ -27,13 +57,13 @@ export enum EventType {
|
||||
USER_LOGGED_OUT = 'user_logged_out',
|
||||
USER_UPGRADED = 'user_upgraded',
|
||||
USER_DOWNGRADED = 'user_downgraded',
|
||||
|
||||
|
||||
// Subscription events
|
||||
SUBSCRIPTION_CREATED = 'subscription_created',
|
||||
SUBSCRIPTION_UPDATED = 'subscription_updated',
|
||||
SUBSCRIPTION_CANCELLED = 'subscription_cancelled',
|
||||
SUBSCRIPTION_RENEWED = 'subscription_renewed',
|
||||
|
||||
|
||||
// DarkWatch events
|
||||
DARK_WEB_SCAN_STARTED = 'dark_web_scan_started',
|
||||
DARK_WEB_SCAN_COMPLETED = 'dark_web_scan_completed',
|
||||
@@ -41,20 +71,20 @@ export enum EventType {
|
||||
EXPOSURE_RESOLVED = 'exposure_resolved',
|
||||
WATCHLIST_ITEM_ADDED = 'watchlist_item_added',
|
||||
WATCHLIST_ITEM_REMOVED = 'watchlist_item_removed',
|
||||
|
||||
|
||||
// VoicePrint events
|
||||
VOICE_ENROLLED = 'voice_enrolled',
|
||||
VOICE_ANALYZED = 'voice_analyzed',
|
||||
VOICE_MATCH_FOUND = 'voice_match_found',
|
||||
SYNTHETIC_VOICE_DETECTED = 'synthetic_voice_detected',
|
||||
|
||||
|
||||
// SpamShield events
|
||||
CALL_ANALYZED = 'call_analyzed',
|
||||
SMS_ANALYZED = 'sms_analyzed',
|
||||
SPAM_BLOCKED = 'spam_blocked',
|
||||
SPAM_FLAGGED = 'spam_flagged',
|
||||
SPAM_FEEDBACK_SUBMITTED = 'spam_feedback_submitted',
|
||||
|
||||
|
||||
// KPI events
|
||||
MRR_UPDATED = 'mrr_updated',
|
||||
CONVERSION_OCCURRED = 'conversion_occurred',
|
||||
@@ -63,15 +93,15 @@ export enum EventType {
|
||||
REFERRAL_CONVERTED = 'referral_converted',
|
||||
}
|
||||
|
||||
// Event properties schema
|
||||
// Event properties schema - accepts common properties and allows extension
|
||||
export const eventPropertiesSchema = z.object({
|
||||
userId: z.string().optional(),
|
||||
sessionId: z.string().optional(),
|
||||
timestamp: z.date().optional(),
|
||||
timestamp: z.union([z.date(), z.string().datetime()]).optional(),
|
||||
platform: z.enum(['web', 'mobile', 'desktop', 'api']).optional(),
|
||||
version: z.string().optional(),
|
||||
environment: z.string().optional(),
|
||||
});
|
||||
}).passthrough();
|
||||
|
||||
// KPI definitions
|
||||
export const kpiDefinitions = {
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
eventPropertiesSchema,
|
||||
kpiDefinitions,
|
||||
alertThresholds,
|
||||
isAnalyticsConfigured,
|
||||
} from './config/analytics.config';
|
||||
|
||||
// Services
|
||||
@@ -16,3 +17,8 @@ export {
|
||||
GA4Service,
|
||||
ga4Service,
|
||||
} from './services/ga4.service';
|
||||
|
||||
// Utils
|
||||
export {
|
||||
hashPhoneNumber,
|
||||
} from './utils/phone-hash';
|
||||
|
||||
@@ -4,14 +4,22 @@ import { hashPhoneNumber } from '../utils/phone-hash';
|
||||
|
||||
// Mixpanel service
|
||||
export class MixpanelService {
|
||||
private static _instance: MixpanelService | null = null;
|
||||
private client: Analytics;
|
||||
|
||||
constructor() {
|
||||
private constructor() {
|
||||
this.client = new Analytics({
|
||||
apiKey: analyticsEnv.MIXPANEL_TOKEN,
|
||||
});
|
||||
}
|
||||
|
||||
public static getInstance(): MixpanelService {
|
||||
if (!MixpanelService._instance) {
|
||||
MixpanelService._instance = new MixpanelService();
|
||||
}
|
||||
return MixpanelService._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track an event in Mixpanel
|
||||
*/
|
||||
@@ -21,13 +29,14 @@ export class MixpanelService {
|
||||
properties?: Record<string, any>
|
||||
): Promise<void> {
|
||||
const validatedProperties = eventPropertiesSchema.parse(properties);
|
||||
|
||||
|
||||
this.client.track({
|
||||
event,
|
||||
distinctId,
|
||||
properties: {
|
||||
...validatedProperties,
|
||||
...properties,
|
||||
...validatedProperties,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -113,5 +122,5 @@ export class MixpanelService {
|
||||
}
|
||||
}
|
||||
|
||||
// Export instance
|
||||
export const mixpanelService = new MixpanelService();
|
||||
// Export singleton instance
|
||||
export const mixpanelService = MixpanelService.getInstance();
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Hash a phone number for analytics purposes
|
||||
* Uses SHA-256 for consistent, cryptographically strong hashing
|
||||
* Normalize phone number to E.164 format before hashing.
|
||||
* Strips all non-digit characters, handles common formats.
|
||||
* Ensures consistent hashing regardless of input format.
|
||||
*/
|
||||
function normalizePhoneNumber(phoneNumber: string): string {
|
||||
const digits = phoneNumber.replace(/\D/g, '');
|
||||
|
||||
if (digits.length === 11 && digits.startsWith('1')) {
|
||||
return `+${digits}`;
|
||||
}
|
||||
|
||||
if (digits.length === 10) {
|
||||
return `+1${digits}`;
|
||||
}
|
||||
|
||||
if (digits.length > 10 && !digits.startsWith('1')) {
|
||||
return `+${digits}`;
|
||||
}
|
||||
|
||||
return `+${digits}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a phone number for analytics purposes.
|
||||
* Normalizes to E.164 before hashing so different formats
|
||||
* (+1-415-555-2671, 4155552671, +14155552671) produce the same hash.
|
||||
* Uses SHA-256 for consistent, cryptographically strong hashing.
|
||||
*/
|
||||
export function hashPhoneNumber(phoneNumber: string): string {
|
||||
const hash = crypto.createHash('sha256').update(phoneNumber).digest('hex');
|
||||
const normalized = normalizePhoneNumber(phoneNumber);
|
||||
const hash = crypto.createHash('sha256').update(normalized).digest('hex');
|
||||
return `sha256_${hash}`;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@shieldsai/shared-analytics": "workspace:*",
|
||||
"@shieldai/db": "workspace:*",
|
||||
"@shieldai/types": "workspace:*",
|
||||
"@shieldai/correlation": "workspace:*",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mixpanelService, EventType } from '@shieldsai/shared-analytics';
|
||||
import { FieldEncryptionService } from '@shieldai/db';
|
||||
|
||||
export interface SpamBlockedEvent {
|
||||
@@ -30,6 +31,14 @@ const DEFAULT_CONFIG: Required<MixpanelConfig> = {
|
||||
enableLogging: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* SpamShield analytics adapter.
|
||||
* Delegates to the shared MixpanelService for consistent event tracking
|
||||
* across the ShieldAI platform, while maintaining spam-specific interfaces.
|
||||
*
|
||||
* @deprecated Use {@link @shieldsai/shared-analytics#MixpanelService} directly
|
||||
* for new analytics code. This wrapper maintains backward compatibility.
|
||||
*/
|
||||
export class MixpanelService {
|
||||
private readonly config: Required<MixpanelConfig>;
|
||||
private readonly events: MixpanelEventProperties[] = [];
|
||||
@@ -58,15 +67,24 @@ export class MixpanelService {
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.track('spam_blocked', properties);
|
||||
await mixpanelService.track(EventType.SPAM_BLOCKED, properties.phoneNumberHash, {
|
||||
decision: event.decision,
|
||||
confidence: event.confidence,
|
||||
ruleMatches: event.ruleMatches,
|
||||
timestamp: event.timestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
...properties,
|
||||
...response,
|
||||
};
|
||||
return properties;
|
||||
}
|
||||
|
||||
async track(eventName: string, properties: Record<string, any>): Promise<Record<string, any>> {
|
||||
const mpEvent = Object.values(EventType).find(e => e === eventName) as EventType | undefined;
|
||||
|
||||
if (mpEvent) {
|
||||
await mixpanelService.track(mpEvent, properties.phoneNumberHash || 'anonymous', properties);
|
||||
return { status: 200 };
|
||||
}
|
||||
|
||||
const url = `https://${this.config.apiHost}/track`;
|
||||
|
||||
const payload = {
|
||||
|
||||
Reference in New Issue
Block a user