Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/light-tables-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
10 changes: 10 additions & 0 deletions server/src/db/migrations/170_slack_activity_index.sql
Original file line number Diff line number Diff line change
@@ -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;
82 changes: 12 additions & 70 deletions server/src/db/org-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
)
)`;

Expand Down Expand Up @@ -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
Expand All @@ -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'
)
)`;

Expand Down
45 changes: 41 additions & 4 deletions server/src/routes/admin/domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
14 changes: 7 additions & 7 deletions server/src/routes/admin/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
`),

Expand Down
21 changes: 21 additions & 0 deletions server/src/routes/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down