diff --git a/nftopia-backend/src/app.module.ts b/nftopia-backend/src/app.module.ts index 014774bb..582625e9 100644 --- a/nftopia-backend/src/app.module.ts +++ b/nftopia-backend/src/app.module.ts @@ -18,6 +18,7 @@ import { LoggerModule } from 'nestjs-pino'; import { APP_FILTER, APP_GUARD } from '@nestjs/core'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { StorageModule } from './storage/storage.module'; + import { RedisRateGuard } from './common/guards/redis-rate.guard'; import { SearchModule } from './search/search.module'; import { SorobanRpcService } from './services/soroban-rpc.service'; @@ -97,7 +98,7 @@ import { StellarAccountService } from './services/stellar-account.service'; ListingModule, OrderModule, StorageModule, - SearchModule, + ], controllers: [AppController], providers: [ diff --git a/nftopia-backend/src/modules/analytics/analytics.controller.ts b/nftopia-backend/src/modules/analytics/analytics.controller.ts new file mode 100644 index 00000000..0a797536 --- /dev/null +++ b/nftopia-backend/src/modules/analytics/analytics.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { AnalyticsService } from './analytics.service.js'; + +@Controller('collections') +export class AnalyticsController { + constructor(private readonly analyticsService: AnalyticsService) {} + + @Get(':id/stats') + async getCollectionStats( + @Param('id') id: string, + @Query('from') from?: string, + @Query('to') to?: string, + ) { + return this.analyticsService.getCollectionStats(id, from, to); + } +} diff --git a/nftopia-backend/src/modules/analytics/analytics.module.ts b/nftopia-backend/src/modules/analytics/analytics.module.ts new file mode 100644 index 00000000..388aeace --- /dev/null +++ b/nftopia-backend/src/modules/analytics/analytics.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; +import { AnalyticsService } from './analytics.service.js'; +import { AnalyticsController } from './analytics.controller.js'; +import { CollectionStats } from './entities/collection-stats.entity.js'; +import { Order } from '../order/entities/order.entity.js'; +import { Nft } from '../nft/entities/nft.entity.js'; +import { Listing } from '../listing/entities/listing.entity.js'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([CollectionStats, Order, Nft, Listing]), + ScheduleModule.forRoot(), + ], + controllers: [AnalyticsController], + providers: [AnalyticsService], +}) +export class AnalyticsModule {} diff --git a/nftopia-backend/src/modules/analytics/analytics.service.spec.ts b/nftopia-backend/src/modules/analytics/analytics.service.spec.ts new file mode 100644 index 00000000..bf3397e4 --- /dev/null +++ b/nftopia-backend/src/modules/analytics/analytics.service.spec.ts @@ -0,0 +1,140 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { AnalyticsService } from './analytics.service.js'; +import { CollectionStats } from './entities/collection-stats.entity.js'; +import { Order } from '../order/entities/order.entity.js'; +import { Nft } from '../nft/entities/nft.entity.js'; +import { Listing } from '../listing/entities/listing.entity.js'; + +const mockQueryBuilder = { + innerJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + into: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + orUpdate: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + getRawOne: jest.fn().mockResolvedValue({ floorPrice: null }), + getMany: jest.fn().mockResolvedValue([]), + execute: jest.fn().mockResolvedValue({}), +}; + +const mockRepo = () => ({ + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), +}); + +describe('AnalyticsService', () => { + let service: AnalyticsService; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AnalyticsService, + { provide: getRepositoryToken(CollectionStats), useFactory: mockRepo }, + { provide: getRepositoryToken(Order), useFactory: mockRepo }, + { provide: getRepositoryToken(Nft), useFactory: mockRepo }, + { provide: getRepositoryToken(Listing), useFactory: mockRepo }, + ], + }).compile(); + + service = module.get(AnalyticsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getCollectionStats', () => { + it('returns empty stats and charts when no data exists', async () => { + mockQueryBuilder.getMany.mockResolvedValueOnce([]); + + const result = await service.getCollectionStats('col-1'); + + expect(result.stats).toEqual([]); + expect(result.charts.volume).toEqual([]); + expect(result.charts.floorPrice).toEqual([]); + }); + + it('maps stats rows to chart [timestamp, value] pairs', async () => { + const row: Partial = { + collectionId: 'col-1', + date: '2026-03-25', + volume: '500.0000000', + floorPrice: '10.0000000', + salesCount: 5, + }; + mockQueryBuilder.getMany.mockResolvedValueOnce([row]); + + const result = await service.getCollectionStats('col-1'); + + expect(result.stats).toHaveLength(1); + expect(result.charts.volume[0][1]).toBe('500.0000000'); + expect(result.charts.floorPrice[0][1]).toBe('10.0000000'); + expect(typeof result.charts.volume[0][0]).toBe('number'); + }); + + it('applies from/to date filters', async () => { + mockQueryBuilder.getMany.mockResolvedValueOnce([]); + + await service.getCollectionStats('col-1', '2026-03-01', '2026-03-25'); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'stats.date >= :from', + { from: '2026-03-01' }, + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'stats.date <= :to', + { to: '2026-03-25' }, + ); + }); + }); + + describe('aggregateDailyStats', () => { + it('does nothing when no collections had sales yesterday', async () => { + mockQueryBuilder.getRawMany.mockResolvedValueOnce([]); + + await service.aggregateDailyStats(); + + expect(mockQueryBuilder.execute).not.toHaveBeenCalled(); + }); + + it('upserts a row for each collection with sales', async () => { + mockQueryBuilder.getRawMany.mockResolvedValueOnce([ + { collectionId: 'col-1', volume: '200.0000000', salesCount: '3' }, + ]); + mockQueryBuilder.getRawOne.mockResolvedValueOnce({ floorPrice: '15.0000000' }); + + await service.aggregateDailyStats(); + + expect(mockQueryBuilder.values).toHaveBeenCalledWith( + expect.objectContaining({ + collectionId: 'col-1', + volume: '200.0000000', + salesCount: 3, + floorPrice: '15.0000000', + }), + ); + expect(mockQueryBuilder.execute).toHaveBeenCalled(); + }); + + it('sets floorPrice to null when no active listings exist', async () => { + mockQueryBuilder.getRawMany.mockResolvedValueOnce([ + { collectionId: 'col-2', volume: '50.0000000', salesCount: '1' }, + ]); + mockQueryBuilder.getRawOne.mockResolvedValueOnce({ floorPrice: null }); + + await service.aggregateDailyStats(); + + expect(mockQueryBuilder.values).toHaveBeenCalledWith( + expect.objectContaining({ floorPrice: null }), + ); + }); + }); +}); diff --git a/nftopia-backend/src/modules/analytics/analytics.service.ts b/nftopia-backend/src/modules/analytics/analytics.service.ts new file mode 100644 index 00000000..9ebd7452 --- /dev/null +++ b/nftopia-backend/src/modules/analytics/analytics.service.ts @@ -0,0 +1,111 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { CollectionStats } from './entities/collection-stats.entity.js'; +import { Order } from '../order/entities/order.entity.js'; +import { Nft } from '../nft/entities/nft.entity.js'; +import { Listing } from '../listing/entities/listing.entity.js'; + +@Injectable() +export class AnalyticsService { + private readonly logger = new Logger(AnalyticsService.name); + + constructor( + @InjectRepository(CollectionStats) + private readonly statsRepo: Repository, + @InjectRepository(Order) + private readonly orderRepo: Repository, + @InjectRepository(Nft) + private readonly nftRepo: Repository, + @InjectRepository(Listing) + private readonly listingRepo: Repository, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async aggregateDailyStats(): Promise { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const dateStr = yesterday.toISOString().slice(0, 10); + + const start = new Date(`${dateStr}T00:00:00.000Z`); + const end = new Date(`${dateStr}T23:59:59.999Z`); + + this.logger.log(`Aggregating collection stats for ${dateStr}`); + + const rows = (await this.orderRepo + .createQueryBuilder('order') + .innerJoin(Nft, 'nft', 'nft.id = order.nftId') + .select('nft.collectionId', 'collectionId') + .addSelect('SUM(CAST(order.price AS DECIMAL))', 'volume') + .addSelect('COUNT(order.id)', 'salesCount') + .where('order.status = :status', { status: 'COMPLETED' }) + .andWhere('order.type = :type', { type: 'SALE' }) + .andWhere('order.createdAt >= :start', { start }) + .andWhere('order.createdAt <= :end', { end }) + .andWhere('nft.collectionId IS NOT NULL') + .groupBy('nft.collectionId') + .getRawMany()) as { collectionId: string; volume: string; salesCount: string }[]; + + for (const row of rows) { + const floorPrice = await this.getFloorPrice(row.collectionId); + + await this.statsRepo + .createQueryBuilder() + .insert() + .into(CollectionStats) + .values({ + collectionId: row.collectionId, + date: dateStr, + volume: row.volume ?? '0', + floorPrice, + salesCount: parseInt(row.salesCount, 10), + }) + .orUpdate(['volume', 'floor_price', 'sales_count'], [ + 'collection_id', + 'date', + ]) + .execute(); + } + + this.logger.log(`Done. Aggregated ${rows.length} collection(s) for ${dateStr}`); + } + + async getCollectionStats( + collectionId: string, + from?: string, + to?: string, + ): Promise<{ + stats: CollectionStats[]; + charts: { volume: [number, string][]; floorPrice: [number, string | null][] }; + }> { + const qb = this.statsRepo + .createQueryBuilder('stats') + .where('stats.collectionId = :collectionId', { collectionId }) + .orderBy('stats.date', 'ASC'); + + if (from) qb.andWhere('stats.date >= :from', { from }); + if (to) qb.andWhere('stats.date <= :to', { to }); + + const stats = await qb.getMany(); + + const charts = { + volume: stats.map((s) => [new Date(s.date).getTime(), s.volume] as [number, string]), + floorPrice: stats.map((s) => [new Date(s.date).getTime(), s.floorPrice] as [number, string | null]), + }; + + return { stats, charts }; + } + + private async getFloorPrice(collectionId: string): Promise { + const result = (await this.listingRepo + .createQueryBuilder('listing') + .innerJoin(Nft, 'nft', 'nft.tokenId = listing.nftTokenId AND nft.contractAddress = listing.nftContractId') + .select('MIN(CAST(listing.price AS DECIMAL))', 'floorPrice') + .where('listing.status = :status', { status: 'ACTIVE' }) + .andWhere('nft.collectionId = :collectionId', { collectionId }) + .getRawOne()) as { floorPrice: string | null }; + + return result?.floorPrice ?? null; + } +} diff --git a/nftopia-backend/src/modules/analytics/entities/collection-stats.entity.ts b/nftopia-backend/src/modules/analytics/entities/collection-stats.entity.ts new file mode 100644 index 00000000..daadbfad --- /dev/null +++ b/nftopia-backend/src/modules/analytics/entities/collection-stats.entity.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + Unique, +} from 'typeorm'; + +@Entity('collection_stats') +@Unique('uq_collection_stats_collection_date', ['collectionId', 'date']) +@Index('idx_collection_stats_collection_id', ['collectionId']) +@Index('idx_collection_stats_date', ['date']) +export class CollectionStats { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'collection_id', type: 'varchar', length: 100 }) + collectionId: string; + + @Column({ type: 'date' }) + date: string; + + @Column({ type: 'decimal', precision: 20, scale: 7, default: '0' }) + volume: string; + + @Column({ + name: 'floor_price', + type: 'decimal', + precision: 20, + scale: 7, + nullable: true, + }) + floorPrice: string | null; + + @Column({ name: 'sales_count', type: 'int', default: 0 }) + salesCount: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +}