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/sunny-bears-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
65 changes: 18 additions & 47 deletions server/public/admin-analytics.html
Original file line number Diff line number Diff line change
Expand Up @@ -224,20 +224,20 @@ <h1>Analytics Dashboard</h1>
<!-- Metrics Grid -->
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">Total Revenue</div>
<div class="metric-value" id="total-revenue">$0</div>
<div class="metric-label">Annual Recurring Revenue</div>
<div class="metric-value" id="arr">$0</div>
</div>
<div class="metric-card">
<div class="metric-label">Active Customers</div>
<div class="metric-value" id="active-customers">0</div>
</div>
<div class="metric-card">
<div class="metric-label">Monthly Recurring Revenue</div>
<div class="metric-value" id="mrr">$0</div>
<div class="metric-label">New Bookings (30 days)</div>
<div class="metric-value" id="new-bookings">0</div>
</div>
<div class="metric-card">
<div class="metric-label">Total Customers</div>
<div class="metric-value" id="total-customers">0</div>
<div class="metric-label">Total Revenue</div>
<div class="metric-value" id="total-revenue">$0</div>
</div>
</div>

Expand All @@ -248,25 +248,10 @@ <h2>Revenue by Month (Last 12 Months)</h2>
<thead>
<tr>
<th>Month</th>
<th>Revenue</th>
<th>New Customers</th>
<th>Churned Customers</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>

<!-- Customer Health -->
<div class="section">
<h2>Customer Health</h2>
<table id="customer-table">
<thead>
<tr>
<th>Organization</th>
<th>Status</th>
<th>MRR</th>
<th>Customer Since</th>
<th>Gross Revenue</th>
<th>Refunds</th>
<th>Net Revenue</th>
<th>Paying Customers</th>
</tr>
</thead>
<tbody></tbody>
Expand Down Expand Up @@ -329,39 +314,25 @@ <h2>Revenue by Product</h2>

// Update metrics
const metrics = data.subscription_metrics || {};
document.getElementById('total-revenue').textContent = formatCurrency(metrics.total_revenue || 0);
document.getElementById('arr').textContent = formatCurrency(metrics.total_arr || 0);
document.getElementById('active-customers').textContent = metrics.active_subscriptions || 0;
document.getElementById('mrr').textContent = formatCurrency(metrics.mrr || 0);
document.getElementById('total-customers').textContent = metrics.total_customers || 0;
document.getElementById('new-bookings').textContent = metrics.new_bookings_30d || 0;
document.getElementById('total-revenue').textContent = formatCurrency(metrics.total_revenue || 0);

// Populate revenue by month table
const revenueTable = document.getElementById('revenue-table').querySelector('tbody');
if (data.revenue_by_month && data.revenue_by_month.length > 0) {
revenueTable.innerHTML = data.revenue_by_month.map(row => `
<tr>
<td>${formatMonth(row.month)}</td>
<td>${formatCurrency(row.total_revenue)}</td>
<td>${row.new_customers || 0}</td>
<td>${row.churned_customers || 0}</td>
</tr>
`).join('');
} else {
revenueTable.innerHTML = '<tr><td colspan="4" style="text-align: center; color: var(--color-text-secondary);">No revenue data yet</td></tr>';
}

// Populate customer health table
const customerTable = document.getElementById('customer-table').querySelector('tbody');
if (data.customer_health && data.customer_health.length > 0) {
customerTable.innerHTML = data.customer_health.slice(0, 20).map(row => `
<tr>
<td>${row.company_name || 'N/A'}</td>
<td><span style="color: ${row.subscription_status === 'active' ? 'var(--color-success-500)' : 'var(--color-text-secondary)'}">${row.subscription_status || 'N/A'}</span></td>
<td>${formatCurrency(row.mrr)}</td>
<td>${formatDate(row.customer_since)}</td>
<td>${formatCurrency(row.gross_revenue)}</td>
<td>${formatCurrency(row.refunds)}</td>
<td>${formatCurrency(row.net_revenue)}</td>
<td>${row.paying_customers || 0}</td>
</tr>
`).join('');
} else {
customerTable.innerHTML = '<tr><td colspan="4" style="text-align: center; color: var(--color-text-secondary);">No customer data yet</td></tr>';
revenueTable.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--color-text-secondary);">No revenue data yet</td></tr>';
}

