diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index 7dc3a678..fa10e837 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -1,27 +1,27 @@ import { + Body, Controller, Get, - Post, - Patch, Param, - Body, + Patch, + Post, Query, - UseGuards, Request, + UseGuards, } from '@nestjs/common'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { RolesGuard } from '../common/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; import { Role } from '../common/enums/role.enum'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; import { AdminService } from './admin.service'; -import { ListUsersQueryDto } from './dto/list-users-query.dto'; -import { BanUserDto } from './dto/ban-user.dto'; import { ActivityLogQueryDto } from './dto/activity-log-query.dto'; -import { StatsResponseDto } from './dto/stats-response.dto'; -import { ResolveMarketDto } from './dto/resolve-market.dto'; -import { UpdateUserRoleDto } from './dto/update-user-role.dto'; +import { BanUserDto } from './dto/ban-user.dto'; +import { ListUsersQueryDto } from './dto/list-users-query.dto'; import { ModerateCommentDto } from './dto/moderate-comment.dto'; import { ReportQueryDto } from './dto/report-query.dto'; +import { ResolveMarketDto } from './dto/resolve-market.dto'; +import { StatsResponseDto } from './dto/stats-response.dto'; +import { UpdateUserRoleDto } from './dto/update-user-role.dto'; @Controller('admin') @UseGuards(JwtAuthGuard, RolesGuard) @@ -102,6 +102,22 @@ export class AdminController { return this.adminService.moderateComment(id, dto.is_moderated, dto.reason); } + @Patch('markets/:id/feature') + async featureMarket(@Param('id') id: string, @Request() req: any) { + return this.adminService.featureMarket( + id, + (req as { user: { id: string } }).user.id, + ); + } + + @Patch('markets/:id/unfeature') + async unfeatureMarket(@Param('id') id: string, @Request() req: any) { + return this.adminService.unfeatureMarket( + id, + (req as { user: { id: string } }).user.id, + ); + } + @Get('reports/activity') async getActivityReport(@Query() query: ReportQueryDto) { return this.adminService.getActivityReport(query); diff --git a/backend/src/admin/admin.service.spec.ts b/backend/src/admin/admin.service.spec.ts index 12ea8a26..151ba082 100644 --- a/backend/src/admin/admin.service.spec.ts +++ b/backend/src/admin/admin.service.spec.ts @@ -6,10 +6,11 @@ import { } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Comment } from '../markets/entities/comment.entity'; -import { ActivityLog } from '../analytics/entities/activity-log.entity'; import { AnalyticsService } from '../analytics/analytics.service'; +import { ActivityLog } from '../analytics/entities/activity-log.entity'; +import { Role } from '../common/enums/role.enum'; import { Competition } from '../competitions/entities/competition.entity'; +import { Comment } from '../markets/entities/comment.entity'; import { Market } from '../markets/entities/market.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { Prediction } from '../predictions/entities/prediction.entity'; @@ -17,7 +18,6 @@ import { SorobanService } from '../soroban/soroban.service'; import { User } from '../users/entities/user.entity'; import { AdminService } from './admin.service'; import { ResolveMarketDto } from './dto/resolve-market.dto'; -import { Role } from '../common/enums/role.enum'; const mockRepo = () => ({ findOne: jest.fn(), @@ -208,6 +208,181 @@ describe('AdminService.adminResolveMarket', () => { }); }); +describe('AdminService.featureMarket', () => { + let service: AdminService; + let marketsRepo: ReturnType; + let analyticsService: jest.Mocked>; + + const adminId = 'admin-1'; + + const makeMarket = (overrides: Partial = {}): Market => + ({ + id: 'market-1', + on_chain_market_id: 'on-chain-1', + title: 'Test Market', + is_featured: false, + featured_at: null, + ...overrides, + }) as Market; + + beforeEach(async () => { + marketsRepo = mockRepo(); + analyticsService = { logActivity: jest.fn().mockResolvedValue({}) }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminService, + { provide: getRepositoryToken(User), useValue: mockRepo() }, + { provide: getRepositoryToken(Market), useValue: marketsRepo }, + { provide: getRepositoryToken(Comment), useValue: mockRepo() }, + { provide: getRepositoryToken(Prediction), useValue: mockRepo() }, + { provide: getRepositoryToken(Competition), useValue: mockRepo() }, + { provide: getRepositoryToken(ActivityLog), useValue: mockRepo() }, + { provide: AnalyticsService, useValue: analyticsService }, + { provide: NotificationsService, useValue: { create: jest.fn() } }, + { provide: SorobanService, useValue: { resolveMarket: jest.fn() } }, + ], + }).compile(); + + service = module.get(AdminService); + }); + + it('throws NotFoundException when market does not exist', async () => { + marketsRepo.findOne.mockResolvedValue(null); + + await expect(service.featureMarket('bad-id', adminId)).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws ConflictException when market is already featured', async () => { + marketsRepo.findOne.mockResolvedValue(makeMarket({ is_featured: true })); + + await expect(service.featureMarket('market-1', adminId)).rejects.toThrow( + ConflictException, + ); + }); + + it('features market and logs admin action', async () => { + const market = makeMarket(); + const featuredMarket = { + ...market, + is_featured: true, + featured_at: new Date(), + }; + + marketsRepo.findOne.mockResolvedValue(market); + marketsRepo.save.mockResolvedValue(featuredMarket); + + const result = await service.featureMarket('market-1', adminId); + + expect(marketsRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + is_featured: true, + featured_at: expect.any(Date), + }), + ); + expect(analyticsService.logActivity).toHaveBeenCalledWith( + adminId, + 'MARKET_FEATURED_BY_ADMIN', + expect.objectContaining({ + market_id: 'market-1', + featured_at: expect.any(Date), + }), + ); + expect(result.is_featured).toBe(true); + expect(result.featured_at).toBeInstanceOf(Date); + expect(result.featured_at).not.toBeNull(); + }); +}); + +describe('AdminService.unfeatureMarket', () => { + let service: AdminService; + let marketsRepo: ReturnType; + let analyticsService: jest.Mocked>; + + const adminId = 'admin-1'; + + const makeMarket = (overrides: Partial = {}): Market => + ({ + id: 'market-1', + on_chain_market_id: 'on-chain-1', + title: 'Test Market', + is_featured: true, + featured_at: new Date(), + ...overrides, + }) as Market; + + beforeEach(async () => { + marketsRepo = mockRepo(); + analyticsService = { logActivity: jest.fn().mockResolvedValue({}) }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminService, + { provide: getRepositoryToken(User), useValue: mockRepo() }, + { provide: getRepositoryToken(Market), useValue: marketsRepo }, + { provide: getRepositoryToken(Comment), useValue: mockRepo() }, + { provide: getRepositoryToken(Prediction), useValue: mockRepo() }, + { provide: getRepositoryToken(Competition), useValue: mockRepo() }, + { provide: getRepositoryToken(ActivityLog), useValue: mockRepo() }, + { provide: AnalyticsService, useValue: analyticsService }, + { provide: NotificationsService, useValue: { create: jest.fn() } }, + { provide: SorobanService, useValue: { resolveMarket: jest.fn() } }, + ], + }).compile(); + + service = module.get(AdminService); + }); + + it('throws NotFoundException when market does not exist', async () => { + marketsRepo.findOne.mockResolvedValue(null); + + await expect(service.unfeatureMarket('bad-id', adminId)).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws ConflictException when market is not featured', async () => { + marketsRepo.findOne.mockResolvedValue(makeMarket({ is_featured: false })); + + await expect(service.unfeatureMarket('market-1', adminId)).rejects.toThrow( + ConflictException, + ); + }); + + it('unfeatures market and logs admin action', async () => { + const market = makeMarket(); + const unfeaturedMarket = { + ...market, + is_featured: false, + featured_at: null, + }; + + marketsRepo.findOne.mockResolvedValue(market); + marketsRepo.save.mockResolvedValue(unfeaturedMarket); + + const result = await service.unfeatureMarket('market-1', adminId); + + expect(marketsRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + is_featured: false, + featured_at: null, + }), + ); + expect(analyticsService.logActivity).toHaveBeenCalledWith( + adminId, + 'MARKET_UNFEATURED_BY_ADMIN', + expect.objectContaining({ + market_id: 'market-1', + unfeatured_at: expect.any(Date), + }), + ); + expect(result.is_featured).toBe(false); + expect(result.featured_at).toBeNull(); + }); +}); + describe('AdminService.updateUserRole', () => { let service: AdminService; let usersRepo: ReturnType; diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index 1aeb48cb..7f67828c 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -1,33 +1,33 @@ import { - Injectable, - NotFoundException, - ConflictException, - BadRequestException, BadGatewayException, + BadRequestException, + ConflictException, + Injectable, Logger, + NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Between } from 'typeorm'; -import { User } from '../users/entities/user.entity'; -import { Market } from '../markets/entities/market.entity'; -import { Comment } from '../markets/entities/comment.entity'; -import { Prediction } from '../predictions/entities/prediction.entity'; -import { Competition } from '../competitions/entities/competition.entity'; -import { ActivityLog } from '../analytics/entities/activity-log.entity'; +import { Between, Repository } from 'typeorm'; import { AnalyticsService } from '../analytics/analytics.service'; -import { NotificationsService } from '../notifications/notifications.service'; +import { ActivityLog } from '../analytics/entities/activity-log.entity'; +import { Competition } from '../competitions/entities/competition.entity'; +import { Comment } from '../markets/entities/comment.entity'; +import { Market } from '../markets/entities/market.entity'; import { NotificationType } from '../notifications/entities/notification.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { Prediction } from '../predictions/entities/prediction.entity'; import { SorobanService } from '../soroban/soroban.service'; -import { ListUsersQueryDto } from './dto/list-users-query.dto'; +import { User } from '../users/entities/user.entity'; import { ActivityLogQueryDto } from './dto/activity-log-query.dto'; -import { StatsResponseDto } from './dto/stats-response.dto'; -import { ResolveMarketDto } from './dto/resolve-market.dto'; -import { UpdateUserRoleDto } from './dto/update-user-role.dto'; +import { ListUsersQueryDto } from './dto/list-users-query.dto'; import { ReportFormat, ReportQueryDto, ReportTimeframe, } from './dto/report-query.dto'; +import { ResolveMarketDto } from './dto/resolve-market.dto'; +import { StatsResponseDto } from './dto/stats-response.dto'; +import { UpdateUserRoleDto } from './dto/update-user-role.dto'; @Injectable() export class AdminService { @@ -352,6 +352,74 @@ export class AdminService { return await this.commentsRepository.save(comment); } + async featureMarket(marketId: string, adminId: string): Promise { + const market = await this.marketsRepository.findOne({ + where: [{ id: marketId }, { on_chain_market_id: marketId }], + }); + + if (!market) { + throw new NotFoundException(`Market "${marketId}" not found`); + } + + if (market.is_featured) { + throw new ConflictException('Market is already featured'); + } + + market.is_featured = true; + market.featured_at = new Date(); + const saved = await this.marketsRepository.save(market); + + // Log admin action + await this.analyticsService.logActivity( + adminId, + 'MARKET_FEATURED_BY_ADMIN', + { + market_id: market.id, + featured_at: market.featured_at, + }, + ); + + this.logger.log( + `Admin ${adminId} featured market "${market.title}" (${market.id})`, + ); + + return saved; + } + + async unfeatureMarket(marketId: string, adminId: string): Promise { + const market = await this.marketsRepository.findOne({ + where: [{ id: marketId }, { on_chain_market_id: marketId }], + }); + + if (!market) { + throw new NotFoundException(`Market "${marketId}" not found`); + } + + if (!market.is_featured) { + throw new ConflictException('Market is not featured'); + } + + market.is_featured = false; + market.featured_at = null; + const saved = await this.marketsRepository.save(market); + + // Log admin action + await this.analyticsService.logActivity( + adminId, + 'MARKET_UNFEATURED_BY_ADMIN', + { + market_id: market.id, + unfeatured_at: new Date(), + }, + ); + + this.logger.log( + `Admin ${adminId} unfeatured market "${market.title}" (${market.id})`, + ); + + return saved; + } + async getActivityReport(query: ReportQueryDto) { const { timeframe, format } = query; const now = new Date(); diff --git a/backend/src/markets/entities/market.entity.ts b/backend/src/markets/entities/market.entity.ts index 9cf470bb..baa451b0 100644 --- a/backend/src/markets/entities/market.entity.ts +++ b/backend/src/markets/entities/market.entity.ts @@ -1,18 +1,18 @@ import { - Entity, - PrimaryGeneratedColumn, + IsBoolean, + IsNumber, + IsOptional, + IsString, + Min, +} from 'class-validator'; +import { Column, CreateDateColumn, - ManyToOne, + Entity, Index, + ManyToOne, + PrimaryGeneratedColumn, } from 'typeorm'; -import { - IsString, - IsOptional, - IsNumber, - IsBoolean, - Min, -} from 'class-validator'; import { User } from '../../users/entities/user.entity'; @Entity('markets') @@ -20,6 +20,7 @@ import { User } from '../../users/entities/user.entity'; @Index(['creator']) @Index(['category']) @Index(['is_resolved']) +@Index(['is_featured']) export class Market { @PrimaryGeneratedColumn('uuid') id: string; @@ -70,6 +71,14 @@ export class Market { @IsBoolean() is_cancelled: boolean; + @Column({ default: false }) + @IsBoolean() + is_featured: boolean; + + @Column({ type: 'timestamptz', nullable: true }) + @IsOptional() + featured_at: Date | null; + @Column({ type: 'bigint', default: '0' }) @IsString() total_pool_stroops: string; diff --git a/backend/src/markets/markets.controller.ts b/backend/src/markets/markets.controller.ts index a096856f..7b378e7a 100644 --- a/backend/src/markets/markets.controller.ts +++ b/backend/src/markets/markets.controller.ts @@ -1,44 +1,44 @@ import { + Body, Controller, - Get, - Post, Delete, - Param, - Body, - Query, + Get, HttpCode, HttpStatus, + Param, + Post, + Query, UseGuards, } from '@nestjs/common'; -import { Throttle } from '@nestjs/throttler'; -import { BanGuard } from '../common/guards/ban.guard'; -import { PredictionStatsDto } from './dto/prediction-stats.dto'; import { + ApiBearerAuth, ApiOperation, ApiResponse, ApiTags, - ApiBearerAuth, } from '@nestjs/swagger'; -import { MarketsService } from './markets.service'; -import { Market } from './entities/market.entity'; -import { Comment } from './entities/comment.entity'; -import { MarketTemplate } from './entities/market-template.entity'; -import { CreateMarketDto } from './dto/create-market.dto'; +import { Throttle } from '@nestjs/throttler'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { Public } from '../common/decorators/public.decorator'; +import { Roles } from '../common/decorators/roles.decorator'; +import { Role } from '../common/enums/role.enum'; +import { BanGuard } from '../common/guards/ban.guard'; +import { User } from '../users/entities/user.entity'; import { BulkCreateMarketsDto } from './dto/bulk-create-markets.dto'; import { CreateCommentDto } from './dto/create-comment.dto'; +import { CreateMarketDto } from './dto/create-market.dto'; import { ListMarketsDto, PaginatedMarketsResponse, } from './dto/list-markets.dto'; +import { PredictionStatsDto } from './dto/prediction-stats.dto'; import { - TrendingMarketsQueryDto, PaginatedTrendingMarketsResponse, + TrendingMarketsQueryDto, } from './dto/trending-markets.dto'; -import { CurrentUser } from '../common/decorators/current-user.decorator'; -import { Public } from '../common/decorators/public.decorator'; -import { Roles } from '../common/decorators/roles.decorator'; -import { Role } from '../common/enums/role.enum'; -import { User } from '../users/entities/user.entity'; +import { Comment } from './entities/comment.entity'; +import { MarketTemplate } from './entities/market-template.entity'; +import { Market } from './entities/market.entity'; +import { MarketsService } from './markets.service'; @ApiTags('Markets') @Controller('markets') @@ -138,6 +138,22 @@ export class MarketsController { return this.marketsService.findAllFiltered(query); } + @Get('featured') + @Public() + @ApiOperation({ summary: 'Get featured markets' }) + @ApiResponse({ + status: 200, + description: 'Paginated featured markets list', + }) + async getFeaturedMarkets( + @Query('page') page?: string, + @Query('limit') limit?: string, + ): Promise { + const pageNum = page ? parseInt(page, 10) : 1; + const limitNum = limit ? Math.min(parseInt(limit, 10), 50) : 20; + return this.marketsService.findFeaturedMarkets(pageNum, limitNum); + } + @Get(':id') @Public() @ApiOperation({ summary: 'Fetch market by ID or on-chain ID' }) diff --git a/backend/src/markets/markets.service.spec.ts b/backend/src/markets/markets.service.spec.ts index a34cd54a..116885fd 100644 --- a/backend/src/markets/markets.service.spec.ts +++ b/backend/src/markets/markets.service.spec.ts @@ -1,16 +1,16 @@ import { BadRequestException, ConflictException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository, DataSource } from 'typeorm'; +import { DataSource, Repository } from 'typeorm'; import { SorobanService } from '../soroban/soroban.service'; -import { UsersService } from '../users/users.service'; import { User } from '../users/entities/user.entity'; -import { Market } from './entities/market.entity'; +import { UsersService } from '../users/users.service'; +import { CreateMarketDto } from './dto/create-market.dto'; import { Comment } from './entities/comment.entity'; import { MarketTemplate } from './entities/market-template.entity'; -import { CreateMarketDto } from './dto/create-market.dto'; -import { MarketsService } from './markets.service'; +import { Market } from './entities/market.entity'; import { UserBookmark } from './entities/user-bookmark.entity'; +import { MarketsService } from './markets.service'; type MockRepo = jest.Mocked< Pick, 'create' | 'save' | 'findOne' | 'find'> @@ -261,3 +261,112 @@ describe('MarketsService', () => { }); }); }); + +describe('MarketsService.findFeaturedMarkets', () => { + let service: MarketsService; + let marketsRepository: jest.Mocked>; + + const makeFeaturedMarket = (overrides: Partial = {}): Market => + ({ + id: `market-${Math.random()}`, + on_chain_market_id: `on-chain-${Math.random()}`, + title: 'Featured Market', + is_featured: true, + featured_at: new Date(), + is_public: true, + is_cancelled: false, + ...overrides, + }) as Market; + + beforeEach(async () => { + marketsRepository = { + createQueryBuilder: jest.fn(), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MarketsService, + { provide: getRepositoryToken(Market), useValue: marketsRepository }, + { provide: getRepositoryToken(Comment), useValue: {} }, + { provide: getRepositoryToken(MarketTemplate), useValue: {} }, + { provide: getRepositoryToken(UserBookmark), useValue: {} }, + { provide: getRepositoryToken(User), useValue: {} }, + { provide: UsersService, useValue: {} }, + { provide: SorobanService, useValue: {} }, + { provide: DataSource, useValue: {} }, + ], + }).compile(); + + service = module.get(MarketsService); + }); + + it('returns featured markets with correct filters', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn(), + }; + + marketsRepository.createQueryBuilder.mockReturnValue( + mockQueryBuilder as any, + ); + + const featuredMarkets = [makeFeaturedMarket(), makeFeaturedMarket()]; + mockQueryBuilder.getManyAndCount.mockResolvedValue([featuredMarkets, 2]); + + const result = await service.findFeaturedMarkets(1, 20); + + expect(marketsRepository.createQueryBuilder).toHaveBeenCalledWith('market'); + expect(mockQueryBuilder.leftJoinAndSelect).toHaveBeenCalledWith( + 'market.creator', + 'creator', + ); + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + 'market.is_featured = true', + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'market.is_public = true', + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'market.is_cancelled = false', + ); + expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith( + 'market.featured_at', + 'DESC', + ); + expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0); + expect(mockQueryBuilder.take).toHaveBeenCalledWith(20); + expect(result).toEqual({ + data: featuredMarkets, + total: 2, + page: 1, + limit: 20, + }); + }); + + it('handles pagination correctly', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn(), + }; + + marketsRepository.createQueryBuilder.mockReturnValue( + mockQueryBuilder as any, + ); + mockQueryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + + await service.findFeaturedMarkets(2, 10); + + expect(mockQueryBuilder.skip).toHaveBeenCalledWith(10); + expect(mockQueryBuilder.take).toHaveBeenCalledWith(10); + }); +}); diff --git a/backend/src/markets/markets.service.ts b/backend/src/markets/markets.service.ts index 330b51ca..bc815204 100644 --- a/backend/src/markets/markets.service.ts +++ b/backend/src/markets/markets.service.ts @@ -1,33 +1,33 @@ import { - Injectable, - NotFoundException, BadGatewayException, - Logger, - ConflictException, BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, } from '@nestjs/common'; -import { PredictionStatsDto } from './dto/prediction-stats.dto'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DataSource } from 'typeorm'; -import { Market } from './entities/market.entity'; -import { Comment } from './entities/comment.entity'; -import { MarketTemplate } from './entities/market-template.entity'; -import { CreateMarketDto } from './dto/create-market.dto'; -import { CreateCommentDto } from './dto/create-comment.dto'; -import { UsersService } from '../users/users.service'; +import { DataSource, Repository } from 'typeorm'; +import { SorobanService } from '../soroban/soroban.service'; import { User } from '../users/entities/user.entity'; -import { UserBookmark } from './entities/user-bookmark.entity'; +import { UsersService } from '../users/users.service'; +import { CreateCommentDto } from './dto/create-comment.dto'; +import { CreateMarketDto } from './dto/create-market.dto'; import { ListMarketsDto, MarketStatus, PaginatedMarketsResponse, } from './dto/list-markets.dto'; +import { PredictionStatsDto } from './dto/prediction-stats.dto'; import { - TrendingMarketsQueryDto, - TrendingMarketItem, PaginatedTrendingMarketsResponse, + TrendingMarketItem, + TrendingMarketsQueryDto, } from './dto/trending-markets.dto'; -import { SorobanService } from '../soroban/soroban.service'; +import { Comment } from './entities/comment.entity'; +import { MarketTemplate } from './entities/market-template.entity'; +import { Market } from './entities/market.entity'; +import { UserBookmark } from './entities/user-bookmark.entity'; @Injectable() export class MarketsService { @@ -376,7 +376,11 @@ export class MarketsService { }); } - qb.orderBy('market.created_at', 'DESC').skip(skip).take(limit); + qb.orderBy('market.is_featured', 'DESC') + .addOrderBy('market.featured_at', 'DESC') + .addOrderBy('market.created_at', 'DESC') + .skip(skip) + .take(limit); const [data, total] = await qb.getManyAndCount(); @@ -528,6 +532,31 @@ export class MarketsService { }); } + /** +<<<<<<< HEAD + * Get featured markets + */ + async findFeaturedMarkets( + page = 1, + limit = 20, + ): Promise { + const skip = (page - 1) * limit; + + const qb = this.marketsRepository + .createQueryBuilder('market') + .leftJoinAndSelect('market.creator', 'creator') + .where('market.is_featured = true') + .andWhere('market.is_public = true') + .andWhere('market.is_cancelled = false') + .orderBy('market.featured_at', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { data, total, page, limit }; + } + /** * Generate a detailed market report with anonymized predictions */ diff --git a/backend/src/migrations/1774821713259-AddFeaturedFieldsToMarket.ts b/backend/src/migrations/1774821713259-AddFeaturedFieldsToMarket.ts new file mode 100644 index 00000000..a6183fc9 --- /dev/null +++ b/backend/src/migrations/1774821713259-AddFeaturedFieldsToMarket.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFeaturedFieldsToMarket1774821713259 implements MigrationInterface { + name = 'AddFeaturedFieldsToMarket1774821713259'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "markets" ADD "is_featured" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "markets" ADD "featured_at" TIMESTAMP WITH TIME ZONE`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_is_featured" ON "markets" ("is_featured")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_is_featured"`); + await queryRunner.query(`ALTER TABLE "markets" DROP COLUMN "featured_at"`); + await queryRunner.query(`ALTER TABLE "markets" DROP COLUMN "is_featured"`); + } +}