diff --git a/apps/api/src/modules/admin/infrastructure/repositories/admin-stats.queries.ts b/apps/api/src/modules/admin/infrastructure/repositories/admin-stats.queries.ts index 2871e89..40566a9 100644 --- a/apps/api/src/modules/admin/infrastructure/repositories/admin-stats.queries.ts +++ b/apps/api/src/modules/admin/infrastructure/repositories/admin-stats.queries.ts @@ -43,67 +43,71 @@ export async function getDashboardStats(prisma: PrismaService): Promise(); + +function buildCacheKey(startDate: Date, endDate: Date, groupBy: string): string { + return `${startDate.toISOString()}|${endDate.toISOString()}|${groupBy}`; +} + +// Raw row returned by Postgres for the aggregation query +interface RevenueRawRow { + period: string; + total_revenue: bigint; + subscription_revenue: bigint; + listing_fee_revenue: bigint; + featured_listing_revenue: bigint; + transaction_count: bigint; +} + export async function getRevenueStats( prisma: PrismaService, startDate: Date, endDate: Date, groupBy: 'day' | 'month', ): Promise { - const payments = await prisma.payment.findMany({ - where: { - status: 'COMPLETED', - createdAt: { gte: startDate, lte: endDate }, - }, - select: { - type: true, - amountVND: true, - createdAt: true, - }, - orderBy: { createdAt: 'asc' }, - }); - - const grouped = new Map(); - - for (const payment of payments) { - const period = groupBy === 'day' - ? payment.createdAt.toISOString().slice(0, 10) - : payment.createdAt.toISOString().slice(0, 7); - - if (!grouped.has(period)) { - grouped.set(period, { - totalRevenue: 0n, - subscriptionRevenue: 0n, - listingFeeRevenue: 0n, - featuredListingRevenue: 0n, - transactionCount: 0, - }); - } - - const stats = grouped.get(period)!; - stats.totalRevenue += payment.amountVND; - stats.transactionCount++; - - switch (payment.type) { - case 'SUBSCRIPTION': - stats.subscriptionRevenue += payment.amountVND; - break; - case 'LISTING_FEE': - stats.listingFeeRevenue += payment.amountVND; - break; - case 'FEATURED_LISTING': - stats.featuredListingRevenue += payment.amountVND; - break; - } + const cacheKey = buildCacheKey(startDate, endDate, groupBy); + const cached = revenueStatsCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.data; } - return Array.from(grouped.entries()).map(([period, stats]) => ({ - period, - ...stats, + const truncUnit = groupBy === 'day' ? 'day' : 'month'; + + const rows = await prisma.$queryRaw` + SELECT + TO_CHAR(DATE_TRUNC(${truncUnit}, "createdAt"), 'YYYY-MM-DD') AS period, + SUM("amountVND") AS total_revenue, + SUM(CASE WHEN type = 'SUBSCRIPTION' THEN "amountVND" ELSE 0 END) AS subscription_revenue, + SUM(CASE WHEN type = 'LISTING_FEE' THEN "amountVND" ELSE 0 END) AS listing_fee_revenue, + SUM(CASE WHEN type = 'FEATURED_LISTING' THEN "amountVND" ELSE 0 END) AS featured_listing_revenue, + COUNT(*) AS transaction_count + FROM "Payment" + WHERE status = 'COMPLETED' + AND "createdAt" >= ${startDate} + AND "createdAt" <= ${endDate} + GROUP BY DATE_TRUNC(${truncUnit}, "createdAt") + ORDER BY DATE_TRUNC(${truncUnit}, "createdAt") ASC + `; + + const data: RevenueStatsItem[] = rows.map((row) => ({ + period: row.period, + totalRevenue: BigInt(row.total_revenue), + subscriptionRevenue: BigInt(row.subscription_revenue), + listingFeeRevenue: BigInt(row.listing_fee_revenue), + featuredListingRevenue: BigInt(row.featured_listing_revenue), + transactionCount: Number(row.transaction_count), })); + + revenueStatsCache.set(cacheKey, { expiresAt: Date.now() + 60_000, data }); + + return data; } diff --git a/apps/api/src/modules/admin/presentation/dto/revenue-stats.dto.ts b/apps/api/src/modules/admin/presentation/dto/revenue-stats.dto.ts index 05f7eb7..9666296 100644 --- a/apps/api/src/modules/admin/presentation/dto/revenue-stats.dto.ts +++ b/apps/api/src/modules/admin/presentation/dto/revenue-stats.dto.ts @@ -1,5 +1,31 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDateString, IsIn, IsOptional } from 'class-validator'; +import { IsDateString, IsIn, IsOptional, registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator'; + +function MaxDateRangeDays(maxDays: number, validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'maxDateRangeDays', + target: (object as { constructor: new (...args: unknown[]) => unknown }).constructor, + propertyName, + options: validationOptions, + validator: { + validate(_value: unknown, args: ValidationArguments) { + const dto = args.object as RevenueStatsDto; + if (!dto.startDate || !dto.endDate) return true; + const start = new Date(dto.startDate); + const end = new Date(dto.endDate); + const diffMs = end.getTime() - start.getTime(); + const diffDays = diffMs / (1000 * 60 * 60 * 24); + return diffDays >= 0 && diffDays <= maxDays; + }, + defaultMessage(args: ValidationArguments) { + return `Date range must not exceed ${(args.constraints as number[])[0]} days`; + }, + }, + constraints: [maxDays], + }); + }; +} export class RevenueStatsDto { @ApiProperty({ description: 'Start date (ISO 8601)', example: '2025-01-01' }) @@ -8,6 +34,7 @@ export class RevenueStatsDto { @ApiProperty({ description: 'End date (ISO 8601)', example: '2025-12-31' }) @IsDateString() + @MaxDateRangeDays(366, { message: 'Date range must not exceed 366 days' }) endDate!: string; @ApiPropertyOptional({ description: 'Group results by day or month', enum: ['day', 'month'], default: 'month' })