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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user