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(
|
export async function getRevenueStats(
|
||||||
prisma: PrismaService,
|
prisma: PrismaService,
|
||||||
startDate: Date,
|
startDate: Date,
|
||||||
endDate: Date,
|
endDate: Date,
|
||||||
groupBy: 'day' | 'month',
|
groupBy: 'day' | 'month',
|
||||||
): Promise<RevenueStatsItem[]> {
|
): Promise<RevenueStatsItem[]> {
|
||||||
const payments = await prisma.payment.findMany({
|
const cacheKey = buildCacheKey(startDate, endDate, groupBy);
|
||||||
where: {
|
const cached = revenueStatsCache.get(cacheKey);
|
||||||
status: 'COMPLETED',
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
createdAt: { gte: startDate, lte: endDate },
|
return cached.data;
|
||||||
},
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(grouped.entries()).map(([period, stats]) => ({
|
const truncUnit = groupBy === 'day' ? 'day' : 'month';
|
||||||
period,
|
|
||||||
...stats,
|
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 { 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 {
|
export class RevenueStatsDto {
|
||||||
@ApiProperty({ description: 'Start date (ISO 8601)', example: '2025-01-01' })
|
@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' })
|
@ApiProperty({ description: 'End date (ISO 8601)', example: '2025-12-31' })
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
|
@MaxDateRangeDays(366, { message: 'Date range must not exceed 366 days' })
|
||||||
endDate!: string;
|
endDate!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Group results by day or month', enum: ['day', 'month'], default: 'month' })
|
@ApiPropertyOptional({ description: 'Group results by day or month', enum: ['day', 'month'], default: 'month' })
|
||||||
|
|||||||
Reference in New Issue
Block a user