From 9db2b3eaf0bcb889f1d161caf4a1913a7628a9b6 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 7 Jan 2026 07:05:22 -0800 Subject: [PATCH] fix: simplify engagement scoring and fix working group leader detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simplified engagement scoring: 10 points per Slack action + 20 points per event (capped at 100). Removed email engagement, conversation score, and complex org_activities scoring - these either weren't good signals or measured things that were on us to initiate, not the member. - Fixed working group count to include leaders as implicit members. Committee leaders were tracked in working_group_leaders but not counted in working_group_count, which only queried working_group_memberships. - Committee leaders no longer receive "Discover Working Groups" recommendations, which was nonsensical for someone leading 3 groups. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/strict-foxes-wash.md | 8 ++ server/src/addie/services/outbound-planner.ts | 5 +- .../149_simplify_engagement_scoring.sql | 135 ++++++++++++++++++ server/src/db/outbound-db.ts | 20 ++- 4 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 .changeset/strict-foxes-wash.md create mode 100644 server/src/db/migrations/149_simplify_engagement_scoring.sql diff --git a/.changeset/strict-foxes-wash.md b/.changeset/strict-foxes-wash.md new file mode 100644 index 0000000000..64c4740cc9 --- /dev/null +++ b/.changeset/strict-foxes-wash.md @@ -0,0 +1,8 @@ +--- +--- + +fix: simplify engagement scoring and fix working group leader detection + +- Simplified engagement scoring: 10 points per Slack action + 20 points per event (capped at 100) +- Fixed working group count to include leaders as implicit members +- Committee leaders no longer receive "Discover Working Groups" recommendations diff --git a/server/src/addie/services/outbound-planner.ts b/server/src/addie/services/outbound-planner.ts index 1bd18f3d01..f686bec166 100644 --- a/server/src/addie/services/outbound-planner.ts +++ b/server/src/addie/services/outbound-planner.ts @@ -303,7 +303,10 @@ export class OutboundPlanner { } // PRIORITY 4: Working group discovery (for engaged users with none) - if (caps && caps.account_linked && caps.working_group_count === 0 && caps.slack_message_count_30d > 5) { + // Skip for committee leaders - they lead working groups even if not counted as members + // Note: Leaders are now included in working_group_count via the query, but this explicit + // check is kept for clarity and defense against query changes + if (caps && caps.account_linked && !caps.is_committee_leader && caps.working_group_count === 0 && caps.slack_message_count_30d > 5) { const wgGoal = goals.find(g => g.name.toLowerCase().includes('working group') && g.category === 'education' ); diff --git a/server/src/db/migrations/149_simplify_engagement_scoring.sql b/server/src/db/migrations/149_simplify_engagement_scoring.sql new file mode 100644 index 0000000000..e853222ce8 --- /dev/null +++ b/server/src/db/migrations/149_simplify_engagement_scoring.sql @@ -0,0 +1,135 @@ +-- Migration: 149_simplify_engagement_scoring.sql +-- Simplify engagement scoring to focus on actual observable activity +-- +-- Previous scoring was too complex and had thresholds appropriate for +-- a full-time job, not a nonprofit side-commitment. +-- +-- New scoring (0-100, capped): +-- - Slack activity: 10 points per action (message, reaction, thread reply) in last 30 days +-- - Events: 20 points per event registered or attended +-- +-- Removed: +-- - Email engagement (unreliable tracking, not a good signal) +-- - Conversation score (that's on us to initiate, not them) +-- - Community score based on org_activities (replaced by events) + +-- ===================================================== +-- SIMPLIFIED USER ENGAGEMENT SCORE +-- ===================================================== + +CREATE OR REPLACE FUNCTION compute_user_engagement_score(p_workos_user_id VARCHAR(255)) +RETURNS TABLE ( + engagement_score INTEGER, + slack_activity_score INTEGER, + email_engagement_score INTEGER, + conversation_score INTEGER, + community_score INTEGER +) AS $$ +DECLARE + v_slack_user_id VARCHAR(255); + v_slack_score INTEGER := 0; + v_event_score INTEGER := 0; + v_total_score INTEGER := 0; +BEGIN + -- Get the user's primary Slack ID + SELECT u.primary_slack_user_id INTO v_slack_user_id + FROM users u + WHERE u.workos_user_id = p_workos_user_id; + + -- ========================================= + -- SLACK ACTIVITY SCORE (10 points per action) + -- ========================================= + -- Messages, reactions, and thread replies all count equally + IF v_slack_user_id IS NOT NULL THEN + SELECT COALESCE( + ( + SELECT + (COALESCE(SUM(message_count), 0) + + COALESCE(SUM(reaction_count), 0) + + COALESCE(SUM(thread_reply_count), 0)) * 10 + FROM slack_activity_daily + WHERE slack_user_id = v_slack_user_id + AND activity_date >= CURRENT_DATE - INTERVAL '30 days' + ), 0 + ) INTO v_slack_score; + END IF; + + -- ========================================= + -- EVENT SCORE (20 points per event) + -- ========================================= + -- Count events registered for or attended + SELECT COALESCE( + ( + SELECT COUNT(*) * 20 + FROM event_registrations er + WHERE er.workos_user_id = p_workos_user_id + AND ( + er.created_at >= NOW() - INTERVAL '30 days' + OR er.checked_in_at >= NOW() - INTERVAL '30 days' + ) + ), 0 + ) INTO v_event_score; + + -- Calculate total (max 100) + v_total_score := LEAST(100, v_slack_score + v_event_score); + + -- Return with backward-compatible columns + -- slack_activity_score = slack score + -- community_score = event score + -- email_engagement_score and conversation_score = 0 (deprecated) + RETURN QUERY SELECT + v_total_score, + LEAST(100, v_slack_score), + 0, -- email_engagement_score (deprecated) + 0, -- conversation_score (deprecated) + LEAST(100, v_event_score); +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION compute_user_engagement_score IS 'Simplified engagement score (0-100): 10 pts per Slack action + 20 pts per event, based on last 30 days activity'; + +-- ===================================================== +-- UPDATE SLACK-ONLY USER ENGAGEMENT SCORE +-- ===================================================== +-- For users who only have Slack (no WorkOS account) + +CREATE OR REPLACE FUNCTION compute_slack_user_engagement_score(p_slack_user_id VARCHAR(255)) +RETURNS TABLE ( + engagement_score INTEGER, + slack_activity_score INTEGER, + email_engagement_score INTEGER, + conversation_score INTEGER, + community_score INTEGER +) AS $$ +DECLARE + v_slack_score INTEGER := 0; + v_total_score INTEGER := 0; +BEGIN + -- ========================================= + -- SLACK ACTIVITY SCORE (10 points per action) + -- ========================================= + SELECT COALESCE( + ( + SELECT + (COALESCE(SUM(message_count), 0) + + COALESCE(SUM(reaction_count), 0) + + COALESCE(SUM(thread_reply_count), 0)) * 10 + FROM slack_activity_daily + WHERE slack_user_id = p_slack_user_id + AND activity_date >= CURRENT_DATE - INTERVAL '30 days' + ), 0 + ) INTO v_slack_score; + + -- For Slack-only users, we only have Slack activity + v_total_score := LEAST(100, v_slack_score); + + RETURN QUERY SELECT + v_total_score, + LEAST(100, v_slack_score), + 0, -- email_engagement_score (not applicable) + 0, -- conversation_score (deprecated) + 0; -- community_score (no event data for slack-only) +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION compute_slack_user_engagement_score IS 'Engagement score for Slack-only users: 10 pts per Slack action (capped at 100)'; diff --git a/server/src/db/outbound-db.ts b/server/src/db/outbound-db.ts index 9a68f56996..a220ccb9a1 100644 --- a/server/src/db/outbound-db.ts +++ b/server/src/db/outbound-db.ts @@ -732,18 +732,24 @@ export async function getMemberCapabilities( [workosUserId] ), - // Working groups & councils + // Working groups & councils (include leaders as implicit members) query<{ wg_count: number; council_count: number; }>( `SELECT - (SELECT COUNT(*) FROM working_group_memberships wgm - JOIN working_groups wg ON wg.id = wgm.working_group_id - WHERE wgm.workos_user_id = $1 AND wg.committee_type = 'working_group') as wg_count, - (SELECT COUNT(*) FROM working_group_memberships wgm - JOIN working_groups wg ON wg.id = wgm.working_group_id - WHERE wgm.workos_user_id = $1 AND wg.committee_type = 'council') as council_count`, + (SELECT COUNT(DISTINCT wg.id) FROM working_groups wg + WHERE wg.committee_type = 'working_group' + AND ( + EXISTS(SELECT 1 FROM working_group_memberships wgm WHERE wgm.working_group_id = wg.id AND wgm.workos_user_id = $1) + OR EXISTS(SELECT 1 FROM working_group_leaders wgl WHERE wgl.working_group_id = wg.id AND wgl.user_id = $1) + )) as wg_count, + (SELECT COUNT(DISTINCT wg.id) FROM working_groups wg + WHERE wg.committee_type = 'council' + AND ( + EXISTS(SELECT 1 FROM working_group_memberships wgm WHERE wgm.working_group_id = wg.id AND wgm.workos_user_id = $1) + OR EXISTS(SELECT 1 FROM working_group_leaders wgl WHERE wgl.working_group_id = wg.id AND wgl.user_id = $1) + )) as council_count`, [workosUserId] ),