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:
2026-05-02 09:21:42 -04:00
parent b01b79d02a
commit b6b0f86d73
3 changed files with 457 additions and 0 deletions

View File

@@ -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';

View 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 };
}
}