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

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