From 3f98c3c9485ffc3df281f479fb4b4bd02ec64502 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 14 Jan 2026 20:45:23 -0500 Subject: [PATCH 1/2] fix: align funnel metrics with analytics and prevent duplicate orgs - Update dashboard funnel to use MEMBER_FILTER instead of deprecated org_lifecycle_stage field for consistent member counts - Simplify HAS_USER and HAS_ENGAGED_USER filters to use organization_domains for Slack user matching (removes reliance on pending_organization_id and slack_activity_daily table) - Add 409 Conflict checks to create-prospect endpoints to prevent duplicate organizations when domain is already claimed - Add duplicate domain check before user org creation - Add index on last_slack_activity_at for query performance Co-Authored-By: Claude Opus 4.5 --- .changeset/light-tables-stare.md | 2 + .../migrations/169_slack_activity_index.sql | 10 +++ server/src/db/org-filters.ts | 82 +++---------------- server/src/routes/admin/domains.ts | 45 +++++++++- server/src/routes/admin/stats.ts | 14 ++-- server/src/routes/organizations.ts | 21 +++++ 6 files changed, 93 insertions(+), 81 deletions(-) create mode 100644 .changeset/light-tables-stare.md create mode 100644 server/src/db/migrations/169_slack_activity_index.sql diff --git a/.changeset/light-tables-stare.md b/.changeset/light-tables-stare.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/light-tables-stare.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/src/db/migrations/169_slack_activity_index.sql b/server/src/db/migrations/169_slack_activity_index.sql new file mode 100644 index 0000000000..6d8fcd17de --- /dev/null +++ b/server/src/db/migrations/169_slack_activity_index.sql @@ -0,0 +1,10 @@ +-- Migration: 169_slack_activity_index.sql +-- Add index on last_slack_activity_at for HAS_ENGAGED_USER filter performance +-- +-- The org-filters.ts HAS_ENGAGED_USER filter uses this column in a date range +-- comparison to identify organizations with recent Slack activity. Without an +-- index, this query scans all slack_user_mappings rows. + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_slack_user_mappings_last_activity + ON slack_user_mappings(last_slack_activity_at) + WHERE last_slack_activity_at IS NOT NULL; diff --git a/server/src/db/org-filters.ts b/server/src/db/org-filters.ts index a9b68d4294..d200ea9a76 100644 --- a/server/src/db/org-filters.ts +++ b/server/src/db/org-filters.ts @@ -28,27 +28,14 @@ export const HAS_USER = `( ) OR EXISTS ( SELECT 1 FROM slack_user_mappings sm - WHERE sm.pending_organization_id = organizations.workos_organization_id + JOIN organization_domains od ON LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(od.domain) + WHERE od.workos_organization_id = organizations.workos_organization_id AND sm.slack_is_bot = false AND sm.slack_is_deleted = false ) - OR EXISTS ( - SELECT 1 FROM slack_user_mappings sm - WHERE sm.pending_organization_id IS NULL - AND sm.slack_is_bot = false - AND sm.slack_is_deleted = false - AND ( - (organizations.email_domain IS NOT NULL AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(organizations.email_domain)) - OR EXISTS ( - SELECT 1 FROM organization_domains od - WHERE od.workos_organization_id = organizations.workos_organization_id - AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(od.domain) - ) - ) - ) )`; -/** Organization has at least one user with engagement_score > 0 OR Slack user with activity */ +/** Organization has at least one user with engagement_score > 0 OR Slack user with recent activity */ export const HAS_ENGAGED_USER = `( EXISTS ( SELECT 1 FROM organization_memberships om @@ -58,27 +45,11 @@ export const HAS_ENGAGED_USER = `( ) OR EXISTS ( SELECT 1 FROM slack_user_mappings sm - JOIN slack_activity_daily sad ON sad.slack_user_id = sm.slack_user_id - WHERE sm.pending_organization_id = organizations.workos_organization_id - AND sm.slack_is_bot = false - AND sm.slack_is_deleted = false - AND sad.activity_date >= CURRENT_DATE - INTERVAL '30 days' - ) - OR EXISTS ( - SELECT 1 FROM slack_user_mappings sm - JOIN slack_activity_daily sad ON sad.slack_user_id = sm.slack_user_id - WHERE sm.pending_organization_id IS NULL + JOIN organization_domains od ON LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(od.domain) + WHERE od.workos_organization_id = organizations.workos_organization_id AND sm.slack_is_bot = false AND sm.slack_is_deleted = false - AND sad.activity_date >= CURRENT_DATE - INTERVAL '30 days' - AND ( - (organizations.email_domain IS NOT NULL AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(organizations.email_domain)) - OR EXISTS ( - SELECT 1 FROM organization_domains od - WHERE od.workos_organization_id = organizations.workos_organization_id - AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(od.domain) - ) - ) + AND sm.last_slack_activity_at >= CURRENT_DATE - INTERVAL '30 days' ) )`; @@ -106,27 +77,14 @@ export const HAS_USER_ALIASED = `( ) OR EXISTS ( SELECT 1 FROM slack_user_mappings sm - WHERE sm.pending_organization_id = o.workos_organization_id + JOIN organization_domains od ON LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(od.domain) + WHERE od.workos_organization_id = o.workos_organization_id AND sm.slack_is_bot = false AND sm.slack_is_deleted = false ) - OR EXISTS ( - SELECT 1 FROM slack_user_mappings sm - WHERE sm.pending_organization_id IS NULL - AND sm.slack_is_bot = false - AND sm.slack_is_deleted = false - AND ( - (o.email_domain IS NOT NULL AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(o.email_domain)) - OR EXISTS ( - SELECT 1 FROM organization_domains od - WHERE od.workos_organization_id = o.workos_organization_id - AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(od.domain) - ) - ) - ) )`; -/** Organization has at least one user with engagement_score > 0 OR Slack user with activity (aliased) */ +/** Organization has at least one user with engagement_score > 0 OR Slack user with recent activity (aliased) */ export const HAS_ENGAGED_USER_ALIASED = `( EXISTS ( SELECT 1 FROM organization_memberships om @@ -136,27 +94,11 @@ export const HAS_ENGAGED_USER_ALIASED = `( ) OR EXISTS ( SELECT 1 FROM slack_user_mappings sm - JOIN slack_activity_daily sad ON sad.slack_user_id = sm.slack_user_id - WHERE sm.pending_organization_id = o.workos_organization_id - AND sm.slack_is_bot = false - AND sm.slack_is_deleted = false - AND sad.activity_date >= CURRENT_DATE - INTERVAL '30 days' - ) - OR EXISTS ( - SELECT 1 FROM slack_user_mappings sm - JOIN slack_activity_daily sad ON sad.slack_user_id = sm.slack_user_id - WHERE sm.pending_organization_id IS NULL + JOIN organization_domains od ON LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(od.domain) + WHERE od.workos_organization_id = o.workos_organization_id AND sm.slack_is_bot = false AND sm.slack_is_deleted = false - AND sad.activity_date >= CURRENT_DATE - INTERVAL '30 days' - AND ( - (o.email_domain IS NOT NULL AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(o.email_domain)) - OR EXISTS ( - SELECT 1 FROM organization_domains od - WHERE od.workos_organization_id = o.workos_organization_id - AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(od.domain) - ) - ) + AND sm.last_slack_activity_at >= CURRENT_DATE - INTERVAL '30 days' ) )`; diff --git a/server/src/routes/admin/domains.ts b/server/src/routes/admin/domains.ts index 2706c699cd..fb534ac95d 100644 --- a/server/src/routes/admin/domains.ts +++ b/server/src/routes/admin/domains.ts @@ -193,13 +193,33 @@ export function setupDomainRoutes( return res.status(500).json({ error: "WorkOS not configured" }); } + const pool = getPool(); + const normalizedDomain = domain.toLowerCase().trim(); + + // Check if domain already has an organization + const existingResult = await pool.query( + `SELECT od.workos_organization_id, o.name + FROM organization_domains od + JOIN organizations o ON o.workos_organization_id = od.workos_organization_id + WHERE LOWER(od.domain) = $1`, + [normalizedDomain] + ); + + if (existingResult.rows.length > 0) { + return res.status(409).json({ + error: "Domain already claimed", + message: `Domain ${domain} already belongs to organization "${existingResult.rows[0].name}"`, + existing_org_id: existingResult.rows[0].workos_organization_id, + }); + } + // Get the users from this domain for context const domainData = await slackDb.getUnmappedDomains({ excludeFreeEmailProviders: false, minUsers: 1, }); const domainInfo = domainData.find( - (d) => d.domain.toLowerCase() === domain.toLowerCase() + (d) => d.domain.toLowerCase() === normalizedDomain ); if (!domainInfo) { @@ -229,7 +249,6 @@ export function setupDomainRoutes( ); // Create local record - const pool = getPool(); const slackUserNames = domainInfo.users .map((u) => u.slack_real_name || u.slack_display_name) .filter(Boolean) @@ -640,16 +659,34 @@ export function setupDomainRoutes( } const pool = getPool(); + const normalizedDomain = domain.toLowerCase().trim(); + + // Check if domain already has an organization + const existingResult = await pool.query( + `SELECT od.workos_organization_id, o.name + FROM organization_domains od + JOIN organizations o ON o.workos_organization_id = od.workos_organization_id + WHERE LOWER(od.domain) = $1`, + [normalizedDomain] + ); + + if (existingResult.rows.length > 0) { + return res.status(409).json({ + error: "Domain already claimed", + message: `Domain ${domain} already belongs to organization "${existingResult.rows[0].name}"`, + existing_org_id: existingResult.rows[0].workos_organization_id, + }); + } // Get the contacts from this domain for context const contactsResult = await pool.query( `SELECT email, display_name, email_count, last_seen_at FROM email_contacts WHERE mapping_status = 'unmapped' - AND LOWER(domain) = LOWER($1) + AND LOWER(domain) = $1 ORDER BY email_count DESC LIMIT 10`, - [domain] + [normalizedDomain] ); if (contactsResult.rows.length === 0) { diff --git a/server/src/routes/admin/stats.ts b/server/src/routes/admin/stats.ts index 2a11d42405..3d43612f1e 100644 --- a/server/src/routes/admin/stats.ts +++ b/server/src/routes/admin/stats.ts @@ -193,16 +193,16 @@ export function setupStatsRoutes(apiRouter: Router): void { WHERE engagement_score IS NOT NULL `), - // Org lifecycle stats + // Org lifecycle stats (using subscription status, not deprecated org_lifecycle_stage) pool.query(` SELECT COUNT(*) as total_orgs, - COUNT(CASE WHEN subscription_status = 'active' THEN 1 END) as active_orgs, - COUNT(CASE WHEN org_lifecycle_stage = 'prospect' THEN 1 END) as prospects, - COUNT(CASE WHEN org_lifecycle_stage = 'evaluating' THEN 1 END) as evaluating, - COUNT(CASE WHEN org_lifecycle_stage = 'trial' THEN 1 END) as trials, - COUNT(CASE WHEN org_lifecycle_stage = 'paying' THEN 1 END) as paying, - COUNT(CASE WHEN org_lifecycle_stage IN ('evaluating', 'trial') AND engagement_score >= 50 THEN 1 END) as engaged_prospects + COUNT(CASE WHEN ${MEMBER_FILTER} THEN 1 END) as active_orgs, + COUNT(CASE WHEN engagement_score >= 50 AND NOT (${MEMBER_FILTER}) THEN 1 END) as prospects, + COUNT(CASE WHEN engagement_score >= 30 AND engagement_score < 50 AND NOT (${MEMBER_FILTER}) THEN 1 END) as evaluating, + COUNT(CASE WHEN engagement_score > 0 AND engagement_score < 30 AND NOT (${MEMBER_FILTER}) THEN 1 END) as trials, + COUNT(CASE WHEN ${MEMBER_FILTER} THEN 1 END) as paying, + COUNT(CASE WHEN engagement_score >= 50 AND NOT (${MEMBER_FILTER}) THEN 1 END) as engaged_prospects FROM organizations `), diff --git a/server/src/routes/organizations.ts b/server/src/routes/organizations.ts index ae4f5f252b..74203d26b0 100644 --- a/server/src/routes/organizations.ts +++ b/server/src/routes/organizations.ts @@ -1077,6 +1077,27 @@ export function createOrganizationsRouter(): Router { // Use trimmed name for consistency const trimmedName = organization_name.trim(); + // Check if an org with this domain already exists BEFORE creating + if (verifiedDomain) { + const pool = getPool(); + const existingOrgResult = await pool.query( + `SELECT o.workos_organization_id, o.name + FROM organization_domains od + JOIN organizations o ON o.workos_organization_id = od.workos_organization_id + WHERE LOWER(od.domain) = LOWER($1)`, + [verifiedDomain] + ); + + if (existingOrgResult.rows.length > 0) { + return res.status(409).json({ + error: 'Organization exists', + message: `An organization for ${verifiedDomain} already exists: "${existingOrgResult.rows[0].name}". Please search for it and request to join instead of creating a new one.`, + existing_org_id: existingOrgResult.rows[0].workos_organization_id, + existing_org_name: existingOrgResult.rows[0].name, + }); + } + } + logger.info({ organization_name: trimmedName, is_personal, company_type, revenue_tier, verifiedDomain }, 'Creating WorkOS organization'); let workosOrgId: string; From d5ba0111996d958f1098597476c94a96795e3629 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Jan 2026 05:46:14 -0500 Subject: [PATCH 2/2] fix: rename migration to 170 to avoid version conflict Migration 169 already exists on main (remove_rss_from_editorial). Co-Authored-By: Claude Opus 4.5 --- ...69_slack_activity_index.sql => 170_slack_activity_index.sql} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename server/src/db/migrations/{169_slack_activity_index.sql => 170_slack_activity_index.sql} (91%) diff --git a/server/src/db/migrations/169_slack_activity_index.sql b/server/src/db/migrations/170_slack_activity_index.sql similarity index 91% rename from server/src/db/migrations/169_slack_activity_index.sql rename to server/src/db/migrations/170_slack_activity_index.sql index 6d8fcd17de..b2bb9748c1 100644 --- a/server/src/db/migrations/169_slack_activity_index.sql +++ b/server/src/db/migrations/170_slack_activity_index.sql @@ -1,4 +1,4 @@ --- Migration: 169_slack_activity_index.sql +-- Migration: 170_slack_activity_index.sql -- Add index on last_slack_activity_at for HAS_ENGAGED_USER filter performance -- -- The org-filters.ts HAS_ENGAGED_USER filter uses this column in a date range