From b81efb9d32db0990c7747822839ba8808d646aa1 Mon Sep 17 00:00:00 2001 From: JMAD Date: Thu, 30 Oct 2025 21:09:02 +0100 Subject: [PATCH 1/4] fix: type errors --- .../src/seeds/factories/game.factory.ts | 21 ++-- .../src/utils/game-mode/game-mode-strategy.ts | 54 +-------- .../waiting-room/waiting-room-setup.ts | 2 +- .../ui/countdown-timer/countdown-timer.svelte | 2 +- .../components/waiting-room-chat.svelte | 105 ------------------ .../(authenticated)/multiplayer/+page.svelte | 16 ++- .../core/comment/schema/comment-dto.schema.ts | 2 +- .../schema/waiting-room-response.schema.ts | 2 +- .../create-programming-language.schema.ts | 3 - 9 files changed, 28 insertions(+), 179 deletions(-) delete mode 100644 libs/frontend/src/lib/features/multiplayer/components/waiting-room-chat.svelte diff --git a/libs/backend/src/seeds/factories/game.factory.ts b/libs/backend/src/seeds/factories/game.factory.ts index 68ab0553..ff7b5139 100644 --- a/libs/backend/src/seeds/factories/game.factory.ts +++ b/libs/backend/src/seeds/factories/game.factory.ts @@ -1,6 +1,6 @@ import { faker } from "@faker-js/faker"; import Game, { GameDocument } from "../../models/game/game.js"; -import { gameModeEnum, gameVisibilityEnum } from "types"; +import { gameModeEnum, gameVisibilityEnum, GameMode, GameVisibility } from "types"; import { randomFromArray, randomMultipleFromArray @@ -8,16 +8,14 @@ import { import { Types } from "mongoose"; import ProgrammingLanguage from "../../models/programming-language/language.js"; -type GameModeValue = (typeof gameModeEnum)[keyof typeof gameModeEnum]; -type GameVisibilityValue = - (typeof gameVisibilityEnum)[keyof typeof gameVisibilityEnum]; export interface GameFactoryOptions { ownerId: Types.ObjectId; puzzleId: Types.ObjectId; playerIds: Types.ObjectId[]; - mode?: GameModeValue; - visibility?: GameVisibilityValue; + mode?: GameMode; + visibility?: GameVisibility; + rated?: boolean } /** @@ -90,7 +88,8 @@ export async function createGame( mode, visibility, maxGameDurationInSeconds: durationInSeconds, - allowedLanguages: await generateAllowedLanguages() + allowedLanguages: await generateAllowedLanguages(), + rated: faker.datatype.boolean({ probability: 0.6 }) }, playerSubmissions: [] }; @@ -115,10 +114,7 @@ export async function createGames( const ownerId = randomFromArray(userIds); const puzzleId = randomFromArray(puzzleIds); - // Mode distribution: 60% RATED, 40% CASUAL - const mode = faker.datatype.boolean({ probability: 0.6 }) - ? gameModeEnum.RATED - : gameModeEnum.CASUAL; + const mode = randomFromArray(Object.values(gameModeEnum)) // Visibility distribution: 70% PUBLIC, 30% PRIVATE const visibility = faker.datatype.boolean({ probability: 0.7 }) @@ -142,7 +138,8 @@ export async function createGames( puzzleId, playerIds, mode, - visibility + visibility, + rated: faker.datatype.boolean() }) ); } diff --git a/libs/backend/src/utils/game-mode/game-mode-strategy.ts b/libs/backend/src/utils/game-mode/game-mode-strategy.ts index 7f4621bd..04e8782d 100644 --- a/libs/backend/src/utils/game-mode/game-mode-strategy.ts +++ b/libs/backend/src/utils/game-mode/game-mode-strategy.ts @@ -42,15 +42,15 @@ class FastestModeStrategy implements GameModeStrategy { class ShortestModeStrategy implements GameModeStrategy { calculateScore(submission: { successRate: number; - codeLength?: number; + codeLength: number; }): number { if (submission.successRate < 1 || !submission.codeLength) return 0; return 1000000 / submission.codeLength; } compareSubmissions( - a: { successRate: number; timeSpent: number; codeLength?: number }, - b: { successRate: number; timeSpent: number; codeLength?: number } + a: { successRate: number; timeSpent: number; codeLength: number }, + b: { successRate: number; timeSpent: number; codeLength: number } ): number { if (a.successRate !== b.successRate) { return b.successRate - a.successRate; @@ -68,55 +68,11 @@ class ShortestModeStrategy implements GameModeStrategy { } } -class RatedModeStrategy implements GameModeStrategy { - calculateScore(submission: { - successRate: number; - timeSpent: number; - }): number { - if (submission.successRate < 1) return 0; - return 1000000 / submission.timeSpent; - } - - compareSubmissions( - a: { successRate: number; timeSpent: number }, - b: { successRate: number; timeSpent: number } - ): number { - if (a.successRate !== b.successRate) { - return b.successRate - a.successRate; - } - return a.timeSpent - b.timeSpent; - } - - getDisplayMetrics(): string[] { - return ["score", "time"]; - } -} - -class CasualModeStrategy implements GameModeStrategy { - calculateScore(submission: { successRate: number }): number { - return submission.successRate; - } - - compareSubmissions( - a: { successRate: number; timeSpent: number }, - b: { successRate: number; timeSpent: number } - ): number { - if (a.successRate !== b.successRate) { - return b.successRate - a.successRate; - } - return a.timeSpent - b.timeSpent; - } - - getDisplayMetrics(): string[] { - return ["score"]; - } -} const strategies: Record = { [gameModeEnum.FASTEST]: new FastestModeStrategy(), - [gameModeEnum.SHORTEST]: new ShortestModeStrategy(), - [gameModeEnum.RATED]: new RatedModeStrategy(), - [gameModeEnum.CASUAL]: new CasualModeStrategy() + [gameModeEnum.SHORTEST]: new ShortestModeStrategy(), + [gameModeEnum.RANDOM]: null, }; export function getGameModeStrategy(mode: GameMode): GameModeStrategy { diff --git a/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts b/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts index 96515fe7..f92f7e4b 100644 --- a/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts +++ b/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts @@ -127,7 +127,7 @@ export function waitingRoomSetup( event: waitingRoomEventEnum.CHAT_MESSAGE, username: req.user.username, message: parsedMessage.message, - timestamp: new Date() + createdAt: new Date() }); break; } diff --git a/libs/frontend/src/lib/components/ui/countdown-timer/countdown-timer.svelte b/libs/frontend/src/lib/components/ui/countdown-timer/countdown-timer.svelte index 0a93c6c9..72cb72c9 100644 --- a/libs/frontend/src/lib/components/ui/countdown-timer/countdown-timer.svelte +++ b/libs/frontend/src/lib/components/ui/countdown-timer/countdown-timer.svelte @@ -20,7 +20,7 @@ } else { const now = $currentTime.getTime(); const end = dayjs(endDate); - const diff = Math.max(end.diff(now), 0); // Ensure no negative values + const diff = Math.max(end.diff(now), 0); const seconds = Math.floor((diff / 1000) % 60); const minutes = Math.floor((diff / (1000 * 60)) % 60); diff --git a/libs/frontend/src/lib/features/multiplayer/components/waiting-room-chat.svelte b/libs/frontend/src/lib/features/multiplayer/components/waiting-room-chat.svelte deleted file mode 100644 index 94658b84..00000000 --- a/libs/frontend/src/lib/features/multiplayer/components/waiting-room-chat.svelte +++ /dev/null @@ -1,105 +0,0 @@ - - - -

Chat

- - - {#if chatMessages.length > 0} -
    - {#each chatMessages as chatMessage} -
  1. -
    - - {chatMessage.username} - - - {formatTime(chatMessage.timestamp)} - -
    -

    {chatMessage.message}

    -
  2. - {/each} -
- {:else} -
-

- No messages yet. Start chatting! -

-
- {/if} -
- -
- - - - -
-
diff --git a/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte b/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte index fd7680c9..d06de8d9 100644 --- a/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte +++ b/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte @@ -29,10 +29,14 @@ type RoomStateResponse, type WaitingRoomRequest, type WaitingRoomResponse, - type GameOptions + type GameOptions, + + type ChatMessage + } from "types"; import { testIds } from "@/config/test-ids"; import { currentTime } from "@/stores/current-time"; + import Chat from "@/features/chat/components/chat.svelte"; let room: RoomStateResponse | undefined = $state(); let rooms: RoomOverviewResponse[] = $state([]); @@ -43,7 +47,7 @@ let customGameDialogOpen = $state(false); let joinByInviteDialogOpen = $state(false); let chatMessages = $state< - Array<{ username: string; message: string; timestamp: Date }> + Array >([]); const queryParamKeys = { @@ -94,8 +98,9 @@ chatMessages.push({ username: data.username, message: data.message, - timestamp: new Date(data.timestamp) + createdAt: new Date(data.createdAt) }); + chatMessages = chatMessages; // Trigger reactivity } break; @@ -160,7 +165,6 @@ if (room?.roomId) updateRoomIdInUrl(); }); - // Auto-redirect when countdown reaches zero $effect(() => { if (!pendingGameStart) return; @@ -359,10 +363,10 @@ {#if $authenticatedUserInfo?.username} - {/if} diff --git a/libs/types/src/core/comment/schema/comment-dto.schema.ts b/libs/types/src/core/comment/schema/comment-dto.schema.ts index 887dda4a..c8204259 100644 --- a/libs/types/src/core/comment/schema/comment-dto.schema.ts +++ b/libs/types/src/core/comment/schema/comment-dto.schema.ts @@ -4,7 +4,7 @@ import { commentEntitySchema } from "./comment-entity.schema.js"; export const commentDtoSchema = commentEntitySchema.extend({ _id: objectIdSchema, - comments: z.array(objectIdSchema), + comments: z.array(objectIdSchema).optional(), parentId: objectIdSchema, }); diff --git a/libs/types/src/core/game/schema/waiting-room-response.schema.ts b/libs/types/src/core/game/schema/waiting-room-response.schema.ts index cfbaf40f..40fc15a0 100644 --- a/libs/types/src/core/game/schema/waiting-room-response.schema.ts +++ b/libs/types/src/core/game/schema/waiting-room-response.schema.ts @@ -52,7 +52,7 @@ const chatMessageResponseSchema = z.object({ event: z.literal(waitingRoomEventEnum.CHAT_MESSAGE), username: z.string(), message: z.string(), - timestamp: acceptedDateSchema, + createdAt: acceptedDateSchema, }); export const waitingRoomResponseSchema = z.discriminatedUnion("event", [ diff --git a/libs/types/src/core/programming-language/schema/create-programming-language.schema.ts b/libs/types/src/core/programming-language/schema/create-programming-language.schema.ts index 236c8bea..bf723e49 100644 --- a/libs/types/src/core/programming-language/schema/create-programming-language.schema.ts +++ b/libs/types/src/core/programming-language/schema/create-programming-language.schema.ts @@ -1,8 +1,5 @@ import { z } from "zod"; -/** - * Schema for creating a new programming language - */ export const createProgrammingLanguageSchema = z.object({ language: z.string().min(1, "Language name is required"), version: z.string().min(1, "Language version is required"), From 60aac3c636499bedb5f0b24b0ffbda2e16455f70 Mon Sep 17 00:00:00 2001 From: reeveng <36441093+reeveng@users.noreply.github.com> Date: Thu, 30 Oct 2025 20:09:32 +0000 Subject: [PATCH 2/4] Apply automatic changes --- libs/backend/src/seeds/factories/game.factory.ts | 12 ++++++++---- .../src/utils/game-mode/game-mode-strategy.ts | 5 ++--- .../routes/(authenticated)/multiplayer/+page.svelte | 6 +----- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/libs/backend/src/seeds/factories/game.factory.ts b/libs/backend/src/seeds/factories/game.factory.ts index ff7b5139..3fc791dc 100644 --- a/libs/backend/src/seeds/factories/game.factory.ts +++ b/libs/backend/src/seeds/factories/game.factory.ts @@ -1,6 +1,11 @@ import { faker } from "@faker-js/faker"; import Game, { GameDocument } from "../../models/game/game.js"; -import { gameModeEnum, gameVisibilityEnum, GameMode, GameVisibility } from "types"; +import { + gameModeEnum, + gameVisibilityEnum, + GameMode, + GameVisibility +} from "types"; import { randomFromArray, randomMultipleFromArray @@ -8,14 +13,13 @@ import { import { Types } from "mongoose"; import ProgrammingLanguage from "../../models/programming-language/language.js"; - export interface GameFactoryOptions { ownerId: Types.ObjectId; puzzleId: Types.ObjectId; playerIds: Types.ObjectId[]; mode?: GameMode; visibility?: GameVisibility; - rated?: boolean + rated?: boolean; } /** @@ -114,7 +118,7 @@ export async function createGames( const ownerId = randomFromArray(userIds); const puzzleId = randomFromArray(puzzleIds); - const mode = randomFromArray(Object.values(gameModeEnum)) + const mode = randomFromArray(Object.values(gameModeEnum)); // Visibility distribution: 70% PUBLIC, 30% PRIVATE const visibility = faker.datatype.boolean({ probability: 0.7 }) diff --git a/libs/backend/src/utils/game-mode/game-mode-strategy.ts b/libs/backend/src/utils/game-mode/game-mode-strategy.ts index 04e8782d..7e69c465 100644 --- a/libs/backend/src/utils/game-mode/game-mode-strategy.ts +++ b/libs/backend/src/utils/game-mode/game-mode-strategy.ts @@ -68,11 +68,10 @@ class ShortestModeStrategy implements GameModeStrategy { } } - const strategies: Record = { [gameModeEnum.FASTEST]: new FastestModeStrategy(), - [gameModeEnum.SHORTEST]: new ShortestModeStrategy(), - [gameModeEnum.RANDOM]: null, + [gameModeEnum.SHORTEST]: new ShortestModeStrategy(), + [gameModeEnum.RANDOM]: null }; export function getGameModeStrategy(mode: GameMode): GameModeStrategy { diff --git a/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte b/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte index d06de8d9..177d27be 100644 --- a/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte +++ b/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte @@ -30,9 +30,7 @@ type WaitingRoomRequest, type WaitingRoomResponse, type GameOptions, - type ChatMessage - } from "types"; import { testIds } from "@/config/test-ids"; import { currentTime } from "@/stores/current-time"; @@ -46,9 +44,7 @@ $state(); let customGameDialogOpen = $state(false); let joinByInviteDialogOpen = $state(false); - let chatMessages = $state< - Array - >([]); + let chatMessages = $state>([]); const queryParamKeys = { ROOM_ID: "roomId" From 671b60782d1f0de8360cc7f6f1ebe726b8dfe86b Mon Sep 17 00:00:00 2001 From: J Mad <36441093+reeveng@users.noreply.github.com> Date: Fri, 31 Oct 2025 01:01:24 +0100 Subject: [PATCH 3/4] feat: leaderboards --- .github/copilot-instructions.md | 275 ++++++ .github/workflows/e2e-tests.yml | 138 +++ ELIXIR_MIGRATION_ROADMAP.md | 916 ++++++++++++++++++ FEATURE_ROADMAP.md | 64 +- libs/backend/package.json | 2 + libs/backend/src/app.ts | 6 + libs/backend/src/config/cron.ts | 47 + .../src/models/submission/submission.ts | 4 - .../src/models/user-metrics/user-metrics.ts | 177 ++++ .../src/plugins/middleware/validate-body.ts | 197 ++++ libs/backend/src/router.ts | 4 + .../src/routes/comment/[id]/vote/index.ts | 23 +- libs/backend/src/routes/execute/index.ts | 21 +- .../src/routes/game/leaderboard/index.ts | 105 ++ libs/backend/src/routes/leaderboard/index.ts | 119 +++ .../src/routes/submission/game/index.ts | 83 +- libs/backend/src/routes/submission/index.ts | 133 +-- .../seeds/factories/preferences.factory.ts | 1 + .../src/seeds/factories/submission.factory.ts | 14 +- .../src/seeds/factories/user-ban.factory.ts | 4 +- .../backend/src/services/game-mode.service.ts | 216 +++++ libs/backend/src/services/game.service.ts | 25 + .../src/services/leaderboard.service.ts | 355 +++++++ .../src/tests/game-mode-strategy.test.ts | 367 +++++++ libs/backend/src/utils/constants/model.ts | 1 + .../src/utils/functions/calculate-result.ts | 8 +- .../src/utils/game-mode/game-mode-strategy.ts | 438 +++++++-- libs/backend/src/utils/rating/glicko.ts | 261 +++++ .../waiting-room/waiting-room-setup.ts | 8 +- .../websocket/waiting-room/waiting-room.ts | 18 + libs/e2e/.gitignore | 5 + libs/e2e/README.md | 173 ++++ libs/e2e/package.json | 25 + libs/e2e/playwright.config.ts | 78 ++ libs/e2e/src/fixtures/base.fixtures.ts | 75 ++ libs/e2e/src/pages/auth.page.ts | 94 ++ libs/e2e/src/pages/base.page.ts | 82 ++ libs/e2e/src/pages/game.page.ts | 146 +++ libs/e2e/src/pages/multiplayer.page.ts | 177 ++++ libs/e2e/src/utils/test-helpers.ts | 116 +++ libs/e2e/tests/auth.spec.ts | 77 ++ libs/e2e/tests/multiplayer-chat.spec.ts | 193 ++++ libs/e2e/tests/multiplayer-game-flow.spec.ts | 309 ++++++ libs/e2e/tests/multiplayer-late-join.spec.ts | 213 ++++ libs/e2e/tests/multiplayer-websocket.spec.ts | 248 +++++ libs/e2e/tests/multiplayer.spec.ts | 109 +++ libs/e2e/tsconfig.json | 23 + .../lib/components/error/display-error.svelte | 2 +- .../nav/navigation/navigation-item.svelte | 2 +- .../nav/navigation/navigation.svelte | 2 +- .../src/lib/components/nav/pagination.svelte | 2 +- .../lib/components/nav/toggle-theme.svelte | 2 +- .../lib/components/nav/user-dropdown.svelte | 2 +- .../lib/components/ui/button/button.svelte | 2 +- .../lib/components/ui/form/form-button.svelte | 2 +- libs/frontend/src/lib/config/api.ts | 31 - libs/frontend/src/lib/config/local-storage.ts | 3 +- .../login/components/login-form.svelte | 2 +- .../register/components/register-form.svelte | 2 +- .../register/config/register-form-schema.ts | 5 +- .../chat/components/chat-message.svelte | 2 +- .../lib/features/chat/components/chat.svelte | 2 +- .../chat/components/report-chat-dialog.svelte | 30 +- .../components/add-comment-form.svelte | 9 +- .../comment/components/comment.svelte | 22 +- .../components/standings-table.svelte | 18 +- .../components/become-a-contributor.svelte | 2 +- .../components/custom-game-dialog.svelte | 2 +- .../components/join-by-invite-dialog.svelte | 2 +- .../components/create-puzzle-form.svelte | 2 +- .../delete-puzzle-confirmation-dialog.svelte | 2 +- .../components/edit-puzzle-form.svelte | 2 +- .../puzzles/components/play-puzzle.svelte | 11 +- .../puzzles/components/user-hover-card.svelte | 7 +- libs/frontend/src/lib/stores/languages.ts | 146 ++- libs/frontend/src/lib/stores/preferences.ts | 140 ++- .../src/routes/(authenticated)/+error.svelte | 132 +++ .../(authenticated)/multiplayer/+page.svelte | 26 +- .../multiplayer/[id]/+page.svelte | 16 +- .../puzzles/[id]/edit/+page.server.ts | 38 +- libs/frontend/src/routes/+error.svelte | 111 ++- .../routes/api/account/preferences/+server.ts | 54 -- .../src/routes/api/comment/[id]/+server.ts | 27 - .../api/comment/[id]/comment/+server.ts | 20 - .../routes/api/comment/[id]/vote/+server.ts | 20 - .../src/routes/api/execute-code/+server.ts | 17 - .../api/get-user-activity-by-username.ts | 9 - .../routes/api/handle-delete-puzzle-form.ts | 57 -- libs/frontend/src/routes/api/login.ts | 10 - .../moderation/puzzle/[id]/approve/+server.ts | 20 - .../moderation/puzzle/[id]/revise/+server.ts | 21 - .../moderation/report/[id]/resolve/+server.ts | 21 - .../user/[id]/ban/[type]/+server.ts | 55 -- .../user/[id]/ban/history/+server.ts | 41 - .../api/moderation/user/[id]/unban/+server.ts | 47 - .../api/programming-languages/+server.ts | 19 - .../api/puzzles/[id]/comment/+server.ts | 20 - .../src/routes/api/submission/[id]/+server.ts | 17 - .../src/routes/api/submit-code/+server.ts | 20 - .../src/routes/api/submit-game/+server.ts | 20 - .../routes/api/supported-languages/+server.ts | 23 - .../src/routes/api/user/[username]/+server.ts | 17 - .../api/user/[username]/puzzle/+server.ts | 19 - .../[username]/+server.ts | 11 - .../src/routes/leaderboard/+page.svelte | 302 ++++++ .../src/routes/maintenance/+page.svelte | 2 +- .../src/routes/moderation/+page.svelte | 60 +- .../routes/profile/[username]/+page.server.ts | 14 +- .../routes/profile/[username]/+page.svelte | 2 +- .../[username]/puzzles/+page.server.ts | 5 +- .../profile/[username]/puzzles/+page.svelte | 2 +- libs/frontend/src/routes/puzzles/+page.svelte | 2 +- .../src/routes/puzzles/[id]/+page.svelte | 2 +- .../src/core/api/schema/account.schema.ts | 48 + .../src/core/api/schema/auth/login.schema.ts | 15 + .../src/core/api/schema/auth/logout.schema.ts | 9 + .../core/api/schema/auth/register.schema.ts | 33 + .../src/core/api/schema/comment.schema.ts | 40 + .../core/api/schema/execute-code.schema.ts | 11 + .../api/schema/execute/execute-api.schema.ts | 21 + .../core/api/schema/game/game-api.schema.ts | 118 +++ .../api/schema/game/leaderboard-api.schema.ts | 68 ++ .../leaderboard/leaderboard-api.schema.ts | 60 ++ .../src/core/api/schema/moderation.schema.ts | 101 ++ .../api/schema/programming-language.schema.ts | 26 + .../programming-language-api.schema.ts | 32 + .../src/core/api/schema/puzzle.schema.ts | 63 ++ .../api/schema/puzzle/puzzle-api.schema.ts | 55 ++ .../src/core/api/schema/submission.schema.ts | 20 + .../submission/submission-api.schema.ts | 108 +++ .../src/core/api/schema/submit-code.schema.ts | 15 + libs/types/src/core/api/schema/user.schema.ts | 40 + .../core/api/schema/user/user-api.schema.ts | 13 + .../src/core/common/config/backend-urls.ts | 7 + .../src/core/common}/config/test-ids.ts | 7 +- .../common/schema/error-response.schema.ts | 1 + .../src/core/game/enum/game-mode-enum.ts | 21 +- .../core/game/enum/game-tournament-enum.ts | 5 + .../core/game/schema/game-entity.schema.ts | 4 +- .../game/schema/game-submission.schema.ts | 17 + .../schema/leaderboard-entry.schema.ts | 21 + .../leaderboard/schema/user-metrics.schema.ts | 64 ++ .../schema/submission-entity.schema.ts | 4 +- libs/types/src/index.ts | 29 + pnpm-lock.yaml | 80 ++ 145 files changed, 8064 insertions(+), 1022 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/workflows/e2e-tests.yml create mode 100644 ELIXIR_MIGRATION_ROADMAP.md create mode 100644 libs/backend/src/config/cron.ts create mode 100644 libs/backend/src/models/user-metrics/user-metrics.ts create mode 100644 libs/backend/src/plugins/middleware/validate-body.ts create mode 100644 libs/backend/src/routes/game/leaderboard/index.ts create mode 100644 libs/backend/src/routes/leaderboard/index.ts create mode 100644 libs/backend/src/services/game-mode.service.ts create mode 100644 libs/backend/src/services/leaderboard.service.ts create mode 100644 libs/backend/src/tests/game-mode-strategy.test.ts create mode 100644 libs/backend/src/utils/rating/glicko.ts create mode 100644 libs/e2e/.gitignore create mode 100644 libs/e2e/README.md create mode 100644 libs/e2e/package.json create mode 100644 libs/e2e/playwright.config.ts create mode 100644 libs/e2e/src/fixtures/base.fixtures.ts create mode 100644 libs/e2e/src/pages/auth.page.ts create mode 100644 libs/e2e/src/pages/base.page.ts create mode 100644 libs/e2e/src/pages/game.page.ts create mode 100644 libs/e2e/src/pages/multiplayer.page.ts create mode 100644 libs/e2e/src/utils/test-helpers.ts create mode 100644 libs/e2e/tests/auth.spec.ts create mode 100644 libs/e2e/tests/multiplayer-chat.spec.ts create mode 100644 libs/e2e/tests/multiplayer-game-flow.spec.ts create mode 100644 libs/e2e/tests/multiplayer-late-join.spec.ts create mode 100644 libs/e2e/tests/multiplayer-websocket.spec.ts create mode 100644 libs/e2e/tests/multiplayer.spec.ts create mode 100644 libs/e2e/tsconfig.json delete mode 100644 libs/frontend/src/lib/config/api.ts create mode 100644 libs/frontend/src/routes/(authenticated)/+error.svelte delete mode 100644 libs/frontend/src/routes/api/account/preferences/+server.ts delete mode 100644 libs/frontend/src/routes/api/comment/[id]/+server.ts delete mode 100644 libs/frontend/src/routes/api/comment/[id]/comment/+server.ts delete mode 100644 libs/frontend/src/routes/api/comment/[id]/vote/+server.ts delete mode 100644 libs/frontend/src/routes/api/execute-code/+server.ts delete mode 100644 libs/frontend/src/routes/api/get-user-activity-by-username.ts delete mode 100644 libs/frontend/src/routes/api/handle-delete-puzzle-form.ts delete mode 100644 libs/frontend/src/routes/api/login.ts delete mode 100644 libs/frontend/src/routes/api/moderation/puzzle/[id]/approve/+server.ts delete mode 100644 libs/frontend/src/routes/api/moderation/puzzle/[id]/revise/+server.ts delete mode 100644 libs/frontend/src/routes/api/moderation/report/[id]/resolve/+server.ts delete mode 100644 libs/frontend/src/routes/api/moderation/user/[id]/ban/[type]/+server.ts delete mode 100644 libs/frontend/src/routes/api/moderation/user/[id]/ban/history/+server.ts delete mode 100644 libs/frontend/src/routes/api/moderation/user/[id]/unban/+server.ts delete mode 100644 libs/frontend/src/routes/api/programming-languages/+server.ts delete mode 100644 libs/frontend/src/routes/api/puzzles/[id]/comment/+server.ts delete mode 100644 libs/frontend/src/routes/api/submission/[id]/+server.ts delete mode 100644 libs/frontend/src/routes/api/submit-code/+server.ts delete mode 100644 libs/frontend/src/routes/api/submit-game/+server.ts delete mode 100644 libs/frontend/src/routes/api/supported-languages/+server.ts delete mode 100644 libs/frontend/src/routes/api/user/[username]/+server.ts delete mode 100644 libs/frontend/src/routes/api/user/[username]/puzzle/+server.ts delete mode 100644 libs/frontend/src/routes/api/username-is-available/[username]/+server.ts create mode 100644 libs/frontend/src/routes/leaderboard/+page.svelte create mode 100644 libs/types/src/core/api/schema/account.schema.ts create mode 100644 libs/types/src/core/api/schema/auth/login.schema.ts create mode 100644 libs/types/src/core/api/schema/auth/logout.schema.ts create mode 100644 libs/types/src/core/api/schema/auth/register.schema.ts create mode 100644 libs/types/src/core/api/schema/comment.schema.ts create mode 100644 libs/types/src/core/api/schema/execute-code.schema.ts create mode 100644 libs/types/src/core/api/schema/execute/execute-api.schema.ts create mode 100644 libs/types/src/core/api/schema/game/game-api.schema.ts create mode 100644 libs/types/src/core/api/schema/game/leaderboard-api.schema.ts create mode 100644 libs/types/src/core/api/schema/leaderboard/leaderboard-api.schema.ts create mode 100644 libs/types/src/core/api/schema/moderation.schema.ts create mode 100644 libs/types/src/core/api/schema/programming-language.schema.ts create mode 100644 libs/types/src/core/api/schema/programming-language/programming-language-api.schema.ts create mode 100644 libs/types/src/core/api/schema/puzzle.schema.ts create mode 100644 libs/types/src/core/api/schema/puzzle/puzzle-api.schema.ts create mode 100644 libs/types/src/core/api/schema/submission.schema.ts create mode 100644 libs/types/src/core/api/schema/submission/submission-api.schema.ts create mode 100644 libs/types/src/core/api/schema/submit-code.schema.ts create mode 100644 libs/types/src/core/api/schema/user.schema.ts create mode 100644 libs/types/src/core/api/schema/user/user-api.schema.ts rename libs/{frontend/src/lib => types/src/core/common}/config/test-ids.ts (96%) create mode 100644 libs/types/src/core/game/enum/game-tournament-enum.ts create mode 100644 libs/types/src/core/game/schema/game-submission.schema.ts create mode 100644 libs/types/src/core/leaderboard/schema/leaderboard-entry.schema.ts create mode 100644 libs/types/src/core/leaderboard/schema/user-metrics.schema.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..8828813d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,275 @@ +Project Context + + Svelte 5.x with runes system ($state, $derived, $effect, $props, $bindable) + SvelteKit for full-stack applications with file-based routing + TypeScript for type safety and better developer experience + Component-scoped styling with CSS custom properties + Progressive enhancement and performance-first approach + Modern build tooling (Vite) with optimizations + +Development Standards +Architecture + + Use Svelte 5 runes system for all reactivity instead of legacy stores + Organize components by feature or domain for scalability + Separate presentation components from logic-heavy components + Extract reusable logic into composable functions + Implement proper component composition with slots and snippets + Use SvelteKit's file-based routing with proper load functions + +TypeScript Integration + + Enable strict mode in tsconfig.json for maximum type safety + Define interfaces for component props using $props() syntax + Type event handlers, refs, and SvelteKit's generated types + Use generic types for reusable components + Leverage $types.ts files generated by SvelteKit + Implement proper type checking with svelte-check + +Component Design + + Follow single responsibility principle for components + Use diff --git a/libs/frontend/src/lib/components/nav/pagination.svelte b/libs/frontend/src/lib/components/nav/pagination.svelte index 194a61d6..0638b7c7 100644 --- a/libs/frontend/src/lib/components/nav/pagination.svelte +++ b/libs/frontend/src/lib/components/nav/pagination.svelte @@ -2,7 +2,7 @@ import { page } from "$app/stores"; import { DEFAULT_PAGE } from "types"; import { Button } from "../ui/button"; - import { testIds } from "@/config/test-ids"; + import { testIds } from "types"; let { currentPage, diff --git a/libs/frontend/src/lib/components/nav/toggle-theme.svelte b/libs/frontend/src/lib/components/nav/toggle-theme.svelte index 50e8014a..e48f07dd 100644 --- a/libs/frontend/src/lib/components/nav/toggle-theme.svelte +++ b/libs/frontend/src/lib/components/nav/toggle-theme.svelte @@ -5,7 +5,7 @@ import Toggle from "../ui/toggle/toggle.svelte"; import { cn } from "@/utils/cn"; import { Toggle as TogglePrimitive } from "bits-ui"; - import type { DataTestIdProp } from "@/config/test-ids"; + import type { DataTestIdProp } from "types"; type $$Props = TogglePrimitive.RootProps & { class?: string }; diff --git a/libs/frontend/src/lib/components/nav/user-dropdown.svelte b/libs/frontend/src/lib/components/nav/user-dropdown.svelte index ee48e912..c9303539 100644 --- a/libs/frontend/src/lib/components/nav/user-dropdown.svelte +++ b/libs/frontend/src/lib/components/nav/user-dropdown.svelte @@ -2,7 +2,7 @@ import * as DropdownMenu from "#/ui/dropdown-menu"; import * as Avatar from "#/ui/avatar"; import { frontendUrls } from "types"; - import { testIds } from "@/config/test-ids"; + import { testIds } from "types"; import { authenticatedUserInfo } from "@/stores"; import { Button } from "#/ui/button"; diff --git a/libs/frontend/src/lib/components/ui/button/button.svelte b/libs/frontend/src/lib/components/ui/button/button.svelte index 9c750ee6..15169478 100644 --- a/libs/frontend/src/lib/components/ui/button/button.svelte +++ b/libs/frontend/src/lib/components/ui/button/button.svelte @@ -45,7 +45,7 @@ diff --git a/libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte b/libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte index d67a6da1..1dbe8e0b 100644 --- a/libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte +++ b/libs/frontend/src/lib/features/multiplayer/components/custom-game-dialog.svelte @@ -5,7 +5,7 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; - import { testIds } from "@/config/test-ids"; + import { testIds } from "types"; import { DEFAULT_GAME_LENGTH_IN_SECONDS, type GameOptions, diff --git a/libs/frontend/src/lib/features/multiplayer/components/join-by-invite-dialog.svelte b/libs/frontend/src/lib/features/multiplayer/components/join-by-invite-dialog.svelte index df95505a..c0fb58d2 100644 --- a/libs/frontend/src/lib/features/multiplayer/components/join-by-invite-dialog.svelte +++ b/libs/frontend/src/lib/features/multiplayer/components/join-by-invite-dialog.svelte @@ -3,7 +3,7 @@ import * as Dialog from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; - import { testIds } from "@/config/test-ids"; + import { testIds } from "types"; let { open = $bindable(false), diff --git a/libs/frontend/src/lib/features/puzzles/components/create-puzzle-form.svelte b/libs/frontend/src/lib/features/puzzles/components/create-puzzle-form.svelte index 4bba7d25..9029a4a1 100644 --- a/libs/frontend/src/lib/features/puzzles/components/create-puzzle-form.svelte +++ b/libs/frontend/src/lib/features/puzzles/components/create-puzzle-form.svelte @@ -7,7 +7,7 @@ import GenericAlert from "@/components/ui/alert/generic-alert.svelte"; import { isHttpErrorCode } from "@/utils/is-http-error-code"; import { page } from "$app/stores"; - import { testIds } from "@/config/test-ids"; + import { testIds } from "types"; let { data diff --git a/libs/frontend/src/lib/features/puzzles/components/delete-puzzle-confirmation-dialog.svelte b/libs/frontend/src/lib/features/puzzles/components/delete-puzzle-confirmation-dialog.svelte index b1e57db6..6629b7b9 100644 --- a/libs/frontend/src/lib/features/puzzles/components/delete-puzzle-confirmation-dialog.svelte +++ b/libs/frontend/src/lib/features/puzzles/components/delete-puzzle-confirmation-dialog.svelte @@ -3,7 +3,7 @@ import * as Dialog from "@/components/ui/dialog"; import * as Form from "@/components/ui/form"; import Input from "@/components/ui/input/input.svelte"; - import { testIds } from "@/config/test-ids"; + import { testIds } from "types"; import { superForm, type SuperValidated } from "sveltekit-superforms"; import { zod4Client } from "sveltekit-superforms/adapters"; import { deletePuzzleSchema, type DeletePuzzle } from "types"; diff --git a/libs/frontend/src/lib/features/puzzles/components/edit-puzzle-form.svelte b/libs/frontend/src/lib/features/puzzles/components/edit-puzzle-form.svelte index c2b573bb..6a4c3d71 100644 --- a/libs/frontend/src/lib/features/puzzles/components/edit-puzzle-form.svelte +++ b/libs/frontend/src/lib/features/puzzles/components/edit-puzzle-form.svelte @@ -25,7 +25,7 @@ import LanguageSelect from "./language-select.svelte"; import Codemirror from "@/features/game/components/codemirror.svelte"; import { languages } from "@/stores/languages"; - import { testIds } from "@/config/test-ids"; + import { testIds } from "types"; let { data diff --git a/libs/frontend/src/lib/features/puzzles/components/play-puzzle.svelte b/libs/frontend/src/lib/features/puzzles/components/play-puzzle.svelte index 4c82c9da..030a4f42 100644 --- a/libs/frontend/src/lib/features/puzzles/components/play-puzzle.svelte +++ b/libs/frontend/src/lib/features/puzzles/components/play-puzzle.svelte @@ -1,6 +1,7 @@ + + + Error - CodinCod + + + + +
+
+
+ +
+ +
+

+ {#if isAuthError} + Authentication Required + {:else if page.status === 404} + Page Not Found + {:else} + Something Went Wrong + {/if} +

+ +

+ {#if isAuthError} + Your session has expired or you don't have permission to access this + page. Please log in again. + {:else if page.status === 404} + The page you're looking for doesn't exist or has been moved. + {:else if page.status >= 500} + We're experiencing technical difficulties. Please try again later. + {:else} + {page.error?.message || "An unexpected error occurred."} + {/if} +

+ + {#if !isAuthError} +

+ Error code: {page.status} +

+ {/if} +
+
+ +
+ {#if isAuthError} + + {:else} + + {/if} + + {#if !isAuthError && page.status !== 404} + + {/if} +
+ + {#if import.meta.env.DEV && page.error} +
+ + Debug Information (Dev Only) + +
{JSON.stringify(
+						{
+							message: page.error.message,
+							status: page.status,
+							path: page.url.pathname,
+							stack: (page.error as Error & { stack?: string }).stack
+						},
+						null,
+						2
+					)}
+
+ {/if} +
+
diff --git a/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte b/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte index 177d27be..7bcfe229 100644 --- a/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte +++ b/libs/frontend/src/routes/(authenticated)/multiplayer/+page.svelte @@ -11,7 +11,6 @@ import CountdownTimer from "@/components/ui/countdown-timer/countdown-timer.svelte"; import CustomGameDialog from "@/features/multiplayer/components/custom-game-dialog.svelte"; import JoinByInviteDialog from "@/features/multiplayer/components/join-by-invite-dialog.svelte"; - import WaitingRoomChat from "@/features/multiplayer/components/waiting-room-chat.svelte"; import { buildWebSocketUrl } from "@/config/websocket"; import { authenticatedUserInfo } from "@/stores"; import { WebSocketManager } from "@/websocket/websocket-manager.svelte"; @@ -32,9 +31,10 @@ type GameOptions, type ChatMessage } from "types"; - import { testIds } from "@/config/test-ids"; + import { testIds } from "types"; import { currentTime } from "@/stores/current-time"; import Chat from "@/features/chat/components/chat.svelte"; + import { Input } from "#/ui/input"; let room: RoomStateResponse | undefined = $state(); let rooms: RoomOverviewResponse[] = $state([]); @@ -45,6 +45,7 @@ let customGameDialogOpen = $state(false); let joinByInviteDialogOpen = $state(false); let chatMessages = $state>([]); + let showInviteCode = $state(false); const queryParamKeys = { ROOM_ID: "roomId" @@ -317,15 +318,24 @@
{#if room.inviteCode}
-

- 🔒 Private Game - Invite Code: -

+

Invite Code

- + + + {#if page.status !== 404} + + {/if} +
-

Go back to the .

+ {#if import.meta.env.DEV && page.error} +
+ + Debug Information (Dev Only) + +
{JSON.stringify(
+						{
+							message: page.error.message,
+							status: page.status,
+							path: page.url.pathname,
+							stack: (page.error as Error & { stack?: string }).stack
+						},
+						null,
+						2
+					)}
+
+ {/if} +
diff --git a/libs/frontend/src/routes/api/account/preferences/+server.ts b/libs/frontend/src/routes/api/account/preferences/+server.ts deleted file mode 100644 index 41f1d142..00000000 --- a/libs/frontend/src/routes/api/account/preferences/+server.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { httpRequestMethod } from "types"; -import type { RequestEvent } from "./$types"; -import { - fetchWithAuthenticationCookie, - getCookieHeader -} from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { backendUrls } from "types"; -import { buildBackendUrl } from "@/config/backend"; - -export async function POST({ request }: RequestEvent) { - const body = await request.text(); - - return fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.ACCOUNT_PREFERENCES), - { - body: body, - headers: getCookieHeader(request), - method: httpRequestMethod.POST - } - ); -} - -export async function PUT({ request }: RequestEvent) { - const body = await request.text(); - - return fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.ACCOUNT_PREFERENCES), - { - body: body, - headers: getCookieHeader(request), - method: httpRequestMethod.PUT - } - ); -} - -export async function GET({ request }: RequestEvent) { - return fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.ACCOUNT_PREFERENCES), - { - headers: getCookieHeader(request), - method: httpRequestMethod.GET - } - ); -} - -export async function DELETE({ request }: RequestEvent) { - return fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.ACCOUNT_PREFERENCES), - { - headers: getCookieHeader(request), - method: httpRequestMethod.DELETE - } - ); -} diff --git a/libs/frontend/src/routes/api/comment/[id]/+server.ts b/libs/frontend/src/routes/api/comment/[id]/+server.ts deleted file mode 100644 index b404b592..00000000 --- a/libs/frontend/src/routes/api/comment/[id]/+server.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { backendUrls, httpRequestMethod } from "types"; -import type { RequestEvent } from "./$types"; -import { - fetchWithAuthenticationCookie, - getCookieHeader -} from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { buildBackendUrl } from "@/config/backend"; - -export async function GET({ params, request }: RequestEvent) { - return await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.commentById(params.id)), - { - headers: getCookieHeader(request), - method: httpRequestMethod.GET - } - ); -} - -export async function DELETE({ params, request }: RequestEvent) { - return await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.commentById(params.id)), - { - headers: getCookieHeader(request), - method: httpRequestMethod.DELETE - } - ); -} diff --git a/libs/frontend/src/routes/api/comment/[id]/comment/+server.ts b/libs/frontend/src/routes/api/comment/[id]/comment/+server.ts deleted file mode 100644 index 24d47f91..00000000 --- a/libs/frontend/src/routes/api/comment/[id]/comment/+server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { backendUrls, httpRequestMethod } from "types"; -import type { RequestEvent } from "../vote/$types"; -import { - fetchWithAuthenticationCookie, - getCookieHeader -} from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { buildBackendUrl } from "@/config/backend"; - -export async function POST({ params, request }: RequestEvent) { - const body = await request.text(); - - return await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.commentByIdComment(params.id)), - { - body, - headers: getCookieHeader(request), - method: httpRequestMethod.POST - } - ); -} diff --git a/libs/frontend/src/routes/api/comment/[id]/vote/+server.ts b/libs/frontend/src/routes/api/comment/[id]/vote/+server.ts deleted file mode 100644 index 2188e380..00000000 --- a/libs/frontend/src/routes/api/comment/[id]/vote/+server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { backendUrls, httpRequestMethod } from "types"; -import type { RequestEvent } from "./$types"; -import { - fetchWithAuthenticationCookie, - getCookieHeader -} from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { buildBackendUrl } from "@/config/backend"; - -export async function POST({ params, request }: RequestEvent) { - const body = await request.text(); - - return await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.commentByIdVote(params.id)), - { - body, - headers: getCookieHeader(request), - method: httpRequestMethod.POST - } - ); -} diff --git a/libs/frontend/src/routes/api/execute-code/+server.ts b/libs/frontend/src/routes/api/execute-code/+server.ts deleted file mode 100644 index 429aa0be..00000000 --- a/libs/frontend/src/routes/api/execute-code/+server.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { backendUrls, httpRequestMethod } from "types"; -import type { RequestEvent } from "./$types"; -import { - fetchWithAuthenticationCookie, - getCookieHeader -} from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { buildBackendUrl } from "@/config/backend"; - -export async function POST({ request }: RequestEvent) { - const body = await request.text(); - - return fetchWithAuthenticationCookie(buildBackendUrl(backendUrls.EXECUTE), { - body: body, - headers: getCookieHeader(request), - method: httpRequestMethod.POST - }); -} diff --git a/libs/frontend/src/routes/api/get-user-activity-by-username.ts b/libs/frontend/src/routes/api/get-user-activity-by-username.ts deleted file mode 100644 index 2bf64306..00000000 --- a/libs/frontend/src/routes/api/get-user-activity-by-username.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { buildBackendUrl } from "@/config/backend"; -import { fetchWithAuthenticationCookie } from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { backendUrls } from "types"; - -export async function getUserActivityByUsername(username: string) { - return fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.userByUsernameActivity(username)) - ); -} diff --git a/libs/frontend/src/routes/api/handle-delete-puzzle-form.ts b/libs/frontend/src/routes/api/handle-delete-puzzle-form.ts deleted file mode 100644 index 086ac463..00000000 --- a/libs/frontend/src/routes/api/handle-delete-puzzle-form.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { buildBackendUrl } from "@/config/backend"; -import { fetchWithAuthenticationCookie } from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { redirect, type RequestEvent } from "@sveltejs/kit"; -import { fail, superValidate } from "sveltekit-superforms"; -import { zod4 } from "sveltekit-superforms/adapters"; -import { - backendUrls, - DELETE, - deletePuzzleSchema, - frontendUrls, - httpResponseCodes -} from "types"; - -export async function handleDeletePuzzleForm({ request }: RequestEvent) { - const deletePuzzleForm = await superValidate( - request, - zod4(deletePuzzleSchema) - ); - - if (!deletePuzzleForm.valid) { - // Again, return { form } and things will just work. - fail(400, { deletePuzzleForm }); - } - - const cookie = request.headers.get("cookie") || ""; - - // Prepare the url - const id = deletePuzzleForm.data.id; - const deletePuzzleUrl = buildBackendUrl(backendUrls.puzzleById(id)); - - // Update puzzle data to backend - const response = await fetchWithAuthenticationCookie(deletePuzzleUrl, { - headers: { - "Content-Type": "application/json", - Cookie: cookie - }, - method: DELETE - }); - - if (!response.ok) { - fail(response.status, { - deletePuzzleForm, - error: "Failed to delete the puzzle." - }); - } - - if (response.ok) { - redirect(httpResponseCodes.REDIRECTION.SEE_OTHER, frontendUrls.PUZZLES); - } - - // Display a success status message - return { - deletePuzzleForm, - message: "Puzzle deleted successfully!", - success: true - }; -} diff --git a/libs/frontend/src/routes/api/login.ts b/libs/frontend/src/routes/api/login.ts deleted file mode 100644 index 2257d564..00000000 --- a/libs/frontend/src/routes/api/login.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { buildBackendUrl } from "@/config/backend"; -import { fetchWithAuthenticationCookie } from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { backendUrls, POST } from "types"; - -export async function login(identifier: string, password: string) { - return fetchWithAuthenticationCookie(buildBackendUrl(backendUrls.LOGIN), { - body: JSON.stringify({ identifier, password }), - method: POST - }); -} diff --git a/libs/frontend/src/routes/api/moderation/puzzle/[id]/approve/+server.ts b/libs/frontend/src/routes/api/moderation/puzzle/[id]/approve/+server.ts deleted file mode 100644 index b195ef69..00000000 --- a/libs/frontend/src/routes/api/moderation/puzzle/[id]/approve/+server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { buildBackendUrl } from "@/config/backend"; -import { - fetchWithAuthenticationCookie, - getCookieHeader -} from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { backendUrls, httpRequestMethod } from "types"; -import type { RequestEvent } from "./$types"; - -export async function POST({ params, request }: RequestEvent) { - const { id } = params; - - return await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.moderationPuzzleApprove(id)), - { - headers: getCookieHeader(request), - method: httpRequestMethod.POST, - body: JSON.stringify({}) - } - ); -} diff --git a/libs/frontend/src/routes/api/moderation/puzzle/[id]/revise/+server.ts b/libs/frontend/src/routes/api/moderation/puzzle/[id]/revise/+server.ts deleted file mode 100644 index b4688b01..00000000 --- a/libs/frontend/src/routes/api/moderation/puzzle/[id]/revise/+server.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { buildBackendUrl } from "@/config/backend"; -import { - fetchWithAuthenticationCookie, - getCookieHeader -} from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { backendUrls, httpRequestMethod } from "types"; -import type { RequestEvent } from "./$types"; - -export async function POST({ params, request }: RequestEvent) { - const { id } = params; - const body = await request.text(); - - return await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.moderationPuzzleRevise(id)), - { - headers: getCookieHeader(request), - method: httpRequestMethod.POST, - body - } - ); -} diff --git a/libs/frontend/src/routes/api/moderation/report/[id]/resolve/+server.ts b/libs/frontend/src/routes/api/moderation/report/[id]/resolve/+server.ts deleted file mode 100644 index 68ba6b74..00000000 --- a/libs/frontend/src/routes/api/moderation/report/[id]/resolve/+server.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { buildBackendUrl } from "@/config/backend"; -import { - fetchWithAuthenticationCookie, - getCookieHeader -} from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { backendUrls, httpRequestMethod } from "types"; -import type { RequestEvent } from "./$types"; - -export async function POST({ params, request }: RequestEvent) { - const { id } = params; - const body = await request.json(); - - return await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.moderationReportResolve(id)), - { - headers: getCookieHeader(request), - method: httpRequestMethod.POST, - body: JSON.stringify(body) - } - ); -} diff --git a/libs/frontend/src/routes/api/moderation/user/[id]/ban/[type]/+server.ts b/libs/frontend/src/routes/api/moderation/user/[id]/ban/[type]/+server.ts deleted file mode 100644 index bcac1a97..00000000 --- a/libs/frontend/src/routes/api/moderation/user/[id]/ban/[type]/+server.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { env } from "$env/dynamic/private"; -import { error, json } from "@sveltejs/kit"; -import type { RequestHandler } from "./$types"; -import { backendUrls, httpRequestMethod, isBanType } from "types"; - -export const POST: RequestHandler = async ({ request, params, cookies }) => { - const sessionToken = cookies.get("sessionToken"); - const userId = params.id; - const type = params.type; - - if (!sessionToken) { - throw error(401, "Unauthorized"); - } - - if (!userId) { - throw error(400, "User ID is required"); - } - - if (!isBanType(type)) { - throw error(400, "Invalid ban type"); - } - - const body = await request.json(); - const { duration, reason } = body; - - try { - const response = await fetch( - backendUrls.moderationUserByIdBanByType(userId, type), - { - method: httpRequestMethod.POST, - headers: { - "Content-Type": "application/json", - Cookie: `sessionToken=${sessionToken}` - }, - body: JSON.stringify({ duration, reason }) - } - ); - - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ message: "Failed to ban user" })); - throw error(response.status, errorData.message); - } - - const result = await response.json(); - return json(result); - } catch (err) { - console.error("Error banning user:", err); - if (err instanceof Error && "status" in err) { - throw err; - } - throw error(500, "Internal server error"); - } -}; diff --git a/libs/frontend/src/routes/api/moderation/user/[id]/ban/history/+server.ts b/libs/frontend/src/routes/api/moderation/user/[id]/ban/history/+server.ts deleted file mode 100644 index 9a1ca749..00000000 --- a/libs/frontend/src/routes/api/moderation/user/[id]/ban/history/+server.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { error, json } from "@sveltejs/kit"; -import { apiUrls } from "@/config/api"; -import type { RequestHandler } from "./$types"; - -export const GET: RequestHandler = async ({ params, cookies }) => { - const sessionToken = cookies.get("sessionToken"); - const userId = params.id; - - if (!sessionToken) { - throw error(401, "Unauthorized"); - } - - if (!userId) { - throw error(400, "User ID is required"); - } - - try { - const response = await fetch(apiUrls.moderationUserByIdBanHistory(userId), { - method: "GET", - headers: { - Cookie: `sessionToken=${sessionToken}` - } - }); - - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ message: "Failed to fetch ban history" })); - throw error(response.status, errorData.message); - } - - const result = await response.json(); - return json(result); - } catch (err) { - console.error("Error fetching ban history:", err); - if (err instanceof Error && "status" in err) { - throw err; - } - throw error(500, "Internal server error"); - } -}; diff --git a/libs/frontend/src/routes/api/moderation/user/[id]/unban/+server.ts b/libs/frontend/src/routes/api/moderation/user/[id]/unban/+server.ts deleted file mode 100644 index eb557a3e..00000000 --- a/libs/frontend/src/routes/api/moderation/user/[id]/unban/+server.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { env } from "$env/dynamic/private"; -import { error, json } from "@sveltejs/kit"; -import type { RequestHandler } from "./$types"; -import { apiUrls } from "@/config/api"; - -export const POST: RequestHandler = async ({ request, params, cookies }) => { - const sessionToken = cookies.get("sessionToken"); - const userId = params.id; - - if (!sessionToken) { - throw error(401, "Unauthorized"); - } - - if (!userId) { - throw error(400, "User ID is required"); - } - - const body = await request.json(); - const { reason } = body; - - try { - const response = await fetch(apiUrls.moderationUserByIdUnban(userId), { - method: "POST", - headers: { - "Content-Type": "application/json", - Cookie: `sessionToken=${sessionToken}` - }, - body: JSON.stringify({ reason }) - }); - - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ message: "Failed to unban user" })); - throw error(response.status, errorData.message); - } - - const result = await response.json(); - return json(result); - } catch (err) { - console.error("Error unbanning user:", err); - if (err instanceof Error && "status" in err) { - throw err; - } - throw error(500, "Internal server error"); - } -}; diff --git a/libs/frontend/src/routes/api/programming-languages/+server.ts b/libs/frontend/src/routes/api/programming-languages/+server.ts deleted file mode 100644 index a1fe88d9..00000000 --- a/libs/frontend/src/routes/api/programming-languages/+server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { backendUrls, httpRequestMethod } from "types"; -import { buildBackendUrl } from "@/config/backend"; -import type { RequestEvent } from "./$types"; -import { getCookieHeader } from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { json } from "@sveltejs/kit"; - -export async function GET({ fetch, request }: RequestEvent) { - const response = await fetch( - buildBackendUrl(backendUrls.PROGRAMMING_LANGUAGE), - { - headers: getCookieHeader(request), - method: httpRequestMethod.GET - } - ); - - const { languages } = await response.json(); - - return json({ languages }); -} diff --git a/libs/frontend/src/routes/api/puzzles/[id]/comment/+server.ts b/libs/frontend/src/routes/api/puzzles/[id]/comment/+server.ts deleted file mode 100644 index 1545872f..00000000 --- a/libs/frontend/src/routes/api/puzzles/[id]/comment/+server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { backendUrls, httpRequestMethod } from "types"; -import type { RequestEvent } from "./$types"; -import { - fetchWithAuthenticationCookie, - getCookieHeader -} from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { buildBackendUrl } from "@/config/backend"; - -export async function POST({ params, request }: RequestEvent) { - const body = await request.text(); - - return await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.puzzleByIdComment(params.id)), - { - body, - headers: getCookieHeader(request), - method: httpRequestMethod.POST - } - ); -} diff --git a/libs/frontend/src/routes/api/submission/[id]/+server.ts b/libs/frontend/src/routes/api/submission/[id]/+server.ts deleted file mode 100644 index 4c9b8d8e..00000000 --- a/libs/frontend/src/routes/api/submission/[id]/+server.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { backendUrls, httpRequestMethod } from "types"; -import { - fetchWithAuthenticationCookie, - getCookieHeader -} from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { buildBackendUrl } from "@/config/backend"; -import type { RequestEvent } from "./$types"; - -export async function GET({ params, request }: RequestEvent) { - return await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.submissionById(params.id)), - { - headers: getCookieHeader(request), - method: httpRequestMethod.GET - } - ); -} diff --git a/libs/frontend/src/routes/api/submit-code/+server.ts b/libs/frontend/src/routes/api/submit-code/+server.ts deleted file mode 100644 index 1ab10c84..00000000 --- a/libs/frontend/src/routes/api/submit-code/+server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { backendUrls, httpRequestMethod } from "types"; -import type { RequestEvent } from "./$types"; -import { - fetchWithAuthenticationCookie, - getCookieHeader -} from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { buildBackendUrl } from "@/config/backend"; - -export async function POST({ request }: RequestEvent) { - const body = await request.text(); - - return await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.SUBMISSION), - { - body: body, - headers: getCookieHeader(request), - method: httpRequestMethod.POST - } - ); -} diff --git a/libs/frontend/src/routes/api/submit-game/+server.ts b/libs/frontend/src/routes/api/submit-game/+server.ts deleted file mode 100644 index b9d7e68c..00000000 --- a/libs/frontend/src/routes/api/submit-game/+server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { backendUrls, httpRequestMethod } from "types"; -import type { RequestEvent } from "./$types"; -import { - fetchWithAuthenticationCookie, - getCookieHeader -} from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { buildBackendUrl } from "@/config/backend"; - -export async function POST({ request }: RequestEvent) { - const body = await request.text(); - - return await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.SUBMISSION_GAME), - { - body: body, - headers: getCookieHeader(request), - method: httpRequestMethod.POST - } - ); -} diff --git a/libs/frontend/src/routes/api/supported-languages/+server.ts b/libs/frontend/src/routes/api/supported-languages/+server.ts deleted file mode 100644 index 1f0005e6..00000000 --- a/libs/frontend/src/routes/api/supported-languages/+server.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { backendUrls, httpRequestMethod } from "types"; -import { buildBackendUrl } from "@/config/backend"; -import type { RequestEvent } from "./$types"; -import { getCookieHeader } from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { json } from "@sveltejs/kit"; - -export async function GET({ fetch, request }: RequestEvent) { - const response = await fetch( - buildBackendUrl(backendUrls.PROGRAMMING_LANGUAGE), - { - headers: getCookieHeader(request), - method: httpRequestMethod.GET - } - ); - - const { languages } = await response.json(); - - const uniqueLanguages = Array.from( - new Set(languages.map((lang: { language: string }) => lang.language)) - ).sort(); - - return json({ languages: uniqueLanguages }); -} diff --git a/libs/frontend/src/routes/api/user/[username]/+server.ts b/libs/frontend/src/routes/api/user/[username]/+server.ts deleted file mode 100644 index 888b1272..00000000 --- a/libs/frontend/src/routes/api/user/[username]/+server.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { buildBackendUrl } from "@/config/backend"; -import { - fetchWithAuthenticationCookie, - getCookieHeader -} from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { backendUrls, httpRequestMethod } from "types"; -import type { RequestEvent } from "./$types"; - -export async function GET({ params, request }: RequestEvent) { - return fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.userByUsername(params.username)), - { - headers: getCookieHeader(request), - method: httpRequestMethod.GET - } - ); -} diff --git a/libs/frontend/src/routes/api/user/[username]/puzzle/+server.ts b/libs/frontend/src/routes/api/user/[username]/puzzle/+server.ts deleted file mode 100644 index fb97e7da..00000000 --- a/libs/frontend/src/routes/api/user/[username]/puzzle/+server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { buildBackendUrl } from "@/config/backend"; -import { - fetchWithAuthenticationCookie, - getCookieHeader -} from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { backendUrls, httpRequestMethod } from "types"; -import type { RequestEvent } from "./$types"; - -export async function GET({ params, request, url }: RequestEvent) { - const username = params.username; - const userPuzzlesByUsernameUrl = buildBackendUrl( - backendUrls.userByUsernamePuzzle(username) - ); - - return fetchWithAuthenticationCookie(userPuzzlesByUsernameUrl + url.search, { - headers: getCookieHeader(request), - method: httpRequestMethod.GET - }); -} diff --git a/libs/frontend/src/routes/api/username-is-available/[username]/+server.ts b/libs/frontend/src/routes/api/username-is-available/[username]/+server.ts deleted file mode 100644 index c827b212..00000000 --- a/libs/frontend/src/routes/api/username-is-available/[username]/+server.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { buildBackendUrl } from "@/config/backend"; -import { backendUrls } from "types"; -import type { RequestEvent } from "./$types"; - -export async function GET({ fetch, params }: RequestEvent) { - const username = params.username; - - return fetch( - buildBackendUrl(backendUrls.userByUsernameIsAvailable(username)) - ); -} diff --git a/libs/frontend/src/routes/leaderboard/+page.svelte b/libs/frontend/src/routes/leaderboard/+page.svelte new file mode 100644 index 00000000..37df5650 --- /dev/null +++ b/libs/frontend/src/routes/leaderboard/+page.svelte @@ -0,0 +1,302 @@ + + +
+

Leaderboards

+ + +
+
+ {#each Object.values(gameModeEnum) as mode} + + {/each} +
+
+ + + {#if loading} +
+
+
+ {/if} + + + {#if error} +
+ {error} +
+ {/if} + + + {#if leaderboardData && !loading} +
+ +
+

+ {gameModeNames[selectedMode]} Leaderboard +

+

+ Last updated: {formatDate(leaderboardData.lastUpdated)} +

+
+ + +
+ + + + + + + + + + + + + + {#each leaderboardData.entries as entry} + + + + + + + + + + {/each} + +
+ Rank + + Player + + Rating + + Games + + Win Rate + + Best Score + + Avg Score +
+ {getRankBadge(entry.rank)} + #{entry.rank} + + + {entry.username} + + + + {Math.round(entry.rating)} + + + (±{Math.round(entry.glicko.rd)}) + + + {entry.gamesPlayed} + + {entry.gamesWon}W + + +
+
+
+
+ + {(entry.winRate * 100).toFixed(1)}% + +
+
+ {Math.round(entry.bestScore).toLocaleString()} + + {Math.round(entry.averageScore).toLocaleString()} +
+
+ + +
+
+ Showing {(currentPage - 1) * pageSize + 1} to {Math.min( + currentPage * pageSize, + leaderboardData.totalEntries + )} of {leaderboardData.totalEntries} players +
+
+ + + Page {currentPage} of {leaderboardData.totalPages} + + +
+
+
+ {/if} +
+ + diff --git a/libs/frontend/src/routes/maintenance/+page.svelte b/libs/frontend/src/routes/maintenance/+page.svelte index ff426dff..6ee100e5 100644 --- a/libs/frontend/src/routes/maintenance/+page.svelte +++ b/libs/frontend/src/routes/maintenance/+page.svelte @@ -3,7 +3,7 @@ import H1 from "@/components/typography/h1.svelte"; import P from "@/components/typography/p.svelte"; import Button from "@/components/ui/button/button.svelte"; - import { testIds } from "@/config/test-ids"; + import { testIds } from "types"; diff --git a/libs/frontend/src/routes/moderation/+page.svelte b/libs/frontend/src/routes/moderation/+page.svelte index 9d10685a..5c2da904 100644 --- a/libs/frontend/src/routes/moderation/+page.svelte +++ b/libs/frontend/src/routes/moderation/+page.svelte @@ -20,8 +20,9 @@ import { Button } from "#/ui/button"; import { page } from "$app/state"; import { formattedDateYearMonthDay } from "@/utils/date-functions"; - import { testIds } from "@/config/test-ids"; - import { apiUrls } from "@/config/api"; + import { testIds } from "types"; + import { buildBackendUrl } from "@/config/backend"; + import { backendUrls } from "types"; import Pagination from "#/nav/pagination.svelte"; let { data }: { data: PageData } = $props(); @@ -79,9 +80,12 @@ async function handleApprove(id: string) { try { - const response = await fetch(apiUrls.moderationPuzzleByIdApprove(id), { - method: httpRequestMethod.POST - }); + const response = await fetch( + buildBackendUrl(backendUrls.moderationPuzzleApprove(id)), + { + method: httpRequestMethod.POST + } + ); if (!response.ok) { throw new Error("Failed to approve puzzle"); @@ -114,7 +118,7 @@ try { const response = await fetch( - apiUrls.moderationPuzzleByIdRevise(selectedPuzzleId), + buildBackendUrl(backendUrls.moderationPuzzleRevise(selectedPuzzleId)), { method: httpRequestMethod.POST, headers: { @@ -142,13 +146,16 @@ status: typeof reviewStatusEnum.RESOLVED | typeof reviewStatusEnum.REJECTED ) { try { - const response = await fetch(apiUrls.moderationReportByIdResolve(id), { - method: httpRequestMethod.POST, - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ status }) - }); + const response = await fetch( + buildBackendUrl(backendUrls.moderationReportResolve(id)), + { + method: httpRequestMethod.POST, + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ status }) + } + ); if (!response.ok) { throw new Error("Failed to resolve report"); @@ -184,7 +191,9 @@ try { const response = await fetch( - apiUrls.moderationUserByIdBanByType(selectedUserId, banType), + buildBackendUrl( + backendUrls.moderationUserByIdBanByType(selectedUserId, banType) + ), { method: httpRequestMethod.POST, headers: { @@ -219,15 +228,18 @@ } try { - const response = await fetch(apiUrls.moderationUserByIdUnban(userId), { - method: httpRequestMethod.POST, - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - reason: "Unbanned by moderator" - }) - }); + const response = await fetch( + buildBackendUrl(backendUrls.moderationUserByIdUnban(userId)), + { + method: httpRequestMethod.POST, + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + reason: "Unbanned by moderator" + }) + } + ); if (!response.ok) { throw new Error("Failed to unban user"); @@ -249,7 +261,7 @@ try { const response = await fetch( - apiUrls.moderationUserByIdBanHistory(userId), + buildBackendUrl(backendUrls.moderationUserByIdBanHistory(userId)), { method: httpRequestMethod.GET } diff --git a/libs/frontend/src/routes/profile/[username]/+page.server.ts b/libs/frontend/src/routes/profile/[username]/+page.server.ts index 0ae90207..d717ff4f 100644 --- a/libs/frontend/src/routes/profile/[username]/+page.server.ts +++ b/libs/frontend/src/routes/profile/[username]/+page.server.ts @@ -1,11 +1,19 @@ -import { getUserActivityByUsername } from "../../api/get-user-activity-by-username.js"; -import { activityTypeEnum, type PuzzleDto, type SubmissionDto } from "types"; +import { + activityTypeEnum, + backendUrls, + type PuzzleDto, + type SubmissionDto +} from "types"; import type { PageServerLoadEvent } from "./$types"; +import { buildBackendUrl } from "@/config/backend"; +import { fetchWithAuthenticationCookie } from "@/features/authentication/utils/fetch-with-authentication-cookie"; export async function load({ params }: PageServerLoadEvent) { const username = params.username; - const response = await getUserActivityByUsername(username); + const response = await fetchWithAuthenticationCookie( + buildBackendUrl(backendUrls.userByUsernameActivity(username)) + ); if (!response.ok) { console.error(response); } diff --git a/libs/frontend/src/routes/profile/[username]/+page.svelte b/libs/frontend/src/routes/profile/[username]/+page.svelte index 823da1bd..971642a5 100644 --- a/libs/frontend/src/routes/profile/[username]/+page.svelte +++ b/libs/frontend/src/routes/profile/[username]/+page.svelte @@ -5,7 +5,7 @@ import H1 from "@/components/typography/h1.svelte"; import * as Card from "@/components/ui/card"; import Container from "@/components/ui/container/container.svelte"; - import { testIds } from "@/config/test-ids"; + import { testIds } from "types"; import ActivityGroup from "@/features/profile/components/activity-group.svelte"; import ActivityHeatmap from "@/features/profile/components/activity-heatmap.svelte"; import dayjs from "dayjs"; diff --git a/libs/frontend/src/routes/profile/[username]/puzzles/+page.server.ts b/libs/frontend/src/routes/profile/[username]/puzzles/+page.server.ts index 20bada8b..3ca37d15 100644 --- a/libs/frontend/src/routes/profile/[username]/puzzles/+page.server.ts +++ b/libs/frontend/src/routes/profile/[username]/puzzles/+page.server.ts @@ -1,6 +1,7 @@ import type { PageServerLoadEvent } from "./$types"; import { httpRequestMethod, type PaginatedQueryResponse } from "types"; -import { apiUrls } from "@/config/api"; +import { buildBackendUrl } from "@/config/backend"; +import { backendUrls } from "types"; export async function load({ fetch, @@ -10,7 +11,7 @@ export async function load({ }: PageServerLoadEvent) { const username = params.username; - const apiUrl = apiUrls.userByUsernamePuzzle(username); + const apiUrl = buildBackendUrl(backendUrls.userByUsernamePuzzle(username)); const apiUrlWithQueryParams = new URL(apiUrl, request.url); apiUrlWithQueryParams.search = url.search; diff --git a/libs/frontend/src/routes/profile/[username]/puzzles/+page.svelte b/libs/frontend/src/routes/profile/[username]/puzzles/+page.svelte index 27efdf85..aa9f3554 100644 --- a/libs/frontend/src/routes/profile/[username]/puzzles/+page.svelte +++ b/libs/frontend/src/routes/profile/[username]/puzzles/+page.svelte @@ -14,7 +14,7 @@ import LogicalUnit from "@/components/ui/logical-unit/logical-unit.svelte"; import PuzzleDifficultyBadge from "@/features/puzzles/components/puzzle-difficulty-badge.svelte"; import PuzzleVisibilityBadge from "@/features/puzzles/components/puzzle-visibility-badge.svelte"; - import { testIds } from "@/config/test-ids"; + import { testIds } from "types"; import { authenticatedUserInfo } from "@/stores/index.js"; let { data }: { data: PaginatedQueryResponse | undefined } = $props(); diff --git a/libs/frontend/src/routes/puzzles/+page.svelte b/libs/frontend/src/routes/puzzles/+page.svelte index 4758e823..0c283b9a 100644 --- a/libs/frontend/src/routes/puzzles/+page.svelte +++ b/libs/frontend/src/routes/puzzles/+page.svelte @@ -12,7 +12,7 @@ import LogicalUnit from "@/components/ui/logical-unit/logical-unit.svelte"; import PuzzleDifficultyBadge from "@/features/puzzles/components/puzzle-difficulty-badge.svelte"; import PuzzleVisibilityBadge from "@/features/puzzles/components/puzzle-visibility-badge.svelte"; - import { testIds } from "@/config/test-ids"; + import { testIds } from "types"; import { authenticatedUserInfo, isAuthenticated } from "@/stores"; let { data }: { data: PaginatedQueryResponse | undefined } = $props(); diff --git a/libs/frontend/src/routes/puzzles/[id]/+page.svelte b/libs/frontend/src/routes/puzzles/[id]/+page.svelte index 53125e15..4546a033 100644 --- a/libs/frontend/src/routes/puzzles/[id]/+page.svelte +++ b/libs/frontend/src/routes/puzzles/[id]/+page.svelte @@ -20,7 +20,7 @@ import H2 from "@/components/typography/h2.svelte"; import Comments from "@/features/comment/components/comments.svelte"; import AddCommentForm from "@/features/comment/components/add-comment-form.svelte"; - import { testIds } from "@/config/test-ids"; + import { testIds } from "types"; import { page } from "$app/state"; let { data } = $props(); diff --git a/libs/types/src/core/api/schema/account.schema.ts b/libs/types/src/core/api/schema/account.schema.ts new file mode 100644 index 00000000..3a1b25a1 --- /dev/null +++ b/libs/types/src/core/api/schema/account.schema.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { preferencesDtoSchema } from "../../preferences/schema/preferences-dto.schema.js"; +import { preferencesEntitySchema } from "../../preferences/schema/preferences-entity.schema.js"; +import { messageSchema } from "../../common/schema/message.schema.js"; + +// GET /account/preferences response +export const getPreferencesResponseSchema = preferencesDtoSchema; +export type GetPreferencesResponse = z.infer< + typeof getPreferencesResponseSchema +>; + +// POST /account/preferences request +export const createPreferencesRequestSchema = preferencesEntitySchema; +export type CreatePreferencesRequest = z.infer< + typeof createPreferencesRequestSchema +>; + +// POST /account/preferences response +export const createPreferencesResponseSchema = z.object({ + message: messageSchema, + preferences: preferencesDtoSchema, +}); +export type CreatePreferencesResponse = z.infer< + typeof createPreferencesResponseSchema +>; + +// PUT /account/preferences request +export const updatePreferencesRequestSchema = preferencesEntitySchema.partial(); +export type UpdatePreferencesRequest = z.infer< + typeof updatePreferencesRequestSchema +>; + +// PUT /account/preferences response +export const updatePreferencesResponseSchema = z.object({ + message: messageSchema, + preferences: preferencesDtoSchema, +}); +export type UpdatePreferencesResponse = z.infer< + typeof updatePreferencesResponseSchema +>; + +// DELETE /account/preferences response +export const deletePreferencesResponseSchema = z.object({ + message: messageSchema, +}); +export type DeletePreferencesResponse = z.infer< + typeof deletePreferencesResponseSchema +>; diff --git a/libs/types/src/core/api/schema/auth/login.schema.ts b/libs/types/src/core/api/schema/auth/login.schema.ts new file mode 100644 index 00000000..a428620a --- /dev/null +++ b/libs/types/src/core/api/schema/auth/login.schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { messageSchema } from "../../../common/schema/message.schema.js"; + +/** + * POST /login - User login + */ +export const loginRequestSchema = z.object({ + identifier: z.string().min(1, "Identifier is required"), + password: z.string().min(1, "Password is required"), +}); + +export const loginResponseSchema = messageSchema; + +export type LoginRequest = z.infer; +export type LoginResponse = z.infer; diff --git a/libs/types/src/core/api/schema/auth/logout.schema.ts b/libs/types/src/core/api/schema/auth/logout.schema.ts new file mode 100644 index 00000000..6b4cf51c --- /dev/null +++ b/libs/types/src/core/api/schema/auth/logout.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { messageSchema } from "../../../common/schema/message.schema.js"; + +/** + * POST /logout - User logout + */ +export const logoutResponseSchema = messageSchema; + +export type LogoutResponse = z.infer; diff --git a/libs/types/src/core/api/schema/auth/register.schema.ts b/libs/types/src/core/api/schema/auth/register.schema.ts new file mode 100644 index 00000000..e2f63b51 --- /dev/null +++ b/libs/types/src/core/api/schema/auth/register.schema.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { messageSchema } from "../../../common/schema/message.schema.js"; +import { USERNAME_CONFIG } from "../../../authentication/config/username-config.js"; +import { PASSWORD_CONFIG } from "../../../authentication/config/password-config.js"; + +export const registerRequestSchema = z.object({ + username: z + .string() + .min( + USERNAME_CONFIG.minUsernameLength, + `Username must be at least ${USERNAME_CONFIG.minUsernameLength} characters`, + ) + .max( + USERNAME_CONFIG.maxUsernameLength, + `Username cannot exceed ${USERNAME_CONFIG.maxUsernameLength} characters`, + ) + .regex( + USERNAME_CONFIG.allowedCharacters, + "Username can only contain letters, numbers, underscores, and hyphens", + ), + email: z.string().email("Invalid email address").optional(), + password: z + .string() + .min( + PASSWORD_CONFIG.minPasswordLength, + `Password must be at least ${PASSWORD_CONFIG.minPasswordLength} characters`, + ), +}); + +export const registerResponseSchema = messageSchema; + +export type RegisterRequest = z.infer; +export type RegisterResponse = z.infer; diff --git a/libs/types/src/core/api/schema/comment.schema.ts b/libs/types/src/core/api/schema/comment.schema.ts new file mode 100644 index 00000000..9a73dbe4 --- /dev/null +++ b/libs/types/src/core/api/schema/comment.schema.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { commentDtoSchema } from "../../comment/schema/comment-dto.schema.js"; +import { createCommentSchema } from "../../comment/schema/create-comment.schema.js"; +import { commentVoteRequestSchema } from "../../comment/schema/comment-vote.schema.js"; +import { messageSchema } from "../../common/schema/message.schema.js"; + +// GET /comment/:id response +export const getCommentByIdResponseSchema = commentDtoSchema; +export type GetCommentByIdResponse = z.infer< + typeof getCommentByIdResponseSchema +>; + +// DELETE /comment/:id response +export const deleteCommentResponseSchema = z.object({ + message: messageSchema, +}); +export type DeleteCommentResponse = z.infer; + +// POST /comment/:id/comment request +export const createReplyCommentRequestSchema = createCommentSchema; +export type CreateReplyCommentRequest = z.infer< + typeof createReplyCommentRequestSchema +>; + +// POST /comment/:id/comment response +export const createReplyCommentResponseSchema = commentDtoSchema; +export type CreateReplyCommentResponse = z.infer< + typeof createReplyCommentResponseSchema +>; + +// POST /comment/:id/vote request +export const voteCommentRequestSchema = commentVoteRequestSchema; +export type VoteCommentRequest = z.infer; + +// POST /comment/:id/vote response +export const voteCommentResponseSchema = z.object({ + message: messageSchema, + voteCount: z.number(), +}); +export type VoteCommentResponse = z.infer; diff --git a/libs/types/src/core/api/schema/execute-code.schema.ts b/libs/types/src/core/api/schema/execute-code.schema.ts new file mode 100644 index 00000000..50c91212 --- /dev/null +++ b/libs/types/src/core/api/schema/execute-code.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; +import { pistonExecutionRequestSchema } from "../../piston/schema/request.js"; +import { codeExecutionResponseSchema } from "../../piston/schema/code-execution-response.js"; + +// Request schema for code execution +export const executeCodeRequestSchema = pistonExecutionRequestSchema; +export type ExecuteCodeRequest = z.infer; + +// Response schema for code execution +export const executeCodeResponseSchema = codeExecutionResponseSchema; +export type ExecuteCodeResponse = z.infer; diff --git a/libs/types/src/core/api/schema/execute/execute-api.schema.ts b/libs/types/src/core/api/schema/execute/execute-api.schema.ts new file mode 100644 index 00000000..edc4ca92 --- /dev/null +++ b/libs/types/src/core/api/schema/execute/execute-api.schema.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { pistonExecutionRequestSchema } from "../../../piston/schema/request.js"; +import { codeExecutionResponseSchema } from "../../../piston/schema/code-execution-response.js"; +import { errorResponseSchema } from "../../../common/schema/error-response.schema.js"; + +/** + * POST /execute - Execute code without saving submission + * Used for testing code before final submission + */ +export const executeCodeRequestSchema = z.object({ + code: z.string().min(1, "Code cannot be empty"), + language: z.string().min(1, "Language is required"), + testInput: z.string().default(""), + testOutput: z.string().default(""), +}); + +export const executeCodeResponseSchema = + codeExecutionResponseSchema.or(errorResponseSchema); + +export type ExecuteCodeRequest = z.infer; +export type ExecuteCodeResponse = z.infer; diff --git a/libs/types/src/core/api/schema/game/game-api.schema.ts b/libs/types/src/core/api/schema/game/game-api.schema.ts new file mode 100644 index 00000000..10894bed --- /dev/null +++ b/libs/types/src/core/api/schema/game/game-api.schema.ts @@ -0,0 +1,118 @@ +import { z } from "zod"; +import { objectIdSchema } from "../../../common/schema/object-id.js"; +import { gameEntitySchema } from "../../../game/schema/game-entity.schema.js"; +import { gameModeSchema } from "../../../game/schema/mode.schema.js"; +import { errorResponseSchema } from "../../../common/schema/error-response.schema.js"; +import { gameVisibilitySchema } from "../../../game/schema/visibility.schema.js"; +import { MINIMUM_PLAYERS_IN_GAME } from "../../../game/config/game-config.js"; + +/** + * POST /game - Create a new multiplayer game + */ +export const createGameRequestSchema = z.object({ + puzzleId: objectIdSchema.optional(), + mode: gameModeSchema, + visibility: gameVisibilitySchema, + maxPlayers: z.number().int().min(MINIMUM_PLAYERS_IN_GAME), + timeLimit: z.number().int().min(60).max(3600).optional(), // in seconds +}); + +export const createGameResponseSchema = gameEntitySchema + .extend({ + inviteCode: z.string().optional(), // For private games + }) + .or(errorResponseSchema); + +export type CreateGameRequest = z.infer; +export type CreateGameResponse = z.infer; + +/** + * GET /game/:id - Get game details + */ +export const getGameByIdRequestSchema = z.object({ + id: objectIdSchema, +}); + +export const getGameByIdResponseSchema = + gameEntitySchema.or(errorResponseSchema); + +export type GetGameByIdRequest = z.infer; +export type GetGameByIdResponse = z.infer; + +/** + * GET /game - List available games + */ +export const listGamesRequestSchema = z.object({ + visibility: gameVisibilitySchema.optional(), + mode: gameModeSchema.optional(), + status: z.enum(["waiting", "in_progress", "completed"]).optional(), + page: z.number().int().positive().default(1), + pageSize: z.number().int().positive().max(50).default(20), +}); + +export const listGamesResponseSchema = z + .object({ + items: z.array(gameEntitySchema), + page: z.number().int().positive(), + pageSize: z.number().int().positive(), + totalPages: z.number().int().nonnegative(), + totalItems: z.number().int().nonnegative(), + }) + .or(errorResponseSchema); + +export type ListGamesRequest = z.infer; +export type ListGamesResponse = z.infer; + +/** + * POST /game/:id/join - Join a game + */ +export const joinGameRequestSchema = z.object({ + gameId: objectIdSchema, + inviteCode: z.string().optional(), +}); + +export const joinGameResponseSchema = z + .object({ + success: z.boolean(), + message: z.string(), + game: gameEntitySchema.optional(), + }) + .or(errorResponseSchema); + +export type JoinGameRequest = z.infer; +export type JoinGameResponse = z.infer; + +/** + * POST /game/:id/leave - Leave a game + */ +export const leaveGameRequestSchema = z.object({ + gameId: objectIdSchema, +}); + +export const leaveGameResponseSchema = z + .object({ + success: z.boolean(), + message: z.string(), + }) + .or(errorResponseSchema); + +export type LeaveGameRequest = z.infer; +export type LeaveGameResponse = z.infer; + +/** + * POST /game/:id/start - Start a game (host only) + */ +export const startGameRequestSchema = z.object({ + gameId: objectIdSchema, +}); + +export const startGameResponseSchema = z + .object({ + success: z.boolean(), + message: z.string(), + startTime: z.date().or(z.string()).optional(), + }) + .or(errorResponseSchema); + +export type StartGameRequest = z.infer; +export type StartGameResponse = z.infer; diff --git a/libs/types/src/core/api/schema/game/leaderboard-api.schema.ts b/libs/types/src/core/api/schema/game/leaderboard-api.schema.ts new file mode 100644 index 00000000..08db8c74 --- /dev/null +++ b/libs/types/src/core/api/schema/game/leaderboard-api.schema.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; +import { objectIdSchema } from "../../../common/schema/object-id.js"; +import { errorResponseSchema } from "../../../common/schema/error-response.schema.js"; +import { gameModeSchema } from "../../../game/schema/mode.schema.js"; +import { acceptedDateSchema } from "../../../common/schema/accepted-date.js"; + +/** + * GET /game/:id/leaderboard - Get ranked leaderboard for a game + */ +export const getGameLeaderboardRequestSchema = z.object({ + gameId: objectIdSchema, +}); + +export const getGameLeaderboardResponseSchema = z + .object({ + gameId: objectIdSchema, + mode: gameModeSchema, + leaderboard: z.array( + z.object({ + userId: objectIdSchema, + username: z.string(), + score: z.number(), + timeSpent: z.number(), // in seconds + codeLength: z.number().int().nonnegative().optional(), + successRate: z.number().min(0).max(1), + rank: z.number().int().positive(), + programmingLanguage: z.string().optional(), + }), + ), + totalPlayers: z.number().int().nonnegative(), + }) + .or(errorResponseSchema); + +export type GetGameLeaderboardRequest = z.infer< + typeof getGameLeaderboardRequestSchema +>; +export type GetGameLeaderboardResponse = z.infer< + typeof getGameLeaderboardResponseSchema +>; + +/** + * GET /game/:id/stats - Get game statistics and metadata + */ +export const getGameStatsRequestSchema = z.object({ + gameId: objectIdSchema, +}); + +export const getGameStatsResponseSchema = z + .object({ + gameId: objectIdSchema, + mode: gameModeSchema, + description: z.string(), + displayMetrics: z.array(z.string()), + playerCount: z.number().int().nonnegative(), + submissionCount: z.number().int().nonnegative(), + createdAt: acceptedDateSchema, + options: z + .object({ + mode: gameModeSchema, + maxPlayers: z.number().int().positive(), + timeLimit: z.number().int().positive().optional(), + }) + .optional(), + }) + .or(errorResponseSchema); + +export type GetGameStatsRequest = z.infer; +export type GetGameStatsResponse = z.infer; diff --git a/libs/types/src/core/api/schema/leaderboard/leaderboard-api.schema.ts b/libs/types/src/core/api/schema/leaderboard/leaderboard-api.schema.ts new file mode 100644 index 00000000..8e01c12d --- /dev/null +++ b/libs/types/src/core/api/schema/leaderboard/leaderboard-api.schema.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; +import { gameModeSchema } from "../../../game/schema/mode.schema.js"; +import { leaderboardEntrySchema } from "../../../leaderboard/schema/leaderboard-entry.schema.js"; +import { errorResponseSchema } from "../../../common/schema/error-response.schema.js"; + +/** + * GET /leaderboard/:gameMode - Get leaderboard for a specific game mode + */ +export const getLeaderboardRequestSchema = z.object({ + gameMode: gameModeSchema, + page: z.number().int().positive().default(1), + pageSize: z.number().int().positive().max(100).default(50), +}); + +export const getLeaderboardResponseSchema = z + .object({ + gameMode: gameModeSchema, + entries: z.array(leaderboardEntrySchema), + page: z.number().int().positive(), + pageSize: z.number().int().positive(), + totalEntries: z.number().int().nonnegative(), + totalPages: z.number().int().nonnegative(), + lastUpdated: z.date().or(z.string()), + }) + .or(errorResponseSchema); + +export type GetLeaderboardRequest = z.infer; +export type GetLeaderboardResponse = z.infer< + typeof getLeaderboardResponseSchema +>; + +/** + * GET /leaderboard/user/:userId - Get user's rankings across all game modes + */ +export const getUserLeaderboardStatsRequestSchema = z.object({ + userId: z.string(), +}); + +export const getUserLeaderboardStatsResponseSchema = z + .object({ + userId: z.string(), + username: z.string(), + rankings: z.record( + z.string(), // Game mode as string key + z.object({ + rank: z.number().int().positive().optional(), + rating: z.number(), + gamesPlayed: z.number().int().nonnegative(), + winRate: z.number().min(0).max(1), + }), + ), + }) + .or(errorResponseSchema); + +export type GetUserLeaderboardStatsRequest = z.infer< + typeof getUserLeaderboardStatsRequestSchema +>; +export type GetUserLeaderboardStatsResponse = z.infer< + typeof getUserLeaderboardStatsResponseSchema +>; diff --git a/libs/types/src/core/api/schema/moderation.schema.ts b/libs/types/src/core/api/schema/moderation.schema.ts new file mode 100644 index 00000000..b6199785 --- /dev/null +++ b/libs/types/src/core/api/schema/moderation.schema.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; +import { messageSchema } from "../../common/schema/message.schema.js"; +import { paginatedQueryResponseSchema } from "../../common/schema/paginated-query-response.schema.js"; +import { paginatedQuerySchema } from "../../common/schema/paginated-query.schema.js"; +import { reviewItemSchema } from "../../moderation/schema/review-item.schema.js"; +import { + approvePuzzleSchema, + revisePuzzleSchema, +} from "../../moderation/schema/puzzle-moderation.schema.js"; +import { reportEntitySchema } from "../../moderation/schema/report.schema.js"; +import { userBanEntitySchema } from "../../moderation/schema/user-ban.schema.js"; + +// GET /moderation/review query params +export const getModerationReviewQuerySchema = paginatedQuerySchema; +export type GetModerationReviewQuery = z.infer< + typeof getModerationReviewQuerySchema +>; + +// GET /moderation/review response +export const getModerationReviewResponseSchema = + paginatedQueryResponseSchema.extend({ + items: z.array(reviewItemSchema), + }); +export type GetModerationReviewResponse = z.infer< + typeof getModerationReviewResponseSchema +>; + +// POST /moderation/puzzle/:id/approve request +export const approvePuzzleRequestSchema = approvePuzzleSchema; +export type ApprovePuzzleRequest = z.infer; + +// POST /moderation/puzzle/:id/approve response +export const approvePuzzleResponseSchema = z.object({ + message: messageSchema, +}); +export type ApprovePuzzleResponse = z.infer; + +// POST /moderation/puzzle/:id/revise request +export const revisePuzzleRequestSchema = revisePuzzleSchema; +export type RevisePuzzleRequest = z.infer; + +// POST /moderation/puzzle/:id/revise response +export const revisePuzzleResponseSchema = z.object({ + message: messageSchema, +}); +export type RevisePuzzleResponse = z.infer; + +// POST /moderation/report/:id/resolve request +export const resolveReportRequestSchema = z.object({ + action: z.enum(["accept", "reject"]), + notes: z.string().optional(), +}); +export type ResolveReportRequest = z.infer; + +// POST /moderation/report/:id/resolve response +export const resolveReportResponseSchema = z.object({ + message: messageSchema, +}); +export type ResolveReportResponse = z.infer; + +// POST /moderation/user/:id/ban/:type request +export const banUserRequestSchema = z.object({ + reason: z.string().min(10, "Reason must be at least 10 characters"), + duration: z.number().positive().optional(), // Duration in seconds, optional for permanent bans +}); +export type BanUserRequest = z.infer; + +// POST /moderation/user/:id/ban/:type response +export const banUserResponseSchema = z.object({ + message: messageSchema, + ban: userBanEntitySchema, +}); +export type BanUserResponse = z.infer; + +// GET /moderation/user/:id/ban/history response +export const getBanHistoryResponseSchema = z.object({ + bans: z.array(userBanEntitySchema), +}); +export type GetBanHistoryResponse = z.infer; + +// POST /moderation/user/:id/unban response +export const unbanUserResponseSchema = z.object({ + message: messageSchema, +}); +export type UnbanUserResponse = z.infer; + +// POST /report request +export const createReportRequestSchema = reportEntitySchema.omit({ + createdAt: true, + updatedAt: true, + status: true, + resolvedBy: true, +}); +export type CreateReportRequest = z.infer; + +// POST /report response +export const createReportResponseSchema = z.object({ + message: messageSchema, + report: reportEntitySchema, +}); +export type CreateReportResponse = z.infer; diff --git a/libs/types/src/core/api/schema/programming-language.schema.ts b/libs/types/src/core/api/schema/programming-language.schema.ts new file mode 100644 index 00000000..4feb6a60 --- /dev/null +++ b/libs/types/src/core/api/schema/programming-language.schema.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { programmingLanguageDtoSchema } from "../../programming-language/schema/programming-language-dto.schema.js"; +import { paginatedQueryResponseSchema } from "../../common/schema/paginated-query-response.schema.js"; + +// GET /programming-language response +export const programmingLanguagesResponseSchema = z.object({ + languages: z.array(programmingLanguageDtoSchema), +}); +export type ProgrammingLanguagesResponse = z.infer< + typeof programmingLanguagesResponseSchema +>; + +// GET /programming-language/:id response +export const programmingLanguageByIdResponseSchema = + programmingLanguageDtoSchema; +export type ProgrammingLanguageByIdResponse = z.infer< + typeof programmingLanguageByIdResponseSchema +>; + +// GET /programming-language/supported (filtered unique languages) +export const supportedLanguagesResponseSchema = z.object({ + languages: z.array(z.string()), +}); +export type SupportedLanguagesResponse = z.infer< + typeof supportedLanguagesResponseSchema +>; diff --git a/libs/types/src/core/api/schema/programming-language/programming-language-api.schema.ts b/libs/types/src/core/api/schema/programming-language/programming-language-api.schema.ts new file mode 100644 index 00000000..b0c7bb39 --- /dev/null +++ b/libs/types/src/core/api/schema/programming-language/programming-language-api.schema.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { programmingLanguageDtoSchema } from "../../../programming-language/schema/programming-language-dto.schema.js"; +import { objectIdSchema } from "../../../common/schema/object-id.js"; +import { errorResponseSchema } from "../../../common/schema/error-response.schema.js"; + +/** + * GET /programming-language - List all available programming languages + */ +export const getProgrammingLanguagesResponseSchema = z.array( + programmingLanguageDtoSchema, +); + +export type GetProgrammingLanguagesResponse = z.infer< + typeof getProgrammingLanguagesResponseSchema +>; + +/** + * GET /programming-language/:id - Get programming language by ID + */ +export const getProgrammingLanguageByIdRequestSchema = z.object({ + id: objectIdSchema, +}); + +export const getProgrammingLanguageByIdResponseSchema = + programmingLanguageDtoSchema.or(errorResponseSchema); + +export type GetProgrammingLanguageByIdRequest = z.infer< + typeof getProgrammingLanguageByIdRequestSchema +>; +export type GetProgrammingLanguageByIdResponse = z.infer< + typeof getProgrammingLanguageByIdResponseSchema +>; diff --git a/libs/types/src/core/api/schema/puzzle.schema.ts b/libs/types/src/core/api/schema/puzzle.schema.ts new file mode 100644 index 00000000..cb365d23 --- /dev/null +++ b/libs/types/src/core/api/schema/puzzle.schema.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; +import { puzzleDtoSchema } from "../../puzzle/schema/puzzle-dto.schema.js"; +import { paginatedQueryResponseSchema } from "../../common/schema/paginated-query-response.schema.js"; +import { paginatedQuerySchema } from "../../common/schema/paginated-query.schema.js"; +import { commentDtoSchema } from "../../comment/schema/comment-dto.schema.js"; +import { createCommentSchema } from "../../comment/schema/create-comment.schema.js"; +import { solutionSchema } from "../../puzzle/schema/solution.schema.js"; +import { messageSchema } from "../../common/schema/message.schema.js"; + +// GET /puzzle query params +export const getPuzzlesQuerySchema = paginatedQuerySchema; +export type GetPuzzlesQuery = z.infer; + +// GET /puzzle response +export const getPuzzlesResponseSchema = paginatedQueryResponseSchema.extend({ + items: z.array(puzzleDtoSchema), +}); +export type GetPuzzlesResponse = z.infer; + +// GET /puzzle/:id response +export const getPuzzleByIdResponseSchema = puzzleDtoSchema; +export type GetPuzzleByIdResponse = z.infer; + +// POST /puzzle/:id/comment request +export const createPuzzleCommentRequestSchema = createCommentSchema; +export type CreatePuzzleCommentRequest = z.infer< + typeof createPuzzleCommentRequestSchema +>; + +// POST /puzzle/:id/comment response +export const createPuzzleCommentResponseSchema = commentDtoSchema; +export type CreatePuzzleCommentResponse = z.infer< + typeof createPuzzleCommentResponseSchema +>; + +// GET /puzzle/:id/comment query params +export const getPuzzleCommentsQuerySchema = paginatedQuerySchema; +export type GetPuzzleCommentsQuery = z.infer< + typeof getPuzzleCommentsQuerySchema +>; + +// GET /puzzle/:id/comment response +export const getPuzzleCommentsResponseSchema = + paginatedQueryResponseSchema.extend({ + items: z.array(commentDtoSchema), + }); +export type GetPuzzleCommentsResponse = z.infer< + typeof getPuzzleCommentsResponseSchema +>; + +// GET /puzzle/:id/solution response +export const getPuzzleSolutionResponseSchema = z.object({ + solutions: z.array(solutionSchema), +}); +export type GetPuzzleSolutionResponse = z.infer< + typeof getPuzzleSolutionResponseSchema +>; + +// DELETE /puzzle/:id response +export const deletePuzzleResponseSchema = z.object({ + message: messageSchema, +}); +export type DeletePuzzleResponse = z.infer; diff --git a/libs/types/src/core/api/schema/puzzle/puzzle-api.schema.ts b/libs/types/src/core/api/schema/puzzle/puzzle-api.schema.ts new file mode 100644 index 00000000..0ae80ca5 --- /dev/null +++ b/libs/types/src/core/api/schema/puzzle/puzzle-api.schema.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import { paginatedQuerySchema } from "../../../common/schema/paginated-query.schema.js"; +import { paginatedQueryResponseSchema } from "../../../common/schema/paginated-query-response.schema.js"; +import { puzzleEntitySchema } from "../../../puzzle/schema/puzzle-entity.schema.js"; +import { objectIdSchema } from "../../../common/schema/object-id.js"; +import { errorResponseSchema } from "../../../common/schema/error-response.schema.js"; + +/** + * GET /puzzle - List puzzles with pagination + */ +export const getPuzzlesRequestSchema = paginatedQuerySchema; + +export const getPuzzlesResponseSchema = paginatedQueryResponseSchema.extend({ + items: z.array(puzzleEntitySchema), +}); + +export type GetPuzzlesRequest = z.infer; +export type GetPuzzlesResponse = z.infer; + +/** + * GET /puzzle/:id - Get single puzzle by ID + */ +export const getPuzzleByIdRequestSchema = z.object({ + id: objectIdSchema, +}); + +export const getPuzzleByIdResponseSchema = + puzzleEntitySchema.or(errorResponseSchema); + +export type GetPuzzleByIdRequest = z.infer; +export type GetPuzzleByIdResponse = z.infer; + +/** + * POST /puzzle - Create new puzzle + */ +export const createPuzzleRequestSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().min(1), + difficulty: z.enum(["easy", "medium", "hard"]), + validators: z + .array( + z.object({ + input: z.string(), + output: z.string(), + }), + ) + .min(1), + tags: z.array(z.string()).optional(), +}); + +export const createPuzzleResponseSchema = + puzzleEntitySchema.or(errorResponseSchema); + +export type CreatePuzzleRequest = z.infer; +export type CreatePuzzleResponse = z.infer; diff --git a/libs/types/src/core/api/schema/submission.schema.ts b/libs/types/src/core/api/schema/submission.schema.ts new file mode 100644 index 00000000..eca03748 --- /dev/null +++ b/libs/types/src/core/api/schema/submission.schema.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; +import { submissionDtoSchema } from "../../submission/schema/submission-dto.schema.js"; + +// GET /submission/:id response +export const getSubmissionByIdResponseSchema = submissionDtoSchema; +export type GetSubmissionByIdResponse = z.infer< + typeof getSubmissionByIdResponseSchema +>; + +// POST /submission/game request +export const submitGameRequestSchema = z.object({ + gameId: z.string(), + code: z.string(), + language: z.string(), +}); +export type SubmitGameRequest = z.infer; + +// POST /submission/game response +export const submitGameResponseSchema = submissionDtoSchema; +export type SubmitGameResponse = z.infer; diff --git a/libs/types/src/core/api/schema/submission/submission-api.schema.ts b/libs/types/src/core/api/schema/submission/submission-api.schema.ts new file mode 100644 index 00000000..58c2f6b3 --- /dev/null +++ b/libs/types/src/core/api/schema/submission/submission-api.schema.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import { objectIdSchema } from "../../../common/schema/object-id.js"; +import { submissionEntitySchema } from "../../../submission/schema/submission-entity.schema.js"; +import { errorResponseSchema } from "../../../common/schema/error-response.schema.js"; + +/** + * POST /submission - Submit code for evaluation + * This replaces the generic DTO approach with specific types + */ +export const submitCodeRequestSchema = z.object({ + puzzleId: objectIdSchema, + programmingLanguageId: objectIdSchema, + code: z.string().min(1, "Code cannot be empty"), + userId: objectIdSchema, // Should come from authenticated session +}); + +export const submitCodeResponseSchema = z + .object({ + // Specific fields returned on submission + submissionId: z.string(), + code: z.string(), + puzzleId: z.string(), + programmingLanguageId: z.string(), + userId: z.string(), + codeLength: z.number().int().positive(), + result: z.object({ + successRate: z.number().min(0).max(1), + passed: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), + total: z.number().int().positive(), + }), + createdAt: z.date().or(z.string()), + }) + .or(errorResponseSchema); + +export type SubmitCodeRequest = z.infer; +export type SubmitCodeResponse = z.infer; + +/** + * GET /submission/:id - Get submission by ID + */ +export const getSubmissionByIdRequestSchema = z.object({ + id: objectIdSchema, +}); + +export const getSubmissionByIdResponseSchema = + submissionEntitySchema.or(errorResponseSchema); + +export type GetSubmissionByIdRequest = z.infer< + typeof getSubmissionByIdRequestSchema +>; +export type GetSubmissionByIdResponse = z.infer< + typeof getSubmissionByIdResponseSchema +>; + +/** + * GET /submission - List user submissions + */ +export const listSubmissionsRequestSchema = z.object({ + userId: objectIdSchema.optional(), + puzzleId: objectIdSchema.optional(), + page: z.number().int().positive().default(1), + pageSize: z.number().int().positive().max(100).default(20), +}); + +export const listSubmissionsResponseSchema = z + .object({ + items: z.array(submissionEntitySchema), + page: z.number().int().positive(), + pageSize: z.number().int().positive(), + totalPages: z.number().int().nonnegative(), + totalItems: z.number().int().nonnegative(), + }) + .or(errorResponseSchema); + +export type ListSubmissionsRequest = z.infer< + typeof listSubmissionsRequestSchema +>; +export type ListSubmissionsResponse = z.infer< + typeof listSubmissionsResponseSchema +>; + +/** + * POST /submission/game - Submit an existing submission to a game + */ +export const submitToGameRequestSchema = z.object({ + gameId: objectIdSchema, + submissionId: objectIdSchema, + userId: objectIdSchema, +}); + +export const submitToGameResponseSchema = z + .object({ + success: z.boolean(), + message: z.string(), + game: z + .object({ + id: objectIdSchema, + status: z.enum(["waiting", "in_progress", "completed"]), + playerCount: z.number().int().nonnegative(), + }) + .optional(), + leaderboardPosition: z.number().int().positive().optional(), + }) + .or(errorResponseSchema); + +export type SubmitToGameRequest = z.infer; +export type SubmitToGameResponse = z.infer; diff --git a/libs/types/src/core/api/schema/submit-code.schema.ts b/libs/types/src/core/api/schema/submit-code.schema.ts new file mode 100644 index 00000000..a18354f1 --- /dev/null +++ b/libs/types/src/core/api/schema/submit-code.schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { submissionEntitySchema } from "../../submission/schema/submission-entity.schema.js"; +import { messageSchema } from "../../common/schema/message.schema.js"; + +// Submit code request - includes puzzle ID, code, language +export const submitCodeRequestSchema = z.object({ + puzzleId: z.string(), + code: z.string(), + language: z.string(), +}); +export type SubmitCodeRequest = z.infer; + +// Submit code response - returns submission details +export const submitCodeResponseSchema = submissionEntitySchema; +export type SubmitCodeResponse = z.infer; diff --git a/libs/types/src/core/api/schema/user.schema.ts b/libs/types/src/core/api/schema/user.schema.ts new file mode 100644 index 00000000..8190e625 --- /dev/null +++ b/libs/types/src/core/api/schema/user.schema.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { userDtoSchema } from "../../user/schema/user-dto.schema.js"; +import { userActivitySchema } from "../../user/schema/user-activity.schema.js"; +import { paginatedQueryResponseSchema } from "../../common/schema/paginated-query-response.schema.js"; +import { paginatedQuerySchema } from "../../common/schema/paginated-query.schema.js"; +import { puzzleDtoSchema } from "../../puzzle/schema/puzzle-dto.schema.js"; + +// GET /user/:username response +export const getUserByUsernameResponseSchema = userDtoSchema; +export type GetUserByUsernameResponse = z.infer< + typeof getUserByUsernameResponseSchema +>; + +// GET /user/:username/puzzle query +export const getUserPuzzlesQuerySchema = paginatedQuerySchema; +export type GetUserPuzzlesQuery = z.infer; + +// GET /user/:username/puzzle response +export const getUserPuzzlesResponseSchema = paginatedQueryResponseSchema.extend( + { + items: z.array(puzzleDtoSchema), + }, +); +export type GetUserPuzzlesResponse = z.infer< + typeof getUserPuzzlesResponseSchema +>; + +// GET /user/:username/activity response +export const getUserActivityResponseSchema = z.array(userActivitySchema); +export type GetUserActivityResponse = z.infer< + typeof getUserActivityResponseSchema +>; + +// GET /user/:username/isAvailable response +export const usernameIsAvailableResponseSchema = z.object({ + available: z.boolean(), +}); +export type UsernameIsAvailableResponse = z.infer< + typeof usernameIsAvailableResponseSchema +>; diff --git a/libs/types/src/core/api/schema/user/user-api.schema.ts b/libs/types/src/core/api/schema/user/user-api.schema.ts new file mode 100644 index 00000000..79fe6768 --- /dev/null +++ b/libs/types/src/core/api/schema/user/user-api.schema.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; +import { userDtoSchema } from "../../../user/schema/user-dto.schema.js"; +import { errorResponseSchema } from "../../../common/schema/error-response.schema.js"; + +/** + * GET /user/me - Get current user information + */ +export const getCurrentUserResponseSchema = + userDtoSchema.or(errorResponseSchema); + +export type GetCurrentUserResponse = z.infer< + typeof getCurrentUserResponseSchema +>; diff --git a/libs/types/src/core/common/config/backend-urls.ts b/libs/types/src/core/common/config/backend-urls.ts index 740f8c10..ed8aeb0c 100644 --- a/libs/types/src/core/common/config/backend-urls.ts +++ b/libs/types/src/core/common/config/backend-urls.ts @@ -43,6 +43,13 @@ export const backendUrls = { submissionById: (id: string) => `${baseRoute}/submission/${id}`, SUBMISSION_GAME: `${baseRoute}/submission/game`, + // leaderboard routes + LEADERBOARD: `${baseRoute}/leaderboard`, + leaderboardByGameMode: (gameMode: string) => + `${baseRoute}/leaderboard/${gameMode}`, + leaderboardUserStats: (userId: string) => + `${baseRoute}/leaderboard/user/${userId}`, + // moderation routes MODERATION_REVIEW: `${baseRoute}/moderation/review`, moderationPuzzleApprove: (id: string) => diff --git a/libs/frontend/src/lib/config/test-ids.ts b/libs/types/src/core/common/config/test-ids.ts similarity index 96% rename from libs/frontend/src/lib/config/test-ids.ts rename to libs/types/src/core/common/config/test-ids.ts index eb8e30db..6bcd8df2 100644 --- a/libs/frontend/src/lib/config/test-ids.ts +++ b/libs/types/src/core/common/config/test-ids.ts @@ -1,4 +1,4 @@ -import type { ValueOf } from "types"; +import type { ValueOf } from "../types/value-of.js"; export const DATA_TESTID_STRING = "data-testid"; @@ -52,6 +52,7 @@ export const testIds = { // error component ERROR_COMPONENT_ANCHOR_HOMEPAGE: "error-component-anchor-homepage", + ERROR_COMPONENT_BUTTON_RELOAD: "error-component-button-reload", // login form LOGIN_FORM_BUTTON_LOGIN: "login-form-button-login", @@ -77,6 +78,8 @@ export const testIds = { MULTIPLAYER_PAGE_BUTTON_JOIN_BY_INVITE: "multiplayer-page-button-join-by-invite", MULTIPLAYER_PAGE_BUTTON_COPY_INVITE: "multiplayer-page-button-copy-invite", + MULTIPLAYER_PAGE_BUTTON_TOGGLE_INVITE_CODE: + "multiplayer-page-button-toggle-invite-code", // custom game dialog CUSTOM_GAME_DIALOG_BUTTON_CANCEL: "custom-game-dialog-button-cancel", @@ -170,7 +173,7 @@ export const testIds = { // user hover card component USER_HOVER_CARD_COMPONENT_ANCHOR_USER_PROFILE: - "user-hover-card-component-anchor-user-profile" + "user-hover-card-component-anchor-user-profile", } as const; type DataTestIdMap = typeof testIds; diff --git a/libs/types/src/core/common/schema/error-response.schema.ts b/libs/types/src/core/common/schema/error-response.schema.ts index 2b06d0b3..0d0192fc 100644 --- a/libs/types/src/core/common/schema/error-response.schema.ts +++ b/libs/types/src/core/common/schema/error-response.schema.ts @@ -3,6 +3,7 @@ import { z } from "zod"; export const errorResponseSchema = z.object({ message: z.string(), error: z.string(), + details: z.array(z.any()).optional(), // Validation error details }); export type ErrorResponse = z.infer; diff --git a/libs/types/src/core/game/enum/game-mode-enum.ts b/libs/types/src/core/game/enum/game-mode-enum.ts index 032e58b4..6f66645b 100644 --- a/libs/types/src/core/game/enum/game-mode-enum.ts +++ b/libs/types/src/core/game/enum/game-mode-enum.ts @@ -1,5 +1,20 @@ export const gameModeEnum = { - FASTEST: "fastest", - SHORTEST: "shortest", - RANDOM: "random", + // Core modes + FASTEST: "fastest", // Complete the task in the shortest time + SHORTEST: "shortest", // Write the least amount of code (characters) + + // Challenge modes + BACKWARDS: "backwards", // Work from output to input + HARDCORE: "hardcore", // One attempt only, no tries + DEBUG: "debug", // Fix broken code instead of writing from scratch + TYPERACER: "typeracer", // Copy code perfectly, fastest wins + + // Advanced modes + EFFICIENCY: "efficiency", // Focus on writing the most efficient code (computationally or memory) + INCREMENTAL: "incremental", // Requirements added each minute + + // Special modes + RANDOM: "random", // Randomized game mode } as const; + +// dont fucking add random game modes that don't belong diff --git a/libs/types/src/core/game/enum/game-tournament-enum.ts b/libs/types/src/core/game/enum/game-tournament-enum.ts new file mode 100644 index 00000000..3368259f --- /dev/null +++ b/libs/types/src/core/game/enum/game-tournament-enum.ts @@ -0,0 +1,5 @@ +export const gameTournamentStyleEnum = { + BEST_OF_THREE: "best_of_three", // Multiple rounds, win 2 out of 3 + ELIMINATION: "elimination", // Tournament bracket style + BATTLE_ROYALE: "battle_royale", // Many players, last one standing +} as const; diff --git a/libs/types/src/core/game/schema/game-entity.schema.ts b/libs/types/src/core/game/schema/game-entity.schema.ts index da7298fa..06800356 100644 --- a/libs/types/src/core/game/schema/game-entity.schema.ts +++ b/libs/types/src/core/game/schema/game-entity.schema.ts @@ -3,7 +3,7 @@ import { gameOptionsSchema } from "./game-options.schema.js"; import { userDtoSchema } from "../../user/schema/user-dto.schema.js"; import { puzzleDtoSchema } from "../../puzzle/schema/puzzle-dto.schema.js"; import { acceptedDateSchema } from "../../common/schema/accepted-date.js"; -import { submissionDtoSchema } from "../../submission/schema/submission-dto.schema.js"; +import { gameSubmissionSchema } from "./game-submission.schema.js"; import { objectIdSchema } from "../../common/schema/object-id.js"; export const gameEntitySchema = z.object({ @@ -15,7 +15,7 @@ export const gameEntitySchema = z.object({ options: gameOptionsSchema, createdAt: acceptedDateSchema, playerSubmissions: z - .array(objectIdSchema.or(submissionDtoSchema)) + .array(objectIdSchema.or(gameSubmissionSchema)) .prefault([]), }); export type GameEntity = z.infer; diff --git a/libs/types/src/core/game/schema/game-submission.schema.ts b/libs/types/src/core/game/schema/game-submission.schema.ts new file mode 100644 index 00000000..d6a33ea3 --- /dev/null +++ b/libs/types/src/core/game/schema/game-submission.schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { submissionDtoSchema } from "../../submission/schema/submission-dto.schema.js"; + +/** + * Extended submission schema specifically for game submissions. + * Includes computed fields like codeLength that are added by the backend. + */ +export const gameSubmissionSchema = submissionDtoSchema.extend({ + codeLength: z.number().int().nonnegative().optional(), + timeSpent: z.number().nonnegative().optional(), // Time spent in seconds +}); + +export type GameSubmission = z.infer; + +export function isGameSubmission(data: unknown): data is GameSubmission { + return gameSubmissionSchema.safeParse(data).success; +} diff --git a/libs/types/src/core/leaderboard/schema/leaderboard-entry.schema.ts b/libs/types/src/core/leaderboard/schema/leaderboard-entry.schema.ts new file mode 100644 index 00000000..bbcf21b8 --- /dev/null +++ b/libs/types/src/core/leaderboard/schema/leaderboard-entry.schema.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { objectIdSchema } from "../../common/schema/object-id.js"; +import { glickoRatingSchema } from "./user-metrics.schema.js"; + +/** + * Single entry in a leaderboard + */ +export const leaderboardEntrySchema = z.object({ + rank: z.number().int().positive(), + userId: objectIdSchema, + username: z.string(), + rating: z.number(), + glicko: glickoRatingSchema, + gamesPlayed: z.number().int().nonnegative(), + gamesWon: z.number().int().nonnegative(), + winRate: z.number().min(0).max(1), + bestScore: z.number().nonnegative(), + averageScore: z.number().nonnegative(), +}); + +export type LeaderboardEntry = z.infer; diff --git a/libs/types/src/core/leaderboard/schema/user-metrics.schema.ts b/libs/types/src/core/leaderboard/schema/user-metrics.schema.ts new file mode 100644 index 00000000..959af68e --- /dev/null +++ b/libs/types/src/core/leaderboard/schema/user-metrics.schema.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; +import { objectIdSchema } from "../../common/schema/object-id.js"; +import { gameModeSchema } from "../../game/schema/mode.schema.js"; +import { acceptedDateSchema } from "../../common/schema/accepted-date.js"; + +/** + * Glicko-2 rating components + */ +export const glickoRatingSchema = z.object({ + rating: z.number().default(1500), // Base rating + rd: z.number().default(350), // Rating deviation + volatility: z.number().default(0.06), // Volatility + lastUpdated: acceptedDateSchema.default(() => new Date()), +}); + +export type GlickoRating = z.infer; + +/** + * User metrics per game mode + */ +export const gameModeMetricsSchema = z.object({ + gamesPlayed: z.number().int().nonnegative().default(0), + gamesWon: z.number().int().nonnegative().default(0), + bestScore: z.number().nonnegative().default(0), + averageScore: z.number().nonnegative().default(0), + totalScore: z.number().nonnegative().default(0), + glickoRating: glickoRatingSchema, + rank: z.number().int().positive().optional(), // Position in leaderboard + lastGameDate: acceptedDateSchema.optional(), +}); + +export type GameModeMetrics = z.infer; + +/** + * User metrics entity - stores aggregated performance data + */ +export const userMetricsEntitySchema = z.object({ + _id: objectIdSchema.optional(), + userId: objectIdSchema, + + // Metrics per game mode + fastest: gameModeMetricsSchema.optional(), + shortest: gameModeMetricsSchema.optional(), + backwards: gameModeMetricsSchema.optional(), + hardcore: gameModeMetricsSchema.optional(), + debug: gameModeMetricsSchema.optional(), + typeracer: gameModeMetricsSchema.optional(), + efficiency: gameModeMetricsSchema.optional(), + incremental: gameModeMetricsSchema.optional(), + random: gameModeMetricsSchema.optional(), + + // Overall stats + totalGamesPlayed: z.number().int().nonnegative().default(0), + totalGamesWon: z.number().int().nonnegative().default(0), + + // Tracking for incremental updates + lastProcessedGameDate: acceptedDateSchema.default(() => new Date(0)), // Epoch + lastCalculationDate: acceptedDateSchema.default(() => new Date()), + + createdAt: acceptedDateSchema.default(() => new Date()), + updatedAt: acceptedDateSchema.default(() => new Date()), +}); + +export type UserMetricsEntity = z.infer; diff --git a/libs/types/src/core/submission/schema/submission-entity.schema.ts b/libs/types/src/core/submission/schema/submission-entity.schema.ts index ef9f58b1..47147ebb 100644 --- a/libs/types/src/core/submission/schema/submission-entity.schema.ts +++ b/libs/types/src/core/submission/schema/submission-entity.schema.ts @@ -8,11 +8,13 @@ import { puzzleResultInformationSchema } from "../../piston/schema/puzzle-result export const submissionEntitySchema = z.object({ code: z.string().optional(), - codeLength: z.number().optional(), + // codelenght shouldn't be added here, since it should be derived from the code itself + // codelength should also be returned by a more specific dto/schema instead of this one, this one is too generic programmingLanguage: objectIdSchema.or(programmingLanguageDtoSchema), createdAt: acceptedDateSchema.prefault(() => new Date()), puzzle: objectIdSchema.or(puzzleDtoSchema), result: puzzleResultInformationSchema, user: objectIdSchema.or(userDtoSchema), }); + export type SubmissionEntity = z.infer; diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 086dca1d..dedd7960 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -37,6 +37,7 @@ export * from "./core/common/config/cookie.js"; export * from "./core/common/config/environment.js"; export * from "./core/common/config/default-values-query-params.js"; export * from "./core/common/config/frontend-urls.js"; +export * from "./core/common/config/test-ids.js"; export * from "./core/common/config/web-socket-urls.js"; export * from "./core/common/enum/websocket-close-codes.js"; export * from "./core/common/enum/http-response-codes.js"; @@ -60,6 +61,7 @@ export * from "./core/game/enum/game-visibility-enum.js"; export * from "./core/game/schema/game-dto.schema.js"; export * from "./core/game/schema/game-entity.schema.js"; export * from "./core/game/schema/game-user-info.schema.js"; +export * from "./core/game/schema/game-submission.schema.js"; export * from "./core/game/schema/waiting-room-request.schema.js"; export * from "./core/game/schema/waiting-room-response.schema.js"; export * from "./core/game/schema/game-request.schema.js"; @@ -68,6 +70,10 @@ export * from "./core/game/schema/mode.schema.js"; export * from "./core/game/schema/game-options.schema.js"; export * from "./core/game/schema/visibility.schema.js"; +// leaderboard +export * from "./core/leaderboard/schema/user-metrics.schema.js"; +export * from "./core/leaderboard/schema/leaderboard-entry.schema.js"; + // moderation export * from "./core/moderation/config/report-config.js"; export * from "./core/moderation/config/ban-config.js"; @@ -143,6 +149,29 @@ export * from "./core/user/schema/user-entity.schema.js"; export * from "./core/user/schema/user-vote-entity.schema.js"; export * from "./core/user/schema/user-profile.schema.js"; +// api - request/response types for all endpoints +export * from "./core/api/schema/execute-code.schema.js"; +export * from "./core/api/schema/submit-code.schema.js"; +export * from "./core/api/schema/programming-language.schema.js"; +export * from "./core/api/schema/user.schema.js"; +export * from "./core/api/schema/comment.schema.js"; +export * from "./core/api/schema/submission.schema.js"; +export * from "./core/api/schema/puzzle.schema.js"; +export * from "./core/api/schema/account.schema.js"; +export * from "./core/api/schema/moderation.schema.js"; + +// New specific API endpoint types (v2 - more specific, less generic) +export * as AuthAPI from "./core/api/schema/auth/login.schema.js"; +export * as RegisterAPI from "./core/api/schema/auth/register.schema.js"; +export * as LogoutAPI from "./core/api/schema/auth/logout.schema.js"; +export * as PuzzleAPI from "./core/api/schema/puzzle/puzzle-api.schema.js"; +export * as UserAPI from "./core/api/schema/user/user-api.schema.js"; +export * as ProgrammingLanguageAPI from "./core/api/schema/programming-language/programming-language-api.schema.js"; +export * as SubmissionAPI from "./core/api/schema/submission/submission-api.schema.js"; +export * as ExecuteAPI from "./core/api/schema/execute/execute-api.schema.js"; +export * as GameAPI from "./core/api/schema/game/game-api.schema.js"; +export * as LeaderboardAPI from "./core/api/schema/leaderboard/leaderboard-api.schema.js"; + // utils - constants export * from "./utils/constants/http-methods.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d535280..72149107 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@fastify/websocket': specifier: ^10.0.1 version: 10.0.1 + '@types/node-cron': + specifier: ^3.0.11 + version: 3.0.11 bcryptjs: specifier: ^3.0.2 version: 3.0.2 @@ -50,6 +53,9 @@ importers: mongoose: specifier: ^8.5.1 version: 8.19.2 + node-cron: + specifier: ^4.2.1 + version: 4.2.1 zod: specifier: ^4.1.12 version: 4.1.12 @@ -106,6 +112,22 @@ importers: specifier: ^3.0.8 version: 3.2.4(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + libs/e2e: + dependencies: + types: + specifier: workspace:* + version: link:../types + devDependencies: + '@playwright/test': + specifier: ^1.48.2 + version: 1.56.1 + '@types/node': + specifier: ^22.10.2 + version: 22.18.13 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + libs/frontend: dependencies: '@codemirror/autocomplete': @@ -1166,6 +1188,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.56.1': + resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1574,6 +1601,12 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-cron@3.0.11': + resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} + + '@types/node@22.18.13': + resolution: {integrity: sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A==} + '@types/node@24.9.1': resolution: {integrity: sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==} @@ -2229,6 +2262,11 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2633,6 +2671,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-cron@4.2.1: + resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} + engines: {node: '>=6.0.0'} + normalize-url@8.1.0: resolution: {integrity: sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w==} engines: {node: '>=14.16'} @@ -2723,6 +2765,16 @@ packages: typescript: optional: true + playwright-core@1.56.1: + resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.56.1: + resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} + engines: {node: '>=18'} + hasBin: true + postcss-load-config@3.1.4: resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} engines: {node: '>= 10'} @@ -3227,6 +3279,9 @@ packages: ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -4178,6 +4233,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.56.1': + dependencies: + playwright: 1.56.1 + '@polka/url@1.0.0-next.29': {} '@poppinss/colors@4.1.5': @@ -4534,6 +4593,12 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node-cron@3.0.11': {} + + '@types/node@22.18.13': + dependencies: + undici-types: 6.21.0 + '@types/node@24.9.1': dependencies: undici-types: 7.16.0 @@ -5319,6 +5384,9 @@ snapshots: forwarded@0.2.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -5693,6 +5761,8 @@ snapshots: natural-compare@1.4.0: {} + node-cron@4.2.1: {} + normalize-url@8.1.0: optional: true @@ -5789,6 +5859,14 @@ snapshots: optionalDependencies: typescript: 5.9.3 + playwright-core@1.56.1: {} + + playwright@1.56.1: + dependencies: + playwright-core: 1.56.1 + optionalDependencies: + fsevents: 2.3.2 + postcss-load-config@3.1.4(postcss@8.5.6): dependencies: lilconfig: 2.1.0 @@ -6257,6 +6335,8 @@ snapshots: ufo@1.6.1: {} + undici-types@6.21.0: {} + undici-types@7.16.0: {} undici@7.14.0: {} From 49b39bfe16ee4e0da95449662185c59a64ae95a7 Mon Sep 17 00:00:00 2001 From: J Mad <36441093+reeveng@users.noreply.github.com> Date: Fri, 31 Oct 2025 21:32:18 +0100 Subject: [PATCH 4/4] fix: leaderboards and multiplayer --- libs/backend/src/router.ts | 14 +- .../src/routes/game/leaderboard/index.ts | 2 +- .../routes/leaderboard/[gameMode]/index.ts | 76 +++++ libs/backend/src/routes/leaderboard/index.ts | 119 ------- .../routes/leaderboard/recalculate/index.ts | 32 ++ .../src/routes/leaderboard/user/[id]/index.ts | 44 +++ .../src/services/leaderboard.service.ts | 47 ++- libs/backend/src/services/puzzle.service.ts | 15 +- libs/backend/src/websocket/game/game-setup.ts | 18 +- .../src/websocket/game/on-connection.ts | 22 +- .../waiting-room/waiting-room-setup.ts | 2 +- libs/frontend/components.json | 2 +- libs/frontend/package.json | 6 +- .../lib/components/typography/markdown.svelte | 2 +- .../button-group-separator.svelte | 23 ++ .../ui/button-group/button-group-text.svelte | 30 ++ .../ui/button-group/button-group.svelte | 48 +++ .../lib/components/ui/button-group/index.ts | 13 + .../components/ui/separator/separator.svelte | 11 +- .../login/components/login-form.svelte | 1 + .../register/components/register-form.svelte | 1 + .../game/components/codemirror.svelte | 30 +- .../(authenticated)/logout/+page.server.ts | 5 +- .../puzzles/[id]/edit/+page.server.ts | 5 +- .../puzzles/create/+page.server.ts | 21 +- .../login/+page.server.ts | 25 +- .../register/+page.server.ts | 3 +- .../src/routes/leaderboard/+page.svelte | 302 ------------------ .../src/routes/leaderboards/+page.svelte | 282 ++++++++++++++++ .../src/routes/moderation/+page.server.ts | 26 +- .../src/core/common/config/backend-urls.ts | 6 +- .../src/core/common/config/error-messages.ts | 40 +++ .../src/core/common/config/pagination.ts | 11 + libs/types/src/core/common/config/test-ids.ts | 20 ++ .../types/src/core/game/schema/mode.schema.ts | 10 +- .../leaderboard/config/leaderboard-config.ts | 24 ++ .../core/puzzle/schema/puzzle-dto.schema.ts | 1 - libs/types/src/index.ts | 3 + .../utils/functions/get-user-id-from-user.ts | 2 +- pnpm-lock.yaml | 101 +++--- 40 files changed, 906 insertions(+), 539 deletions(-) create mode 100644 libs/backend/src/routes/leaderboard/[gameMode]/index.ts delete mode 100644 libs/backend/src/routes/leaderboard/index.ts create mode 100644 libs/backend/src/routes/leaderboard/recalculate/index.ts create mode 100644 libs/backend/src/routes/leaderboard/user/[id]/index.ts create mode 100644 libs/frontend/src/lib/components/ui/button-group/button-group-separator.svelte create mode 100644 libs/frontend/src/lib/components/ui/button-group/button-group-text.svelte create mode 100644 libs/frontend/src/lib/components/ui/button-group/button-group.svelte create mode 100644 libs/frontend/src/lib/components/ui/button-group/index.ts delete mode 100644 libs/frontend/src/routes/leaderboard/+page.svelte create mode 100644 libs/frontend/src/routes/leaderboards/+page.svelte create mode 100644 libs/types/src/core/common/config/error-messages.ts create mode 100644 libs/types/src/core/common/config/pagination.ts create mode 100644 libs/types/src/core/leaderboard/config/leaderboard-config.ts diff --git a/libs/backend/src/router.ts b/libs/backend/src/router.ts index c8a0e694..29b4a3c2 100644 --- a/libs/backend/src/router.ts +++ b/libs/backend/src/router.ts @@ -34,7 +34,9 @@ import moderationUserByIdBanPermanentRoutes from "./routes/moderation/user/[id]/ import moderationUserByIdBanTemporaryRoutes from "./routes/moderation/user/[id]/ban/temporary/index.js"; import programmingLanguageRoutes from "./routes/programming-language/index.js"; import programmingLanguageByIdRoutes from "./routes/programming-language/[id]/index.js"; -import leaderboardRoutes from "./routes/leaderboard/index.js"; +import leaderboardByGameModeRoutes from "./routes/leaderboard/[gameMode]/index.js"; +import leaderboardRecalculateRoutes from "./routes/leaderboard/recalculate/index.js"; +import leaderboardUserByIdRoutes from "./routes/leaderboard/user/[id]/index.js"; export default async function router(fastify: FastifyInstance) { fastify.register(indexRoutes, { prefix: backendUrls.ROOT }); @@ -123,7 +125,13 @@ export default async function router(fastify: FastifyInstance) { banTypeEnum.TEMPORARY ) }); - fastify.register(leaderboardRoutes, { - prefix: backendUrls.LEADERBOARD + fastify.register(leaderboardByGameModeRoutes, { + prefix: backendUrls.leaderboardByGameMode(backendParams.GAME_MODE) + }); + fastify.register(leaderboardRecalculateRoutes, { + prefix: backendUrls.LEADERBOARD_RECALCULATE + }); + fastify.register(leaderboardUserByIdRoutes, { + prefix: backendUrls.leaderboardUserById(backendParams.ID) }); } diff --git a/libs/backend/src/routes/game/leaderboard/index.ts b/libs/backend/src/routes/game/leaderboard/index.ts index 040d3d0e..b1bec370 100644 --- a/libs/backend/src/routes/game/leaderboard/index.ts +++ b/libs/backend/src/routes/game/leaderboard/index.ts @@ -40,7 +40,7 @@ export default async function gameLeaderboardRoutes(fastify: FastifyInstance) { // Build leaderboard using game mode service const leaderboard = gameModeService.getGameLeaderboard( game, - submissions as any + submissions ); return reply.status(httpResponseCodes.SUCCESSFUL.OK).send({ diff --git a/libs/backend/src/routes/leaderboard/[gameMode]/index.ts b/libs/backend/src/routes/leaderboard/[gameMode]/index.ts new file mode 100644 index 00000000..e62e8436 --- /dev/null +++ b/libs/backend/src/routes/leaderboard/[gameMode]/index.ts @@ -0,0 +1,76 @@ +import { genericReturnMessages } from "@/config/generic-return-messages.js"; +import { leaderboardService } from "@/services/leaderboard.service.js"; +import { FastifyInstance } from "fastify"; +import { + DEFAULT_PAGE, + ERROR_MESSAGES, + httpResponseCodes, + isGameMode, + LeaderboardAPI, + PAGINATION_CONFIG +} from "types"; + +const LEADERBOARD = "Leaderboard"; + +export default async function leaderboardByGameModeRoutes( + fastify: FastifyInstance +) { + fastify.get<{ + Params: { gameMode: string }; + Querystring: { page?: string; pageSize?: string }; + }>("/", async (request, reply) => { + const { gameMode } = request.params; + + const page = Math.max( + parseInt(request.query.page || String(DEFAULT_PAGE), 10), + PAGINATION_CONFIG.MIN_PAGE + ); + const pageSize = Math.min( + Math.max( + parseInt( + request.query.pageSize || + String(PAGINATION_CONFIG.DEFAULT_LIMIT_LEADERBOARD), + 10 + ), + PAGINATION_CONFIG.MIN_LIMIT + ), + PAGINATION_CONFIG.MAX_LIMIT + ); + + if (!isGameMode(gameMode)) { + return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({ + error: `Game mode ${genericReturnMessages[httpResponseCodes.CLIENT_ERROR.BAD_REQUEST].IS_INVALID}.` + }); + } + + try { + const result = await leaderboardService.getLeaderboard( + gameMode, + page, + pageSize + ); + + const response: LeaderboardAPI.GetLeaderboardResponse = { + gameMode: gameMode, + entries: result.entries, + page, + pageSize, + totalEntries: result.total, + totalPages: Math.ceil(result.total / pageSize), + lastUpdated: result.lastUpdated.toISOString() + }; + + return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(response); + } catch (error) { + request.log.error( + { err: error }, + `${ERROR_MESSAGES.FETCH.FAILED_TO_FETCH} leaderboard` + ); + return reply + .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) + .send({ + error: `${LEADERBOARD} ${genericReturnMessages[httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR].WENT_WRONG}` + }); + } + }); +} diff --git a/libs/backend/src/routes/leaderboard/index.ts b/libs/backend/src/routes/leaderboard/index.ts deleted file mode 100644 index 5fb18456..00000000 --- a/libs/backend/src/routes/leaderboard/index.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { httpResponseCodes, LeaderboardAPI, gameModeEnum } from "types"; -import { leaderboardService } from "../../services/leaderboard.service.js"; -import User from "../../models/user/user.js"; - -export default async function leaderboardRoutes(fastify: FastifyInstance) { - /** - * GET /leaderboard/:gameMode - Get leaderboard for a specific game mode - */ - fastify.get<{ - Params: { gameMode: string }; - Querystring: { page?: string; pageSize?: string }; - }>("/:gameMode", async (request, reply) => { - const { gameMode } = request.params; - const page = parseInt(request.query.page || "1", 10); - const pageSize = Math.min( - parseInt(request.query.pageSize || "50", 10), - 100 - ); - - // Validate game mode - const validModes = Object.values(gameModeEnum); - if (!validModes.includes(gameMode as any)) { - return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({ - error: `Invalid game mode. Valid modes: ${validModes.join(", ")}` - }); - } - - try { - const result = await leaderboardService.getLeaderboard( - gameMode as any, - page, - pageSize - ); - - const response: LeaderboardAPI.GetLeaderboardResponse = { - gameMode: gameMode as any, - entries: result.entries, - page, - pageSize, - totalEntries: result.total, - totalPages: Math.ceil(result.total / pageSize), - lastUpdated: result.lastUpdated.toISOString() - }; - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(response); - } catch (error) { - request.log.error({ err: error }, "Error fetching leaderboard"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ - error: "Failed to fetch leaderboard" - }); - } - }); - - /** - * GET /leaderboard/user/:userId - Get user's rankings across all game modes - */ - fastify.get<{ Params: { userId: string } }>( - "/user/:userId", - async (request, reply) => { - const { userId } = request.params; - - try { - // Verify user exists - const user = await User.findById(userId); - if (!user) { - return reply.status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND).send({ - error: `User with id ${userId} not found` - }); - } - - const rankings = await leaderboardService.getUserRankings(userId); - - const response: LeaderboardAPI.GetUserLeaderboardStatsResponse = { - userId, - username: user.username, - rankings - }; - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(response); - } catch (error) { - request.log.error({ err: error }, "Error fetching user rankings"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ - error: "Failed to fetch user rankings" - }); - } - } - ); - - /** - * POST /leaderboard/recalculate - Manually trigger leaderboard recalculation - * (Admin only - should add authentication middleware in production) - */ - fastify.post("/recalculate", async (request, reply) => { - try { - request.log.info("Manual leaderboard recalculation triggered"); - - const results = await leaderboardService.recalculateAllLeaderboards(); - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send({ - success: true, - message: "Leaderboard recalculation completed", - processedGames: results.processedGames, - totalProcessed: results.totalProcessed - }); - } catch (error) { - request.log.error({ err: error }, "Error during manual recalculation"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ - error: "Failed to recalculate leaderboards" - }); - } - }); -} diff --git a/libs/backend/src/routes/leaderboard/recalculate/index.ts b/libs/backend/src/routes/leaderboard/recalculate/index.ts new file mode 100644 index 00000000..2a62816d --- /dev/null +++ b/libs/backend/src/routes/leaderboard/recalculate/index.ts @@ -0,0 +1,32 @@ +import { genericReturnMessages } from "@/config/generic-return-messages.js"; +import { leaderboardService } from "@/services/leaderboard.service.js"; +import { FastifyInstance } from "fastify"; +import { httpResponseCodes } from "types"; + +export default async function leaderboardRecalculateRoutes( + fastify: FastifyInstance +) { + fastify.post("/", async (request, reply) => { + const { OK } = httpResponseCodes.SUCCESSFUL; + const { INTERNAL_SERVER_ERROR } = httpResponseCodes.SERVER_ERROR; + + try { + request.log.info("Manual leaderboard recalculation triggered"); + + const results = await leaderboardService.recalculateAllLeaderboards(); + + return reply.status(OK).send({ + success: true, + message: "Leaderboard recalculation completed", + processedGames: results.processedGames, + totalProcessed: results.totalProcessed + }); + } catch (error) { + request.log.error({ err: error }, "Error during manual recalculation"); + const { WENT_WRONG } = genericReturnMessages[INTERNAL_SERVER_ERROR]; + return reply.status(INTERNAL_SERVER_ERROR).send({ + error: `Leaderboard recalculation ${WENT_WRONG}` + }); + } + }); +} diff --git a/libs/backend/src/routes/leaderboard/user/[id]/index.ts b/libs/backend/src/routes/leaderboard/user/[id]/index.ts new file mode 100644 index 00000000..35325aef --- /dev/null +++ b/libs/backend/src/routes/leaderboard/user/[id]/index.ts @@ -0,0 +1,44 @@ +import { genericReturnMessages } from "@/config/generic-return-messages.js"; +import User from "@/models/user/user.js"; +import { leaderboardService } from "@/services/leaderboard.service.js"; +import { FastifyInstance } from "fastify"; +import { ERROR_MESSAGES, httpResponseCodes, LeaderboardAPI } from "types"; + +const USER = "User"; + +export default async function leaderboardUserByIdRoutes( + fastify: FastifyInstance +) { + fastify.get<{ Params: { id: string } }>("/", async (request, reply) => { + const { id } = request.params; + + try { + const user = await User.findById(id); + if (!user) { + return reply.status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND).send({ + error: `${USER} ${genericReturnMessages[httpResponseCodes.CLIENT_ERROR.NOT_FOUND].COULD_NOT_BE_FOUND}` + }); + } + + const rankings = await leaderboardService.getUserRankings(id); + + const response: LeaderboardAPI.GetUserLeaderboardStatsResponse = { + userId: id, + username: user.username, + rankings + }; + + return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(response); + } catch (error) { + request.log.error( + { err: error }, + `${ERROR_MESSAGES.FETCH.FAILED_TO_FETCH} user rankings` + ); + return reply + .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) + .send({ + error: `User rankings ${genericReturnMessages[httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR]}` + }); + } + }); +} diff --git a/libs/backend/src/services/leaderboard.service.ts b/libs/backend/src/services/leaderboard.service.ts index d2676901..51be09d9 100644 --- a/libs/backend/src/services/leaderboard.service.ts +++ b/libs/backend/src/services/leaderboard.service.ts @@ -12,6 +12,19 @@ import { type GlickoRating } from "../utils/rating/glicko.js"; +/** + * Helper to ensure glicko rating has Date objects instead of strings + */ +function normalizeGlickoRating(rating: any): GlickoRating { + return { + ...rating, + lastUpdated: + rating.lastUpdated instanceof Date + ? rating.lastUpdated + : new Date(rating.lastUpdated) + }; +} + /** * Service for calculating and managing leaderboards * Processes games incrementally to update player ratings and rankings @@ -47,7 +60,7 @@ export class LeaderboardService { const modeMetrics = metric[mode as keyof UserMetricsDocument]; if (!modeMetrics || typeof modeMetrics !== "object") return earliest; - const lastGameDate = (modeMetrics as any).lastGameDate; + const lastGameDate = modeMetrics.lastGameDate; if (!lastGameDate) return earliest; return lastGameDate < earliest ? lastGameDate : earliest; @@ -100,10 +113,7 @@ export class LeaderboardService { } // Get leaderboard to determine winner and rankings - const leaderboard = gameModeService.getGameLeaderboard( - game, - submissions as any - ); + const leaderboard = gameModeService.getGameLeaderboard(game, submissions); if (leaderboard.length === 0) return; @@ -120,7 +130,7 @@ export class LeaderboardService { // Initialize game mode metrics if not exists if (!metrics[mode]) { - (metrics as any)[mode] = { + metrics[mode] = { gamesPlayed: 0, gamesWon: 0, bestScore: 0, @@ -130,7 +140,7 @@ export class LeaderboardService { }; } - const modeMetrics = (metrics as any)[mode]; + const modeMetrics = metrics[mode]; const isWinner = entry.userId === winner.userId; // Update game count and scores @@ -148,13 +158,16 @@ export class LeaderboardService { } // Update Glicko rating based on outcomes against all opponents - const currentRating: GlickoRating = modeMetrics.glickoRating; + const currentRating: GlickoRating = normalizeGlickoRating( + modeMetrics.glickoRating + ); const games = leaderboard .filter((opp) => opp.userId !== userId) .map((opponent) => { - const opponentMetrics = (metrics as any)[mode]; - const opponentRating: GlickoRating = - opponentMetrics?.glickoRating || getDefaultRating(); + const opponentMetrics = metrics[mode]; + const opponentRating: GlickoRating = opponentMetrics?.glickoRating + ? normalizeGlickoRating(opponentMetrics.glickoRating) + : getDefaultRating(); // Determine if player beat this opponent const playerWon = entry.rank < opponent.rank; @@ -206,7 +219,7 @@ export class LeaderboardService { // Update ranks for (let i = 0; i < sortedMetrics.length; i++) { const metrics = sortedMetrics[i]; - const modeMetrics = (metrics as any)[mode]; + const modeMetrics = metrics[mode]; if (modeMetrics) { modeMetrics.rank = i + 1; @@ -276,15 +289,19 @@ export class LeaderboardService { const entries = await Promise.all( metrics.map(async (metric) => { - const modeMetrics = (metric as any)[mode]; + const modeMetrics = metric[mode]; const user = await User.findById(metric.userId); + if (!modeMetrics) { + throw new Error("Mode metrics not found for user"); + } + return { rank: modeMetrics.rank || 0, userId: metric.userId.toString(), username: user?.username || "Unknown", rating: modeMetrics.glickoRating.rating, - glicko: modeMetrics.glickoRating, + glicko: normalizeGlickoRating(modeMetrics.glickoRating), gamesPlayed: modeMetrics.gamesPlayed, gamesWon: modeMetrics.gamesWon, winRate: @@ -332,7 +349,7 @@ export class LeaderboardService { const rankings: any = {}; for (const mode of modes) { - const modeMetrics = (metrics as any)[mode]; + const modeMetrics = metrics[mode]; if (modeMetrics) { rankings[mode] = { diff --git a/libs/backend/src/services/puzzle.service.ts b/libs/backend/src/services/puzzle.service.ts index 995e0887..c9d87226 100644 --- a/libs/backend/src/services/puzzle.service.ts +++ b/libs/backend/src/services/puzzle.service.ts @@ -1,5 +1,5 @@ import Puzzle, { PuzzleDocument } from "../models/puzzle/puzzle.js"; -import { ObjectId, PuzzleEntity, puzzleVisibilityEnum } from "types"; +import { ObjectId, PuzzleDto, PuzzleEntity, puzzleVisibilityEnum } from "types"; import { PipelineStage } from "mongoose"; /** @@ -15,22 +15,29 @@ export class PuzzleService { } /** - * Find a puzzle by ID with author populated + * Find a puzzle by ID with author and comments populated */ async findByIdPopulated( id: string | ObjectId ): Promise { - return await Puzzle.findById(id).populate("author").exec(); + return await Puzzle.findById(id) + .populate("author") + .populate({ + path: "comments", + populate: { path: "author" } + }) + .exec(); } /** * Find random approved puzzles */ - async findRandomApproved(count: number = 1): Promise { + async findRandomApproved(count: number = 1): Promise { const pipeline: PipelineStage[] = [ { $match: { visibility: puzzleVisibilityEnum.APPROVED } }, { $sample: { size: count } } ]; + return await Puzzle.aggregate(pipeline).exec(); } diff --git a/libs/backend/src/websocket/game/game-setup.ts b/libs/backend/src/websocket/game/game-setup.ts index 7d4178fe..a90e4bba 100644 --- a/libs/backend/src/websocket/game/game-setup.ts +++ b/libs/backend/src/websocket/game/game-setup.ts @@ -10,7 +10,8 @@ import { isPuzzleDto, ObjectId, banTypeEnum, - websocketCloseCodes + websocketCloseCodes, + ERROR_MESSAGES } from "types"; import { isValidObjectId } from "mongoose"; import { parseRawDataGameRequest } from "@/utils/functions/parse-raw-data-message.js"; @@ -45,7 +46,10 @@ export async function gameSetup( const { id } = req.params; if (!isAuthenticatedInfo(req.user)) { - sendErrorAndClose(socket, "Authentication required"); + sendErrorAndClose( + socket, + ERROR_MESSAGES.AUTHENTICATION.AUTHENTICATION_REQUIRED + ); return; } @@ -60,7 +64,7 @@ export async function gameSetup( } if (!isValidObjectId(id)) { - sendErrorAndClose(socket, "Invalid game ID"); + sendErrorAndClose(socket, ERROR_MESSAGES.GAME.NOT_FOUND); return; } @@ -99,7 +103,7 @@ export async function gameSetup( if (!isGameDto(gameToUpdate)) { userWebSockets.updateUser(req.user.username, { event: gameEventEnum.NONEXISTENT_GAME, - message: "Game not found" + message: ERROR_MESSAGES.GAME.NOT_FOUND }); return; } @@ -121,7 +125,7 @@ export async function gameSetup( if (!isGameDto(game)) { userWebSockets.updateUser(req.user.username, { event: gameEventEnum.NONEXISTENT_GAME, - message: "Game not found" + message: ERROR_MESSAGES.GAME.NOT_FOUND }); return; } @@ -133,7 +137,7 @@ export async function gameSetup( if (!isPuzzleDto(puzzle)) { userWebSockets.updateUser(req.user.username, { event: gameEventEnum.ERROR, - message: "Puzzle not found" + message: ERROR_MESSAGES.PUZZLE.NOT_FOUND }); return; } @@ -167,7 +171,7 @@ export async function gameSetup( if (!isGameDto(game)) { userWebSockets.updateUser(req.user.username, { event: gameEventEnum.NONEXISTENT_GAME, - message: "Game not found" + message: ERROR_MESSAGES.GAME.NOT_FOUND }); return; } diff --git a/libs/backend/src/websocket/game/on-connection.ts b/libs/backend/src/websocket/game/on-connection.ts index d5d3c50a..146c2792 100644 --- a/libs/backend/src/websocket/game/on-connection.ts +++ b/libs/backend/src/websocket/game/on-connection.ts @@ -1,5 +1,6 @@ import { AuthenticatedInfo, + ERROR_MESSAGES, gameEventEnum, getUserIdFromUser, isGameDto, @@ -26,10 +27,13 @@ export async function onConnection( socket.send( JSON.stringify({ event: gameEventEnum.NONEXISTENT_GAME, - message: "Game not found" + message: ERROR_MESSAGES.GAME.NOT_FOUND }) ); - socket.close(websocketCloseCodes.POLICY_VIOLATION, "Game not found"); + socket.close( + websocketCloseCodes.POLICY_VIOLATION, + ERROR_MESSAGES.GAME.NOT_FOUND + ); return; } @@ -47,10 +51,13 @@ export async function onConnection( socket.send( JSON.stringify({ event: gameEventEnum.ERROR, - message: `User not in this game` + message: ERROR_MESSAGES.GAME.USER_NOT_IN_GAME }) ); - socket.close(1008, "User not in game"); + socket.close( + websocketCloseCodes.POLICY_VIOLATION, + ERROR_MESSAGES.GAME.USER_NOT_IN_GAME + ); return; } @@ -73,7 +80,7 @@ export async function onConnection( if (!isPuzzleDto(puzzle)) { userWebSockets.updateUser(user.username, { event: gameEventEnum.ERROR, - message: "Puzzle not found" + message: ERROR_MESSAGES.PUZZLE.NOT_FOUND }); return; } @@ -85,6 +92,9 @@ export async function onConnection( }); } catch (error) { console.error("Error in game websocket connection:", error); - socket.close(websocketCloseCodes.INTERNAL_ERROR, "Internal server error"); + socket.close( + websocketCloseCodes.INTERNAL_ERROR, + ERROR_MESSAGES.SERVER.INTERNAL_ERROR + ); } } diff --git a/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts b/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts index 1754a09a..5f6052b2 100644 --- a/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts +++ b/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts @@ -180,7 +180,7 @@ export function waitingRoomSetup( const createGameEntity: GameEntity = { players, owner: waitingRoom.findRoomOwner(room).userId, - puzzle: (randomPuzzle._id as any).toString(), + puzzle: randomPuzzle._id.toString(), createdAt: now, startTime, endTime, diff --git a/libs/frontend/components.json b/libs/frontend/components.json index 05de2582..737ca293 100644 --- a/libs/frontend/components.json +++ b/libs/frontend/components.json @@ -12,5 +12,5 @@ "lib": "$lib" }, "typescript": true, - "registry": "https://tw3.shadcn-svelte.com/registry/default" + "registry": "https://shadcn-svelte.com/registry" } diff --git a/libs/frontend/package.json b/libs/frontend/package.json index 1a0b37fb..1871be52 100644 --- a/libs/frontend/package.json +++ b/libs/frontend/package.json @@ -16,7 +16,8 @@ }, "devDependencies": { "@eslint/js": "^9.7.0", - "@lucide/svelte": "^0.548.0", + "@internationalized/date": "^3.10.0", + "@lucide/svelte": "^0.552.0", "@sveltejs/adapter-node": "5.2.12", "@sveltejs/kit": "^2.47.2", "@sveltejs/vite-plugin-svelte": "^6.2.0", @@ -27,7 +28,7 @@ "@types/jsonwebtoken": "^9.0.6", "@typescript-eslint/eslint-plugin": "^8.22.0", "@typescript-eslint/parser": "^8.22.0", - "bits-ui": "^1.8.0", + "bits-ui": "^2.14.1", "eslint": "^9.10.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-sort-destructure-keys": "^2.0.0", @@ -76,7 +77,6 @@ "@replit/codemirror-vscode-keymap": "^6.0.2", "@tailwindcss/vite": "^4.1.13", "clsx": "^2.1.1", - "codemirror": "^6.0.1", "codemirror-lang-elixir": "^4.0.0", "codemirror-lang-prolog": "^0.1.0", "dayjs": "^1.11.13", diff --git a/libs/frontend/src/lib/components/typography/markdown.svelte b/libs/frontend/src/lib/components/typography/markdown.svelte index 821e4719..02913b65 100644 --- a/libs/frontend/src/lib/components/typography/markdown.svelte +++ b/libs/frontend/src/lib/components/typography/markdown.svelte @@ -1,5 +1,4 @@ + + diff --git a/libs/frontend/src/lib/components/ui/button-group/button-group-text.svelte b/libs/frontend/src/lib/components/ui/button-group/button-group-text.svelte new file mode 100644 index 00000000..20245d50 --- /dev/null +++ b/libs/frontend/src/lib/components/ui/button-group/button-group-text.svelte @@ -0,0 +1,30 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render mergedProps.children?.()} +
+{/if} diff --git a/libs/frontend/src/lib/components/ui/button-group/button-group.svelte b/libs/frontend/src/lib/components/ui/button-group/button-group.svelte new file mode 100644 index 00000000..b82c0d2a --- /dev/null +++ b/libs/frontend/src/lib/components/ui/button-group/button-group.svelte @@ -0,0 +1,48 @@ + + + + +
+ {@render children?.()} +
diff --git a/libs/frontend/src/lib/components/ui/button-group/index.ts b/libs/frontend/src/lib/components/ui/button-group/index.ts new file mode 100644 index 00000000..476bef81 --- /dev/null +++ b/libs/frontend/src/lib/components/ui/button-group/index.ts @@ -0,0 +1,13 @@ +import Root from "./button-group.svelte"; +import Text from "./button-group-text.svelte"; +import Separator from "./button-group-separator.svelte"; + +export { + Root, + Text, + Separator, + // + Root as ButtonGroup, + Text as ButtonGroupText, + Separator as ButtonGroupSeparator +}; diff --git a/libs/frontend/src/lib/components/ui/separator/separator.svelte b/libs/frontend/src/lib/components/ui/separator/separator.svelte index 98b7eae7..d4a68cef 100644 --- a/libs/frontend/src/lib/components/ui/separator/separator.svelte +++ b/libs/frontend/src/lib/components/ui/separator/separator.svelte @@ -1,22 +1,21 @@ diff --git a/libs/frontend/src/lib/features/authentication/login/components/login-form.svelte b/libs/frontend/src/lib/features/authentication/login/components/login-form.svelte index b6676f17..4ad9dd37 100644 --- a/libs/frontend/src/lib/features/authentication/login/components/login-form.svelte +++ b/libs/frontend/src/lib/features/authentication/login/components/login-form.svelte @@ -76,6 +76,7 @@ class="pr-10" /> - {/each} -
- - - - {#if loading} -
-
-
- {/if} - - - {#if error} -
- {error} -
- {/if} - - - {#if leaderboardData && !loading} -
- -
-

- {gameModeNames[selectedMode]} Leaderboard -

-

- Last updated: {formatDate(leaderboardData.lastUpdated)} -

-
- - -
- - - - - - - - - - - - - - {#each leaderboardData.entries as entry} - - - - - - - - - - {/each} - -
- Rank - - Player - - Rating - - Games - - Win Rate - - Best Score - - Avg Score -
- {getRankBadge(entry.rank)} - #{entry.rank} - - - {entry.username} - - - - {Math.round(entry.rating)} - - - (±{Math.round(entry.glicko.rd)}) - - - {entry.gamesPlayed} - - {entry.gamesWon}W - - -
-
-
-
- - {(entry.winRate * 100).toFixed(1)}% - -
-
- {Math.round(entry.bestScore).toLocaleString()} - - {Math.round(entry.averageScore).toLocaleString()} -
-
- - -
-
- Showing {(currentPage - 1) * pageSize + 1} to {Math.min( - currentPage * pageSize, - leaderboardData.totalEntries - )} of {leaderboardData.totalEntries} players -
-
- - - Page {currentPage} of {leaderboardData.totalPages} - - -
-
-
- {/if} - - - diff --git a/libs/frontend/src/routes/leaderboards/+page.svelte b/libs/frontend/src/routes/leaderboards/+page.svelte new file mode 100644 index 00000000..e10eda2b --- /dev/null +++ b/libs/frontend/src/routes/leaderboards/+page.svelte @@ -0,0 +1,282 @@ + + + +

Leaderboards

+ + + + {#each Object.values(gameModeEnum) as mode} + + {/each} + + + + {#if loading} +
+ +
+ {/if} + + + {#if error} + + +

{error}

+
+
+ {/if} + + + {#if leaderboardData && !loading} + + + {gameModeNames[selectedMode]} Leaderboard + + Last updated: {formatDate(leaderboardData.lastUpdated)} + + + + + + + Rank + Player + Rating + Games + Win Rate + Best Score + Avg Score + + + + {#each leaderboardData.entries as entry} + + + {getRankBadge(entry.rank)} + #{entry.rank} + + + + {entry.username} + + + + + {Math.round(entry.rating)} + + + (±{Math.round(entry.glicko.rd)}) + + + +
+ {entry.gamesPlayed} + {entry.gamesWon}W +
+
+ +
+
+
+
+ + {(entry.winRate * 100).toFixed(1)}% + +
+
+ + {Math.round(entry.bestScore).toLocaleString()} + + + {Math.round(entry.averageScore).toLocaleString()} + +
+ {/each} +
+
+
+ +

+ Showing {(currentPage - 1) * pageSize + 1} to {Math.min( + currentPage * pageSize, + leaderboardData.totalEntries + )} of {leaderboardData.totalEntries} players +

+ + + + Page {currentPage} of {leaderboardData.totalPages} + + + +
+
+ {/if} +
diff --git a/libs/frontend/src/routes/moderation/+page.server.ts b/libs/frontend/src/routes/moderation/+page.server.ts index 77d8cc18..6b726018 100644 --- a/libs/frontend/src/routes/moderation/+page.server.ts +++ b/libs/frontend/src/routes/moderation/+page.server.ts @@ -5,7 +5,9 @@ import { } from "@/features/authentication/utils/fetch-with-authentication-cookie"; import { backendUrls, + ERROR_MESSAGES, httpRequestMethod, + PAGINATION_CONFIG, reviewItemTypeEnum, type ReviewItem } from "types"; @@ -25,8 +27,10 @@ export async function load({ request, url }: PageServerLoadEvent) { // Get query parameters const type = url.searchParams.get("type") || reviewItemTypeEnum.PENDING_PUZZLE; - const page = url.searchParams.get("page") || "1"; - const limit = url.searchParams.get("limit") || "20"; + const page = + url.searchParams.get("page") || String(PAGINATION_CONFIG.DEFAULT_PAGE); + const limit = + url.searchParams.get("limit") || String(PAGINATION_CONFIG.DEFAULT_LIMIT); // Fetch review items from backend const reviewUrl = `${buildBackendUrl(backendUrls.MODERATION_REVIEW)}?type=${type}&page=${page}&limit=${limit}`; @@ -41,10 +45,15 @@ export async function load({ request, url }: PageServerLoadEvent) { return { reviewItems: { data: [], - pagination: { page: 1, limit: 20, total: 0, totalPages: 0 } + pagination: { + page: PAGINATION_CONFIG.DEFAULT_PAGE, + limit: PAGINATION_CONFIG.DEFAULT_LIMIT, + total: 0, + totalPages: 0 + } } as PaginatedResponse, currentType: type, - error: "Failed to fetch review items" + error: ERROR_MESSAGES.MODERATION.FAILED_TO_FETCH_REVIEW_ITEMS }; } @@ -59,10 +68,15 @@ export async function load({ request, url }: PageServerLoadEvent) { return { reviewItems: { data: [], - pagination: { page: 1, limit: 20, total: 0, totalPages: 0 } + pagination: { + page: PAGINATION_CONFIG.DEFAULT_PAGE, + limit: PAGINATION_CONFIG.DEFAULT_LIMIT, + total: 0, + totalPages: 0 + } } as PaginatedResponse, currentType: type, - error: "Failed to fetch review items" + error: ERROR_MESSAGES.MODERATION.FAILED_TO_FETCH_REVIEW_ITEMS }; } } diff --git a/libs/types/src/core/common/config/backend-urls.ts b/libs/types/src/core/common/config/backend-urls.ts index ed8aeb0c..057f618e 100644 --- a/libs/types/src/core/common/config/backend-urls.ts +++ b/libs/types/src/core/common/config/backend-urls.ts @@ -44,11 +44,10 @@ export const backendUrls = { SUBMISSION_GAME: `${baseRoute}/submission/game`, // leaderboard routes - LEADERBOARD: `${baseRoute}/leaderboard`, + LEADERBOARD_RECALCULATE: `${baseRoute}/leaderboard/recalculate`, leaderboardByGameMode: (gameMode: string) => `${baseRoute}/leaderboard/${gameMode}`, - leaderboardUserStats: (userId: string) => - `${baseRoute}/leaderboard/user/${userId}`, + leaderboardUserById: (id: string) => `${baseRoute}/leaderboard/user/${id}`, // moderation routes MODERATION_REVIEW: `${baseRoute}/moderation/review`, @@ -71,4 +70,5 @@ export const backendParams = { USERNAME: ":username", ID: ":id", TYPE: ":type", + GAME_MODE: ":gameMode", } as const; diff --git a/libs/types/src/core/common/config/error-messages.ts b/libs/types/src/core/common/config/error-messages.ts new file mode 100644 index 00000000..b871276d --- /dev/null +++ b/libs/types/src/core/common/config/error-messages.ts @@ -0,0 +1,40 @@ +export const ERROR_MESSAGES = { + FORM: { + VALIDATION_ERRORS: "Form validation errors", + REQUIRED_FIELD: "This field is required", + }, + FETCH: { + FAILED_TO_LOAD: "Failed to load data", + FAILED_TO_FETCH: "Failed to fetch", + NETWORK_ERROR: "Network error occurred", + }, + AUTHENTICATION: { + INVALID_CREDENTIALS: "Invalid email/username or password", + UNAUTHORIZED: "You are not authorized to perform this action", + SESSION_EXPIRED: "Your session has expired. Please login again", + AUTHENTICATION_REQUIRED: "Authentication required", + }, + MODERATION: { + FAILED_TO_FETCH_REVIEW_ITEMS: "Failed to fetch review items", + }, + PUZZLE: { + FAILED_TO_DELETE: "Failed to delete the puzzle", + FAILED_TO_UPDATE: "Failed to update the puzzle", + FAILED_TO_CREATE: "Failed to create the puzzle", + NOT_FOUND: "Puzzle not found", + }, + GAME: { + NOT_FOUND: "Game not found", + USER_NOT_IN_GAME: "User not in this game", + ALREADY_FINISHED: "Game has already finished", + FAILED_TO_START: "Failed to start game", + }, + SERVER: { + INTERNAL_ERROR: "Internal server error", + DATABASE_ERROR: "Database error occurred", + }, + GENERIC: { + SOMETHING_WENT_WRONG: "Something went wrong", + TRY_AGAIN_LATER: "Please try again later", + }, +} as const; diff --git a/libs/types/src/core/common/config/pagination.ts b/libs/types/src/core/common/config/pagination.ts new file mode 100644 index 00000000..df83bb6b --- /dev/null +++ b/libs/types/src/core/common/config/pagination.ts @@ -0,0 +1,11 @@ +/** + * Default pagination configuration values + */ +export const PAGINATION_CONFIG = { + DEFAULT_PAGE: 1, + DEFAULT_LIMIT: 20, + DEFAULT_LIMIT_LEADERBOARD: 50, + MIN_PAGE: 1, + MIN_LIMIT: 1, + MAX_LIMIT: 100, +} as const; diff --git a/libs/types/src/core/common/config/test-ids.ts b/libs/types/src/core/common/config/test-ids.ts index 6bcd8df2..a9cf167c 100644 --- a/libs/types/src/core/common/config/test-ids.ts +++ b/libs/types/src/core/common/config/test-ids.ts @@ -89,6 +89,26 @@ export const testIds = { JOIN_BY_INVITE_DIALOG_BUTTON_CANCEL: "join-by-invite-dialog-button-cancel", JOIN_BY_INVITE_DIALOG_BUTTON_JOIN: "join-by-invite-dialog-button-join", + // leaderboard page + LEADERBOARD_PAGE_BUTTON_MODE_FASTEST: "leaderboard-page-button-mode-fastest", + LEADERBOARD_PAGE_BUTTON_MODE_SHORTEST: + "leaderboard-page-button-mode-shortest", + LEADERBOARD_PAGE_BUTTON_MODE_BACKWARDS: + "leaderboard-page-button-mode-backwards", + LEADERBOARD_PAGE_BUTTON_MODE_HARDCORE: + "leaderboard-page-button-mode-hardcore", + LEADERBOARD_PAGE_BUTTON_MODE_DEBUG: "leaderboard-page-button-mode-debug", + LEADERBOARD_PAGE_BUTTON_MODE_TYPERACER: + "leaderboard-page-button-mode-typeracer", + LEADERBOARD_PAGE_BUTTON_MODE_EFFICIENCY: + "leaderboard-page-button-mode-efficiency", + LEADERBOARD_PAGE_BUTTON_MODE_INCREMENTAL: + "leaderboard-page-button-mode-incremental", + LEADERBOARD_PAGE_BUTTON_MODE_RANDOM: "leaderboard-page-button-mode-random", + LEADERBOARD_PAGE_BUTTON_PREVIOUS_PAGE: + "leaderboard-page-button-previous-page", + LEADERBOARD_PAGE_BUTTON_NEXT_PAGE: "leaderboard-page-button-next-page", + // waiting room chat WAITING_ROOM_CHAT_BUTTON_SEND: "waiting-room-chat-button-send", diff --git a/libs/types/src/core/game/schema/mode.schema.ts b/libs/types/src/core/game/schema/mode.schema.ts index 878f9a01..be67f34e 100644 --- a/libs/types/src/core/game/schema/mode.schema.ts +++ b/libs/types/src/core/game/schema/mode.schema.ts @@ -2,7 +2,11 @@ import { z } from "zod"; import { getValues } from "../../../utils/functions/get-values.js"; import { gameModeEnum } from "../enum/game-mode-enum.js"; -export const gameModeSchema = z - .enum(getValues(gameModeEnum)) - .prefault(gameModeEnum.FASTEST); +export const gameModes = getValues(gameModeEnum); + +export const gameModeSchema = z.enum(gameModes).prefault(gameModeEnum.FASTEST); export type GameMode = z.infer; + +export function isGameMode(data: unknown): data is GameMode { + return gameModeSchema.safeParse(data).success; +} diff --git a/libs/types/src/core/leaderboard/config/leaderboard-config.ts b/libs/types/src/core/leaderboard/config/leaderboard-config.ts new file mode 100644 index 00000000..f08c0664 --- /dev/null +++ b/libs/types/src/core/leaderboard/config/leaderboard-config.ts @@ -0,0 +1,24 @@ +/** + * Leaderboard configuration and thresholds + */ +export const LEADERBOARD_CONFIG = { + RATING_THRESHOLDS: { + LEGENDARY: 2000, + MASTER: 1800, + EXPERT: 1600, + ADVANCED: 1400, + BEGINNER: 0, + }, + COLORS: { + LEGENDARY: "text-purple-600 dark:text-purple-400", + MASTER: "text-blue-600 dark:text-blue-400", + EXPERT: "text-green-600 dark:text-green-400", + ADVANCED: "text-yellow-600 dark:text-yellow-400", + BEGINNER: "text-gray-600 dark:text-gray-400", + }, + MEDALS: { + FIRST: "🥇", + SECOND: "🥈", + THIRD: "🥉", + }, +} as const; diff --git a/libs/types/src/core/puzzle/schema/puzzle-dto.schema.ts b/libs/types/src/core/puzzle/schema/puzzle-dto.schema.ts index 340178d7..8694b612 100644 --- a/libs/types/src/core/puzzle/schema/puzzle-dto.schema.ts +++ b/libs/types/src/core/puzzle/schema/puzzle-dto.schema.ts @@ -18,6 +18,5 @@ export const puzzleDtoSchema = basePuzzleDtoSchema.extend({ export type PuzzleDto = z.infer; export function isPuzzleDto(data: unknown): data is PuzzleDto { - console.log({ result: puzzleDtoSchema.safeParse(data) }); return puzzleDtoSchema.safeParse(data).success; } diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index dedd7960..8d180873 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -36,7 +36,9 @@ export * from "./core/common/config/backend-urls.js"; export * from "./core/common/config/cookie.js"; export * from "./core/common/config/environment.js"; export * from "./core/common/config/default-values-query-params.js"; +export * from "./core/common/config/error-messages.js"; export * from "./core/common/config/frontend-urls.js"; +export * from "./core/common/config/pagination.js"; export * from "./core/common/config/test-ids.js"; export * from "./core/common/config/web-socket-urls.js"; export * from "./core/common/enum/websocket-close-codes.js"; @@ -71,6 +73,7 @@ export * from "./core/game/schema/game-options.schema.js"; export * from "./core/game/schema/visibility.schema.js"; // leaderboard +export * from "./core/leaderboard/config/leaderboard-config.js"; export * from "./core/leaderboard/schema/user-metrics.schema.js"; export * from "./core/leaderboard/schema/leaderboard-entry.schema.js"; diff --git a/libs/types/src/utils/functions/get-user-id-from-user.ts b/libs/types/src/utils/functions/get-user-id-from-user.ts index 57ed52f3..5b43cc43 100644 --- a/libs/types/src/utils/functions/get-user-id-from-user.ts +++ b/libs/types/src/utils/functions/get-user-id-from-user.ts @@ -9,7 +9,7 @@ export function getUserIdFromUser(user: unknown): string | null { } if (typeof user === "object" && user !== null && "_id" in user) { - const id = (user as any)._id; + const id = user._id; return id ? String(id) : null; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72149107..85153c9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,9 +184,6 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - codemirror: - specifier: ^6.0.1 - version: 6.0.2 codemirror-lang-elixir: specifier: ^4.0.0 version: 4.0.0 @@ -215,9 +212,12 @@ importers: '@eslint/js': specifier: ^9.7.0 version: 9.38.0 + '@internationalized/date': + specifier: ^3.10.0 + version: 3.10.0 '@lucide/svelte': - specifier: ^0.548.0 - version: 0.548.0(svelte@5.41.4) + specifier: ^0.552.0 + version: 0.552.0(svelte@5.41.4) '@sveltejs/adapter-node': specifier: 5.2.12 version: 5.2.12(@sveltejs/kit@2.47.3(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))) @@ -249,8 +249,8 @@ importers: specifier: ^8.22.0 version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) bits-ui: - specifier: ^1.8.0 - version: 1.8.0(svelte@5.41.4) + specifier: ^2.14.1 + version: 2.14.1(@internationalized/date@3.10.0)(@sveltejs/kit@2.47.3(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.41.4) eslint: specifier: ^9.10.0 version: 9.38.0(jiti@2.6.1) @@ -1158,8 +1158,8 @@ packages: '@lezer/rust@1.0.2': resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==} - '@lucide/svelte@0.548.0': - resolution: {integrity: sha512-Iwh5GXK8+tE1lBjYoBPfOhBiWv6/K/XinZ/Bjx/2Qys86ufZ/CbjHKacYyZeGnyWQSsjcr7P3xavkmCMhv/1RA==} + '@lucide/svelte@0.552.0': + resolution: {integrity: sha512-8wQF1YUKgaXiFidPdHM5NKESArKHLrgf8A1EAOjvqRmdVlL2KFsPxTchez/lMUOJKxXeDgrRKfXQuaN1O5KlhQ==} peerDependencies: svelte: ^5 @@ -1838,11 +1838,12 @@ packages: birpc@0.2.14: resolution: {integrity: sha512-37FHE8rqsYM5JEKCnXFyHpBCzvgHEExwVVTq+nUmloInU7l8ezD1TpOhKpS8oe1DTYFqEK27rFZVKG43oTqXRA==} - bits-ui@1.8.0: - resolution: {integrity: sha512-CXD6Orp7l8QevNDcRPLXc/b8iMVgxDWT2LyTwsdLzJKh9CxesOmPuNePSPqAxKoT59FIdU4aFPS1k7eBdbaCxg==} - engines: {node: '>=18', pnpm: '>=8.7.0'} + bits-ui@2.14.1: + resolution: {integrity: sha512-FkQTBDF+BLh5fgwioi04JJD8kpsQ+pVjPwzxUYYd39pYR0uKAyMLmoKo3EAWJIszV7fjTv1ZiNzxTkpGutJ32w==} + engines: {node: '>=20'} peerDependencies: - svelte: ^5.11.0 + '@internationalized/date': ^3.8.1 + svelte: ^5.33.0 blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} @@ -1917,9 +1918,6 @@ packages: codemirror-lang-prolog@0.1.0: resolution: {integrity: sha512-l8UvvCy3ub9kHbREFPG44xhHNG/AuCwkQEbLANfppHi1qZEWdr59ChSo4ZVu5XmC4PrHH3aMUHF+E2KS/V+LpA==} - codemirror@6.0.2: - resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} - 'codin-cod@file:': resolution: {directory: '', type: directory} @@ -1966,9 +1964,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css.escape@1.5.1: - resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2000,6 +1995,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2549,6 +2548,10 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2978,6 +2981,15 @@ packages: peerDependencies: svelte: ^5.7.0 + runed@0.35.1: + resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==} + peerDependencies: + '@sveltejs/kit': ^2.21.0 + svelte: ^5.7.0 + peerDependenciesMeta: + '@sveltejs/kit': + optional: true + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -3122,6 +3134,12 @@ packages: peerDependencies: svelte: ^5.0.0 + svelte-toolbelt@0.10.6: + resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==} + engines: {node: '>=18', pnpm: '>=8.7.0'} + peerDependencies: + svelte: ^5.30.2 + svelte-toolbelt@0.4.6: resolution: {integrity: sha512-k8OUvXBUifHZcAlWeY/HLg/4J0v5m2iOfOhn8fDmjt4AP8ZluaDh9eBFus9lFiLX6O5l6vKqI1dKL5wy7090NQ==} engines: {node: '>=18', pnpm: '>=8.7.0'} @@ -4207,7 +4225,7 @@ snapshots: '@lezer/highlight': 1.2.2 '@lezer/lr': 1.4.2 - '@lucide/svelte@0.548.0(svelte@5.41.4)': + '@lucide/svelte@0.552.0(svelte@5.41.4)': dependencies: svelte: 5.41.4 @@ -4868,17 +4886,18 @@ snapshots: birpc@0.2.14: {} - bits-ui@1.8.0(svelte@5.41.4): + bits-ui@2.14.1(@internationalized/date@3.10.0)(@sveltejs/kit@2.47.3(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.41.4): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.0 - css.escape: 1.5.1 esm-env: 1.2.2 - runed: 0.23.4(svelte@5.41.4) + runed: 0.35.1(@sveltejs/kit@2.47.3(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.41.4) svelte: 5.41.4 - svelte-toolbelt: 0.7.1(svelte@5.41.4) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.47.3(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.41.4) tabbable: 6.3.0 + transitivePeerDependencies: + - '@sveltejs/kit' blake3-wasm@2.1.5: {} @@ -4954,16 +4973,6 @@ snapshots: '@lezer/highlight': 1.2.2 '@lezer/lr': 1.4.2 - codemirror@6.0.2: - dependencies: - '@codemirror/autocomplete': 6.19.1 - '@codemirror/commands': 6.10.0 - '@codemirror/language': 6.11.3 - '@codemirror/lint': 6.9.1 - '@codemirror/search': 6.5.11 - '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 - 'codin-cod@file:': {} color-convert@2.0.1: @@ -5002,8 +5011,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css.escape@1.5.1: {} - cssesc@3.0.0: {} dayjs@1.11.18: {} @@ -5020,6 +5027,8 @@ snapshots: defu@6.1.4: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} devalue@5.4.2: {} @@ -5628,6 +5637,8 @@ snapshots: loupe@3.2.1: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6014,6 +6025,15 @@ snapshots: esm-env: 1.2.2 svelte: 5.41.4 + runed@0.35.1(@sveltejs/kit@2.47.3(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.41.4): + dependencies: + dequal: 2.0.3 + esm-env: 1.2.2 + lz-string: 1.5.0 + svelte: 5.41.4 + optionalDependencies: + '@sveltejs/kit': 2.47.3(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + sade@1.8.1: dependencies: mri: 1.2.0 @@ -6172,6 +6192,15 @@ snapshots: runed: 0.28.0(svelte@5.41.4) svelte: 5.41.4 + svelte-toolbelt@0.10.6(@sveltejs/kit@2.47.3(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.41.4): + dependencies: + clsx: 2.1.1 + runed: 0.35.1(@sveltejs/kit@2.47.3(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.41.4)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.41.4) + style-to-object: 1.0.12 + svelte: 5.41.4 + transitivePeerDependencies: + - '@sveltejs/kit' + svelte-toolbelt@0.4.6(svelte@5.41.4): dependencies: clsx: 2.1.1