- Add MetricsPage and useOnboarding hook - Update superadmin controller and service - Add module documentation (docs/01-modulos/) - Add CONTEXT-MAP.yml and Sprint 5 execution report - Update project status and task traces 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
433 lines
12 KiB
TypeScript
433 lines
12 KiB
TypeScript
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<T> {
|
|
data: T[];
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
totalPages: number;
|
|
}
|
|
|
|
@Injectable()
|
|
export class SuperadminService {
|
|
constructor(
|
|
@InjectRepository(Tenant)
|
|
private readonly tenantRepository: Repository<Tenant>,
|
|
@InjectRepository(User)
|
|
private readonly userRepository: Repository<User>,
|
|
@InjectRepository(Subscription)
|
|
private readonly subscriptionRepository: Repository<Subscription>,
|
|
) {}
|
|
|
|
async listTenants(query: ListTenantsQueryDto): Promise<PaginatedResult<TenantWithStats>> {
|
|
const { page = 1, limit = 10, search, status, sortBy = 'created_at', sortOrder = 'DESC' } = query;
|
|
|
|
const where: FindOptionsWhere<Tenant> = {};
|
|
|
|
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<TenantWithStats> {
|
|
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<Tenant> {
|
|
// 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<Tenant> {
|
|
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<Tenant> {
|
|
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<void> {
|
|
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<PaginatedResult<User>> {
|
|
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<string, number> = {};
|
|
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,
|
|
};
|
|
}
|
|
}
|