- Database: Add login_history, ai_recommendation_log tables; is_platform_owner column on users; subscription fields on organizations (payment_date, confirmation_number, renewal_date) - Backend: New AdminAnalyticsService with platform metrics, tenant detail, and health score calculations (0-100 based on activity, budget, transactions, members, AI usage) - Backend: Login/org-switch now records to login_history; AI recommendations logged to ai_recommendation_log; platform owner protected from superadmin toggle - Frontend: 4-tab admin panel (Dashboard, Organizations, Users, Tenant Health) with tenant detail drawer, subscription management, health scoring visualization - Platform owner account (admin@hoaledgeriq.com) auto-redirects to admin panel - Seed data includes platform owner account and sample login history Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { DataSource } from 'typeorm';
|
|
|
|
@Injectable()
|
|
export class AdminAnalyticsService {
|
|
private readonly logger = new Logger(AdminAnalyticsService.name);
|
|
|
|
constructor(private dataSource: DataSource) {}
|
|
|
|
/**
|
|
* Platform-wide metrics for the admin dashboard.
|
|
*/
|
|
async getPlatformMetrics() {
|
|
const [
|
|
userStats,
|
|
orgStats,
|
|
planBreakdown,
|
|
statusBreakdown,
|
|
newTenantsPerMonth,
|
|
newUsersPerMonth,
|
|
aiStats,
|
|
activeUsers30d,
|
|
] = await Promise.all([
|
|
this.dataSource.query(`
|
|
SELECT
|
|
COUNT(*) as total_users,
|
|
COUNT(*) FILTER (WHERE is_superadmin = true) as superadmin_count,
|
|
COUNT(*) FILTER (WHERE is_platform_owner = true) as platform_owner_count
|
|
FROM shared.users
|
|
`),
|
|
this.dataSource.query(`
|
|
SELECT
|
|
COUNT(*) as total_organizations,
|
|
COUNT(*) FILTER (WHERE status = 'active') as active_count,
|
|
COUNT(*) FILTER (WHERE status = 'archived') as archived_count,
|
|
COUNT(*) FILTER (WHERE status = 'suspended') as suspended_count,
|
|
COUNT(*) FILTER (WHERE status = 'trial') as trial_count
|
|
FROM shared.organizations
|
|
`),
|
|
this.dataSource.query(`
|
|
SELECT plan_level, COUNT(*) as count
|
|
FROM shared.organizations
|
|
WHERE status != 'archived'
|
|
GROUP BY plan_level
|
|
ORDER BY count DESC
|
|
`),
|
|
this.dataSource.query(`
|
|
SELECT status, COUNT(*) as count
|
|
FROM shared.organizations
|
|
GROUP BY status
|
|
ORDER BY count DESC
|
|
`),
|
|
this.dataSource.query(`
|
|
SELECT
|
|
DATE_TRUNC('month', created_at) as month,
|
|
COUNT(*) as count
|
|
FROM shared.organizations
|
|
WHERE created_at > NOW() - INTERVAL '6 months'
|
|
GROUP BY DATE_TRUNC('month', created_at)
|
|
ORDER BY month DESC
|
|
`),
|
|
this.dataSource.query(`
|
|
SELECT
|
|
DATE_TRUNC('month', created_at) as month,
|
|
COUNT(*) as count
|
|
FROM shared.users
|
|
WHERE created_at > NOW() - INTERVAL '6 months'
|
|
GROUP BY DATE_TRUNC('month', created_at)
|
|
ORDER BY month DESC
|
|
`),
|
|
this.dataSource.query(`
|
|
SELECT
|
|
COUNT(*) as total_requests,
|
|
COUNT(*) FILTER (WHERE status = 'success') as successful,
|
|
ROUND(AVG(response_time_ms)) as avg_response_ms
|
|
FROM shared.ai_recommendation_log
|
|
WHERE requested_at > NOW() - INTERVAL '30 days'
|
|
`),
|
|
this.dataSource.query(`
|
|
SELECT COUNT(DISTINCT user_id) as count
|
|
FROM shared.login_history
|
|
WHERE logged_in_at > NOW() - INTERVAL '30 days'
|
|
`),
|
|
]);
|
|
|
|
return {
|
|
totalUsers: parseInt(userStats[0]?.total_users || '0'),
|
|
superadminCount: parseInt(userStats[0]?.superadmin_count || '0'),
|
|
platformOwnerCount: parseInt(userStats[0]?.platform_owner_count || '0'),
|
|
activeUsers30d: parseInt(activeUsers30d[0]?.count || '0'),
|
|
totalOrganizations: parseInt(orgStats[0]?.total_organizations || '0'),
|
|
activeOrganizations: parseInt(orgStats[0]?.active_count || '0'),
|
|
archivedOrganizations: parseInt(orgStats[0]?.archived_count || '0'),
|
|
suspendedOrganizations: parseInt(orgStats[0]?.suspended_count || '0'),
|
|
trialOrganizations: parseInt(orgStats[0]?.trial_count || '0'),
|
|
planBreakdown: planBreakdown.map((r: any) => ({
|
|
plan: r.plan_level,
|
|
count: parseInt(r.count),
|
|
})),
|
|
statusBreakdown: statusBreakdown.map((r: any) => ({
|
|
status: r.status,
|
|
count: parseInt(r.count),
|
|
})),
|
|
newTenantsPerMonth: newTenantsPerMonth.map((r: any) => ({
|
|
month: r.month,
|
|
count: parseInt(r.count),
|
|
})),
|
|
newUsersPerMonth: newUsersPerMonth.map((r: any) => ({
|
|
month: r.month,
|
|
count: parseInt(r.count),
|
|
})),
|
|
aiRequestsLast30d: parseInt(aiStats[0]?.total_requests || '0'),
|
|
aiSuccessfulLast30d: parseInt(aiStats[0]?.successful || '0'),
|
|
aiAvgResponseMs: parseInt(aiStats[0]?.avg_response_ms || '0'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Detailed analytics for a specific tenant/organization.
|
|
*/
|
|
async getTenantDetail(orgId: string) {
|
|
const [orgInfo, loginStats, weeklyLogins, monthlyLogins, aiCount, memberCount] = await Promise.all([
|
|
this.dataSource.query(
|
|
`SELECT o.*, (SELECT MAX(logged_in_at) FROM shared.login_history WHERE organization_id = o.id) as last_login
|
|
FROM shared.organizations o WHERE o.id = $1`,
|
|
[orgId],
|
|
),
|
|
this.dataSource.query(
|
|
`SELECT
|
|
COUNT(*) FILTER (WHERE logged_in_at > NOW() - INTERVAL '7 days') as logins_this_week,
|
|
COUNT(*) FILTER (WHERE logged_in_at > NOW() - INTERVAL '30 days') as logins_this_month,
|
|
COUNT(DISTINCT user_id) FILTER (WHERE logged_in_at > NOW() - INTERVAL '30 days') as active_users_30d
|
|
FROM shared.login_history WHERE organization_id = $1`,
|
|
[orgId],
|
|
),
|
|
this.dataSource.query(
|
|
`SELECT
|
|
DATE_TRUNC('week', logged_in_at) as week,
|
|
COUNT(*) as count
|
|
FROM shared.login_history
|
|
WHERE organization_id = $1 AND logged_in_at > NOW() - INTERVAL '4 weeks'
|
|
GROUP BY DATE_TRUNC('week', logged_in_at)
|
|
ORDER BY week DESC`,
|
|
[orgId],
|
|
),
|
|
this.dataSource.query(
|
|
`SELECT
|
|
DATE_TRUNC('month', logged_in_at) as month,
|
|
COUNT(*) as count
|
|
FROM shared.login_history
|
|
WHERE organization_id = $1 AND logged_in_at > NOW() - INTERVAL '6 months'
|
|
GROUP BY DATE_TRUNC('month', logged_in_at)
|
|
ORDER BY month DESC`,
|
|
[orgId],
|
|
),
|
|
this.dataSource.query(
|
|
`SELECT COUNT(*) as count
|
|
FROM shared.ai_recommendation_log
|
|
WHERE organization_id = $1 AND requested_at > NOW() - INTERVAL '30 days'`,
|
|
[orgId],
|
|
),
|
|
this.dataSource.query(
|
|
`SELECT COUNT(*) as count FROM shared.user_organizations WHERE organization_id = $1 AND is_active = true`,
|
|
[orgId],
|
|
),
|
|
]);
|
|
|
|
const org = orgInfo[0];
|
|
if (!org) return null;
|
|
|
|
// Cross-schema queries for tenant financial data
|
|
let cashOnHand = 0;
|
|
let hasBudget = false;
|
|
let recentTransactions = 0;
|
|
|
|
try {
|
|
const cashResult = await this.dataSource.query(`
|
|
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
|
FROM "${org.schema_name}".accounts a
|
|
JOIN "${org.schema_name}".journal_entry_lines jel ON jel.account_id = a.id
|
|
JOIN "${org.schema_name}".journal_entries je ON je.id = jel.journal_entry_id
|
|
AND je.is_posted = true AND je.is_void = false
|
|
WHERE a.account_type = 'asset' AND a.is_active = true
|
|
GROUP BY a.id
|
|
) sub
|
|
`);
|
|
cashOnHand = parseFloat(cashResult[0]?.total || '0');
|
|
|
|
const budgetResult = await this.dataSource.query(
|
|
`SELECT COUNT(*) as count FROM "${org.schema_name}".budgets WHERE fiscal_year = $1`,
|
|
[new Date().getFullYear()],
|
|
);
|
|
hasBudget = parseInt(budgetResult[0]?.count || '0') > 0;
|
|
|
|
const txnResult = await this.dataSource.query(`
|
|
SELECT COUNT(*) as count
|
|
FROM "${org.schema_name}".journal_entries
|
|
WHERE is_posted = true AND entry_date > NOW() - INTERVAL '30 days'
|
|
`);
|
|
recentTransactions = parseInt(txnResult[0]?.count || '0');
|
|
} catch (err) {
|
|
this.logger.warn(`Failed to query tenant schema ${org.schema_name}: ${err.message}`);
|
|
}
|
|
|
|
return {
|
|
organization: org,
|
|
lastLogin: org.last_login,
|
|
loginsThisWeek: parseInt(loginStats[0]?.logins_this_week || '0'),
|
|
loginsThisMonth: parseInt(loginStats[0]?.logins_this_month || '0'),
|
|
activeUsers30d: parseInt(loginStats[0]?.active_users_30d || '0'),
|
|
weeklyLogins: weeklyLogins.map((r: any) => ({
|
|
week: r.week,
|
|
count: parseInt(r.count),
|
|
})),
|
|
monthlyLogins: monthlyLogins.map((r: any) => ({
|
|
month: r.month,
|
|
count: parseInt(r.count),
|
|
})),
|
|
aiRecommendations30d: parseInt(aiCount[0]?.count || '0'),
|
|
memberCount: parseInt(memberCount[0]?.count || '0'),
|
|
cashOnHand,
|
|
hasBudget,
|
|
recentTransactions,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* All tenants with health scores for the Health & Adoption tab.
|
|
*
|
|
* Health Score (0-100):
|
|
* Active users 30d ≥ 1 → 25pts
|
|
* Has current-year budget → 25pts
|
|
* Journal entries 30d ≥ 1 → 25pts
|
|
* 2+ active members → 15pts
|
|
* AI usage 30d ≥ 1 → 10pts
|
|
*/
|
|
async getAllTenantsHealth() {
|
|
const orgs = await this.dataSource.query(`
|
|
SELECT
|
|
o.id, o.name, o.schema_name, o.status, o.plan_level, o.created_at,
|
|
o.payment_date, o.renewal_date,
|
|
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id AND is_active = true) as member_count,
|
|
(SELECT MAX(lh.logged_in_at) FROM shared.login_history lh WHERE lh.organization_id = o.id) as last_login,
|
|
(SELECT COUNT(DISTINCT lh.user_id) FROM shared.login_history lh WHERE lh.organization_id = o.id AND lh.logged_in_at > NOW() - INTERVAL '30 days') as active_users_30d,
|
|
(SELECT COUNT(*) FROM shared.ai_recommendation_log ar WHERE ar.organization_id = o.id AND ar.requested_at > NOW() - INTERVAL '30 days') as ai_usage_30d
|
|
FROM shared.organizations o
|
|
WHERE o.status != 'archived'
|
|
ORDER BY o.name
|
|
`);
|
|
|
|
const currentYear = new Date().getFullYear();
|
|
const results = [];
|
|
|
|
for (const org of orgs) {
|
|
let cashOnHand = 0;
|
|
let hasBudget = false;
|
|
let journalEntries30d = 0;
|
|
|
|
try {
|
|
const cashResult = await this.dataSource.query(`
|
|
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
|
FROM "${org.schema_name}".accounts a
|
|
JOIN "${org.schema_name}".journal_entry_lines jel ON jel.account_id = a.id
|
|
JOIN "${org.schema_name}".journal_entries je ON je.id = jel.journal_entry_id
|
|
AND je.is_posted = true AND je.is_void = false
|
|
WHERE a.account_type = 'asset' AND a.is_active = true
|
|
GROUP BY a.id
|
|
) sub
|
|
`);
|
|
cashOnHand = parseFloat(cashResult[0]?.total || '0');
|
|
|
|
const budgetResult = await this.dataSource.query(
|
|
`SELECT COUNT(*) as count FROM "${org.schema_name}".budgets WHERE fiscal_year = $1`,
|
|
[currentYear],
|
|
);
|
|
hasBudget = parseInt(budgetResult[0]?.count || '0') > 0;
|
|
|
|
const jeResult = await this.dataSource.query(`
|
|
SELECT COUNT(*) as count
|
|
FROM "${org.schema_name}".journal_entries
|
|
WHERE is_posted = true AND entry_date > NOW() - INTERVAL '30 days'
|
|
`);
|
|
journalEntries30d = parseInt(jeResult[0]?.count || '0');
|
|
} catch (err) {
|
|
// Schema may not exist yet (new tenant)
|
|
this.logger.warn(`Health check skip for ${org.schema_name}: ${err.message}`);
|
|
}
|
|
|
|
// Calculate health score
|
|
const activeUsers = parseInt(org.active_users_30d) || 0;
|
|
const memberCount = parseInt(org.member_count) || 0;
|
|
const aiUsage = parseInt(org.ai_usage_30d) || 0;
|
|
|
|
let healthScore = 0;
|
|
if (activeUsers >= 1) healthScore += 25;
|
|
if (hasBudget) healthScore += 25;
|
|
if (journalEntries30d >= 1) healthScore += 25;
|
|
if (memberCount >= 2) healthScore += 15;
|
|
if (aiUsage >= 1) healthScore += 10;
|
|
|
|
results.push({
|
|
id: org.id,
|
|
name: org.name,
|
|
schemaName: org.schema_name,
|
|
status: org.status,
|
|
planLevel: org.plan_level,
|
|
createdAt: org.created_at,
|
|
paymentDate: org.payment_date,
|
|
renewalDate: org.renewal_date,
|
|
memberCount,
|
|
lastLogin: org.last_login,
|
|
activeUsers30d: activeUsers,
|
|
aiUsage30d: aiUsage,
|
|
cashOnHand,
|
|
hasBudget,
|
|
journalEntries30d,
|
|
healthScore,
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
}
|