Add comprehensive platform administration panel

- 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>
This commit is contained in:
2026-02-26 08:51:39 -05:00
parent 0bd30a0eb8
commit a32d4cc179
20 changed files with 3183 additions and 317 deletions

View File

@@ -0,0 +1,325 @@
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;
}
}

View File

@@ -3,6 +3,7 @@ import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { UsersService } from '../users/users.service';
import { OrganizationsService } from '../organizations/organizations.service';
import { AdminAnalyticsService } from './admin-analytics.service';
import * as bcrypt from 'bcryptjs';
@ApiTags('admin')
@@ -13,6 +14,7 @@ export class AdminController {
constructor(
private usersService: UsersService,
private orgService: OrganizationsService,
private analyticsService: AdminAnalyticsService,
) {}
private async requireSuperadmin(req: any) {
@@ -22,25 +24,76 @@ export class AdminController {
}
}
// ── Platform Metrics ──
@Get('metrics')
async getPlatformMetrics(@Req() req: any) {
await this.requireSuperadmin(req);
return this.analyticsService.getPlatformMetrics();
}
// ── Users ──
@Get('users')
async listUsers(@Req() req: any) {
await this.requireSuperadmin(req);
const users = await this.usersService.findAllUsers();
return users.map(u => ({
id: u.id, email: u.email, firstName: u.firstName, lastName: u.lastName,
isSuperadmin: u.isSuperadmin, lastLoginAt: u.lastLoginAt, createdAt: u.createdAt,
isSuperadmin: u.isSuperadmin, isPlatformOwner: u.isPlatformOwner || false,
lastLoginAt: u.lastLoginAt, createdAt: u.createdAt,
organizations: u.userOrganizations?.map(uo => ({
id: uo.organizationId, name: uo.organization?.name, role: uo.role,
})) || [],
}));
}
// ── Organizations ──
@Get('organizations')
async listOrganizations(@Req() req: any) {
await this.requireSuperadmin(req);
return this.usersService.findAllOrganizations();
}
@Get('organizations/:id/detail')
async getTenantDetail(@Req() req: any, @Param('id') id: string) {
await this.requireSuperadmin(req);
const detail = await this.analyticsService.getTenantDetail(id);
if (!detail) {
throw new BadRequestException('Organization not found');
}
return detail;
}
@Put('organizations/:id/subscription')
async updateSubscription(
@Req() req: any,
@Param('id') id: string,
@Body() body: { paymentDate?: string; confirmationNumber?: string; renewalDate?: string },
) {
await this.requireSuperadmin(req);
const org = await this.orgService.updateSubscription(id, body);
return { success: true, organization: org };
}
@Put('organizations/:id/status')
async updateOrgStatus(
@Req() req: any,
@Param('id') id: string,
@Body() body: { status: string },
) {
await this.requireSuperadmin(req);
const validStatuses = ['active', 'suspended', 'trial', 'archived'];
if (!validStatuses.includes(body.status)) {
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
}
const org = await this.orgService.updateStatus(id, body.status);
return { success: true, organization: org };
}
// ── Superadmin Toggle ──
@Post('users/:id/superadmin')
async toggleSuperadmin(@Req() req: any, @Param('id') id: string, @Body() body: { isSuperadmin: boolean }) {
await this.requireSuperadmin(req);
@@ -48,6 +101,16 @@ export class AdminController {
return { success: true };
}
// ── Tenant Health ──
@Get('tenants-health')
async getTenantsHealth(@Req() req: any) {
await this.requireSuperadmin(req);
return this.analyticsService.getAllTenantsHealth();
}
// ── Create Tenant ──
@Post('tenants')
async createTenant(@Req() req: any, @Body() body: {
orgName: string;
@@ -105,19 +168,4 @@ export class AdminController {
return { success: true, organization: org };
}
@Put('organizations/:id/status')
async updateOrgStatus(
@Req() req: any,
@Param('id') id: string,
@Body() body: { status: string },
) {
await this.requireSuperadmin(req);
const validStatuses = ['active', 'suspended', 'trial', 'archived'];
if (!validStatuses.includes(body.status)) {
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
}
const org = await this.orgService.updateStatus(id, body.status);
return { success: true, organization: org };
}
}

View File

@@ -29,7 +29,9 @@ export class AuthController {
@ApiOperation({ summary: 'Login with email and password' })
@UseGuards(AuthGuard('local'))
async login(@Request() req: any, @Body() _dto: LoginDto) {
return this.authService.login(req.user);
const ip = req.headers['x-forwarded-for'] || req.ip;
const ua = req.headers['user-agent'];
return this.authService.login(req.user, ip, ua);
}
@Get('profile')
@@ -45,6 +47,8 @@ export class AuthController {
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
return this.authService.switchOrganization(req.user.sub, dto.organizationId);
const ip = req.headers['x-forwarded-for'] || req.ip;
const ua = req.headers['user-agent'];
return this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
}
}

View File

@@ -5,6 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AdminController } from './admin.controller';
import { AuthService } from './auth.service';
import { AdminAnalyticsService } from './admin-analytics.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
import { UsersModule } from '../users/users.module';
@@ -25,7 +26,7 @@ import { OrganizationsModule } from '../organizations/organizations.module';
}),
],
controllers: [AuthController, AdminController],
providers: [AuthService, JwtStrategy, LocalStrategy],
providers: [AuthService, AdminAnalyticsService, JwtStrategy, LocalStrategy],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -4,6 +4,7 @@ import {
ConflictException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { DataSource } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { UsersService } from '../users/users.service';
import { RegisterDto } from './dto/register.dto';
@@ -14,6 +15,7 @@ export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
private dataSource: DataSource,
) {}
async register(dto: RegisterDto) {
@@ -47,7 +49,7 @@ export class AuthService {
return user;
}
async login(user: User) {
async login(user: User, ipAddress?: string, userAgent?: string) {
await this.usersService.updateLastLogin(user.id);
const fullUser = await this.usersService.findByIdWithOrgs(user.id);
const u = fullUser || user;
@@ -65,6 +67,9 @@ export class AuthService {
}
}
// Record login in history (org_id is null at initial login)
this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {});
return this.generateTokenResponse(u);
}
@@ -86,7 +91,7 @@ export class AuthService {
};
}
async switchOrganization(userId: string, organizationId: string) {
async switchOrganization(userId: string, organizationId: string, ipAddress?: string, userAgent?: string) {
const user = await this.usersService.findByIdWithOrgs(userId);
if (!user) {
throw new UnauthorizedException('User not found');
@@ -107,6 +112,9 @@ export class AuthService {
role: membership.role,
};
// Record org switch in login history
this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {});
return {
accessToken: this.jwtService.sign(payload),
organization: {
@@ -117,6 +125,23 @@ export class AuthService {
};
}
private async recordLoginHistory(
userId: string,
organizationId: string | null,
ipAddress?: string,
userAgent?: string,
) {
try {
await this.dataSource.query(
`INSERT INTO shared.login_history (user_id, organization_id, ip_address, user_agent)
VALUES ($1, $2, $3, $4)`,
[userId, organizationId, ipAddress || null, userAgent || null],
);
} catch (err) {
// Non-critical — don't let login history failure block auth
}
}
private generateTokenResponse(user: User) {
const orgs = user.userOrganizations || [];
const defaultOrg = orgs[0];
@@ -141,6 +166,7 @@ export class AuthService {
firstName: user.firstName,
lastName: user.lastName,
isSuperadmin: user.isSuperadmin || false,
isPlatformOwner: user.isPlatformOwner || false,
},
organizations: orgs.map((uo) => ({
id: uo.organizationId,