Skip to content
Draft
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
Binary file added .DS_Store
Binary file not shown.
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
url = https://github.com/Statsify/public-assets
[submodule "assets/private"]
path = assets/private
url = https://github.com/Statsify/assets
url = https://github.com/Statsify/assets
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
10 changes: 9 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 { LeaderboardScanner, Player } from "@statsify/schemas";
import { PlayerDto } from "./player.dto.js";
import { Transform } from "class-transformer";
Expand All @@ -32,3 +32,11 @@ export class PlayerLeaderboardDto extends PartialType(PlayerDto) {
@ApiProperty({ minimum: 1, type: () => Number, required: false })
public position?: number;
}

export class GuildScopedPlayerLeaderboardDto extends PlayerLeaderboardDto {
@IsString()
@MinLength(3)
@MaxLength(32)
@ApiProperty()
public guild: string;
}
7 changes: 6 additions & 1 deletion apps/api/src/dtos/player-rankings.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
import { IsEnum, IsOptional, IsString } from "class-validator";
import { LeaderboardScanner, Player } from "@statsify/schemas";
import { UuidDto } from "./uuid.dto.js";

Expand All @@ -17,4 +17,9 @@ export class PlayerRankingsDto extends UuidDto {
@ApiProperty({ enum: fields, type: [String] })
@IsEnum(fields, { each: true })
public fields: string[];

@ApiProperty({ required: false })
@IsOptional()
@IsString()
public guild?: string;
}
7 changes: 4 additions & 3 deletions apps/api/src/dtos/player-search.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
*/

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

export class PlayerSearchDto {
@IsOptional()
@IsString()
@MaxLength(16)
@ApiProperty()
public query: string;
@ApiProperty({ required: false })
public query?: string;
}
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 @@ -149,12 +151,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,
};
}
}
87 changes: 87 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,87 @@
/**
* 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 this.getCachedGuilds();

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) 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 filter = query ?
{ nameToLower: { $regex: `^${this.escapeRegex(query)}` } } :
{};

const guilds = await this.guildModel
.find(filter)
.select({ name: true, _id: false })
.sort({ exp: -1 })
.limit(25)
.lean()
.exec();

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

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

private handleRedisError(error: unknown) {
this.logger.error(error);
this.logger.error(REDI_SEARCH_NOT_INSTALLED);
}
}
46 changes: 44 additions & 2 deletions apps/api/src/player/leaderboards/player-leaderboard.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ import { Body, Controller, Post } from "@nestjs/common";
import {
ErrorResponse,
LeaderboardQuery,
PostGuildScopedPlayerLeaderboardResponse,
PostLeaderboardRankingsResponse,
PostLeaderboardResponse,
} from "@statsify/api-client";
import {
GuildScopedPlayerLeaderboardDto,
PlayerLeaderboardDto,
PlayerRankingsDto,
} from "#dtos";
import { Player } from "@statsify/schemas";
import { PlayerLeaderboardDto, PlayerRankingsDto } from "#dtos";
import { PlayerLeaderboardService } from "./player-leaderboard.service.js";

@Controller("/player/leaderboards")
Expand Down Expand Up @@ -56,12 +61,49 @@ export class PlayerLeaderboardsController {
return this.playerLeaderboardService.getLeaderboard(Player, field, input, type);
}

@Post("/guild")
@ApiOperation({ summary: "Get a Guild Scoped Player Leaderboard" })
@ApiOkResponse({ type: PostGuildScopedPlayerLeaderboardResponse })
@ApiBadRequestResponse({ type: ErrorResponse })
@Auth({ weight: 3 })
public getGuildScopedPlayerLeaderboard(
@Body() { field, page, player, position, guild }: GuildScopedPlayerLeaderboardDto
) {
let input: number | string;
let type: LeaderboardQuery;

if (player) {
input = player;
type = LeaderboardQuery.INPUT;
} else if (position) {
input = position;
type = LeaderboardQuery.POSITION;
} else {
input = page;
type = LeaderboardQuery.PAGE;
}

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

@Post("/rankings")
@ApiOperation({ summary: "Get a Player Rankings" })
@ApiOkResponse({ type: [PostLeaderboardRankingsResponse] })
@ApiBadRequestResponse({ type: ErrorResponse })
@Auth({ weight: 5 })
public async getPlayerRankings(@Body() { fields, uuid }: PlayerRankingsDto) {
public async getPlayerRankings(@Body() { fields, guild, uuid }: PlayerRankingsDto) {
if (guild)
return this.playerLeaderboardService.getGuildScopedLeaderboardRankings(
guild,
fields,
uuid
);

return this.playerLeaderboardService.getLeaderboardRankings(Player, fields, uuid);
}
}
Loading