Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e7d8e73
feat(rankings): add guild leaderboard filtering
ugcodrr Jun 3, 2026
e989c96
chore(leaderboards): sync guild filtering with main and tracked assets
ugcodrr Nov 3, 2026
553b815
fix(leaderboards): use current scanner exports and lint rules
ugcodrr Nov 3, 2026
7ee088d
fix(discord-bot): use the command alias for guild rankings arguments
ugcodrr Nov 3, 2026
c233b04
fix(locales): keep new guild filter key in English only
ugcodrr Nov 3, 2026
25973a0
chore: drop the stray .DS_Store and .gitmodules newline change
ugcodrr Nov 5, 2026
9bada10
style(api): keep crlf line endings in the player leaderboard service
ugcodrr Nov 5, 2026
db54019
refactor(api): leave leaderboard row batching to #1002
ugcodrr Nov 5, 2026
e5bb407
refactor(discord-bot): leave the leaderboard page fallback fix to #926
ugcodrr Nov 5, 2026
5ab6cd6
fix(discord-bot): keep main's page fallback in the search modal handler
ugcodrr Nov 5, 2026
b3f2155
refactor(discord-bot): drop the leaderboard background and logo prefetch
ugcodrr Nov 5, 2026
4e4fa5a
refactor(discord-bot): check the guild filter tier with User.isDiamon…
ugcodrr Nov 5, 2026
0e0e8f0
refactor(discord): drop the duplicate empty-query branches in autocom…
ugcodrr Nov 5, 2026
a854be4
refactor(api-client): return PostLeaderboardResponse from the guild s…
ugcodrr Nov 5, 2026
5a626fe
style(locales): sort the guild filter argument key
ugcodrr Nov 5, 2026
e31fc5b
refactor(api): share guild member score parsing and drop an unreachab…
ugcodrr Nov 5, 2026
6dee201
refactor(rankings): drop the guild filter from /rankings
ugcodrr Nov 5, 2026
0045e89
refactor(api-client): drop the autocomplete response cache
ugcodrr Nov 5, 2026
a13b354
refactor(api): leave player search changes to #1010
ugcodrr Nov 5, 2026
6d89155
refactor(api): take the guild filter on the existing player leaderboa…
ugcodrr Nov 5, 2026
e72f1d6
fix(discord): reply with an error for an unknown guild on the guild l…
ugcodrr Nov 5, 2026
f759da2
refactor(api): share leaderboard formatting with the guild scoped lea…
ugcodrr Nov 5, 2026
ff5c916
refactor(api-client): group guild autocomplete with the other guild r…
ugcodrr Nov 5, 2026
b603cae
fix(api): return no guild suggestions for an empty query
ugcodrr Nov 5, 2026
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
18 changes: 18 additions & 0 deletions apps/api/src/dtos/guild-search.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Copyright (c) Statsify
*
* This source code is licensed under the GNU GPL v3 license found in the
* LICENSE file in the root directory of this source tree.
* https://github.com/Statsify/statsify/blob/main/LICENSE
*/

import { ApiProperty } from "@nestjs/swagger";
import { IsOptional, IsString, MaxLength } from "class-validator";

