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
This commit is contained in:
102
src/tracking-links.ts
Normal file
102
src/tracking-links.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user