// Populate product revenue table
Expand Down
7 changes: 6 additions & 1 deletion server/src/db/migrations/081_unified_contacts_view.sql
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,12 @@ SELECT
'user' as contact_type,
u.workos_user_id,
u.email,
COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '') as full_name,
COALESCE(
NULLIF(TRIM(COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')), ''),
sm.slack_real_name,
sm.slack_display_name,
SPLIT_PART(u.email, '@', 1)
) as full_name,
u.first_name,
u.last_name,

Expand Down
192 changes: 192 additions & 0 deletions server/src/db/migrations/082_fix_contact_full_name.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
-- Migration: 082_fix_contact_full_name.sql
-- Fix full_name fallback in unified_contacts view
-- When WorkOS first/last name is empty, fall back to Slack name or email username

-- Drop dependent view first
DROP VIEW IF EXISTS unified_contacts_with_goals;

-- Recreate unified_contacts with better full_name logic
CREATE OR REPLACE VIEW unified_contacts AS

-- Users with full accounts
SELECT
'user' as contact_type,
u.workos_user_id,
u.email,
COALESCE(
NULLIF(TRIM(COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')), ''),
sm.slack_real_name,
sm.slack_display_name,
SPLIT_PART(u.email, '@', 1)
) as full_name,
u.first_name,
u.last_name,

-- Slack identity
u.primary_slack_user_id as slack_user_id,
sm.slack_display_name,
sm.slack_real_name,
CASE WHEN sm.mapping_status = 'mapped' THEN TRUE ELSE FALSE END as is_slack_mapped,

-- Organization
u.primary_organization_id as organization_id,
o.name as organization_name,
CASE WHEN o.subscription_status = 'active' THEN TRUE ELSE FALSE END as is_paying,

-- Scores
u.engagement_score,
u.excitement_score,
u.lifecycle_stage,
u.slack_activity_score,
u.email_engagement_score,
u.conversation_score,
u.community_score,
u.scores_computed_at,

-- Activity timestamps
sm.last_slack_activity_at,
(SELECT MAX(created_at) FROM addie_threads t
WHERE (t.user_type = 'workos' AND t.user_id = u.workos_user_id)
OR (t.user_type = 'slack' AND t.user_id = u.primary_slack_user_id)) as last_conversation_at,
sm.last_outreach_at,

-- Counts
(SELECT COUNT(*) FROM member_insights mi
WHERE mi.workos_user_id = u.workos_user_id AND mi.is_current = TRUE) as insight_count,
(SELECT COUNT(*) FROM addie_threads t
WHERE (t.user_type = 'workos' AND t.user_id = u.workos_user_id)
OR (t.user_type = 'slack' AND t.user_id = u.primary_slack_user_id)) as conversation_count,

u.created_at,
u.updated_at

FROM users u
LEFT JOIN slack_user_mappings sm ON sm.slack_user_id = u.primary_slack_user_id
LEFT JOIN organizations o ON o.workos_organization_id = u.primary_organization_id

UNION ALL

-- Slack-only contacts (not linked to WorkOS account)
SELECT
'slack_only' as contact_type,
NULL as workos_user_id,
sm.slack_email as email,
COALESCE(sm.slack_real_name, sm.slack_display_name, sm.slack_email, 'Unknown') as full_name,
NULL as first_name,
NULL as last_name,

-- Slack identity
sm.slack_user_id,
sm.slack_display_name,
sm.slack_real_name,
FALSE as is_slack_mapped,

-- Organization (none for Slack-only)
NULL as organization_id,
NULL as organization_name,
FALSE as is_paying,

-- No scores for Slack-only
NULL::INTEGER as engagement_score,
NULL::INTEGER as excitement_score,
NULL::VARCHAR(20) as lifecycle_stage,
NULL::INTEGER as slack_activity_score,
NULL::INTEGER as email_engagement_score,
NULL::INTEGER as conversation_score,
NULL::INTEGER as community_score,
NULL::TIMESTAMP WITH TIME ZONE as scores_computed_at,

-- Activity
sm.last_slack_activity_at,
(SELECT MAX(created_at) FROM addie_threads t
WHERE t.user_type = 'slack' AND t.user_id = sm.slack_user_id) as last_conversation_at,
sm.last_outreach_at,

-- Counts
(SELECT COUNT(*) FROM member_insights mi
WHERE mi.slack_user_id = sm.slack_user_id AND mi.is_current = TRUE) as insight_count,
(SELECT COUNT(*) FROM addie_threads t
WHERE t.user_type = 'slack' AND t.user_id = sm.slack_user_id) as conversation_count,

sm.created_at,
sm.updated_at

FROM slack_user_mappings sm
WHERE sm.workos_user_id IS NULL
AND sm.slack_is_bot = FALSE
AND sm.slack_is_deleted = FALSE

UNION ALL

-- Email-only contacts (from email activities, not in Slack or WorkOS)
SELECT
'email_only' as contact_type,
NULL as workos_user_id,
ec.email,
COALESCE(ec.name, SPLIT_PART(ec.email, '@', 1)) as full_name,
NULL as first_name,
NULL as last_name,

-- No Slack identity
NULL as slack_user_id,
NULL as slack_display_name,
NULL as slack_real_name,
FALSE as is_slack_mapped,

-- Organization if we've linked the domain
ec.organization_id,
o.name as organization_name,
CASE WHEN o.subscription_status = 'active' THEN TRUE ELSE FALSE END as is_paying,

-- No scores for email-only
NULL::INTEGER as engagement_score,
NULL::INTEGER as excitement_score,
NULL::VARCHAR(20) as lifecycle_stage,
NULL::INTEGER as slack_activity_score,
NULL::INTEGER as email_engagement_score,
NULL::INTEGER as conversation_score,
NULL::INTEGER as community_score,
NULL::TIMESTAMP WITH TIME ZONE as scores_computed_at,

-- Activity
NULL as last_slack_activity_at,
NULL as last_conversation_at,
NULL as last_outreach_at,

-- Counts
0 as insight_count,
0 as conversation_count,

ec.created_at,
ec.updated_at

FROM email_contacts ec
LEFT JOIN organizations o ON o.workos_organization_id = ec.organization_id
WHERE NOT EXISTS (
SELECT 1 FROM users u WHERE LOWER(u.email) = LOWER(ec.email)
)
AND NOT EXISTS (
SELECT 1 FROM slack_user_mappings sm WHERE LOWER(sm.slack_email) = LOWER(ec.email)
);

COMMENT ON VIEW unified_contacts IS 'All contacts from users, Slack, and email with fallback names';

-- Recreate the with_goals view
CREATE OR REPLACE VIEW unified_contacts_with_goals AS
SELECT
uc.*,
g.goal_key,
g.name as goal_name,
g.priority as goal_priority,
g.prompt_template as goal_prompt,
g.reasoning as goal_reasoning
FROM unified_contacts uc
CROSS JOIN LATERAL select_addie_goal(
uc.workos_user_id IS NOT NULL,
uc.is_slack_mapped,
COALESCE(uc.engagement_score, 0),
COALESCE(uc.excitement_score, 0),
uc.is_paying
) g;

COMMENT ON VIEW unified_contacts_with_goals IS 'Contacts with dynamically selected Addie goals';
Loading