Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion nftopia-backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
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';

Check failure on line 23 in nftopia-backend/src/app.module.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

'SearchModule' is defined but never used
import { SorobanRpcService } from './services/soroban-rpc.service';
import { StellarAccountService } from './services/stellar-account.service';

Expand Down Expand Up @@ -97,7 +98,7 @@
ListingModule,
OrderModule,
StorageModule,
SearchModule,

],
controllers: [AppController],
providers: [
Expand Down
16 changes: 16 additions & 0 deletions nftopia-backend/src/modules/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
19 changes: 19 additions & 0 deletions nftopia-backend/src/modules/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
140 changes: 140 additions & 0 deletions nftopia-backend/src/modules/analytics/analytics.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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<CollectionStats> = {
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 }),
);
});
});
});
111 changes: 111 additions & 0 deletions nftopia-backend/src/modules/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
@@ -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<CollectionStats>,
@InjectRepository(Order)
private readonly orderRepo: Repository<Order>,
@InjectRepository(Nft)
private readonly nftRepo: Repository<Nft>,
@InjectRepository(Listing)
private readonly listingRepo: Repository<Listing>,
) {}

@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async aggregateDailyStats(): Promise<void> {
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);

Check failure on line 51 in nftopia-backend/src/modules/analytics/analytics.service.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe member access .collectionId on an `any` value

Check warning on line 51 in nftopia-backend/src/modules/analytics/analytics.service.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe argument of type `any` assigned to a parameter of type `string`

await this.statsRepo
.createQueryBuilder()
.insert()
.into(CollectionStats)
.values({
collectionId: row.collectionId,

Check failure on line 58 in nftopia-backend/src/modules/analytics/analytics.service.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe member access .collectionId on an `any` value

Check failure on line 58 in nftopia-backend/src/modules/analytics/analytics.service.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe assignment of an `any` value
date: dateStr,
volume: row.volume ?? '0',

Check failure on line 60 in nftopia-backend/src/modules/analytics/analytics.service.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe member access .volume on an `any` value

Check failure on line 60 in nftopia-backend/src/modules/analytics/analytics.service.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe assignment of an `any` value
floorPrice,
salesCount: parseInt(row.salesCount, 10),

Check failure on line 62 in nftopia-backend/src/modules/analytics/analytics.service.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe member access .salesCount on an `any` value

Check warning on line 62 in nftopia-backend/src/modules/analytics/analytics.service.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe argument of type `any` assigned to a parameter of type `string`
})
.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<string | null> {
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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Loading