From 06ca3ec0cf37d4e00e705366eb7cf816921ada32 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 17 May 2026 15:37:21 -0400 Subject: [PATCH] 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 --- packages/shared-analytics/package.json | 9 +- .../src/__tests__/analytics.config.test.ts | 113 +++++++++++ .../src/__tests__/mixpanel.service.test.ts | 180 ++++++++++++++++++ .../src/__tests__/phone-hash.test.ts | 74 +++++++ .../src/config/analytics.config.ts | 64 +++++-- packages/shared-analytics/src/index.ts | 6 + .../src/services/mixpanel.service.ts | 19 +- .../shared-analytics/src/utils/phone-hash.ts | 32 +++- services/spamshield/package.json | 1 + .../src/services/mixpanel.service.ts | 28 ++- 10 files changed, 494 insertions(+), 32 deletions(-) create mode 100644 packages/shared-analytics/src/__tests__/analytics.config.test.ts create mode 100644 packages/shared-analytics/src/__tests__/mixpanel.service.test.ts create mode 100644 packages/shared-analytics/src/__tests__/phone-hash.test.ts diff --git a/packages/shared-analytics/package.json b/packages/shared-analytics/package.json index d2352c6..279215e 100644 --- a/packages/shared-analytics/package.json +++ b/packages/shared-analytics/package.json @@ -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" } } diff --git a/packages/shared-analytics/src/__tests__/analytics.config.test.ts b/packages/shared-analytics/src/__tests__/analytics.config.test.ts new file mode 100644 index 0000000..e6db4a6 --- /dev/null +++ b/packages/shared-analytics/src/__tests__/analytics.config.test.ts @@ -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'); + }); +}); diff --git a/packages/shared-analytics/src/__tests__/mixpanel.service.test.ts b/packages/shared-analytics/src/__tests__/mixpanel.service.test.ts new file mode 100644 index 0000000..40cc845 --- /dev/null +++ b/packages/shared-analytics/src/__tests__/mixpanel.service.test.ts @@ -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'); + }); +}); diff --git a/packages/shared-analytics/src/__tests__/phone-hash.test.ts b/packages/shared-analytics/src/__tests__/phone-hash.test.ts new file mode 100644 index 0000000..c8a2b2e --- /dev/null +++ b/packages/shared-analytics/src/__tests__/phone-hash.test.ts @@ -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}`); + }); +}); diff --git a/packages/shared-analytics/src/config/analytics.config.ts b/packages/shared-analytics/src/config/analytics.config.ts index 06ca140..3c7441c 100644 --- a/packages/shared-analytics/src/config/analytics.config.ts +++ b/packages/shared-analytics/src/config/analytics.config.ts @@ -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 = { diff --git a/packages/shared-analytics/src/index.ts b/packages/shared-analytics/src/index.ts index c51357f..56fef7d 100644 --- a/packages/shared-analytics/src/index.ts +++ b/packages/shared-analytics/src/index.ts @@ -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'; diff --git a/packages/shared-analytics/src/services/mixpanel.service.ts b/packages/shared-analytics/src/services/mixpanel.service.ts index 4ca2102..260d8ac 100644 --- a/packages/shared-analytics/src/services/mixpanel.service.ts +++ b/packages/shared-analytics/src/services/mixpanel.service.ts @@ -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 ): Promise { 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(); diff --git a/packages/shared-analytics/src/utils/phone-hash.ts b/packages/shared-analytics/src/utils/phone-hash.ts index 879dd2e..17adcbd 100644 --- a/packages/shared-analytics/src/utils/phone-hash.ts +++ b/packages/shared-analytics/src/utils/phone-hash.ts @@ -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}`; } diff --git a/services/spamshield/package.json b/services/spamshield/package.json index f43e12b..590966b 100644 --- a/services/spamshield/package.json +++ b/services/spamshield/package.json @@ -12,6 +12,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@shieldsai/shared-analytics": "workspace:*", "@shieldai/db": "workspace:*", "@shieldai/types": "workspace:*", "@shieldai/correlation": "workspace:*", diff --git a/services/spamshield/src/services/mixpanel.service.ts b/services/spamshield/src/services/mixpanel.service.ts index e4f08cd..fb5648a 100644 --- a/services/spamshield/src/services/mixpanel.service.ts +++ b/services/spamshield/src/services/mixpanel.service.ts @@ -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 = { 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; 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): Promise> { + 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 = {