diff --git a/.changeset/dry-singers-pull.md b/.changeset/dry-singers-pull.md new file mode 100644 index 0000000000..cb633bcc8e --- /dev/null +++ b/.changeset/dry-singers-pull.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": patch +--- + +Fix admin dashboard null reference errors, route ordering for view-counts endpoint, and capture early JavaScript errors before PostHog loads. diff --git a/server/public/admin.html b/server/public/admin.html index 591a729d55..e430365383 100644 --- a/server/public/admin.html +++ b/server/public/admin.html @@ -828,8 +828,6 @@

Dashboard

// FUNNEL document.getElementById('totalOrgs').textContent = formatNumber(stats.total_orgs); document.getElementById('prospects').textContent = formatNumber(stats.prospects); - document.getElementById('engagedProspects').textContent = formatNumber(stats.engaged_prospects); - document.getElementById('trials').textContent = formatNumber(stats.trials); document.getElementById('payingOrgs').textContent = formatNumber(stats.paying_orgs); document.getElementById('totalUsers').textContent = formatNumber(stats.total_users); document.getElementById('activeSubscriptions').textContent = formatNumber(stats.active_subscriptions); diff --git a/server/public/posthog-init.js b/server/public/posthog-init.js index c99a6bde86..379f75884a 100644 --- a/server/public/posthog-init.js +++ b/server/public/posthog-init.js @@ -93,4 +93,25 @@ is_admin: user.isAdmin, }); } + + // Flush any errors that occurred before PostHog loaded + if (window.__earlyErrors && window.__earlyErrors.length) { + window.__earlyErrors.forEach(function(err) { + if (err.type === 'unhandledrejection') { + posthog.captureException(err.reason || new Error('Unhandled rejection'), { + type: 'unhandledrejection', + early_capture: true, + }); + } else { + posthog.captureException(err.error || new Error(err.message || 'Unknown error'), { + source: err.source, + lineno: err.lineno, + colno: err.colno, + early_capture: true, + }); + } + }); + window.__earlyErrors = []; + window.onerror = null; // Clear early capture handler + } })(); diff --git a/server/src/routes/admin/accounts.ts b/server/src/routes/admin/accounts.ts index 740055f659..e10c977379 100644 --- a/server/src/routes/admin/accounts.ts +++ b/server/src/routes/admin/accounts.ts @@ -126,6 +126,153 @@ export function setupAccountRoutes( } ); + // GET /api/admin/accounts/view-counts - Get counts for each view tab + // NOTE: Must be registered BEFORE /accounts/:orgId to avoid matching "view-counts" as an orgId + apiRouter.get( + "/accounts/view-counts", + requireAuth, + requireAdmin, + async (req, res) => { + try { + const pool = getPool(); + const currentUserId = req.user?.id; + + const [ + needsAttention, + newInsights, + hot, + newProspects, + goingCold, + myAccounts, + renewals, + members, + disqualified, + ] = await Promise.all([ + // Needs attention - all accounts needing action + pool.query(` + SELECT COUNT(DISTINCT o.workos_organization_id) as count + FROM organizations o + LEFT JOIN org_activities na ON na.organization_id = o.workos_organization_id + AND na.is_next_step = TRUE + AND na.next_step_completed_at IS NULL + AND (na.next_step_due_date IS NULL OR na.next_step_due_date <= NOW() + INTERVAL '7 days') + LEFT JOIN org_invoices oi ON oi.workos_organization_id = o.workos_organization_id + AND oi.status IN ('draft', 'open') + WHERE COALESCE(o.prospect_status, 'prospect') != 'disqualified' + AND ( + na.id IS NOT NULL + OR oi.stripe_invoice_id IS NOT NULL + OR ( + COALESCE(o.engagement_score, 0) >= 50 + AND NOT EXISTS ( + SELECT 1 FROM org_stakeholders os WHERE os.organization_id = o.workos_organization_id + ) + ) + ) + `), + + // New insights - prospects with recent Slack activity + pool.query(` + SELECT COUNT(DISTINCT o.workos_organization_id) as count + FROM organizations o + WHERE EXISTS ( + SELECT 1 FROM organization_memberships om + JOIN slack_user_mappings sm ON sm.workos_user_id = om.workos_user_id + WHERE om.workos_organization_id = o.workos_organization_id + AND sm.last_slack_activity_at >= NOW() - INTERVAL '7 days' + ) + AND COALESCE(o.prospect_status, 'prospect') != 'disqualified' + AND (o.subscription_status IS NULL OR o.subscription_status NOT IN ('active', 'trialing')) + `), + + // Hot prospects + pool.query(` + SELECT COUNT(*) as count + FROM organizations o + WHERE (o.subscription_status IS NULL OR o.subscription_status NOT IN ('active', 'trialing')) + AND COALESCE(o.engagement_score, 0) >= 50 + AND o.interest_level IN ('high', 'very_high') + AND COALESCE(o.prospect_status, 'prospect') != 'disqualified' + `), + + // New prospects - recently created non-members + pool.query(` + SELECT COUNT(*) as count + FROM organizations o + WHERE o.created_at >= NOW() - INTERVAL '14 days' + AND COALESCE(o.prospect_status, 'prospect') != 'disqualified' + AND (o.subscription_status IS NULL OR o.subscription_status NOT IN ('active', 'trialing')) + `), + + // Going cold + pool.query(` + SELECT COUNT(*) as count + FROM organizations o + WHERE o.last_activity_at IS NOT NULL + AND o.last_activity_at < NOW() - INTERVAL '30 days' + AND COALESCE(o.prospect_status, 'prospect') != 'disqualified' + AND (o.subscription_status IS NULL OR o.subscription_status NOT IN ('active', 'trialing')) + `), + + // My accounts + currentUserId + ? pool.query( + ` + SELECT COUNT(DISTINCT o.workos_organization_id) as count + FROM organizations o + INNER JOIN org_stakeholders os ON os.organization_id = o.workos_organization_id AND os.user_id = $1 + WHERE COALESCE(o.prospect_status, 'prospect') != 'disqualified' + `, + [currentUserId] + ) + : Promise.resolve({ rows: [{ count: 0 }] }), + + // Renewals + pool.query(` + SELECT COUNT(*) as count + FROM organizations o + WHERE o.subscription_status = 'active' + AND o.subscription_current_period_end IS NOT NULL + AND o.subscription_current_period_end <= NOW() + INTERVAL '60 days' + AND o.subscription_current_period_end > NOW() + `), + + // Members + pool.query(` + SELECT COUNT(*) as count + FROM organizations o + WHERE o.subscription_status = 'active' + `), + + // Disqualified + pool.query(` + SELECT COUNT(*) as count + FROM organizations o + WHERE o.prospect_status = 'disqualified' + `), + ]); + + res.json({ + needs_attention: parseInt(needsAttention.rows[0].count), + new_insights: parseInt(newInsights.rows[0].count), + hot: parseInt(hot.rows[0].count), + new_prospects: parseInt(newProspects.rows[0].count), + going_cold: parseInt(goingCold.rows[0].count), + my_accounts: parseInt(myAccounts.rows[0].count), + renewals: parseInt(renewals.rows[0].count), + members: parseInt(members.rows[0].count), + disqualified: parseInt(disqualified.rows[0].count), + }); + } catch (error) { + logger.error({ err: error }, "Error fetching view counts"); + res.status(500).json({ + error: "Internal server error", + message: "Unable to fetch view counts", + }); + } + } + ); + // GET /api/admin/accounts/:orgId - Unified account detail apiRouter.get( "/accounts/:orgId", @@ -909,152 +1056,6 @@ export function setupAccountRoutes( } }); - // GET /api/admin/accounts/view-counts - Get counts for each view tab - apiRouter.get( - "/accounts/view-counts", - requireAuth, - requireAdmin, - async (req, res) => { - try { - const pool = getPool(); - const currentUserId = req.user?.id; - - const [ - needsAttention, - newInsights, - hot, - newProspects, - goingCold, - myAccounts, - renewals, - members, - disqualified, - ] = await Promise.all([ - // Needs attention - all accounts needing action - pool.query(` - SELECT COUNT(DISTINCT o.workos_organization_id) as count - FROM organizations o - LEFT JOIN org_activities na ON na.organization_id = o.workos_organization_id - AND na.is_next_step = TRUE - AND na.next_step_completed_at IS NULL - AND (na.next_step_due_date IS NULL OR na.next_step_due_date <= NOW() + INTERVAL '7 days') - LEFT JOIN org_invoices oi ON oi.workos_organization_id = o.workos_organization_id - AND oi.status IN ('draft', 'open') - WHERE COALESCE(o.prospect_status, 'prospect') != 'disqualified' - AND ( - na.id IS NOT NULL - OR oi.stripe_invoice_id IS NOT NULL - OR ( - COALESCE(o.engagement_score, 0) >= 50 - AND NOT EXISTS ( - SELECT 1 FROM org_stakeholders os WHERE os.organization_id = o.workos_organization_id - ) - ) - ) - `), - - // New insights - prospects with recent Slack activity - pool.query(` - SELECT COUNT(DISTINCT o.workos_organization_id) as count - FROM organizations o - WHERE EXISTS ( - SELECT 1 FROM organization_memberships om - JOIN slack_user_mappings sm ON sm.workos_user_id = om.workos_user_id - WHERE om.workos_organization_id = o.workos_organization_id - AND sm.last_slack_activity_at >= NOW() - INTERVAL '7 days' - ) - AND COALESCE(o.prospect_status, 'prospect') != 'disqualified' - AND (o.subscription_status IS NULL OR o.subscription_status NOT IN ('active', 'trialing')) - `), - - // Hot prospects - pool.query(` - SELECT COUNT(*) as count - FROM organizations o - WHERE (o.subscription_status IS NULL OR o.subscription_status NOT IN ('active', 'trialing')) - AND COALESCE(o.engagement_score, 0) >= 50 - AND o.interest_level IN ('high', 'very_high') - AND COALESCE(o.prospect_status, 'prospect') != 'disqualified' - `), - - // New prospects - recently created non-members - pool.query(` - SELECT COUNT(*) as count - FROM organizations o - WHERE o.created_at >= NOW() - INTERVAL '14 days' - AND COALESCE(o.prospect_status, 'prospect') != 'disqualified' - AND (o.subscription_status IS NULL OR o.subscription_status NOT IN ('active', 'trialing')) - `), - - // Going cold - pool.query(` - SELECT COUNT(*) as count - FROM organizations o - WHERE o.last_activity_at IS NOT NULL - AND o.last_activity_at < NOW() - INTERVAL '30 days' - AND COALESCE(o.prospect_status, 'prospect') != 'disqualified' - AND (o.subscription_status IS NULL OR o.subscription_status NOT IN ('active', 'trialing')) - `), - - // My accounts - currentUserId - ? pool.query( - ` - SELECT COUNT(DISTINCT o.workos_organization_id) as count - FROM organizations o - INNER JOIN org_stakeholders os ON os.organization_id = o.workos_organization_id AND os.user_id = $1 - WHERE COALESCE(o.prospect_status, 'prospect') != 'disqualified' - `, - [currentUserId] - ) - : Promise.resolve({ rows: [{ count: 0 }] }), - - // Renewals - pool.query(` - SELECT COUNT(*) as count - FROM organizations o - WHERE o.subscription_status = 'active' - AND o.subscription_current_period_end IS NOT NULL - AND o.subscription_current_period_end <= NOW() + INTERVAL '60 days' - AND o.subscription_current_period_end > NOW() - `), - - // Members - pool.query(` - SELECT COUNT(*) as count - FROM organizations o - WHERE o.subscription_status = 'active' - `), - - // Disqualified - pool.query(` - SELECT COUNT(*) as count - FROM organizations o - WHERE o.prospect_status = 'disqualified' - `), - ]); - - res.json({ - needs_attention: parseInt(needsAttention.rows[0].count), - new_insights: parseInt(newInsights.rows[0].count), - hot: parseInt(hot.rows[0].count), - new_prospects: parseInt(newProspects.rows[0].count), - going_cold: parseInt(goingCold.rows[0].count), - my_accounts: parseInt(myAccounts.rows[0].count), - renewals: parseInt(renewals.rows[0].count), - members: parseInt(members.rows[0].count), - disqualified: parseInt(disqualified.rows[0].count), - }); - } catch (error) { - logger.error({ err: error }, "Error fetching view counts"); - res.status(500).json({ - error: "Internal server error", - message: "Unable to fetch view counts", - }); - } - } - ); - /** * Convert account type between personal and team * diff --git a/server/src/utils/html-config.ts b/server/src/utils/html-config.ts index 8da68f2427..34323dda82 100644 --- a/server/src/utils/html-config.ts +++ b/server/src/utils/html-config.ts @@ -70,6 +70,18 @@ export function getAppConfigScript(user?: AppUser | null): string { return ``; } +/** + * Inline script that buffers errors before PostHog loads. + * Must run synchronously before any other scripts. + */ +const EARLY_ERROR_BUFFER_SCRIPT = ``; + /** * Inject app config into HTML string. * Inserts before or before found. @@ -78,12 +90,12 @@ export function getAppConfigScript(user?: AppUser | null): string { export function injectConfigIntoHtml(html: string, user?: AppUser | null): string { const configScript = getAppConfigScript(user); - // Add PostHog script if API key is configured - const posthogScript = POSTHOG_API_KEY - ? `` + // Add early error buffer (sync) and PostHog script (deferred) if API key is configured + const posthogScripts = POSTHOG_API_KEY + ? `${EARLY_ERROR_BUFFER_SCRIPT}\n` : ''; - const injectedScripts = `${configScript}\n${posthogScript}`; + const injectedScripts = `${configScript}\n${posthogScripts}`; if (html.includes("")) { return html.replace("", `${injectedScripts}\n`);