template-saas/apps/backend/src/modules/superadmin/superadmin.service.ts
rckrdmrd 4dafffa386 feat: Add superadmin metrics, onboarding and module documentation
- 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>
2026-01-07 05:40:26 -06:00

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