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 <noreply@paperclip.ing>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
export * from './services/spamshield.service';
|
export * from './services/spamshield.service';
|
||||||
|
export * from './services/mixpanel.service';
|
||||||
export * from './circuit-breaker';
|
export * from './circuit-breaker';
|
||||||
export * from './config/spamshield.config';
|
export * from './config/spamshield.config';
|
||||||
export * from './utils/phone-validation';
|
export * from './utils/phone-validation';
|
||||||
|
|||||||
116
services/spamshield/src/services/mixpanel.service.ts
Normal file
116
services/spamshield/src/services/mixpanel.service.ts
Normal file
@@ -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<MixpanelConfig> = {
|
||||||
|
token: process.env.MIXPANEL_TOKEN || '',
|
||||||
|
apiHost: 'api.mixpanel.com',
|
||||||
|
enableLogging: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MixpanelService {
|
||||||
|
private readonly config: Required<MixpanelConfig>;
|
||||||
|
private readonly events: MixpanelEventProperties[] = [];
|
||||||
|
|
||||||
|
constructor(config?: MixpanelConfig) {
|
||||||
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
async spamBlocked(event: SpamBlockedEvent): Promise<MixpanelEventProperties> {
|
||||||
|
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<string, any>): Promise<Record<string, any>> {
|
||||||
|
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<MixpanelConfig> {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
}
|
||||||
340
services/spamshield/test/mixpanel.test.ts
Normal file
340
services/spamshield/test/mixpanel.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user