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...
- - - - - - - - - - - - - - - - -
-
- - - - - - - - - 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