728 lines
22 KiB
TypeScript
728 lines
22 KiB
TypeScript
import crypto from 'crypto';
|
|
import { db } from './database.js';
|
|
|
|
/**
|
|
* Device fingerprint data structure
|
|
*/
|
|
export interface FingerprintData {
|
|
ipAddress: string;
|
|
userAgent: string;
|
|
timezone?: string;
|
|
language?: string;
|
|
screenWidth?: number;
|
|
screenHeight?: number;
|
|
platform?: string;
|
|
platformVersion?: string;
|
|
}
|
|
|
|
/**
|
|
* Fingerprint match result with confidence scoring
|
|
*/
|
|
export interface FingerprintMatch {
|
|
clickId: string;
|
|
linkId: string;
|
|
confidenceScore: number;
|
|
matchedFactors: string[];
|
|
clickedAt: Date;
|
|
}
|
|
|
|
type FingerprintFactorDebug = {
|
|
ip: {
|
|
matched: boolean;
|
|
installAttributable: boolean;
|
|
clickAttributable: boolean;
|
|
installValue: string;
|
|
clickValue: string;
|
|
};
|
|
userAgent: {
|
|
matched: boolean;
|
|
installValue: string;
|
|
clickValue: string;
|
|
mobilePlatformBridge: boolean;
|
|
};
|
|
platform: {
|
|
matched: boolean;
|
|
installValue: string;
|
|
clickValue: string;
|
|
};
|
|
timezone: {
|
|
matched: boolean;
|
|
installValue: string;
|
|
clickValue: string;
|
|
};
|
|
language: {
|
|
matched: boolean;
|
|
installValue: string;
|
|
clickValue: string;
|
|
};
|
|
screen: {
|
|
matched: boolean;
|
|
installValue: string;
|
|
clickValue: string;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Scoring weights for probabilistic matching
|
|
* Total should equal 100 for percentage-based confidence
|
|
*/
|
|
const FINGERPRINT_WEIGHTS = {
|
|
IP_ADDRESS: 40,
|
|
USER_AGENT: 30,
|
|
TIMEZONE: 10,
|
|
LANGUAGE: 10,
|
|
SCREEN_RESOLUTION: 10,
|
|
PLATFORM: 20,
|
|
};
|
|
|
|
/**
|
|
* Default attribution window in hours (7 days)
|
|
*/
|
|
export const DEFAULT_ATTRIBUTION_WINDOW_HOURS = 168;
|
|
|
|
/**
|
|
* Minimum confidence threshold for attribution (70%)
|
|
*/
|
|
export const CONFIDENCE_THRESHOLD = 70;
|
|
const ATTRIBUTION_DEBUG_ENV = 'LINKFORTY_DEBUG_ATTRIBUTION';
|
|
|
|
/**
|
|
* Generate a fingerprint hash from device data
|
|
* Uses SHA-256 hash of concatenated device attributes
|
|
*/
|
|
export function generateFingerprintHash(data: FingerprintData): string {
|
|
const components = [
|
|
data.ipAddress || '',
|
|
data.userAgent || '',
|
|
data.timezone || '',
|
|
data.language || '',
|
|
data.screenWidth?.toString() || '',
|
|
data.screenHeight?.toString() || '',
|
|
data.platform || '',
|
|
data.platformVersion || '',
|
|
];
|
|
|
|
const concatenated = components.join('|');
|
|
return crypto.createHash('sha256').update(concatenated).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Normalize IP address for comparison
|
|
* Handles IPv4 and IPv6, removes subnet variations
|
|
*/
|
|
function ipv4ToInt(ip: string): number | null {
|
|
const parts = ip.split('.');
|
|
if (parts.length !== 4) return null;
|
|
let n = 0;
|
|
for (const p of parts) {
|
|
const o = Number(p);
|
|
if (!Number.isInteger(o) || o < 0 || o > 255) return null;
|
|
n = (n << 8) | o;
|
|
}
|
|
return n >>> 0;
|
|
}
|
|
|
|
function inCidr4(ipInt: number, baseIp: string, prefix: number): boolean {
|
|
const base = ipv4ToInt(baseIp);
|
|
if (base === null) return false;
|
|
const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
|
|
return (ipInt & mask) === (base & mask);
|
|
}
|
|
|
|
// Shared / non-routable IPv4 ranges that can't identify a single device:
|
|
// carrier-grade NAT, RFC1918 private, loopback, link-local, etc. Two different
|
|
// users routinely share these (mobile carrier NAT, office Wi-Fi, VPN egress).
|
|
const NON_ATTRIBUTABLE_V4: ReadonlyArray<readonly [string, number]> = [
|
|
['0.0.0.0', 8],
|
|
['10.0.0.0', 8],
|
|
['100.64.0.0', 10], // CGNAT (RFC 6598)
|
|
['127.0.0.0', 8], // loopback
|
|
['169.254.0.0', 16], // link-local
|
|
['172.16.0.0', 12], // private
|
|
['192.0.0.0', 24], // IETF protocol assignments
|
|
['192.168.0.0', 16], // private
|
|
['198.18.0.0', 15], // benchmarking
|
|
];
|
|
|
|
/**
|
|
* Whether an IP is specific enough to use as an attribution signal. Returns
|
|
* false for shared/non-routable ranges (CGNAT, RFC1918, loopback, link-local,
|
|
* IPv6 ULA/link-local) where the IP does NOT identify a single device, so it
|
|
* must not contribute to a fingerprint match. Public IPs return true.
|
|
*/
|
|
export function isAttributableIp(ip: string): boolean {
|
|
if (!ip) return false;
|
|
let addr = ip.trim();
|
|
if (addr.startsWith('::ffff:')) addr = addr.slice(7); // IPv4-mapped IPv6
|
|
|
|
// IPv4
|
|
if (addr.includes('.') && !addr.includes(':')) {
|
|
const n = ipv4ToInt(addr);
|
|
if (n === null) return false;
|
|
return !NON_ATTRIBUTABLE_V4.some(([base, prefix]) => inCidr4(n, base, prefix));
|
|
}
|
|
|
|
// IPv6
|
|
if (addr.includes(':')) {
|
|
const low = addr.toLowerCase();
|
|
if (low === '::' || low === '::1') return false; // unspecified / loopback
|
|
if (low.startsWith('fc') || low.startsWith('fd')) return false; // fc00::/7 ULA
|
|
if (low.startsWith('fe8') || low.startsWith('fe9') || low.startsWith('fea') || low.startsWith('feb')) {
|
|
return false; // fe80::/10 link-local
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Not a recognizable IPv4 or IPv6 address.
|
|
return false;
|
|
}
|
|
|
|
function normalizeIP(ip: string): string {
|
|
if (!ip) return '';
|
|
|
|
// For IPv4, use first 3 octets (e.g., 192.168.1.x)
|
|
if (ip.includes('.')) {
|
|
const parts = ip.split('.');
|
|
return parts.slice(0, 3).join('.');
|
|
}
|
|
|
|
// For IPv6, use first 4 groups (e.g., 2001:0db8:85a3:0000:xxxx)
|
|
if (ip.includes(':')) {
|
|
const parts = ip.split(':');
|
|
return parts.slice(0, 4).join(':');
|
|
}
|
|
|
|
return ip;
|
|
}
|
|
|
|
/**
|
|
* Normalize user agent for comparison
|
|
* Extracts key identifiers and removes version numbers
|
|
*/
|
|
function normalizeUserAgent(ua: string): string {
|
|
if (!ua) return '';
|
|
|
|
// Extract platform (iOS, Android, Windows, Mac, Linux)
|
|
const platformMatch = ua.match(/(iPhone|iPad|Android|Windows|Macintosh|Linux)/i);
|
|
const platform = platformMatch ? platformMatch[1] : '';
|
|
|
|
// Extract browser (Chrome, Safari, Firefox, Edge)
|
|
const browserMatch = ua.match(/(Chrome|Safari|Firefox|Edge|Opera)/i);
|
|
const browser = browserMatch ? browserMatch[1] : '';
|
|
|
|
return `${platform}|${browser}`.toLowerCase();
|
|
}
|
|
|
|
function extractPlatformFromUserAgent(ua: string): string {
|
|
if (!ua) return '';
|
|
|
|
const platformMatch = ua.match(/(iPhone|iPad|iOS|Android|Windows|Macintosh|Linux)/i);
|
|
return platformMatch ? normalizePlatform(platformMatch[1]) : '';
|
|
}
|
|
|
|
function normalizePlatform(platform: string | undefined): string {
|
|
if (!platform) return '';
|
|
|
|
const normalized = platform.trim().toLowerCase();
|
|
if (!normalized) return '';
|
|
if (['iphone', 'ipad', 'ios'].includes(normalized)) return 'ios';
|
|
if (normalized === 'android') return 'android';
|
|
if (['mac', 'macos', 'macintosh'].includes(normalized)) return 'mac';
|
|
if (['win', 'windows'].includes(normalized)) return 'windows';
|
|
return normalized;
|
|
}
|
|
|
|
function resolvePlatformFingerprint(data: FingerprintData): string {
|
|
return normalizePlatform(data.platform) || extractPlatformFromUserAgent(data.userAgent);
|
|
}
|
|
|
|
function isMobilePlatform(platform: string): boolean {
|
|
return platform === 'ios' || platform === 'android';
|
|
}
|
|
|
|
function attributionDebugEnabled(): boolean {
|
|
const value = process.env[ATTRIBUTION_DEBUG_ENV]?.trim().toLowerCase();
|
|
return value === '1' || value === 'true' || value === 'yes';
|
|
}
|
|
|
|
function logAttributionDebug(event: string, payload: Record<string, unknown>): void {
|
|
if (!attributionDebugEnabled()) return;
|
|
console.info(`[linkforty][attribution] ${event} ${JSON.stringify(payload)}`);
|
|
}
|
|
|
|
function scoreFingerprintMatch(
|
|
fingerprint1: FingerprintData,
|
|
fingerprint2: FingerprintData
|
|
): { score: number; matchedFactors: string[]; debug: FingerprintFactorDebug } {
|
|
let score = 0;
|
|
const matchedFactors: string[] = [];
|
|
const platform1 = resolvePlatformFingerprint(fingerprint1);
|
|
const platform2 = resolvePlatformFingerprint(fingerprint2);
|
|
const installIpAttributable = isAttributableIp(fingerprint1.ipAddress);
|
|
const clickIpAttributable = isAttributableIp(fingerprint2.ipAddress);
|
|
const ip1 = fingerprint1.ipAddress ? normalizeIP(fingerprint1.ipAddress) : '';
|
|
const ip2 = fingerprint2.ipAddress ? normalizeIP(fingerprint2.ipAddress) : '';
|
|
const ua1 = fingerprint1.userAgent ? normalizeUserAgent(fingerprint1.userAgent) : '';
|
|
const ua2 = fingerprint2.userAgent ? normalizeUserAgent(fingerprint2.userAgent) : '';
|
|
const mobilePlatformBridge = platform1 !== '' && platform1 === platform2 && isMobilePlatform(platform1);
|
|
const language1 = fingerprint1.language?.substring(0, 2).toLowerCase() || '';
|
|
const language2 = fingerprint2.language?.substring(0, 2).toLowerCase() || '';
|
|
const screen1 =
|
|
fingerprint1.screenWidth && fingerprint1.screenHeight
|
|
? `${fingerprint1.screenWidth}x${fingerprint1.screenHeight}`
|
|
: '';
|
|
const screen2 =
|
|
fingerprint2.screenWidth && fingerprint2.screenHeight
|
|
? `${fingerprint2.screenWidth}x${fingerprint2.screenHeight}`
|
|
: '';
|
|
|
|
const debug: FingerprintFactorDebug = {
|
|
ip: {
|
|
matched: false,
|
|
installAttributable: installIpAttributable,
|
|
clickAttributable: clickIpAttributable,
|
|
installValue: ip1,
|
|
clickValue: ip2,
|
|
},
|
|
userAgent: {
|
|
matched: false,
|
|
installValue: ua1,
|
|
clickValue: ua2,
|
|
mobilePlatformBridge,
|
|
},
|
|
platform: {
|
|
matched: false,
|
|
installValue: platform1,
|
|
clickValue: platform2,
|
|
},
|
|
timezone: {
|
|
matched: false,
|
|
installValue: fingerprint1.timezone || '',
|
|
clickValue: fingerprint2.timezone || '',
|
|
},
|
|
language: {
|
|
matched: false,
|
|
installValue: language1,
|
|
clickValue: language2,
|
|
},
|
|
screen: {
|
|
matched: false,
|
|
installValue: screen1,
|
|
clickValue: screen2,
|
|
},
|
|
};
|
|
|
|
if (
|
|
fingerprint1.ipAddress &&
|
|
fingerprint2.ipAddress &&
|
|
installIpAttributable &&
|
|
clickIpAttributable &&
|
|
ip1 === ip2
|
|
) {
|
|
score += FINGERPRINT_WEIGHTS.IP_ADDRESS;
|
|
matchedFactors.push('ip');
|
|
debug.ip.matched = true;
|
|
}
|
|
|
|
if (fingerprint1.userAgent && fingerprint2.userAgent && (ua1 === ua2 || mobilePlatformBridge)) {
|
|
score += FINGERPRINT_WEIGHTS.USER_AGENT;
|
|
matchedFactors.push('user_agent');
|
|
debug.userAgent.matched = true;
|
|
}
|
|
|
|
if (mobilePlatformBridge) {
|
|
score += FINGERPRINT_WEIGHTS.PLATFORM;
|
|
matchedFactors.push('platform');
|
|
debug.platform.matched = true;
|
|
}
|
|
|
|
if (fingerprint1.timezone && fingerprint2.timezone && fingerprint1.timezone === fingerprint2.timezone) {
|
|
score += FINGERPRINT_WEIGHTS.TIMEZONE;
|
|
matchedFactors.push('timezone');
|
|
debug.timezone.matched = true;
|
|
}
|
|
|
|
if (language1 !== '' && language1 === language2) {
|
|
score += FINGERPRINT_WEIGHTS.LANGUAGE;
|
|
matchedFactors.push('language');
|
|
debug.language.matched = true;
|
|
}
|
|
|
|
if (
|
|
fingerprint1.screenWidth &&
|
|
fingerprint1.screenHeight &&
|
|
fingerprint2.screenWidth &&
|
|
fingerprint2.screenHeight &&
|
|
fingerprint1.screenWidth === fingerprint2.screenWidth &&
|
|
fingerprint1.screenHeight === fingerprint2.screenHeight
|
|
) {
|
|
score += FINGERPRINT_WEIGHTS.SCREEN_RESOLUTION;
|
|
matchedFactors.push('screen');
|
|
debug.screen.matched = true;
|
|
}
|
|
|
|
return { score: Math.min(score, 100), matchedFactors, debug };
|
|
}
|
|
|
|
/**
|
|
* Calculate confidence score by comparing two fingerprints
|
|
* Returns a score from 0-100 based on matched components
|
|
*/
|
|
export function calculateConfidenceScore(
|
|
fingerprint1: FingerprintData,
|
|
fingerprint2: FingerprintData
|
|
): { score: number; matchedFactors: string[] } {
|
|
const { score, matchedFactors } = scoreFingerprintMatch(fingerprint1, fingerprint2);
|
|
return { score, matchedFactors };
|
|
}
|
|
|
|
/**
|
|
* Match an install event to potential click events via probabilistic fingerprinting
|
|
* Returns the best match above confidence threshold within attribution window
|
|
*
|
|
* Note: Uses link-specific attribution windows - each link can have its own window
|
|
*/
|
|
export async function matchInstallToClick(
|
|
installFingerprint: FingerprintData,
|
|
attributionWindowHours: number = DEFAULT_ATTRIBUTION_WINDOW_HOURS
|
|
): Promise<FingerprintMatch | null> {
|
|
// Query recent click events within maximum possible attribution window (90 days)
|
|
// We'll validate against each link's specific window during matching
|
|
const maxWindowHours = 2160; // 90 days
|
|
const cutoffTime = new Date(Date.now() - maxWindowHours * 60 * 60 * 1000);
|
|
|
|
const clicksResult = await db.query(
|
|
`SELECT
|
|
ce.id as click_id,
|
|
ce.link_id,
|
|
ce.clicked_at,
|
|
l.attribution_window_hours,
|
|
df.ip_address,
|
|
df.user_agent,
|
|
df.timezone,
|
|
df.language,
|
|
df.screen_width,
|
|
df.screen_height,
|
|
df.platform,
|
|
df.platform_version
|
|
FROM click_events ce
|
|
INNER JOIN device_fingerprints df ON df.click_id = ce.id
|
|
INNER JOIN links l ON ce.link_id = l.id
|
|
WHERE ce.clicked_at >= $1
|
|
ORDER BY ce.clicked_at DESC
|
|
LIMIT 1000`,
|
|
[cutoffTime]
|
|
);
|
|
|
|
if (clicksResult.rows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const installTime = new Date();
|
|
|
|
// Calculate confidence score for each potential match
|
|
let bestMatch: FingerprintMatch | null = null;
|
|
let highestScore = 0;
|
|
|
|
for (const row of clicksResult.rows) {
|
|
// Check if click is within the link's specific attribution window
|
|
const linkWindowHours = row.attribution_window_hours || DEFAULT_ATTRIBUTION_WINDOW_HOURS;
|
|
const clickTime = new Date(row.clicked_at);
|
|
const timeDiffHours = (installTime.getTime() - clickTime.getTime()) / (1000 * 60 * 60);
|
|
|
|
if (timeDiffHours > linkWindowHours) {
|
|
// Click is too old for this link's attribution window, skip it
|
|
continue;
|
|
}
|
|
|
|
const clickFingerprint: FingerprintData = {
|
|
ipAddress: row.ip_address,
|
|
userAgent: row.user_agent,
|
|
timezone: row.timezone,
|
|
language: row.language,
|
|
screenWidth: row.screen_width,
|
|
screenHeight: row.screen_height,
|
|
platform: row.platform,
|
|
platformVersion: row.platform_version,
|
|
};
|
|
|
|
const { score, matchedFactors, debug } = scoreFingerprintMatch(
|
|
installFingerprint,
|
|
clickFingerprint
|
|
);
|
|
|
|
logAttributionDebug('candidate_scored', {
|
|
clickId: row.click_id,
|
|
linkId: row.link_id,
|
|
score,
|
|
threshold: CONFIDENCE_THRESHOLD,
|
|
matchedFactors,
|
|
timeDiffHours,
|
|
attributionWindowHours: linkWindowHours,
|
|
installFingerprint: {
|
|
ipAddress: installFingerprint.ipAddress,
|
|
platform: installFingerprint.platform || null,
|
|
timezone: installFingerprint.timezone || null,
|
|
language: installFingerprint.language || null,
|
|
screenWidth: installFingerprint.screenWidth || null,
|
|
screenHeight: installFingerprint.screenHeight || null,
|
|
},
|
|
clickFingerprint: {
|
|
ipAddress: clickFingerprint.ipAddress,
|
|
platform: clickFingerprint.platform || null,
|
|
timezone: clickFingerprint.timezone || null,
|
|
language: clickFingerprint.language || null,
|
|
screenWidth: clickFingerprint.screenWidth || null,
|
|
screenHeight: clickFingerprint.screenHeight || null,
|
|
},
|
|
factors: debug,
|
|
});
|
|
|
|
// Track the best match
|
|
if (score > highestScore && score >= CONFIDENCE_THRESHOLD) {
|
|
highestScore = score;
|
|
bestMatch = {
|
|
clickId: row.click_id,
|
|
linkId: row.link_id,
|
|
confidenceScore: score,
|
|
matchedFactors,
|
|
clickedAt: new Date(row.clicked_at),
|
|
};
|
|
}
|
|
}
|
|
|
|
logAttributionDebug('install_match_result', {
|
|
matched: bestMatch !== null,
|
|
highestScore,
|
|
threshold: CONFIDENCE_THRESHOLD,
|
|
installFingerprint: {
|
|
ipAddress: installFingerprint.ipAddress,
|
|
platform: installFingerprint.platform || null,
|
|
timezone: installFingerprint.timezone || null,
|
|
language: installFingerprint.language || null,
|
|
screenWidth: installFingerprint.screenWidth || null,
|
|
screenHeight: installFingerprint.screenHeight || null,
|
|
},
|
|
bestMatch: bestMatch
|
|
? {
|
|
clickId: bestMatch.clickId,
|
|
linkId: bestMatch.linkId,
|
|
confidenceScore: bestMatch.confidenceScore,
|
|
matchedFactors: bestMatch.matchedFactors,
|
|
clickedAt: bestMatch.clickedAt.toISOString(),
|
|
}
|
|
: null,
|
|
});
|
|
|
|
return bestMatch;
|
|
}
|
|
|
|
/**
|
|
* Store device fingerprint for a click event
|
|
*/
|
|
export async function storeFingerprintForClick(
|
|
clickId: string,
|
|
fingerprintData: FingerprintData
|
|
): Promise<void> {
|
|
const fingerprintHash = generateFingerprintHash(fingerprintData);
|
|
|
|
await db.query(
|
|
`INSERT INTO device_fingerprints (
|
|
click_id,
|
|
fingerprint_hash,
|
|
ip_address,
|
|
user_agent,
|
|
timezone,
|
|
language,
|
|
screen_width,
|
|
screen_height,
|
|
platform,
|
|
platform_version
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
|
[
|
|
clickId,
|
|
fingerprintHash,
|
|
fingerprintData.ipAddress,
|
|
fingerprintData.userAgent,
|
|
fingerprintData.timezone || null,
|
|
fingerprintData.language || null,
|
|
fingerprintData.screenWidth || null,
|
|
fingerprintData.screenHeight || null,
|
|
fingerprintData.platform || null,
|
|
fingerprintData.platformVersion || null,
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Record an install event and attempt to match it to a click
|
|
*/
|
|
export async function recordInstallEvent(
|
|
fingerprintData: FingerprintData,
|
|
deviceId?: string,
|
|
attributionWindowHours: number = DEFAULT_ATTRIBUTION_WINDOW_HOURS,
|
|
sdk?: { name?: string | null; version?: string | null }
|
|
): Promise<{
|
|
installId: string;
|
|
match: FingerprintMatch | null;
|
|
deepLinkData: any;
|
|
}> {
|
|
const fingerprintHash = generateFingerprintHash(fingerprintData);
|
|
|
|
// Attempt to match install to a click
|
|
const match = await matchInstallToClick(fingerprintData, attributionWindowHours);
|
|
|
|
// Attribution metadata for measurement (SIT-296): install attribution is
|
|
// fingerprint-based, so the method is 'fingerprint' on a match and 'none'
|
|
// (organic) otherwise; matched_factors records which signals matched.
|
|
const attributionMethod = match ? 'fingerprint' : 'none';
|
|
const matchedFactors = match?.matchedFactors ?? null;
|
|
|
|
// Insert install event
|
|
const installResult = await db.query(
|
|
`INSERT INTO install_events (
|
|
link_id,
|
|
click_id,
|
|
fingerprint_hash,
|
|
confidence_score,
|
|
installed_at,
|
|
first_open_at,
|
|
attribution_window_hours,
|
|
ip_address,
|
|
user_agent,
|
|
timezone,
|
|
language,
|
|
screen_width,
|
|
screen_height,
|
|
platform,
|
|
platform_version,
|
|
device_id,
|
|
deep_link_data,
|
|
sdk_name,
|
|
sdk_version,
|
|
attribution_method,
|
|
matched_factors
|
|
) VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
|
RETURNING id, deep_link_data`,
|
|
[
|
|
match?.linkId || null,
|
|
match?.clickId || null,
|
|
fingerprintHash,
|
|
match?.confidenceScore || null,
|
|
attributionWindowHours,
|
|
fingerprintData.ipAddress,
|
|
fingerprintData.userAgent,
|
|
fingerprintData.timezone || null,
|
|
fingerprintData.language || null,
|
|
fingerprintData.screenWidth || null,
|
|
fingerprintData.screenHeight || null,
|
|
fingerprintData.platform || null,
|
|
fingerprintData.platformVersion || null,
|
|
deviceId || null,
|
|
match ? JSON.stringify({}) : JSON.stringify({}), // Will be populated from link data
|
|
sdk?.name || null,
|
|
sdk?.version || null,
|
|
attributionMethod,
|
|
matchedFactors,
|
|
]
|
|
);
|
|
|
|
const installId = installResult.rows[0].id;
|
|
let deepLinkData = {};
|
|
|
|
// If we have a match, retrieve the deep link data from the original link
|
|
if (match) {
|
|
const linkResult = await db.query(
|
|
`SELECT
|
|
short_code,
|
|
original_url,
|
|
ios_app_store_url,
|
|
android_app_store_url,
|
|
web_fallback_url,
|
|
utm_parameters,
|
|
targeting_rules,
|
|
deep_link_parameters
|
|
FROM links
|
|
WHERE id = $1`,
|
|
[match.linkId]
|
|
);
|
|
|
|
if (linkResult.rows.length > 0) {
|
|
const link = linkResult.rows[0];
|
|
deepLinkData = {
|
|
shortCode: link.short_code,
|
|
originalUrl: link.original_url,
|
|
iosUrl: link.ios_app_store_url,
|
|
androidUrl: link.android_app_store_url,
|
|
webFallbackUrl: link.web_fallback_url,
|
|
utmParameters: link.utm_parameters,
|
|
targetingRules: link.targeting_rules,
|
|
deepLinkParameters: link.deep_link_parameters,
|
|
clickedAt: match.clickedAt,
|
|
confidenceScore: match.confidenceScore,
|
|
matchedFactors: match.matchedFactors,
|
|
};
|
|
|
|
// Update the install event with deep link data
|
|
await db.query(
|
|
`UPDATE install_events
|
|
SET deep_link_data = $1,
|
|
deep_link_retrieved = true
|
|
WHERE id = $2`,
|
|
[JSON.stringify(deepLinkData), installId]
|
|
);
|
|
|
|
// Trigger webhooks for install_event (only if attributed)
|
|
try {
|
|
// Get the link's user_id for webhook lookup
|
|
const linkUserResult = await db.query(
|
|
'SELECT user_id FROM links WHERE id = $1',
|
|
[match.linkId]
|
|
);
|
|
|
|
if (linkUserResult.rows.length > 0) {
|
|
const userId = linkUserResult.rows[0].user_id;
|
|
|
|
const webhooksResult = await db.query(
|
|
'SELECT * FROM webhooks WHERE user_id = $1 AND is_active = true',
|
|
[userId]
|
|
);
|
|
|
|
if (webhooksResult.rows.length > 0) {
|
|
const { triggerWebhooks } = await import('./webhook.js');
|
|
|
|
const installEventData = {
|
|
id: installId,
|
|
linkId: match.linkId,
|
|
fingerprintHash,
|
|
confidenceScore: match.confidenceScore,
|
|
installedAt: new Date().toISOString(),
|
|
deepLinkData,
|
|
ipAddress: fingerprintData.ipAddress,
|
|
userAgent: fingerprintData.userAgent,
|
|
platform: fingerprintData.platform,
|
|
};
|
|
|
|
// Trigger webhooks without delivery logging (basic version)
|
|
// For delivery logging, use @linkforty/cloud premium features
|
|
await triggerWebhooks(
|
|
webhooksResult.rows,
|
|
'install_event',
|
|
installId,
|
|
installEventData
|
|
);
|
|
}
|
|
}
|
|
} catch (webhookError) {
|
|
console.error(`Error triggering install webhooks: ${webhookError}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
installId,
|
|
match,
|
|
deepLinkData,
|
|
};
|
|
}
|