- Backend NestJS con módulos de autenticación, inventario, créditos - Frontend React con dashboard y componentes UI - Base de datos PostgreSQL con migraciones - Tests E2E configurados - Configuración de Docker y deployment Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
207 lines
6.8 KiB
TypeScript
207 lines
6.8 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository, Between, MoreThanOrEqual } from 'typeorm';
|
|
import { User } from '../../users/entities/user.entity';
|
|
import { Store } from '../../stores/entities/store.entity';
|
|
import { Video } from '../../videos/entities/video.entity';
|
|
import { Payment, PaymentStatus } from '../../payments/entities/payment.entity';
|
|
import { CreditTransaction, TransactionType } from '../../credits/entities/credit-transaction.entity';
|
|
import { DashboardMetrics, RevenueSeriesPoint, DashboardPeriod } from '../dto/dashboard.dto';
|
|
|
|
@Injectable()
|
|
export class AdminDashboardService {
|
|
constructor(
|
|
@InjectRepository(User)
|
|
private userRepository: Repository<User>,
|
|
@InjectRepository(Store)
|
|
private storeRepository: Repository<Store>,
|
|
@InjectRepository(Video)
|
|
private videoRepository: Repository<Video>,
|
|
@InjectRepository(Payment)
|
|
private paymentRepository: Repository<Payment>,
|
|
@InjectRepository(CreditTransaction)
|
|
private creditTransactionRepository: Repository<CreditTransaction>,
|
|
) {}
|
|
|
|
async getDashboardMetrics(
|
|
startDate?: Date,
|
|
endDate?: Date,
|
|
): Promise<DashboardMetrics> {
|
|
const now = new Date();
|
|
const start = startDate || new Date(now.getFullYear(), now.getMonth(), 1);
|
|
const end = endDate || now;
|
|
|
|
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
|
|
const [
|
|
totalUsers,
|
|
newUsers,
|
|
totalStores,
|
|
activeStores,
|
|
totalVideos,
|
|
processedVideos,
|
|
payments,
|
|
creditTransactions,
|
|
mauCount,
|
|
dauCount,
|
|
] = await Promise.all([
|
|
this.userRepository.count(),
|
|
this.userRepository.count({
|
|
where: { createdAt: Between(start, end) },
|
|
}),
|
|
this.storeRepository.count(),
|
|
this.storeRepository
|
|
.createQueryBuilder('store')
|
|
.innerJoin('videos', 'video', 'video.storeId = store.id')
|
|
.where('video.createdAt >= :start', { start })
|
|
.getCount(),
|
|
this.videoRepository.count(),
|
|
this.videoRepository.count({
|
|
where: { createdAt: Between(start, end) },
|
|
}),
|
|
this.paymentRepository.find({
|
|
where: {
|
|
status: PaymentStatus.COMPLETED,
|
|
createdAt: Between(start, end),
|
|
},
|
|
relations: ['package'],
|
|
}),
|
|
this.creditTransactionRepository.find({
|
|
where: { createdAt: Between(start, end) },
|
|
}),
|
|
this.getActiveUsersCount(thirtyDaysAgo),
|
|
this.getActiveUsersCount(oneDayAgo),
|
|
]);
|
|
|
|
const revenue = payments.reduce((sum, p) => sum + Number(p.amountMXN), 0);
|
|
const revenueByPackage = this.groupRevenueByPackage(payments);
|
|
|
|
const purchased = creditTransactions
|
|
.filter(t => t.type === TransactionType.PURCHASE)
|
|
.reduce((sum, t) => sum + t.amount, 0);
|
|
const used = creditTransactions
|
|
.filter(t => t.type === TransactionType.CONSUMPTION)
|
|
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
|
|
const gifted = creditTransactions
|
|
.filter(t => [TransactionType.REFERRAL_BONUS, TransactionType.PROMO].includes(t.type))
|
|
.reduce((sum, t) => sum + t.amount, 0);
|
|
|
|
// Estimate COGS (placeholder - would integrate with IA provider usage)
|
|
const estimatedCogs = revenue * 0.15; // 15% estimate
|
|
|
|
return {
|
|
users: {
|
|
total: totalUsers,
|
|
mau: mauCount,
|
|
dau: dauCount,
|
|
newThisPeriod: newUsers,
|
|
},
|
|
stores: {
|
|
total: totalStores,
|
|
activeThisPeriod: activeStores,
|
|
},
|
|
videos: {
|
|
total: totalVideos,
|
|
processedThisPeriod: processedVideos,
|
|
averageProcessingTime: 0, // Would calculate from video metadata
|
|
},
|
|
revenue: {
|
|
total: revenue,
|
|
thisPeriod: revenue,
|
|
byPackage: revenueByPackage,
|
|
},
|
|
cogs: {
|
|
total: estimatedCogs,
|
|
thisPeriod: estimatedCogs,
|
|
byProvider: [], // Would integrate with IA provider usage tracking
|
|
},
|
|
margin: {
|
|
gross: revenue - estimatedCogs,
|
|
percentage: revenue > 0 ? ((revenue - estimatedCogs) / revenue) * 100 : 0,
|
|
},
|
|
credits: {
|
|
purchased,
|
|
used,
|
|
gifted,
|
|
},
|
|
};
|
|
}
|
|
|
|
async getRevenueSeries(
|
|
startDate: Date,
|
|
endDate: Date,
|
|
period: DashboardPeriod = DashboardPeriod.DAY,
|
|
): Promise<RevenueSeriesPoint[]> {
|
|
const payments = await this.paymentRepository.find({
|
|
where: {
|
|
status: PaymentStatus.COMPLETED,
|
|
createdAt: Between(startDate, endDate),
|
|
},
|
|
order: { createdAt: 'ASC' },
|
|
});
|
|
|
|
const groupedData = new Map<string, { revenue: number; cogs: number }>();
|
|
|
|
for (const payment of payments) {
|
|
const dateKey = this.getDateKey(payment.createdAt, period);
|
|
const existing = groupedData.get(dateKey) || { revenue: 0, cogs: 0 };
|
|
existing.revenue += Number(payment.amountMXN);
|
|
existing.cogs += Number(payment.amountMXN) * 0.15; // Estimated COGS
|
|
groupedData.set(dateKey, existing);
|
|
}
|
|
|
|
return Array.from(groupedData.entries()).map(([date, data]) => ({
|
|
date,
|
|
revenue: data.revenue,
|
|
cogs: data.cogs,
|
|
margin: data.revenue - data.cogs,
|
|
}));
|
|
}
|
|
|
|
private async getActiveUsersCount(since: Date): Promise<number> {
|
|
const result = await this.videoRepository
|
|
.createQueryBuilder('video')
|
|
.select('COUNT(DISTINCT video.uploadedById)', 'count')
|
|
.where('video.createdAt >= :since', { since })
|
|
.getRawOne();
|
|
|
|
return parseInt(result?.count || '0', 10);
|
|
}
|
|
|
|
private groupRevenueByPackage(payments: Payment[]): { packageId: string; name: string; amount: number }[] {
|
|
const grouped = new Map<string, { name: string; amount: number }>();
|
|
|
|
for (const payment of payments) {
|
|
if (payment.packageId && payment.package) {
|
|
const existing = grouped.get(payment.packageId) || { name: payment.package.name, amount: 0 };
|
|
existing.amount += Number(payment.amountMXN);
|
|
grouped.set(payment.packageId, existing);
|
|
}
|
|
}
|
|
|
|
return Array.from(grouped.entries()).map(([packageId, data]) => ({
|
|
packageId,
|
|
name: data.name,
|
|
amount: data.amount,
|
|
}));
|
|
}
|
|
|
|
private getDateKey(date: Date, period: DashboardPeriod): string {
|
|
switch (period) {
|
|
case DashboardPeriod.DAY:
|
|
return date.toISOString().split('T')[0];
|
|
case DashboardPeriod.WEEK:
|
|
const weekStart = new Date(date);
|
|
weekStart.setDate(date.getDate() - date.getDay());
|
|
return weekStart.toISOString().split('T')[0];
|
|
case DashboardPeriod.MONTH:
|
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
|
case DashboardPeriod.YEAR:
|
|
return String(date.getFullYear());
|
|
default:
|
|
return date.toISOString().split('T')[0];
|
|
}
|
|
}
|
|
}
|