Bladeren bron

feat(stats-reporting): implement stats reporting module with controller, service, and DTOs

Dave 1 maand geleden
bovenliggende
commit
641688a3ac

+ 1 - 0
.gitignore

@@ -79,3 +79,4 @@ scripts/*
 box-nestjs-monorepo-init.md
 box-nestjs-monorepo-init.md
 box-nestjs-monorepo-init.md
+docker/mongo/mongo-keyfile

+ 2 - 0
apps/box-stats-api/src/app.module.ts

@@ -6,6 +6,7 @@ import { PrismaMongoModule } from './prisma/prisma-mongo.module';
 import { UserLoginModule } from './feature/user-login/user-login.module';
 import { RabbitmqConsumerModule } from './feature/rabbitmq/rabbitmq-consumer.module';
 import { StatsEventsModule } from './feature/stats-events/stats-events.module';
+import { StatsReportingModule } from './feature/stats-reporting/stats-reporting.module';
 import path from 'path';
 
 @Module({
@@ -33,6 +34,7 @@ import path from 'path';
     UserLoginModule,
     RabbitmqConsumerModule,
     StatsEventsModule,
+    StatsReportingModule,
   ],
 })
 export class AppModule {}

+ 73 - 0
apps/box-stats-api/src/feature/stats-reporting/dto/stats-reporting.dto.ts

@@ -0,0 +1,73 @@
+import { Type } from 'class-transformer';
+import {
+  IsDefined,
+  IsEnum,
+  IsInt,
+  IsOptional,
+  IsString,
+  Max,
+  Min,
+} from 'class-validator';
+
+const SORT_ORDER = ['asc', 'desc'] as const;
+
+type SortOrder = (typeof SORT_ORDER)[number];
+
+export class BaseRangeQueryDto {
+  @IsDefined()
+  @Type(() => Number)
+  @IsInt()
+  fromSec!: number;
+
+  @IsDefined()
+  @Type(() => Number)
+  @IsInt()
+  toSec!: number;
+
+  @IsOptional()
+  @IsEnum(SORT_ORDER)
+  order?: SortOrder;
+
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  @Min(1)
+  limit?: number;
+
+  @IsOptional()
+  @IsString()
+  cursor?: string;
+
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  @Min(1)
+  page?: number;
+
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  @Min(1)
+  @Max(200)
+  size?: number;
+}
+
+export class AdsStatsQueryDto extends BaseRangeQueryDto {
+  @IsOptional()
+  @IsString()
+  channelId?: string;
+
+  @IsOptional()
+  @IsString()
+  adType?: string;
+
+  @IsOptional()
+  @IsString()
+  adsId?: string;
+}
+
+export class ChannelStatsQueryDto extends BaseRangeQueryDto {
+  @IsOptional()
+  @IsString()
+  channelId?: string;
+}

+ 31 - 0
apps/box-stats-api/src/feature/stats-reporting/stats-reporting.controller.ts

@@ -0,0 +1,31 @@
+import { Body, Controller, Post } from '@nestjs/common';
+import {
+  AdsStatsQueryDto,
+  ChannelStatsQueryDto,
+} from './dto/stats-reporting.dto';
+import { StatsReportingService } from './stats-reporting.service';
+
+@Controller('stats/reporting')
+export class StatsReportingController {
+  constructor(private readonly statsReportingService: StatsReportingService) {}
+
+  @Post('ads/hourly')
+  async getAdsHourly(@Body() query: AdsStatsQueryDto) {
+    return this.statsReportingService.getAdsHourly(query);
+  }
+
+  @Post('ads/daily')
+  async getAdsDaily(@Body() query: AdsStatsQueryDto) {
+    return this.statsReportingService.getAdsDaily(query);
+  }
+
+  @Post('channel/hourly-users')
+  async getChannelHourlyUsers(@Body() query: ChannelStatsQueryDto) {
+    return this.statsReportingService.getChannelHourlyUsers(query);
+  }
+
+  @Post('channel/daily-users')
+  async getChannelDailyUsers(@Body() query: ChannelStatsQueryDto) {
+    return this.statsReportingService.getChannelDailyUsers(query);
+  }
+}

+ 56 - 0
apps/box-stats-api/src/feature/stats-reporting/stats-reporting.cursor.ts

@@ -0,0 +1,56 @@
+import { BadRequestException } from '@nestjs/common';
+
+export type SortOrder = 'asc' | 'desc';
+
+const DEFAULT_LIMIT = 50;
+const MAX_LIMIT = 200;
+
+interface CursorPayload {
+  bucketStartAt: number;
+  id: string;
+}
+
+export function encodeCursor(payload: CursorPayload) {
+  return Buffer.from(JSON.stringify(payload)).toString('base64url');
+}
+
+export function decodeCursor(cursor?: string): CursorPayload | null {
+  if (!cursor) {
+    return null;
+  }
+
+  try {
+    const decoded = Buffer.from(cursor, 'base64url').toString('utf-8');
+    const parsed = JSON.parse(decoded);
+
+    if (
+      typeof parsed !== 'object' ||
+      parsed === null ||
+      typeof parsed.bucketStartAt !== 'number' ||
+      typeof parsed.id !== 'string'
+    ) {
+      throw new Error('invalid cursor payload');
+    }
+
+    return parsed;
+  } catch (error) {
+    throw new BadRequestException('invalid cursor');
+  }
+}
+
+export function clampLimit(limit?: number, def = DEFAULT_LIMIT, max = MAX_LIMIT) {
+  if (!Number.isFinite(limit ?? NaN)) {
+    return def;
+  }
+
+  const normalized = Math.floor(limit as number);
+  if (normalized < 1) {
+    return 1;
+  }
+
+  return Math.min(normalized, max);
+}
+
+export function normalizeOrder(order?: string): SortOrder {
+  return order === 'asc' ? 'asc' : 'desc';
+}

+ 10 - 0
apps/box-stats-api/src/feature/stats-reporting/stats-reporting.module.ts

@@ -0,0 +1,10 @@
+import { Module } from '@nestjs/common';
+import { StatsReportingController } from './stats-reporting.controller';
+import { StatsReportingService } from './stats-reporting.service';
+
+@Module({
+  controllers: [StatsReportingController],
+  providers: [StatsReportingService],
+  exports: [StatsReportingService],
+})
+export class StatsReportingModule {}

+ 218 - 0
apps/box-stats-api/src/feature/stats-reporting/stats-reporting.service.ts

@@ -0,0 +1,218 @@
+import { Injectable } from '@nestjs/common';
+import { Prisma } from '@prisma/mongo-stats/client';
+import type {
+  AdsDailyStats,
+  AdsHourlyStats,
+  ChannelDailyUserStats,
+  ChannelHourlyUserStats,
+} from '@prisma/mongo-stats/client';
+import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
+import {
+  ChannelStatsQueryDto,
+  AdsStatsQueryDto,
+} from './dto/stats-reporting.dto';
+import {
+  assertRange,
+  alignDayStart,
+  alignHourStart,
+} from './stats-reporting.time';
+
+interface PaginatedResult<T> {
+  page: number;
+  size: number;
+  total: number;
+  items: T[];
+}
+
+interface PaginationParams {
+  page?: number;
+  size?: number;
+}
+
+interface NormalizedPagination {
+  page: number;
+  size: number;
+  skip: number;
+  take: number;
+}
+
+const DEFAULT_PAGE = 1;
+const DEFAULT_SIZE = 10;
+const MAX_SIZE = 200;
+
+@Injectable()
+export class StatsReportingService {
+  constructor(private readonly prisma: PrismaMongoService) {}
+
+  async getAdsHourly(query: AdsStatsQueryDto): Promise<any> {
+    assertRange(query.fromSec, query.toSec);
+    const { page, size, skip, take } = this.normalizePagination({
+      page: query.page,
+      size: query.size,
+    });
+
+    const fromAligned = alignHourStart(query.fromSec);
+    const toAligned = alignHourStart(query.toSec);
+
+    const where: Prisma.AdsHourlyStatsWhereInput = {
+      hourStartAt: {
+        gte: BigInt(fromAligned),
+        lt: BigInt(toAligned),
+      },
+    };
+
+    if (query.adsId) {
+      where.adsId = query.adsId;
+    }
+
+    const [items, total] = await Promise.all([
+      this.prisma.adsHourlyStats.findMany({
+        where,
+        orderBy: [{ hourStartAt: 'desc' }, { id: 'desc' }],
+        skip,
+        take,
+      }),
+      this.prisma.adsHourlyStats.count({ where }),
+    ]);
+
+    return { page, size, total, items };
+  }
+
+  async getAdsDaily(query: AdsStatsQueryDto): Promise<any> {
+    assertRange(query.fromSec, query.toSec);
+    const { page, size, skip, take } = this.normalizePagination({
+      page: query.page,
+      size: query.size,
+    });
+
+    const fromAligned = alignDayStart(query.fromSec);
+    const toAligned = alignDayStart(query.toSec);
+
+    const where: Prisma.AdsDailyStatsWhereInput = {
+      dayStartAt: {
+        gte: BigInt(fromAligned),
+        lt: BigInt(toAligned),
+      },
+    };
+
+    if (query.adsId) {
+      where.adsId = query.adsId;
+    }
+
+    const [items, total] = await Promise.all([
+      this.prisma.adsDailyStats.findMany({
+        where,
+        orderBy: [{ dayStartAt: 'desc' }, { id: 'desc' }],
+        skip,
+        take,
+      }),
+      this.prisma.adsDailyStats.count({ where }),
+    ]);
+
+    return { page, size, total, items };
+  }
+
+  async getChannelHourlyUsers(query: ChannelStatsQueryDto): Promise<any> {
+    assertRange(query.fromSec, query.toSec);
+    const { page, size, skip, take } = this.normalizePagination({
+      page: query.page,
+      size: query.size,
+    });
+
+    const fromAligned = alignHourStart(query.fromSec);
+    const toAligned = alignHourStart(query.toSec);
+
+    const where: Prisma.ChannelHourlyUserStatsWhereInput = {
+      hourStartAt: {
+        gte: BigInt(fromAligned),
+        lt: BigInt(toAligned),
+      },
+    };
+
+    if (query.channelId) {
+      where.channelId = query.channelId;
+    }
+
+    const [items, total] = await Promise.all([
+      this.prisma.channelHourlyUserStats.findMany({
+        where,
+        orderBy: [{ hourStartAt: 'desc' }, { id: 'desc' }],
+        skip,
+        take,
+      }),
+      this.prisma.channelHourlyUserStats.count({ where }),
+    ]);
+
+    return { page, size, total, items };
+  }
+
+  async getChannelDailyUsers(query: ChannelStatsQueryDto): Promise<any> {
+    assertRange(query.fromSec, query.toSec);
+    const { page, size, skip, take } = this.normalizePagination({
+      page: query.page,
+      size: query.size,
+    });
+
+    const fromAligned = alignDayStart(query.fromSec);
+    const toAligned = alignDayStart(query.toSec);
+
+    const where: Prisma.ChannelDailyUserStatsWhereInput = {
+      dayStartAt: {
+        gte: BigInt(fromAligned),
+        lt: BigInt(toAligned),
+      },
+    };
+
+    if (query.channelId) {
+      where.channelId = query.channelId;
+    }
+
+    const [items, total] = await Promise.all([
+      this.prisma.channelDailyUserStats.findMany({
+        where,
+        orderBy: [{ dayStartAt: 'desc' }, { id: 'desc' }],
+        skip,
+        take,
+      }),
+      this.prisma.channelDailyUserStats.count({ where }),
+    ]);
+
+    return { page, size, total, items };
+  }
+
+  private normalizePagination({
+    page,
+    size,
+  }: PaginationParams = {}): NormalizedPagination {
+    const normalizedPage = this.normalizePage(page);
+    const normalizedSize = this.normalizeSize(size);
+    const skip = Math.max(0, (normalizedPage - 1) * normalizedSize);
+    return {
+      page: normalizedPage,
+      size: normalizedSize,
+      skip,
+      take: normalizedSize,
+    };
+  }
+
+  private normalizePage(value?: number) {
+    if (!Number.isFinite(value ?? NaN)) {
+      return DEFAULT_PAGE;
+    }
+
+    return Math.max(DEFAULT_PAGE, Math.floor(value));
+  }
+
+  private normalizeSize(value?: number) {
+    if (!Number.isFinite(value ?? NaN)) {
+      return DEFAULT_SIZE;
+    }
+
+    const normalized = Math.floor(value);
+    if (normalized < 1) {
+      return DEFAULT_SIZE;
+    }
+
+    return Math.min(MAX_SIZE, normalized);
+  }
+}

+ 23 - 0
apps/box-stats-api/src/feature/stats-reporting/stats-reporting.time.ts

@@ -0,0 +1,23 @@
+import { BadRequestException } from '@nestjs/common';
+
+const TZ_OFFSET_SEC = 8 * 3600;
+
+export function assertRange(fromSec: number, toSec: number) {
+  const isValidNumber = (value: number) => Number.isFinite(value);
+
+  if (!isValidNumber(fromSec) || !isValidNumber(toSec)) {
+    throw new BadRequestException('range values must be valid numbers');
+  }
+
+  if (fromSec >= toSec) {
+    throw new BadRequestException('fromSec must be less than toSec');
+  }
+}
+
+export function alignHourStart(secUtc: number) {
+  return Math.floor((secUtc + TZ_OFFSET_SEC) / 3600) * 3600 - TZ_OFFSET_SEC;
+}
+
+export function alignDayStart(secUtc: number) {
+  return Math.floor((secUtc + TZ_OFFSET_SEC) / 86400) * 86400 - TZ_OFFSET_SEC;
+}