fix(admin): push revenue aggregation to DB with DATE_TRUNC and add 60s cache

- Replace prisma.payment.findMany() with $queryRaw GROUP BY DATE_TRUNC
  to push all aggregation work to the database, avoiding loading all
  payment rows into application memory
- Add simple in-process 60s TTL cache keyed by startDate|endDate|groupBy
  to reduce repeated expensive queries
- Cap date range to 366 days via custom @MaxDateRangeDays validator on RevenueStatsDto.endDate

Closes GOO-26

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-23 00:05:30 +07:00
parent 8706fff92f
commit 05be5f4467
2 changed files with 86 additions and 55 deletions

View File

@@ -43,67 +43,71 @@ export async function getDashboardStats(prisma: PrismaService): Promise<Dashboar
};
}
// ---------------------------------------------------------------------------
// Simple in-process cache for revenue stats (TTL = 60 seconds)
// ---------------------------------------------------------------------------
interface RevenueCacheEntry {
expiresAt: number;
data: RevenueStatsItem[];
}
const revenueStatsCache = new Map<string, RevenueCacheEntry>();
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<RevenueStatsItem[]> {
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<string, {
totalRevenue: bigint;
subscriptionRevenue: bigint;
listingFeeRevenue: bigint;
featuredListingRevenue: bigint;
transactionCount: number;
}>();
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<RevenueRawRow[]>`
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;
}

View File

@@ -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' })