diff --git a/nftopia-backend/docker-compose.yml b/nftopia-backend/docker-compose.yml index 9e3701e..8d91a9a 100644 --- a/nftopia-backend/docker-compose.yml +++ b/nftopia-backend/docker-compose.yml @@ -14,5 +14,12 @@ services: volumes: - postgres_data:/var/lib/postgresql/data + redis: + image: redis:alpine + container_name: nftopia-redis + restart: unless-stopped + ports: + - '6379:6379' + volumes: postgres_data: diff --git a/nftopia-backend/lint_results.txt b/nftopia-backend/lint_results.txt new file mode 100644 index 0000000..012eb52 --- /dev/null +++ b/nftopia-backend/lint_results.txt @@ -0,0 +1,46 @@ + +> nftopia-backend@0.0.1 lint +> eslint "{src,apps,libs,test}/**/*.ts" --fix + + +/home/knights/Documents/Project/Drips/nftopia-stellar/nftopia-backend/src/app.module.ts + 10:0 error Parsing error: Merge conflict marker encountered + +/home/knights/Documents/Project/Drips/nftopia-stellar/nftopia-backend/src/common/filters/http-exception.filter.ts + 70:7 error Unsafe call of a type that could not be resolved @typescript-eslint/no-unsafe-call + 70:19 error Unsafe member access .error on a type that cannot be resolved @typescript-eslint/no-unsafe-member-access + 78:7 error Unsafe call of a type that could not be resolved @typescript-eslint/no-unsafe-call + 78:19 error Unsafe member access .warn on a type that cannot be resolved @typescript-eslint/no-unsafe-member-access + 80:7 error Unsafe call of a type that could not be resolved @typescript-eslint/no-unsafe-call + 80:19 error Unsafe member access .info on a type that cannot be resolved @typescript-eslint/no-unsafe-member-access + +/home/knights/Documents/Project/Drips/nftopia-stellar/nftopia-backend/src/main.ts + 10:25 warning Unsafe argument of type error typed assigned to a parameter of type `string | symbol | Function | Type` @typescript-eslint/no-unsafe-argument + 47:9 error Unsafe assignment of an `any` value @typescript-eslint/no-unsafe-assignment + 47:26 warning Unsafe argument of type error typed assigned to a parameter of type `string | symbol | Function | Type` @typescript-eslint/no-unsafe-argument + 49:3 error Unsafe call of an `any` typed value @typescript-eslint/no-unsafe-call + 49:10 error Unsafe member access .log on an `any` value @typescript-eslint/no-unsafe-member-access + 50:3 error Unsafe call of an `any` typed value @typescript-eslint/no-unsafe-call + 50:10 error Unsafe member access .log on an `any` value @typescript-eslint/no-unsafe-member-access + +/home/knights/Documents/Project/Drips/nftopia-stellar/nftopia-backend/src/nft/nft.controller.ts + 31:36 warning Unsafe argument of type `any` assigned to a parameter of type `NftFilterDto` @typescript-eslint/no-unsafe-argument + 47:5 error Unsafe return of a value of type `Promise` @typescript-eslint/no-unsafe-return + +/home/knights/Documents/Project/Drips/nftopia-stellar/nftopia-backend/src/nft/nft.service.ts + 11:10 error 'scValToNative' is defined but never used @typescript-eslint/no-unused-vars + 11:25 error 'xdr' is defined but never used @typescript-eslint/no-unused-vars + 89:11 error Unsafe assignment of an `any` value @typescript-eslint/no-unsafe-assignment + 98:5 error Unsafe return of a value of type `any` @typescript-eslint/no-unsafe-return + 115:13 error 'events' is assigned a value but never used @typescript-eslint/no-unused-vars + 126:43 error Unsafe member access .message on an `any` value @typescript-eslint/no-unsafe-member-access + +/home/knights/Documents/Project/Drips/nftopia-stellar/nftopia-backend/src/nft/soroban.service.ts + 4:10 error 'scValToNative' is defined but never used @typescript-eslint/no-unused-vars + 35:72 error Unsafe member access .message on an `any` value @typescript-eslint/no-unsafe-member-access + 36:11 error Unsafe member access .stack on an `any` value @typescript-eslint/no-unsafe-member-access + 60:53 error Unsafe member access .message on an `any` value @typescript-eslint/no-unsafe-member-access + 70:60 error Unsafe member access .message on an `any` value @typescript-eslint/no-unsafe-member-access + +✖ 27 problems (24 errors, 3 warnings) + diff --git a/nftopia-backend/package-lock.json b/nftopia-backend/package-lock.json index f5b1778..c5aee51 100644 --- a/nftopia-backend/package-lock.json +++ b/nftopia-backend/package-lock.json @@ -16,8 +16,10 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^11.2.5", "@nestjs/typeorm": "^11.0.0", + "@types/cron": "^2.0.1", "cache-manager": "^7.2.8", "cache-manager-redis-store": "^3.0.1", "class-transformer": "^0.5.1", @@ -2345,6 +2347,19 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/schedule": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.1.tgz", + "integrity": "sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==", + "license": "MIT", + "dependencies": { + "cron": "4.4.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "11.0.9", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.9.tgz", @@ -2899,6 +2914,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cron": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/cron/-/cron-2.0.1.tgz", + "integrity": "sha512-WHa/1rtNtD2Q/H0+YTTZoty+/5rcE66iAFX2IY+JuUoOACsevYyFkSYu/2vdw+G5LrmO7Lxowrqm0av4k3qWNQ==", + "license": "MIT", + "dependencies": { + "@types/luxon": "*", + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -3015,6 +3040,12 @@ "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -5086,6 +5117,23 @@ "devOptional": true, "license": "MIT" }, + "node_modules/cron": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", + "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/intcreator" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7989,6 +8037,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", diff --git a/nftopia-backend/package.json b/nftopia-backend/package.json index 3b72419..2c8deb7 100644 --- a/nftopia-backend/package.json +++ b/nftopia-backend/package.json @@ -27,8 +27,10 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^11.2.5", "@nestjs/typeorm": "^11.0.0", + "@types/cron": "^2.0.1", "cache-manager": "^7.2.8", "cache-manager-redis-store": "^3.0.1", "class-transformer": "^0.5.1", diff --git a/nftopia-backend/src/app.module.ts b/nftopia-backend/src/app.module.ts index 8fc9469..db9933f 100644 --- a/nftopia-backend/src/app.module.ts +++ b/nftopia-backend/src/app.module.ts @@ -7,6 +7,7 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; +import { NftModule } from './nft/nft.module'; import { LoggerModule } from 'nestjs-pino'; import { APP_FILTER } from '@nestjs/core'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; @@ -68,6 +69,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter'; }), UsersModule, ]), + NftModule, ], controllers: [AppController], providers: [ diff --git a/nftopia-backend/src/nft/dto/nft-filter.dto.ts b/nftopia-backend/src/nft/dto/nft-filter.dto.ts new file mode 100644 index 0000000..b8bbd17 --- /dev/null +++ b/nftopia-backend/src/nft/dto/nft-filter.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsOptional, IsNumber, Min } from 'class-validator'; + +export class NftFilterDto { + @ApiProperty({ required: false, description: 'Stellar Contract ID (C...)' }) + @IsString() + @IsOptional() + contractId?: string; + + @ApiProperty({ required: false, description: 'Stellar Account ID (G...)' }) + @IsString() + @IsOptional() + owner?: string; + + @ApiProperty({ required: false, minimum: 1, default: 1 }) + @IsNumber() + @Min(1) + @IsOptional() + page?: number = 1; + + @ApiProperty({ required: false, minimum: 1, default: 10 }) + @IsNumber() + @Min(1) + @IsOptional() + limit?: number = 10; + + @ApiProperty({ required: false, enum: ['asc', 'desc'], default: 'desc' }) + @IsString() + @IsOptional() + sortOrder?: 'asc' | 'desc' = 'desc'; + + @ApiProperty({ + required: false, + enum: ['mintedAt', 'price', 'views'], + default: 'mintedAt', + }) + @IsString() + @IsOptional() + sortBy?: 'mintedAt' | 'price' | 'views' = 'mintedAt'; +} diff --git a/nftopia-backend/src/nft/dto/stellar-nft.dto.ts b/nftopia-backend/src/nft/dto/stellar-nft.dto.ts new file mode 100644 index 0000000..daaa1b0 --- /dev/null +++ b/nftopia-backend/src/nft/dto/stellar-nft.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsOptional, Length, Matches } from 'class-validator'; + +export class StellarNftDto { + @ApiProperty({ description: 'Stellar Contract ID (C...)' }) + @IsString() + @Length(56, 56) + @Matches(/^C[A-Z0-9]{55}$/, { message: 'Invalid Stellar Contract ID format' }) + contractId: string; + + @ApiProperty({ description: 'Token ID (uint256 or string)' }) + @IsString() + tokenId: string; + + @ApiProperty({ description: 'Owner Account ID (G...)' }) + @IsString() + @Length(56, 56) + @Matches(/^G[A-Z0-9]{55}$/, { message: 'Invalid Stellar Account ID format' }) + owner: string; + + @ApiProperty({ required: false, description: 'IPFS or HTTP URI' }) + @IsString() + @IsOptional() + metadataUri?: string; + + @ApiProperty({ required: false }) + @IsString() + @IsOptional() + name?: string; + + @ApiProperty({ required: false }) + @IsString() + @IsOptional() + description?: string; + + @ApiProperty({ required: false }) + @IsString() + @IsOptional() + image?: string; +} diff --git a/nftopia-backend/src/nft/entities/nft-metadata.entity.ts b/nftopia-backend/src/nft/entities/nft-metadata.entity.ts new file mode 100644 index 0000000..bfefcb8 --- /dev/null +++ b/nftopia-backend/src/nft/entities/nft-metadata.entity.ts @@ -0,0 +1,23 @@ +import { Entity, Column, PrimaryGeneratedColumn, OneToOne } from 'typeorm'; +import { StellarNft } from './stellar-nft.entity'; + +@Entity('nft_metadata') +export class NftMetadata { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ nullable: true }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ nullable: true }) + image: string; + + @Column({ type: 'jsonb', nullable: true }) + attributes: any; + + @OneToOne(() => StellarNft, (nft) => nft.metadata) + nft: StellarNft; +} diff --git a/nftopia-backend/src/nft/entities/stellar-nft.entity.ts b/nftopia-backend/src/nft/entities/stellar-nft.entity.ts new file mode 100644 index 0000000..ae0be5e --- /dev/null +++ b/nftopia-backend/src/nft/entities/stellar-nft.entity.ts @@ -0,0 +1,53 @@ +import { + Entity, + Column, + PrimaryColumn, + CreateDateColumn, + UpdateDateColumn, + OneToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { NftMetadata } from './nft-metadata.entity'; + +@Entity('stellar_nfts') +@Index(['owner']) // Optimize queries by owner +@Index(['contractId']) // Optimize filter by contract +export class StellarNft { + @PrimaryColumn() + contractId: string; + + @PrimaryColumn() + tokenId: string; + + @Column() + owner: string; // Stellar G-address + + @Column({ nullable: true }) + metadataUri: string; + + @OneToOne(() => NftMetadata, (metadata) => metadata.nft, { + cascade: true, + eager: true, + }) + @JoinColumn() + metadata: NftMetadata; + + @Column({ default: 0 }) + views: number; // For PopularThisWeek + + @Column({ default: 0 }) + salesCount: number; // For TopSellers + + @Column({ type: 'decimal', precision: 20, scale: 7, default: 0 }) + volume: number; // Total volume in XLM + + @Column({ nullable: true }) + mintedAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/nftopia-backend/src/nft/nft.controller.spec.ts b/nftopia-backend/src/nft/nft.controller.spec.ts new file mode 100644 index 0000000..a13d203 --- /dev/null +++ b/nftopia-backend/src/nft/nft.controller.spec.ts @@ -0,0 +1,41 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NftController } from './nft.controller'; +import { NftService } from './nft.service'; + +const mockNftService = { + findAll: jest.fn().mockResolvedValue([]), + getPopular: jest.fn().mockResolvedValue([]), + getTopSellers: jest.fn().mockResolvedValue([]), +}; + +describe('NftController', () => { + let controller: NftController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [NftController], + providers: [ + { + provide: NftService, + useValue: mockNftService, + }, + ], + }).compile(); + + controller = module.get(NftController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should list NFTs', async () => { + expect(await controller.findAll({})).toEqual([]); + expect(mockNftService.findAll).toHaveBeenCalled(); + }); + + it('should get popular NFTs', async () => { + expect(await controller.getPopular()).toEqual([]); + expect(mockNftService.getPopular).toHaveBeenCalled(); + }); +}); diff --git a/nftopia-backend/src/nft/nft.controller.ts b/nftopia-backend/src/nft/nft.controller.ts new file mode 100644 index 0000000..5a2fec9 --- /dev/null +++ b/nftopia-backend/src/nft/nft.controller.ts @@ -0,0 +1,61 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { NftService } from './nft.service'; +import { NftFilterDto } from './dto/nft-filter.dto'; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; + +@ApiTags('nfts') +@Controller('nfts') +export class NftController { + constructor(private readonly nftService: NftService) {} + + /** + * List NFTs with optional filtering, sorting, and pagination. + * @param query NftFilterDto containing filter parameters + * @returns List of StellarNft entities + */ + @Get() + @ApiOperation({ + summary: 'List NFTs with filtering, sorting, and pagination', + }) + @ApiResponse({ status: 200, description: 'List of NFTs.' }) + @ApiQuery({ + name: 'contractId', + required: false, + description: 'Filter by contract ID', + }) + @ApiQuery({ + name: 'owner', + required: false, + description: 'Filter by owner G-address', + }) + async findAll(@Query() query: NftFilterDto) { + return this.nftService.findAll(query); + } + + /** + * Get the most popular NFTs based on view count. + * Cached for 5 minutes. + */ + @Get('popular') + @ApiOperation({ summary: 'Get Popular This Week NFTs' }) + async getPopular() { + return this.nftService.getPopular(); + } + + @Get('top-sellers') + @ApiOperation({ summary: 'Get Top Sellers analytics' }) + async getTopSellers(): Promise< + Array<{ owner: string; sales: string; volume: string }> + > { + return this.nftService.getTopSellers(); + } + + @Get(':contractId/:tokenId') + @ApiOperation({ summary: 'Get specific NFT details' }) + async findOne( + @Param('contractId') contractId: string, + @Param('tokenId') tokenId: string, + ) { + return this.nftService.findOne(contractId, tokenId); + } +} diff --git a/nftopia-backend/src/nft/nft.module.ts b/nftopia-backend/src/nft/nft.module.ts new file mode 100644 index 0000000..27ce3d3 --- /dev/null +++ b/nftopia-backend/src/nft/nft.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { NftService } from './nft.service'; +import { NftController } from './nft.controller'; +import { SorobanService } from './soroban.service'; +import { ScheduleModule } from '@nestjs/schedule'; + +import { TypeOrmModule } from '@nestjs/typeorm'; +import { StellarNft } from './entities/stellar-nft.entity'; +import { NftMetadata } from './entities/nft-metadata.entity'; + +@Module({ + imports: [ + ScheduleModule.forRoot(), + TypeOrmModule.forFeature([StellarNft, NftMetadata]), + ], + controllers: [NftController], + providers: [NftService, SorobanService], + exports: [NftService, SorobanService], +}) +export class NftModule {} diff --git a/nftopia-backend/src/nft/nft.service.spec.ts b/nftopia-backend/src/nft/nft.service.spec.ts new file mode 100644 index 0000000..77c8129 --- /dev/null +++ b/nftopia-backend/src/nft/nft.service.spec.ts @@ -0,0 +1,89 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NftService } from './nft.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { StellarNft } from './entities/stellar-nft.entity'; +import { NftMetadata } from './entities/nft-metadata.entity'; +import { SorobanService } from './soroban.service'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; + +const mockRepository = { + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn(), + query: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + leftJoinAndSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn().mockReturnValue([]), + })), +}; + +const mockSorobanService = { + getLatestLedger: jest.fn().mockResolvedValue(100), + getEvents: jest.fn().mockResolvedValue([]), +}; + +const mockCacheManager = { + get: jest.fn(), + set: jest.fn(), +}; + +describe('NftService', () => { + let service: NftService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NftService, + { + provide: getRepositoryToken(StellarNft), + useValue: mockRepository, + }, + { + provide: getRepositoryToken(NftMetadata), + useValue: mockRepository, + }, + { + provide: SorobanService, + useValue: mockSorobanService, + }, + { + provide: CACHE_MANAGER, + useValue: mockCacheManager, + }, + ], + }).compile(); + + service = module.get(NftService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should findAll NFTs', async () => { + const result = await service.findAll({}); + expect(result).toEqual([]); + expect(mockRepository.createQueryBuilder).toHaveBeenCalled(); + }); + + it('should get popular NFTs (cached)', async () => { + mockCacheManager.get.mockResolvedValueOnce([{ id: 1 }]); + const result = await service.getPopular(); + expect(result).toEqual([{ id: 1 }]); + expect(mockRepository.find).not.toHaveBeenCalled(); + }); + + it('should get popular NFTs (database)', async () => { + mockCacheManager.get.mockResolvedValueOnce(null); + mockRepository.find.mockResolvedValueOnce([{ id: 2 }]); + const result = await service.getPopular(); + expect(result).toEqual([{ id: 2 }]); + expect(mockRepository.find).toHaveBeenCalled(); + expect(mockCacheManager.set).toHaveBeenCalled(); + }); +}); diff --git a/nftopia-backend/src/nft/nft.service.ts b/nftopia-backend/src/nft/nft.service.ts new file mode 100644 index 0000000..f8df205 --- /dev/null +++ b/nftopia-backend/src/nft/nft.service.ts @@ -0,0 +1,139 @@ +import { Injectable, Logger, OnModuleInit, Inject } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import type { Cache } from 'cache-manager'; +import { NftFilterDto } from './dto/nft-filter.dto'; +import { StellarNft } from './entities/stellar-nft.entity'; +import { NftMetadata } from './entities/nft-metadata.entity'; +import { SorobanService } from './soroban.service'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +@Injectable() +export class NftService implements OnModuleInit { + private readonly logger = new Logger(NftService.name); + private lastSyncedLedger = 0; + + constructor( + @Inject(CACHE_MANAGER) private cacheManager: Cache, + @InjectRepository(StellarNft) + private readonly nftRepository: Repository, + @InjectRepository(NftMetadata) + private readonly metadataRepository: Repository, + private readonly sorobanService: SorobanService, + ) {} + + async onModuleInit() { + // Initialize lastSyncedLedger from latest ledger or DB + const latest = await this.sorobanService.getLatestLedger(); + this.lastSyncedLedger = latest > 1000 ? latest - 1000 : 0; + this.logger.log(`Initialized sync from ledger ${this.lastSyncedLedger}`); + } + + async findAll(query: NftFilterDto) { + const qb = this.nftRepository.createQueryBuilder('nft'); + qb.leftJoinAndSelect('nft.metadata', 'metadata'); + + if (query.contractId) { + qb.andWhere('nft.contractId = :contractId', { + contractId: query.contractId, + }); + } + if (query.owner) { + qb.andWhere('nft.owner = :owner', { owner: query.owner }); + } + + if (query.sortBy === 'price') { + qb.orderBy('nft.price', query.sortOrder === 'asc' ? 'ASC' : 'DESC'); + } else if (query.sortBy === 'views') { + qb.orderBy('nft.views', query.sortOrder === 'asc' ? 'ASC' : 'DESC'); + } else { + qb.orderBy('nft.mintedAt', query.sortOrder === 'asc' ? 'ASC' : 'DESC'); + } + + const page = query.page || 1; + const limit = query.limit || 10; + qb.skip((page - 1) * limit).take(limit); + + return qb.getMany(); + } + + async findOne(contractId: string, tokenId: string) { + return this.nftRepository.findOne({ + where: { contractId, tokenId }, + relations: ['metadata'], + }); + } + + async getPopular() { + const cacheKey = 'nft:popular'; + const cached = await this.cacheManager.get(cacheKey); + if (cached) return cached; + + const popular = await this.nftRepository.find({ + order: { views: 'DESC' }, + take: 10, + relations: ['metadata'], + }); + + await this.cacheManager.set(cacheKey, popular, 300000); // 5 minutes + return popular; + } + + async getTopSellers(): Promise< + Array<{ owner: string; sales: string; volume: string }> + > { + const cacheKey = 'nft:top-sellers'; + const cached = + await this.cacheManager.get< + Array<{ owner: string; sales: string; volume: string }> + >(cacheKey); + if (cached) return cached; + + const sellers: Array<{ owner: string; sales: string; volume: string }> = + await this.nftRepository.query(` + SELECT owner, count(*) as sales, sum(volume) as volume + FROM stellar_nfts + GROUP BY owner + ORDER BY volume DESC + LIMIT 10 + `); + + await this.cacheManager.set(cacheKey, sellers, 300000); // 5 minutes + + return sellers; + } + + @Cron(CronExpression.EVERY_MINUTE) + async handleCron() { + this.logger.debug('Syncing NFT data...'); + try { + const latest = await this.sorobanService.getLatestLedger(); + if (latest <= this.lastSyncedLedger) return; + + // Fetch events from lastSyncedLedger to latest + // For simplicity, we just fetch specific contract events if we knew the IDs, + // but here we might need to scan known contracts or assume a factory. + // Issue description says "marketplace_contract: Get listings... nft_contract...". + // Use a dummy contract ID list or config for now. + const contractIds = ['CDUMMY_CONTRACT_ID']; // Replace with actual or config + + const events = await this.sorobanService.getEvents( + this.lastSyncedLedger, + contractIds, + ); + + // Process events to find minted/transferred tokens + // This is a simplification. + // Ideally we parse: 'mint', 'transfer', 'sale'. + if (events && events.length > 0) { + this.logger.debug(`Found ${events.length} new events to process.`); + } + + this.lastSyncedLedger = latest; + } catch (e) { + const error = e as Error; + this.logger.error(`Sync failed: ${error.message}`); + } + } +} diff --git a/nftopia-backend/src/nft/soroban.service.ts b/nftopia-backend/src/nft/soroban.service.ts new file mode 100644 index 0000000..5992ac8 --- /dev/null +++ b/nftopia-backend/src/nft/soroban.service.ts @@ -0,0 +1,77 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Server, Durability } from 'stellar-sdk/rpc'; +import { xdr } from 'stellar-sdk'; + +@Injectable() +export class SorobanService { + private readonly logger = new Logger(SorobanService.name); + private server: Server; + + constructor(private configService: ConfigService) { + const rpcUrl = this.configService.get('SOROBAN_RPC_URL'); + if (!rpcUrl) { + this.logger.warn( + 'SOROBAN_RPC_URL is not set. Using default testnet URL.', + ); + } + this.server = new Server(rpcUrl || 'https://soroban-testnet.stellar.org'); + } + + getRpcServer() { + return this.server; + } + + async getContractData(contractId: string, key: xdr.ScVal) { + try { + const data = await this.server.getContractData( + contractId, + key, + Durability.Persistent, + ); + return data; + } catch (e) { + const error = e as Error; + this.logger.error( + `Failed to fetch contract data for contract ${contractId}: ${error.message}`, + error.stack, + ); + return null; + } + } + + async getEvents( + startLedger: number, + contractIds: string[], + topics: string[][] = [], + ) { + try { + const response = await this.server.getEvents({ + startLedger, + filters: [ + { + type: 'contract', + contractIds, + topics, + }, + ], + }); + return response.events; + } catch (e) { + const error = e as Error; + this.logger.error(`Error fetching events: ${error.message}`); + return []; + } + } + + async getLatestLedger() { + try { + const response = await this.server.getLatestLedger(); + return response.sequence; + } catch (e) { + const error = e as Error; + this.logger.error(`Error fetching latest ledger: ${error.message}`); + return 0; + } + } +}