From f25163ecd08b1c868fcb8adb1808e93ac64148ad Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 5 Jan 2026 12:09:44 -0500 Subject: [PATCH] Add unified account lifecycle stage to admin tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `get_account` as the primary tool for looking up any organization - Introduce `computeLifecycleStage()` function that combines subscription_status and prospect_status into a unified view: prospect → contacted → responded → interested → negotiating → member (or declined/churned) - Handle all subscription statuses: active, trialing, canceled, past_due, unpaid, incomplete, incomplete_expired - Mark `lookup_organization` and `find_prospect` as deprecated (still functional) - Update `find_prospect` to show lifecycle stage instead of raw prospect_status - Export `LIFECYCLE_STAGE_EMOJI` constant for consistent emoji usage This fixes the issue where paying members (like Mediaocean) would incorrectly show as "prospect" in find_prospect because it didn't check subscription_status. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/account-lifecycle-stage.md | 4 + server/src/addie/mcp/admin-tools.ts | 256 ++++++++++++++++++++------ 2 files changed, 204 insertions(+), 56 deletions(-) create mode 100644 .changeset/account-lifecycle-stage.md diff --git a/.changeset/account-lifecycle-stage.md b/.changeset/account-lifecycle-stage.md new file mode 100644 index 0000000000..1a3d295713 --- /dev/null +++ b/.changeset/account-lifecycle-stage.md @@ -0,0 +1,4 @@ +--- +--- + +Internal tooling: Add unified account lifecycle stage to admin tools diff --git a/server/src/addie/mcp/admin-tools.ts b/server/src/addie/mcp/admin-tools.ts index 6647d03757..a5a429d61e 100644 --- a/server/src/addie/mcp/admin-tools.ts +++ b/server/src/addie/mcp/admin-tools.ts @@ -203,6 +203,95 @@ export function isAdmin(memberContext: MemberContext | null): boolean { return memberContext?.org_membership?.role === 'admin'; } +/** + * Compute the unified lifecycle stage for an organization. + * This combines prospect_status and subscription_status into a single view. + * + * Lifecycle stages: + * - prospect: Not contacted yet + * - contacted: Outreach sent + * - responded: They replied + * - interested: Expressed interest + * - negotiating: In discussions / invoice sent + * - member: Active subscription + * - churned: Was a member, subscription ended + * - declined: Not interested + */ +export type LifecycleStage = + | 'prospect' + | 'contacted' + | 'responded' + | 'interested' + | 'negotiating' + | 'member' + | 'churned' + | 'declined'; + +// Emoji mapping for lifecycle stages - used in multiple places +export const LIFECYCLE_STAGE_EMOJI: Record = { + prospect: '🔍', + contacted: '📧', + responded: '💬', + interested: '⭐', + negotiating: '🤝', + member: '✅', + churned: '⚠️', + declined: '❌', +}; + +export function computeLifecycleStage(org: { + subscription_status?: string | null; + prospect_status?: string | null; + invoice_requested_at?: Date | null; +}): LifecycleStage { + // Active subscription (including trial) = member + if (org.subscription_status === 'active' || org.subscription_status === 'trialing') { + return 'member'; + } + + // Subscription ended or payment failed = churned + if ( + org.subscription_status === 'canceled' || + org.subscription_status === 'past_due' || + org.subscription_status === 'unpaid' || + org.subscription_status === 'incomplete_expired' + ) { + return 'churned'; + } + + // Incomplete subscription = started payment but didn't finish + if (org.subscription_status === 'incomplete') { + return 'negotiating'; + } + + // If they have an invoice requested, they're at least negotiating + // (only promote if they're still in early pipeline stages) + if (org.invoice_requested_at && (!org.prospect_status || org.prospect_status === 'prospect' || org.prospect_status === 'contacted')) { + return 'negotiating'; + } + + // Map prospect_status to lifecycle stage + const prospectStatusMap: Record = { + prospect: 'prospect', + contacted: 'contacted', + responded: 'responded', + interested: 'interested', + negotiating: 'negotiating', + converted: 'member', // legacy value + joined: 'member', // legacy value + declined: 'declined', + inactive: 'declined', + disqualified: 'declined', + }; + + if (org.prospect_status && prospectStatusMap[org.prospect_status]) { + return prospectStatusMap[org.prospect_status]; + } + + // Default: unknown org is a prospect + return 'prospect'; +} + /** * Admin tool definitions - includes both billing/invoice tools and prospect management tools */ @@ -212,9 +301,9 @@ export const ADMIN_TOOLS: AddieTool[] = [ // ============================================ { name: 'lookup_organization', - description: `Look up an organization by name to get their membership status, pending invoices, and other details. -Use this when an admin asks about a specific company's membership status. -Returns organization details including subscription status, pending invoices, and contact information.`, + description: `DEPRECATED: Use get_account instead for complete account view with lifecycle stage. +This tool only searches Stripe data - it will fail for organizations that were created through the prospect flow. +Use get_account for a unified view of all organizations.`, input_schema: { type: 'object' as const, properties: { @@ -242,19 +331,41 @@ Returns a list of organizations with open or draft invoices.`, }, }, { - name: 'get_organization_details', - description: `Get comprehensive details about an organization including Slack activity, working group participation, engagement signals, enrichment data, and membership status. + name: 'get_account', + description: `Get the complete account view for any organization - whether they're a prospect, member, or churned. -USE THIS for questions like: -- "How many Slack users does [company] have?" -- "Which working groups is [company] in?" -- "What do we know about [company]?" -- "Has [company] signed up yet?" -- "How engaged is [company]?" +This is the PRIMARY tool for looking up any company. It shows: +- **Lifecycle stage**: prospect → contacted → responded → interested → negotiating → member (or declined/churned) +- **Membership**: Subscription status, tier, renewal date +- **Engagement**: Slack users, activity, working groups, dashboard logins +- **Pipeline**: Contact info, notes, interest level, activities +- **Enrichment**: Industry, revenue, employee count + +USE THIS for ANY question about a company: - "What's the status of [company]?" +- "Is [company] a member?" +- "Tell me about [company]" +- "How engaged is [company]?" +- "Who is our contact at [company]?" -Returns: Slack user count and activity, working groups, engagement level and signals, enrichment data (industry, revenue, employees), prospect status, and membership/subscription status.`, - usage_hints: 'Use this for ANY question about a specific organization beyond just "is it a prospect". This gives you the full picture.', +This replaces find_prospect and lookup_organization with a unified view.`, + usage_hints: 'Use this for ANY question about a specific organization. This gives you the complete account picture including lifecycle stage.', + input_schema: { + type: 'object' as const, + properties: { + query: { + type: 'string', + description: 'Company name or domain to look up (e.g., "Mediaocean" or "mediaocean.com")', + }, + }, + required: ['query'], + }, + }, + // Alias for backwards compatibility + { + name: 'get_organization_details', + description: `Alias for get_account. Use get_account instead for the complete account lifecycle view.`, + usage_hints: 'Prefer get_account - this is kept for backwards compatibility.', input_schema: { type: 'object' as const, properties: { @@ -318,8 +429,8 @@ Returns: Slack user count and activity, working groups, engagement level and sig { name: 'find_prospect', description: - 'Search for existing prospects by name or domain. USE THIS FIRST whenever an admin mentions any company - check if they already exist before offering to add them. Searches both company names and email domains.', - usage_hints: 'Always use this before add_prospect. When an admin says "check on [company]" or mentions any company name, use this tool first.', + 'DEPRECATED: Use get_account instead for complete account view with lifecycle stage. This tool only checks if a company exists before adding - use get_account to see full status including whether they are already a member.', + usage_hints: 'DEPRECATED: Prefer get_account for lookups. Only use this before add_prospect to check existence.', input_schema: { type: 'object', properties: { @@ -1780,8 +1891,8 @@ export function createAdminToolHandlers( } }); - // Get comprehensive organization details - handlers.set('get_organization_details', async (input) => { + // Shared handler for get_account and get_organization_details + const getAccountHandler = async (input: Record) => { const adminCheck = requireAdminFromContext(); if (adminCheck) return adminCheck; @@ -1811,21 +1922,18 @@ export function createAdminToolHandlers( return `No organization found matching "${query}". Try searching by company name or domain.`; } - // If multiple matches, present options to the user + // If multiple matches, present options to the user with lifecycle stage if (result.rows.length > 1) { let response = `## Found ${result.rows.length} organizations matching "${query}"\n\n`; response += `Which one would you like to know more about?\n\n`; for (let i = 0; i < result.rows.length; i++) { const org = result.rows[i]; + const lifecycleStage = computeLifecycleStage(org); response += `**${i + 1}. ${org.name}**\n`; if (org.email_domain) response += ` Domain: ${org.email_domain}\n`; if (org.company_type) response += ` Type: ${org.company_type}\n`; - if (org.subscription_status === 'active') { - response += ` Status: ✅ Member\n`; - } else if (org.prospect_status) { - response += ` Status: 📋 Prospect (${org.prospect_status})\n`; - } + response += ` Lifecycle: ${LIFECYCLE_STAGE_EMOJI[lifecycleStage]} ${lifecycleStage}\n`; response += `\n`; } @@ -1836,6 +1944,9 @@ export function createAdminToolHandlers( const org = result.rows[0]; const orgId = org.workos_organization_id; + // Compute the unified lifecycle stage + const lifecycleStage = computeLifecycleStage(org); + // Gather all the data in parallel const [ slackUsersResult, @@ -1843,6 +1954,7 @@ export function createAdminToolHandlers( workingGroupsResult, activitiesResult, engagementSignals, + pendingInvoicesResult, ] = await Promise.all([ // Slack users count for this org pool.query( @@ -1884,49 +1996,77 @@ export function createAdminToolHandlers( ), // Engagement signals orgDb.getEngagementSignals(orgId), + // Get pending invoices if they have a Stripe customer + org.stripe_customer_id ? getPendingInvoices(org.stripe_customer_id) : Promise.resolve([]), ]); const slackUserCount = parseInt(slackUsersResult.rows[0]?.slack_user_count || '0'); const slackActivity = slackActivityResult.rows[0] || { active_users: 0, messages: 0, reactions: 0, thread_replies: 0 }; const workingGroups = workingGroupsResult.rows; const recentActivities = activitiesResult.rows; + const pendingInvoices = pendingInvoicesResult; // Build comprehensive response let response = `## ${org.name}\n\n`; + // Lifecycle stage - the unified view (prominently displayed at top) + response += `**Lifecycle Stage:** ${LIFECYCLE_STAGE_EMOJI[lifecycleStage]} **${lifecycleStage.charAt(0).toUpperCase() + lifecycleStage.slice(1)}**\n`; + // Basic info if (org.company_type) response += `**Type:** ${org.company_type}\n`; if (org.email_domain) response += `**Domain:** ${org.email_domain}\n`; if (org.parent_name) response += `**Parent:** ${org.parent_name}\n`; + response += `**ID:** ${orgId}\n`; response += '\n'; - // Membership status - response += `### Membership Status\n`; - if (org.subscription_status === 'active') { - response += `✅ **Active Member** - ${org.subscription_product_name || 'Subscription'}\n`; - if (org.subscription_current_period_end) { - response += ` Renews: ${formatDate(new Date(org.subscription_current_period_end))}\n`; + // Membership details (if member or has subscription history) + if (lifecycleStage === 'member' || lifecycleStage === 'churned' || org.subscription_status) { + response += `### Membership\n`; + if (org.subscription_status === 'active') { + response += `**Status:** Active - ${org.subscription_product_name || 'Subscription'}\n`; + if (org.subscription_current_period_end) { + response += `**Renews:** ${formatDate(new Date(org.subscription_current_period_end))}\n`; + } + } else if (org.subscription_status === 'canceled') { + response += `**Status:** Canceled\n`; + } else if (org.subscription_status === 'past_due') { + response += `**Status:** Past due - payment needed\n`; } - } else if (org.prospect_status) { - const statusEmojiMap: Record = { - prospect: '🔍', - contacted: '📧', - responded: '💬', - interested: '⭐', - negotiating: '🤝', - declined: '❌', - }; - const statusEmoji = statusEmojiMap[org.prospect_status as string] || '📋'; - response += `${statusEmoji} **Prospect** - Status: ${org.prospect_status}\n`; + if (pendingInvoices.length > 0) { + response += `**Pending invoices:** ${pendingInvoices.length}\n`; + for (const inv of pendingInvoices.slice(0, 3)) { + response += ` - ${formatPendingInvoice(inv).amount} (${formatPendingInvoice(inv).status})\n`; + } + } + response += '\n'; + } + + // Pipeline info (for prospects/negotiating) + if (lifecycleStage !== 'member' && lifecycleStage !== 'churned') { + response += `### Pipeline\n`; if (org.prospect_contact_name) { - response += ` Contact: ${org.prospect_contact_name}`; + response += `**Contact:** ${org.prospect_contact_name}`; if (org.prospect_contact_title) response += ` (${org.prospect_contact_title})`; response += '\n'; } - } else { - response += `⚪ Not a member yet\n`; + if (org.prospect_contact_email) response += `**Email:** ${org.prospect_contact_email}\n`; + if (org.invoice_requested_at) { + response += `**Invoice requested:** ${formatDate(new Date(org.invoice_requested_at))}\n`; + } + if (engagementSignals.interest_level) { + response += `**Interest:** ${engagementSignals.interest_level}`; + if (engagementSignals.interest_level_set_by) response += ` (set by ${engagementSignals.interest_level_set_by})`; + response += '\n'; + } + // Show pending invoices for prospects in negotiating stage too + if (pendingInvoices.length > 0) { + response += `**Pending invoices:** ${pendingInvoices.length}\n`; + for (const inv of pendingInvoices.slice(0, 3)) { + response += ` - ${formatPendingInvoice(inv).amount} (${formatPendingInvoice(inv).status})\n`; + } + } + response += '\n'; } - response += '\n'; // Slack presence response += `### Slack Presence\n`; @@ -1964,12 +2104,6 @@ export function createAdminToolHandlers( else if (engagementSignals.login_count_30d > 0) engagementLevel = 2; response += `**Level:** ${engagementLabels[engagementLevel]} (${engagementLevel}/5)\n`; - if (engagementSignals.interest_level) { - response += `**Interest:** ${engagementSignals.interest_level}`; - if (engagementSignals.interest_level_set_by) response += ` (set by ${engagementSignals.interest_level_set_by})`; - if (engagementSignals.interest_level_note) response += `\n Note: "${engagementSignals.interest_level_note}"`; - response += '\n'; - } if (engagementSignals.login_count_30d > 0) { response += `**Dashboard logins (30d):** ${engagementSignals.login_count_30d}\n`; } @@ -2007,10 +2141,16 @@ export function createAdminToolHandlers( return response; } catch (error) { - logger.error({ error, query }, 'Addie: Error getting organization details'); - return `❌ Failed to get organization details. Please try again or contact support.`; + logger.error({ error, query }, 'Addie: Error getting account details'); + return `❌ Failed to get account details. Please try again or contact support.`; } - }); + }; + + // Register get_account as the primary tool + handlers.set('get_account', getAccountHandler); + + // Register get_organization_details as an alias for backwards compatibility + handlers.set('get_organization_details', getAccountHandler); // ============================================ // PROSPECT MANAGEMENT HANDLERS @@ -2089,7 +2229,7 @@ export function createAdminToolHandlers( return response; }); - // Find prospect + // Find prospect (DEPRECATED - use get_account instead) handlers.set('find_prospect', async (input) => { const adminCheck = requireAdminFromContext(); if (adminCheck) return adminCheck; @@ -2098,9 +2238,11 @@ export function createAdminToolHandlers( const query = input.query as string; const searchPattern = `%${query}%`; + // Include subscription_status and invoice_requested_at for lifecycle stage computation const result = await pool.query( `SELECT workos_organization_id, name, company_type, email_domain, prospect_status, prospect_source, prospect_contact_name, + subscription_status, invoice_requested_at, enrichment_at, enrichment_industry, enrichment_revenue, enrichment_employee_count, created_at, updated_at FROM organizations @@ -2116,17 +2258,19 @@ export function createAdminToolHandlers( ); if (result.rows.length === 0) { - return `No prospects found matching "${query}". Would you like me to add them as a new prospect?`; + return `No organizations found matching "${query}". Would you like me to add them as a new prospect?`; } let response = `## Found ${result.rows.length} match${result.rows.length === 1 ? '' : 'es'} for "${query}"\n\n`; + response += `_Note: Use get_account for full details_\n\n`; for (const org of result.rows) { + const lifecycleStage = computeLifecycleStage(org); response += `### ${org.name}\n`; response += `**ID:** ${org.workos_organization_id}\n`; + response += `**Lifecycle:** ${LIFECYCLE_STAGE_EMOJI[lifecycleStage]} ${lifecycleStage}\n`; response += `**Type:** ${org.company_type || 'Not set'}\n`; if (org.email_domain) response += `**Domain:** ${org.email_domain}\n`; - response += `**Status:** ${org.prospect_status || 'unknown'}\n`; if (org.prospect_contact_name) response += `**Contact:** ${org.prospect_contact_name}\n`; if (org.enrichment_industry) response += `**Industry:** ${org.enrichment_industry}\n`; if (org.enrichment_employee_count) response += `**Employees:** ${org.enrichment_employee_count.toLocaleString()}\n`;