diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 861e67f1..4f12e8b3 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -24,6 +24,7 @@ import { NotificationsModule } from './notifications/notifications.module'; import { PredictionsModule } from './predictions/predictions.module'; import { SeasonsModule } from './seasons/seasons.module'; import { SorobanModule } from './soroban/soroban.module'; +import { SearchModule } from './search/search.module'; import { UsersModule } from './users/users.module'; @Module({ @@ -79,6 +80,7 @@ import { UsersModule } from './users/users.module'; SorobanModule, AdminModule, AchievementsModule, + SearchModule, CommonModule, AnalyticsModule, ], diff --git a/backend/src/search/dto/global-search.dto.ts b/backend/src/search/dto/global-search.dto.ts new file mode 100644 index 00000000..fd78cc13 --- /dev/null +++ b/backend/src/search/dto/global-search.dto.ts @@ -0,0 +1,94 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsEnum, + IsNumber, + IsOptional, + IsString, + Max, + Min, + MinLength, +} from 'class-validator'; + +export enum SearchType { + All = 'all', + Markets = 'markets', + Users = 'users', + Competitions = 'competitions', +} + +export class GlobalSearchDto { + @ApiProperty({ description: 'Search query string', example: 'bitcoin' }) + @IsString() + @MinLength(1) + query: string; + + @ApiPropertyOptional({ + enum: SearchType, + default: SearchType.All, + description: 'Filter results by entity type', + }) + @IsOptional() + @IsEnum(SearchType) + type?: SearchType = SearchType.All; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20, maximum: 50 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(50) + limit?: number = 20; +} + +export class MarketSearchResult { + @ApiProperty() id: string; + @ApiProperty() title: string; + @ApiProperty() description: string; + @ApiProperty() category: string; + @ApiProperty() is_resolved: boolean; + @ApiProperty() is_public: boolean; + @ApiProperty() participant_count: number; + @ApiProperty() created_at: Date; +} + +export class UserSearchResult { + @ApiProperty() id: string; + @ApiProperty() username: string | null; + @ApiProperty() stellar_address: string; + @ApiProperty() avatar_url: string | null; + @ApiProperty() reputation_score: number; + @ApiProperty() total_predictions: number; +} + +export class CompetitionSearchResult { + @ApiProperty() id: string; + @ApiProperty() title: string; + @ApiProperty() description: string; + @ApiProperty() start_time: Date; + @ApiProperty() end_time: Date; + @ApiProperty() participant_count: number; + @ApiProperty() visibility: string; +} + +export class GlobalSearchResponseDto { + @ApiProperty({ type: [MarketSearchResult] }) + markets: MarketSearchResult[]; + + @ApiProperty({ type: [UserSearchResult] }) + users: UserSearchResult[]; + + @ApiProperty({ type: [CompetitionSearchResult] }) + competitions: CompetitionSearchResult[]; + + @ApiProperty() total: number; + @ApiProperty() page: number; + @ApiProperty() limit: number; +} diff --git a/backend/src/search/search.controller.ts b/backend/src/search/search.controller.ts new file mode 100644 index 00000000..429a98a9 --- /dev/null +++ b/backend/src/search/search.controller.ts @@ -0,0 +1,40 @@ +import { + Controller, + Get, + Query, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Public } from '../common/decorators/public.decorator'; +import { GlobalSearchDto, GlobalSearchResponseDto } from './dto/global-search.dto'; +import { SearchService } from './search.service'; + +@ApiTags('Search') +@Controller('search') +export class SearchController { + constructor(private readonly searchService: SearchService) {} + + @Public() + @Get() + @UsePipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + }), + ) + @ApiOperation({ + summary: 'Global search across markets, users, and competitions (public)', + description: + 'Searches across multiple entity types using a single query string. ' + + 'Results can be filtered by type and are paginated. ' + + 'Only public markets, non-banned users, and public competitions are returned.', + }) + @ApiResponse({ status: 200, type: GlobalSearchResponseDto }) + async search( + @Query() query: GlobalSearchDto, + ): Promise { + return this.searchService.search(query); + } +} diff --git a/backend/src/search/search.module.ts b/backend/src/search/search.module.ts new file mode 100644 index 00000000..62ec80f6 --- /dev/null +++ b/backend/src/search/search.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Market } from '../markets/entities/market.entity'; +import { User } from '../users/entities/user.entity'; +import { Competition } from '../competitions/entities/competition.entity'; +import { SearchController } from './search.controller'; +import { SearchService } from './search.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Market, User, Competition])], + controllers: [SearchController], + providers: [SearchService], +}) +export class SearchModule {} diff --git a/backend/src/search/search.service.ts b/backend/src/search/search.service.ts new file mode 100644 index 00000000..cf91b196 --- /dev/null +++ b/backend/src/search/search.service.ts @@ -0,0 +1,133 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Market } from '../markets/entities/market.entity'; +import { User } from '../users/entities/user.entity'; +import { + Competition, + CompetitionVisibility, +} from '../competitions/entities/competition.entity'; +import { + GlobalSearchDto, + GlobalSearchResponseDto, + SearchType, +} from './dto/global-search.dto'; + +@Injectable() +export class SearchService { + constructor( + @InjectRepository(Market) + private readonly marketsRepository: Repository, + @InjectRepository(User) + private readonly usersRepository: Repository, + @InjectRepository(Competition) + private readonly competitionsRepository: Repository, + ) {} + + async search(dto: GlobalSearchDto): Promise { + const page = dto.page ?? 1; + const limit = Math.min(dto.limit ?? 20, 50); + const skip = (page - 1) * limit; + const searchType = dto.type ?? SearchType.All; + const searchPattern = `%${dto.query}%`; + + const [markets, users, competitions] = await Promise.all([ + searchType === SearchType.All || searchType === SearchType.Markets + ? this.searchMarkets(searchPattern, skip, limit) + : Promise.resolve([]), + searchType === SearchType.All || searchType === SearchType.Users + ? this.searchUsers(searchPattern, skip, limit) + : Promise.resolve([]), + searchType === SearchType.All || searchType === SearchType.Competitions + ? this.searchCompetitions(searchPattern, skip, limit) + : Promise.resolve([]), + ]); + + const total = markets.length + users.length + competitions.length; + + return { markets, users, competitions, total, page, limit }; + } + + private async searchMarkets( + pattern: string, + skip: number, + limit: number, + ): Promise { + return this.marketsRepository + .createQueryBuilder('market') + .select([ + 'market.id', + 'market.title', + 'market.description', + 'market.category', + 'market.is_resolved', + 'market.is_public', + 'market.participant_count', + 'market.created_at', + ]) + .where('market.is_public = :isPublic', { isPublic: true }) + .andWhere( + '(market.title ILIKE :pattern OR market.description ILIKE :pattern)', + { pattern }, + ) + .orderBy('market.created_at', 'DESC') + .skip(skip) + .take(limit) + .getMany(); + } + + private async searchUsers( + pattern: string, + skip: number, + limit: number, + ): Promise { + return this.usersRepository + .createQueryBuilder('user') + .select([ + 'user.id', + 'user.username', + 'user.stellar_address', + 'user.avatar_url', + 'user.reputation_score', + 'user.total_predictions', + ]) + .where('user.is_banned = :banned', { banned: false }) + .andWhere( + '(user.username ILIKE :pattern OR user.stellar_address ILIKE :pattern)', + { pattern }, + ) + .orderBy('user.reputation_score', 'DESC') + .skip(skip) + .take(limit) + .getMany(); + } + + private async searchCompetitions( + pattern: string, + skip: number, + limit: number, + ): Promise { + return this.competitionsRepository + .createQueryBuilder('competition') + .select([ + 'competition.id', + 'competition.title', + 'competition.description', + 'competition.start_time', + 'competition.end_time', + 'competition.participant_count', + 'competition.visibility', + ]) + .where('competition.visibility = :visibility', { + visibility: CompetitionVisibility.Public, + }) + .andWhere( + '(competition.title ILIKE :pattern OR competition.description ILIKE :pattern)', + { pattern }, + ) + .orderBy('competition.created_at', 'DESC') + .skip(skip) + .take(limit) + .getMany(); + } +} diff --git a/backend/src/seasons/seasons.controller.ts b/backend/src/seasons/seasons.controller.ts index 40b0d203..99f5e4af 100644 --- a/backend/src/seasons/seasons.controller.ts +++ b/backend/src/seasons/seasons.controller.ts @@ -49,6 +49,22 @@ export class SeasonsController { return this.seasonsService.findActive(); } + @Public() + @Get('current') + @ApiOperation({ + summary: 'Get the current active season (public)', + description: + 'Alias for /active. Returns the season that is currently active based on `is_active` flag and time window. Responds with 404 when none qualifies.', + }) + @ApiResponse({ status: 200, description: 'Current season', type: Season }) + @ApiResponse({ + status: 404, + description: 'No season is active for the current time', + }) + async getCurrent(): Promise { + return this.seasonsService.findActive(); + } + @Public() @Get() @UsePipes(