export class GuildSearchDto {
@IsOptional()
@IsString()
@MaxLength(32)
@ApiProperty({ required: false })
public query?: string;
}
1 change: 1 addition & 0 deletions apps/api/src/dtos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from "./cache.dto.js";
export * from "./cached-player.dto.js";
export * from "./guild-leaderboard.dto.js";
export * from "./guild-rankings.dto.js";
export * from "./guild-search.dto.js";
export * from "./guild.dto.js";
export * from "./head.dto.js";
export * from "./user.dto.js";
Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/dtos/player-leaderboard.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import { ApiProperty, PartialType } from "@nestjs/swagger";
import { IsEnum, IsInt, IsOptional, Min } from "class-validator";
import { IsEnum, IsInt, IsOptional, IsString, MaxLength, Min, MinLength } from "class-validator";
import { Player, getLeaderboardFields } from "@statsify/schemas";
import { PlayerDto } from "./player.dto.js";
import { Transform } from "class-transformer";
Expand All @@ -19,6 +19,13 @@ export class PlayerLeaderboardDto extends PartialType(PlayerDto) {
@ApiProperty({ enum: fields })
public field: string;

@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(32)
@ApiProperty({ required: false })
public guild?: string;

@Transform((params) => +params.value)
@IsInt()
@Min(0)
Expand Down
6 changes: 4 additions & 2 deletions apps/api/src/guild/guild.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { Guild, Player } from "@statsify/schemas";
import { GuildController } from "./guild.controller.js";
import { GuildLeaderboardController } from "./leaderboards/guild-leaderboard.controller.js";
import { GuildLeaderboardService } from "./leaderboards/guild-leaderboard.service.js";
import { GuildSearchController } from "./search/guild-search.controller.js";
import { GuildSearchService } from "./search/guild-search.service.js";
import { GuildService } from "./guild.service.js";
import { HypixelModule } from "#hypixel";
import { Module } from "@nestjs/common";
Expand All @@ -18,7 +20,7 @@ import { TypegooseModule } from "@m8a/nestjs-typegoose";

@Module({
imports: [HypixelModule, PlayerModule, TypegooseModule.forFeature([Guild, Player])],
controllers: [GuildController, GuildLeaderboardController],
providers: [GuildService, GuildLeaderboardService],
controllers: [GuildController, GuildLeaderboardController, GuildSearchController],
providers: [GuildService, GuildLeaderboardService, GuildSearchService],
})
export class GuildModule {}
16 changes: 10 additions & 6 deletions apps/api/src/guild/guild.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "@statsify/api-client";
import { Guild, GuildMember, Player, deserialize, serialize } from "@statsify/schemas";
import { GuildLeaderboardService } from "./leaderboards/guild-leaderboard.service.js";
import { GuildSearchService } from "./search/guild-search.service.js";
import { HypixelService } from "#hypixel";
import { InjectModel } from "@m8a/nestjs-typegoose";
import { Injectable } from "@nestjs/common";
Expand All @@ -30,6 +31,7 @@ export class GuildService {
private readonly hypixelService: HypixelService,
private readonly playerService: PlayerService,
private readonly guildLeaderboardService: GuildLeaderboardService,
private readonly guildSearchService: GuildSearchService,
@InjectModel(Guild) private readonly guildModel: ReturnModelType<typeof Guild>,
@InjectModel(Player) private readonly playerModel: ReturnModelType<typeof Player>
) {}
Expand Down Expand Up @@ -140,12 +142,14 @@ export class GuildService {
const flatGuild = flatten(guild);
const serializedGuild = serialize(Guild, flatGuild);

await this.guildModel
.replaceOne({ id: guild.id }, serializedGuild, { upsert: true })
.lean()
.exec();

await this.guildLeaderboardService.addLeaderboards(Guild, serializedGuild, "id");
await Promise.all([
this.guildModel
.replaceOne({ id: guild.id }, serializedGuild, { upsert: true })
.lean()
.exec(),
this.guildLeaderboardService.addLeaderboards(Guild, serializedGuild, "id"),
this.guildSearchService.add(guild.name),
]);

return deserialize(Guild, flatGuild);
}
Expand Down
33 changes: 33 additions & 0 deletions apps/api/src/guild/search/guild-search.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Copyright (c) Statsify
*
* This source code is licensed under the GNU GPL v3 license found in the
* LICENSE file in the root directory of this source tree.
* https://github.com/Statsify/statsify/blob/main/LICENSE
*/

import { ApiOkResponse, ApiOperation, ApiTags } from "@nestjs/swagger";
import { Auth } from "#auth";
import { Controller, Get, Query } from "@nestjs/common";
import { GetGuildSearchResponse } from "@statsify/api-client";
import { GuildSearchDto } from "#dtos";
import { GuildSearchService } from "./guild-search.service.js";

@Controller("/guild/search")
@ApiTags("Guild")
export class GuildSearchController {
public constructor(private readonly guildSearchService: GuildSearchService) {}

@ApiOperation({ summary: "Get a Fuzzy Searched List of Guilds" })
@ApiOkResponse({ type: GetGuildSearchResponse })
@Auth({ weight: 0 })
@Get()
public async getGuilds(@Query() { query = "" }: GuildSearchDto) {
const guilds = await this.guildSearchService.get(query);

return {
success: true,
guilds,
};
}
}
83 changes: 83 additions & 0 deletions apps/api/src/guild/search/guild-search.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Copyright (c) Statsify
*
* This source code is licensed under the GNU GPL v3 license found in the
* LICENSE file in the root directory of this source tree.
* https://github.com/Statsify/statsify/blob/main/LICENSE
*/

import { Guild } from "@statsify/schemas";
import { InjectModel } from "@m8a/nestjs-typegoose";
import { InjectRedis } from "#redis";
import { Injectable } from "@nestjs/common";
import { Logger } from "@statsify/logger";
import { Redis } from "ioredis";
import type { ReturnModelType } from "@typegoose/typegoose";

const REDI_SEARCH_NOT_INSTALLED =
"This error was most likely caused because RediSearch is not installed.";
const GUILD_AUTOCOMPLETE_KEY = "guild:autocomplete";

@Injectable()
export class GuildSearchService {
private logger = new Logger("GuildSearchService");

public constructor(
@InjectRedis() private readonly redis: Redis,
@InjectModel(Guild) private readonly guildModel: ReturnModelType<typeof Guild>
) {}

public async get(query: string): Promise<string[]> {
query = query.trim().toLowerCase();

if (!query) return [];

const searchOptions = query.length >= 3 ? ["FUZZY", "MAX", "25"] : ["MAX", "25"];

const redisResults = await (
this.redis.call(
"FT.SUGGET",
GUILD_AUTOCOMPLETE_KEY,
query,
...searchOptions
) as Promise<string[]>
).catch((e) => {
this.handleRedisError(e);

return [];
});

if (redisResults.length > 0) return redisResults;

return this.getCachedGuilds(query);
}

public async add(name: string) {
if (name.length < 3 || name.length > 32) return;

await this.redis
.call("FT.SUGADD", GUILD_AUTOCOMPLETE_KEY, name, "1", "INCR")
.catch((e) => this.handleRedisError(e));
}

private async getCachedGuilds(query: string) {
const guilds = await this.guildModel
.find({ nameToLower: { $regex: `^${this.escapeRegex(query)}` } })
.select({ name: true, _id: false })
.sort({ exp: -1 })
.limit(25)
.lean()
.exec();

return guilds.map(({ name }) => name);
}

private escapeRegex(input: string) {
return input.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
}

private handleRedisError(error: unknown) {
this.logger.error(error);
this.logger.error(REDI_SEARCH_NOT_INSTALLED);
}
}
40 changes: 27 additions & 13 deletions apps/api/src/leaderboards/leaderboard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,18 +80,7 @@ export abstract class LeaderboardService {
) {
const PAGE_SIZE = 10;

const {
fieldName,
additionalFields = [],
extraDisplay,
formatter,
sort,
name,
hidden,
} = getLeaderboardField(
constructor,
field
) as LeaderboardEnabledMetadata;
const { sort } = getLeaderboardField(constructor, field);

let top: number;
let bottom: number;
Expand Down Expand Up @@ -127,6 +116,31 @@ export abstract class LeaderboardService {
sort
);

return this.formatLeaderboard(
constructor,
field,
leaderboard,
top / PAGE_SIZE,
highlight
);
}

protected async formatLeaderboard<T>(
constructor: Constructor<T>,
field: string,
leaderboard: { id: string; score: number; index: number }[],
page: number,
highlight?: number
) {
const {
fieldName,
additionalFields = [],
extraDisplay,
formatter,
name,
hidden,
} = getLeaderboardField(constructor, field);

const additionalFieldMetadata = additionalFields.map((k) =>
getLeaderboardField(constructor, k, false)
);
Expand Down Expand Up @@ -185,7 +199,7 @@ export abstract class LeaderboardService {
name,
fields,
data,
page: top / PAGE_SIZE,
page,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class PlayerLeaderboardsController {
@ApiBadRequestResponse({ type: ErrorResponse })
@Auth({ weight: 3 })
public getPlayerLeaderboard(
@Body() { field, page, player, position }: PlayerLeaderboardDto
@Body() { field, guild, page, player, position }: PlayerLeaderboardDto
) {
let input: number | string;
let type: LeaderboardQuery;
Expand All @@ -53,6 +53,14 @@ export class PlayerLeaderboardsController {
type = LeaderboardQuery.PAGE;
}

if (guild)
return this.playerLeaderboardService.getGuildScopedLeaderboard(
guild,
field,
input,
type
);

return this.playerLeaderboardService.getLeaderboard(Player, field, input, type);
}

Expand Down
Loading
Loading