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
5 changes: 5 additions & 0 deletions .changeset/dry-singers-pull.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 0 additions & 2 deletions server/public/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -828,8 +828,6 @@ <h1>Dashboard</h1>
// 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);
Expand Down
21 changes: 21 additions & 0 deletions server/public/posthog-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
})();
293 changes: 147 additions & 146 deletions server/src/routes/admin/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
*
Expand Down
20 changes: 16 additions & 4 deletions server/src/utils/html-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ export function getAppConfigScript(user?: AppUser | null): string {
return `<script>window.__APP_CONFIG__=${JSON.stringify(config)};</script>`;
}

/**
* Inline script that buffers errors before PostHog loads.
* Must run synchronously before any other scripts.
*/
const EARLY_ERROR_BUFFER_SCRIPT = `<script>
(function(){
window.__earlyErrors=[];
window.onerror=function(m,u,l,c,e){window.__earlyErrors.push({message:m,source:u,lineno:l,colno:c,error:e});};
window.addEventListener('unhandledrejection',function(e){window.__earlyErrors.push({type:'unhandledrejection',reason:e.reason});});
})();
</script>`;

/**
* Inject app config into HTML string.
* Inserts before </head> or before <body if no </head> found.
Expand All @@ -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
? `<script src="/posthog-init.js" defer></script>`
// Add early error buffer (sync) and PostHog script (deferred) if API key is configured
const posthogScripts = POSTHOG_API_KEY
? `${EARLY_ERROR_BUFFER_SCRIPT}\n<script src="/posthog-init.js" defer></script>`
: '';

const injectedScripts = `${configScript}\n${posthogScript}`;
const injectedScripts = `${configScript}\n${posthogScripts}`;

if (html.includes("</head>")) {
return html.replace("</head>", `${injectedScripts}\n</head>`);
Expand Down