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/170_slack_activity_index.sql b/server/src/db/migrations/170_slack_activity_index.sql new file mode 100644 index 0000000000..b2bb9748c1 --- /dev/null +++ b/server/src/db/migrations/170_slack_activity_index.sql @@ -0,0 +1,10 @@ +-- 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 +-- 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;