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

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