diff --git a/src/db.ts b/src/db.ts index 1c24e5e..8b2a121 100644 --- a/src/db.ts +++ b/src/db.ts @@ -164,10 +164,46 @@ export async function initDatabase(): Promise { ) `); + 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 diff --git a/src/index.ts b/src/index.ts index 29d12b3..7f5c27e 100644 --- a/src/index.ts +++ b/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; + 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 { console.log(`[webhook/whatsapp] inbound message from=${event.message.from} customer=${event.customerId} type=${event.message.type}`); diff --git a/src/tracking-links.test.ts b/src/tracking-links.test.ts new file mode 100644 index 0000000..5a65080 --- /dev/null +++ b/src/tracking-links.test.ts @@ -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(); + }); +}); diff --git a/src/tracking-links.ts b/src/tracking-links.ts new file mode 100644 index 0000000..1b95cc4 --- /dev/null +++ b/src/tracking-links.ts @@ -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 { + 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 { + const [rows] = await getPool().query( + '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)); +}