From b6b0f86d73adc6292ebe96e5b9bc3144ad2dee54 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sat, 2 May 2026 09:21:42 -0400 Subject: [PATCH] Add MixpanelService with hashed phoneNumber in spamBlocked() (FRE-4519) Create MixpanelService that uses FieldEncryptionService.hashPhoneNumber() to SHA-256 hash phone numbers before sending to Mixpanel analytics. - Implement spamBlocked() method with phone number hashing - Add 16 unit tests verifying hash correctness and API behavior - Export service from package index Co-Authored-By: Paperclip --- services/spamshield/src/index.ts | 1 + .../src/services/mixpanel.service.ts | 116 ++++++ services/spamshield/test/mixpanel.test.ts | 340 ++++++++++++++++++ 3 files changed, 457 insertions(+) create mode 100644 services/spamshield/src/services/mixpanel.service.ts create mode 100644 services/spamshield/test/mixpanel.test.ts diff --git a/services/spamshield/src/index.ts b/services/spamshield/src/index.ts index b7fab8e..95638a2 100644 --- a/services/spamshield/src/index.ts +++ b/services/spamshield/src/index.ts @@ -1,4 +1,5 @@ export * from './services/spamshield.service'; +export * from './services/mixpanel.service'; export * from './circuit-breaker'; export * from './config/spamshield.config'; export * from './utils/phone-validation'; diff --git a/services/spamshield/src/services/mixpanel.service.ts b/services/spamshield/src/services/mixpanel.service.ts new file mode 100644 index 0000000..e4f08cd --- /dev/null +++ b/services/spamshield/src/services/mixpanel.service.ts @@ -0,0 +1,116 @@ +import { FieldEncryptionService } from '@shieldai/db'; + +export interface SpamBlockedEvent { + phoneNumber: string; + decision: 'BLOCK' | 'FLAG'; + confidence: number; + ruleMatches?: string[]; + timestamp: Date; +} + +export interface MixpanelEventProperties { + $event_name: string; + phoneNumberHash: string; + decision: 'BLOCK' | 'FLAG'; + confidence: number; + ruleMatches?: string[]; + timestamp: string; + [key: string]: any; +} + +export interface MixpanelConfig { + token: string; + apiHost: string; + enableLogging?: boolean; +} + +const DEFAULT_CONFIG: Required = { + token: process.env.MIXPANEL_TOKEN || '', + apiHost: 'api.mixpanel.com', + enableLogging: true, +}; + +export class MixpanelService { + private readonly config: Required; + private readonly events: MixpanelEventProperties[] = []; + + constructor(config?: MixpanelConfig) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + async spamBlocked(event: SpamBlockedEvent): Promise { + const phoneNumberHash = FieldEncryptionService.hashPhoneNumber(event.phoneNumber); + + const properties: MixpanelEventProperties = { + $event_name: 'spam_blocked', + phoneNumberHash, + decision: event.decision, + confidence: event.confidence, + ruleMatches: event.ruleMatches, + timestamp: event.timestamp.toISOString(), + }; + + this.events.push(properties); + + if (this.config.enableLogging) { + console.log( + `[Mixpanel] Event: spam_blocked, phoneNumberHash: ${phoneNumberHash}, decision: ${event.decision}` + ); + } + + const response = await this.track('spam_blocked', properties); + + return { + ...properties, + ...response, + }; + } + + async track(eventName: string, properties: Record): Promise> { + const url = `https://${this.config.apiHost}/track`; + + const payload = { + event: eventName, + properties: { + ...properties, + $token: this.config.token, + time: properties.timestamp ? new Date(properties.timestamp).getTime() / 1000 : Date.now() / 1000, + }, + }; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ data: JSON.stringify(payload) }), + }); + + if (!response.ok) { + if (this.config.enableLogging) { + console.log(`[Mixpanel] Track failed: ${response.status} ${response.statusText}`); + } + } + + return { status: response.status }; + } catch (error) { + if (this.config.enableLogging) { + console.log(`[Mixpanel] Track error:`, error); + } + return { status: 0, error: String(error) }; + } + } + + getEvents(): MixpanelEventProperties[] { + return [...this.events]; + } + + clearEvents(): void { + this.events.length = 0; + } + + getConfig(): Required { + return { ...this.config }; + } +} diff --git a/services/spamshield/test/mixpanel.test.ts b/services/spamshield/test/mixpanel.test.ts new file mode 100644 index 0000000..0a946d7 --- /dev/null +++ b/services/spamshield/test/mixpanel.test.ts @@ -0,0 +1,340 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { MixpanelService, SpamBlockedEvent, MixpanelEventProperties } from '../src/services/mixpanel.service'; +import { FieldEncryptionService } from '@shieldai/db'; +import crypto from 'crypto'; + +const mockFetch = vi.fn(); +(global as any).fetch = mockFetch; + +describe('MixpanelService', () => { + let service: MixpanelService; + + beforeEach(() => { + service = new MixpanelService({ + token: 'test-token', + apiHost: 'api.mixpanel.com', + enableLogging: false, + }); + mockFetch.mockReset(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('spamBlocked', () => { + it('hashes phoneNumber using SHA-256 before sending to Mixpanel', async () => { + const phoneNumber = '+14155552671'; + const expectedHash = crypto.createHash('sha256').update(phoneNumber).digest('hex'); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + const event: SpamBlockedEvent = { + phoneNumber, + decision: 'BLOCK', + confidence: 0.92, + ruleMatches: ['rule-1'], + timestamp: new Date('2026-01-01T00:00:00Z'), + }; + + const result = await service.spamBlocked(event); + + expect(result.phoneNumberHash).toBe(expectedHash); + expect(result.phoneNumberHash).not.toBe(phoneNumber); + expect(result.phoneNumberHash.length).toBe(64); + }); + + it('uses FieldEncryptionService.hashPhoneNumber for hashing', async () => { + const phoneNumber = '+14155552671'; + const hashSpy = vi.spyOn(FieldEncryptionService, 'hashPhoneNumber'); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + const event: SpamBlockedEvent = { + phoneNumber, + decision: 'BLOCK', + confidence: 0.85, + timestamp: new Date(), + }; + + await service.spamBlocked(event); + + expect(hashSpy).toHaveBeenCalledWith(phoneNumber); + hashSpy.mockRestore(); + }); + + it('sends hashed phoneNumber in event properties', async () => { + const phoneNumber = '+14155552671'; + const expectedHash = FieldEncryptionService.hashPhoneNumber(phoneNumber); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + const event: SpamBlockedEvent = { + phoneNumber, + decision: 'FLAG', + confidence: 0.65, + ruleMatches: ['rule-2', 'rule-3'], + timestamp: new Date('2026-01-01T00:00:00Z'), + }; + + const result = await service.spamBlocked(event); + + expect(result.$event_name).toBe('spam_blocked'); + expect(result.phoneNumberHash).toBe(expectedHash); + expect(result.decision).toBe('FLAG'); + expect(result.confidence).toBe(0.65); + expect(result.ruleMatches).toEqual(['rule-2', 'rule-3']); + expect(result.timestamp).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('includes raw phoneNumber only internally, sends hash to Mixpanel API', async () => { + const phoneNumber = '+14155552671'; + const expectedHash = FieldEncryptionService.hashPhoneNumber(phoneNumber); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + const event: SpamBlockedEvent = { + phoneNumber, + decision: 'BLOCK', + confidence: 0.95, + timestamp: new Date(), + }; + + await service.spamBlocked(event); + + const callArgs = mockFetch.mock.calls[0] as any[]; + const body = JSON.parse(callArgs[1].body); + const trackPayload = JSON.parse(body.data); + + expect(trackPayload.event).toBe('spam_blocked'); + expect(trackPayload.properties.phoneNumberHash).toBe(expectedHash); + expect(trackPayload.properties.phoneNumberHash).not.toBe(phoneNumber); + }); + + it('handles different phone numbers with unique hashes', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + } as Response); + + const phone1 = '+14155552671'; + const phone2 = '+442071234567'; + + const event1: SpamBlockedEvent = { + phoneNumber: phone1, + decision: 'BLOCK', + confidence: 0.9, + timestamp: new Date(), + }; + + const event2: SpamBlockedEvent = { + phoneNumber: phone2, + decision: 'FLAG', + confidence: 0.7, + timestamp: new Date(), + }; + + const result1 = await service.spamBlocked(event1); + const result2 = await service.spamBlocked(event2); + + expect(result1.phoneNumberHash).not.toBe(result2.phoneNumberHash); + expect(result1.phoneNumberHash).toBe(FieldEncryptionService.hashPhoneNumber(phone1)); + expect(result2.phoneNumberHash).toBe(FieldEncryptionService.hashPhoneNumber(phone2)); + }); + + it('produces deterministic hash for same phone number', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + } as Response); + + const phoneNumber = '+14155552671'; + + const event1: SpamBlockedEvent = { + phoneNumber, + decision: 'BLOCK', + confidence: 0.9, + timestamp: new Date(), + }; + + const event2: SpamBlockedEvent = { + phoneNumber, + decision: 'FLAG', + confidence: 0.7, + timestamp: new Date(), + }; + + const result1 = await service.spamBlocked(event1); + const result2 = await service.spamBlocked(event2); + + expect(result1.phoneNumberHash).toBe(result2.phoneNumberHash); + }); + + it('stores event in internal events array', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + const event: SpamBlockedEvent = { + phoneNumber: '+14155552671', + decision: 'BLOCK', + confidence: 0.9, + timestamp: new Date(), + }; + + await service.spamBlocked(event); + + const events = service.getEvents(); + expect(events).toHaveLength(1); + expect(events[0].phoneNumberHash).toBe(FieldEncryptionService.hashPhoneNumber('+14155552671')); + expect(events[0].$event_name).toBe('spam_blocked'); + }); + + it('handles BLOCK and FLAG decisions', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + } as Response); + + const blockEvent: SpamBlockedEvent = { + phoneNumber: '+14155552671', + decision: 'BLOCK', + confidence: 0.95, + timestamp: new Date(), + }; + + const flagEvent: SpamBlockedEvent = { + phoneNumber: '+14155552671', + decision: 'FLAG', + confidence: 0.6, + timestamp: new Date(), + }; + + const blockResult = await service.spamBlocked(blockEvent); + const flagResult = await service.spamBlocked(flagEvent); + + expect(blockResult.decision).toBe('BLOCK'); + expect(flagResult.decision).toBe('FLAG'); + }); + + it('gracefully handles Mixpanel API failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + } as Response); + + const event: SpamBlockedEvent = { + phoneNumber: '+14155552671', + decision: 'BLOCK', + confidence: 0.9, + timestamp: new Date(), + }; + + const result = await service.spamBlocked(event); + + expect(result.phoneNumberHash).toBe(FieldEncryptionService.hashPhoneNumber('+14155552671')); + expect(result.status).toBe(500); + }); + + it('gracefully handles Mixpanel API network error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + } as Response); + + const event: SpamBlockedEvent = { + phoneNumber: '+14155552671', + decision: 'BLOCK', + confidence: 0.9, + timestamp: new Date(), + }; + + const result = await service.spamBlocked(event); + + expect(result.phoneNumberHash).toBe(FieldEncryptionService.hashPhoneNumber('+14155552671')); + expect(result.status).toBe(429); + }); + }); + + describe('hashPhoneNumber consistency', () => { + it('hash matches SHA-256 hex digest for known input', () => { + const phoneNumber = '+14155552671'; + const hash = FieldEncryptionService.hashPhoneNumber(phoneNumber); + const expected = crypto.createHash('sha256').update(phoneNumber).digest('hex'); + + expect(hash).toBe(expected); + expect(hash).toBe('cb6880e416769253645cb9c6b8989154bf66a56a77fc14c81fb1019663cbb928'); + }); + + it('hash is 64 characters (SHA-256 hex)', () => { + const hash = FieldEncryptionService.hashPhoneNumber('+14155552671'); + expect(hash.length).toBe(64); + expect(/^[0-9a-f]{64}$/.test(hash)).toBe(true); + }); + + it('different phone numbers produce different hashes', () => { + const hash1 = FieldEncryptionService.hashPhoneNumber('+14155552671'); + const hash2 = FieldEncryptionService.hashPhoneNumber('+442071234567'); + expect(hash1).not.toBe(hash2); + }); + }); + + describe('configuration', () => { + it('uses custom config when provided', () => { + const customService = new MixpanelService({ + token: 'custom-token', + apiHost: 'custom.api.com', + enableLogging: true, + }); + + const config = customService.getConfig(); + expect(config.token).toBe('custom-token'); + expect(config.apiHost).toBe('custom.api.com'); + expect(config.enableLogging).toBe(true); + }); + + it('uses default config when no config provided', () => { + const defaultService = new MixpanelService(); + const config = defaultService.getConfig(); + expect(config.apiHost).toBe('api.mixpanel.com'); + }); + }); + + describe('event management', () => { + it('clearEvents removes all stored events', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + } as Response); + + const event: SpamBlockedEvent = { + phoneNumber: '+14155552671', + decision: 'BLOCK', + confidence: 0.9, + timestamp: new Date(), + }; + + await service.spamBlocked(event); + expect(service.getEvents()).toHaveLength(1); + + service.clearEvents(); + expect(service.getEvents()).toHaveLength(0); + }); + }); +});