Skip to content
Merged
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
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -79,6 +80,7 @@ import { UsersModule } from './users/users.module';
SorobanModule,
AdminModule,
AchievementsModule,
SearchModule,
CommonModule,
AnalyticsModule,
],
Expand Down
94 changes: 94 additions & 0 deletions backend/src/search/dto/global-search.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
40 changes: 40 additions & 0 deletions backend/src/search/search.controller.ts
Original file line number Diff line number Diff line change
@@ -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<GlobalSearchResponseDto> {
return this.searchService.search(query);
}
}
14 changes: 14 additions & 0 deletions backend/src/search/search.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
133 changes: 133 additions & 0 deletions backend/src/search/search.service.ts
Original file line number Diff line number Diff line change
@@ -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<Market>,
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
@InjectRepository(Competition)
private readonly competitionsRepository: Repository<Competition>,
) {}

async search(dto: GlobalSearchDto): Promise<GlobalSearchResponseDto> {
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<Market[]> {
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<User[]> {
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<Competition[]> {
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();
}
}
16 changes: 16 additions & 0 deletions backend/src/seasons/seasons.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Season> {
return this.seasonsService.findActive();
}

@Public()
@Get()
@UsePipes(
Expand Down
Loading