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:
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}`);
|
||||
|
||||
Reference in New Issue
Block a user