code
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import redis from '@fastify/redis';
|
||||
import { initializeDatabase, DatabaseOptions } from './lib/database.js';
|
||||
import { redirectRoutes } from './routes/redirect.js';
|
||||
import { linkRoutes } from './routes/links.js';
|
||||
import { analyticsRoutes } from './routes/analytics.js';
|
||||
import { sdkRoutes } from './routes/sdk.js';
|
||||
import { webhookRoutes } from './routes/webhooks.js';
|
||||
import { templateRoutes } from './routes/templates.js';
|
||||
import { qrRoutes } from './routes/qr.js';
|
||||
import { wellKnownRoutes } from './routes/well-known.js';
|
||||
|
||||
/**
|
||||
* Configuration options for creating a LinkForty server instance.
|
||||
*/
|
||||
export interface ServerOptions {
|
||||
database?: DatabaseOptions;
|
||||
redis?: {
|
||||
url: string;
|
||||
};
|
||||
cors?: {
|
||||
origin: string | string[];
|
||||
};
|
||||
logger?: boolean;
|
||||
/** When true or a number (proxy hop count), Fastify trusts X-Forwarded-For so request.ip is the real client IP. Set when behind a reverse proxy. */
|
||||
trustProxy?: boolean | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure a LinkForty Fastify server instance.
|
||||
*
|
||||
* Registers CORS, optional Redis, the database connection, and all built-in
|
||||
* route plugins. The returned instance is ready to call `listen()` on.
|
||||
*
|
||||
* @param options - Server configuration (database, Redis, CORS, logger).
|
||||
* @returns A configured Fastify instance with all routes registered.
|
||||
*/
|
||||
export async function createServer(options: ServerOptions = {}) {
|
||||
const fastify = Fastify({
|
||||
logger: options.logger !== undefined ? options.logger : true,
|
||||
trustProxy: options.trustProxy,
|
||||
});
|
||||
|
||||
// CORS
|
||||
await fastify.register(cors, {
|
||||
origin: options.cors?.origin || '*',
|
||||
});
|
||||
|
||||
// Redis (optional)
|
||||
if (options.redis?.url) {
|
||||
await fastify.register(redis, {
|
||||
url: options.redis.url,
|
||||
});
|
||||
}
|
||||
|
||||
// Database
|
||||
await initializeDatabase(options.database);
|
||||
|
||||
// Routes
|
||||
await fastify.register(wellKnownRoutes);
|
||||
await fastify.register(redirectRoutes);
|
||||
await fastify.register(linkRoutes);
|
||||
await fastify.register(analyticsRoutes);
|
||||
await fastify.register(sdkRoutes);
|
||||
await fastify.register(webhookRoutes);
|
||||
await fastify.register(templateRoutes);
|
||||
await fastify.register(qrRoutes);
|
||||
|
||||
return fastify;
|
||||
}
|
||||
|
||||
// Re-export utilities and types
|
||||
export * from './lib/utils.js';
|
||||
export * from './lib/client-ip.js';
|
||||
export * from './lib/database.js';
|
||||
export * from './lib/fingerprint.js';
|
||||
export * from './lib/webhook.js';
|
||||
export * from './lib/event-emitter.js';
|
||||
export * from './types/index.js';
|
||||
export { redirectRoutes, linkRoutes, analyticsRoutes, sdkRoutes, webhookRoutes, templateRoutes, qrRoutes, previewRoutes, debugRoutes, wellKnownRoutes } from './routes/index.js';
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { classifyBot, edgeBotSignal } from './bot-detection.js';
|
||||
|
||||
const CHROME = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
|
||||
|
||||
describe('classifyBot', () => {
|
||||
it('flags known crawlers/scrapers by user-agent', () => {
|
||||
expect(classifyBot('Googlebot/2.1 (+http://www.google.com/bot.html)', 'GET')).toEqual({ isBot: true, reason: 'ua' });
|
||||
expect(classifyBot('facebookexternalhit/1.1', 'GET').isBot).toBe(true);
|
||||
expect(classifyBot('curl/8.4.0', 'GET').isBot).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag a normal browser', () => {
|
||||
expect(classifyBot(CHROME, 'GET')).toEqual({ isBot: false, reason: null });
|
||||
});
|
||||
|
||||
it('flags HEAD/OPTIONS as non-human, case-insensitively', () => {
|
||||
expect(classifyBot(CHROME, 'HEAD')).toEqual({ isBot: true, reason: 'method' });
|
||||
expect(classifyBot(CHROME, 'options')).toEqual({ isBot: true, reason: 'method' });
|
||||
});
|
||||
|
||||
it('honors the edge signal with highest authority', () => {
|
||||
// Edge wins even for a normal browser UA on a GET.
|
||||
expect(classifyBot(CHROME, 'GET', true)).toEqual({ isBot: true, reason: 'edge' });
|
||||
// An explicit false edge signal does not override real UA/method detection.
|
||||
expect(classifyBot('Googlebot/2.1', 'GET', false)).toEqual({ isBot: true, reason: 'ua' });
|
||||
});
|
||||
|
||||
it('handles a missing/empty user-agent', () => {
|
||||
expect(classifyBot(undefined, 'GET')).toEqual({ isBot: false, reason: null });
|
||||
expect(classifyBot('', 'GET').isBot).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edgeBotSignal', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.TRUST_EDGE_BOT_HEADER;
|
||||
});
|
||||
|
||||
it('is ignored unless TRUST_EDGE_BOT_HEADER=true', () => {
|
||||
expect(edgeBotSignal('1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses the header only when trusted', () => {
|
||||
process.env.TRUST_EDGE_BOT_HEADER = 'true';
|
||||
expect(edgeBotSignal('1')).toBe(true);
|
||||
expect(edgeBotSignal('true')).toBe(true);
|
||||
expect(edgeBotSignal('TRUE')).toBe(true);
|
||||
expect(edgeBotSignal('0')).toBe(false);
|
||||
expect(edgeBotSignal(['1'])).toBe(true);
|
||||
expect(edgeBotSignal(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { isbot } from 'isbot';
|
||||
|
||||
/**
|
||||
* Bot classification for click ingestion.
|
||||
*
|
||||
* A click's "is this a real human?" quality is determined by the request that
|
||||
* created it, so it's classified here at write time and persisted on the
|
||||
* click_events row. Downstream analytics simply read the stored flag — they
|
||||
* never re-detect (the raw request signals aren't available at read time).
|
||||
*
|
||||
* Signals, in order of authority:
|
||||
* 1. edge — a trusted upstream (reverse proxy / CDN bot management) already
|
||||
* classified this request as a bot. Only honored when the caller
|
||||
* opts in (the header must come from a trusted proxy, not the
|
||||
* client). Highest confidence.
|
||||
* 2. method — HEAD / OPTIONS requests are link probes/prefetch, never a human
|
||||
* opening a link.
|
||||
* 3. ua — the `isbot` user-agent database (crawlers, scrapers, monitors).
|
||||
*/
|
||||
export type BotReason = 'edge' | 'method' | 'ua';
|
||||
|
||||
export interface BotClassification {
|
||||
isBot: boolean;
|
||||
reason: BotReason | null;
|
||||
}
|
||||
|
||||
const NON_HUMAN_METHODS = new Set(['HEAD', 'OPTIONS']);
|
||||
|
||||
export function classifyBot(
|
||||
userAgent: string | undefined,
|
||||
method: string | undefined,
|
||||
edgeIsBot?: boolean
|
||||
): BotClassification {
|
||||
if (edgeIsBot === true) return { isBot: true, reason: 'edge' };
|
||||
if (method && NON_HUMAN_METHODS.has(method.toUpperCase())) return { isBot: true, reason: 'method' };
|
||||
if (isbot(userAgent ?? '')) return { isBot: true, reason: 'ua' };
|
||||
return { isBot: false, reason: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the optional edge bot signal from request headers. Only trusted when
|
||||
* `TRUST_EDGE_BOT_HEADER=true` — otherwise a client could set the header to
|
||||
* mark its own clicks as bots. Deployments behind a proxy that authoritatively
|
||||
* sets (and strips client-supplied copies of) `x-lf-bot` can enable it.
|
||||
*/
|
||||
export function edgeBotSignal(headerValue: string | string[] | undefined): boolean | undefined {
|
||||
if (process.env.TRUST_EDGE_BOT_HEADER !== 'true') return undefined;
|
||||
const v = Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
||||
if (v === undefined) return undefined;
|
||||
return v === '1' || v.toLowerCase() === 'true';
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { getClientIp } from './client-ip';
|
||||
|
||||
declare global {
|
||||
var __capturedInstallFingerprint: { ipAddress: string } | null;
|
||||
}
|
||||
|
||||
describe('getClientIp', () => {
|
||||
it('returns request.ip when set', () => {
|
||||
const request = { ip: '192.168.1.1' } as unknown as FastifyRequest;
|
||||
expect(getClientIp(request)).toBe('192.168.1.1');
|
||||
});
|
||||
|
||||
it('returns raw.socket.remoteAddress when request.ip is undefined', () => {
|
||||
const request = {
|
||||
ip: undefined,
|
||||
raw: { socket: { remoteAddress: '10.0.0.2' } },
|
||||
} as unknown as FastifyRequest;
|
||||
expect(getClientIp(request)).toBe('10.0.0.2');
|
||||
});
|
||||
|
||||
it('unwraps IPv6-mapped IPv4', () => {
|
||||
const request = { ip: '::ffff:192.168.1.1' } as unknown as FastifyRequest;
|
||||
expect(getClientIp(request)).toBe('192.168.1.1');
|
||||
});
|
||||
|
||||
it('returns empty string when neither ip nor socket.remoteAddress is available', () => {
|
||||
const request = { ip: undefined, raw: { socket: {} } } as unknown as FastifyRequest;
|
||||
expect(getClientIp(request)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when request.raw has no socket', () => {
|
||||
const request = { ip: undefined, raw: {} } as unknown as FastifyRequest;
|
||||
expect(getClientIp(request)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClientIp with TRUSTED_CLIENT_IP_HEADER', () => {
|
||||
const ORIGINAL = process.env.TRUSTED_CLIENT_IP_HEADER;
|
||||
afterEach(() => {
|
||||
if (ORIGINAL === undefined) delete process.env.TRUSTED_CLIENT_IP_HEADER;
|
||||
else process.env.TRUSTED_CLIENT_IP_HEADER = ORIGINAL;
|
||||
});
|
||||
|
||||
it('prefers the configured header over request.ip', () => {
|
||||
process.env.TRUSTED_CLIENT_IP_HEADER = 'cf-connecting-ip';
|
||||
const request = {
|
||||
ip: '162.158.23.75', // a Cloudflare edge IP
|
||||
headers: { 'cf-connecting-ip': '203.0.113.50' },
|
||||
} as unknown as FastifyRequest;
|
||||
expect(getClientIp(request)).toBe('203.0.113.50');
|
||||
});
|
||||
|
||||
it('falls back to request.ip when the configured header is absent', () => {
|
||||
process.env.TRUSTED_CLIENT_IP_HEADER = 'cf-connecting-ip';
|
||||
const request = { ip: '203.0.113.50', headers: {} } as unknown as FastifyRequest;
|
||||
expect(getClientIp(request)).toBe('203.0.113.50');
|
||||
});
|
||||
|
||||
it('takes the first entry of a comma-separated header value', () => {
|
||||
process.env.TRUSTED_CLIENT_IP_HEADER = 'true-client-ip';
|
||||
const request = {
|
||||
ip: '10.0.0.1',
|
||||
headers: { 'true-client-ip': '203.0.113.50, 70.0.0.1' },
|
||||
} as unknown as FastifyRequest;
|
||||
expect(getClientIp(request)).toBe('203.0.113.50');
|
||||
});
|
||||
|
||||
it('ignores the header when the env var is unset (default behavior)', () => {
|
||||
delete process.env.TRUSTED_CLIENT_IP_HEADER;
|
||||
const request = {
|
||||
ip: '203.0.113.50',
|
||||
headers: { 'cf-connecting-ip': '1.2.3.4' },
|
||||
} as unknown as FastifyRequest;
|
||||
expect(getClientIp(request)).toBe('203.0.113.50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClientIp with Fastify trustProxy (proxied request)', () => {
|
||||
it('uses X-Forwarded-For when trustProxy is true', async () => {
|
||||
const Fastify = (await import('fastify')).default;
|
||||
const { getClientIp: getIp } = await import('./client-ip.js');
|
||||
const app = Fastify({ trustProxy: true });
|
||||
app.get('/ip', async (request, reply) => {
|
||||
return reply.send({ ip: getIp(request) });
|
||||
});
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/ip',
|
||||
headers: { 'x-forwarded-for': '203.0.113.50' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as { ip: string };
|
||||
expect(body.ip).toBe('203.0.113.50');
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock('./fingerprint.js', async (importOriginal) => {
|
||||
const mod = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...mod,
|
||||
recordInstallEvent: vi.fn().mockImplementation(async (data: { ipAddress: string }) => {
|
||||
globalThis.__capturedInstallFingerprint = data;
|
||||
return { installId: 'test-id', match: null, deepLinkData: null };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('SDK install does not trust client-provided ipAddress', () => {
|
||||
it('uses connection/proxy IP for attribution, not body ipAddress', async () => {
|
||||
globalThis.__capturedInstallFingerprint = null;
|
||||
const Fastify = (await import('fastify')).default;
|
||||
const { sdkRoutes } = await import('../routes/sdk.js');
|
||||
const app = Fastify({ trustProxy: true });
|
||||
await app.register(sdkRoutes);
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sdk/v1/install',
|
||||
payload: { ipAddress: '1.2.3.4', userAgent: 'Mozilla/5.0 Test' },
|
||||
headers: { 'x-forwarded-for': '203.0.113.50', 'content-type': 'application/json' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(globalThis.__capturedInstallFingerprint).not.toBeNull();
|
||||
expect(globalThis.__capturedInstallFingerprint!.ipAddress).toBe('203.0.113.50');
|
||||
const body = res.json() as { clientReportedIp?: string };
|
||||
expect(body.clientReportedIp).toBe('1.2.3.4');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
function normalizeIp(ip: string): string {
|
||||
// IPv6-mapped IPv4: ::ffff:192.168.1.1 -> 192.168.1.1
|
||||
return ip.startsWith('::ffff:') ? ip.slice(7) : ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the trusted client IP for the request.
|
||||
* Use this everywhere client IP is needed (targeting, attribution, fingerprinting).
|
||||
*
|
||||
* Behind a CDN/proxy that terminates the connection (e.g. Cloudflare), the
|
||||
* left-most `X-Forwarded-For` entry that Fastify exposes as `request.ip` is not
|
||||
* reliably the real client — it can be a CDN edge or NAT hop. When the proxy
|
||||
* sends an authoritative client-IP header (Cloudflare's `CF-Connecting-IP`, or
|
||||
* `True-Client-IP`), prefer it.
|
||||
*
|
||||
* Set `TRUSTED_CLIENT_IP_HEADER` to that header name to opt in (e.g.
|
||||
* `cf-connecting-ip`). IMPORTANT: only enable this when the origin is reachable
|
||||
* ONLY through that proxy — otherwise a direct client could spoof the header.
|
||||
* When unset, behavior is unchanged (uses `request.ip`).
|
||||
*/
|
||||
export function getClientIp(request: FastifyRequest): string {
|
||||
const headerName = process.env.TRUSTED_CLIENT_IP_HEADER?.toLowerCase().trim();
|
||||
if (headerName) {
|
||||
const raw = request.headers[headerName];
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (value && typeof value === 'string') {
|
||||
// Some proxies send a comma-separated list — take the first entry.
|
||||
const first = value.split(',')[0]?.trim();
|
||||
if (first) return normalizeIp(first);
|
||||
}
|
||||
}
|
||||
|
||||
const ip = request.ip ?? request.raw.socket?.remoteAddress;
|
||||
if (ip && typeof ip === 'string') {
|
||||
return normalizeIp(ip);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import pg from 'pg';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
export interface DatabaseOptions {
|
||||
url?: string;
|
||||
pool?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export let db: pg.Pool;
|
||||
|
||||
// Helper function to wait for a specified time
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Retry database connection with exponential backoff
|
||||
async function connectWithRetry(maxRetries: number = 10, baseDelay: number = 1000): Promise<pg.PoolClient> {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const client = await db.connect();
|
||||
console.log('Database connection established successfully');
|
||||
return client;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ECONNREFUSED' && attempt < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, attempt - 1); // Exponential backoff
|
||||
console.log(`Database connection attempt ${attempt} failed. Retrying in ${delay}ms...`);
|
||||
await sleep(delay);
|
||||
} else {
|
||||
console.error('Failed to connect to database after all retries:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('Max retries exceeded');
|
||||
}
|
||||
|
||||
// Initialize database schema
|
||||
export async function initializeDatabase(options: DatabaseOptions = {}) {
|
||||
// Initialize pool
|
||||
db = new Pool({
|
||||
connectionString: options.url || process.env.DATABASE_URL || 'postgresql://postgres:password@localhost:5432/linkforty',
|
||||
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
|
||||
min: options.pool?.min || 2,
|
||||
max: options.pool?.max || 10,
|
||||
});
|
||||
|
||||
const client = await connectWithRetry();
|
||||
|
||||
try {
|
||||
// Link templates table (must be created before links, which references it)
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS link_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
settings JSONB DEFAULT '{}',
|
||||
is_default BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// Links table
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS links (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID,
|
||||
short_code VARCHAR(20) UNIQUE NOT NULL,
|
||||
original_url TEXT NOT NULL,
|
||||
title VARCHAR(255),
|
||||
description TEXT,
|
||||
ios_url TEXT,
|
||||
android_url TEXT,
|
||||
web_fallback_url TEXT,
|
||||
utm_parameters JSONB DEFAULT '{}',
|
||||
targeting_rules JSONB DEFAULT '{}',
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
expires_at TIMESTAMP,
|
||||
append_click_id BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// Click events table
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS click_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
link_id UUID NOT NULL REFERENCES links(id) ON DELETE CASCADE,
|
||||
clicked_at TIMESTAMP DEFAULT NOW(),
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
device_type VARCHAR(20),
|
||||
platform VARCHAR(20),
|
||||
country_code CHAR(2),
|
||||
country_name VARCHAR(100),
|
||||
region VARCHAR(100),
|
||||
city VARCHAR(100),
|
||||
latitude DECIMAL(10, 8),
|
||||
longitude DECIMAL(11, 8),
|
||||
timezone VARCHAR(100),
|
||||
utm_source VARCHAR(255),
|
||||
utm_medium VARCHAR(255),
|
||||
utm_campaign VARCHAR(255),
|
||||
referrer TEXT,
|
||||
is_bot BOOLEAN NOT NULL DEFAULT false,
|
||||
bot_reason VARCHAR(16)
|
||||
)
|
||||
`);
|
||||
|
||||
// Device fingerprints table - stores individual fingerprint components for probabilistic matching
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS device_fingerprints (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
click_id UUID NOT NULL REFERENCES click_events(id) ON DELETE CASCADE,
|
||||
fingerprint_hash VARCHAR(64) NOT NULL,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
timezone VARCHAR(100),
|
||||
language VARCHAR(10),
|
||||
screen_width INTEGER,
|
||||
screen_height INTEGER,
|
||||
platform VARCHAR(50),
|
||||
platform_version VARCHAR(50),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// Install events table - tracks app installations and matches to clicks via fingerprinting
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS install_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
link_id UUID REFERENCES links(id) ON DELETE SET NULL,
|
||||
click_id UUID REFERENCES click_events(id) ON DELETE SET NULL,
|
||||
fingerprint_hash VARCHAR(64) NOT NULL,
|
||||
confidence_score DECIMAL(5, 2),
|
||||
attribution_method VARCHAR(20),
|
||||
matched_factors TEXT[],
|
||||
installed_at TIMESTAMP DEFAULT NOW(),
|
||||
first_open_at TIMESTAMP,
|
||||
deep_link_retrieved BOOLEAN DEFAULT false,
|
||||
deep_link_data JSONB DEFAULT '{}',
|
||||
attribution_window_hours INTEGER DEFAULT 168,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
timezone VARCHAR(100),
|
||||
language VARCHAR(10),
|
||||
screen_width INTEGER,
|
||||
screen_height INTEGER,
|
||||
platform VARCHAR(50),
|
||||
platform_version VARCHAR(50),
|
||||
device_id VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// In-app events table - tracks conversion events from mobile apps
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS in_app_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
install_id UUID NOT NULL REFERENCES install_events(id) ON DELETE CASCADE,
|
||||
event_name VARCHAR(255) NOT NULL,
|
||||
event_data JSONB DEFAULT '{}',
|
||||
event_timestamp TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// Webhooks table - stores webhook configurations for event postbacks
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
secret VARCHAR(255) NOT NULL,
|
||||
events TEXT[] NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
retry_count INTEGER DEFAULT 3,
|
||||
timeout_ms INTEGER DEFAULT 10000,
|
||||
headers JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// Add template_id column to links table
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='template_id'
|
||||
) THEN
|
||||
ALTER TABLE links ADD COLUMN template_id UUID REFERENCES link_templates(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Add description column to existing links table if it doesn't exist
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='description'
|
||||
) THEN
|
||||
ALTER TABLE links ADD COLUMN description TEXT;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Add Open Graph (OG) tag columns for social media previews
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='og_title'
|
||||
) THEN
|
||||
ALTER TABLE links ADD COLUMN og_title VARCHAR(255);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='og_description'
|
||||
) THEN
|
||||
ALTER TABLE links ADD COLUMN og_description TEXT;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='og_image_url'
|
||||
) THEN
|
||||
ALTER TABLE links ADD COLUMN og_image_url TEXT;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='og_type'
|
||||
) THEN
|
||||
ALTER TABLE links ADD COLUMN og_type VARCHAR(50) DEFAULT 'website';
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Add attribution window column for configurable install attribution windows
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='attribution_window_hours'
|
||||
) THEN
|
||||
ALTER TABLE links ADD COLUMN attribution_window_hours INTEGER DEFAULT 168;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Rename ios_url to ios_app_store_url for clarity
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='ios_url'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='ios_app_store_url'
|
||||
) THEN
|
||||
ALTER TABLE links RENAME COLUMN ios_url TO ios_app_store_url;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Rename android_url to android_app_store_url for clarity
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='android_url'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='android_app_store_url'
|
||||
) THEN
|
||||
ALTER TABLE links RENAME COLUMN android_url TO android_app_store_url;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Add app URL scheme column (same for iOS and Android per industry best practice)
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='app_scheme'
|
||||
) THEN
|
||||
ALTER TABLE links ADD COLUMN app_scheme VARCHAR(255);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Add iOS Universal Link URL column
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='ios_universal_link'
|
||||
) THEN
|
||||
ALTER TABLE links ADD COLUMN ios_universal_link TEXT;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Add Android App Link URL column
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='android_app_link'
|
||||
) THEN
|
||||
ALTER TABLE links ADD COLUMN android_app_link TEXT;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Add deep link path column for in-app navigation
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='deep_link_path'
|
||||
) THEN
|
||||
ALTER TABLE links ADD COLUMN deep_link_path TEXT;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Add deep link parameters column for custom app parameters
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='deep_link_parameters'
|
||||
) THEN
|
||||
ALTER TABLE links ADD COLUMN deep_link_parameters JSONB DEFAULT '{}';
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Click correlation id passthrough (opt-in, default off). When enabled per
|
||||
// link, the redirect appends ?lf_click=<click id> to web/HTTPS destinations
|
||||
// so a downstream analytics tool on the landing page can tie the landing
|
||||
// visit back to the exact originating click. Off by default so the redirect
|
||||
// never alters a destination's query string unless explicitly opted in.
|
||||
// App-scheme/deep-link destinations are never appended to regardless.
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='append_click_id'
|
||||
) THEN
|
||||
ALTER TABLE links ADD COLUMN append_click_id BOOLEAN DEFAULT false;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Bot classification columns on click_events (SIT-298). Classified at
|
||||
// ingestion (see lib/bot-detection.ts) and persisted so every consumer reads
|
||||
// one consistent flag; analytics excludes is_bot rows. Backward compatible:
|
||||
// legacy rows default to is_bot=false and age out of the retention window.
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='click_events' AND column_name='is_bot') THEN
|
||||
ALTER TABLE click_events ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='click_events' AND column_name='bot_reason') THEN
|
||||
ALTER TABLE click_events ADD COLUMN bot_reason VARCHAR(16);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Attribution metadata on install_events (SIT-296): how the install was
|
||||
// attributed ('fingerprint' | 'none') and which fingerprint signals matched.
|
||||
// Makes attribution quality measurable. Backward compatible (NULL until set).
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='install_events' AND column_name='attribution_method') THEN
|
||||
ALTER TABLE install_events ADD COLUMN attribution_method VARCHAR(20);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='install_events' AND column_name='matched_factors') THEN
|
||||
ALTER TABLE install_events ADD COLUMN matched_factors TEXT[];
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Last-click attribution columns on in_app_events (SIT-237).
|
||||
// Events (screen views + custom events) are attributed to the deep link that
|
||||
// drove them, not just the original install link. The SDK stamps each event
|
||||
// with the active link, when it opened, and the app-open session; the window
|
||||
// (organic vs attributed) is applied at query time. Nullable + backward
|
||||
// compatible: legacy/organic rows stay null and fall back to the install link.
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='attributed_link_id') THEN
|
||||
ALTER TABLE in_app_events ADD COLUMN attributed_link_id UUID REFERENCES links(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='attributed_click_id') THEN
|
||||
ALTER TABLE in_app_events ADD COLUMN attributed_click_id UUID;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='attributed_at') THEN
|
||||
ALTER TABLE in_app_events ADD COLUMN attributed_at TIMESTAMP;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='session_id') THEN
|
||||
ALTER TABLE in_app_events ADD COLUMN session_id UUID;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// SDK identity columns (SIT-235) — name + version of the SDK that sent the
|
||||
// install/event, for SDK version diagnostics. Persisted on BOTH tables:
|
||||
// install_events (version at install time) and in_app_events because an app
|
||||
// that updates keeps its original install row but sends events with the new
|
||||
// version — so version-fragmentation / outdated-version checks must read from
|
||||
// the event stream. Nullable + backward compatible (older SDKs omit them).
|
||||
// NOTE: no index on sdk_name/sdk_version yet — deferred until a consumer
|
||||
// aggregates them (e.g. "installs by version"), so the index can match the
|
||||
// real query shape.
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='install_events' AND column_name='sdk_name') THEN
|
||||
ALTER TABLE install_events ADD COLUMN sdk_name VARCHAR(50);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='install_events' AND column_name='sdk_version') THEN
|
||||
ALTER TABLE install_events ADD COLUMN sdk_version VARCHAR(50);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='sdk_name') THEN
|
||||
ALTER TABLE in_app_events ADD COLUMN sdk_name VARCHAR(50);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='sdk_version') THEN
|
||||
ALTER TABLE in_app_events ADD COLUMN sdk_version VARCHAR(50);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Create indexes for performance
|
||||
await client.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_links_short_code ON links(short_code)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_links_user_id ON links(user_id)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_links_created_at ON links(created_at DESC)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_clicks_link_id ON click_events(link_id)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_clicks_timestamp ON click_events(clicked_at DESC)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_clicks_link_date ON click_events(link_id, clicked_at DESC)');
|
||||
// Partial index for the common analytics filter (human clicks only, is_bot = false).
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_clicks_human_link_date ON click_events(link_id, clicked_at DESC) WHERE is_bot = false');
|
||||
// Indexes for deferred deep linking
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_fingerprints_hash ON device_fingerprints(fingerprint_hash)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_fingerprints_click_id ON device_fingerprints(click_id)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_installs_fingerprint ON install_events(fingerprint_hash)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_installs_link_id ON install_events(link_id)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_installs_timestamp ON install_events(installed_at DESC)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_installs_link_date ON install_events(link_id, installed_at DESC)');
|
||||
|
||||
// Indexes for link templates
|
||||
await client.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_link_templates_slug ON link_templates(slug)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_link_templates_user_id ON link_templates(user_id)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_links_template_id ON links(template_id)');
|
||||
|
||||
// Indexes for webhooks
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_webhooks_user_id ON webhooks(user_id)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_webhooks_active ON webhooks(is_active) WHERE is_active = true');
|
||||
|
||||
// Indexes for in-app events
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_in_app_events_install_id ON in_app_events(install_id)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_in_app_events_name ON in_app_events(event_name)');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_in_app_events_timestamp ON in_app_events(event_timestamp DESC)');
|
||||
// Attribution lookups: per-link conversion aggregation + per-session screen flow
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_in_app_events_attributed_link ON in_app_events(attributed_link_id, event_timestamp DESC)');
|
||||
// Partial: session_id is null for legacy/organic in-app events (the majority);
|
||||
// per-session screen-flow lookups always filter `session_id IS NOT NULL`.
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_in_app_events_session ON in_app_events(session_id) WHERE session_id IS NOT NULL');
|
||||
|
||||
// Add deep_link_parameters column for custom deep link parameters
|
||||
await client.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='links' AND column_name='deep_link_parameters'
|
||||
) THEN
|
||||
ALTER TABLE links ADD COLUMN deep_link_parameters JSONB DEFAULT '{}';
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
console.log('Database schema initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Error initializing database:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
clickEventEmitter,
|
||||
emitClickEvent,
|
||||
subscribeToClickEvents,
|
||||
ClickEventData,
|
||||
} from './event-emitter';
|
||||
|
||||
const mockClickEvent: ClickEventData = {
|
||||
eventId: 'evt-123',
|
||||
timestamp: '2026-03-10T00:00:00.000Z',
|
||||
linkId: 'link-abc',
|
||||
shortCode: 'abc123',
|
||||
userId: 'user-1',
|
||||
organizationId: 'org-1',
|
||||
ipAddress: '1.2.3.4',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
country: 'US',
|
||||
city: 'New York',
|
||||
deviceType: 'web',
|
||||
platform: 'Windows',
|
||||
browser: 'Chrome',
|
||||
redirectUrl: 'https://example.com',
|
||||
redirectReason: 'web_fallback',
|
||||
targetingMatched: true,
|
||||
utmParameters: {
|
||||
source: 'newsletter',
|
||||
medium: 'email',
|
||||
campaign: 'spring',
|
||||
},
|
||||
referer: 'https://google.com',
|
||||
language: 'en-US',
|
||||
};
|
||||
|
||||
describe('clickEventEmitter', () => {
|
||||
it('should be an EventEmitter instance', () => {
|
||||
expect(clickEventEmitter).toBeDefined();
|
||||
expect(typeof clickEventEmitter.on).toBe('function');
|
||||
expect(typeof clickEventEmitter.emit).toBe('function');
|
||||
expect(typeof clickEventEmitter.off).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitClickEvent', () => {
|
||||
beforeEach(() => {
|
||||
clickEventEmitter.removeAllListeners('click');
|
||||
});
|
||||
|
||||
it('should emit a click event with the provided data', () => {
|
||||
const handler = vi.fn();
|
||||
clickEventEmitter.on('click', handler);
|
||||
|
||||
emitClickEvent(mockClickEvent);
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(handler).toHaveBeenCalledWith(mockClickEvent);
|
||||
});
|
||||
|
||||
it('should emit to all registered listeners', () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
clickEventEmitter.on('click', handler1);
|
||||
clickEventEmitter.on('click', handler2);
|
||||
|
||||
emitClickEvent(mockClickEvent);
|
||||
|
||||
expect(handler1).toHaveBeenCalledOnce();
|
||||
expect(handler2).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should emit multiple times when called multiple times', () => {
|
||||
const handler = vi.fn();
|
||||
clickEventEmitter.on('click', handler);
|
||||
|
||||
emitClickEvent(mockClickEvent);
|
||||
emitClickEvent({ ...mockClickEvent, eventId: 'evt-456' });
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should pass the exact event data to the handler', () => {
|
||||
const handler = vi.fn();
|
||||
clickEventEmitter.on('click', handler);
|
||||
|
||||
const eventData: ClickEventData = {
|
||||
...mockClickEvent,
|
||||
deviceType: 'ios',
|
||||
country: 'CA',
|
||||
targetingMatched: false,
|
||||
};
|
||||
|
||||
emitClickEvent(eventData);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(eventData);
|
||||
});
|
||||
|
||||
it('should work with minimal required fields', () => {
|
||||
const handler = vi.fn();
|
||||
clickEventEmitter.on('click', handler);
|
||||
|
||||
const minimalEvent: ClickEventData = {
|
||||
eventId: 'evt-min',
|
||||
timestamp: '2026-03-10T00:00:00.000Z',
|
||||
linkId: 'link-min',
|
||||
shortCode: 'min',
|
||||
ipAddress: '0.0.0.0',
|
||||
userAgent: '',
|
||||
deviceType: 'web',
|
||||
redirectUrl: 'https://example.com',
|
||||
redirectReason: 'default',
|
||||
targetingMatched: true,
|
||||
};
|
||||
|
||||
emitClickEvent(minimalEvent);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(minimalEvent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeToClickEvents', () => {
|
||||
beforeEach(() => {
|
||||
clickEventEmitter.removeAllListeners('click');
|
||||
});
|
||||
|
||||
it('should call the callback when a click event is emitted', () => {
|
||||
const callback = vi.fn();
|
||||
subscribeToClickEvents(callback);
|
||||
|
||||
emitClickEvent(mockClickEvent);
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
expect(callback).toHaveBeenCalledWith(mockClickEvent);
|
||||
});
|
||||
|
||||
it('should return an unsubscribe function', () => {
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = subscribeToClickEvents(callback);
|
||||
|
||||
expect(typeof unsubscribe).toBe('function');
|
||||
});
|
||||
|
||||
it('should stop receiving events after unsubscribing', () => {
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = subscribeToClickEvents(callback);
|
||||
|
||||
emitClickEvent(mockClickEvent);
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
|
||||
unsubscribe();
|
||||
emitClickEvent(mockClickEvent);
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce(); // still only once
|
||||
});
|
||||
|
||||
it('should allow multiple subscribers independently', () => {
|
||||
const callback1 = vi.fn();
|
||||
const callback2 = vi.fn();
|
||||
|
||||
const unsubscribe1 = subscribeToClickEvents(callback1);
|
||||
subscribeToClickEvents(callback2);
|
||||
|
||||
emitClickEvent(mockClickEvent);
|
||||
expect(callback1).toHaveBeenCalledOnce();
|
||||
expect(callback2).toHaveBeenCalledOnce();
|
||||
|
||||
unsubscribe1();
|
||||
emitClickEvent(mockClickEvent);
|
||||
|
||||
expect(callback1).toHaveBeenCalledOnce(); // no new calls
|
||||
expect(callback2).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not affect other subscribers when one unsubscribes', () => {
|
||||
const callback1 = vi.fn();
|
||||
const callback2 = vi.fn();
|
||||
|
||||
const unsubscribe1 = subscribeToClickEvents(callback1);
|
||||
subscribeToClickEvents(callback2);
|
||||
|
||||
unsubscribe1();
|
||||
emitClickEvent(mockClickEvent);
|
||||
|
||||
expect(callback1).not.toHaveBeenCalled();
|
||||
expect(callback2).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should handle unsubscribe called multiple times without error', () => {
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = subscribeToClickEvents(callback);
|
||||
|
||||
expect(() => {
|
||||
unsubscribe();
|
||||
unsubscribe();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should pass event data correctly to callback', () => {
|
||||
const received: ClickEventData[] = [];
|
||||
subscribeToClickEvents((data) => received.push(data));
|
||||
|
||||
const event1 = { ...mockClickEvent, eventId: 'e1', deviceType: 'ios' as const };
|
||||
const event2 = { ...mockClickEvent, eventId: 'e2', deviceType: 'android' as const };
|
||||
|
||||
emitClickEvent(event1);
|
||||
emitClickEvent(event2);
|
||||
|
||||
expect(received).toHaveLength(2);
|
||||
expect(received[0]).toEqual(event1);
|
||||
expect(received[1]).toEqual(event2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
/**
|
||||
* Global event emitter for real-time events
|
||||
* Used for broadcasting click events to WebSocket clients
|
||||
*/
|
||||
export const clickEventEmitter = new EventEmitter();
|
||||
|
||||
/**
|
||||
* Click event data structure for real-time streaming
|
||||
*/
|
||||
export interface ClickEventData {
|
||||
eventId: string;
|
||||
timestamp: string;
|
||||
linkId: string;
|
||||
shortCode: string;
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
|
||||
// Request details
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
|
||||
// Device detection
|
||||
deviceType: 'ios' | 'android' | 'web';
|
||||
platform?: string;
|
||||
browser?: string;
|
||||
|
||||
// Redirect decision
|
||||
redirectUrl: string;
|
||||
redirectReason: string;
|
||||
targetingMatched: boolean;
|
||||
|
||||
// UTM parameters
|
||||
utmParameters?: {
|
||||
source?: string;
|
||||
medium?: string;
|
||||
campaign?: string;
|
||||
term?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
// Additional metadata
|
||||
referer?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a click event for real-time streaming
|
||||
*/
|
||||
export function emitClickEvent(eventData: ClickEventData) {
|
||||
clickEventEmitter.emit('click', eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to click events
|
||||
* Returns unsubscribe function
|
||||
*/
|
||||
export function subscribeToClickEvents(
|
||||
callback: (eventData: ClickEventData) => void
|
||||
): () => void {
|
||||
clickEventEmitter.on('click', callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
clickEventEmitter.off('click', callback);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||
|
||||
// Mock the database module so tests don't require a real Postgres connection.
|
||||
vi.mock('./database', () => ({
|
||||
db: {
|
||||
query: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import * as fingerprint from './fingerprint';
|
||||
import { db } from './database';
|
||||
|
||||
const mockDbQuery = db.query as Mock;
|
||||
|
||||
const baseFingerprint = {
|
||||
ipAddress: '24.5.10.100',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36',
|
||||
timezone: 'America/Los_Angeles',
|
||||
language: 'en-US',
|
||||
screenWidth: 1080,
|
||||
screenHeight: 1920,
|
||||
platform: 'Windows',
|
||||
platformVersion: '10',
|
||||
};
|
||||
|
||||
describe('generateFingerprintHash', () => {
|
||||
it('produces a deterministic 64-character SHA-256 hash', () => {
|
||||
const hash1 = fingerprint.generateFingerprintHash(baseFingerprint);
|
||||
const hash2 = fingerprint.generateFingerprintHash(baseFingerprint);
|
||||
|
||||
expect(hash1).toHaveLength(64);
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
it('produces different hashes for different data', () => {
|
||||
const other = { ...baseFingerprint, ipAddress: '10.0.0.1' };
|
||||
const hash1 = fingerprint.generateFingerprintHash(baseFingerprint);
|
||||
const hash2 = fingerprint.generateFingerprintHash(other);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateConfidenceScore', () => {
|
||||
it('returns 0 score when nothing matches', () => {
|
||||
const a = { ...baseFingerprint };
|
||||
const b = {
|
||||
...baseFingerprint,
|
||||
ipAddress: '10.0.0.1',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
|
||||
timezone: 'Asia/Tokyo',
|
||||
language: 'ja-JP',
|
||||
screenWidth: 800,
|
||||
screenHeight: 600,
|
||||
platform: 'Macintosh',
|
||||
platformVersion: '11.0',
|
||||
};
|
||||
|
||||
const { score, matchedFactors } = fingerprint.calculateConfidenceScore(a, b);
|
||||
|
||||
expect(score).toBe(0);
|
||||
expect(matchedFactors).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches IP within the same /24 subnet and normalizes user agent', () => {
|
||||
const click = {
|
||||
...baseFingerprint,
|
||||
ipAddress: '24.5.10.250',
|
||||
timezone: 'Africa/Cairo',
|
||||
language: 'fr-FR',
|
||||
screenWidth: 800,
|
||||
screenHeight: 600,
|
||||
platform: 'Linux',
|
||||
};
|
||||
|
||||
const install = {
|
||||
...baseFingerprint,
|
||||
ipAddress: '24.5.10.123',
|
||||
userAgent: baseFingerprint.userAgent.replace('Chrome/95.0.4638.69', 'Chrome/116.0.0.0'),
|
||||
timezone: 'Europe/London',
|
||||
language: 'de-DE',
|
||||
screenWidth: 1200,
|
||||
screenHeight: 900,
|
||||
platform: 'Windows',
|
||||
};
|
||||
|
||||
const { score, matchedFactors } = fingerprint.calculateConfidenceScore(click, install);
|
||||
|
||||
expect(score).toBe(70);
|
||||
expect(matchedFactors).toContain('ip');
|
||||
expect(matchedFactors).toContain('user_agent');
|
||||
});
|
||||
|
||||
it('matches language by first two characters', () => {
|
||||
const a = {
|
||||
...baseFingerprint,
|
||||
ipAddress: '10.0.0.1',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
|
||||
timezone: 'Asia/Tokyo',
|
||||
screenWidth: 800,
|
||||
screenHeight: 600,
|
||||
platform: 'Macintosh',
|
||||
platformVersion: '11.0',
|
||||
language: 'en-US',
|
||||
};
|
||||
|
||||
const b = {
|
||||
...a,
|
||||
ipAddress: '172.16.0.1',
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G973F) Chrome/91.0.4472.120 Mobile Safari/537.36',
|
||||
timezone: 'UTC',
|
||||
screenWidth: 1024,
|
||||
screenHeight: 768,
|
||||
platform: 'Linux',
|
||||
platformVersion: '10',
|
||||
language: 'en-GB',
|
||||
};
|
||||
|
||||
const { score, matchedFactors } = fingerprint.calculateConfidenceScore(a, b);
|
||||
|
||||
expect(score).toBe(10);
|
||||
expect(matchedFactors).toEqual(['language']);
|
||||
});
|
||||
|
||||
it('matches timezone and resolution', () => {
|
||||
const a = {
|
||||
...baseFingerprint,
|
||||
ipAddress: '10.0.0.1',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
|
||||
language: 'ja-JP',
|
||||
platform: 'Macintosh',
|
||||
platformVersion: '11.0',
|
||||
timezone: 'UTC',
|
||||
screenWidth: 100,
|
||||
screenHeight: 200,
|
||||
};
|
||||
|
||||
const b = {
|
||||
...a,
|
||||
ipAddress: '172.16.0.1',
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G973F) Chrome/91.0.4472.120 Mobile Safari/537.36',
|
||||
language: 'fr-FR',
|
||||
};
|
||||
|
||||
const { score, matchedFactors } = fingerprint.calculateConfidenceScore(a, b);
|
||||
expect(score).toBe(20);
|
||||
expect(matchedFactors.sort()).toEqual(['screen', 'timezone'].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAttributableIp', () => {
|
||||
it('treats public IPv4 as attributable', () => {
|
||||
expect(fingerprint.isAttributableIp('24.5.10.100')).toBe(true);
|
||||
expect(fingerprint.isAttributableIp('8.8.8.8')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects CGNAT (100.64.0.0/10) — the Marriage365 case', () => {
|
||||
expect(fingerprint.isAttributableIp('100.64.0.5')).toBe(false);
|
||||
expect(fingerprint.isAttributableIp('100.127.255.254')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects RFC1918 private, loopback and link-local', () => {
|
||||
expect(fingerprint.isAttributableIp('10.0.0.1')).toBe(false);
|
||||
expect(fingerprint.isAttributableIp('172.16.0.1')).toBe(false);
|
||||
expect(fingerprint.isAttributableIp('192.168.1.1')).toBe(false);
|
||||
expect(fingerprint.isAttributableIp('127.0.0.1')).toBe(false);
|
||||
expect(fingerprint.isAttributableIp('169.254.1.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles IPv6: public attributable, ULA/link-local/loopback not', () => {
|
||||
expect(fingerprint.isAttributableIp('2600:1700:6508:8040::1')).toBe(true);
|
||||
expect(fingerprint.isAttributableIp('fd00::1')).toBe(false); // ULA
|
||||
expect(fingerprint.isAttributableIp('fe80::1')).toBe(false); // link-local
|
||||
expect(fingerprint.isAttributableIp('::1')).toBe(false); // loopback
|
||||
});
|
||||
|
||||
it('unwraps IPv4-mapped IPv6 before classifying', () => {
|
||||
expect(fingerprint.isAttributableIp('::ffff:100.64.0.5')).toBe(false);
|
||||
expect(fingerprint.isAttributableIp('::ffff:24.5.10.100')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for empty/garbage input', () => {
|
||||
expect(fingerprint.isAttributableIp('')).toBe(false);
|
||||
expect(fingerprint.isAttributableIp('not-an-ip')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateConfidenceScore — shared-IP filter', () => {
|
||||
it('does NOT award the IP score for two devices sharing a CGNAT /24', () => {
|
||||
// Exactly the Marriage365 leak: unrelated installs collapsing onto 100.64.0.x.
|
||||
const click = { ...baseFingerprint, ipAddress: '100.64.0.5' };
|
||||
const install = { ...baseFingerprint, ipAddress: '100.64.0.9' };
|
||||
|
||||
const { score, matchedFactors } = fingerprint.calculateConfidenceScore(click, install);
|
||||
|
||||
expect(matchedFactors).not.toContain('ip');
|
||||
// UA(30)+TZ(10)+lang(10)+screen(10) = 60, below the 70 threshold → no match.
|
||||
expect(score).toBe(60);
|
||||
expect(score).toBeLessThan(fingerprint.CONFIDENCE_THRESHOLD);
|
||||
});
|
||||
|
||||
it('still awards the IP score for two devices sharing a public /24', () => {
|
||||
const click = { ...baseFingerprint, ipAddress: '24.5.10.5' };
|
||||
const install = { ...baseFingerprint, ipAddress: '24.5.10.9' };
|
||||
|
||||
const { matchedFactors } = fingerprint.calculateConfidenceScore(click, install);
|
||||
expect(matchedFactors).toContain('ip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchInstallToClick', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
|
||||
mockDbQuery.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns null when there are no click rows', async () => {
|
||||
mockDbQuery.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
const result = await fingerprint.matchInstallToClick(baseFingerprint);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the best match above the confidence threshold', async () => {
|
||||
const clickTime = new Date('2024-12-31T23:00:00Z');
|
||||
|
||||
// First row: only IP match (score 40)
|
||||
const rowA = {
|
||||
click_id: 'click-a',
|
||||
link_id: 'link-a',
|
||||
clicked_at: clickTime.toISOString(),
|
||||
attribution_window_hours: 24,
|
||||
ip_address: '24.5.10.200',
|
||||
user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
|
||||
timezone: 'Asia/Tokyo',
|
||||
language: 'ja-JP',
|
||||
screen_width: 720,
|
||||
screen_height: 1280,
|
||||
platform: 'Macintosh',
|
||||
platform_version: '11.0',
|
||||
};
|
||||
|
||||
// Second row: IP + user agent + timezone + language + screen (score 100)
|
||||
const rowB = {
|
||||
click_id: 'click-b',
|
||||
link_id: 'link-b',
|
||||
clicked_at: clickTime.toISOString(),
|
||||
attribution_window_hours: 24,
|
||||
ip_address: '24.5.10.250',
|
||||
user_agent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36',
|
||||
timezone: 'America/Los_Angeles',
|
||||
language: 'en-US',
|
||||
screen_width: 1080,
|
||||
screen_height: 1920,
|
||||
platform: 'Windows',
|
||||
platform_version: '10',
|
||||
};
|
||||
|
||||
mockDbQuery.mockResolvedValueOnce({ rows: [rowA, rowB] });
|
||||
|
||||
const installFingerprint = {
|
||||
...baseFingerprint,
|
||||
ipAddress: '24.5.10.123',
|
||||
userAgent: baseFingerprint.userAgent.replace('Chrome/95.0.4638.69', 'Chrome/116.0.0.0'),
|
||||
};
|
||||
|
||||
const result = await fingerprint.matchInstallToClick(installFingerprint);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.clickId).toBe('click-b');
|
||||
expect(result?.confidenceScore).toBe(100);
|
||||
expect(result?.matchedFactors).toEqual(expect.arrayContaining(['ip', 'user_agent', 'timezone', 'language', 'screen']));
|
||||
});
|
||||
|
||||
it('skips clicks that are outside the attribution window', async () => {
|
||||
const oldClickTime = new Date('2024-01-01T00:00:00Z');
|
||||
|
||||
mockDbQuery.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{
|
||||
click_id: 'click-old',
|
||||
link_id: 'link-old',
|
||||
clicked_at: oldClickTime.toISOString(),
|
||||
attribution_window_hours: 1,
|
||||
ip_address: baseFingerprint.ipAddress,
|
||||
user_agent: baseFingerprint.userAgent,
|
||||
timezone: baseFingerprint.timezone,
|
||||
language: baseFingerprint.language,
|
||||
screen_width: baseFingerprint.screenWidth,
|
||||
screen_height: baseFingerprint.screenHeight,
|
||||
platform: baseFingerprint.platform,
|
||||
platform_version: baseFingerprint.platformVersion,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await fingerprint.matchInstallToClick(baseFingerprint);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordInstallEvent', () => {
|
||||
beforeEach(() => {
|
||||
mockDbQuery.mockReset();
|
||||
});
|
||||
|
||||
it('inserts an install event and returns the install id when no match is found', async () => {
|
||||
// matchInstallToClick is invoked internally by recordInstallEvent.
|
||||
// The first db query is used to find click events. Return an empty list to force a null match.
|
||||
mockDbQuery.mockResolvedValueOnce({ rows: [] });
|
||||
mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-123', deep_link_data: {} }] });
|
||||
|
||||
const result = await fingerprint.recordInstallEvent(baseFingerprint, 'device-1');
|
||||
|
||||
expect(result.installId).toBe('install-123');
|
||||
expect(result.match).toBeNull();
|
||||
expect(result.deepLinkData).toEqual({});
|
||||
|
||||
expect(mockDbQuery).toHaveBeenCalledTimes(2);
|
||||
expect(mockDbQuery).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
expect.arrayContaining([null, null, expect.any(String), null, expect.any(String), expect.any(String), null, null, null, null, null, null, null, 'device-1', null])
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards sdk name + version as the last two positional params of the install insert (guards column misalignment)', async () => {
|
||||
mockDbQuery.mockResolvedValueOnce({ rows: [] }); // matchInstallToClick -> no match
|
||||
mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-456', deep_link_data: {} }] });
|
||||
|
||||
await fingerprint.recordInstallEvent(baseFingerprint, 'device-1', undefined, {
|
||||
name: 'react-native',
|
||||
version: '1.4.0',
|
||||
});
|
||||
|
||||
const [sql, params] = mockDbQuery.mock.calls[1];
|
||||
expect(sql).toMatch(/INSERT INTO install_events/);
|
||||
expect(sql).toMatch(/sdk_name/);
|
||||
expect(sql).toMatch(/sdk_version/);
|
||||
// sdk_name/$16, sdk_version/$17, then attribution_method/$18, matched_factors/$19
|
||||
expect(params).toHaveLength(19);
|
||||
expect(params[15]).toBe('react-native'); // sdk_name
|
||||
expect(params[16]).toBe('1.4.0'); // sdk_version
|
||||
expect(params[17]).toBe('none'); // attribution_method (no fingerprint match)
|
||||
expect(params[18]).toBeNull(); // matched_factors (no match)
|
||||
});
|
||||
|
||||
it('stores null sdk on the install row when the metadata is absent or empty', async () => {
|
||||
mockDbQuery.mockResolvedValueOnce({ rows: [] });
|
||||
mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-789', deep_link_data: {} }] });
|
||||
|
||||
await fingerprint.recordInstallEvent(baseFingerprint, 'device-1', undefined, { name: '', version: undefined });
|
||||
|
||||
const params = mockDbQuery.mock.calls[1][1];
|
||||
expect(params[15]).toBeNull(); // sdk_name '' -> null
|
||||
expect(params[16]).toBeNull(); // sdk_version undefined -> null
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,555 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] } {
|
||||
let score = 0;
|
||||
const matchedFactors: string[] = [];
|
||||
|
||||
// Compare IP addresses (normalized to /24 subnet for IPv4). Only count IPs
|
||||
// that actually identify a device — shared/NAT ranges (CGNAT, RFC1918, etc.)
|
||||
// are skipped so unrelated users behind the same NAT can't match on IP.
|
||||
if (
|
||||
fingerprint1.ipAddress &&
|
||||
fingerprint2.ipAddress &&
|
||||
isAttributableIp(fingerprint1.ipAddress) &&
|
||||
isAttributableIp(fingerprint2.ipAddress)
|
||||
) {
|
||||
const ip1 = normalizeIP(fingerprint1.ipAddress);
|
||||
const ip2 = normalizeIP(fingerprint2.ipAddress);
|
||||
|
||||
if (ip1 === ip2) {
|
||||
score += FINGERPRINT_WEIGHTS.IP_ADDRESS;
|
||||
matchedFactors.push('ip');
|
||||
}
|
||||
}
|
||||
|
||||
// Compare user agents (normalized to platform + browser)
|
||||
if (fingerprint1.userAgent && fingerprint2.userAgent) {
|
||||
const ua1 = normalizeUserAgent(fingerprint1.userAgent);
|
||||
const ua2 = normalizeUserAgent(fingerprint2.userAgent);
|
||||
|
||||
if (ua1 === ua2) {
|
||||
score += FINGERPRINT_WEIGHTS.USER_AGENT;
|
||||
matchedFactors.push('user_agent');
|
||||
}
|
||||
}
|
||||
|
||||
// Compare timezone
|
||||
if (fingerprint1.timezone && fingerprint2.timezone) {
|
||||
if (fingerprint1.timezone === fingerprint2.timezone) {
|
||||
score += FINGERPRINT_WEIGHTS.TIMEZONE;
|
||||
matchedFactors.push('timezone');
|
||||
}
|
||||
}
|
||||
|
||||
// Compare language
|
||||
if (fingerprint1.language && fingerprint2.language) {
|
||||
// Match first 2 characters (e.g., "en-US" matches "en-GB")
|
||||
const lang1 = fingerprint1.language.substring(0, 2).toLowerCase();
|
||||
const lang2 = fingerprint2.language.substring(0, 2).toLowerCase();
|
||||
|
||||
if (lang1 === lang2) {
|
||||
score += FINGERPRINT_WEIGHTS.LANGUAGE;
|
||||
matchedFactors.push('language');
|
||||
}
|
||||
}
|
||||
|
||||
// Compare screen resolution
|
||||
if (
|
||||
fingerprint1.screenWidth &&
|
||||
fingerprint1.screenHeight &&
|
||||
fingerprint2.screenWidth &&
|
||||
fingerprint2.screenHeight
|
||||
) {
|
||||
if (
|
||||
fingerprint1.screenWidth === fingerprint2.screenWidth &&
|
||||
fingerprint1.screenHeight === fingerprint2.screenHeight
|
||||
) {
|
||||
score += FINGERPRINT_WEIGHTS.SCREEN_RESOLUTION;
|
||||
matchedFactors.push('screen');
|
||||
}
|
||||
}
|
||||
|
||||
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 } = calculateConfidenceScore(
|
||||
installFingerprint,
|
||||
clickFingerprint
|
||||
);
|
||||
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { invalidateLinkResolutionCache } from './link-resolution-cache';
|
||||
|
||||
describe('invalidateLinkResolutionCache', () => {
|
||||
it('no-ops when redis is null', async () => {
|
||||
await expect(invalidateLinkResolutionCache(null, 'abc')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('no-ops when redis is undefined', async () => {
|
||||
await expect(invalidateLinkResolutionCache(undefined, 'abc')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes legacy key when no templateSlug', async () => {
|
||||
const del = vi.fn().mockResolvedValue(1);
|
||||
await invalidateLinkResolutionCache({ del } as any, 'abc');
|
||||
expect(del).toHaveBeenCalledTimes(1);
|
||||
expect(del).toHaveBeenCalledWith('link:abc');
|
||||
});
|
||||
|
||||
it('deletes both legacy and template keys when templateSlug is provided', async () => {
|
||||
const del = vi.fn().mockResolvedValue(2);
|
||||
await invalidateLinkResolutionCache({ del } as any, 'abc', 'mytemplate');
|
||||
expect(del).toHaveBeenCalledTimes(1);
|
||||
expect(del).toHaveBeenCalledWith('link:abc', 'link:mytemplate:abc');
|
||||
});
|
||||
|
||||
it('skips template key when templateSlug is null', async () => {
|
||||
const del = vi.fn().mockResolvedValue(1);
|
||||
await invalidateLinkResolutionCache({ del } as any, 'abc', null);
|
||||
expect(del).toHaveBeenCalledTimes(1);
|
||||
expect(del).toHaveBeenCalledWith('link:abc');
|
||||
});
|
||||
|
||||
it('skips template key when templateSlug is empty string', async () => {
|
||||
const del = vi.fn().mockResolvedValue(1);
|
||||
await invalidateLinkResolutionCache({ del } as any, 'abc', '');
|
||||
expect(del).toHaveBeenCalledTimes(1);
|
||||
expect(del).toHaveBeenCalledWith('link:abc');
|
||||
});
|
||||
|
||||
it('swallows errors from redis.del without throwing', async () => {
|
||||
const del = vi.fn().mockRejectedValue(new Error('connection lost'));
|
||||
await expect(invalidateLinkResolutionCache({ del } as any, 'abc')).resolves.toBeUndefined();
|
||||
expect(del).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Invalidate the Redis cache entries used by the redirect and SDK resolve
|
||||
* read paths for a given link. Safe to call when Redis is not configured.
|
||||
*
|
||||
* Cache key patterns (set in redirect.ts / sdk.ts):
|
||||
* link:${shortCode}
|
||||
* link:${templateSlug}:${shortCode}
|
||||
*/
|
||||
export async function invalidateLinkResolutionCache(
|
||||
redis: { del(...keys: string[]): Promise<number> } | null | undefined,
|
||||
shortCode: string,
|
||||
templateSlug?: string | null,
|
||||
): Promise<void> {
|
||||
if (!redis) return;
|
||||
try {
|
||||
const keys = [`link:${shortCode}`];
|
||||
if (templateSlug) keys.push(`link:${templateSlug}:${shortCode}`);
|
||||
await redis.del(...keys);
|
||||
} catch {
|
||||
// Swallow — a cache miss on the next read is self-healing.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
generateShortCode,
|
||||
parseUserAgent,
|
||||
buildRedirectUrl,
|
||||
detectDevice,
|
||||
getLocationFromIP,
|
||||
} from './utils';
|
||||
|
||||
describe('generateShortCode', () => {
|
||||
it('should generate a short code of default length 8', () => {
|
||||
const code = generateShortCode();
|
||||
expect(code).toHaveLength(8);
|
||||
expect(typeof code).toBe('string');
|
||||
});
|
||||
|
||||
it('should generate a short code of custom length', () => {
|
||||
const code = generateShortCode(12);
|
||||
expect(code).toHaveLength(12);
|
||||
});
|
||||
|
||||
it('should generate unique codes', () => {
|
||||
const code1 = generateShortCode();
|
||||
const code2 = generateShortCode();
|
||||
expect(code1).not.toBe(code2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseUserAgent', () => {
|
||||
it('should parse Chrome on Windows user agent', () => {
|
||||
const ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36';
|
||||
const result = parseUserAgent(ua);
|
||||
|
||||
expect(result.deviceType).toBe('desktop');
|
||||
expect(result.platform).toBe('Windows');
|
||||
expect(result.browser).toBe('Chrome');
|
||||
});
|
||||
|
||||
it('should parse iPhone user agent', () => {
|
||||
const ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1';
|
||||
const result = parseUserAgent(ua);
|
||||
|
||||
expect(result.deviceType).toBe('mobile');
|
||||
expect(result.platform).toBe('iOS');
|
||||
expect(result.browser).toBe('Mobile Safari');
|
||||
});
|
||||
|
||||
it('should parse Android user agent', () => {
|
||||
const ua = 'Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36';
|
||||
const result = parseUserAgent(ua);
|
||||
|
||||
expect(result.deviceType).toBe('mobile');
|
||||
expect(result.platform).toBe('Android');
|
||||
expect(result.browser).toBe('Chrome');
|
||||
});
|
||||
|
||||
it('should handle empty user agent', () => {
|
||||
const result = parseUserAgent('');
|
||||
|
||||
expect(result.deviceType).toBe('desktop');
|
||||
expect(result.platform).toBe('unknown');
|
||||
expect(result.browser).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectDevice', () => {
|
||||
it('should detect iOS devices', () => {
|
||||
expect(detectDevice('Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X)')).toBe('ios');
|
||||
expect(detectDevice('Mozilla/5.0 (iPad; CPU OS 14_6 like Mac OS X)')).toBe('ios');
|
||||
expect(detectDevice('Mozilla/5.0 (iPod touch; CPU iPhone OS 14_6 like Mac OS X)')).toBe('ios');
|
||||
});
|
||||
|
||||
it('should detect Android devices', () => {
|
||||
expect(detectDevice('Mozilla/5.0 (Linux; Android 11; Pixel 5)')).toBe('android');
|
||||
expect(detectDevice('Mozilla/5.0 (Linux; Android 10; SM-G973F)')).toBe('android');
|
||||
});
|
||||
|
||||
it('should default to web for desktop browsers', () => {
|
||||
expect(detectDevice('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/91.0')).toBe('web');
|
||||
expect(detectDevice('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe('web');
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
expect(detectDevice('MOZILLA/5.0 (IPHONE; CPU IPHONE OS 14_6)')).toBe('ios');
|
||||
expect(detectDevice('MOZILLA/5.0 (LINUX; ANDROID 11)')).toBe('android');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRedirectUrl', () => {
|
||||
it('should return original URL when no UTM parameters provided', () => {
|
||||
const url = 'https://example.com/page';
|
||||
const result = buildRedirectUrl(url);
|
||||
expect(result).toBe(url);
|
||||
});
|
||||
|
||||
it('should append UTM parameters to URL', () => {
|
||||
const url = 'https://example.com/page';
|
||||
const utmParams = {
|
||||
source: 'newsletter',
|
||||
medium: 'email',
|
||||
campaign: 'summer-sale',
|
||||
};
|
||||
const result = buildRedirectUrl(url, utmParams);
|
||||
|
||||
expect(result).toContain('utm_source=newsletter');
|
||||
expect(result).toContain('utm_medium=email');
|
||||
expect(result).toContain('utm_campaign=summer-sale');
|
||||
});
|
||||
|
||||
it('should preserve existing query parameters', () => {
|
||||
const url = 'https://example.com/page?foo=bar';
|
||||
const utmParams = { source: 'google' };
|
||||
const result = buildRedirectUrl(url, utmParams);
|
||||
|
||||
expect(result).toContain('foo=bar');
|
||||
expect(result).toContain('utm_source=google');
|
||||
});
|
||||
|
||||
it('should skip UTM parameters with empty values', () => {
|
||||
const url = 'https://example.com/page';
|
||||
const utmParams = {
|
||||
source: 'twitter',
|
||||
medium: '',
|
||||
campaign: 'promo',
|
||||
};
|
||||
const result = buildRedirectUrl(url, utmParams);
|
||||
|
||||
expect(result).toContain('utm_source=twitter');
|
||||
expect(result).not.toContain('utm_medium');
|
||||
expect(result).toContain('utm_campaign=promo');
|
||||
});
|
||||
|
||||
it('should URL encode UTM parameter values', () => {
|
||||
const url = 'https://example.com/page';
|
||||
const utmParams = {
|
||||
source: 'email newsletter',
|
||||
campaign: 'summer sale 2024',
|
||||
};
|
||||
const result = buildRedirectUrl(url, utmParams);
|
||||
|
||||
expect(result).toContain('utm_source=email+newsletter');
|
||||
expect(result).toContain('utm_campaign=summer+sale+2024');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLocationFromIP', () => {
|
||||
it('should return null values for invalid IP', () => {
|
||||
const result = getLocationFromIP('invalid-ip');
|
||||
|
||||
expect(result.countryCode).toBeNull();
|
||||
expect(result.countryName).toBeNull();
|
||||
expect(result.city).toBeNull();
|
||||
expect(result.latitude).toBeNull();
|
||||
expect(result.longitude).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null values for private IP addresses', () => {
|
||||
const result = getLocationFromIP('192.168.1.1');
|
||||
|
||||
expect(result.countryCode).toBeNull();
|
||||
expect(result.countryName).toBeNull();
|
||||
});
|
||||
|
||||
it('should parse public IP addresses', () => {
|
||||
// Google DNS IP (known to be US-based)
|
||||
const result = getLocationFromIP('8.8.8.8');
|
||||
|
||||
expect(result.countryCode).toBe('US');
|
||||
expect(result.countryName).toBe('United States');
|
||||
expect(result.latitude).toBeDefined();
|
||||
expect(result.longitude).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle countries not in the mapping', () => {
|
||||
// This test might need adjustment based on actual geoip-lite behavior
|
||||
// but demonstrates the fallback logic
|
||||
const result = getLocationFromIP('8.8.8.8');
|
||||
|
||||
if (result.countryCode && !result.countryName) {
|
||||
// Fallback to country code
|
||||
expect(result.countryName).toBe(result.countryCode);
|
||||
} else {
|
||||
// Has a mapped name
|
||||
expect(typeof result.countryName).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import geoip from 'geoip-lite';
|
||||
import UAParser from 'ua-parser-js';
|
||||
|
||||
/**
|
||||
* Generate a URL-safe random short code using nanoid.
|
||||
*
|
||||
* @param length - Number of characters in the generated code. Defaults to 8.
|
||||
* @returns A random URL-safe string of the specified length.
|
||||
*/
|
||||
export function generateShortCode(length: number = 8): string {
|
||||
return nanoid(length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a User-Agent string into structured device and browser information.
|
||||
*
|
||||
* @param userAgent - Raw User-Agent header value from an HTTP request.
|
||||
* @returns An object containing `deviceType`, `platform`, `platformVersion`, and `browser`.
|
||||
*/
|
||||
export function parseUserAgent(userAgent: string) {
|
||||
const parser = new UAParser(userAgent);
|
||||
const result = parser.getResult();
|
||||
|
||||
return {
|
||||
deviceType: result.device.type || 'desktop',
|
||||
platform: result.os.name || 'unknown',
|
||||
platformVersion: result.os.version || undefined,
|
||||
browser: result.browser.name || 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
// Country code to name mapping (common countries)
|
||||
const COUNTRY_NAMES: Record<string, string> = {
|
||||
US: 'United States',
|
||||
GB: 'United Kingdom',
|
||||
CA: 'Canada',
|
||||
AU: 'Australia',
|
||||
DE: 'Germany',
|
||||
FR: 'France',
|
||||
IT: 'Italy',
|
||||
ES: 'Spain',
|
||||
NL: 'Netherlands',
|
||||
SE: 'Sweden',
|
||||
NO: 'Norway',
|
||||
DK: 'Denmark',
|
||||
FI: 'Finland',
|
||||
PL: 'Poland',
|
||||
BR: 'Brazil',
|
||||
MX: 'Mexico',
|
||||
AR: 'Argentina',
|
||||
IN: 'India',
|
||||
CN: 'China',
|
||||
JP: 'Japan',
|
||||
KR: 'South Korea',
|
||||
SG: 'Singapore',
|
||||
MY: 'Malaysia',
|
||||
TH: 'Thailand',
|
||||
ID: 'Indonesia',
|
||||
PH: 'Philippines',
|
||||
VN: 'Vietnam',
|
||||
ZA: 'South Africa',
|
||||
EG: 'Egypt',
|
||||
NG: 'Nigeria',
|
||||
KE: 'Kenya',
|
||||
RU: 'Russia',
|
||||
TR: 'Turkey',
|
||||
AE: 'United Arab Emirates',
|
||||
SA: 'Saudi Arabia',
|
||||
IL: 'Israel',
|
||||
NZ: 'New Zealand',
|
||||
IE: 'Ireland',
|
||||
CH: 'Switzerland',
|
||||
AT: 'Austria',
|
||||
BE: 'Belgium',
|
||||
PT: 'Portugal',
|
||||
GR: 'Greece',
|
||||
CZ: 'Czech Republic',
|
||||
HU: 'Hungary',
|
||||
RO: 'Romania',
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up geographic location data for an IP address using geoip-lite.
|
||||
*
|
||||
* @param ip - IPv4 or IPv6 address to look up.
|
||||
* @returns An object with `countryCode`, `countryName`, `region`, `city`,
|
||||
* `latitude`, `longitude`, and `timezone`. All fields are `null` when the
|
||||
* IP address is not found in the GeoIP database.
|
||||
*/
|
||||
export function getLocationFromIP(ip: string) {
|
||||
const geo = geoip.lookup(ip);
|
||||
|
||||
if (!geo) {
|
||||
return {
|
||||
countryCode: null,
|
||||
countryName: null,
|
||||
region: null,
|
||||
city: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
timezone: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
countryCode: geo.country,
|
||||
countryName: COUNTRY_NAMES[geo.country] || geo.country,
|
||||
region: geo.region,
|
||||
city: geo.city,
|
||||
latitude: geo.ll?.[0] || null,
|
||||
longitude: geo.ll?.[1] || null,
|
||||
timezone: geo.timezone,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Append UTM tracking parameters to a URL.
|
||||
*
|
||||
* Each key in `utmParameters` is prefixed with `utm_` before being added as a
|
||||
* query parameter (e.g., `{ source: 'email' }` → `?utm_source=email`).
|
||||
* Empty values are skipped.
|
||||
*
|
||||
* @param originalUrl - The destination URL to append parameters to.
|
||||
* @param utmParameters - Optional map of UTM parameter names (without the `utm_` prefix) to values.
|
||||
* @returns The URL string with UTM parameters appended.
|
||||
*/
|
||||
export function buildRedirectUrl(
|
||||
originalUrl: string | null | undefined,
|
||||
utmParameters?: Record<string, string>
|
||||
): string | null {
|
||||
if (!originalUrl) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(originalUrl);
|
||||
|
||||
if (utmParameters) {
|
||||
Object.entries(utmParameters).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
url.searchParams.set(`utm_${key}`, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
} catch {
|
||||
// If URL is invalid, return it as-is rather than crashing
|
||||
return originalUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the device platform from a User-Agent string.
|
||||
*
|
||||
* Uses simple substring matching to identify iOS and Android devices.
|
||||
* Anything that does not match is classified as `'web'`.
|
||||
*
|
||||
* @param userAgent - Raw User-Agent header value from an HTTP request.
|
||||
* @returns `'ios'`, `'android'`, or `'web'`.
|
||||
*/
|
||||
export function detectDevice(userAgent: string): 'ios' | 'android' | 'web' {
|
||||
const ua = userAgent.toLowerCase();
|
||||
|
||||
if (ua.includes('iphone') || ua.includes('ipad') || ua.includes('ipod')) {
|
||||
return 'ios';
|
||||
}
|
||||
|
||||
if (ua.includes('android')) {
|
||||
return 'android';
|
||||
}
|
||||
|
||||
return 'web';
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
generateWebhookSignature,
|
||||
generateWebhookSecret,
|
||||
deliverWebhook,
|
||||
triggerWebhooks,
|
||||
} from './webhook.js';
|
||||
import type { Webhook, WebhookPayload } from '../types/index.js';
|
||||
|
||||
// Helper to build a Webhook fixture
|
||||
function makeWebhook(overrides: Partial<Webhook> = {}): Webhook {
|
||||
return {
|
||||
id: 'wh-1',
|
||||
user_id: 'user-1',
|
||||
name: 'Test Webhook',
|
||||
url: 'https://example.com/hook',
|
||||
secret: 'test-secret',
|
||||
events: ['click_event'],
|
||||
is_active: true,
|
||||
retry_count: 1,
|
||||
timeout_ms: 5000,
|
||||
headers: {},
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to build a WebhookPayload fixture
|
||||
function makePayload(overrides: Partial<WebhookPayload> = {}): WebhookPayload {
|
||||
return {
|
||||
event: 'click_event',
|
||||
event_id: 'evt-1',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
data: { id: 'data-1' } as any,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to make a minimal Response-like mock
|
||||
function mockResponse(
|
||||
ok: boolean,
|
||||
status: number,
|
||||
statusText: string,
|
||||
body = ''
|
||||
): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
statusText,
|
||||
text: () => Promise.resolve(body),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe('generateWebhookSignature', () => {
|
||||
it('returns a hex string', () => {
|
||||
const sig = generateWebhookSignature('payload', 'secret');
|
||||
expect(typeof sig).toBe('string');
|
||||
expect(sig).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('is deterministic for the same inputs', () => {
|
||||
const sig1 = generateWebhookSignature('hello', 'key');
|
||||
const sig2 = generateWebhookSignature('hello', 'key');
|
||||
expect(sig1).toBe(sig2);
|
||||
});
|
||||
|
||||
it('produces different signatures for different payloads', () => {
|
||||
const sig1 = generateWebhookSignature('payload-a', 'secret');
|
||||
const sig2 = generateWebhookSignature('payload-b', 'secret');
|
||||
expect(sig1).not.toBe(sig2);
|
||||
});
|
||||
|
||||
it('produces different signatures for different secrets', () => {
|
||||
const sig1 = generateWebhookSignature('payload', 'secret-a');
|
||||
const sig2 = generateWebhookSignature('payload', 'secret-b');
|
||||
expect(sig1).not.toBe(sig2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateWebhookSecret', () => {
|
||||
it('returns a 64-character hex string (32 random bytes)', () => {
|
||||
const secret = generateWebhookSecret();
|
||||
expect(typeof secret).toBe('string');
|
||||
expect(secret).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('returns unique values on each call', () => {
|
||||
const s1 = generateWebhookSecret();
|
||||
const s2 = generateWebhookSecret();
|
||||
expect(s1).not.toBe(s2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliverWebhook', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns a success result on HTTP 200', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK', 'received')));
|
||||
|
||||
const webhook = makeWebhook();
|
||||
const payload = makePayload();
|
||||
|
||||
const result = await deliverWebhook(webhook, payload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.webhookId).toBe(webhook.id);
|
||||
expect(result.eventType).toBe(payload.event);
|
||||
expect(result.eventId).toBe(payload.event_id);
|
||||
expect(result.responseStatus).toBe(200);
|
||||
expect(result.responseBody).toBe('received');
|
||||
expect(result.attemptNumber).toBe(1);
|
||||
expect(result.deliveredAt).toBeDefined();
|
||||
expect(result.errorMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns a failure result on HTTP 500', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(false, 500, 'Internal Server Error', 'error body')));
|
||||
|
||||
// Use logDelivery to capture per-attempt details (the final return is a
|
||||
// generic "Failed after N attempts" object once retries are exhausted).
|
||||
const logDelivery = vi.fn().mockResolvedValue(undefined);
|
||||
const webhook = makeWebhook({ retry_count: 1 });
|
||||
const payload = makePayload();
|
||||
|
||||
const result = await deliverWebhook(webhook, payload, logDelivery);
|
||||
|
||||
// Per-attempt result captured via logDelivery
|
||||
const attemptResult = logDelivery.mock.calls[0][0];
|
||||
expect(attemptResult.responseStatus).toBe(500);
|
||||
expect(attemptResult.errorMessage).toBe('HTTP 500: Internal Server Error');
|
||||
expect(attemptResult.responseBody).toBe('error body');
|
||||
|
||||
// Final returned result after retries exhausted
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.deliveredAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets correct request headers', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK'));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const webhook = makeWebhook({ headers: { 'X-Custom': 'value' } });
|
||||
const payload = makePayload();
|
||||
|
||||
await deliverWebhook(webhook, payload);
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe(webhook.url);
|
||||
expect(init.method).toBe('POST');
|
||||
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers['Content-Type']).toBe('application/json');
|
||||
expect(headers['X-LinkForty-Event']).toBe(payload.event);
|
||||
expect(headers['X-LinkForty-Event-ID']).toBe(payload.event_id);
|
||||
expect(headers['X-LinkForty-Signature']).toMatch(/^sha256=[0-9a-f]{64}$/);
|
||||
expect(headers['User-Agent']).toBe('LinkForty-Webhook/1.0');
|
||||
expect(headers['X-Custom']).toBe('value');
|
||||
});
|
||||
|
||||
it('sends the correct JSON body', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK'));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const webhook = makeWebhook();
|
||||
const payload = makePayload();
|
||||
|
||||
await deliverWebhook(webhook, payload);
|
||||
|
||||
const [, init] = mockFetch.mock.calls[0];
|
||||
expect(JSON.parse(init.body as string)).toEqual(payload);
|
||||
});
|
||||
|
||||
it('truncates response body to 1000 characters', async () => {
|
||||
const longBody = 'x'.repeat(2000);
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK', longBody)));
|
||||
|
||||
const result = await deliverWebhook(makeWebhook(), makePayload());
|
||||
|
||||
expect(result.responseBody).toHaveLength(1000);
|
||||
});
|
||||
|
||||
it('retries on failure and succeeds on second attempt', async () => {
|
||||
const mockFetch = vi.fn()
|
||||
.mockResolvedValueOnce(mockResponse(false, 503, 'Service Unavailable'))
|
||||
.mockResolvedValueOnce(mockResponse(true, 200, 'OK'));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const webhook = makeWebhook({ retry_count: 2 });
|
||||
const payload = makePayload();
|
||||
|
||||
const promise = deliverWebhook(webhook, payload);
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.attemptNumber).toBe(2);
|
||||
});
|
||||
|
||||
it('exhausts all retries and returns final failure', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(false, 500, 'Error')));
|
||||
|
||||
const webhook = makeWebhook({ retry_count: 3 });
|
||||
const payload = makePayload();
|
||||
|
||||
const promise = deliverWebhook(webhook, payload);
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorMessage).toBe('Failed after 3 attempts');
|
||||
expect(result.attemptNumber).toBe(3);
|
||||
});
|
||||
|
||||
it('calls logDelivery for each attempt', async () => {
|
||||
const mockFetch = vi.fn()
|
||||
.mockResolvedValueOnce(mockResponse(false, 500, 'Error'))
|
||||
.mockResolvedValueOnce(mockResponse(true, 200, 'OK'));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const logDelivery = vi.fn().mockResolvedValue(undefined);
|
||||
const webhook = makeWebhook({ retry_count: 2 });
|
||||
const payload = makePayload();
|
||||
|
||||
const promise = deliverWebhook(webhook, payload, logDelivery);
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(logDelivery).toHaveBeenCalledTimes(2);
|
||||
expect(logDelivery.mock.calls[0][0].success).toBe(false);
|
||||
expect(logDelivery.mock.calls[1][0].success).toBe(true);
|
||||
});
|
||||
|
||||
it('continues even if logDelivery throws', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK')));
|
||||
const logDelivery = vi.fn().mockRejectedValue(new Error('log failed'));
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const result = await deliverWebhook(makeWebhook(), makePayload(), logDelivery);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('captures timeout error in logDelivery when fetch exceeds timeout_ms', async () => {
|
||||
const abortError = new Error('The operation was aborted');
|
||||
abortError.name = 'AbortError';
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abortError));
|
||||
|
||||
const logDelivery = vi.fn().mockResolvedValue(undefined);
|
||||
const webhook = makeWebhook({ timeout_ms: 1000, retry_count: 1 });
|
||||
const result = await deliverWebhook(webhook, makePayload(), logDelivery);
|
||||
|
||||
// Per-attempt error is exposed via logDelivery
|
||||
const attemptResult = logDelivery.mock.calls[0][0];
|
||||
expect(attemptResult.success).toBe(false);
|
||||
expect(attemptResult.errorMessage).toBe('Timeout after 1000ms');
|
||||
|
||||
// Final result reflects exhausted retries
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('captures network error message in logDelivery', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
|
||||
|
||||
const logDelivery = vi.fn().mockResolvedValue(undefined);
|
||||
const webhook = makeWebhook({ retry_count: 1 });
|
||||
const result = await deliverWebhook(webhook, makePayload(), logDelivery);
|
||||
|
||||
const attemptResult = logDelivery.mock.calls[0][0];
|
||||
expect(attemptResult.success).toBe(false);
|
||||
expect(attemptResult.errorMessage).toBe('ECONNREFUSED');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerWebhooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does nothing when no webhooks provided', async () => {
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await triggerWebhooks([], 'click_event', 'evt-1', {});
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips inactive webhooks', async () => {
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const inactive = makeWebhook({ is_active: false });
|
||||
await triggerWebhooks([inactive], 'click_event', 'evt-1', {});
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips webhooks not subscribed to the event', async () => {
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const webhook = makeWebhook({ events: ['install_event'] });
|
||||
await triggerWebhooks([webhook], 'click_event', 'evt-1', {});
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delivers to matching active webhooks', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK'));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const webhook = makeWebhook({ events: ['click_event', 'install_event'] });
|
||||
triggerWebhooks([webhook], 'click_event', 'evt-1', { foo: 'bar' });
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.event).toBe('click_event');
|
||||
expect(body.event_id).toBe('evt-1');
|
||||
expect(body.data).toEqual({ foo: 'bar' });
|
||||
});
|
||||
|
||||
it('delivers to multiple matching webhooks', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK'));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const wh1 = makeWebhook({ id: 'wh-1', url: 'https://a.com/hook' });
|
||||
const wh2 = makeWebhook({ id: 'wh-2', url: 'https://b.com/hook' });
|
||||
triggerWebhooks([wh1, wh2], 'click_event', 'evt-2', {});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('calls logDelivery with webhookId and result', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK'));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const logDelivery = vi.fn().mockResolvedValue(undefined);
|
||||
const webhook = makeWebhook({ id: 'wh-log' });
|
||||
|
||||
triggerWebhooks([webhook], 'click_event', 'evt-3', {}, logDelivery);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(logDelivery).toHaveBeenCalledWith('wh-log', expect.objectContaining({ success: true }));
|
||||
});
|
||||
|
||||
it('includes the timestamp in the payload', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK'));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
triggerWebhooks([makeWebhook()], 'click_event', 'evt-4', {});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.timestamp).toBeDefined();
|
||||
expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import crypto from 'crypto';
|
||||
import type { Webhook, WebhookPayload, WebhookDeliveryResult, WebhookEvent } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Generate HMAC SHA-256 signature for webhook payload
|
||||
*/
|
||||
export function generateWebhookSignature(payload: string, secret: string): string {
|
||||
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure random secret for webhook signing
|
||||
*/
|
||||
export function generateWebhookSecret(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep utility for retry delays
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt a single webhook delivery
|
||||
*/
|
||||
async function attemptWebhookDelivery(
|
||||
webhook: Webhook,
|
||||
payload: WebhookPayload,
|
||||
attemptNumber: number
|
||||
): Promise<WebhookDeliveryResult> {
|
||||
const payloadString = JSON.stringify(payload);
|
||||
const signature = generateWebhookSignature(payloadString, webhook.secret);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-LinkForty-Signature': `sha256=${signature}`,
|
||||
'X-LinkForty-Event': payload.event,
|
||||
'X-LinkForty-Event-ID': payload.event_id,
|
||||
'User-Agent': 'LinkForty-Webhook/1.0',
|
||||
...webhook.headers,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), webhook.timeout_ms);
|
||||
|
||||
try {
|
||||
const response = await fetch(webhook.url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payloadString,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const responseBody = await response.text().catch(() => '');
|
||||
|
||||
const result: WebhookDeliveryResult = {
|
||||
success: response.ok,
|
||||
webhookId: webhook.id,
|
||||
eventType: payload.event,
|
||||
eventId: payload.event_id,
|
||||
responseStatus: response.status,
|
||||
responseBody: responseBody.substring(0, 1000), // Limit response body size
|
||||
attemptNumber,
|
||||
deliveredAt: response.ok ? new Date().toISOString() : undefined,
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
result.errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
webhookId: webhook.id,
|
||||
eventType: payload.event,
|
||||
eventId: payload.event_id,
|
||||
errorMessage: error.name === 'AbortError'
|
||||
? `Timeout after ${webhook.timeout_ms}ms`
|
||||
: error.message || 'Unknown error',
|
||||
attemptNumber,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver webhook with retry logic and exponential backoff
|
||||
*/
|
||||
export async function deliverWebhook(
|
||||
webhook: Webhook,
|
||||
payload: WebhookPayload,
|
||||
logDelivery?: (result: WebhookDeliveryResult) => Promise<void>
|
||||
): Promise<WebhookDeliveryResult> {
|
||||
const maxRetries = webhook.retry_count ?? 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
const result = await attemptWebhookDelivery(webhook, payload, attempt);
|
||||
|
||||
// Log delivery attempt if logging function provided
|
||||
if (logDelivery) {
|
||||
await logDelivery(result).catch(err => {
|
||||
console.error('Failed to log webhook delivery:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// If successful, return immediately
|
||||
if (result.success) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// If not the last attempt, wait before retrying with exponential backoff
|
||||
if (attempt < maxRetries) {
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (capped at 30s)
|
||||
const delayMs = Math.min(1000 * Math.pow(2, attempt - 1), 30000);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
// Return the last failed result
|
||||
return {
|
||||
success: false,
|
||||
webhookId: webhook.id,
|
||||
eventType: payload.event,
|
||||
eventId: payload.event_id,
|
||||
errorMessage: `Failed after ${maxRetries} attempts`,
|
||||
attemptNumber: maxRetries,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger webhooks for a specific event (fire and forget)
|
||||
*/
|
||||
export async function triggerWebhooks(
|
||||
webhooks: Webhook[],
|
||||
event: WebhookEvent,
|
||||
eventId: string,
|
||||
data: any,
|
||||
logDelivery?: (webhookId: string, result: WebhookDeliveryResult) => Promise<void>
|
||||
): Promise<void> {
|
||||
// Create webhook payload
|
||||
const payload: WebhookPayload = {
|
||||
event,
|
||||
event_id: eventId,
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
};
|
||||
|
||||
// Filter webhooks that should receive this event
|
||||
const relevantWebhooks = webhooks.filter(
|
||||
webhook => webhook.is_active && webhook.events.includes(event)
|
||||
);
|
||||
|
||||
if (relevantWebhooks.length === 0) {
|
||||
return; // No webhooks to trigger
|
||||
}
|
||||
|
||||
// Fire and forget - don't await these deliveries
|
||||
const deliveryPromises = relevantWebhooks.map(async (webhook) => {
|
||||
try {
|
||||
const result = await deliverWebhook(
|
||||
webhook,
|
||||
payload,
|
||||
logDelivery ? (result) => logDelivery(webhook.id, result) : undefined
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`Webhook ${webhook.id} delivery failed:`, result.errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Webhook ${webhook.id} delivery error:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Fire and forget - log errors but don't block
|
||||
Promise.all(deliveryPromises).catch(err => {
|
||||
console.error('Webhook delivery batch error:', err);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { db } from '../lib/database.js';
|
||||
|
||||
export async function analyticsRoutes(fastify: FastifyInstance) {
|
||||
// Get overall analytics (optionally filtered by userId)
|
||||
fastify.get('/api/analytics/overview', async (request: FastifyRequest<{
|
||||
Querystring: { userId?: string; days?: number }
|
||||
}>) => {
|
||||
const { userId, days = 30 } = request.query;
|
||||
|
||||
const userFilter = userId ? 'AND l.user_id = $1' : '';
|
||||
const userFilterWhere = userId ? 'WHERE l.user_id = $1' : '';
|
||||
const params = userId ? [userId] : [];
|
||||
|
||||
// Get total and unique clicks
|
||||
const clicksResult = await db.query(
|
||||
`SELECT
|
||||
COUNT(*) as total_clicks,
|
||||
COUNT(DISTINCT ip_address) as unique_clicks
|
||||
FROM click_events ce
|
||||
JOIN links l ON ce.link_id = l.id
|
||||
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter} AND ce.is_bot = false`,
|
||||
params
|
||||
);
|
||||
|
||||
// Get clicks by date
|
||||
const dateResult = await db.query(
|
||||
`SELECT
|
||||
DATE(ce.clicked_at) as date,
|
||||
COUNT(*) as clicks
|
||||
FROM click_events ce
|
||||
JOIN links l ON ce.link_id = l.id
|
||||
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter} AND ce.is_bot = false
|
||||
GROUP BY DATE(ce.clicked_at)
|
||||
ORDER BY date`,
|
||||
params
|
||||
);
|
||||
|
||||
// Get clicks by country
|
||||
const countryResult = await db.query(
|
||||
`SELECT
|
||||
COALESCE(ce.country_code, 'Unknown') as country_code,
|
||||
COALESCE(ce.country_name, ce.country_code, 'Unknown') as country,
|
||||
COUNT(*) as clicks
|
||||
FROM click_events ce
|
||||
JOIN links l ON ce.link_id = l.id
|
||||
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter} AND ce.is_bot = false
|
||||
GROUP BY ce.country_code, ce.country_name
|
||||
ORDER BY clicks DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
// Get clicks by device
|
||||
const deviceResult = await db.query(
|
||||
`SELECT
|
||||
COALESCE(ce.device_type, 'Unknown') as device,
|
||||
COUNT(*) as clicks
|
||||
FROM click_events ce
|
||||
JOIN links l ON ce.link_id = l.id
|
||||
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter} AND ce.is_bot = false
|
||||
GROUP BY ce.device_type
|
||||
ORDER BY clicks DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
// Get clicks by platform
|
||||
const platformResult = await db.query(
|
||||
`SELECT
|
||||
COALESCE(ce.platform, 'Unknown') as platform,
|
||||
COUNT(*) as clicks
|
||||
FROM click_events ce
|
||||
JOIN links l ON ce.link_id = l.id
|
||||
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter} AND ce.is_bot = false
|
||||
GROUP BY ce.platform
|
||||
ORDER BY clicks DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
// Get top performing links
|
||||
const topLinksResult = await db.query(
|
||||
`SELECT
|
||||
l.id,
|
||||
l.short_code,
|
||||
l.title,
|
||||
l.original_url,
|
||||
COUNT(ce.id) as total_clicks,
|
||||
COUNT(DISTINCT ce.ip_address) as unique_clicks
|
||||
FROM links l
|
||||
LEFT JOIN click_events ce ON l.id = ce.link_id
|
||||
AND ce.clicked_at >= NOW() - INTERVAL '${days} days'
|
||||
AND ce.is_bot = false
|
||||
${userFilterWhere}
|
||||
GROUP BY l.id
|
||||
ORDER BY total_clicks DESC
|
||||
LIMIT 10`,
|
||||
params
|
||||
);
|
||||
|
||||
return {
|
||||
totalClicks: parseInt(clicksResult.rows[0]?.total_clicks || '0'),
|
||||
uniqueClicks: parseInt(clicksResult.rows[0]?.unique_clicks || '0'),
|
||||
clicksByDate: dateResult.rows.map(row => ({
|
||||
date: row.date,
|
||||
clicks: parseInt(row.clicks),
|
||||
})),
|
||||
clicksByCountry: countryResult.rows.map(row => ({
|
||||
countryCode: row.country_code,
|
||||
country: row.country,
|
||||
clicks: parseInt(row.clicks),
|
||||
})),
|
||||
clicksByDevice: deviceResult.rows.map(row => ({
|
||||
device: row.device,
|
||||
clicks: parseInt(row.clicks),
|
||||
})),
|
||||
clicksByPlatform: platformResult.rows.map(row => ({
|
||||
platform: row.platform,
|
||||
clicks: parseInt(row.clicks),
|
||||
})),
|
||||
topLinks: topLinksResult.rows.map(row => ({
|
||||
id: row.id,
|
||||
shortCode: row.short_code,
|
||||
title: row.title,
|
||||
originalUrl: row.original_url,
|
||||
totalClicks: parseInt(row.total_clicks),
|
||||
uniqueClicks: parseInt(row.unique_clicks),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Get link-specific analytics
|
||||
fastify.get('/api/analytics/links/:linkId', async (request: FastifyRequest<{
|
||||
Params: { linkId: string };
|
||||
Querystring: { userId?: string; days?: number };
|
||||
}>) => {
|
||||
const { linkId } = request.params;
|
||||
const { userId, days = 30 } = request.query;
|
||||
|
||||
// Verify link exists (and ownership if userId provided)
|
||||
let linkResult;
|
||||
if (userId) {
|
||||
linkResult = await db.query(
|
||||
'SELECT id FROM links WHERE id = $1 AND user_id = $2',
|
||||
[linkId, userId]
|
||||
);
|
||||
} else {
|
||||
linkResult = await db.query(
|
||||
'SELECT id FROM links WHERE id = $1',
|
||||
[linkId]
|
||||
);
|
||||
}
|
||||
|
||||
if (linkResult.rows.length === 0) {
|
||||
throw new Error('Link not found');
|
||||
}
|
||||
|
||||
// Get analytics for specific link
|
||||
const clicksResult = await db.query(
|
||||
`SELECT
|
||||
COUNT(*) as total_clicks,
|
||||
COUNT(DISTINCT ip_address) as unique_clicks
|
||||
FROM click_events
|
||||
WHERE link_id = $1 AND clicked_at >= NOW() - INTERVAL '${days} days' AND is_bot = false`,
|
||||
[linkId]
|
||||
);
|
||||
|
||||
const dateResult = await db.query(
|
||||
`SELECT
|
||||
DATE(clicked_at) as date,
|
||||
COUNT(*) as clicks
|
||||
FROM click_events
|
||||
WHERE link_id = $1 AND clicked_at >= NOW() - INTERVAL '${days} days' AND is_bot = false
|
||||
GROUP BY DATE(clicked_at)
|
||||
ORDER BY date`,
|
||||
[linkId]
|
||||
);
|
||||
|
||||
const countryResult = await db.query(
|
||||
`SELECT
|
||||
COALESCE(country_code, 'Unknown') as country_code,
|
||||
COALESCE(country_name, country_code, 'Unknown') as country,
|
||||
COUNT(*) as clicks
|
||||
FROM click_events
|
||||
WHERE link_id = $1 AND clicked_at >= NOW() - INTERVAL '${days} days' AND is_bot = false
|
||||
GROUP BY country_code, country_name
|
||||
ORDER BY clicks DESC`,
|
||||
[linkId]
|
||||
);
|
||||
|
||||
const deviceResult = await db.query(
|
||||
`SELECT
|
||||
COALESCE(device_type, 'Unknown') as device,
|
||||
COUNT(*) as clicks
|
||||
FROM click_events
|
||||
WHERE link_id = $1 AND clicked_at >= NOW() - INTERVAL '${days} days' AND is_bot = false
|
||||
GROUP BY device_type
|
||||
ORDER BY clicks DESC`,
|
||||
[linkId]
|
||||
);
|
||||
|
||||
const platformResult = await db.query(
|
||||
`SELECT
|
||||
COALESCE(platform, 'Unknown') as platform,
|
||||
COUNT(*) as clicks
|
||||
FROM click_events
|
||||
WHERE link_id = $1 AND clicked_at >= NOW() - INTERVAL '${days} days' AND is_bot = false
|
||||
GROUP BY platform
|
||||
ORDER BY clicks DESC`,
|
||||
[linkId]
|
||||
);
|
||||
|
||||
return {
|
||||
totalClicks: parseInt(clicksResult.rows[0]?.total_clicks || '0'),
|
||||
uniqueClicks: parseInt(clicksResult.rows[0]?.unique_clicks || '0'),
|
||||
clicksByDate: dateResult.rows.map(row => ({
|
||||
date: row.date,
|
||||
clicks: parseInt(row.clicks),
|
||||
})),
|
||||
clicksByCountry: countryResult.rows.map(row => ({
|
||||
countryCode: row.country_code,
|
||||
country: row.country,
|
||||
clicks: parseInt(row.clicks),
|
||||
})),
|
||||
clicksByDevice: deviceResult.rows.map(row => ({
|
||||
device: row.device,
|
||||
clicks: parseInt(row.clicks),
|
||||
})),
|
||||
clicksByPlatform: platformResult.rows.map(row => ({
|
||||
platform: row.platform,
|
||||
clicks: parseInt(row.clicks),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../lib/database.js';
|
||||
import { detectDevice } from '../lib/utils.js';
|
||||
import { subscribeToClickEvents, ClickEventData } from '../lib/event-emitter.js';
|
||||
|
||||
/**
|
||||
* Device simulation request schema
|
||||
*/
|
||||
const simulateRequestSchema = z.object({
|
||||
linkId: z.string().uuid(),
|
||||
userId: z.string().uuid().optional(),
|
||||
deviceType: z.enum(['ios', 'android', 'web']).optional(),
|
||||
userAgent: z.string().optional(),
|
||||
country: z.string().length(2).optional(), // ISO country code
|
||||
language: z.string().optional(), // e.g., "en", "es", "fr"
|
||||
ipAddress: z.string().ip().optional(),
|
||||
});
|
||||
|
||||
type SimulateRequest = z.infer<typeof simulateRequestSchema>;
|
||||
|
||||
/**
|
||||
* Debugging routes for testing and validation
|
||||
* Premium Cloud-only feature
|
||||
*/
|
||||
export async function debugRoutes(fastify: FastifyInstance) {
|
||||
/**
|
||||
* POST /api/debug/simulate
|
||||
* Simulate a link click with custom device parameters
|
||||
* Returns detailed information about redirect decision without logging
|
||||
*/
|
||||
fastify.post('/api/debug/simulate', async (request: FastifyRequest) => {
|
||||
const data = simulateRequestSchema.parse(request.body);
|
||||
|
||||
// Fetch the link
|
||||
let linkResult;
|
||||
if (data.userId) {
|
||||
linkResult = await db.query(
|
||||
`SELECT * FROM links WHERE id = $1 AND user_id = $2`,
|
||||
[data.linkId, data.userId]
|
||||
);
|
||||
} else {
|
||||
linkResult = await db.query(
|
||||
`SELECT * FROM links WHERE id = $1`,
|
||||
[data.linkId]
|
||||
);
|
||||
}
|
||||
|
||||
if (linkResult.rows.length === 0) {
|
||||
throw new Error('Link not found');
|
||||
}
|
||||
|
||||
const link = linkResult.rows[0];
|
||||
|
||||
// Determine device type
|
||||
let deviceType: 'ios' | 'android' | 'web';
|
||||
if (data.deviceType) {
|
||||
deviceType = data.deviceType;
|
||||
} else if (data.userAgent) {
|
||||
deviceType = detectDevice(data.userAgent);
|
||||
} else {
|
||||
deviceType = 'web'; // Default
|
||||
}
|
||||
|
||||
// Simulate targeting rules evaluation
|
||||
const targetingRules = link.targeting_rules || {};
|
||||
const simulatedCountry = data.country || 'US';
|
||||
const simulatedLanguage = data.language || 'en';
|
||||
|
||||
let targetingMatched = true;
|
||||
const targetingDetails: {
|
||||
countryMatch: boolean | null;
|
||||
deviceMatch: boolean | null;
|
||||
languageMatch: boolean | null;
|
||||
} = {
|
||||
countryMatch: null,
|
||||
deviceMatch: null,
|
||||
languageMatch: null,
|
||||
};
|
||||
|
||||
// Check country targeting
|
||||
if (targetingRules.countries && targetingRules.countries.length > 0) {
|
||||
targetingDetails.countryMatch = targetingRules.countries.includes(simulatedCountry);
|
||||
if (!targetingDetails.countryMatch) {
|
||||
targetingMatched = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check device targeting
|
||||
if (targetingRules.devices && targetingRules.devices.length > 0) {
|
||||
targetingDetails.deviceMatch = targetingRules.devices.includes(deviceType);
|
||||
if (!targetingDetails.deviceMatch) {
|
||||
targetingMatched = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check language targeting
|
||||
if (targetingRules.languages && targetingRules.languages.length > 0) {
|
||||
const primaryLang = simulatedLanguage.split('-')[0];
|
||||
targetingDetails.languageMatch = targetingRules.languages.some(
|
||||
(lang: string) => lang.toLowerCase().startsWith(primaryLang.toLowerCase())
|
||||
);
|
||||
if (!targetingDetails.languageMatch) {
|
||||
targetingMatched = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine redirect URL based on device type
|
||||
let redirectUrl = link.original_url;
|
||||
let redirectReason = 'original_url (default)';
|
||||
|
||||
if (deviceType === 'ios' && link.ios_app_store_url) {
|
||||
redirectUrl = link.ios_app_store_url;
|
||||
redirectReason = 'ios_app_store_url (iOS device detected)';
|
||||
} else if (deviceType === 'android' && link.android_app_store_url) {
|
||||
redirectUrl = link.android_app_store_url;
|
||||
redirectReason = 'android_app_store_url (Android device detected)';
|
||||
} else if (deviceType === 'web' && link.web_fallback_url) {
|
||||
redirectUrl = link.web_fallback_url;
|
||||
redirectReason = 'web_fallback_url (Web device detected)';
|
||||
}
|
||||
|
||||
// Add UTM parameters if present
|
||||
let finalUrl = redirectUrl;
|
||||
const utmParameters = link.utm_parameters || {};
|
||||
if (Object.keys(utmParameters).length > 0) {
|
||||
const url = new URL(redirectUrl);
|
||||
if (utmParameters.source) url.searchParams.set('utm_source', utmParameters.source);
|
||||
if (utmParameters.medium) url.searchParams.set('utm_medium', utmParameters.medium);
|
||||
if (utmParameters.campaign) url.searchParams.set('utm_campaign', utmParameters.campaign);
|
||||
if (utmParameters.term) url.searchParams.set('utm_term', utmParameters.term);
|
||||
if (utmParameters.content) url.searchParams.set('utm_content', utmParameters.content);
|
||||
finalUrl = url.toString();
|
||||
}
|
||||
|
||||
// Return detailed simulation results
|
||||
return {
|
||||
simulation: {
|
||||
linkId: link.id,
|
||||
shortCode: link.short_code,
|
||||
title: link.title,
|
||||
isActive: link.is_active,
|
||||
expiresAt: link.expires_at,
|
||||
},
|
||||
input: {
|
||||
deviceType: data.deviceType || 'auto-detected',
|
||||
userAgent: data.userAgent || 'Not provided',
|
||||
country: simulatedCountry,
|
||||
language: simulatedLanguage,
|
||||
ipAddress: data.ipAddress || 'Not provided',
|
||||
},
|
||||
detection: {
|
||||
detectedDevice: deviceType,
|
||||
detectionMethod: data.deviceType
|
||||
? 'manual (provided in request)'
|
||||
: data.userAgent
|
||||
? 'user-agent parsing'
|
||||
: 'default (web)',
|
||||
},
|
||||
targeting: {
|
||||
hasRules: Object.keys(targetingRules).length > 0,
|
||||
rules: targetingRules,
|
||||
matched: targetingMatched,
|
||||
details: targetingDetails,
|
||||
},
|
||||
redirect: {
|
||||
wouldRedirect: link.is_active && targetingMatched,
|
||||
finalUrl: targetingMatched ? finalUrl : null,
|
||||
redirectReason: targetingMatched ? redirectReason : 'Targeting rules not matched',
|
||||
utmParametersAdded: Object.keys(utmParameters).length > 0,
|
||||
utmParameters: utmParameters,
|
||||
},
|
||||
warnings: [
|
||||
...(!link.is_active ? ['Link is inactive - would return 404'] : []),
|
||||
...(link.expires_at && new Date(link.expires_at) < new Date()
|
||||
? ['Link has expired - would return 404']
|
||||
: []),
|
||||
...(!targetingMatched ? ['Targeting rules not matched - would return 404'] : []),
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/debug/user-agents
|
||||
* Get a list of common User-Agent strings for testing
|
||||
*/
|
||||
fastify.get('/api/debug/user-agents', async () => {
|
||||
return {
|
||||
ios: [
|
||||
{
|
||||
name: 'iPhone 15 Pro - iOS 17 - Safari',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
||||
device: 'ios',
|
||||
},
|
||||
{
|
||||
name: 'iPhone 14 - iOS 16 - Safari',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||
device: 'ios',
|
||||
},
|
||||
{
|
||||
name: 'iPad Pro - iOS 17 - Safari',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
||||
device: 'ios',
|
||||
},
|
||||
],
|
||||
android: [
|
||||
{
|
||||
name: 'Samsung Galaxy S23 - Android 13 - Chrome',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 13; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36',
|
||||
device: 'android',
|
||||
},
|
||||
{
|
||||
name: 'Google Pixel 8 - Android 14 - Chrome',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36',
|
||||
device: 'android',
|
||||
},
|
||||
{
|
||||
name: 'OnePlus 11 - Android 13 - Chrome',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 13; CPH2449) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36',
|
||||
device: 'android',
|
||||
},
|
||||
],
|
||||
web: [
|
||||
{
|
||||
name: 'Chrome on Windows',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
|
||||
device: 'web',
|
||||
},
|
||||
{
|
||||
name: 'Safari on macOS',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
|
||||
device: 'web',
|
||||
},
|
||||
{
|
||||
name: 'Firefox on Linux',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0',
|
||||
device: 'web',
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/debug/countries
|
||||
* Get a list of common countries for testing
|
||||
*/
|
||||
fastify.get('/api/debug/countries', async () => {
|
||||
return {
|
||||
countries: [
|
||||
{ code: 'US', name: 'United States' },
|
||||
{ code: 'GB', name: 'United Kingdom' },
|
||||
{ code: 'CA', name: 'Canada' },
|
||||
{ code: 'AU', name: 'Australia' },
|
||||
{ code: 'DE', name: 'Germany' },
|
||||
{ code: 'FR', name: 'France' },
|
||||
{ code: 'ES', name: 'Spain' },
|
||||
{ code: 'IT', name: 'Italy' },
|
||||
{ code: 'JP', name: 'Japan' },
|
||||
{ code: 'CN', name: 'China' },
|
||||
{ code: 'IN', name: 'India' },
|
||||
{ code: 'BR', name: 'Brazil' },
|
||||
{ code: 'MX', name: 'Mexico' },
|
||||
{ code: 'KR', name: 'South Korea' },
|
||||
{ code: 'SG', name: 'Singapore' },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/debug/languages
|
||||
* Get a list of common languages for testing
|
||||
*/
|
||||
fastify.get('/api/debug/languages', async () => {
|
||||
return {
|
||||
languages: [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'es', name: 'Spanish' },
|
||||
{ code: 'fr', name: 'French' },
|
||||
{ code: 'de', name: 'German' },
|
||||
{ code: 'it', name: 'Italian' },
|
||||
{ code: 'pt', name: 'Portuguese' },
|
||||
{ code: 'ja', name: 'Japanese' },
|
||||
{ code: 'zh', name: 'Chinese' },
|
||||
{ code: 'ko', name: 'Korean' },
|
||||
{ code: 'ar', name: 'Arabic' },
|
||||
{ code: 'ru', name: 'Russian' },
|
||||
{ code: 'hi', name: 'Hindi' },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* WebSocket: /api/debug/live
|
||||
* Real-time click event streaming
|
||||
* Clients can subscribe to live click events filtered by userId and optionally linkId
|
||||
*/
|
||||
(fastify as any).get(
|
||||
'/api/debug/live',
|
||||
{ websocket: true },
|
||||
(connection: any, request: FastifyRequest<{
|
||||
Querystring: { userId?: string; linkId?: string };
|
||||
}>) => {
|
||||
const { userId, linkId } = request.query;
|
||||
|
||||
// Send welcome message
|
||||
connection.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'connected',
|
||||
message: 'Connected to live request inspector',
|
||||
filters: {
|
||||
userId: userId || 'all',
|
||||
linkId: linkId || 'all',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Subscribe to click events
|
||||
const unsubscribe = subscribeToClickEvents((eventData: ClickEventData) => {
|
||||
// Filter by userId if provided
|
||||
if (userId && eventData.userId !== userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter by linkId if provided
|
||||
if (linkId && eventData.linkId !== linkId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send event to client
|
||||
try {
|
||||
connection.socket.send(
|
||||
JSON.stringify({
|
||||
type: 'click_event',
|
||||
data: eventData,
|
||||
})
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to send WebSocket message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle client disconnect
|
||||
connection.socket.on('close', () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
connection.socket.on('error', (error: any) => {
|
||||
console.error('WebSocket error:', error);
|
||||
unsubscribe();
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { redirectRoutes } from './redirect.js';
|
||||
export { linkRoutes } from './links.js';
|
||||
export { analyticsRoutes } from './analytics.js';
|
||||
export { sdkRoutes } from './sdk.js';
|
||||
export { qrRoutes } from './qr.js';
|
||||
export { webhookRoutes } from './webhooks.js';
|
||||
export { templateRoutes } from './templates.js';
|
||||
export { previewRoutes } from './preview.js';
|
||||
export { debugRoutes } from './debug.js';
|
||||
export { wellKnownRoutes } from './well-known.js';
|
||||
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const dbQueryMock = vi.fn();
|
||||
|
||||
vi.mock('../lib/database.js', () => ({
|
||||
db: {
|
||||
query: dbQueryMock,
|
||||
},
|
||||
}));
|
||||
|
||||
import { resolveLinkTemplateId } from './links.js';
|
||||
|
||||
describe('resolveLinkTemplateId', () => {
|
||||
beforeEach(() => {
|
||||
dbQueryMock.mockReset();
|
||||
});
|
||||
|
||||
it('returns null when the provided template id does not exist', async () => {
|
||||
dbQueryMock.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
await expect(resolveLinkTemplateId('00000000-0000-0000-0000-000000000000', null)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to template slug when the provided id is not usable', async () => {
|
||||
dbQueryMock.mockResolvedValueOnce({ rows: [] });
|
||||
dbQueryMock.mockResolvedValueOnce({ rows: [{ id: '11111111-1111-1111-1111-111111111111' }] });
|
||||
|
||||
await expect(resolveLinkTemplateId(null, 'default')).resolves.toBe('11111111-1111-1111-1111-111111111111');
|
||||
expect(dbQueryMock).toHaveBeenNthCalledWith(2, 'SELECT id FROM link_templates WHERE slug = $1 LIMIT 1', ['default']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../lib/database.js';
|
||||
import { generateShortCode } from '../lib/utils.js';
|
||||
import { invalidateLinkResolutionCache } from '../lib/link-resolution-cache.js';
|
||||
|
||||
async function getTemplateSlug(templateId: string | null): Promise<string | null> {
|
||||
if (!templateId) return null;
|
||||
const result = await db.query(
|
||||
'SELECT slug FROM link_templates WHERE id = $1',
|
||||
[templateId]
|
||||
);
|
||||
return result.rows[0]?.slug ?? null;
|
||||
}
|
||||
|
||||
const createLinkSchema = z.object({
|
||||
userId: z.string().uuid().optional(),
|
||||
templateId: z.string().uuid().optional(),
|
||||
originalUrl: z.string().url().optional(),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
// App store URLs (renamed for clarity)
|
||||
iosAppStoreUrl: z.string().url().optional(),
|
||||
androidAppStoreUrl: z.string().url().optional(),
|
||||
webFallbackUrl: z.string().url().optional(),
|
||||
// App deep linking configuration
|
||||
appScheme: z.string()
|
||||
.regex(/^[a-z][a-z0-9+.-]*$/, 'Invalid URI scheme format (must start with lowercase letter, contain only lowercase letters, numbers, +, ., or -)')
|
||||
.optional(),
|
||||
iosUniversalLink: z.string().url().optional(),
|
||||
androidAppLink: z.string().url().optional(),
|
||||
deepLinkPath: z.string().optional(),
|
||||
deepLinkParameters: z.record(z.string(), z.any()).optional(),
|
||||
// Existing fields
|
||||
customCode: z.string().optional(),
|
||||
utmParameters: z.object({
|
||||
source: z.string().optional(),
|
||||
medium: z.string().optional(),
|
||||
campaign: z.string().optional(),
|
||||
term: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
}).optional(),
|
||||
targetingRules: z.object({
|
||||
countries: z.array(z.string()).optional(),
|
||||
devices: z.array(z.enum(['ios', 'android', 'web'])).optional(),
|
||||
languages: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
ogTitle: z.string().optional(),
|
||||
ogDescription: z.string().optional(),
|
||||
ogImageUrl: z.string().url().optional(),
|
||||
ogType: z.string().optional(),
|
||||
attributionWindowHours: z.number()
|
||||
.int('Attribution window must be an integer')
|
||||
.min(1, 'Attribution window must be at least 1 hour')
|
||||
.max(2160, 'Attribution window must be at most 2160 hours (90 days)')
|
||||
.optional(),
|
||||
expiresAt: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
const updateLinkSchema = createLinkSchema.partial().extend({
|
||||
isActive: z.boolean().optional(),
|
||||
}).omit({ userId: true });
|
||||
|
||||
export async function linkRoutes(fastify: FastifyInstance) {
|
||||
// Get all links (optionally filtered by userId)
|
||||
fastify.get('/api/links', async (request: FastifyRequest<{
|
||||
Querystring: { userId?: string }
|
||||
}>) => {
|
||||
const { userId } = request.query;
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (userId) {
|
||||
query = `
|
||||
SELECT l.*,
|
||||
t.slug as template_slug,
|
||||
COUNT(ce.id) as click_count
|
||||
FROM links l
|
||||
LEFT JOIN link_templates t ON l.template_id = t.id
|
||||
LEFT JOIN click_events ce ON l.id = ce.link_id
|
||||
WHERE l.user_id = $1
|
||||
GROUP BY l.id, t.slug
|
||||
ORDER BY l.created_at DESC
|
||||
`;
|
||||
params = [userId];
|
||||
} else {
|
||||
query = `
|
||||
SELECT l.*,
|
||||
t.slug as template_slug,
|
||||
COUNT(ce.id) as click_count
|
||||
FROM links l
|
||||
LEFT JOIN link_templates t ON l.template_id = t.id
|
||||
LEFT JOIN click_events ce ON l.id = ce.link_id
|
||||
GROUP BY l.id, t.slug
|
||||
ORDER BY l.created_at DESC
|
||||
`;
|
||||
params = [];
|
||||
}
|
||||
|
||||
const result = await db.query(query, params);
|
||||
|
||||
return result.rows.map(row => ({
|
||||
...row,
|
||||
clickCount: parseInt(row.click_count),
|
||||
utmParameters: row.utm_parameters,
|
||||
targetingRules: row.targeting_rules,
|
||||
deepLinkParameters: row.deep_link_parameters,
|
||||
}));
|
||||
});
|
||||
|
||||
// Get single link
|
||||
fastify.get('/api/links/:id', async (request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Querystring: { userId?: string };
|
||||
}>) => {
|
||||
const { id } = request.params;
|
||||
const { userId } = request.query;
|
||||
|
||||
let result;
|
||||
if (userId) {
|
||||
result = await db.query(
|
||||
`SELECT l.*, t.slug as template_slug, COUNT(ce.id) as click_count
|
||||
FROM links l
|
||||
LEFT JOIN link_templates t ON l.template_id = t.id
|
||||
LEFT JOIN click_events ce ON l.id = ce.link_id
|
||||
WHERE l.id = $1 AND l.user_id = $2
|
||||
GROUP BY l.id, t.slug`,
|
||||
[id, userId]
|
||||
);
|
||||
} else {
|
||||
result = await db.query(
|
||||
`SELECT l.*, t.slug as template_slug, COUNT(ce.id) as click_count
|
||||
FROM links l
|
||||
LEFT JOIN link_templates t ON l.template_id = t.id
|
||||
LEFT JOIN click_events ce ON l.id = ce.link_id
|
||||
WHERE l.id = $1
|
||||
GROUP BY l.id, t.slug`,
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Link not found');
|
||||
}
|
||||
|
||||
const link = result.rows[0];
|
||||
return {
|
||||
...link,
|
||||
clickCount: parseInt(link.click_count),
|
||||
utmParameters: link.utm_parameters,
|
||||
targetingRules: link.targeting_rules,
|
||||
deepLinkParameters: link.deep_link_parameters,
|
||||
};
|
||||
});
|
||||
|
||||
// Create link
|
||||
fastify.post('/api/links', async (request) => {
|
||||
const data = createLinkSchema.parse(request.body);
|
||||
|
||||
// Generate short code
|
||||
let shortCode = data.customCode || generateShortCode();
|
||||
|
||||
// Ensure short code is unique
|
||||
let attempts = 0;
|
||||
while (attempts < 10) {
|
||||
const existing = await db.query(
|
||||
'SELECT id FROM links WHERE short_code = $1',
|
||||
[shortCode]
|
||||
);
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
shortCode = generateShortCode();
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (attempts >= 10) {
|
||||
throw new Error('Unable to generate unique short code');
|
||||
}
|
||||
|
||||
const originalUrl = data.originalUrl || 'https://angkorlifes.com';
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO links (
|
||||
user_id, template_id, short_code, original_url, title, description,
|
||||
ios_app_store_url, android_app_store_url, web_fallback_url,
|
||||
app_scheme, ios_universal_link, android_app_link, deep_link_path, deep_link_parameters,
|
||||
utm_parameters, targeting_rules,
|
||||
og_title, og_description, og_image_url, og_type,
|
||||
attribution_window_hours, expires_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.userId || null,
|
||||
data.templateId || null,
|
||||
shortCode,
|
||||
originalUrl,
|
||||
data.title || null,
|
||||
data.description || null,
|
||||
data.iosAppStoreUrl || null,
|
||||
data.androidAppStoreUrl || null,
|
||||
data.webFallbackUrl || null,
|
||||
data.appScheme || null,
|
||||
data.iosUniversalLink || null,
|
||||
data.androidAppLink || null,
|
||||
data.deepLinkPath || null,
|
||||
JSON.stringify(data.deepLinkParameters || {}),
|
||||
JSON.stringify(data.utmParameters || {}),
|
||||
JSON.stringify(data.targetingRules || {}),
|
||||
data.ogTitle || null,
|
||||
data.ogDescription || null,
|
||||
data.ogImageUrl || null,
|
||||
data.ogType || 'website',
|
||||
data.attributionWindowHours || 168, // Default 7 days
|
||||
data.expiresAt || null,
|
||||
]
|
||||
);
|
||||
|
||||
const link = result.rows[0];
|
||||
return {
|
||||
...link,
|
||||
clickCount: 0,
|
||||
utmParameters: link.utm_parameters,
|
||||
targetingRules: link.targeting_rules,
|
||||
deepLinkParameters: link.deep_link_parameters,
|
||||
};
|
||||
});
|
||||
|
||||
// Update link
|
||||
fastify.put('/api/links/:id', async (request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Querystring: { userId?: string };
|
||||
}>) => {
|
||||
const { id } = request.params;
|
||||
const { userId } = request.query;
|
||||
|
||||
const data = updateLinkSchema.parse(request.body);
|
||||
|
||||
// Capture current identifiers for cache invalidation after the update
|
||||
const oldLinkResult = await db.query(
|
||||
'SELECT short_code, template_id FROM links WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
// Build update query dynamically
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
if (key === 'utmParameters' || key === 'targetingRules' || key === 'deepLinkParameters') {
|
||||
updates.push(`${key.replace(/([A-Z])/g, '_$1').toLowerCase()} = $${paramIndex}`);
|
||||
values.push(JSON.stringify(value));
|
||||
} else {
|
||||
const dbKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
|
||||
updates.push(`${dbKey} = $${paramIndex}`);
|
||||
values.push(value);
|
||||
}
|
||||
paramIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new Error('No updates provided');
|
||||
}
|
||||
|
||||
updates.push('updated_at = NOW()');
|
||||
values.push(id);
|
||||
|
||||
let whereClause = `WHERE id = $${paramIndex}`;
|
||||
if (userId) {
|
||||
values.push(userId);
|
||||
whereClause += ` AND user_id = $${paramIndex + 1}`;
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
`UPDATE links SET ${updates.join(', ')}
|
||||
${whereClause}
|
||||
RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Link not found');
|
||||
}
|
||||
|
||||
const link = result.rows[0];
|
||||
|
||||
// Invalidate cached link JSON for both old and new template associations
|
||||
const oldRow = oldLinkResult.rows[0];
|
||||
const oldTemplateSlug = await getTemplateSlug(oldRow?.template_id);
|
||||
const newTemplateSlug = await getTemplateSlug(link.template_id);
|
||||
await invalidateLinkResolutionCache(fastify.redis, link.short_code, oldTemplateSlug);
|
||||
if (newTemplateSlug !== oldTemplateSlug) {
|
||||
await invalidateLinkResolutionCache(fastify.redis, link.short_code, newTemplateSlug);
|
||||
}
|
||||
|
||||
return {
|
||||
...link,
|
||||
utmParameters: link.utm_parameters,
|
||||
targetingRules: link.targeting_rules,
|
||||
deepLinkParameters: link.deep_link_parameters,
|
||||
};
|
||||
});
|
||||
|
||||
// Duplicate link
|
||||
fastify.post('/api/links/:id/duplicate', async (request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Querystring: { userId?: string };
|
||||
}>) => {
|
||||
const { id } = request.params;
|
||||
const { userId } = request.query;
|
||||
|
||||
// Get original link
|
||||
let originalLink;
|
||||
if (userId) {
|
||||
originalLink = await db.query(
|
||||
'SELECT * FROM links WHERE id = $1 AND user_id = $2',
|
||||
[id, userId]
|
||||
);
|
||||
} else {
|
||||
originalLink = await db.query(
|
||||
'SELECT * FROM links WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
if (originalLink.rows.length === 0) {
|
||||
throw new Error('Link not found');
|
||||
}
|
||||
|
||||
const link = originalLink.rows[0];
|
||||
|
||||
// Generate new short code
|
||||
let shortCode = generateShortCode();
|
||||
let attempts = 0;
|
||||
while (attempts < 10) {
|
||||
const existing = await db.query(
|
||||
'SELECT id FROM links WHERE short_code = $1',
|
||||
[shortCode]
|
||||
);
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
shortCode = generateShortCode();
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (attempts >= 10) {
|
||||
throw new Error('Unable to generate unique short code');
|
||||
}
|
||||
|
||||
// Create duplicate with new short code and "(Copy)" suffix in title
|
||||
const title = link.title ? `${link.title} (Copy)` : null;
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO links (
|
||||
user_id, template_id, short_code, original_url, title, description,
|
||||
ios_app_store_url, android_app_store_url, web_fallback_url,
|
||||
app_scheme, ios_universal_link, android_app_link, deep_link_path, deep_link_parameters,
|
||||
utm_parameters, targeting_rules,
|
||||
og_title, og_description, og_image_url, og_type,
|
||||
attribution_window_hours, expires_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
|
||||
RETURNING *`,
|
||||
[
|
||||
link.user_id,
|
||||
link.template_id || null,
|
||||
shortCode,
|
||||
link.original_url,
|
||||
title,
|
||||
link.description,
|
||||
link.ios_app_store_url,
|
||||
link.android_app_store_url,
|
||||
link.web_fallback_url,
|
||||
link.app_scheme,
|
||||
link.ios_universal_link,
|
||||
link.android_app_link,
|
||||
link.deep_link_path,
|
||||
link.deep_link_parameters,
|
||||
link.utm_parameters,
|
||||
link.targeting_rules,
|
||||
link.og_title,
|
||||
link.og_description,
|
||||
link.og_image_url,
|
||||
link.og_type,
|
||||
link.attribution_window_hours,
|
||||
link.expires_at,
|
||||
]
|
||||
);
|
||||
|
||||
const newLink = result.rows[0];
|
||||
return {
|
||||
...newLink,
|
||||
clickCount: 0,
|
||||
utmParameters: newLink.utm_parameters,
|
||||
targetingRules: newLink.targeting_rules,
|
||||
deepLinkParameters: newLink.deep_link_parameters,
|
||||
};
|
||||
});
|
||||
|
||||
// Delete link
|
||||
fastify.delete('/api/links/:id', async (request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Querystring: { userId?: string };
|
||||
}>) => {
|
||||
const { id } = request.params;
|
||||
const { userId } = request.query;
|
||||
|
||||
let result;
|
||||
if (userId) {
|
||||
result = await db.query(
|
||||
'DELETE FROM links WHERE id = $1 AND user_id = $2 RETURNING id, short_code, template_id',
|
||||
[id, userId]
|
||||
);
|
||||
} else {
|
||||
result = await db.query(
|
||||
'DELETE FROM links WHERE id = $1 RETURNING id, short_code, template_id',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Link not found');
|
||||
}
|
||||
|
||||
const deleted = result.rows[0];
|
||||
const templateSlug = await getTemplateSlug(deleted.template_id);
|
||||
await invalidateLinkResolutionCache(fastify.redis, deleted.short_code, templateSlug);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { db } from '../lib/database.js';
|
||||
|
||||
/**
|
||||
* Detect if the request is from a social media scraper/bot
|
||||
* These bots crawl links to generate previews when shared on social platforms
|
||||
*/
|
||||
function isSocialScraper(userAgent: string): boolean {
|
||||
const scraperPatterns = [
|
||||
/facebookexternalhit/i, // Facebook
|
||||
/Facebot/i, // Facebook
|
||||
/Twitterbot/i, // Twitter
|
||||
/LinkedInBot/i, // LinkedIn
|
||||
/Slackbot/i, // Slack
|
||||
/Discordbot/i, // Discord
|
||||
/TelegramBot/i, // Telegram
|
||||
/WhatsApp/i, // WhatsApp
|
||||
/PinterestBot/i, // Pinterest
|
||||
/SkypeUriPreview/i, // Skype
|
||||
/Googlebot/i, // Google (for search previews)
|
||||
/bingbot/i, // Bing
|
||||
/ia_archiver/i, // Alexa
|
||||
];
|
||||
|
||||
return scraperPatterns.some(pattern => pattern.test(userAgent));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML preview page with Open Graph meta tags
|
||||
*/
|
||||
function generatePreviewHTML(
|
||||
link: any,
|
||||
shortUrl: string,
|
||||
autoRedirect: boolean = true
|
||||
): string {
|
||||
// Use OG-specific values if provided, otherwise fall back to regular title/description
|
||||
const ogTitle = link.og_title || link.title || 'Shared Link';
|
||||
const ogDescription = link.og_description || link.description || '';
|
||||
const ogImage = link.og_image_url || '';
|
||||
const ogType = link.og_type || 'website';
|
||||
const ogUrl = shortUrl;
|
||||
|
||||
// Auto-redirect after 2 seconds for human visitors
|
||||
const metaRefresh = autoRedirect
|
||||
? `<meta http-equiv="refresh" content="2;url=${link.original_url}">`
|
||||
: '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(ogTitle)}</title>
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="${escapeHtml(ogType)}">
|
||||
<meta property="og:url" content="${escapeHtml(ogUrl)}">
|
||||
<meta property="og:title" content="${escapeHtml(ogTitle)}">
|
||||
<meta property="og:description" content="${escapeHtml(ogDescription)}">
|
||||
${ogImage ? `<meta property="og:image" content="${escapeHtml(ogImage)}">` : ''}
|
||||
${ogImage ? `<meta property="og:image:secure_url" content="${escapeHtml(ogImage)}">` : ''}
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">
|
||||
<meta name="twitter:url" content="${escapeHtml(ogUrl)}">
|
||||
<meta name="twitter:title" content="${escapeHtml(ogTitle)}">
|
||||
<meta name="twitter:description" content="${escapeHtml(ogDescription)}">
|
||||
${ogImage ? `<meta name="twitter:image" content="${escapeHtml(ogImage)}">` : ''}
|
||||
|
||||
<!-- LinkedIn -->
|
||||
<meta property="og:site_name" content="LinkForty">
|
||||
|
||||
${metaRefresh}
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
p {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.link {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.link:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.loader {
|
||||
margin: 2rem auto;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4px solid white;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
${ogImage ? `<img src="${escapeHtml(ogImage)}" alt="${escapeHtml(ogTitle)}" style="max-width: 100%; border-radius: 12px; margin-bottom: 2rem; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);">` : ''}
|
||||
<h1>${escapeHtml(ogTitle)}</h1>
|
||||
${ogDescription ? `<p>${escapeHtml(ogDescription)}</p>` : ''}
|
||||
${autoRedirect ? '<div class="loader"></div><p>Redirecting you...</p>' : ''}
|
||||
<a href="${escapeHtml(link.original_url)}" class="link">
|
||||
${autoRedirect ? 'Click here if not redirected' : 'Continue to destination'}
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
return text.replace(/[&<>"']/g, (m) => map[m]);
|
||||
}
|
||||
|
||||
export async function previewRoutes(fastify: FastifyInstance) {
|
||||
/**
|
||||
* GET /:shortCode/preview
|
||||
* Always return HTML preview page with Open Graph tags
|
||||
* Auto-redirects after 2 seconds for human visitors
|
||||
*/
|
||||
fastify.get('/:shortCode/preview', async (request, reply) => {
|
||||
const { shortCode } = request.params as { shortCode: string };
|
||||
const baseUrl = request.headers.host
|
||||
? `${request.protocol}://${request.headers.host}`
|
||||
: 'https://link.forty';
|
||||
|
||||
try {
|
||||
// Lookup link by short code
|
||||
const result = await db.query(
|
||||
`SELECT * FROM links
|
||||
WHERE short_code = $1 AND is_active = true
|
||||
AND (expires_at IS NULL OR expires_at > NOW())`,
|
||||
[shortCode]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return reply.status(404).send('Link not found');
|
||||
}
|
||||
|
||||
const link = result.rows[0];
|
||||
const shortUrl = `${baseUrl}/${shortCode}`;
|
||||
|
||||
// Return HTML with OG tags and auto-redirect
|
||||
const html = generatePreviewHTML(link, shortUrl, true);
|
||||
|
||||
return reply
|
||||
.header('Content-Type', 'text/html; charset=utf-8')
|
||||
.send(html);
|
||||
} catch (error: any) {
|
||||
fastify.log.error(`Error generating preview: ${error}`);
|
||||
return reply.status(500).send('Error generating preview');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Middleware for /:shortCode route to detect social scrapers
|
||||
* If scraper detected, return OG preview instead of redirecting
|
||||
*
|
||||
* Note: This should be registered BEFORE the main redirect route
|
||||
*/
|
||||
fastify.addHook('preHandler', async (request, reply) => {
|
||||
// Only apply to short code routes (not API routes, not preview routes)
|
||||
const path = request.url.split('?')[0]; // Remove query string
|
||||
if (
|
||||
path.startsWith('/api/') ||
|
||||
path.endsWith('/preview') ||
|
||||
path === '/' ||
|
||||
path.includes('.')
|
||||
) {
|
||||
return; // Skip this hook
|
||||
}
|
||||
|
||||
const userAgent = request.headers['user-agent'] || '';
|
||||
const shortCode = path.split('/').pop() || '';
|
||||
const baseUrl = request.headers.host
|
||||
? `${request.protocol}://${request.headers.host}`
|
||||
: 'https://link.forty';
|
||||
|
||||
// If it's a social scraper, return OG preview HTML
|
||||
if (isSocialScraper(userAgent)) {
|
||||
try {
|
||||
const result = await db.query(
|
||||
`SELECT * FROM links
|
||||
WHERE short_code = $1 AND is_active = true
|
||||
AND (expires_at IS NULL OR expires_at > NOW())`,
|
||||
[shortCode]
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const link = result.rows[0];
|
||||
const shortUrl = `${baseUrl}/${shortCode}`;
|
||||
|
||||
// Return HTML with OG tags, no auto-redirect for bots
|
||||
const html = generatePreviewHTML(link, shortUrl, false);
|
||||
|
||||
reply
|
||||
.header('Content-Type', 'text/html; charset=utf-8')
|
||||
.send(html);
|
||||
|
||||
// Stop the request here, don't continue to redirect route
|
||||
return reply;
|
||||
}
|
||||
} catch (error: any) {
|
||||
fastify.log.error(`Error in social scraper detection: ${error}`);
|
||||
// Continue to normal redirect route on error
|
||||
}
|
||||
}
|
||||
|
||||
// Not a social scraper, continue to normal redirect logic
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import QRCode from 'qrcode';
|
||||
import { db } from '../lib/database.js';
|
||||
|
||||
/**
|
||||
* QR Code Routes - Generate QR codes for links
|
||||
*/
|
||||
export async function qrRoutes(fastify: FastifyInstance) {
|
||||
/**
|
||||
* GET /api/links/:id/qr
|
||||
* Generate QR code for a link
|
||||
*
|
||||
* Query parameters:
|
||||
* - format: 'png' | 'svg' (default: 'png')
|
||||
* - size: number 128-2048 (default: 512)
|
||||
* - color: hex color for foreground (default: '#000000')
|
||||
* - bgcolor: hex color for background (default: '#ffffff')
|
||||
*
|
||||
* Returns: QR code image (PNG or SVG)
|
||||
*/
|
||||
fastify.get('/api/links/:id/qr', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const query = request.query as Record<string, string | undefined>;
|
||||
|
||||
const format = (query.format || 'png') as 'png' | 'svg';
|
||||
const size = Math.min(Math.max(parseInt(query.size || '512', 10), 128), 2048);
|
||||
const color = query.color || '#000000';
|
||||
const bgcolor = query.bgcolor || '#ffffff';
|
||||
|
||||
// Validate format
|
||||
if (!['png', 'svg'].includes(format)) {
|
||||
return reply.status(400).send({ error: 'Invalid format. Use "png" or "svg".' });
|
||||
}
|
||||
|
||||
// Build cache key
|
||||
const cacheKey = `qr:${id}:${format}:${size}:${color}:${bgcolor}`;
|
||||
|
||||
// Try to get from cache
|
||||
if (fastify.redis) {
|
||||
try {
|
||||
const cached = await fastify.redis.get(cacheKey);
|
||||
if (cached) {
|
||||
fastify.log.info(`QR code cache hit: ${cacheKey}`);
|
||||
|
||||
if (format === 'png') {
|
||||
// Cached PNG is base64
|
||||
const buffer = Buffer.from(cached, 'base64');
|
||||
return reply
|
||||
.type('image/png')
|
||||
.header('Cache-Control', 'public, max-age=86400') // 24 hours
|
||||
.send(buffer);
|
||||
} else {
|
||||
// Cached SVG is text
|
||||
return reply
|
||||
.type('image/svg+xml')
|
||||
.header('Cache-Control', 'public, max-age=86400')
|
||||
.send(cached);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
fastify.log.warn('Redis QR cache lookup failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Get link from database
|
||||
const result = await db.query(
|
||||
'SELECT short_code, original_url FROM links WHERE id = $1 AND is_active = true',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return reply.status(404).send({ error: 'Link not found' });
|
||||
}
|
||||
|
||||
const link = result.rows[0];
|
||||
|
||||
// Build short URL using configured domain or request hostname
|
||||
// Use SHORTLINK_DOMAIN env var for production deployments
|
||||
const shortLinkDomain = process.env.SHORTLINK_DOMAIN || `${request.protocol}://${request.hostname}`;
|
||||
const shortUrl = link.short_code
|
||||
? `${shortLinkDomain}/${link.short_code}`
|
||||
: link.original_url;
|
||||
|
||||
try {
|
||||
// QR code options
|
||||
const options = {
|
||||
errorCorrectionLevel: 'M' as const, // Medium error correction
|
||||
margin: 1, // Quiet zone margin
|
||||
width: size,
|
||||
color: {
|
||||
dark: color,
|
||||
light: bgcolor,
|
||||
},
|
||||
};
|
||||
|
||||
if (format === 'png') {
|
||||
// Generate PNG as buffer
|
||||
const buffer = await QRCode.toBuffer(shortUrl, options);
|
||||
|
||||
// Cache as base64
|
||||
if (fastify.redis) {
|
||||
try {
|
||||
await fastify.redis.setex(cacheKey, 86400, buffer.toString('base64')); // 24 hour TTL
|
||||
} catch (error) {
|
||||
fastify.log.warn('Failed to cache QR code');
|
||||
}
|
||||
}
|
||||
|
||||
return reply
|
||||
.type('image/png')
|
||||
.header('Cache-Control', 'public, max-age=86400')
|
||||
.header('Content-Disposition', `inline; filename="qr-${link.short_code || 'code'}.png"`)
|
||||
.send(buffer);
|
||||
} else {
|
||||
// Generate SVG as string
|
||||
const svg = await QRCode.toString(shortUrl, {
|
||||
...options,
|
||||
type: 'svg',
|
||||
});
|
||||
|
||||
// Cache SVG text
|
||||
if (fastify.redis) {
|
||||
try {
|
||||
await fastify.redis.setex(cacheKey, 86400, svg);
|
||||
} catch (error) {
|
||||
fastify.log.warn('Failed to cache QR code');
|
||||
}
|
||||
}
|
||||
|
||||
return reply
|
||||
.type('image/svg+xml')
|
||||
.header('Cache-Control', 'public, max-age=86400')
|
||||
.header('Content-Disposition', `inline; filename="qr-${link.short_code || 'code'}.svg"`)
|
||||
.send(svg);
|
||||
}
|
||||
} catch (error: any) {
|
||||
fastify.log.error(`QR code generation failed: ${error.message}`);
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to generate QR code',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isIOSInAppBrowser,
|
||||
isAndroidInAppBrowser,
|
||||
pickMobileFallbackUrl,
|
||||
} from './redirect.js';
|
||||
|
||||
// Real-world UA strings (truncated where helpful) for use across test cases.
|
||||
const UA = {
|
||||
// Regular browsers — should NOT be detected as in-app
|
||||
iosSafari:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1',
|
||||
iosChrome:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/123.0.6312.52 Mobile/15E148 Safari/604.1',
|
||||
androidChrome:
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
|
||||
androidFirefox:
|
||||
'Mozilla/5.0 (Android 14; Mobile; rv:125.0) Gecko/125.0 Firefox/125.0',
|
||||
|
||||
// iOS in-app browsers
|
||||
iosGmail:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 GSA/322.0.616052181 Safari/604.1',
|
||||
iosFacebook:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/450.0.0.0]',
|
||||
iosInstagram:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 320.0.0.0',
|
||||
iosOutlook:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Outlook-iOS/2.0',
|
||||
iosTwitter:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Twitter for iPhone/10.0',
|
||||
|
||||
// Android in-app browsers
|
||||
androidWebview:
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile Safari/537.36',
|
||||
androidFacebook:
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile Safari/537.36 [FB_IAB/FB4A;FBAV/450.0.0.0]',
|
||||
androidInstagram:
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Instagram 320.0.0.0',
|
||||
androidLine:
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Line/13.0.0',
|
||||
androidWhatsapp:
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 WhatsApp/2.24.0',
|
||||
};
|
||||
|
||||
const URLS = {
|
||||
iosStore: 'https://apps.apple.com/app/example/id123',
|
||||
androidStore: 'https://play.google.com/store/apps/details?id=com.example',
|
||||
webFallback: 'https://example.com/landing',
|
||||
};
|
||||
|
||||
describe('isIOSInAppBrowser', () => {
|
||||
it('detects Gmail (GSA token)', () => {
|
||||
expect(isIOSInAppBrowser(UA.iosGmail)).toBe(true);
|
||||
});
|
||||
it('detects Facebook (FBAN/FBAV)', () => {
|
||||
expect(isIOSInAppBrowser(UA.iosFacebook)).toBe(true);
|
||||
});
|
||||
it('detects Instagram', () => {
|
||||
expect(isIOSInAppBrowser(UA.iosInstagram)).toBe(true);
|
||||
});
|
||||
it('detects Outlook', () => {
|
||||
expect(isIOSInAppBrowser(UA.iosOutlook)).toBe(true);
|
||||
});
|
||||
it('detects Twitter', () => {
|
||||
expect(isIOSInAppBrowser(UA.iosTwitter)).toBe(true);
|
||||
});
|
||||
it('does not flag Safari as in-app', () => {
|
||||
expect(isIOSInAppBrowser(UA.iosSafari)).toBe(false);
|
||||
});
|
||||
it('does not flag Chrome on iOS as in-app', () => {
|
||||
expect(isIOSInAppBrowser(UA.iosChrome)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAndroidInAppBrowser', () => {
|
||||
it('detects generic Android WebView via wv) marker', () => {
|
||||
expect(isAndroidInAppBrowser(UA.androidWebview)).toBe(true);
|
||||
});
|
||||
it('detects Facebook (FB_IAB/FBAN/FBAV)', () => {
|
||||
expect(isAndroidInAppBrowser(UA.androidFacebook)).toBe(true);
|
||||
});
|
||||
it('detects Instagram', () => {
|
||||
expect(isAndroidInAppBrowser(UA.androidInstagram)).toBe(true);
|
||||
});
|
||||
it('detects Line', () => {
|
||||
expect(isAndroidInAppBrowser(UA.androidLine)).toBe(true);
|
||||
});
|
||||
it('detects WhatsApp', () => {
|
||||
expect(isAndroidInAppBrowser(UA.androidWhatsapp)).toBe(true);
|
||||
});
|
||||
it('does not flag Chrome as in-app', () => {
|
||||
expect(isAndroidInAppBrowser(UA.androidChrome)).toBe(false);
|
||||
});
|
||||
it('does not flag Firefox as in-app', () => {
|
||||
expect(isAndroidInAppBrowser(UA.androidFirefox)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickMobileFallbackUrl — iOS regular browser (Safari/Chrome)', () => {
|
||||
it('prefers iOS store URL over web fallback', () => {
|
||||
const r = pickMobileFallbackUrl('ios', UA.iosSafari, URLS.iosStore, URLS.androidStore, URLS.webFallback);
|
||||
expect(r).toEqual({ url: URLS.iosStore, reason: 'ios_app_store_url' });
|
||||
});
|
||||
it('falls back to web fallback when iOS store URL is absent', () => {
|
||||
const r = pickMobileFallbackUrl('ios', UA.iosSafari, null, URLS.androidStore, URLS.webFallback);
|
||||
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
|
||||
});
|
||||
it('uses iOS store URL when web fallback is absent', () => {
|
||||
const r = pickMobileFallbackUrl('ios', UA.iosSafari, URLS.iosStore, URLS.androidStore, null);
|
||||
expect(r).toEqual({ url: URLS.iosStore, reason: 'ios_app_store_url' });
|
||||
});
|
||||
it('returns null when both iOS store URL and web fallback are absent', () => {
|
||||
expect(pickMobileFallbackUrl('ios', UA.iosSafari, null, URLS.androidStore, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickMobileFallbackUrl — iOS in-app browser (Gmail/FB/Instagram/etc.)', () => {
|
||||
it('prefers web fallback over iOS store URL', () => {
|
||||
const r = pickMobileFallbackUrl('ios', UA.iosGmail, URLS.iosStore, URLS.androidStore, URLS.webFallback);
|
||||
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
|
||||
});
|
||||
it('falls back to iOS store URL when web fallback is absent', () => {
|
||||
const r = pickMobileFallbackUrl('ios', UA.iosGmail, URLS.iosStore, URLS.androidStore, null);
|
||||
expect(r).toEqual({ url: URLS.iosStore, reason: 'ios_app_store_url' });
|
||||
});
|
||||
it('returns null when both iOS store URL and web fallback are absent', () => {
|
||||
expect(pickMobileFallbackUrl('ios', UA.iosGmail, null, URLS.androidStore, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickMobileFallbackUrl — Android regular browser (Chrome/Firefox)', () => {
|
||||
it('prefers Android store URL over web fallback', () => {
|
||||
const r = pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, URLS.androidStore, URLS.webFallback);
|
||||
expect(r).toEqual({ url: URLS.androidStore, reason: 'android_app_store_url' });
|
||||
});
|
||||
it('falls back to web fallback when Android store URL is absent', () => {
|
||||
const r = pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, null, URLS.webFallback);
|
||||
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
|
||||
});
|
||||
it('uses Android store URL when web fallback is absent', () => {
|
||||
const r = pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, URLS.androidStore, null);
|
||||
expect(r).toEqual({ url: URLS.androidStore, reason: 'android_app_store_url' });
|
||||
});
|
||||
it('returns null when both Android store URL and web fallback are absent', () => {
|
||||
expect(pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickMobileFallbackUrl — Android in-app browser (FB/Instagram/Line/WebView)', () => {
|
||||
it('prefers web fallback over Android store URL', () => {
|
||||
const r = pickMobileFallbackUrl('android', UA.androidFacebook, URLS.iosStore, URLS.androidStore, URLS.webFallback);
|
||||
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
|
||||
});
|
||||
it('detects WebView via wv) marker and prefers web fallback', () => {
|
||||
const r = pickMobileFallbackUrl('android', UA.androidWebview, URLS.iosStore, URLS.androidStore, URLS.webFallback);
|
||||
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
|
||||
});
|
||||
it('falls back to Android store URL when web fallback is absent', () => {
|
||||
const r = pickMobileFallbackUrl('android', UA.androidFacebook, URLS.iosStore, URLS.androidStore, null);
|
||||
expect(r).toEqual({ url: URLS.androidStore, reason: 'android_app_store_url' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickMobileFallbackUrl — reporter scenario regression test', () => {
|
||||
// Reproduces SIT-163: user creates a link with iOS, Android, and web fallback URLs;
|
||||
// mobile visitor expects the App/Play Store; previously got the web fallback.
|
||||
it('iOS Safari → iOS App Store (was: web fallback)', () => {
|
||||
const r = pickMobileFallbackUrl('ios', UA.iosSafari, URLS.iosStore, URLS.androidStore, URLS.webFallback);
|
||||
expect(r?.url).toBe(URLS.iosStore);
|
||||
expect(r?.reason).toBe('ios_app_store_url');
|
||||
});
|
||||
it('Android Chrome → Play Store (was: web fallback)', () => {
|
||||
const r = pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, URLS.androidStore, URLS.webFallback);
|
||||
expect(r?.url).toBe(URLS.androidStore);
|
||||
expect(r?.reason).toBe('android_app_store_url');
|
||||
});
|
||||
// And the email-marketing flow that the prior fix protected stays correct:
|
||||
it('iOS Gmail in-app → web fallback (preserves UL second-chance)', () => {
|
||||
const r = pickMobileFallbackUrl('ios', UA.iosGmail, URLS.iosStore, URLS.androidStore, URLS.webFallback);
|
||||
expect(r?.url).toBe(URLS.webFallback);
|
||||
});
|
||||
it('Android Facebook in-app → web fallback (preserves UL second-chance)', () => {
|
||||
const r = pickMobileFallbackUrl('android', UA.androidFacebook, URLS.iosStore, URLS.androidStore, URLS.webFallback);
|
||||
expect(r?.url).toBe(URLS.webFallback);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,606 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { db } from '../lib/database.js';
|
||||
import { getClientIp } from '../lib/client-ip.js';
|
||||
import { parseUserAgent, getLocationFromIP, buildRedirectUrl, detectDevice } from '../lib/utils.js';
|
||||
import { storeFingerprintForClick, type FingerprintData } from '../lib/fingerprint.js';
|
||||
import { emitClickEvent } from '../lib/event-emitter.js';
|
||||
import { classifyBot, edgeBotSignal } from '../lib/bot-detection.js';
|
||||
|
||||
/**
|
||||
* Detect iOS in-app browsers where Universal Links don't fire.
|
||||
* These browsers use WKWebView which bypasses the Universal Links mechanism.
|
||||
*/
|
||||
export function isIOSInAppBrowser(userAgent: string): boolean {
|
||||
const inAppPatterns = [
|
||||
/GSA\//i, // Google Search App (Gmail in-app browser)
|
||||
/Gmail\//i, // Gmail
|
||||
/FBAN|FBAV/i, // Facebook
|
||||
/Instagram/i, // Instagram
|
||||
/Twitter/i, // Twitter/X
|
||||
/LinkedIn/i, // LinkedIn
|
||||
/MicroMessenger/i, // WeChat
|
||||
/Outlook/i, // Outlook
|
||||
/YahooMobile/i, // Yahoo Mail
|
||||
];
|
||||
return inAppPatterns.some(pattern => pattern.test(userAgent));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Android in-app browsers where App Links don't fire.
|
||||
* These browsers use Android WebView (or app-specific webviews) that bypass
|
||||
* the App Link / Digital Asset Link mechanism.
|
||||
*/
|
||||
export function isAndroidInAppBrowser(userAgent: string): boolean {
|
||||
const inAppPatterns = [
|
||||
/FB_IAB|FBAN|FBAV/i, // Facebook in-app browser
|
||||
/Instagram/i,
|
||||
/Line\//i,
|
||||
/KAKAOTALK/i,
|
||||
/Twitter/i,
|
||||
/LinkedIn/i,
|
||||
/MicroMessenger/i, // WeChat
|
||||
/Outlook-Android/i,
|
||||
/WhatsApp/i,
|
||||
/Pinterest/i,
|
||||
/Telegram/i,
|
||||
/Snapchat/i,
|
||||
/\swv\)/, // Generic Android WebView marker (e.g. "Mobile Safari/537.36; wv)")
|
||||
];
|
||||
return inAppPatterns.some(pattern => pattern.test(userAgent));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the destination URL for a mobile click that has fallen through the
|
||||
* Universal Link / App Link / app_scheme priority steps. The choice depends on
|
||||
* whether the click is from an in-app browser:
|
||||
*
|
||||
* - Regular browser (Safari, Chrome): the OS-level UL/App Link check ran and
|
||||
* didn't fire, so the app must not be installed → prefer the App/Play Store URL.
|
||||
*
|
||||
* - In-app browser (Gmail, GSA, FB, Instagram, Outlook, etc.): UL is bypassed
|
||||
* regardless of install state, so we don't know if the app is installed →
|
||||
* prefer the web fallback URL, which gives the OS another chance to fire UL
|
||||
* if the fallback is on the app's UL/App-Link domain.
|
||||
*
|
||||
* Returns null if no URL is available (caller should fall back to original_url).
|
||||
*/
|
||||
export function pickMobileFallbackUrl(
|
||||
device: 'ios' | 'android',
|
||||
userAgent: string,
|
||||
iosUrl: string | null,
|
||||
androidUrl: string | null,
|
||||
webFallbackUrl: string | null,
|
||||
): { url: string; reason: string } | null {
|
||||
const inApp = device === 'ios'
|
||||
? isIOSInAppBrowser(userAgent)
|
||||
: isAndroidInAppBrowser(userAgent);
|
||||
const storeUrl = device === 'ios' ? iosUrl : androidUrl;
|
||||
const storeReason = device === 'ios' ? 'ios_app_store_url' : 'android_app_store_url';
|
||||
|
||||
if (inApp) {
|
||||
if (webFallbackUrl) return { url: webFallbackUrl, reason: 'web_fallback_url' };
|
||||
if (storeUrl) return { url: storeUrl, reason: storeReason };
|
||||
} else {
|
||||
if (storeUrl) return { url: storeUrl, reason: storeReason };
|
||||
if (webFallbackUrl) return { url: webFallbackUrl, reason: 'web_fallback_url' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an interstitial HTML page that tries to open the app via custom scheme,
|
||||
* then falls back to the App Store / Play Store.
|
||||
*
|
||||
* The JavaScript reads the URL fragment (window.location.hash) and appends it
|
||||
* to the scheme URL. This preserves the E2E encryption key, which lives only
|
||||
* in the fragment and is never sent to the server.
|
||||
*/
|
||||
function generateInterstitialHTML(schemeUrl: string, fallbackUrl: string, title?: string): string {
|
||||
const safeSchemeUrl = schemeUrl.replace(/"/g, '"').replace(/</g, '<');
|
||||
const safeFallbackUrl = fallbackUrl.replace(/"/g, '"').replace(/</g, '<');
|
||||
const safeTitle = (title || 'the app').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Opening ${safeTitle}...</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f9fafb; color: #111827; text-align: center; }
|
||||
.container { padding: 2rem; }
|
||||
.spinner { width: 40px; height: 40px; border: 3px solid #e5e7eb; border-top-color: #3b82f6; border-radius: 50%; animation: spin 0.8s linear infinite; margin: 0 auto 1.5rem; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
h1 { font-size: 1.25rem; font-weight: 600; margin: 0 0 0.5rem; }
|
||||
p { font-size: 0.875rem; color: #6b7280; margin: 0 0 2rem; }
|
||||
.btn { display: inline-block; padding: 0.75rem 1.5rem; border-radius: 0.5rem; font-size: 0.875rem; font-weight: 500; text-decoration: none; margin: 0.25rem; }
|
||||
.btn-primary { background: #3b82f6; color: #fff; }
|
||||
.btn-secondary { background: #e5e7eb; color: #374151; }
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="container">
|
||||
<div class="spinner"></div>
|
||||
<h1>Opening ${safeTitle}...</h1>
|
||||
<p>If the app doesn't open automatically:</p>
|
||||
<a class="btn btn-primary" id="open-btn" href="${safeSchemeUrl}">Open App</a>
|
||||
<a class="btn btn-secondary" id="store-btn" href="${safeFallbackUrl}">Download App</a>
|
||||
</div>
|
||||
<script>
|
||||
// Preserve URL fragment (E2E encryption key) through the scheme redirect
|
||||
var hash = window.location.hash || '';
|
||||
var schemeUrl = "${safeSchemeUrl}" + hash;
|
||||
document.getElementById('open-btn').href = schemeUrl;
|
||||
window.location = schemeUrl;
|
||||
setTimeout(function() { window.location.replace("${safeFallbackUrl}"); }, 1500);
|
||||
</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
export async function redirectRoutes(fastify: FastifyInstance) {
|
||||
// Helper function to handle the actual redirect logic
|
||||
async function handleRedirect(request: any, reply: any, shortCode: string, templateSlug?: string) {
|
||||
let linkData: string | null = null;
|
||||
|
||||
// Build cache key (include template if present)
|
||||
const cacheKey = templateSlug ? `link:${templateSlug}:${shortCode}` : `link:${shortCode}`;
|
||||
|
||||
// Try to get link from cache if Redis is available
|
||||
if (fastify.redis) {
|
||||
try {
|
||||
linkData = await fastify.redis.get(cacheKey);
|
||||
} catch (error) {
|
||||
fastify.log.warn('Redis cache lookup failed, falling back to database');
|
||||
}
|
||||
}
|
||||
|
||||
if (!linkData) {
|
||||
// Build query based on whether template slug is provided
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (templateSlug) {
|
||||
// Template-based URL: verify both template and link match
|
||||
// Also fetch template settings and org settings for URL fallback chain
|
||||
query = `
|
||||
SELECT l.*, t.settings AS template_settings, o.settings AS org_settings
|
||||
FROM links l
|
||||
LEFT JOIN link_templates t ON l.template_id = t.id
|
||||
LEFT JOIN organizations o ON l.organization_id = o.id
|
||||
WHERE l.short_code = $1 AND t.slug = $2
|
||||
AND l.is_active = true
|
||||
AND (l.expires_at IS NULL OR l.expires_at > NOW())
|
||||
`;
|
||||
params = [shortCode, templateSlug];
|
||||
} else {
|
||||
// Legacy URL: just lookup by short code
|
||||
// Also fetch template settings and org settings for URL fallback chain
|
||||
query = `
|
||||
SELECT l.*, t.settings AS template_settings, o.settings AS org_settings
|
||||
FROM links l
|
||||
LEFT JOIN link_templates t ON l.template_id = t.id
|
||||
LEFT JOIN organizations o ON l.organization_id = o.id
|
||||
WHERE l.short_code = $1 AND l.is_active = true
|
||||
AND (l.expires_at IS NULL OR l.expires_at > NOW())
|
||||
`;
|
||||
params = [shortCode];
|
||||
}
|
||||
|
||||
const result = await db.query(query, params);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return reply.status(404).send({ error: 'Link not found' });
|
||||
}
|
||||
|
||||
linkData = JSON.stringify(result.rows[0]);
|
||||
|
||||
// Cache for 5 minutes if Redis is available
|
||||
if (fastify.redis) {
|
||||
try {
|
||||
await fastify.redis.setex(cacheKey, 300, linkData);
|
||||
} catch (error) {
|
||||
fastify.log.warn('Redis cache set failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const link = JSON.parse(linkData);
|
||||
|
||||
// Check targeting rules BEFORE redirecting
|
||||
if (link.targeting_rules) {
|
||||
const userAgent = request.headers['user-agent'] || '';
|
||||
const ip = getClientIp(request);
|
||||
const acceptLanguage = request.headers['accept-language'] || '';
|
||||
|
||||
// Get user's actual data for targeting checks
|
||||
const device = detectDevice(userAgent);
|
||||
const { countryCode } = getLocationFromIP(ip);
|
||||
|
||||
// Extract primary language from accept-language header (e.g., "en-US,en;q=0.9" -> "en")
|
||||
const primaryLanguage = acceptLanguage.split(',')[0]?.split('-')[0]?.toLowerCase();
|
||||
|
||||
const rules = link.targeting_rules;
|
||||
let isTargeted = true;
|
||||
|
||||
// Check country targeting
|
||||
if (rules.countries && rules.countries.length > 0) {
|
||||
const targetCountries = rules.countries.map((c: string) => c.toUpperCase());
|
||||
if (!countryCode || !targetCountries.includes(countryCode.toUpperCase())) {
|
||||
isTargeted = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check device targeting
|
||||
if (rules.devices && rules.devices.length > 0) {
|
||||
if (!rules.devices.includes(device)) {
|
||||
isTargeted = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check language targeting
|
||||
if (rules.languages && rules.languages.length > 0) {
|
||||
const targetLanguages = rules.languages.map((l: string) => l.toLowerCase());
|
||||
if (!primaryLanguage || !targetLanguages.includes(primaryLanguage)) {
|
||||
isTargeted = false;
|
||||
}
|
||||
}
|
||||
|
||||
// If targeting rules exist but user doesn't match, return 404
|
||||
if (!isTargeted) {
|
||||
return reply.status(404).send({ error: 'Link not found' });
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the click id up front (rather than letting the DB default it on
|
||||
// insert) so the synchronous redirect below can carry it on the destination
|
||||
// URL while the click row is still written asynchronously with the same id.
|
||||
const clickId = randomUUID();
|
||||
|
||||
// Track click asynchronously
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const userAgent = request.headers['user-agent'] || '';
|
||||
const ip = getClientIp(request);
|
||||
const referrer = request.headers.referer || null;
|
||||
const acceptLanguage = request.headers['accept-language'] || '';
|
||||
|
||||
const deviceType = detectDevice(userAgent);
|
||||
const { platform, platformVersion } = parseUserAgent(userAgent);
|
||||
const { countryCode, countryName, region, city, latitude, longitude, timezone } = getLocationFromIP(ip);
|
||||
|
||||
// Classify bots at ingestion (SIT-298) — persisted on the row so
|
||||
// analytics reads a consistent flag instead of re-detecting from the
|
||||
// stored user-agent.
|
||||
const { isBot, reason: botReason } = classifyBot(
|
||||
userAgent,
|
||||
request.method,
|
||||
edgeBotSignal(request.headers['x-lf-bot'])
|
||||
);
|
||||
|
||||
// Extract UTM parameters from query string
|
||||
const query = request.query as Record<string, string | undefined>;
|
||||
const utmSource = query?.utm_source;
|
||||
const utmMedium = query?.utm_medium;
|
||||
const utmCampaign = query?.utm_campaign;
|
||||
|
||||
// Extract fingerprint data from query params (sent by SDK/client)
|
||||
const fpTimezone = query?.fp_tz || timezone || undefined;
|
||||
const fpLanguage = query?.fp_lang || acceptLanguage.split(',')[0]?.split(';')[0] || undefined;
|
||||
const fpScreenWidth = query?.fp_sw ? parseInt(query.fp_sw, 10) : undefined;
|
||||
const fpScreenHeight = query?.fp_sh ? parseInt(query.fp_sh, 10) : undefined;
|
||||
|
||||
// Insert click event with the pre-generated id (see above) so the row
|
||||
// matches the lf_click value already placed on the redirect URL.
|
||||
await db.query(
|
||||
`INSERT INTO click_events (
|
||||
id, link_id, ip_address, user_agent, device_type, platform,
|
||||
country_code, country_name, region, city, latitude, longitude, timezone,
|
||||
utm_source, utm_medium, utm_campaign, referrer, is_bot, bot_reason
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
|
||||
[
|
||||
clickId,
|
||||
link.id,
|
||||
ip,
|
||||
userAgent,
|
||||
deviceType,
|
||||
platform,
|
||||
countryCode,
|
||||
countryName,
|
||||
region,
|
||||
city,
|
||||
latitude,
|
||||
longitude,
|
||||
timezone,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
referrer,
|
||||
isBot,
|
||||
botReason,
|
||||
]
|
||||
);
|
||||
|
||||
// Store device fingerprint for deferred deep linking
|
||||
const fingerprintData: FingerprintData = {
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
timezone: fpTimezone,
|
||||
language: fpLanguage,
|
||||
screenWidth: fpScreenWidth,
|
||||
screenHeight: fpScreenHeight,
|
||||
platform: deviceType,
|
||||
platformVersion,
|
||||
};
|
||||
|
||||
await storeFingerprintForClick(clickId, fingerprintData);
|
||||
|
||||
// Determine redirect URL for event emission (using same logic as main redirect)
|
||||
// Use the same fallback chain: link → template → workspace
|
||||
const tplSettings = link.template_settings || {};
|
||||
const oSettings = link.org_settings || {};
|
||||
const oAppConfig = oSettings.appConfig || {};
|
||||
const iosStoreUrl = link.ios_app_store_url || tplSettings.defaultIosUrl || oAppConfig.iosAppStoreUrl || null;
|
||||
const androidStoreUrl = link.android_app_store_url || tplSettings.defaultAndroidUrl || oAppConfig.androidAppStoreUrl || null;
|
||||
const webFallback = link.web_fallback_url || tplSettings.defaultWebFallbackUrl || oAppConfig.webFallbackUrl || null;
|
||||
|
||||
let redirectUrl = link.original_url;
|
||||
let redirectReason = 'original_url';
|
||||
|
||||
if (deviceType === 'ios') {
|
||||
if (link.ios_universal_link) {
|
||||
redirectUrl = link.ios_universal_link;
|
||||
redirectReason = 'ios_universal_link';
|
||||
} else if (link.app_scheme && link.deep_link_path) {
|
||||
redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`;
|
||||
redirectReason = 'app_scheme';
|
||||
} else {
|
||||
const fb = pickMobileFallbackUrl('ios', userAgent, iosStoreUrl, androidStoreUrl, webFallback);
|
||||
if (fb) {
|
||||
redirectUrl = fb.url;
|
||||
redirectReason = fb.reason;
|
||||
}
|
||||
}
|
||||
} else if (deviceType === 'android') {
|
||||
if (link.android_app_link) {
|
||||
redirectUrl = link.android_app_link;
|
||||
redirectReason = 'android_app_link';
|
||||
} else if (link.app_scheme && link.deep_link_path) {
|
||||
redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`;
|
||||
redirectReason = 'app_scheme';
|
||||
} else {
|
||||
const fb = pickMobileFallbackUrl('android', userAgent, iosStoreUrl, androidStoreUrl, webFallback);
|
||||
if (fb) {
|
||||
redirectUrl = fb.url;
|
||||
redirectReason = fb.reason;
|
||||
}
|
||||
}
|
||||
} else if (deviceType === 'web' && webFallback) {
|
||||
redirectUrl = webFallback;
|
||||
redirectReason = 'web_fallback_url';
|
||||
}
|
||||
|
||||
const finalRedirectUrl = buildRedirectUrl(redirectUrl, link.utm_parameters) || redirectUrl;
|
||||
|
||||
// Emit click event for real-time streaming to WebSocket clients
|
||||
emitClickEvent({
|
||||
eventId: clickId,
|
||||
timestamp: new Date().toISOString(),
|
||||
linkId: link.id,
|
||||
shortCode: link.short_code,
|
||||
userId: link.user_id,
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
country: countryCode || undefined,
|
||||
city: city || undefined,
|
||||
deviceType,
|
||||
platform: platform || undefined,
|
||||
redirectUrl: finalRedirectUrl,
|
||||
redirectReason,
|
||||
targetingMatched: true, // If we got here, targeting matched
|
||||
utmParameters: link.utm_parameters || undefined,
|
||||
referer: referrer || undefined,
|
||||
language: fpLanguage,
|
||||
});
|
||||
|
||||
// Trigger webhooks for click_event
|
||||
try {
|
||||
const webhooksResult = await db.query(
|
||||
'SELECT * FROM webhooks WHERE user_id = $1 AND is_active = true',
|
||||
[link.user_id]
|
||||
);
|
||||
|
||||
if (webhooksResult.rows.length > 0) {
|
||||
const { triggerWebhooks } = await import('../lib/webhook.js');
|
||||
|
||||
const clickEventData = {
|
||||
id: clickId,
|
||||
linkId: link.id,
|
||||
clickedAt: new Date().toISOString(),
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
deviceType,
|
||||
platform,
|
||||
countryCode,
|
||||
countryName,
|
||||
region,
|
||||
city,
|
||||
latitude,
|
||||
longitude,
|
||||
timezone,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
referrer,
|
||||
};
|
||||
|
||||
// Trigger webhooks without delivery logging (basic version)
|
||||
// For delivery logging, use @linkforty/cloud premium features
|
||||
await triggerWebhooks(
|
||||
webhooksResult.rows,
|
||||
'click_event',
|
||||
clickId,
|
||||
clickEventData
|
||||
);
|
||||
}
|
||||
} catch (webhookError) {
|
||||
fastify.log.error(`Error triggering click webhooks: ${webhookError}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fastify.log.error(`Error tracking click: ${error}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Determine redirect URL based on device with smart fallback chain
|
||||
// Fallback chain: link URLs → template default URLs → workspace settings URLs
|
||||
const userAgent = request.headers['user-agent'] || '';
|
||||
const device = detectDevice(userAgent);
|
||||
|
||||
// Extract fallback URLs from template settings and org settings
|
||||
const templateSettings = link.template_settings || {};
|
||||
const orgSettings = link.org_settings || {};
|
||||
const orgAppConfig = orgSettings.appConfig || {};
|
||||
|
||||
// Resolve platform URLs with fallback chain: link → template → workspace
|
||||
const iosUrl = link.ios_app_store_url || templateSettings.defaultIosUrl || orgAppConfig.iosAppStoreUrl || null;
|
||||
const androidUrl = link.android_app_store_url || templateSettings.defaultAndroidUrl || orgAppConfig.androidAppStoreUrl || null;
|
||||
const webFallbackUrl = link.web_fallback_url || templateSettings.defaultWebFallbackUrl || orgAppConfig.webFallbackUrl || null;
|
||||
|
||||
let redirectUrl = link.original_url;
|
||||
let useSchemeUrl = false; // Track if we're using a URI scheme URL
|
||||
|
||||
if (device === 'ios') {
|
||||
// iOS Priority:
|
||||
// 1. Universal Link (HTTPS URL with AASA file) — if app installed, OS opens app
|
||||
// (this branch only runs when UL didn't fire upstream, e.g. in-app browser)
|
||||
// 2. URI scheme (myapp://path) — explicit deep link
|
||||
// 3. Mobile fallback (browser-aware):
|
||||
// - regular browser: App Store URL > web fallback URL
|
||||
// (UL would have fired if app installed, so app is not installed)
|
||||
// - in-app browser: web fallback URL > App Store URL
|
||||
// (UL was bypassed; web fallback gives UL a second chance to fire)
|
||||
// 4. Original URL — ultimate fallback
|
||||
if (link.ios_universal_link) {
|
||||
redirectUrl = link.ios_universal_link;
|
||||
} else if (link.app_scheme && link.deep_link_path) {
|
||||
// Build URI scheme URL: myapp://product/123
|
||||
redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`;
|
||||
useSchemeUrl = true;
|
||||
} else {
|
||||
const fb = pickMobileFallbackUrl('ios', userAgent, iosUrl, androidUrl, webFallbackUrl);
|
||||
if (fb) redirectUrl = fb.url;
|
||||
}
|
||||
|
||||
} else if (device === 'android') {
|
||||
// Android Priority — same logic as iOS, with android_app_link in place of UL
|
||||
if (link.android_app_link) {
|
||||
redirectUrl = link.android_app_link;
|
||||
} else if (link.app_scheme && link.deep_link_path) {
|
||||
// Build URI scheme URL: myapp://product/123
|
||||
redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`;
|
||||
useSchemeUrl = true;
|
||||
} else {
|
||||
const fb = pickMobileFallbackUrl('android', userAgent, iosUrl, androidUrl, webFallbackUrl);
|
||||
if (fb) redirectUrl = fb.url;
|
||||
}
|
||||
|
||||
} else if (device === 'web') {
|
||||
// Web fallback
|
||||
redirectUrl = webFallbackUrl || link.original_url;
|
||||
}
|
||||
|
||||
// If no URL found at all, return a user-friendly error
|
||||
if (!redirectUrl) {
|
||||
return reply.status(404).send({ error: 'No destination URL configured for this link' });
|
||||
}
|
||||
|
||||
// Build final URL with parameters
|
||||
let finalUrl = redirectUrl;
|
||||
|
||||
if (!useSchemeUrl) {
|
||||
// For HTTP(S) URLs, add UTM parameters
|
||||
finalUrl = buildRedirectUrl(redirectUrl, link.utm_parameters) || redirectUrl;
|
||||
|
||||
// Add deep link parameters as query params
|
||||
if (link.deep_link_parameters && Object.keys(link.deep_link_parameters).length > 0) {
|
||||
try {
|
||||
const url = new URL(finalUrl);
|
||||
Object.entries(link.deep_link_parameters).forEach(([key, value]) => {
|
||||
url.searchParams.set(key, String(value));
|
||||
});
|
||||
finalUrl = url.toString();
|
||||
} catch (error) {
|
||||
// If URL parsing fails, continue without deep link parameters
|
||||
console.error('Failed to add deep link parameters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// When opted in per link (append_click_id), append the originating click id
|
||||
// so a downstream analytics tool on the landing page can correlate the
|
||||
// landing visit to this exact click. Opt-in (default off), web/HTTPS only —
|
||||
// an absent/false flag (incl. stale cache) leaves the destination untouched.
|
||||
if (link.append_click_id === true) {
|
||||
try {
|
||||
const url = new URL(finalUrl);
|
||||
url.searchParams.set('lf_click', clickId);
|
||||
finalUrl = url.toString();
|
||||
} catch {
|
||||
// Non-absolute / unparseable URL — skip the correlation param.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For URI scheme URLs, append query params differently
|
||||
if (link.deep_link_parameters && Object.keys(link.deep_link_parameters).length > 0) {
|
||||
const params = new URLSearchParams(
|
||||
Object.entries(link.deep_link_parameters).map(([k, v]) => [k, String(v)] as [string, string])
|
||||
);
|
||||
finalUrl += `?${params.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Serve an interstitial page for mobile requests when a custom scheme is available.
|
||||
// The interstitial tries to open the app via URI scheme, then falls back to the store.
|
||||
// This works for both in-app browsers (where Universal Links don't fire) and regular
|
||||
// browsers (where a 302 to a custom scheme fails silently if the app isn't installed).
|
||||
// The interstitial JavaScript preserves the URL fragment (E2E encryption key).
|
||||
if ((device === 'ios' || device === 'android') && link.app_scheme) {
|
||||
const deepPath = link.deep_link_path ? link.deep_link_path.replace(/^\//, '') : '';
|
||||
const schemeUrl = link.custom_scheme_url
|
||||
|| `${link.app_scheme}://${deepPath}`;
|
||||
|
||||
// The interstitial JS tries the scheme first; storeFallback is what we
|
||||
// navigate to if the scheme doesn't open the app within ~1.5s. Pick it
|
||||
// browser-aware: regular browsers prefer the store URL, in-app browsers
|
||||
// prefer the web fallback (gives UL a second chance to fire).
|
||||
const fb = pickMobileFallbackUrl(device, userAgent, iosUrl, androidUrl, webFallbackUrl);
|
||||
const storeFallback = fb?.url || link.original_url;
|
||||
|
||||
if (storeFallback) {
|
||||
let fullSchemeUrl = schemeUrl;
|
||||
if (link.deep_link_parameters && Object.keys(link.deep_link_parameters).length > 0) {
|
||||
const params = new URLSearchParams(
|
||||
Object.entries(link.deep_link_parameters).map(([k, v]: [string, any]) => [k, String(v)] as [string, string])
|
||||
);
|
||||
fullSchemeUrl += (fullSchemeUrl.includes('?') ? '&' : '?') + params.toString();
|
||||
}
|
||||
|
||||
return reply
|
||||
.header('Content-Type', 'text/html; charset=utf-8')
|
||||
.send(generateInterstitialHTML(fullSchemeUrl, storeFallback, link.title || link.og_title));
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect
|
||||
return reply.redirect(302, finalUrl);
|
||||
}
|
||||
|
||||
// Template-based shortlink route: /:templateSlug/:shortCode
|
||||
fastify.get('/:templateSlug/:shortCode', async (request, reply) => {
|
||||
const { templateSlug, shortCode } = request.params as { templateSlug: string; shortCode: string };
|
||||
return handleRedirect(request, reply, shortCode, templateSlug);
|
||||
});
|
||||
|
||||
// Legacy shortlink route (no template): /:shortCode
|
||||
fastify.get('/:shortCode', async (request, reply) => {
|
||||
const { shortCode } = request.params as { shortCode: string };
|
||||
return handleRedirect(request, reply, shortCode);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
|
||||
// Mock the database singleton so the route runs without a real Postgres.
|
||||
vi.mock('../lib/database.js', () => ({
|
||||
db: { query: vi.fn() },
|
||||
}));
|
||||
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { db } from '../lib/database.js';
|
||||
import { sdkRoutes } from './sdk.js';
|
||||
|
||||
const mockQuery = db.query as unknown as Mock;
|
||||
|
||||
const INSTALL_ID = '11111111-1111-4111-8111-111111111111';
|
||||
const LINK_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const SESSION_ID = '33333333-3333-4333-8333-333333333333';
|
||||
const CLICK_ID = '44444444-4444-4444-8444-444444444444';
|
||||
const EVENT_ID = '55555555-5555-4555-8555-555555555555';
|
||||
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
const app = Fastify();
|
||||
await app.register(sdkRoutes);
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
const EVENT_TS = '2026-06-05T10:05:00.000Z';
|
||||
const LINK_OPENED_AT = '2026-06-05T10:00:00.000Z';
|
||||
|
||||
const stampedEvent = {
|
||||
installId: INSTALL_ID,
|
||||
eventName: 'add_to_cart',
|
||||
eventData: { sku: 'abc' },
|
||||
timestamp: EVENT_TS,
|
||||
attributedLinkId: LINK_ID,
|
||||
attributedClickId: CLICK_ID,
|
||||
linkOpenedAt: LINK_OPENED_AT,
|
||||
sessionId: SESSION_ID,
|
||||
sdkName: 'react-native',
|
||||
sdkVersion: '1.4.0',
|
||||
};
|
||||
|
||||
describe('POST /api/sdk/v1/links', () => {
|
||||
beforeEach(() => {
|
||||
mockQuery.mockReset();
|
||||
});
|
||||
|
||||
it('accepts payloads without originalUrl and derives a fallback destination', async () => {
|
||||
mockQuery.mockResolvedValueOnce({
|
||||
rows: [{
|
||||
id: LINK_ID,
|
||||
short_code: 'abc123',
|
||||
original_url: 'https://example.com/emoji/detail',
|
||||
}],
|
||||
});
|
||||
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sdk/v1/links',
|
||||
payload: {
|
||||
deepLinkParameters: { emojiSetId: 'uex1k7qy', path: '/emoji/detail' },
|
||||
utmParameters: { campaign: 'emoji_share' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({
|
||||
shortCode: 'abc123',
|
||||
originalUrl: 'https://example.com/emoji/detail',
|
||||
});
|
||||
|
||||
const insertCall = mockQuery.mock.calls[0];
|
||||
expect(insertCall[0]).toMatch(/INSERT INTO links/);
|
||||
expect(insertCall[1][3]).toBe('https://example.com/emoji/detail');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sdk/v1/event — last-click attribution stamp', () => {
|
||||
beforeEach(() => {
|
||||
mockQuery.mockReset();
|
||||
});
|
||||
|
||||
it('persists the attribution stamp on the in_app_events row', async () => {
|
||||
// 1) install lookup (link_id null so the webhook block is skipped)
|
||||
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
|
||||
// 2) the event INSERT
|
||||
mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] });
|
||||
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ eventId: EVENT_ID, acknowledged: true });
|
||||
|
||||
const insertCall = mockQuery.mock.calls[1];
|
||||
expect(insertCall[0]).toMatch(/INSERT INTO in_app_events/);
|
||||
expect(insertCall[0]).toMatch(/attributed_link_id/);
|
||||
// params: install, name, dataJson, ts, link, click, openedAt, session
|
||||
expect(insertCall[1]).toEqual([
|
||||
INSTALL_ID,
|
||||
'add_to_cart',
|
||||
JSON.stringify({ sku: 'abc' }),
|
||||
EVENT_TS,
|
||||
LINK_ID,
|
||||
CLICK_ID,
|
||||
LINK_OPENED_AT,
|
||||
SESSION_ID,
|
||||
'react-native',
|
||||
'1.4.0',
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('stays backward compatible: an event with no stamp stores null attribution + a sessionId-less row', async () => {
|
||||
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
|
||||
mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] });
|
||||
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sdk/v1/event',
|
||||
payload: { installId: INSTALL_ID, eventName: 'signup' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const params = mockQuery.mock.calls[1][1];
|
||||
expect(params[4]).toBeNull(); // attributed_link_id
|
||||
expect(params[5]).toBeNull(); // attributed_click_id
|
||||
expect(params[6]).toBeNull(); // attributed_at
|
||||
expect(params[7]).toBeNull(); // session_id
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('never loses an event when the attributed link is stale: falls back to no-link insert on FK violation', async () => {
|
||||
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
|
||||
// first INSERT rejects with the attributed_link_id FK violation
|
||||
mockQuery.mockRejectedValueOnce(Object.assign(new Error('FK violation'), { code: '23503', constraint: 'in_app_events_attributed_link_id_fkey' }));
|
||||
// fallback INSERT (without attributed_link_id) succeeds
|
||||
mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] });
|
||||
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ eventId: EVENT_ID, acknowledged: true });
|
||||
|
||||
// the fallback INSERT omits attributed_link_id but keeps click/session
|
||||
const fallbackCall = mockQuery.mock.calls[2];
|
||||
expect(fallbackCall[0]).not.toMatch(/attributed_link_id/);
|
||||
expect(fallbackCall[1]).toEqual([
|
||||
INSTALL_ID,
|
||||
'add_to_cart',
|
||||
JSON.stringify({ sku: 'abc' }),
|
||||
EVENT_TS,
|
||||
CLICK_ID,
|
||||
LINK_OPENED_AT,
|
||||
SESSION_ID,
|
||||
'react-native',
|
||||
'1.4.0',
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rethrows a non-link FK violation (e.g. install deleted mid-request) instead of mislabeling it as a link problem', async () => {
|
||||
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
|
||||
// install_id's FK lost to a concurrent install delete — a 23503 that is NOT the link FK
|
||||
mockQuery.mockRejectedValueOnce(Object.assign(new Error('FK violation'), { code: '23503', constraint: 'in_app_events_install_id_fkey' }));
|
||||
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
|
||||
|
||||
// Surfaces as a real error; no misleading no-link fallback insert is attempted.
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(mockQuery.mock.calls.length).toBe(2); // install lookup + the failed insert only
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns 404 when the install does not exist', async () => {
|
||||
mockQuery.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,754 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../lib/database.js';
|
||||
import { getClientIp } from '../lib/client-ip.js';
|
||||
import {
|
||||
recordInstallEvent,
|
||||
generateFingerprintHash,
|
||||
storeFingerprintForClick,
|
||||
type FingerprintData,
|
||||
} from '../lib/fingerprint.js';
|
||||
import { triggerWebhooks } from '../lib/webhook.js';
|
||||
import { parseUserAgent, getLocationFromIP, detectDevice } from '../lib/utils.js';
|
||||
import { emitClickEvent } from '../lib/event-emitter.js';
|
||||
import { classifyBot, edgeBotSignal } from '../lib/bot-detection.js';
|
||||
|
||||
/**
|
||||
* SDK Routes - Mobile SDK endpoints for deferred deep linking
|
||||
* These endpoints are used by the mobile SDKs to report installs and retrieve attribution data
|
||||
*/
|
||||
export async function sdkRoutes(fastify: FastifyInstance) {
|
||||
/**
|
||||
* POST /api/sdk/v1/links
|
||||
* Create a link from the simplified SDK payload.
|
||||
*
|
||||
* This endpoint accepts the subset of link creation fields that the SDK sends,
|
||||
* including deepLinkParameters and utmParameters. When originalUrl is omitted,
|
||||
* it falls back to the current page's URL or a placeholder destination.
|
||||
*/
|
||||
fastify.post('/api/sdk/v1/links', async (request, reply) => {
|
||||
const schema = z.object({
|
||||
originalUrl: z.string().url().optional(),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
iosAppStoreUrl: z.string().url().optional(),
|
||||
androidAppStoreUrl: z.string().url().optional(),
|
||||
webFallbackUrl: z.string().url().optional(),
|
||||
appScheme: z.string().optional(),
|
||||
iosUniversalLink: z.string().url().optional(),
|
||||
androidAppLink: z.string().url().optional(),
|
||||
deepLinkPath: z.string().optional(),
|
||||
deepLinkParameters: z.record(z.string(), z.any()).optional(),
|
||||
customCode: z.string().optional(),
|
||||
utmParameters: z.object({
|
||||
source: z.string().optional(),
|
||||
medium: z.string().optional(),
|
||||
campaign: z.string().optional(),
|
||||
term: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
}).optional(),
|
||||
targetingRules: z.object({
|
||||
countries: z.array(z.string()).optional(),
|
||||
devices: z.array(z.enum(['ios', 'android', 'web'])).optional(),
|
||||
languages: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
ogTitle: z.string().optional(),
|
||||
ogDescription: z.string().optional(),
|
||||
ogImageUrl: z.string().url().optional(),
|
||||
ogType: z.string().optional(),
|
||||
attributionWindowHours: z.number().int().min(1).max(2160).optional(),
|
||||
expiresAt: z.string().datetime().optional(),
|
||||
templateId: z.string().uuid().optional(),
|
||||
userId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
const body = schema.parse(request.body);
|
||||
const originalUrl = body.originalUrl || 'https://angkorlifes.com';
|
||||
|
||||
const shortCode = body.customCode || 'sdk-link';
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO links (
|
||||
user_id, template_id, short_code, original_url, title, description,
|
||||
ios_app_store_url, android_app_store_url, web_fallback_url,
|
||||
app_scheme, ios_universal_link, android_app_link, deep_link_path, deep_link_parameters,
|
||||
utm_parameters, targeting_rules,
|
||||
og_title, og_description, og_image_url, og_type,
|
||||
attribution_window_hours, expires_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
|
||||
RETURNING *`,
|
||||
[
|
||||
body.userId || null,
|
||||
body.templateId || null,
|
||||
shortCode,
|
||||
originalUrl,
|
||||
body.title || null,
|
||||
body.description || null,
|
||||
body.iosAppStoreUrl || null,
|
||||
body.androidAppStoreUrl || null,
|
||||
body.webFallbackUrl || null,
|
||||
body.appScheme || null,
|
||||
body.iosUniversalLink || null,
|
||||
body.androidAppLink || null,
|
||||
body.deepLinkPath || null,
|
||||
JSON.stringify(body.deepLinkParameters || {}),
|
||||
JSON.stringify(body.utmParameters || {}),
|
||||
JSON.stringify(body.targetingRules || {}),
|
||||
body.ogTitle || null,
|
||||
body.ogDescription || null,
|
||||
body.ogImageUrl || null,
|
||||
body.ogType || 'website',
|
||||
body.attributionWindowHours || 168,
|
||||
body.expiresAt || null,
|
||||
]
|
||||
);
|
||||
|
||||
const link = result.rows[0];
|
||||
|
||||
return reply.status(200).send({
|
||||
id: link.id,
|
||||
shortCode: link.short_code,
|
||||
originalUrl: link.original_url,
|
||||
url: `${process.env.PUBLIC_BASE_URL || 'http://localhost:3000'}/${link.short_code}`,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/sdk/v1/install
|
||||
* Report app installation and retrieve deferred deep link data
|
||||
*
|
||||
* Request body:
|
||||
* - ipAddress: Optional, for debug only (untrusted; server uses connection/proxy headers for trusted IP)
|
||||
* - userAgent: Client user agent
|
||||
* - timezone: Device timezone (e.g., "America/New_York")
|
||||
* - language: Device language (e.g., "en-US")
|
||||
* - screenWidth: Screen width in pixels
|
||||
* - screenHeight: Screen height in pixels
|
||||
* - platform: Platform name (e.g., "iOS", "Android")
|
||||
* - platformVersion: Platform version (e.g., "15.0")
|
||||
* - deviceId: Optional device identifier (IDFA, GAID, etc.)
|
||||
* - attributionWindowHours: Optional custom attribution window (default: 168 = 7 days)
|
||||
*
|
||||
* Response:
|
||||
* - installId: UUID of the install event
|
||||
* - attributed: Boolean indicating if install was matched to a click
|
||||
* - confidenceScore: Confidence score (0-100) if matched
|
||||
* - matchedFactors: Array of matched fingerprint factors
|
||||
* - deepLinkData: Deep link data if matched (shortCode, URLs, UTM params, etc.)
|
||||
*/
|
||||
fastify.post('/api/sdk/v1/install', async (request, reply) => {
|
||||
const schema = z.object({
|
||||
ipAddress: z.string().optional(),
|
||||
userAgent: z.string(),
|
||||
timezone: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
screenWidth: z.number().optional(),
|
||||
screenHeight: z.number().optional(),
|
||||
platform: z.string().optional(),
|
||||
platformVersion: z.string().optional(),
|
||||
deviceId: z.string().optional(),
|
||||
attributionWindowHours: z.number().optional(),
|
||||
// SDK identity for health/version diagnostics (SIT-235). Free-form by
|
||||
// design: a consumer tolerates/normalizes non-semver versions — we never
|
||||
// reject a request over this metadata. Empty → null.
|
||||
sdkName: z.string().max(50).optional(),
|
||||
sdkVersion: z.string().max(50).optional(),
|
||||
// Public app token shipped in SDK app bundles to scope organic
|
||||
// installs to the right org in multi-tenant deployments. A multi-tenant
|
||||
// host reads it to route the install to the correct tenant; self-hosted
|
||||
// single-tenant deployments simply ignore it.
|
||||
appToken: z.string().optional(),
|
||||
});
|
||||
|
||||
const body = schema.parse(request.body);
|
||||
|
||||
// Trusted IP from connection/proxy headers only; never use body.ipAddress for attribution/fingerprint
|
||||
const ipAddress = getClientIp(request);
|
||||
|
||||
const fingerprintData: FingerprintData = {
|
||||
ipAddress,
|
||||
userAgent: body.userAgent,
|
||||
timezone: body.timezone,
|
||||
language: body.language,
|
||||
screenWidth: body.screenWidth,
|
||||
screenHeight: body.screenHeight,
|
||||
platform: body.platform,
|
||||
platformVersion: body.platformVersion,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await recordInstallEvent(
|
||||
fingerprintData,
|
||||
body.deviceId,
|
||||
body.attributionWindowHours,
|
||||
{ name: body.sdkName, version: body.sdkVersion }
|
||||
);
|
||||
|
||||
return reply.status(200).send({
|
||||
installId: result.installId,
|
||||
attributed: result.match !== null,
|
||||
confidenceScore: result.match?.confidenceScore || 0,
|
||||
matchedFactors: result.match?.matchedFactors || [],
|
||||
deepLinkData: result.deepLinkData,
|
||||
...(body.ipAddress != null && { clientReportedIp: body.ipAddress }),
|
||||
});
|
||||
} catch (error: any) {
|
||||
fastify.log.error(`Error recording install event: ${error}`);
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to record install event',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/sdk/v1/attribution/:fingerprint
|
||||
* Retrieve attribution data for a specific device fingerprint
|
||||
* Used for debugging or delayed attribution lookups
|
||||
*
|
||||
* Response:
|
||||
* - fingerprint: The fingerprint hash
|
||||
* - attributed: Boolean indicating if attributed to a click
|
||||
* - installEvent: Install event data if found
|
||||
* - clickEvent: Matched click event data if attributed
|
||||
* - linkData: Link data if attributed
|
||||
*/
|
||||
fastify.get('/api/sdk/v1/attribution/:fingerprint', async (request, reply) => {
|
||||
const { fingerprint } = request.params as { fingerprint: string };
|
||||
|
||||
try {
|
||||
// Look up install event by fingerprint
|
||||
const installResult = await db.query(
|
||||
`SELECT
|
||||
ie.*,
|
||||
l.short_code,
|
||||
l.original_url,
|
||||
l.ios_app_store_url,
|
||||
l.android_app_store_url,
|
||||
l.web_fallback_url,
|
||||
l.utm_parameters,
|
||||
l.deep_link_parameters
|
||||
FROM install_events ie
|
||||
LEFT JOIN links l ON ie.link_id = l.id
|
||||
WHERE ie.fingerprint_hash = $1
|
||||
ORDER BY ie.installed_at DESC
|
||||
LIMIT 1`,
|
||||
[fingerprint]
|
||||
);
|
||||
|
||||
if (installResult.rows.length === 0) {
|
||||
return reply.status(404).send({
|
||||
error: 'No install event found for this fingerprint',
|
||||
});
|
||||
}
|
||||
|
||||
const install = installResult.rows[0];
|
||||
const attributed = install.link_id !== null;
|
||||
|
||||
let clickData = null;
|
||||
if (install.click_id) {
|
||||
const clickResult = await db.query(
|
||||
`SELECT * FROM click_events WHERE id = $1`,
|
||||
[install.click_id]
|
||||
);
|
||||
if (clickResult.rows.length > 0) {
|
||||
clickData = clickResult.rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
return reply.status(200).send({
|
||||
fingerprint,
|
||||
attributed,
|
||||
installEvent: {
|
||||
id: install.id,
|
||||
installedAt: install.installed_at,
|
||||
firstOpenAt: install.first_open_at,
|
||||
confidenceScore: parseFloat(install.confidence_score || '0'),
|
||||
deepLinkRetrieved: install.deep_link_retrieved,
|
||||
},
|
||||
clickEvent: clickData
|
||||
? {
|
||||
id: clickData.id,
|
||||
clickedAt: clickData.clicked_at,
|
||||
deviceType: clickData.device_type,
|
||||
platform: clickData.platform,
|
||||
countryCode: clickData.country_code,
|
||||
city: clickData.city,
|
||||
}
|
||||
: null,
|
||||
linkData: attributed
|
||||
? {
|
||||
shortCode: install.short_code,
|
||||
originalUrl: install.original_url,
|
||||
iosUrl: install.ios_app_store_url,
|
||||
androidUrl: install.android_app_store_url,
|
||||
webFallbackUrl: install.web_fallback_url,
|
||||
utmParameters: install.utm_parameters,
|
||||
deepLinkParameters: install.deep_link_parameters,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
fastify.log.error(`Error retrieving attribution: ${error}`);
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to retrieve attribution data',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/sdk/v1/event
|
||||
* Track in-app events (purchases, signups, etc.)
|
||||
* Used for conversion tracking and webhook triggers
|
||||
*
|
||||
* Revenue convention (all SDKs):
|
||||
* eventName: "revenue"
|
||||
* eventData: { revenue: number, currency: string, ...properties }
|
||||
*
|
||||
* Request body:
|
||||
* - installId: UUID of the install event
|
||||
* - eventName: Name of the event (e.g., "purchase", "signup", "level_complete")
|
||||
* - eventData: Optional JSON data associated with the event
|
||||
* - timestamp: Optional event timestamp (defaults to now)
|
||||
*
|
||||
* Last-click attribution stamp (SIT-237, all optional / backward compatible):
|
||||
* - attributedLinkId: UUID of the deep link currently credited (last-click)
|
||||
* - attributedClickId: UUID of the originating click, when known
|
||||
* - linkOpenedAt: ISO timestamp of when that deep link opened the app
|
||||
* - sessionId: UUID identifying the app-open session (for screen-flow grouping)
|
||||
*
|
||||
* Response:
|
||||
* - eventId: UUID of the tracked event
|
||||
* - acknowledged: Boolean confirmation
|
||||
*/
|
||||
fastify.post('/api/sdk/v1/event', async (request, reply) => {
|
||||
const schema = z.object({
|
||||
installId: z.string().uuid(),
|
||||
eventName: z.string(),
|
||||
eventData: z.record(z.any()).optional(),
|
||||
timestamp: z.string().datetime().optional(),
|
||||
attributedLinkId: z.string().uuid().optional(),
|
||||
attributedClickId: z.string().uuid().optional(),
|
||||
linkOpenedAt: z.string().datetime().optional(),
|
||||
sessionId: z.string().uuid().optional(),
|
||||
// SDK identity for version-health diagnostics (SIT-235). Free-form by
|
||||
// design: a consumer tolerates/normalizes non-semver versions — we never
|
||||
// reject a request over this metadata. Empty → null.
|
||||
sdkName: z.string().max(50).optional(),
|
||||
sdkVersion: z.string().max(50).optional(),
|
||||
});
|
||||
|
||||
const body = schema.parse(request.body);
|
||||
|
||||
try {
|
||||
// Verify install exists and get link_id for webhook lookup
|
||||
const installCheck = await db.query(
|
||||
`SELECT id, link_id FROM install_events WHERE id = $1`,
|
||||
[body.installId]
|
||||
);
|
||||
|
||||
if (installCheck.rows.length === 0) {
|
||||
return reply.status(404).send({
|
||||
error: 'Install event not found',
|
||||
});
|
||||
}
|
||||
|
||||
const install = installCheck.rows[0];
|
||||
const eventTimestamp = body.timestamp || new Date().toISOString();
|
||||
const eventDataJson = JSON.stringify(body.eventData || {});
|
||||
|
||||
// Insert event with the last-click attribution stamp. The attributing link
|
||||
// may differ from the install link (re-engagement) or be absent (organic).
|
||||
let eventResult;
|
||||
try {
|
||||
eventResult = await db.query(
|
||||
`INSERT INTO in_app_events
|
||||
(install_id, event_name, event_data, event_timestamp,
|
||||
attributed_link_id, attributed_click_id, attributed_at, session_id,
|
||||
sdk_name, sdk_version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id`,
|
||||
[
|
||||
body.installId,
|
||||
body.eventName,
|
||||
eventDataJson,
|
||||
eventTimestamp,
|
||||
body.attributedLinkId ?? null,
|
||||
body.attributedClickId ?? null,
|
||||
body.linkOpenedAt ?? null,
|
||||
body.sessionId ?? null,
|
||||
body.sdkName || null,
|
||||
body.sdkVersion || null,
|
||||
]
|
||||
);
|
||||
} catch (insertError: any) {
|
||||
// Only a stale/unknown *attributed link* FK is recoverable here: record
|
||||
// the event without link attribution rather than losing it. Any other
|
||||
// 23503 — e.g. install_id's FK lost to a concurrent install delete — is a
|
||||
// real error that must surface, not be mislabeled as a link problem.
|
||||
const isLinkFk =
|
||||
insertError?.code === '23503' &&
|
||||
String(insertError?.constraint ?? '').includes('attributed_link_id');
|
||||
if (isLinkFk) {
|
||||
fastify.log.warn(
|
||||
`attributed_link_id ${body.attributedLinkId} not found; storing event without link attribution`
|
||||
);
|
||||
// Keep this column list in sync with the primary INSERT above (it just
|
||||
// omits attributed_link_id). attributed_click_id is intentionally kept
|
||||
// without a link: the orphaned-click case is expected, and a null
|
||||
// attributed_link_id is the correct value for link-keyed aggregation
|
||||
// (the SIT-261 consumer must not read it as a data bug).
|
||||
eventResult = await db.query(
|
||||
`INSERT INTO in_app_events
|
||||
(install_id, event_name, event_data, event_timestamp,
|
||||
attributed_click_id, attributed_at, session_id,
|
||||
sdk_name, sdk_version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id`,
|
||||
[
|
||||
body.installId,
|
||||
body.eventName,
|
||||
eventDataJson,
|
||||
eventTimestamp,
|
||||
body.attributedClickId ?? null,
|
||||
body.linkOpenedAt ?? null,
|
||||
body.sessionId ?? null,
|
||||
body.sdkName || null,
|
||||
body.sdkVersion || null,
|
||||
]
|
||||
);
|
||||
} else {
|
||||
throw insertError;
|
||||
}
|
||||
}
|
||||
|
||||
const eventId = eventResult.rows[0].id;
|
||||
|
||||
fastify.log.info({
|
||||
eventId,
|
||||
installId: body.installId,
|
||||
linkId: install.link_id,
|
||||
eventName: body.eventName,
|
||||
eventData: body.eventData,
|
||||
timestamp: eventTimestamp,
|
||||
});
|
||||
|
||||
// Trigger webhooks if install was attributed to a link
|
||||
if (install.link_id) {
|
||||
// Query webhooks for the user who owns the link
|
||||
const webhooksResult = await db.query(
|
||||
`SELECT w.*
|
||||
FROM webhooks w
|
||||
INNER JOIN links l ON l.user_id = w.user_id
|
||||
WHERE l.id = $1 AND w.is_active = true`,
|
||||
[install.link_id]
|
||||
);
|
||||
|
||||
if (webhooksResult.rows.length > 0) {
|
||||
const eventData = {
|
||||
eventId,
|
||||
installId: body.installId,
|
||||
linkId: install.link_id,
|
||||
eventName: body.eventName,
|
||||
eventData: body.eventData || {},
|
||||
timestamp: eventTimestamp,
|
||||
};
|
||||
|
||||
// Trigger webhooks asynchronously (fire and forget)
|
||||
setImmediate(async () => {
|
||||
// Trigger conversion_event webhooks (attributed installs only)
|
||||
triggerWebhooks(
|
||||
webhooksResult.rows,
|
||||
'conversion_event',
|
||||
eventId,
|
||||
eventData
|
||||
).catch((error) => {
|
||||
fastify.log.error('Failed to trigger conversion webhooks:', error);
|
||||
});
|
||||
|
||||
// Trigger sdk_event webhooks (all SDK-tracked events)
|
||||
triggerWebhooks(
|
||||
webhooksResult.rows,
|
||||
'sdk_event',
|
||||
eventId,
|
||||
eventData
|
||||
).catch((error) => {
|
||||
fastify.log.error('Failed to trigger sdk_event webhooks:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return reply.status(200).send({
|
||||
eventId,
|
||||
acknowledged: true,
|
||||
});
|
||||
} catch (error: any) {
|
||||
fastify.log.error(`Error tracking event: ${error}`);
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to track event',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/sdk/v1/resolve/:shortCode
|
||||
* GET /api/sdk/v1/resolve/:templateSlug/:shortCode
|
||||
*
|
||||
* Resolve a short link to its deep link data without triggering a redirect.
|
||||
* Used by mobile SDKs when the OS intercepts a LinkForty URL via App Links
|
||||
* or Universal Links before the server can process the redirect.
|
||||
*
|
||||
* Also records a click event and stores a device fingerprint for attribution,
|
||||
* since the normal redirect flow was bypassed.
|
||||
*
|
||||
* Query params (optional fingerprint data for click attribution):
|
||||
* - fp_tz: Device timezone
|
||||
* - fp_lang: Device language
|
||||
* - fp_sw: Screen width
|
||||
* - fp_sh: Screen height
|
||||
* - fp_platform: Platform (ios/android)
|
||||
* - fp_pv: Platform version
|
||||
*
|
||||
* Response:
|
||||
* - shortCode: The link's short code
|
||||
* - linkId: UUID of the link
|
||||
* - deepLinkPath: In-app destination path
|
||||
* - appScheme: Custom URI scheme
|
||||
* - iosUrl: iOS App Store URL
|
||||
* - androidUrl: Android Play Store URL
|
||||
* - webUrl: Web fallback URL
|
||||
* - utmParameters: UTM tracking parameters
|
||||
* - customParameters: Custom deep link parameters (key-value pairs)
|
||||
* - clickedAt: Timestamp of this resolution
|
||||
*/
|
||||
async function handleResolve(request: any, reply: any, shortCode: string, templateSlug?: string) {
|
||||
let linkData: string | null = null;
|
||||
|
||||
// Build cache key (same pattern as redirect.ts)
|
||||
const cacheKey = templateSlug ? `link:${templateSlug}:${shortCode}` : `link:${shortCode}`;
|
||||
|
||||
// Try Redis cache first
|
||||
if (fastify.redis) {
|
||||
try {
|
||||
linkData = await fastify.redis.get(cacheKey);
|
||||
} catch (error) {
|
||||
fastify.log.warn('Redis cache lookup failed, falling back to database');
|
||||
}
|
||||
}
|
||||
|
||||
if (!linkData) {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (templateSlug) {
|
||||
query = `
|
||||
SELECT l.* FROM links l
|
||||
LEFT JOIN link_templates t ON l.template_id = t.id
|
||||
WHERE l.short_code = $1 AND t.slug = $2
|
||||
AND l.is_active = true
|
||||
AND (l.expires_at IS NULL OR l.expires_at > NOW())
|
||||
`;
|
||||
params = [shortCode, templateSlug];
|
||||
} else {
|
||||
query = `
|
||||
SELECT * FROM links
|
||||
WHERE short_code = $1 AND is_active = true
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
`;
|
||||
params = [shortCode];
|
||||
}
|
||||
|
||||
const result = await db.query(query, params);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return reply.status(404).send({ error: 'Link not found' });
|
||||
}
|
||||
|
||||
linkData = JSON.stringify(result.rows[0]);
|
||||
|
||||
// Cache for 5 minutes
|
||||
if (fastify.redis) {
|
||||
try {
|
||||
await fastify.redis.setex(cacheKey, 300, linkData);
|
||||
} catch (error) {
|
||||
fastify.log.warn('Redis cache set failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const link = JSON.parse(linkData);
|
||||
|
||||
// Record click event + fingerprint asynchronously (mirrors redirect.ts pattern)
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const userAgent = request.headers['user-agent'] || '';
|
||||
const ip = getClientIp(request);
|
||||
const referrer = request.headers.referer || null;
|
||||
const acceptLanguage = request.headers['accept-language'] || '';
|
||||
|
||||
const deviceType = detectDevice(userAgent);
|
||||
const { platform, platformVersion } = parseUserAgent(userAgent);
|
||||
const { countryCode, countryName, region, city, latitude, longitude, timezone } = getLocationFromIP(ip);
|
||||
|
||||
// Classify bots at ingestion (SIT-298); persisted for consistent reads.
|
||||
const { isBot, reason: botReason } = classifyBot(
|
||||
userAgent,
|
||||
request.method,
|
||||
edgeBotSignal(request.headers['x-lf-bot'])
|
||||
);
|
||||
|
||||
// Extract fingerprint data from query params (sent by SDK)
|
||||
const query = request.query as Record<string, string | undefined>;
|
||||
const fpTimezone = query?.fp_tz || timezone || undefined;
|
||||
const fpLanguage = query?.fp_lang || acceptLanguage.split(',')[0]?.split(';')[0] || undefined;
|
||||
const fpScreenWidth = query?.fp_sw ? parseInt(query.fp_sw, 10) : undefined;
|
||||
const fpScreenHeight = query?.fp_sh ? parseInt(query.fp_sh, 10) : undefined;
|
||||
|
||||
// Insert click event
|
||||
const clickResult = await db.query(
|
||||
`INSERT INTO click_events (
|
||||
link_id, ip_address, user_agent, device_type, platform,
|
||||
country_code, country_name, region, city, latitude, longitude, timezone,
|
||||
utm_source, utm_medium, utm_campaign, referrer, is_bot, bot_reason
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
RETURNING id`,
|
||||
[
|
||||
link.id,
|
||||
ip,
|
||||
userAgent,
|
||||
deviceType,
|
||||
platform,
|
||||
countryCode,
|
||||
countryName,
|
||||
region,
|
||||
city,
|
||||
latitude,
|
||||
longitude,
|
||||
timezone,
|
||||
query?.utm_source || null,
|
||||
query?.utm_medium || null,
|
||||
query?.utm_campaign || null,
|
||||
referrer,
|
||||
isBot,
|
||||
botReason,
|
||||
]
|
||||
);
|
||||
|
||||
const clickId = clickResult.rows[0].id;
|
||||
|
||||
// Store device fingerprint for deferred deep linking
|
||||
const fingerprintData: FingerprintData = {
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
timezone: fpTimezone,
|
||||
language: fpLanguage,
|
||||
screenWidth: fpScreenWidth,
|
||||
screenHeight: fpScreenHeight,
|
||||
platform: deviceType,
|
||||
platformVersion,
|
||||
};
|
||||
|
||||
await storeFingerprintForClick(clickId, fingerprintData);
|
||||
|
||||
// Emit click event for real-time streaming
|
||||
emitClickEvent({
|
||||
eventId: clickId,
|
||||
timestamp: new Date().toISOString(),
|
||||
linkId: link.id,
|
||||
shortCode: link.short_code,
|
||||
userId: link.user_id,
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
country: countryCode || undefined,
|
||||
city: city || undefined,
|
||||
deviceType,
|
||||
platform: platform || undefined,
|
||||
redirectUrl: '',
|
||||
redirectReason: 'sdk_resolve',
|
||||
targetingMatched: true,
|
||||
utmParameters: link.utm_parameters || undefined,
|
||||
referer: referrer || undefined,
|
||||
language: fpLanguage,
|
||||
});
|
||||
|
||||
// Trigger webhooks for click_event
|
||||
try {
|
||||
const webhooksResult = await db.query(
|
||||
'SELECT * FROM webhooks WHERE user_id = $1 AND is_active = true',
|
||||
[link.user_id]
|
||||
);
|
||||
|
||||
if (webhooksResult.rows.length > 0) {
|
||||
const clickEventData = {
|
||||
id: clickId,
|
||||
linkId: link.id,
|
||||
clickedAt: new Date().toISOString(),
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
deviceType,
|
||||
platform,
|
||||
countryCode,
|
||||
countryName,
|
||||
region,
|
||||
city,
|
||||
latitude,
|
||||
longitude,
|
||||
timezone,
|
||||
referrer,
|
||||
};
|
||||
|
||||
await triggerWebhooks(
|
||||
webhooksResult.rows,
|
||||
'click_event',
|
||||
clickId,
|
||||
clickEventData
|
||||
);
|
||||
}
|
||||
} catch (webhookError) {
|
||||
fastify.log.error(`Error triggering click webhooks: ${webhookError}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fastify.log.error(`Error tracking click from resolve: ${error}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Return JSON response with deep link data
|
||||
return reply.status(200).send({
|
||||
shortCode: link.short_code,
|
||||
linkId: link.id,
|
||||
deepLinkPath: link.deep_link_path || undefined,
|
||||
appScheme: link.app_scheme || undefined,
|
||||
iosUrl: link.ios_app_store_url || undefined,
|
||||
androidUrl: link.android_app_store_url || undefined,
|
||||
webUrl: link.web_fallback_url || undefined,
|
||||
utmParameters: link.utm_parameters || undefined,
|
||||
customParameters: link.deep_link_parameters || undefined,
|
||||
clickedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
fastify.get('/api/sdk/v1/resolve/:shortCode', async (request, reply) => {
|
||||
const { shortCode } = request.params as { shortCode: string };
|
||||
return handleResolve(request, reply, shortCode);
|
||||
});
|
||||
|
||||
fastify.get('/api/sdk/v1/resolve/:templateSlug/:shortCode', async (request, reply) => {
|
||||
const { templateSlug, shortCode } = request.params as { templateSlug: string; shortCode: string };
|
||||
return handleResolve(request, reply, shortCode, templateSlug);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/sdk/v1/health
|
||||
* Health check endpoint for SDK connectivity testing
|
||||
*/
|
||||
fastify.get('/api/sdk/v1/health', async (request, reply) => {
|
||||
return reply.status(200).send({
|
||||
status: 'healthy',
|
||||
version: 'v1',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../lib/database.js';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
const generateSlug = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 8);
|
||||
|
||||
const createTemplateSchema = z.object({
|
||||
userId: z.string().uuid().optional(),
|
||||
name: z.string().min(1, 'Template name is required').max(255, 'Name must be less than 255 characters'),
|
||||
description: z.string().optional(),
|
||||
settings: z.object({
|
||||
defaultIosUrl: z.string().url('iOS URL must be a valid URL').optional(),
|
||||
defaultAndroidUrl: z.string().url('Android URL must be a valid URL').optional(),
|
||||
defaultWebFallbackUrl: z.string().url('Web Fallback URL must be a valid URL').optional(),
|
||||
defaultAttributionWindowHours: z.number()
|
||||
.min(1, 'Attribution window must be at least 1 hour')
|
||||
.max(2160, 'Attribution window cannot exceed 2160 hours (90 days)')
|
||||
.optional(),
|
||||
utmParameters: z.object({
|
||||
source: z.string().optional(),
|
||||
medium: z.string().optional(),
|
||||
campaign: z.string().optional(),
|
||||
term: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
}).optional(),
|
||||
targetingRules: z.object({
|
||||
countries: z.array(z.string()).optional(),
|
||||
devices: z.array(z.enum(['ios', 'android', 'web'])).optional(),
|
||||
languages: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
expiresAfterDays: z.number().optional(),
|
||||
}).optional(),
|
||||
isDefault: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const updateTemplateSchema = z.object({
|
||||
name: z.string().min(1, 'Template name is required').max(255, 'Name must be less than 255 characters').optional(),
|
||||
description: z.string().optional(),
|
||||
settings: z.object({
|
||||
defaultIosUrl: z.string().url('iOS URL must be a valid URL').optional(),
|
||||
defaultAndroidUrl: z.string().url('Android URL must be a valid URL').optional(),
|
||||
defaultWebFallbackUrl: z.string().url('Web Fallback URL must be a valid URL').optional(),
|
||||
defaultAttributionWindowHours: z.number()
|
||||
.min(1, 'Attribution window must be at least 1 hour')
|
||||
.max(2160, 'Attribution window cannot exceed 2160 hours (90 days)')
|
||||
.optional(),
|
||||
utmParameters: z.object({
|
||||
source: z.string().optional(),
|
||||
medium: z.string().optional(),
|
||||
campaign: z.string().optional(),
|
||||
term: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
}).optional(),
|
||||
targetingRules: z.object({
|
||||
countries: z.array(z.string()).optional(),
|
||||
devices: z.array(z.enum(['ios', 'android', 'web'])).optional(),
|
||||
languages: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
expiresAfterDays: z.number().optional(),
|
||||
}).optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function templateRoutes(fastify: FastifyInstance) {
|
||||
// Get all templates (optionally filtered by userId)
|
||||
fastify.get('/api/templates', async (request: FastifyRequest<{
|
||||
Querystring: { userId?: string }
|
||||
}>) => {
|
||||
const { userId } = request.query;
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (userId) {
|
||||
query = `
|
||||
SELECT id, user_id, name, slug, description, settings, is_default, created_at, updated_at
|
||||
FROM link_templates
|
||||
WHERE user_id = $1
|
||||
ORDER BY is_default DESC, name ASC
|
||||
`;
|
||||
params = [userId];
|
||||
} else {
|
||||
query = `
|
||||
SELECT id, user_id, name, slug, description, settings, is_default, created_at, updated_at
|
||||
FROM link_templates
|
||||
ORDER BY is_default DESC, name ASC
|
||||
`;
|
||||
params = [];
|
||||
}
|
||||
|
||||
const result = await db.query(query, params);
|
||||
return result.rows;
|
||||
});
|
||||
|
||||
// Get single template
|
||||
fastify.get('/api/templates/:id', async (request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Querystring: { userId?: string };
|
||||
}>) => {
|
||||
const { id } = request.params;
|
||||
const { userId } = request.query;
|
||||
|
||||
let result;
|
||||
if (userId) {
|
||||
result = await db.query(
|
||||
`SELECT id, user_id, name, slug, description, settings, is_default, created_at, updated_at
|
||||
FROM link_templates
|
||||
WHERE id = $1 AND user_id = $2`,
|
||||
[id, userId]
|
||||
);
|
||||
} else {
|
||||
result = await db.query(
|
||||
`SELECT id, user_id, name, slug, description, settings, is_default, created_at, updated_at
|
||||
FROM link_templates
|
||||
WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
// Create template
|
||||
fastify.post('/api/templates', async (request) => {
|
||||
const data = createTemplateSchema.parse(request.body);
|
||||
|
||||
// Generate unique slug
|
||||
let slug = generateSlug();
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < 10) {
|
||||
const existing = await db.query(
|
||||
'SELECT id FROM link_templates WHERE slug = $1',
|
||||
[slug]
|
||||
);
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
slug = generateSlug();
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (attempts >= 10) {
|
||||
throw new Error('Unable to generate unique template slug. Please try again.');
|
||||
}
|
||||
|
||||
// If setting as default, unset other defaults
|
||||
if (data.isDefault) {
|
||||
if (data.userId) {
|
||||
await db.query(
|
||||
'UPDATE link_templates SET is_default = false WHERE user_id = $1',
|
||||
[data.userId]
|
||||
);
|
||||
} else {
|
||||
await db.query(
|
||||
'UPDATE link_templates SET is_default = false WHERE user_id IS NULL'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO link_templates (
|
||||
user_id, name, slug, description, settings, is_default
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, user_id, name, slug, description, settings, is_default, created_at, updated_at`,
|
||||
[
|
||||
data.userId || null,
|
||||
data.name,
|
||||
slug,
|
||||
data.description || null,
|
||||
JSON.stringify(data.settings || {}),
|
||||
data.isDefault,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
// Update template
|
||||
fastify.put('/api/templates/:id', async (request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Querystring: { userId?: string };
|
||||
}>) => {
|
||||
const { id } = request.params;
|
||||
const { userId } = request.query;
|
||||
const data = updateTemplateSchema.parse(request.body);
|
||||
|
||||
// Verify template exists
|
||||
let existingResult;
|
||||
if (userId) {
|
||||
existingResult = await db.query(
|
||||
'SELECT id FROM link_templates WHERE id = $1 AND user_id = $2',
|
||||
[id, userId]
|
||||
);
|
||||
} else {
|
||||
existingResult = await db.query(
|
||||
'SELECT id FROM link_templates WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
if (existingResult.rows.length === 0) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
// If setting as default, unset other defaults
|
||||
if (data.isDefault) {
|
||||
if (userId) {
|
||||
await db.query(
|
||||
'UPDATE link_templates SET is_default = false WHERE user_id = $1 AND id != $2',
|
||||
[userId, id]
|
||||
);
|
||||
} else {
|
||||
await db.query(
|
||||
'UPDATE link_templates SET is_default = false WHERE id != $1',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build update query dynamically
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.name !== undefined) {
|
||||
updates.push(`name = $${paramIndex}`);
|
||||
values.push(data.name);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.description !== undefined) {
|
||||
updates.push(`description = $${paramIndex}`);
|
||||
values.push(data.description);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.settings !== undefined) {
|
||||
updates.push(`settings = $${paramIndex}`);
|
||||
values.push(JSON.stringify(data.settings));
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.isDefault !== undefined) {
|
||||
updates.push(`is_default = $${paramIndex}`);
|
||||
values.push(data.isDefault);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new Error('No updates provided');
|
||||
}
|
||||
|
||||
updates.push('updated_at = NOW()');
|
||||
values.push(id);
|
||||
|
||||
let whereClause = `WHERE id = $${paramIndex}`;
|
||||
if (userId) {
|
||||
values.push(userId);
|
||||
whereClause += ` AND user_id = $${paramIndex + 1}`;
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
`UPDATE link_templates
|
||||
SET ${updates.join(', ')}
|
||||
${whereClause}
|
||||
RETURNING id, user_id, name, slug, description, settings, is_default, created_at, updated_at`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
// Delete template
|
||||
fastify.delete('/api/templates/:id', async (request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Querystring: { userId?: string };
|
||||
}>) => {
|
||||
const { id } = request.params;
|
||||
const { userId } = request.query;
|
||||
|
||||
// Check if template has any links
|
||||
const linksCount = await db.query(
|
||||
'SELECT COUNT(*) as count FROM links WHERE template_id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (parseInt(linksCount.rows[0].count) > 0) {
|
||||
throw new Error('Cannot delete template that has links assigned to it. Please reassign or delete the links first.');
|
||||
}
|
||||
|
||||
let result;
|
||||
if (userId) {
|
||||
result = await db.query(
|
||||
'DELETE FROM link_templates WHERE id = $1 AND user_id = $2 RETURNING id',
|
||||
[id, userId]
|
||||
);
|
||||
} else {
|
||||
result = await db.query(
|
||||
'DELETE FROM link_templates WHERE id = $1 RETURNING id',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Set template as default
|
||||
fastify.put('/api/templates/:id/set-default', async (request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Querystring: { userId?: string };
|
||||
}>) => {
|
||||
const { id } = request.params;
|
||||
const { userId } = request.query;
|
||||
|
||||
// Verify template exists
|
||||
let existingResult;
|
||||
if (userId) {
|
||||
existingResult = await db.query(
|
||||
'SELECT id FROM link_templates WHERE id = $1 AND user_id = $2',
|
||||
[id, userId]
|
||||
);
|
||||
} else {
|
||||
existingResult = await db.query(
|
||||
'SELECT id FROM link_templates WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
if (existingResult.rows.length === 0) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
// Unset all defaults (scoped by userId if provided)
|
||||
if (userId) {
|
||||
await db.query(
|
||||
'UPDATE link_templates SET is_default = false WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
} else {
|
||||
await db.query(
|
||||
'UPDATE link_templates SET is_default = false'
|
||||
);
|
||||
}
|
||||
|
||||
// Set new default
|
||||
const result = await db.query(
|
||||
`UPDATE link_templates
|
||||
SET is_default = true, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, user_id, name, slug, description, settings, is_default, created_at, updated_at`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../lib/database.js';
|
||||
import { generateWebhookSecret } from '../lib/webhook.js';
|
||||
import type { Webhook, WebhookEvent } from '../types/index.js';
|
||||
|
||||
const webhookEventSchema = z.enum(['click_event', 'install_event', 'conversion_event']);
|
||||
|
||||
const createWebhookSchema = z.object({
|
||||
userId: z.string().uuid().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
url: z.string().url(),
|
||||
events: z.array(webhookEventSchema).min(1),
|
||||
headers: z.record(z.string()).optional(),
|
||||
retryCount: z.number().int().min(1).max(10).optional(),
|
||||
timeoutMs: z.number().int().min(1000).max(60000).optional(),
|
||||
});
|
||||
|
||||
const updateWebhookSchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
url: z.string().url().optional(),
|
||||
events: z.array(webhookEventSchema).min(1).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
retryCount: z.number().int().min(1).max(10).optional(),
|
||||
timeoutMs: z.number().int().min(1000).max(60000).optional(),
|
||||
});
|
||||
|
||||
export async function webhookRoutes(fastify: FastifyInstance) {
|
||||
// Get all webhooks (optionally filtered by userId)
|
||||
fastify.get('/api/webhooks', async (request: FastifyRequest<{
|
||||
Querystring: { userId?: string }
|
||||
}>) => {
|
||||
const { userId } = request.query;
|
||||
|
||||
let result;
|
||||
if (userId) {
|
||||
result = await db.query(
|
||||
`SELECT id, user_id, name, url, events, is_active, retry_count, timeout_ms, headers, created_at, updated_at
|
||||
FROM webhooks
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
} else {
|
||||
result = await db.query(
|
||||
`SELECT id, user_id, name, url, events, is_active, retry_count, timeout_ms, headers, created_at, updated_at
|
||||
FROM webhooks
|
||||
ORDER BY created_at DESC`
|
||||
);
|
||||
}
|
||||
|
||||
// Don't expose secrets in list view
|
||||
return result.rows;
|
||||
});
|
||||
|
||||
// Get a single webhook with secret
|
||||
fastify.get('/api/webhooks/:id', async (request: FastifyRequest<{
|
||||
Params: { id: string }
|
||||
Querystring: { userId?: string }
|
||||
}>) => {
|
||||
const { id } = request.params;
|
||||
const { userId } = request.query;
|
||||
|
||||
let result;
|
||||
if (userId) {
|
||||
result = await db.query(
|
||||
'SELECT * FROM webhooks WHERE id = $1 AND user_id = $2',
|
||||
[id, userId]
|
||||
);
|
||||
} else {
|
||||
result = await db.query(
|
||||
'SELECT * FROM webhooks WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Webhook not found');
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
// Create a new webhook
|
||||
fastify.post('/api/webhooks', async (request: FastifyRequest) => {
|
||||
const data = createWebhookSchema.parse(request.body);
|
||||
|
||||
// Generate secure random secret
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO webhooks (user_id, name, url, secret, events, retry_count, timeout_ms, headers)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.userId || null,
|
||||
data.name,
|
||||
data.url,
|
||||
secret,
|
||||
data.events,
|
||||
data.retryCount || 3,
|
||||
data.timeoutMs || 10000,
|
||||
JSON.stringify(data.headers || {}),
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
// Update a webhook
|
||||
fastify.put('/api/webhooks/:id', async (request: FastifyRequest<{
|
||||
Params: { id: string }
|
||||
Querystring: { userId?: string }
|
||||
}>) => {
|
||||
const { id } = request.params;
|
||||
const { userId } = request.query;
|
||||
const data = updateWebhookSchema.parse(request.body);
|
||||
|
||||
// Build dynamic update query
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (data.name !== undefined) {
|
||||
updates.push(`name = $${paramCount++}`);
|
||||
values.push(data.name);
|
||||
}
|
||||
if (data.url !== undefined) {
|
||||
updates.push(`url = $${paramCount++}`);
|
||||
values.push(data.url);
|
||||
}
|
||||
if (data.events !== undefined) {
|
||||
updates.push(`events = $${paramCount++}`);
|
||||
values.push(data.events);
|
||||
}
|
||||
if (data.isActive !== undefined) {
|
||||
updates.push(`is_active = $${paramCount++}`);
|
||||
values.push(data.isActive);
|
||||
}
|
||||
if (data.headers !== undefined) {
|
||||
updates.push(`headers = $${paramCount++}`);
|
||||
values.push(JSON.stringify(data.headers));
|
||||
}
|
||||
if (data.retryCount !== undefined) {
|
||||
updates.push(`retry_count = $${paramCount++}`);
|
||||
values.push(data.retryCount);
|
||||
}
|
||||
if (data.timeoutMs !== undefined) {
|
||||
updates.push(`timeout_ms = $${paramCount++}`);
|
||||
values.push(data.timeoutMs);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
updates.push(`updated_at = NOW()`);
|
||||
values.push(id);
|
||||
|
||||
let whereClause = `WHERE id = $${paramCount++}`;
|
||||
if (userId) {
|
||||
values.push(userId);
|
||||
whereClause += ` AND user_id = $${paramCount}`;
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
`UPDATE webhooks
|
||||
SET ${updates.join(', ')}
|
||||
${whereClause}
|
||||
RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Webhook not found');
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
// Delete a webhook
|
||||
fastify.delete('/api/webhooks/:id', async (request: FastifyRequest<{
|
||||
Params: { id: string }
|
||||
Querystring: { userId?: string }
|
||||
}>) => {
|
||||
const { id } = request.params;
|
||||
const { userId } = request.query;
|
||||
|
||||
let result;
|
||||
if (userId) {
|
||||
result = await db.query(
|
||||
'DELETE FROM webhooks WHERE id = $1 AND user_id = $2 RETURNING id',
|
||||
[id, userId]
|
||||
);
|
||||
} else {
|
||||
result = await db.query(
|
||||
'DELETE FROM webhooks WHERE id = $1 RETURNING id',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Webhook not found');
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Test a webhook
|
||||
fastify.post('/api/webhooks/:id/test', async (request: FastifyRequest<{
|
||||
Params: { id: string }
|
||||
Querystring: { userId?: string }
|
||||
}>) => {
|
||||
const { id } = request.params;
|
||||
const { userId } = request.query;
|
||||
|
||||
// Get webhook
|
||||
let webhookResult;
|
||||
if (userId) {
|
||||
webhookResult = await db.query(
|
||||
'SELECT * FROM webhooks WHERE id = $1 AND user_id = $2',
|
||||
[id, userId]
|
||||
);
|
||||
} else {
|
||||
webhookResult = await db.query(
|
||||
'SELECT * FROM webhooks WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
if (webhookResult.rows.length === 0) {
|
||||
throw new Error('Webhook not found');
|
||||
}
|
||||
|
||||
const webhook: Webhook = webhookResult.rows[0];
|
||||
|
||||
// Create test payload
|
||||
const testPayload = {
|
||||
event: 'click_event' as WebhookEvent,
|
||||
event_id: '00000000-0000-0000-0000-000000000000',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
id: '00000000-0000-0000-0000-000000000000',
|
||||
linkId: '00000000-0000-0000-0000-000000000000',
|
||||
clickedAt: new Date().toISOString(),
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'LinkForty-Test/1.0',
|
||||
deviceType: 'web',
|
||||
platform: 'test',
|
||||
countryCode: 'US',
|
||||
},
|
||||
};
|
||||
|
||||
// Deliver webhook synchronously for testing
|
||||
const { deliverWebhook } = await import('../lib/webhook.js');
|
||||
const result = await deliverWebhook(webhook, testPayload);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
statusCode: result.responseStatus,
|
||||
responseBody: result.responseBody,
|
||||
error: result.errorMessage,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Well-Known Routes for @linkforty/core
|
||||
*
|
||||
* Purpose: Serve domain verification files for iOS Universal Links and Android App Links
|
||||
*
|
||||
* Configuration via Environment Variables:
|
||||
* - IOS_TEAM_ID: Your Apple Developer Team ID (e.g., "ABC123XYZ")
|
||||
* - IOS_BUNDLE_ID: Your iOS app bundle identifier (e.g., "com.example.app")
|
||||
* - ANDROID_PACKAGE_NAME: Your Android package name (e.g., "com.example.app")
|
||||
* - ANDROID_SHA256_FINGERPRINTS: Comma-separated SHA-256 fingerprints (e.g., "AA:BB:CC:...,DD:EE:FF:...")
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
|
||||
export async function wellKnownRoutes(fastify: FastifyInstance) {
|
||||
/**
|
||||
* Apple App Site Association (AASA)
|
||||
* Used by iOS to verify Universal Links
|
||||
* https://developer.apple.com/documentation/xcode/supporting-associated-domains
|
||||
*
|
||||
* Endpoint: GET /.well-known/apple-app-site-association
|
||||
*/
|
||||
fastify.get('/.well-known/apple-app-site-association', async (request, reply) => {
|
||||
const teamId = process.env.IOS_TEAM_ID;
|
||||
const bundleId = process.env.IOS_BUNDLE_ID;
|
||||
|
||||
if (!teamId || !bundleId) {
|
||||
return reply.status(404).send({
|
||||
error: 'Configuration missing',
|
||||
message: 'iOS app configuration not found. Please set IOS_TEAM_ID and IOS_BUNDLE_ID environment variables.',
|
||||
docs: 'https://docs.linkforty.com/guides/sdk-integration#ios-universal-links'
|
||||
});
|
||||
}
|
||||
|
||||
const aasa = {
|
||||
applinks: {
|
||||
apps: [],
|
||||
details: [
|
||||
{
|
||||
appID: `${teamId}.${bundleId}`,
|
||||
paths: ['*'] // Match all paths
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// AASA file must be served:
|
||||
// 1. Without .json extension
|
||||
// 2. With application/json content-type
|
||||
// 3. Over HTTPS (in production)
|
||||
return reply
|
||||
.header('Content-Type', 'application/json')
|
||||
.send(aasa);
|
||||
});
|
||||
|
||||
/**
|
||||
* Digital Asset Links (assetlinks.json)
|
||||
* Used by Android to verify App Links
|
||||
* https://developer.android.com/training/app-links/verify-android-applinks
|
||||
*
|
||||
* Endpoint: GET /.well-known/assetlinks.json
|
||||
*/
|
||||
fastify.get('/.well-known/assetlinks.json', async (request, reply) => {
|
||||
const packageName = process.env.ANDROID_PACKAGE_NAME;
|
||||
const fingerprintsEnv = process.env.ANDROID_SHA256_FINGERPRINTS;
|
||||
|
||||
if (!packageName || !fingerprintsEnv) {
|
||||
return reply.status(404).send({
|
||||
error: 'Configuration missing',
|
||||
message: 'Android app configuration not found. Please set ANDROID_PACKAGE_NAME and ANDROID_SHA256_FINGERPRINTS environment variables.',
|
||||
docs: 'https://docs.linkforty.com/guides/sdk-integration#android-app-links'
|
||||
});
|
||||
}
|
||||
|
||||
// Parse comma-separated fingerprints
|
||||
const fingerprints = fingerprintsEnv
|
||||
.split(',')
|
||||
.map(fp => fp.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (fingerprints.length === 0) {
|
||||
return reply.status(500).send({
|
||||
error: 'Invalid configuration',
|
||||
message: 'ANDROID_SHA256_FINGERPRINTS is empty or invalid. Must be comma-separated list of SHA-256 fingerprints.',
|
||||
example: 'ANDROID_SHA256_FINGERPRINTS=AA:BB:CC:DD:EE:FF:...,11:22:33:44:55:66:...'
|
||||
});
|
||||
}
|
||||
|
||||
const assetlinks = [
|
||||
{
|
||||
relation: ['delegate_permission/common.handle_all_urls'],
|
||||
target: {
|
||||
namespace: 'android_app',
|
||||
package_name: packageName,
|
||||
sha256_cert_fingerprints: fingerprints
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return reply
|
||||
.header('Content-Type', 'application/json')
|
||||
.send(assetlinks);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { initializeDatabase } from '../lib/database.js';
|
||||
|
||||
async function runMigrations() {
|
||||
try {
|
||||
console.log('Starting database migration...');
|
||||
await initializeDatabase();
|
||||
console.log('Database migration completed successfully!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runMigrations();
|
||||
@@ -0,0 +1,280 @@
|
||||
// Template types
|
||||
|
||||
/**
|
||||
* Default settings applied to links created from a template.
|
||||
*/
|
||||
export interface LinkTemplateSettings {
|
||||
defaultIosUrl?: string;
|
||||
defaultAndroidUrl?: string;
|
||||
defaultWebFallbackUrl?: string;
|
||||
defaultAttributionWindowHours?: number;
|
||||
utmParameters?: UTMParameters;
|
||||
targetingRules?: TargetingRules;
|
||||
expiresAfterDays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable link template that pre-populates settings when creating new links.
|
||||
*/
|
||||
export interface LinkTemplate {
|
||||
id: string;
|
||||
userId?: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
settings: LinkTemplateSettings;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTemplateRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
settings?: LinkTemplateSettings;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateTemplateRequest extends Partial<CreateTemplateRequest> {}
|
||||
|
||||
/**
|
||||
* A short link with routing, deep-linking, UTM, targeting, and Open Graph metadata.
|
||||
*/
|
||||
export interface Link {
|
||||
id: string;
|
||||
userId?: string;
|
||||
template_id?: string;
|
||||
template_slug?: string;
|
||||
short_code: string;
|
||||
original_url: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
// App store URLs (renamed from ios_url/android_url for clarity)
|
||||
ios_app_store_url?: string;
|
||||
android_app_store_url?: string;
|
||||
web_fallback_url?: string;
|
||||
// App deep linking configuration
|
||||
app_scheme?: string; // URI scheme (e.g., "myapp" or "com.company.app")
|
||||
ios_universal_link?: string; // iOS Universal Link URL (HTTPS)
|
||||
android_app_link?: string; // Android App Link URL (HTTPS)
|
||||
deep_link_path?: string; // In-app destination path (e.g., "/product/123")
|
||||
deep_link_parameters?: Record<string, any>; // Custom app parameters
|
||||
// Existing fields
|
||||
utmParameters?: UTMParameters;
|
||||
targeting_rules?: TargetingRules;
|
||||
og_title?: string;
|
||||
og_description?: string;
|
||||
og_image_url?: string;
|
||||
og_type?: string;
|
||||
attribution_window_hours?: number;
|
||||
is_active: boolean;
|
||||
expires_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
click_count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard UTM tracking parameters appended to redirect URLs for campaign attribution.
|
||||
*/
|
||||
export interface UTMParameters {
|
||||
source?: string;
|
||||
medium?: string;
|
||||
campaign?: string;
|
||||
term?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules that control which redirect URL a visitor receives based on their
|
||||
* country, device type, or browser language.
|
||||
*/
|
||||
export interface TargetingRules {
|
||||
countries?: string[];
|
||||
devices?: ('ios' | 'android' | 'web')[];
|
||||
languages?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A recorded click event on a short link, including device, location, and UTM data.
|
||||
*/
|
||||
export interface ClickEvent {
|
||||
id: string;
|
||||
linkId: string;
|
||||
clickedAt: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
deviceType?: string;
|
||||
platform?: string;
|
||||
countryCode?: string;
|
||||
countryName?: string;
|
||||
region?: string;
|
||||
city?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
timezone?: string;
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
referrer?: string;
|
||||
}
|
||||
|
||||
export interface CreateLinkRequest {
|
||||
templateId?: string;
|
||||
originalUrl: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
// App store URLs (renamed from iosUrl/androidUrl for clarity)
|
||||
iosAppStoreUrl?: string;
|
||||
androidAppStoreUrl?: string;
|
||||
webFallbackUrl?: string;
|
||||
// App deep linking configuration
|
||||
appScheme?: string; // URI scheme (e.g., "myapp" or "com.company.app")
|
||||
iosUniversalLink?: string; // iOS Universal Link URL (HTTPS)
|
||||
androidAppLink?: string; // Android App Link URL (HTTPS)
|
||||
deepLinkPath?: string; // In-app destination path (e.g., "/product/123")
|
||||
deepLinkParameters?: Record<string, any>; // Custom app parameters
|
||||
// Existing fields
|
||||
utmParameters?: UTMParameters;
|
||||
targetingRules?: TargetingRules;
|
||||
ogTitle?: string;
|
||||
ogDescription?: string;
|
||||
ogImageUrl?: string;
|
||||
ogType?: string;
|
||||
attributionWindowHours?: number;
|
||||
customCode?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLinkRequest extends Partial<CreateLinkRequest> {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated analytics for one or more links over a time period, broken down
|
||||
* by date, geography, device, browser, UTM parameters, and referrer.
|
||||
*/
|
||||
export interface AnalyticsData {
|
||||
totalClicks: number;
|
||||
uniqueClicks: number;
|
||||
clicksByDate: Array<{ date: string; clicks: number }>;
|
||||
clicksByCountry: Array<{ country: string; countryCode: string; clicks: number }>;
|
||||
clicksByCity: Array<{ city: string; countryCode: string; region: string; clicks: number }>;
|
||||
clicksByRegion: Array<{ region: string; countryCode: string; clicks: number }>;
|
||||
clicksByTimezone: Array<{ timezone: string; clicks: number }>;
|
||||
clicksByDevice: Array<{ device: string; clicks: number }>;
|
||||
clicksByPlatform: Array<{ platform: string; clicks: number }>;
|
||||
clicksByBrowser: Array<{ browser: string; clicks: number }>;
|
||||
clicksByHour: Array<{ hour: number; clicks: number }>;
|
||||
clicksByUtmSource: Array<{ source: string; clicks: number }>;
|
||||
clicksByUtmMedium: Array<{ medium: string; clicks: number }>;
|
||||
clicksByUtmCampaign: Array<{ campaign: string; clicks: number }>;
|
||||
clicksByReferrer: Array<{ source: string; clicks: number }>;
|
||||
topLinks: Array<{
|
||||
id: string;
|
||||
shortCode: string;
|
||||
title: string | null;
|
||||
originalUrl: string;
|
||||
totalClicks: number;
|
||||
uniqueClicks: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Webhook types
|
||||
|
||||
/**
|
||||
* Discriminated event type sent in webhook payloads.
|
||||
* Consumers should filter webhooks by subscribing to specific event types.
|
||||
*/
|
||||
export type WebhookEvent = 'click_event' | 'install_event' | 'conversion_event' | 'sdk_event';
|
||||
|
||||
/**
|
||||
* A registered webhook endpoint that receives event notifications from LinkForty.
|
||||
*/
|
||||
export interface Webhook {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
secret: string;
|
||||
events: WebhookEvent[];
|
||||
is_active: boolean;
|
||||
retry_count: number;
|
||||
timeout_ms: number;
|
||||
headers: Record<string, string>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateWebhookRequest {
|
||||
name: string;
|
||||
url: string;
|
||||
events: WebhookEvent[];
|
||||
headers?: Record<string, string>;
|
||||
retryCount?: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface UpdateWebhookRequest {
|
||||
name?: string;
|
||||
url?: string;
|
||||
events?: WebhookEvent[];
|
||||
isActive?: boolean;
|
||||
headers?: Record<string, string>;
|
||||
retryCount?: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The JSON body delivered to a webhook endpoint for every event.
|
||||
*/
|
||||
export interface WebhookPayload {
|
||||
event: WebhookEvent;
|
||||
event_id: string;
|
||||
timestamp: string;
|
||||
data: ClickEvent | InstallEvent | ConversionEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of a single webhook delivery attempt, including HTTP status and retry info.
|
||||
*/
|
||||
export interface WebhookDeliveryResult {
|
||||
success: boolean;
|
||||
webhookId: string;
|
||||
eventType: WebhookEvent;
|
||||
eventId: string;
|
||||
responseStatus?: number;
|
||||
responseBody?: string;
|
||||
attemptNumber: number;
|
||||
deliveredAt?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An app install event, optionally attributed to a prior click via device fingerprinting.
|
||||
*/
|
||||
export interface InstallEvent {
|
||||
id: string;
|
||||
linkId?: string;
|
||||
fingerprintHash: string;
|
||||
confidenceScore?: number;
|
||||
installedAt: string;
|
||||
deepLinkData?: any;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A post-install in-app conversion event (e.g., purchase, sign-up) tied to an install.
|
||||
*/
|
||||
export interface ConversionEvent {
|
||||
id: string;
|
||||
installId: string;
|
||||
eventName: string;
|
||||
eventProperties: Record<string, any>;
|
||||
revenue?: number;
|
||||
currency?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user