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; } }