import { Injectable, NotFoundException, ConflictException, BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, ILike, FindOptionsWhere } from 'typeorm'; import { Tenant } from '../tenants/entities/tenant.entity'; import { User } from '../auth/entities/user.entity'; import { Subscription } from '../billing/entities/subscription.entity'; import { CreateTenantDto, UpdateTenantDto, UpdateTenantStatusDto, ListTenantsQueryDto, } from './dto'; export interface TenantWithStats extends Tenant { userCount?: number; subscription?: Subscription | null; } export interface PaginatedResult { data: T[]; total: number; page: number; limit: number; totalPages: number; } @Injectable() export class SuperadminService { constructor( @InjectRepository(Tenant) private readonly tenantRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, @InjectRepository(Subscription) private readonly subscriptionRepository: Repository, ) {} async listTenants(query: ListTenantsQueryDto): Promise> { const { page = 1, limit = 10, search, status, sortBy = 'created_at', sortOrder = 'DESC' } = query; const where: FindOptionsWhere = {}; if (search) { // Search by name or slug where.name = ILike(`%${search}%`); } if (status) { where.status = status; } const [tenants, total] = await this.tenantRepository.findAndCount({ where, order: { [sortBy]: sortOrder }, skip: (page - 1) * limit, take: limit, }); // Get user counts for each tenant const tenantsWithStats: TenantWithStats[] = await Promise.all( tenants.map(async (tenant) => { const userCount = await this.userRepository.count({ where: { tenant_id: tenant.id }, }); const subscription = await this.subscriptionRepository.findOne({ where: { tenant_id: tenant.id }, relations: ['plan'], }); return { ...tenant, userCount, subscription, }; }), ); return { data: tenantsWithStats, total, page, limit, totalPages: Math.ceil(total / limit), }; } async getTenant(id: string): Promise { const tenant = await this.tenantRepository.findOne({ where: { id }, }); if (!tenant) { throw new NotFoundException('Tenant not found'); } const userCount = await this.userRepository.count({ where: { tenant_id: id }, }); const subscription = await this.subscriptionRepository.findOne({ where: { tenant_id: id }, relations: ['plan'], }); return { ...tenant, userCount, subscription, }; } async createTenant(dto: CreateTenantDto): Promise { // Check if slug is unique const existingTenant = await this.tenantRepository.findOne({ where: { slug: dto.slug }, }); if (existingTenant) { throw new ConflictException('A tenant with this slug already exists'); } const tenant = this.tenantRepository.create({ name: dto.name, slug: dto.slug, domain: dto.domain, logo_url: dto.logo_url, plan_id: dto.plan_id, status: dto.status || 'trial', trial_ends_at: dto.status === 'trial' ? new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) : null, // 14 days trial }); return this.tenantRepository.save(tenant); } async updateTenant(id: string, dto: UpdateTenantDto): Promise { const tenant = await this.tenantRepository.findOne({ where: { id }, }); if (!tenant) { throw new NotFoundException('Tenant not found'); } Object.assign(tenant, dto); return this.tenantRepository.save(tenant); } async updateTenantStatus(id: string, dto: UpdateTenantStatusDto): Promise { const tenant = await this.tenantRepository.findOne({ where: { id }, }); if (!tenant) { throw new NotFoundException('Tenant not found'); } tenant.status = dto.status; // If status is suspended or canceled, you might want to log the reason if (dto.reason) { tenant.metadata = { ...tenant.metadata, statusChangeReason: dto.reason, statusChangedAt: new Date().toISOString(), }; } return this.tenantRepository.save(tenant); } async deleteTenant(id: string): Promise { const tenant = await this.tenantRepository.findOne({ where: { id }, }); if (!tenant) { throw new NotFoundException('Tenant not found'); } // Check if tenant has users const userCount = await this.userRepository.count({ where: { tenant_id: id }, }); if (userCount > 0) { throw new BadRequestException( 'Cannot delete tenant with active users. Please remove all users first or suspend the tenant.', ); } await this.tenantRepository.remove(tenant); } async getTenantUsers(tenantId: string, page = 1, limit = 10): Promise> { const tenant = await this.tenantRepository.findOne({ where: { id: tenantId }, }); if (!tenant) { throw new NotFoundException('Tenant not found'); } const [users, total] = await this.userRepository.findAndCount({ where: { tenant_id: tenantId }, order: { created_at: 'DESC' }, skip: (page - 1) * limit, take: limit, }); return { data: users, total, page, limit, totalPages: Math.ceil(total / limit), }; } async getDashboardStats(): Promise<{ totalTenants: number; activeTenants: number; trialTenants: number; suspendedTenants: number; totalUsers: number; newTenantsThisMonth: number; }> { const [ totalTenants, activeTenants, trialTenants, suspendedTenants, totalUsers, newTenantsThisMonth, ] = await Promise.all([ this.tenantRepository.count(), this.tenantRepository.count({ where: { status: 'active' } }), this.tenantRepository.count({ where: { status: 'trial' } }), this.tenantRepository.count({ where: { status: 'suspended' } }), this.userRepository.count(), this.tenantRepository .createQueryBuilder('tenant') .where('tenant.created_at >= :startOfMonth', { startOfMonth: new Date(new Date().getFullYear(), new Date().getMonth(), 1), }) .getCount(), ]); return { totalTenants, activeTenants, trialTenants, suspendedTenants, totalUsers, newTenantsThisMonth, }; } // ==================== Metrics ==================== async getTenantGrowth(months = 12): Promise<{ month: string; count: number }[]> { const result: { month: string; count: number }[] = []; const now = new Date(); for (let i = months - 1; i >= 0; i--) { const startDate = new Date(now.getFullYear(), now.getMonth() - i, 1); const endDate = new Date(now.getFullYear(), now.getMonth() - i + 1, 0, 23, 59, 59); const count = await this.tenantRepository .createQueryBuilder('tenant') .where('tenant.created_at >= :startDate', { startDate }) .andWhere('tenant.created_at <= :endDate', { endDate }) .getCount(); result.push({ month: startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }), count, }); } return result; } async getUserGrowth(months = 12): Promise<{ month: string; count: number }[]> { const result: { month: string; count: number }[] = []; const now = new Date(); for (let i = months - 1; i >= 0; i--) { const startDate = new Date(now.getFullYear(), now.getMonth() - i, 1); const endDate = new Date(now.getFullYear(), now.getMonth() - i + 1, 0, 23, 59, 59); const count = await this.userRepository .createQueryBuilder('user') .where('user.created_at >= :startDate', { startDate }) .andWhere('user.created_at <= :endDate', { endDate }) .getCount(); result.push({ month: startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }), count, }); } return result; } async getPlanDistribution(): Promise<{ plan: string; count: number; percentage: number }[]> { const subscriptions = await this.subscriptionRepository .createQueryBuilder('sub') .leftJoinAndSelect('sub.plan', 'plan') .where('sub.status = :status', { status: 'active' }) .getMany(); const planCounts: Record = {}; let total = 0; for (const sub of subscriptions) { const planName = sub.plan?.display_name || sub.plan?.name || 'Unknown'; planCounts[planName] = (planCounts[planName] || 0) + 1; total++; } // Add tenants without subscription as "Free" const tenantsWithSubscription = subscriptions.map(s => s.tenant_id); const freeCount = await this.tenantRepository .createQueryBuilder('tenant') .where('tenant.id NOT IN (:...ids)', { ids: tenantsWithSubscription.length > 0 ? tenantsWithSubscription : ['00000000-0000-0000-0000-000000000000'] }) .getCount(); if (freeCount > 0) { planCounts['Free'] = freeCount; total += freeCount; } return Object.entries(planCounts).map(([plan, count]) => ({ plan, count, percentage: total > 0 ? Math.round((count / total) * 100) : 0, })); } async getStatusDistribution(): Promise<{ status: string; count: number; percentage: number }[]> { const statuses = ['active', 'trial', 'suspended', 'canceled']; const total = await this.tenantRepository.count(); const result = await Promise.all( statuses.map(async (status) => { const count = await this.tenantRepository.count({ where: { status } }); return { status: status.charAt(0).toUpperCase() + status.slice(1), count, percentage: total > 0 ? Math.round((count / total) * 100) : 0, }; }), ); return result; } async getTopTenants(limit = 10): Promise<{ id: string; name: string; slug: string; userCount: number; status: string; planName: string; }[]> { const tenants = await this.tenantRepository.find({ order: { created_at: 'ASC' }, take: 100, // Get more to sort by user count }); const tenantsWithCounts = await Promise.all( tenants.map(async (tenant) => { const userCount = await this.userRepository.count({ where: { tenant_id: tenant.id }, }); const subscription = await this.subscriptionRepository.findOne({ where: { tenant_id: tenant.id }, relations: ['plan'], }); return { id: tenant.id, name: tenant.name, slug: tenant.slug, userCount, status: tenant.status, planName: subscription?.plan?.display_name || 'Free', }; }), ); // Sort by user count descending and take top N return tenantsWithCounts .sort((a, b) => b.userCount - a.userCount) .slice(0, limit); } async getMetricsSummary(): Promise<{ tenantGrowth: { month: string; count: number }[]; userGrowth: { month: string; count: number }[]; planDistribution: { plan: string; count: number; percentage: number }[]; statusDistribution: { status: string; count: number; percentage: number }[]; topTenants: { id: string; name: string; slug: string; userCount: number; status: string; planName: string }[]; }> { const [tenantGrowth, userGrowth, planDistribution, statusDistribution, topTenants] = await Promise.all([ this.getTenantGrowth(12), this.getUserGrowth(12), this.getPlanDistribution(), this.getStatusDistribution(), this.getTopTenants(10), ]); return { tenantGrowth, userGrowth, planDistribution, statusDistribution, topTenants, }; } }