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:
36
src/db.ts
36
src/db.ts
@@ -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
|
||||
|
||||
81
src/index.ts
81
src/index.ts
@@ -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
200
src/tracking-links.test.ts
Normal 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
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