From 11efa9411653d875ea900b50b274445e28aac702 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 4 Jan 2026 20:14:02 -0500 Subject: [PATCH] Add outreach analytics dashboard and response tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add API endpoints for response rates by goal type and time-series stats - Add migration 136_outreach_goal_stats.sql with analytics views - Enhance user context page with detailed outreach history and thread links - Add Analytics tab to admin-outreach.html with goal performance metrics - Fix null check in getOutreachTimeStats() for empty data - Use encodeURIComponent for thread_id in URLs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/brave-emus-invite.md | 2 + server/public/admin-outreach.html | 138 ++++++++++++++++++ server/public/admin-users.html | 45 +++++- server/src/db/insights-db.ts | 137 +++++++++++++++++ .../db/migrations/136_outreach_goal_stats.sql | 76 ++++++++++ server/src/routes/admin-insights.ts | 22 +++ server/src/routes/admin.ts | 27 ++++ 7 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 .changeset/brave-emus-invite.md create mode 100644 server/src/db/migrations/136_outreach_goal_stats.sql diff --git a/.changeset/brave-emus-invite.md b/.changeset/brave-emus-invite.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/brave-emus-invite.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/public/admin-outreach.html b/server/public/admin-outreach.html index 81c1fb6281..b0ee31d8be 100644 --- a/server/public/admin-outreach.html +++ b/server/public/admin-outreach.html @@ -411,6 +411,7 @@

Proactive Outreach

+
@@ -439,6 +440,56 @@

Proactive Outreach

+ +
+ +
+

Performance Over Time

+
+
+
Today
+
—
+
0 responded
+
+
+
This Week
+
—
+
0 responded
+
+
+
This Month
+
—
+
0 responded
+
+
+
All Time
+
—
+
0% response rate
+
+
+
+ + +
+

Response Rates by Goal

+
Loading goal statistics...
+ + + + + + + + + + + + + + +
+
+
@@ -527,6 +578,7 @@

New Variant

loadHistory(), loadVariants(), loadTestAccounts(), + loadAnalytics(), ]); } @@ -916,6 +968,92 @@

New Variant

