Files
hermes-mcp/src/tracking-links.ts
Garfield 5effb41af4 feat(tracking): MySQL-backed tracking links + pilot schema
- 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
2026-06-09 14:22:58 -04:00

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