- src/tracking-links.ts — create, get, record click, validate URL, bot detection - src/tracking-links.test.ts — 20 tests, all passing - src/db.ts — tracking_links table, post_drafts table, 6 pilot columns on customers - src/index.ts — public GET /t/:token redirect route (no auth) Analytics (click count) is fire-and-forget after redirect. MySQL is source of truth; redirect never depends on Redis. Related: SquareMCP/2026-06-09-tracking-links-deployment.md
103 lines
3.1 KiB
TypeScript
103 lines
3.1 KiB
TypeScript
import crypto from 'crypto';
|
|
import { getPool } from './db.js';
|
|
import type { RowDataPacket } from 'mysql2';
|
|
|
|
const TOKEN_RE = /^[A-Za-z0-9_-]{32}$/;
|
|
const BOT_RE = /bot|crawl|spider|slurp|facebookexternalhit/i;
|
|
|
|
export interface TrackingLink {
|
|
token: string;
|
|
customerId: string;
|
|
draftId: number | null;
|
|
destinationUrl: string;
|
|
clickCount: number;
|
|
expiresAt: Date | null;
|
|
createdAt: Date;
|
|
}
|
|
|
|
interface TrackingLinkRow extends RowDataPacket {
|
|
token: string;
|
|
customer_id: string;
|
|
draft_id: number | null;
|
|
destination_url: string;
|
|
click_count: number;
|
|
expires_at: Date | null;
|
|
created_at: Date;
|
|
}
|
|
|
|
export function isValidTokenFormat(token: string): boolean {
|
|
return TOKEN_RE.test(token);
|
|
}
|
|
|
|
export function isBot(userAgent: string): boolean {
|
|
return BOT_RE.test(userAgent);
|
|
}
|
|
|
|
export function validateDestinationUrl(url: string): void {
|
|
if (url.length > 2048) throw new Error('Destination URL too long');
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
throw new Error('Invalid destination URL');
|
|
}
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
throw new Error('Destination URL must use http or https');
|
|
}
|
|
if (parsed.username || parsed.password) {
|
|
throw new Error('Destination URL must not contain credentials');
|
|
}
|
|
}
|
|
|
|
export async function createTrackingLink(params: {
|
|
customerId: string;
|
|
draftId: number | null;
|
|
destinationUrl: string;
|
|
expiresAt: Date | null;
|
|
}): Promise<string> {
|
|
validateDestinationUrl(params.destinationUrl);
|
|
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
const token = crypto.randomBytes(24).toString('base64url');
|
|
try {
|
|
await getPool().query(
|
|
`INSERT INTO tracking_links (token, customer_id, draft_id, destination_url, expires_at)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
[token, params.customerId, params.draftId ?? null, params.destinationUrl, params.expiresAt]
|
|
);
|
|
return token;
|
|
} catch (err: unknown) {
|
|
const mysqlErr = err as { code?: string };
|
|
if (mysqlErr.code === 'ER_DUP_ENTRY' && attempt < 2) continue;
|
|
throw err;
|
|
}
|
|
}
|
|
throw new Error('Failed to generate unique tracking token after 3 attempts');
|
|
}
|
|
|
|
export async function getTrackingLink(token: string): Promise<TrackingLink | null> {
|
|
const [rows] = await getPool().query<TrackingLinkRow[]>(
|
|
'SELECT token, customer_id, draft_id, destination_url, click_count, expires_at, created_at FROM tracking_links WHERE token = ?',
|
|
[token]
|
|
);
|
|
const row = rows[0];
|
|
if (!row) return null;
|
|
if (row.expires_at && row.expires_at < new Date()) return null;
|
|
return {
|
|
token: row.token,
|
|
customerId: row.customer_id,
|
|
draftId: row.draft_id,
|
|
destinationUrl: row.destination_url,
|
|
clickCount: Number(row.click_count),
|
|
expiresAt: row.expires_at,
|
|
createdAt: row.created_at,
|
|
};
|
|
}
|
|
|
|
// Fire-and-forget — must be called AFTER res.redirect() is sent
|
|
export function recordClick(token: string): void {
|
|
getPool()
|
|
.query('UPDATE tracking_links SET click_count = click_count + 1 WHERE token = ?', [token])
|
|
.catch(err => console.error('[tracking] click record failed:', err));
|
|
}
|