return div.innerHTML; } + // Load analytics data (time stats + goal stats) + async function loadAnalytics() { + try { + const [timeResponse, goalResponse] = await Promise.all([ + fetch('/api/admin/outreach/stats/time-series'), + fetch('/api/admin/outreach/stats/by-goal'), + ]); + + // Load time stats + if (timeResponse.ok) { + const timeStats = await timeResponse.json(); + document.getElementById('time-stat-today-sent').textContent = timeStats.sent_today || 0; + document.getElementById('time-stat-today-responded').textContent = timeStats.responded_today || 0; + document.getElementById('time-stat-week-sent').textContent = timeStats.sent_this_week || 0; + document.getElementById('time-stat-week-responded').textContent = timeStats.responded_this_week || 0; + document.getElementById('time-stat-month-sent').textContent = timeStats.sent_this_month || 0; + document.getElementById('time-stat-month-responded').textContent = timeStats.responded_this_month || 0; + document.getElementById('time-stat-total-sent').textContent = timeStats.total_sent || 0; + document.getElementById('time-stat-total-rate').textContent = timeStats.overall_response_rate_pct || 0; + } + + // Load goal stats + const loading = document.getElementById('goal-stats-loading'); + const table = document.getElementById('goal-stats-table'); + const empty = document.getElementById('goal-stats-empty'); + const tbody = document.getElementById('goal-stats-body'); + + loading.style.display = 'none'; + + if (goalResponse.ok) { + const goalStats = await goalResponse.json(); + + if (goalStats.length === 0) { + empty.style.display = 'block'; + return; + } + + tbody.innerHTML = goalStats.map(goal => { + // Build sentiment bar + const total = goal.positive_responses + goal.neutral_responses + goal.negative_responses + goal.refusal_responses; + const sentimentBar = total > 0 ? ` +
+ ${goal.positive_responses > 0 ? `
` : ''} + ${goal.neutral_responses > 0 ? `
` : ''} + ${goal.negative_responses > 0 ? `
` : ''} + ${goal.refusal_responses > 0 ? `
` : ''} +
+
+ ${goal.positive_responses > 0 ? `${goal.positive_responses} + ` : ''} + ${goal.neutral_responses > 0 ? `${goal.neutral_responses} ~ ` : ''} + ${goal.negative_responses > 0 ? `${goal.negative_responses} - ` : ''} + ${goal.refusal_responses > 0 ? `${goal.refusal_responses} x` : ''} +
+ ` : 'No responses'; + + const responseRate = goal.response_rate_pct !== null ? `${goal.response_rate_pct}%` : '—'; + const insightRate = goal.insight_conversion_rate_pct !== null ? `(${goal.insight_conversion_rate_pct}% conversion)` : ''; + + return ` + +
${escapeHtml(goal.goal_name)}
+ ${goal.is_enabled ? '' : 'Disabled'} + + ${goal.total_sent} + ${goal.total_responded} + + + ${responseRate} + + + + ${goal.total_insights} + ${insightRate} + + ${sentimentBar} + `; + }).join(''); + + table.style.display = 'table'; + } + } catch (err) { + console.error('Failed to load analytics:', err); + document.getElementById('goal-stats-loading').textContent = 'Failed to load analytics'; + } + } + // Initialize init(); diff --git a/server/public/admin-users.html b/server/public/admin-users.html index 8392505901..859fae76f7 100644 --- a/server/public/admin-users.html +++ b/server/public/admin-users.html @@ -2160,8 +2160,51 @@

Strategic Insights

Opted Out
`; } + html += '
'; - html += ''; + // Detailed outreach history with conversation threads + if (context.outreach_history && context.outreach_history.length > 0) { + html += '
'; + context.outreach_history.forEach(outreach => { + const date = new Date(outreach.sent_at).toLocaleDateString(); + const goalName = outreach.goal_name || 'General'; + const hasResponse = outreach.user_responded; + const sentiment = outreach.response_sentiment; + const intent = outreach.response_intent; + + // Sentiment badge colors + let sentimentBadge = ''; + if (sentiment) { + const colors = { + positive: 'background: var(--color-success-100); color: var(--color-success-700);', + neutral: 'background: var(--color-gray-100); color: var(--color-gray-700);', + negative: 'background: var(--color-warning-100); color: var(--color-warning-700);', + refusal: 'background: var(--color-error-100); color: var(--color-error-700);' + }; + sentimentBadge = `${sentiment}`; + } + + // Thread link if available + const threadLink = outreach.thread_id + ? `View Thread (${outreach.thread_message_count || 0} msgs)` + : ''; + + html += `
+
+ ${escapeHtml(goalName)} + ${date} +
+
+ ${hasResponse ? '✓ Responded' : '○ No response'} ${sentimentBadge} +
+ ${outreach.response_text ? `
"${escapeHtml(outreach.response_text.substring(0, 100))}${outreach.response_text.length > 100 ? '...' : ''}"
` : ''} + ${threadLink} +
`; + }); + html += '
'; + } + + html += ''; } // Impersonation Link (if WorkOS user exists) diff --git a/server/src/db/insights-db.ts b/server/src/db/insights-db.ts index a35c0022b0..91d75f3956 100644 --- a/server/src/db/insights-db.ts +++ b/server/src/db/insights-db.ts @@ -286,6 +286,43 @@ export interface OutreachStats { insights_gathered: number; } +export interface OutreachGoalStats { + goal_id: number; + goal_name: string; + goal_question: string; + goal_type: GoalType; + is_enabled: boolean; + total_sent: number; + total_responded: number; + total_insights: number; + response_rate_pct: number | null; + insight_conversion_rate_pct: number | null; + positive_responses: number; + neutral_responses: number; + negative_responses: number; + refusal_responses: number; + converted_count: number; + interested_count: number; + deferred_count: number; + question_count: number; + objection_count: number; + first_outreach_at: Date | null; + last_outreach_at: Date | null; +} + +export interface OutreachTimeStats { + sent_today: number; + responded_today: number; + sent_this_week: number; + responded_this_week: number; + sent_this_month: number; + responded_this_month: number; + total_sent: number; + total_responded: number; + total_insights: number; + overall_response_rate_pct: number | null; +} + // ===================================================== // DATABASE CLASS // ===================================================== @@ -1155,6 +1192,106 @@ export class InsightsDatabase { }; } + /** + * Get outreach response rates broken down by goal + */ + async getOutreachGoalStats(): Promise { + const result = await query<{ + goal_id: string; + goal_name: string; + goal_question: string; + goal_type: GoalType; + is_enabled: boolean; + total_sent: string; + total_responded: string; + total_insights: string; + response_rate_pct: string | null; + insight_conversion_rate_pct: string | null; + positive_responses: string; + neutral_responses: string; + negative_responses: string; + refusal_responses: string; + converted_count: string; + interested_count: string; + deferred_count: string; + question_count: string; + objection_count: string; + first_outreach_at: Date | null; + last_outreach_at: Date | null; + }>('SELECT * FROM outreach_goal_stats ORDER BY total_sent DESC'); + + return result.rows.map(row => ({ + goal_id: parseInt(row.goal_id, 10), + goal_name: row.goal_name, + goal_question: row.goal_question, + goal_type: row.goal_type, + is_enabled: row.is_enabled, + total_sent: parseInt(row.total_sent, 10), + total_responded: parseInt(row.total_responded, 10), + total_insights: parseInt(row.total_insights, 10), + response_rate_pct: row.response_rate_pct ? parseFloat(row.response_rate_pct) : null, + insight_conversion_rate_pct: row.insight_conversion_rate_pct ? parseFloat(row.insight_conversion_rate_pct) : null, + positive_responses: parseInt(row.positive_responses, 10), + neutral_responses: parseInt(row.neutral_responses, 10), + negative_responses: parseInt(row.negative_responses, 10), + refusal_responses: parseInt(row.refusal_responses, 10), + converted_count: parseInt(row.converted_count, 10), + interested_count: parseInt(row.interested_count, 10), + deferred_count: parseInt(row.deferred_count, 10), + question_count: parseInt(row.question_count, 10), + objection_count: parseInt(row.objection_count, 10), + first_outreach_at: row.first_outreach_at, + last_outreach_at: row.last_outreach_at, + })); + } + + /** + * Get outreach time-windowed statistics + */ + async getOutreachTimeStats(): Promise { + const result = await query<{ + sent_today: string; + responded_today: string; + sent_this_week: string; + responded_this_week: string; + sent_this_month: string; + responded_this_month: string; + total_sent: string; + total_responded: string; + total_insights: string; + overall_response_rate_pct: string | null; + }>('SELECT * FROM outreach_time_stats'); + + const row = result.rows[0]; + if (!row) { + return { + sent_today: 0, + responded_today: 0, + sent_this_week: 0, + responded_this_week: 0, + sent_this_month: 0, + responded_this_month: 0, + total_sent: 0, + total_responded: 0, + total_insights: 0, + overall_response_rate_pct: null, + }; + } + + return { + sent_today: parseInt(row.sent_today, 10), + responded_today: parseInt(row.responded_today, 10), + sent_this_week: parseInt(row.sent_this_week, 10), + responded_this_week: parseInt(row.responded_this_week, 10), + sent_this_month: parseInt(row.sent_this_month, 10), + responded_this_month: parseInt(row.responded_this_month, 10), + total_sent: parseInt(row.total_sent, 10), + total_responded: parseInt(row.total_responded, 10), + total_insights: parseInt(row.total_insights, 10), + overall_response_rate_pct: row.overall_response_rate_pct ? parseFloat(row.overall_response_rate_pct) : null, + }; + } + /** * Get recent outreach history for admin dashboard */ diff --git a/server/src/db/migrations/136_outreach_goal_stats.sql b/server/src/db/migrations/136_outreach_goal_stats.sql new file mode 100644 index 0000000000..63ca729be8 --- /dev/null +++ b/server/src/db/migrations/136_outreach_goal_stats.sql @@ -0,0 +1,76 @@ +-- Migration: Outreach response rates by goal type +-- Adds a view and function for analyzing outreach performance by goal + +-- ===================================================== +-- VIEW: Response rates aggregated by insight goal +-- ===================================================== + +CREATE OR REPLACE VIEW outreach_goal_stats AS +SELECT + ig.id as goal_id, + ig.name as goal_name, + ig.question as goal_question, + ig.goal_type, + ig.is_enabled, + COUNT(mo.id) as total_sent, + COUNT(mo.id) FILTER (WHERE mo.user_responded) as total_responded, + COUNT(mo.id) FILTER (WHERE mo.insight_extracted) as total_insights, + ROUND( + 100.0 * COUNT(mo.id) FILTER (WHERE mo.user_responded) / NULLIF(COUNT(mo.id), 0), + 1 + ) as response_rate_pct, + ROUND( + 100.0 * COUNT(mo.id) FILTER (WHERE mo.insight_extracted) / NULLIF(COUNT(mo.id) FILTER (WHERE mo.user_responded), 0), + 1 + ) as insight_conversion_rate_pct, + -- Sentiment breakdown for responses + COUNT(mo.id) FILTER (WHERE mo.response_sentiment = 'positive') as positive_responses, + COUNT(mo.id) FILTER (WHERE mo.response_sentiment = 'neutral') as neutral_responses, + COUNT(mo.id) FILTER (WHERE mo.response_sentiment = 'negative') as negative_responses, + COUNT(mo.id) FILTER (WHERE mo.response_sentiment = 'refusal') as refusal_responses, + -- Intent breakdown + COUNT(mo.id) FILTER (WHERE mo.response_intent = 'converted') as converted_count, + COUNT(mo.id) FILTER (WHERE mo.response_intent = 'interested') as interested_count, + COUNT(mo.id) FILTER (WHERE mo.response_intent = 'deferred') as deferred_count, + COUNT(mo.id) FILTER (WHERE mo.response_intent = 'question') as question_count, + COUNT(mo.id) FILTER (WHERE mo.response_intent = 'objection') as objection_count, + -- Time metrics + MIN(mo.sent_at) as first_outreach_at, + MAX(mo.sent_at) as last_outreach_at +FROM insight_goals ig +LEFT JOIN member_outreach mo ON mo.insight_goal_id = ig.id +GROUP BY ig.id, ig.name, ig.question, ig.goal_type, ig.is_enabled +ORDER BY total_sent DESC; + +-- ===================================================== +-- VIEW: Overall outreach stats with time windows +-- ===================================================== + +CREATE OR REPLACE VIEW outreach_time_stats AS +SELECT + -- Today + COUNT(*) FILTER (WHERE sent_at >= CURRENT_DATE) as sent_today, + COUNT(*) FILTER (WHERE sent_at >= CURRENT_DATE AND user_responded) as responded_today, + -- This week + COUNT(*) FILTER (WHERE sent_at >= CURRENT_DATE - INTERVAL '7 days') as sent_this_week, + COUNT(*) FILTER (WHERE sent_at >= CURRENT_DATE - INTERVAL '7 days' AND user_responded) as responded_this_week, + -- This month + COUNT(*) FILTER (WHERE sent_at >= CURRENT_DATE - INTERVAL '30 days') as sent_this_month, + COUNT(*) FILTER (WHERE sent_at >= CURRENT_DATE - INTERVAL '30 days' AND user_responded) as responded_this_month, + -- All time + COUNT(*) as total_sent, + COUNT(*) FILTER (WHERE user_responded) as total_responded, + COUNT(*) FILTER (WHERE insight_extracted) as total_insights, + -- Response rate + ROUND( + 100.0 * COUNT(*) FILTER (WHERE user_responded) / NULLIF(COUNT(*), 0), + 1 + ) as overall_response_rate_pct +FROM member_outreach; + +-- ===================================================== +-- COMMENTS +-- ===================================================== + +COMMENT ON VIEW outreach_goal_stats IS 'Response rates and sentiment breakdown per insight goal'; +COMMENT ON VIEW outreach_time_stats IS 'Outreach statistics with time-windowed breakdowns'; diff --git a/server/src/routes/admin-insights.ts b/server/src/routes/admin-insights.ts index 37dab6a502..3a7f776f52 100644 --- a/server/src/routes/admin-insights.ts +++ b/server/src/routes/admin-insights.ts @@ -635,6 +635,28 @@ export function createAdminInsightsRouter(): { pageRouter: Router; apiRouter: Ro } }); + // GET /api/admin/outreach/stats/by-goal - Get response rates by goal type + apiRouter.get('/outreach/stats/by-goal', requireAuth, requireAdmin, async (req, res) => { + try { + const stats = await insightsDb.getOutreachGoalStats(); + res.json(stats); + } catch (error) { + logger.error({ err: error }, 'Error getting outreach goal stats'); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + // GET /api/admin/outreach/stats/time-series - Get time-windowed outreach stats + apiRouter.get('/outreach/stats/time-series', requireAuth, requireAdmin, async (req, res) => { + try { + const stats = await insightsDb.getOutreachTimeStats(); + res.json(stats); + } catch (error) { + logger.error({ err: error }, 'Error getting outreach time stats'); + res.status(500).json({ error: 'Internal server error' }); + } + }); + // GET /api/admin/outreach/history - Get recent outreach history apiRouter.get('/outreach/history', requireAuth, requireAdmin, async (req, res) => { try { diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index b37ccfec2f..8c5f20044a 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -226,6 +226,33 @@ export function createAdminRouter(): { pageRouter: Router; apiRouter: Router } { opted_out: row.outreach_opt_out || false, }; } + + // Get detailed outreach history with goals, responses, and linked threads + const outreachHistoryQuery = ` + SELECT + mo.id, + mo.sent_at, + mo.initial_message, + mo.user_responded, + mo.response_received_at, + mo.response_sentiment, + mo.response_intent, + mo.response_text, + mo.thread_id, + mo.dm_channel_id, + ig.name as goal_name, + ig.question as goal_question, + at.message_count as thread_message_count + FROM member_outreach mo + LEFT JOIN insight_goals ig ON ig.id = mo.insight_goal_id + LEFT JOIN addie_threads at ON at.thread_id = mo.thread_id + WHERE mo.slack_user_id = $1 + ORDER BY mo.sent_at DESC + LIMIT 10`; + const historyResult = await pool.query(outreachHistoryQuery, [slackUserId]); + if (historyResult.rows.length > 0) { + (extendedContext as typeof extendedContext & { outreach_history?: unknown }).outreach_history = historyResult.rows; + } } // Get recent conversations (threads) for this user