diff --git a/.changeset/fifty-bars-pump.md b/.changeset/fifty-bars-pump.md
new file mode 100644
index 0000000000..d6ac9145fb
--- /dev/null
+++ b/.changeset/fifty-bars-pump.md
@@ -0,0 +1,4 @@
+---
+---
+
+Remove insight_goals table and consolidate goal management into outreach_goals. Admin insight goals CRUD endpoints and UI have been removed. The insight extractor and passive extraction now read from outreach_goals.
diff --git a/server/public/admin-insight-goals.html b/server/public/admin-insight-goals.html
deleted file mode 100644
index ec4f4dcba0..0000000000
--- a/server/public/admin-insight-goals.html
+++ /dev/null
@@ -1,580 +0,0 @@
-
-
-
-
-
-
- Admin - Insight Goals - AgenticAdvertising.org
-
-
-
-
-
-
-
-
-
-
-
-
-
Loading insight goals...
-
-
-
-
- | Name |
- Question |
- Type |
- Priority |
- Progress |
- Status |
- Actions |
-
-
-
-
-
-
No insight goals defined yet.
-
Create goals to guide what Addie asks members about.
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/server/src/addie/home/builders/admin.ts b/server/src/addie/home/builders/admin.ts
index 53a7dfc714..605e5d509a 100644
--- a/server/src/addie/home/builders/admin.ts
+++ b/server/src/addie/home/builders/admin.ts
@@ -1,17 +1,15 @@
/**
* Admin Panel Builder
*
- * Builds admin-only panel with flagged threads and insight goals.
+ * Builds admin-only panel with flagged threads and outreach goal stats.
*/
import type { AdminPanel, GoalProgress } from '../types.js';
import { AddieDatabase } from '../../../db/addie-db.js';
-import { InsightsDatabase } from '../../../db/insights-db.js';
import { getPool } from '../../../db/client.js';
import { logger } from '../../../logger.js';
const addieDb = new AddieDatabase();
-const insightsDb = new InsightsDatabase();
/**
* Build admin panel with flagged threads, goal progress, and prospect stats
@@ -30,18 +28,30 @@ export async function buildAdminPanel(adminUserId?: string): Promise
logger.warn({ error }, 'Failed to fetch flagged threads count for admin panel');
}
- // Get active insight goals with progress
+ // Get outreach goal stats from the planner's tracking
try {
- const activeGoals = await insightsDb.listGoals({ activeOnly: true });
- for (const goal of activeGoals.slice(0, 5)) {
+ const pool = getPool();
+ const result = await pool.query(`
+ SELECT
+ og.name,
+ COUNT(ugh.id) FILTER (WHERE ugh.status = 'success') as success_count,
+ COUNT(ugh.id) as attempt_count
+ FROM outreach_goals og
+ LEFT JOIN user_goal_history ugh ON ugh.goal_id = og.id
+ WHERE og.is_enabled = TRUE
+ GROUP BY og.id, og.name
+ ORDER BY og.base_priority DESC
+ LIMIT 5
+ `);
+ for (const row of result.rows) {
insightGoals.push({
- goalName: goal.name,
- current: goal.current_response_count,
- target: goal.target_response_count,
+ goalName: row.name,
+ current: parseInt(row.success_count) || 0,
+ target: null, // No fixed target - show success count
});
}
} catch (error) {
- logger.warn({ error }, 'Failed to fetch insight goals for admin panel');
+ logger.warn({ error }, 'Failed to fetch outreach goals for admin panel');
}
// Get prospect stats if we have a user ID
diff --git a/server/src/addie/mcp/admin-tools.ts b/server/src/addie/mcp/admin-tools.ts
index ce3031bf05..fe2464ac4b 100644
--- a/server/src/addie/mcp/admin-tools.ts
+++ b/server/src/addie/mcp/admin-tools.ts
@@ -1562,96 +1562,8 @@ This logs the interaction, analyzes it for learnings, and automatically:
},
// ============================================
- // INSIGHT GOALS MANAGEMENT TOOLS
+ // MEMBER INSIGHT SUMMARY TOOLS
// ============================================
- {
- name: 'list_insight_goals',
- description: `List all insight goals - the questions/topics we're trying to learn about from members.
-
-USE THIS when admin asks:
-- "What are we trying to learn about members?"
-- "What questions should Addie be asking?"
-- "Show me the insight goals"
-- "What member insights are we collecting?"
-
-Returns all configured insight goals with their priority and status.`,
- usage_hints: 'Use to review what information we want to gather from members.',
- input_schema: {
- type: 'object' as const,
- properties: {
- active_only: {
- type: 'boolean',
- description: 'Only show active/enabled goals (default: false - show all)',
- },
- },
- },
- },
- {
- name: 'add_insight_goal',
- description: `Add a new insight goal - a question or topic we want to naturally learn about from members.
-
-USE THIS when admin says:
-- "We should ask members about [topic]"
-- "I want to know what members think about [topic]"
-- "Add a question about [topic] to what Addie asks"
-- "Can we track [topic] for members?"
-
-The goal will be added to Addie's awareness, and she'll naturally try to learn this in conversations.`,
- usage_hints: 'The question should be conversational, not survey-like.',
- input_schema: {
- type: 'object' as const,
- properties: {
- name: {
- type: 'string',
- description: 'Short name for the goal (e.g., "Learn 2026 Plans")',
- },
- question: {
- type: 'string',
- description: 'The question to explore (e.g., "What are you planning for agentic advertising in 2026?")',
- },
- priority: {
- type: 'number',
- description: 'Priority 1-100, higher = more important (default: 50)',
- },
- target_mapped_only: {
- type: 'boolean',
- description: 'Only ask users who have linked their accounts (default: false)',
- },
- },
- required: ['name', 'question'],
- },
- },
- {
- name: 'update_insight_goal',
- description: `Update an existing insight goal - change its priority, enable/disable it, or update the question.
-
-USE THIS when admin says:
-- "Disable the [goal] question"
-- "Make [goal] higher priority"
-- "Change the question for [goal]"`,
- input_schema: {
- type: 'object' as const,
- properties: {
- goal_id: {
- type: 'number',
- description: 'ID of the goal to update',
- },
- is_enabled: {
- type: 'boolean',
- description: 'Enable or disable the goal',
- },
- priority: {
- type: 'number',
- description: 'New priority (1-100)',
- },
- question: {
- type: 'string',
- description: 'Updated question text',
- },
- },
- required: ['goal_id'],
- },
- },
{
name: 'get_insight_summary',
description: `Get a summary of what we've learned from members - collected insights and statistics.
@@ -6279,135 +6191,9 @@ Use add_committee_leader to assign a leader.`;
});
// ============================================
- // INSIGHT GOALS MANAGEMENT HANDLERS
+ // MEMBER INSIGHT SUMMARY HANDLERS
// ============================================
- // List insight goals
- handlers.set('list_insight_goals', async (input) => {
- const adminCheck = requireAdminFromContext();
- if (adminCheck) return adminCheck;
-
- const activeOnly = input.active_only === true;
-
- try {
- const goals = await insightsDb.listGoals({ activeOnly });
-
- if (goals.length === 0) {
- return JSON.stringify({
- success: true,
- message: activeOnly ? 'No active insight goals configured.' : 'No insight goals configured yet.',
- goals: [],
- });
- }
-
- const formatted = goals.map(g => ({
- id: g.id,
- name: g.name,
- question: g.question,
- priority: g.priority,
- is_enabled: g.is_enabled,
- goal_type: g.goal_type,
- response_count: g.current_response_count,
- target: g.target_mapped_only ? 'mapped users only' : g.target_unmapped_only ? 'unmapped users only' : 'all users',
- }));
-
- return JSON.stringify({
- success: true,
- total: goals.length,
- goals: formatted,
- }, null, 2);
- } catch (error) {
- logger.error({ error }, 'Error listing insight goals');
- return '❌ Failed to list insight goals. Please try again.';
- }
- });
-
- // Add insight goal
- handlers.set('add_insight_goal', async (input) => {
- const adminCheck = requireAdminFromContext();
- if (adminCheck) return adminCheck;
-
- const name = input.name as string;
- const question = input.question as string;
- const priority = (input.priority as number) || 50;
- const targetMappedOnly = input.target_mapped_only === true;
-
- if (!name || !question) {
- return '❌ Both name and question are required.';
- }
-
- try {
- const goal = await insightsDb.createGoal({
- name,
- question,
- priority,
- target_mapped_only: targetMappedOnly,
- is_enabled: true,
- created_by: memberContext?.workos_user?.workos_user_id || 'admin',
- });
-
- logger.info({ goalId: goal.id, name }, 'Admin created new insight goal');
-
- return JSON.stringify({
- success: true,
- message: `✅ Created insight goal "${name}" with priority ${priority}. Addie will naturally try to learn this from members.`,
- goal: {
- id: goal.id,
- name: goal.name,
- question: goal.question,
- priority: goal.priority,
- },
- });
- } catch (error) {
- logger.error({ error, name, question }, 'Error creating insight goal');
- return '❌ Failed to create insight goal. Please try again.';
- }
- });
-
- // Update insight goal
- handlers.set('update_insight_goal', async (input) => {
- const adminCheck = requireAdminFromContext();
- if (adminCheck) return adminCheck;
-
- const goalId = input.goal_id as number;
- if (!goalId) {
- return '❌ goal_id is required.';
- }
-
- try {
- const updates: Record = {};
- if (input.is_enabled !== undefined) updates.is_enabled = input.is_enabled;
- if (input.priority !== undefined) updates.priority = input.priority;
- if (input.question !== undefined) updates.question = input.question;
-
- if (Object.keys(updates).length === 0) {
- return '❌ No updates provided. Specify is_enabled, priority, or question.';
- }
-
- const goal = await insightsDb.updateGoal(goalId, updates);
- if (!goal) {
- return `❌ Goal with ID ${goalId} not found.`;
- }
-
- logger.info({ goalId, updates }, 'Admin updated insight goal');
-
- return JSON.stringify({
- success: true,
- message: `✅ Updated insight goal "${goal.name}".`,
- goal: {
- id: goal.id,
- name: goal.name,
- question: goal.question,
- priority: goal.priority,
- is_enabled: goal.is_enabled,
- },
- });
- } catch (error) {
- logger.error({ error, goalId }, 'Error updating insight goal');
- return '❌ Failed to update insight goal. Please try again.';
- }
- });
-
// Get insight summary
handlers.set('get_insight_summary', async (input) => {
const adminCheck = requireAdminFromContext();
diff --git a/server/src/addie/prompts.ts b/server/src/addie/prompts.ts
index b15ddc7ec5..90c6e39725 100644
--- a/server/src/addie/prompts.ts
+++ b/server/src/addie/prompts.ts
@@ -5,7 +5,6 @@
import type { SuggestedPrompt } from './types.js';
import type { MemberContext } from './member-context.js';
import { createLogger } from '../logger.js';
-import { getCachedActiveGoals } from './insights-cache.js';
import {
trimConversationHistory,
getConversationTokenLimit,
@@ -504,22 +503,6 @@ export async function buildDynamicSuggestedPrompts(
): Promise {
const isMapped = !!memberContext?.workos_user?.workos_user_id;
- // Fetch active insight goals with suggested prompts (cached)
- let goalPrompts: SuggestedPrompt[] = [];
- try {
- const goals = await getCachedActiveGoals(isMapped);
- goalPrompts = goals
- .filter(g => g.suggested_prompt_title && g.suggested_prompt_message)
- .sort((a, b) => b.priority - a.priority)
- .slice(0, 2) // Take top 2 goals
- .map(g => ({
- title: g.suggested_prompt_title!,
- message: g.suggested_prompt_message!,
- }));
- } catch (error) {
- logger.warn({ error }, 'Failed to fetch insight goals for suggested prompts');
- }
-
// Not linked - prioritize casual discovery
if (!isMapped) {
const prompts: SuggestedPrompt[] = [
@@ -533,9 +516,6 @@ export async function buildDynamicSuggestedPrompts(
},
];
- // Add goal prompts (e.g., surveys that apply to unmapped users)
- prompts.push(...goalPrompts);
-
prompts.push({
title: 'What is this anyway?',
message: "I keep hearing about agentic advertising but I'm not sure what it actually is",
@@ -569,9 +549,6 @@ export async function buildDynamicSuggestedPrompts(
// Linked non-admin users - personalized prompts
const prompts: SuggestedPrompt[] = [];
- // Add goal prompts first (highest priority)
- prompts.push(...goalPrompts);
-
// Show working groups if they have some, otherwise suggest finding one
if (memberContext.working_groups && memberContext.working_groups.length > 0) {
prompts.push({
diff --git a/server/src/addie/services/insight-extractor.ts b/server/src/addie/services/insight-extractor.ts
index 4e71a80077..5e5be1488f 100644
--- a/server/src/addie/services/insight-extractor.ts
+++ b/server/src/addie/services/insight-extractor.ts
@@ -304,8 +304,6 @@ export async function extractInsights(
});
}
- // Increment goal response count
- await insightsDb.incrementGoalResponseCount(goal.id);
storedGoalResponses.push(goalResponse);
logger.debug(
diff --git a/server/src/db/insights-db.ts b/server/src/db/insights-db.ts
index 53a65c96be..163f6d54b0 100644
--- a/server/src/db/insights-db.ts
+++ b/server/src/db/insights-db.ts
@@ -6,7 +6,6 @@ import { query } from './client.js';
export type InsightConfidence = 'high' | 'medium' | 'low';
export type InsightSourceType = 'conversation' | 'observation' | 'manual';
-export type GoalType = 'campaign' | 'persistent';
export type OutreachType = 'account_link' | 'introduction' | 'insight_goal' | 'custom';
export type OutreachTone = 'casual' | 'professional' | 'brief';
export type OutreachApproach = 'direct' | 'conversational' | 'minimal';
@@ -41,25 +40,18 @@ export interface MemberInsight {
insight_type_name?: string;
}
+/**
+ * InsightGoal - mapped from outreach_goals for passive extraction
+ * Used by the insight extractor to know what to look for in conversations
+ */
export interface InsightGoal {
id: number;
name: string;
- question: string;
- insight_type_id: number | null;
- goal_type: GoalType;
- start_date: Date | null;
- end_date: Date | null;
+ question: string; // Maps from outreach_goals.description
+ insight_type_id: number | null; // Looked up via success_insight_type -> member_insight_types
is_enabled: boolean;
- priority: number;
- target_mapped_only: boolean;
- target_unmapped_only: boolean;
- target_response_count: number | null;
- current_response_count: number;
- suggested_prompt_title: string | null;
- suggested_prompt_message: string | null;
- created_by: string | null;
- created_at: Date;
- updated_at: Date;
+ priority: number; // Maps from base_priority
+ requires_mapped: boolean;
}
export interface OutreachVariant {
@@ -144,23 +136,6 @@ export interface CreateInsightInput {
created_by?: string;
}
-export interface CreateGoalInput {
- name: string;
- question: string;
- insight_type_id?: number;
- goal_type?: GoalType;
- start_date?: Date;
- end_date?: Date;
- is_enabled?: boolean;
- priority?: number;
- target_mapped_only?: boolean;
- target_unmapped_only?: boolean;
- target_response_count?: number;
- suggested_prompt_title?: string;
- suggested_prompt_message?: string;
- created_by?: string;
-}
-
export interface CreateVariantInput {
name: string;
description?: string;
@@ -295,8 +270,8 @@ export interface OutreachStats {
export interface OutreachGoalStats {
goal_id: number;
goal_name: string;
- goal_question: string;
- goal_type: GoalType;
+ goal_question: string | null;
+ goal_type: string;
is_enabled: boolean;
total_sent: number;
total_responded: number;
@@ -576,137 +551,89 @@ export class InsightsDatabase {
return (result.rowCount ?? 0) > 0;
}
- // ============== Insight Goals ==============
+ // ============== Insight Goals (from outreach_goals for passive extraction) ==============
/**
- * Create a new insight goal
- */
- async createGoal(input: CreateGoalInput): Promise {
- const result = await query(
- `INSERT INTO insight_goals (
- name, question, insight_type_id, goal_type, start_date, end_date,
- is_enabled, priority, target_mapped_only, target_unmapped_only,
- target_response_count, suggested_prompt_title, suggested_prompt_message, created_by
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
- RETURNING *`,
- [
- input.name,
- input.question,
- input.insight_type_id || null,
- input.goal_type || 'persistent',
- input.start_date || null,
- input.end_date || null,
- input.is_enabled ?? true,
- input.priority ?? 50,
- input.target_mapped_only ?? false,
- input.target_unmapped_only ?? false,
- input.target_response_count || null,
- input.suggested_prompt_title || null,
- input.suggested_prompt_message || null,
- input.created_by || null,
- ]
- );
- return result.rows[0];
- }
-
- /**
- * Get all insight goals
+ * Get all insight goals (from outreach_goals table)
+ * Used for passive extraction during conversations
*/
async listGoals(options: { activeOnly?: boolean } = {}): Promise {
- let whereClause = '';
- if (options.activeOnly) {
- whereClause = `WHERE is_enabled = TRUE AND (
- goal_type = 'persistent' OR
- (goal_type = 'campaign' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE)
- )`;
- }
+ const whereClause = options.activeOnly ? 'WHERE og.is_enabled = TRUE' : '';
- const result = await query(
- `SELECT * FROM insight_goals ${whereClause} ORDER BY priority DESC, created_at DESC`
+ const result = await query<{
+ id: number;
+ name: string;
+ description: string | null;
+ insight_type_id: number | null;
+ is_enabled: boolean;
+ base_priority: number;
+ requires_mapped: boolean;
+ }>(
+ `SELECT
+ og.id,
+ og.name,
+ og.description,
+ mit.id as insight_type_id,
+ og.is_enabled,
+ og.base_priority,
+ og.requires_mapped
+ FROM outreach_goals og
+ LEFT JOIN member_insight_types mit ON mit.name = og.success_insight_type
+ ${whereClause}
+ ORDER BY og.base_priority DESC, og.created_at DESC`
);
- return result.rows;
+
+ return result.rows.map((row) => ({
+ id: row.id,
+ name: row.name,
+ question: row.description || '', // description serves as the "question" for extraction
+ insight_type_id: row.insight_type_id,
+ is_enabled: row.is_enabled,
+ priority: row.base_priority,
+ requires_mapped: row.requires_mapped,
+ }));
}
/**
- * Get active goals for a specific user context
+ * Get active goals for a specific user context (from outreach_goals table)
+ * Filters by requires_mapped to return appropriate goals for the user type
*/
async getActiveGoalsForUser(isMapped: boolean): Promise {
- const result = await query(
- `SELECT * FROM insight_goals
- WHERE is_enabled = TRUE
- AND (goal_type = 'persistent' OR
- (goal_type = 'campaign' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE))
- AND (
- (target_mapped_only = FALSE AND target_unmapped_only = FALSE) OR
- (target_mapped_only = TRUE AND $1 = TRUE) OR
- (target_unmapped_only = TRUE AND $1 = FALSE)
- )
- ORDER BY priority DESC`,
+ const result = await query<{
+ id: number;
+ name: string;
+ description: string | null;
+ insight_type_id: number | null;
+ is_enabled: boolean;
+ base_priority: number;
+ requires_mapped: boolean;
+ }>(
+ `SELECT
+ og.id,
+ og.name,
+ og.description,
+ mit.id as insight_type_id,
+ og.is_enabled,
+ og.base_priority,
+ og.requires_mapped
+ FROM outreach_goals og
+ LEFT JOIN member_insight_types mit ON mit.name = og.success_insight_type
+ WHERE og.is_enabled = TRUE
+ AND og.description IS NOT NULL
+ AND (og.requires_mapped = FALSE OR og.requires_mapped = $1)
+ ORDER BY og.base_priority DESC`,
[isMapped]
);
- return result.rows;
- }
-
- /**
- * Get a specific goal
- */
- async getGoal(id: number): Promise {
- const result = await query(
- 'SELECT * FROM insight_goals WHERE id = $1',
- [id]
- );
- return result.rows[0] || null;
- }
-
- /**
- * Update an insight goal
- */
- async updateGoal(id: number, updates: Partial): Promise {
- const setClauses: string[] = [];
- const values: unknown[] = [];
- let paramIndex = 1;
-
- const fields: Array = [
- 'name', 'question', 'insight_type_id', 'goal_type', 'start_date', 'end_date',
- 'is_enabled', 'priority', 'target_mapped_only', 'target_unmapped_only',
- 'target_response_count', 'suggested_prompt_title', 'suggested_prompt_message',
- ];
-
- for (const field of fields) {
- if (updates[field] !== undefined) {
- setClauses.push(`${field} = $${paramIndex++}`);
- values.push(updates[field]);
- }
- }
-
- if (setClauses.length === 0) return this.getGoal(id);
-
- setClauses.push('updated_at = NOW()');
- values.push(id);
-
- const result = await query(
- `UPDATE insight_goals SET ${setClauses.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
- values
- );
- return result.rows[0] || null;
- }
- /**
- * Increment goal response count
- */
- async incrementGoalResponseCount(id: number): Promise {
- await query(
- 'UPDATE insight_goals SET current_response_count = current_response_count + 1, updated_at = NOW() WHERE id = $1',
- [id]
- );
- }
-
- /**
- * Delete an insight goal
- */
- async deleteGoal(id: number): Promise {
- const result = await query('DELETE FROM insight_goals WHERE id = $1', [id]);
- return (result.rowCount ?? 0) > 0;
+ return result.rows.map((row) => ({
+ id: row.id,
+ name: row.name,
+ question: row.description || '',
+ insight_type_id: row.insight_type_id,
+ is_enabled: row.is_enabled,
+ priority: row.base_priority,
+ requires_mapped: row.requires_mapped,
+ }));
}
// ============== Outreach Variants ==============
@@ -1252,8 +1179,8 @@ export class InsightsDatabase {
const result = await query<{
goal_id: string;
goal_name: string;
- goal_question: string;
- goal_type: GoalType;
+ goal_question: string | null;
+ goal_type: string;
is_enabled: boolean;
total_sent: string;
total_responded: string;
diff --git a/server/src/db/migrations/165_fix_outreach_goal_stats.sql b/server/src/db/migrations/165_fix_outreach_goal_stats.sql
new file mode 100644
index 0000000000..a64e9afa22
--- /dev/null
+++ b/server/src/db/migrations/165_fix_outreach_goal_stats.sql
@@ -0,0 +1,55 @@
+-- Migration: Fix outreach_goal_stats view to use planner tables
+--
+-- The original view joined insight_goals with member_outreach via insight_goal_id,
+-- but the planner-based outreach (the default) uses outreach_goals and user_goal_history.
+-- The planner never sets insight_goal_id, so stats always show 0s.
+--
+-- This migration updates the view to join through user_goal_history.outreach_id
+-- to get the actual outreach data linked to planner goals.
+
+-- =====================================================
+-- VIEW: Response rates aggregated by outreach goal
+-- =====================================================
+
+-- Drop the old view first (column types are changing)
+DROP VIEW IF EXISTS outreach_goal_stats;
+
+CREATE VIEW outreach_goal_stats AS
+SELECT
+ og.id as goal_id,
+ og.name as goal_name,
+ og.description as goal_question,
+ og.category as goal_type,
+ og.is_enabled,
+ COUNT(ugh.id) as total_sent,
+ COUNT(ugh.id) FILTER (WHERE ugh.status IN ('responded', 'success', 'declined')) as total_responded,
+ COUNT(ugh.id) FILTER (WHERE ugh.status = 'success') as total_insights,
+ ROUND(
+ 100.0 * COUNT(ugh.id) FILTER (WHERE ugh.status IN ('responded', 'success', 'declined')) / NULLIF(COUNT(ugh.id), 0),
+ 1
+ ) as response_rate_pct,
+ ROUND(
+ 100.0 * COUNT(ugh.id) FILTER (WHERE ugh.status = 'success') / NULLIF(COUNT(ugh.id) FILTER (WHERE ugh.status IN ('responded', 'success', 'declined')), 0),
+ 1
+ ) as insight_conversion_rate_pct,
+ -- Sentiment breakdown for responses (from member_outreach via outreach_id)
+ 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 (from user_goal_history or member_outreach)
+ COUNT(COALESCE(ugh.response_intent, mo.response_intent)) FILTER (WHERE COALESCE(ugh.response_intent, mo.response_intent) = 'converted') as converted_count,
+ COUNT(COALESCE(ugh.response_intent, mo.response_intent)) FILTER (WHERE COALESCE(ugh.response_intent, mo.response_intent) = 'interested') as interested_count,
+ COUNT(COALESCE(ugh.response_intent, mo.response_intent)) FILTER (WHERE COALESCE(ugh.response_intent, mo.response_intent) = 'deferred') as deferred_count,
+ COUNT(COALESCE(ugh.response_intent, mo.response_intent)) FILTER (WHERE COALESCE(ugh.response_intent, mo.response_intent) = 'question') as question_count,
+ COUNT(COALESCE(ugh.response_intent, mo.response_intent)) FILTER (WHERE COALESCE(ugh.response_intent, mo.response_intent) = 'objection') as objection_count,
+ -- Time metrics
+ MIN(ugh.created_at) as first_outreach_at,
+ MAX(ugh.last_attempt_at) as last_outreach_at
+FROM outreach_goals og
+LEFT JOIN user_goal_history ugh ON ugh.goal_id = og.id
+LEFT JOIN member_outreach mo ON mo.id = ugh.outreach_id
+GROUP BY og.id, og.name, og.description, og.category, og.is_enabled
+ORDER BY COUNT(ugh.id) DESC;
+
+COMMENT ON VIEW outreach_goal_stats IS 'Response rates and sentiment breakdown per outreach goal (planner-based)';
diff --git a/server/src/db/migrations/166_drop_insight_goals.sql b/server/src/db/migrations/166_drop_insight_goals.sql
new file mode 100644
index 0000000000..7e9bca5892
--- /dev/null
+++ b/server/src/db/migrations/166_drop_insight_goals.sql
@@ -0,0 +1,28 @@
+-- Migration: Drop insight_goals table
+--
+-- The insight_goals table was used for passive insight extraction during conversations.
+-- It has been superseded by outreach_goals which now serves as the single source of truth
+-- for both proactive outreach and passive extraction goals.
+--
+-- This migration:
+-- 1. Drops the insight_goal_progress view (depended on insight_goals)
+-- 2. Drops the FK constraint from member_outreach.insight_goal_id
+-- 3. Drops the insight_goal_id column from member_outreach (no longer used)
+-- 4. Drops the insight_goals table
+
+-- Drop the view that depended on insight_goals
+DROP VIEW IF EXISTS insight_goal_progress;
+
+-- Drop the FK constraint and column from member_outreach
+-- The insight_goal_id was never used by the planner-based outreach system
+ALTER TABLE member_outreach DROP COLUMN IF EXISTS insight_goal_id;
+
+-- Drop indexes on insight_goals
+DROP INDEX IF EXISTS idx_goals_active;
+DROP INDEX IF EXISTS idx_goals_campaign_dates;
+DROP INDEX IF EXISTS idx_goals_priority;
+
+-- Drop the insight_goals table
+DROP TABLE IF EXISTS insight_goals;
+
+COMMENT ON TABLE outreach_goals IS 'Goals for member outreach and passive insight extraction. Single source of truth for what Addie wants to learn about members.';
diff --git a/server/src/routes/admin-insights.ts b/server/src/routes/admin-insights.ts
index 3c720d6425..44cfa4b745 100644
--- a/server/src/routes/admin-insights.ts
+++ b/server/src/routes/admin-insights.ts
@@ -3,7 +3,6 @@
*
* Routes:
* - /api/admin/insight-types - Manage insight taxonomy
- * - /api/admin/insight-goals - Manage insight goals
* - /api/admin/insights - View and manage member insights
* - /api/admin/outreach - Proactive outreach management
*/
@@ -21,7 +20,7 @@ import {
getOutreachMode,
canContactUser,
} from '../addie/services/proactive-outreach.js';
-import { invalidateInsightsCache, invalidateGoalsCache } from '../addie/insights-cache.js';
+import { invalidateInsightsCache } from '../addie/insights-cache.js';
import {
getActionItems,
getOpenActionItems,
@@ -71,13 +70,6 @@ export function createAdminInsightsRouter(): { pageRouter: Router; apiRouter: Ro
});
});
- pageRouter.get('/insight-goals', requireAuth, requireAdmin, (req, res) => {
- serveHtmlWithConfig(req, res, 'admin-insight-goals.html').catch((err) => {
- logger.error({ err }, 'Error serving insight goals page');
- res.status(500).send('Internal server error');
- });
- });
-
pageRouter.get('/insights', requireAuth, requireAdmin, (req, res) => {
serveHtmlWithConfig(req, res, 'admin-insights.html').catch((err) => {
logger.error({ err }, 'Error serving insights page');
@@ -221,141 +213,6 @@ export function createAdminInsightsRouter(): { pageRouter: Router; apiRouter: Ro
}
});
- // =========================================================================
- // INSIGHT GOALS API
- // =========================================================================
-
- // GET /api/admin/insight-goals - List all insight goals
- apiRouter.get('/insight-goals', requireAuth, requireAdmin, async (req, res) => {
- try {
- const activeOnly = req.query.activeOnly === 'true';
- const goals = await insightsDb.listGoals({ activeOnly });
- res.json(goals);
- } catch (error) {
- logger.error({ err: error }, 'Error listing insight goals');
- res.status(500).json({ error: 'Internal server error' });
- }
- });
-
- // GET /api/admin/insight-goals/:id - Get single insight goal
- apiRouter.get('/insight-goals/:id', requireAuth, requireAdmin, async (req, res) => {
- try {
- const id = parseIntId(req.params.id);
- if (id === null) {
- return res.status(400).json({ error: 'Invalid ID format' });
- }
-
- const goal = await insightsDb.getGoal(id);
- if (!goal) {
- return res.status(404).json({ error: 'Insight goal not found' });
- }
- res.json(goal);
- } catch (error) {
- logger.error({ err: error }, 'Error getting insight goal');
- res.status(500).json({ error: 'Internal server error' });
- }
- });
-
- // POST /api/admin/insight-goals - Create insight goal
- apiRouter.post('/insight-goals', requireAuth, requireAdmin, async (req, res) => {
- try {
- const {
- name,
- question,
- insight_type_id,
- goal_type,
- start_date,
- end_date,
- is_enabled,
- priority,
- target_mapped_only,
- target_unmapped_only,
- target_response_count,
- suggested_prompt_title,
- suggested_prompt_message,
- } = req.body;
-
- if (!name || !question) {
- return res.status(400).json({ error: 'Name and question are required' });
- }
-
- const goal = await insightsDb.createGoal({
- name,
- question,
- insight_type_id,
- goal_type,
- start_date: start_date ? new Date(start_date) : undefined,
- end_date: end_date ? new Date(end_date) : undefined,
- is_enabled,
- priority,
- target_mapped_only,
- target_unmapped_only,
- target_response_count,
- suggested_prompt_title,
- suggested_prompt_message,
- created_by: req.user?.id,
- });
-
- // Invalidate goals cache so routing uses fresh goal data
- invalidateGoalsCache();
-
- logger.info({ goalId: goal.id, name }, 'Created insight goal');
- res.status(201).json(goal);
- } catch (error) {
- logger.error({ err: error }, 'Error creating insight goal');
- res.status(500).json({ error: 'Internal server error' });
- }
- });
-
- // PUT /api/admin/insight-goals/:id - Update insight goal
- apiRouter.put('/insight-goals/:id', requireAuth, requireAdmin, async (req, res) => {
- try {
- const id = parseIntId(req.params.id);
- if (id === null) {
- return res.status(400).json({ error: 'Invalid ID format' });
- }
-
- const goal = await insightsDb.updateGoal(id, req.body);
-
- if (!goal) {
- return res.status(404).json({ error: 'Insight goal not found' });
- }
-
- // Invalidate goals cache so routing uses fresh goal data
- invalidateGoalsCache();
-
- logger.info({ goalId: goal.id }, 'Updated insight goal');
- res.json(goal);
- } catch (error) {
- logger.error({ err: error }, 'Error updating insight goal');
- res.status(500).json({ error: 'Internal server error' });
- }
- });
-
- // DELETE /api/admin/insight-goals/:id - Delete insight goal
- apiRouter.delete('/insight-goals/:id', requireAuth, requireAdmin, async (req, res) => {
- try {
- const id = parseIntId(req.params.id);
- if (id === null) {
- return res.status(400).json({ error: 'Invalid ID format' });
- }
-
- const deleted = await insightsDb.deleteGoal(id);
- if (!deleted) {
- return res.status(404).json({ error: 'Insight goal not found' });
- }
-
- // Invalidate goals cache so routing uses fresh goal data
- invalidateGoalsCache();
-
- logger.info({ goalId: id }, 'Deleted insight goal');
- res.json({ success: true });
- } catch (error) {
- logger.error({ err: error }, 'Error deleting insight goal');
- res.status(500).json({ error: 'Internal server error' });
- }
- });
-
// =========================================================================
// MEMBER INSIGHTS API
// =========================================================================
@@ -909,17 +766,17 @@ export function createAdminInsightsRouter(): { pageRouter: Router; apiRouter: Ro
const variant = variantResult.rows[0];
- // Get insight goal if applicable
+ // Get outreach goal description if applicable (for {{goal_question}} placeholder)
let goalQuestion: string | null = null;
if (outreachType === 'insight_goal') {
const goalResult = await pool.query(`
- SELECT question FROM insight_goals
- WHERE is_active = TRUE AND target_unmapped_only = FALSE
- ORDER BY priority DESC
+ SELECT description FROM outreach_goals
+ WHERE is_enabled = TRUE AND requires_mapped = TRUE
+ ORDER BY base_priority DESC
LIMIT 1
`);
if (goalResult.rows.length > 0) {
- goalQuestion = goalResult.rows[0].question;
+ goalQuestion = goalResult.rows[0].description;
}
}
diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts
index b19f7689c3..9b372c3170 100644
--- a/server/src/routes/admin.ts
+++ b/server/src/routes/admin.ts
@@ -240,11 +240,12 @@ export function createAdminRouter(): { pageRouter: Router; apiRouter: Router } {
mo.response_text,
mo.thread_id,
mo.dm_channel_id,
- ig.name as goal_name,
- ig.question as goal_question,
+ og.name as goal_name,
+ og.description 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 user_goal_history ugh ON ugh.outreach_id = mo.id
+ LEFT JOIN outreach_goals og ON og.id = ugh.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