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:
Garfield
2026-06-09 14:22:58 -04:00
parent 95b4138f87
commit 5effb41af4
4 changed files with 419 additions and 0 deletions

View File

@@ -164,10 +164,46 @@ export async function initDatabase(): Promise<void> {
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS tracking_links (
token VARCHAR(32) PRIMARY KEY,
customer_id VARCHAR(255) NOT NULL,
draft_id INT NULL,
destination_url VARCHAR(2048) NOT NULL,
click_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
expires_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_customer_created (customer_id, created_at),
INDEX idx_draft (draft_id)
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS post_drafts (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id VARCHAR(255) NOT NULL,
draft_text_social TEXT NOT NULL,
draft_text_wa TEXT NOT NULL,
destination_url VARCHAR(512) NULL,
utm_token VARCHAR(32) NULL,
status ENUM('pending','approved','rejected','expired','publish_error') DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
approved_at TIMESTAMP NULL,
INDEX idx_tenant_status (tenant_id, status),
INDEX idx_tenant_created (tenant_id, created_at)
)
`);
// Ensure new columns on existing customers table
await ensureColumn(db, 'customers', 'role', "ENUM('user','admin') DEFAULT 'user'");
await ensureColumn(db, 'customers', 'reset_token', 'VARCHAR(255) NULL');
await ensureColumn(db, 'customers', 'reset_expires_at', 'TIMESTAMP NULL');
await ensureColumn(db, 'customers', 'pilot_active', 'TINYINT(1) DEFAULT 0');
await ensureColumn(db, 'customers', 'pilot_paused', 'TINYINT(1) DEFAULT 0');
await ensureColumn(db, 'customers', 'pilot_end_date', 'DATE NULL');
await ensureColumn(db, 'customers', 'pilot_owner_phone', 'VARCHAR(32) NULL');
await ensureColumn(db, 'customers', 'industry', 'VARCHAR(128) NULL');
await ensureColumn(db, 'customers', 'location', 'VARCHAR(128) NULL');
// Remove duplicate (customer_id, period_start) rows before adding unique constraint
// Keeps the earliest invoice (lowest id) for each customer+period pair

View File

@@ -38,6 +38,7 @@ import { notifyNewPilotRequest } from './notifications/index.js';
import redis from './redis.js';
import { handleChat, type ChatMessage } from './chat.js';
import { startEmailPoller } from './email-poller.js';
import { isValidTokenFormat, getTrackingLink, recordClick, isBot } from './tracking-links.js';
import { sendChatEscalationAlert } from './notifications/slack.js';
import { sendEmail } from './smtp.js';
@@ -1238,6 +1239,86 @@ app.get('/api/whatsapp/templates', requireAuth, async (req, res) => {
}
});
// ── Email REST endpoints ────────────────────────────────────────
app.get('/api/email/profile', requireAuth, async (req, res) => {
const account = req.query.account as string | undefined;
try {
const result = await callTool(req, 'get_profile', { account });
res.json(parseToolResult(result));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.get('/api/email/search', requireAuth, async (req, res) => {
const q = req.query.q as string | undefined;
if (!q) { res.status(400).json({ error: 'q is required' }); return; }
const maxResults = req.query.maxResults ? Number(req.query.maxResults) : 20;
const account = req.query.account as string | undefined;
const folder = req.query.folder as string | undefined;
try {
const result = await callTool(req, 'search_messages', { q, maxResults, account, folder });
res.json(parseToolResult(result));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.get('/api/email/read', requireAuth, async (req, res) => {
const uid = req.query.uid ? Number(req.query.uid) : undefined;
if (!uid || isNaN(uid)) { res.status(400).json({ error: 'uid is required' }); return; }
const account = req.query.account as string | undefined;
const folder = req.query.folder as string | undefined;
try {
const result = await callTool(req, 'read_message', { uid, account, folder });
res.json(parseToolResult(result));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.post('/api/email/send', requireAuth, async (req, res) => {
const { to, subject, body, account } = req.body as Record<string, unknown>;
if (!to || !subject || !body) { res.status(400).json({ error: 'to, subject, and body are required' }); return; }
try {
const result = await callTool(req, 'send_email', { to, subject, body, account });
res.json(parseToolResult(result));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// ── Tracking link redirect (public — no auth) ───────────────────
app.get('/t/:token', async (req, res) => {
const { token } = req.params;
if (!isValidTokenFormat(token)) {
res.status(404).send('Not found');
return;
}
let link;
try {
link = await getTrackingLink(token);
} catch (err) {
console.error('[tracking] lookup error:', err);
res.status(500).send('Error');
return;
}
if (!link) {
res.status(404).send('Not found');
return;
}
// Redirect first — analytics must never block the visitor
res.redirect(302, link.destinationUrl);
if (!isBot(req.headers['user-agent'] ?? '')) {
recordClick(token);
}
});
// ── WhatsApp webhook (multi-tenant) ─────────────────────────────
async function handleInboundWhatsAppMessage(event: RoutedWebhookEvent): Promise<void> {
console.log(`[webhook/whatsapp] inbound message from=${event.message.from} customer=${event.customerId} type=${event.message.type}`);

200
src/tracking-links.test.ts Normal file
View File

@@ -0,0 +1,200 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('./db.js', () => ({
getPool: vi.fn(),
}));
import { getPool } from './db.js';
import {
isValidTokenFormat,
isBot,
validateDestinationUrl,
createTrackingLink,
getTrackingLink,
recordClick,
} from './tracking-links.js';
// ── isValidTokenFormat ────────────────────────────────────────────────────────
describe('isValidTokenFormat', () => {
it('accepts a 32-char base64url string', () => {
expect(isValidTokenFormat('abcdefghijklmnopqrstuvwxyz123456')).toBe(true);
expect(isValidTokenFormat('ABCDEFGHIJKLMNOPQRSTUVWXYZ-_1234')).toBe(true);
});
it('rejects wrong length', () => {
expect(isValidTokenFormat('short')).toBe(false);
expect(isValidTokenFormat('a'.repeat(33))).toBe(false);
});
it('rejects non-base64url characters', () => {
expect(isValidTokenFormat('a'.repeat(31) + '!')).toBe(false);
expect(isValidTokenFormat('a'.repeat(31) + '+')).toBe(false);
});
});
// ── isBot ─────────────────────────────────────────────────────────────────────
describe('isBot', () => {
it('flags known bot user agents', () => {
expect(isBot('Googlebot/2.1')).toBe(true);
expect(isBot('facebookexternalhit/1.1')).toBe(true);
expect(isBot('AhrefsBot/7.0')).toBe(true);
});
it('passes real browser user agents', () => {
expect(isBot('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)')).toBe(false);
expect(isBot('Mozilla/5.0 (Windows NT 10.0) Chrome/120')).toBe(false);
});
});
// ── validateDestinationUrl ────────────────────────────────────────────────────
describe('validateDestinationUrl', () => {
it('accepts http and https URLs', () => {
expect(() => validateDestinationUrl('https://calendly.com/alex/30min')).not.toThrow();
expect(() => validateDestinationUrl('http://example.com')).not.toThrow();
});
it('rejects non-http protocols', () => {
expect(() => validateDestinationUrl('javascript:alert(1)')).toThrow();
expect(() => validateDestinationUrl('ftp://example.com')).toThrow();
});
it('rejects URLs with embedded credentials', () => {
expect(() => validateDestinationUrl('https://user:pass@example.com')).toThrow('credentials');
});
it('rejects URLs over 2048 chars', () => {
expect(() => validateDestinationUrl('https://example.com/' + 'a'.repeat(2048))).toThrow('too long');
});
it('rejects malformed URLs', () => {
expect(() => validateDestinationUrl('not a url')).toThrow();
});
});
// ── createTrackingLink ────────────────────────────────────────────────────────
describe('createTrackingLink', () => {
const mockQuery = vi.fn();
beforeEach(() => {
mockQuery.mockClear();
vi.mocked(getPool).mockReturnValue({ query: mockQuery } as any);
mockQuery.mockResolvedValue([{ insertId: 1, affectedRows: 1 }]);
});
it('returns a 32-char token on success', async () => {
const token = await createTrackingLink({
customerId: 'cust-1',
draftId: 42,
destinationUrl: 'https://calendly.com/alex',
expiresAt: null,
});
expect(token).toHaveLength(32);
expect(/^[A-Za-z0-9_-]{32}$/.test(token)).toBe(true);
});
it('retries on duplicate key error and succeeds', async () => {
mockQuery
.mockRejectedValueOnce({ code: 'ER_DUP_ENTRY' })
.mockResolvedValueOnce([{ insertId: 1 }]);
const token = await createTrackingLink({
customerId: 'cust-1',
draftId: null,
destinationUrl: 'https://example.com',
expiresAt: null,
});
expect(token).toHaveLength(32);
expect(mockQuery).toHaveBeenCalledTimes(2);
});
it('throws when destination URL is invalid', async () => {
await expect(
createTrackingLink({
customerId: 'cust-1',
draftId: null,
destinationUrl: 'javascript:alert(1)',
expiresAt: null,
})
).rejects.toThrow();
expect(mockQuery).not.toHaveBeenCalled();
});
});
// ── getTrackingLink ───────────────────────────────────────────────────────────
describe('getTrackingLink', () => {
const mockQuery = vi.fn();
beforeEach(() => {
vi.mocked(getPool).mockReturnValue({ query: mockQuery } as any);
});
const baseRow = {
token: 'a'.repeat(32),
customer_id: 'cust-1',
draft_id: 7,
destination_url: 'https://calendly.com/alex',
click_count: 5,
expires_at: null,
created_at: new Date('2026-06-09'),
};
it('returns a tracking link for a known token', async () => {
mockQuery.mockResolvedValue([[baseRow]]);
const link = await getTrackingLink('a'.repeat(32));
expect(link).not.toBeNull();
expect(link!.destinationUrl).toBe('https://calendly.com/alex');
expect(link!.clickCount).toBe(5);
});
it('returns null for an unknown token', async () => {
mockQuery.mockResolvedValue([[]]);
expect(await getTrackingLink('b'.repeat(32))).toBeNull();
});
it('returns null for an expired token', async () => {
mockQuery.mockResolvedValue([[{ ...baseRow, expires_at: new Date('2020-01-01') }]]);
expect(await getTrackingLink('a'.repeat(32))).toBeNull();
});
it('returns a link when expires_at is null (no expiry)', async () => {
mockQuery.mockResolvedValue([[{ ...baseRow, expires_at: null }]]);
const link = await getTrackingLink('a'.repeat(32));
expect(link).not.toBeNull();
});
it('returns a link when expires_at is in the future', async () => {
const future = new Date(Date.now() + 86400_000);
mockQuery.mockResolvedValue([[{ ...baseRow, expires_at: future }]]);
const link = await getTrackingLink('a'.repeat(32));
expect(link).not.toBeNull();
});
});
// ── recordClick ───────────────────────────────────────────────────────────────
describe('recordClick', () => {
const mockQuery = vi.fn();
beforeEach(() => {
vi.mocked(getPool).mockReturnValue({ query: mockQuery } as any);
});
it('fires an UPDATE without throwing', () => {
mockQuery.mockResolvedValue([{ affectedRows: 1 }]);
expect(() => recordClick('a'.repeat(32))).not.toThrow();
});
it('swallows DB errors — does not propagate', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockQuery.mockRejectedValue(new Error('DB down'));
recordClick('a'.repeat(32));
// Give the microtask queue a tick to process the rejected promise
await new Promise(r => setTimeout(r, 0));
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('[tracking]'),
expect.any(Error)
);
consoleSpy.mockRestore();
});
});

102
src/tracking-links.ts Normal file
View 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));
}