From aa277fb1eb49be36b7bd150f5bf083595011987b Mon Sep 17 00:00:00 2001 From: J Mad <36441093+reeveng@users.noreply.github.com> Date: Wed, 5 Nov 2025 01:05:52 +0100 Subject: [PATCH 1/6] feat: elixir backend with generated types and endpoints on the frontend --- .../backend/src/services/game-mode.service.ts | 11 +- libs/elixir-backend/.env.example | 128 + libs/elixir-backend/README.md | 425 + .../elixir-backend/codincod_api/.dockerignore | 5 + .../codincod_api/.formatter.exs | 5 + libs/elixir-backend/codincod_api/.gitignore | 27 + libs/elixir-backend/codincod_api/AGENTS.md | 99 + .../codincod_api/ARCHITECTURE.md | 202 + libs/elixir-backend/codincod_api/Dockerfile | 27 + libs/elixir-backend/codincod_api/README.md | 18 + .../codincod_api/config/config.exs | 76 + .../codincod_api/config/dev.exs | 46 + .../codincod_api/config/prod.exs | 13 + .../codincod_api/config/runtime.exs | 94 + .../codincod_api/config/test.exs | 42 + libs/elixir-backend/codincod_api/cookies.txt | 4 + .../codincod_api/lib/codincod_api.ex | 9 + .../codincod_api/lib/codincod_api/accounts.ex | 287 + .../lib/codincod_api/accounts/email.ex | 49 + .../lib/codincod_api/accounts/password.ex | 82 + .../codincod_api/accounts/password_reset.ex | 53 + .../lib/codincod_api/accounts/preference.ex | 77 + .../lib/codincod_api/accounts/user.ex | 175 + .../lib/codincod_api/accounts/user_ban.ex | 57 + .../lib/codincod_api/application.ex | 35 + .../codincod_api/lib/codincod_api/chat.ex | 81 + .../lib/codincod_api/chat/chat_message.ex | 65 + .../codincod_api/lib/codincod_api/comments.ex | 172 + .../lib/codincod_api/comments/comment.ex | 110 + .../lib/codincod_api/comments/comment_vote.ex | 44 + .../codincod_api/lib/codincod_api/games.ex | 115 + .../lib/codincod_api/games/game.ex | 96 + .../lib/codincod_api/games/game_player.ex | 61 + .../lib/codincod_api/languages.ex | 47 + .../languages/programming_language.ex | 55 + .../codincod_api/lib/codincod_api/mailer.ex | 3 + .../codincod_api/lib/codincod_api/metrics.ex | 86 + .../metrics/leaderboard_snapshot.ex | 57 + .../lib/codincod_api/metrics/user_metric.ex | 72 + .../lib/codincod_api/moderation.ex | 129 + .../moderation/moderation_review.ex | 81 + .../lib/codincod_api/moderation/report.ex | 91 + .../codincod_api/lib/codincod_api/piston.ex | 38 + .../lib/codincod_api/piston/client.ex | 57 + .../lib/codincod_api/piston/mock.ex | 47 + .../codincod_api/lib/codincod_api/puzzles.ex | 334 + .../lib/codincod_api/puzzles/puzzle.ex | 104 + .../codincod_api/puzzles/puzzle_example.ex | 62 + .../lib/codincod_api/puzzles/puzzle_metric.ex | 52 + .../codincod_api/puzzles/puzzle_test_case.ex | 62 + .../codincod_api/puzzles/puzzle_validator.ex | 43 + .../codincod_api/lib/codincod_api/repo.ex | 5 + .../lib/codincod_api/submissions.ex | 96 + .../lib/codincod_api/submissions/evaluator.ex | 147 + .../codincod_api/submissions/submission.ex | 71 + .../codincod_api/lib/codincod_api/typegen.ex | 136 + .../codincod_api/lib/codincod_api_web.ex | 65 + .../codincod_api_web/auth/error_handler.ex | 23 + .../lib/codincod_api_web/auth/guardian.ex | 53 + .../lib/codincod_api_web/auth/pipeline.ex | 13 + .../codincod_api_web/channels/game_channel.ex | 265 + .../codincod_api_web/channels/user_socket.ex | 30 + .../controllers/account_controller.ex | 284 + .../account_preference_controller.ex | 229 + .../controllers/auth_controller.ex | 328 + .../controllers/comment_controller.ex | 166 + .../controllers/error_json.ex | 21 + .../controllers/execute_controller.ex | 190 + .../controllers/fallback_controller.ex | 45 + .../controllers/game_controller.ex | 519 + .../controllers/health_controller.ex | 33 + .../controllers/leaderboard_controller.ex | 221 + .../controllers/metrics_controller.ex | 324 + .../controllers/moderation_controller.ex | 549 + .../controllers/open_api_controller.ex | 10 + .../controllers/password_reset_controller.ex | 179 + .../programming_language_controller.ex | 57 + .../controllers/puzzle_comment_controller.ex | 183 + .../controllers/puzzle_controller.ex | 692 + .../controllers/submission_controller.ex | 312 + .../controllers/user_controller.ex | 230 + .../lib/codincod_api_web/endpoint.ex | 68 + .../lib/codincod_api_web/gettext.ex | 25 + .../lib/codincod_api_web/open_api.ex | 29 + .../lib/codincod_api_web/openapi/schemas.ex | 74 + .../openapi/schemas/account.ex | 74 + .../codincod_api_web/openapi/schemas/auth.ex | 46 + .../openapi/schemas/comment.ex | 66 + .../openapi/schemas/common.ex | 20 + .../openapi/schemas/execute.ex | 54 + .../codincod_api_web/openapi/schemas/games.ex | 156 + .../openapi/schemas/leaderboard.ex | 95 + .../openapi/schemas/metrics.ex | 112 + .../openapi/schemas/moderation.ex | 205 + .../openapi/schemas/password_reset.ex | 50 + .../openapi/schemas/puzzle.ex | 128 + .../openapi/schemas/submission.ex | 115 + .../codincod_api_web/openapi/schemas/user.ex | 97 + .../plugs/attach_token_from_cookie.ex | 35 + .../codincod_api_web/plugs/current_user.ex | 15 + .../codincod_api_web/plugs/open_api_spec.ex | 19 + .../codincod_api_web/plugs/render_open_api.ex | 18 + .../lib/codincod_api_web/router.ex | 143 + .../codincod_api_web/serializers/helpers.ex | 16 + .../serializers/puzzle_serializer.ex | 110 + .../serializers/submission_serializer.ex | 86 + .../serializers/user_serializer.ex | 29 + .../lib/codincod_api_web/telemetry.ex | 93 + .../mix/tasks/codincod.gen_openapi_spec.ex | 37 + .../lib/mix/tasks/codincod.gen_types.ex | 27 + .../lib/mix/tasks/gen_typescript_types.ex | 12 + .../lib/mix/tasks/migrate_mongo.ex | 1107 ++ .../lib/mix/tasks/mongo.inspect.ex | 89 + libs/elixir-backend/codincod_api/mix.exs | 114 + libs/elixir-backend/codincod_api/mix.lock | 67 + libs/elixir-backend/codincod_api/openapi.json | 12272 ++++++++++++++++ .../priv/gettext/en/LC_MESSAGES/errors.po | 112 + .../codincod_api/priv/gettext/errors.pot | 109 + .../priv/repo/migrations/.formatter.exs | 4 + .../20251101090000_create_accounts_tables.exs | 66 + ...101090100_create_programming_languages.exs | 22 + .../20251101090300_create_puzzles_tables.exs | 64 + ...251101090400_create_submissions_tables.exs | 27 + .../20251101090500_create_games_tables.exs | 52 + .../20251101090600_create_comments_tables.exs | 43 + ...20251101090700_create_reports_and_chat.exs | 63 + .../20251101090800_create_metrics_tables.exs | 36 + ...0251102000001_create_puzzle_test_cases.exs | 24 + .../20251102000002_create_puzzle_examples.exs | 23 + .../20251102154346_create_password_resets.exs | 19 + .../scripts/extract_puzzle_sub_schemas.exs | 93 + .../repo/scripts/seed_test_data_mongodb.exs | 367 + .../priv/repo/scripts/verify_migration.exs | 559 + .../codincod_api/priv/repo/seeds.exs | 11 + .../codincod_api/priv/static/favicon.ico | Bin 0 -> 152 bytes .../codincod_api/priv/static/openapi.json | 12272 ++++++++++++++++ .../codincod_api/priv/static/robots.txt | 5 + .../controllers/error_json_test.exs | 12 + .../submission_controller_test.exs | 207 + .../controllers/user_controller_test.exs | 171 + .../codincod_api/test/support/conn_case.ex | 38 + .../codincod_api/test/support/data_case.ex | 58 + .../codincod_api/test/test_helper.exs | 2 + .../codincod_api/test_migration.sh | 75 + libs/elixir-backend/complete_migration.exs | 435 + libs/elixir-backend/docker-compose.yml | 124 + libs/elixir-backend/init.sql | 18 + libs/elixir-backend/migrate.sh | 39 + libs/elixir-backend/validate_migration.exs | 599 + libs/frontend/.prettierrc | 6 +- libs/frontend/eslint.config.js | 12 +- libs/frontend/knip.json | 20 + libs/frontend/orval.config.ts | 30 + libs/frontend/package.json | 7 +- libs/frontend/scripts/copy-maintenance.mjs | 4 +- libs/frontend/src/lib/api/custom-client.ts | 43 + libs/frontend/src/lib/api/error-handler.ts | 201 + libs/frontend/src/lib/api/errors.ts | 117 + .../account-preferences.ts | 174 + .../src/lib/api/generated/account/account.ts | 174 + .../src/lib/api/generated/auth/auth.ts | 178 + .../src/lib/api/generated/default/default.ts | 222 + .../src/lib/api/generated/execute/execute.ts | 56 + .../src/lib/api/generated/games/games.ts | 307 + .../src/lib/api/generated/health/health.ts | 53 + libs/frontend/src/lib/api/generated/index.ts | 46 + .../api/generated/leaderboard/leaderboard.ts | 157 + .../src/lib/api/generated/metrics/metrics.ts | 140 + .../api/generated/moderation/moderation.ts | 398 + .../password-reset/password-reset.ts | 107 + .../src/lib/api/generated/puzzle/puzzle.ts | 298 + .../generated/schemas/accountPreferences.ts | 18 + .../schemas/accountPreferencesEditor.ts | 9 + .../schemas/accountPreferencesTheme.ts | 20 + .../schemas/accountProfileUpdateRequest.ts | 17 + .../schemas/accountProfileUpdateResponse.ts | 13 + .../accountProfileUpdateResponseProfile.ts | 18 + .../schemas/accountStatusResponse.ts | 14 + .../api/generated/schemas/activityResponse.ts | 15 + .../schemas/activityResponseActivity.ts | 14 + .../activityResponseActivityPuzzlesItem.ts | 39 + ...tivityResponseActivityPuzzlesItemAuthor.ts | 18 + ...esponseActivityPuzzlesItemAuthorProfile.ts | 18 + ...vityResponseActivityPuzzlesItemSolution.ts | 13 + ...sponseActivityPuzzlesItemValidatorsItem.ts | 17 + ...activityResponseActivitySubmissionsItem.ts | 34 + ...ivitySubmissionsItemProgrammingLanguage.ts | 20 + ...tyResponseActivitySubmissionsItemPuzzle.ts | 16 + ...tyResponseActivitySubmissionsItemResult.ts | 11 + ...vityResponseActivitySubmissionsItemUser.ts | 33 + ...ponseActivitySubmissionsItemUserProfile.ts | 18 + .../generated/schemas/activityResponseUser.ts | 33 + .../schemas/activityResponseUserProfile.ts | 18 + .../generated/schemas/authMessageResponse.ts | 11 + .../src/lib/api/generated/schemas/author.ts | 18 + .../api/generated/schemas/authorProfile.ts | 18 + .../generated/schemas/availabilityResponse.ts | 11 + .../lib/api/generated/schemas/banResponse.ts | 16 + .../api/generated/schemas/banUserRequest.ts | 16 + .../codincodApiWebHealthControllerShow200.ts | 11 + .../codincodApiWebHealthControllerShow2200.ts | 11 + ...WebLeaderboardControllerGlobal2GameMode.ts | 17 + ...piWebLeaderboardControllerGlobal2Params.ts | 26 + ...iWebLeaderboardControllerGlobalGameMode.ts | 17 + ...ApiWebLeaderboardControllerGlobalParams.ts | 26 + ...piWebLeaderboardControllerPuzzle2Params.ts | 16 + ...ApiWebLeaderboardControllerPuzzleParams.ts | 16 + ...bModerationControllerListReports2Params.ts | 20 + ...rationControllerListReports2ProblemType.ts | 19 + ...bModerationControllerListReports2Status.ts | 18 + ...ebModerationControllerListReportsParams.ts | 20 + ...erationControllerListReportsProblemType.ts | 19 + ...ebModerationControllerListReportsStatus.ts | 18 + ...bModerationControllerListReviews2Params.ts | 15 + ...bModerationControllerListReviews2Status.ts | 17 + ...ebModerationControllerListReviewsParams.ts | 15 + ...ebModerationControllerListReviewsStatus.ts | 17 + ...ogrammingLanguageControllerIndex200Item.ts | 16 + ...grammingLanguageControllerIndex2200Item.ts | 16 + ...incodApiWebPuzzleControllerIndex2Params.ts | 21 + ...dincodApiWebPuzzleControllerIndexParams.ts | 21 + ...incodApiWebUserControllerPuzzles2Params.ts | 19 + ...dincodApiWebUserControllerPuzzlesParams.ts | 19 + .../generated/schemas/commentCreateRequest.ts | 17 + .../api/generated/schemas/commentResponse.ts | 25 + .../schemas/commentResponseAuthor.ts | 13 + .../schemas/commentResponseCommentType.ts | 17 + .../generated/schemas/commentVoteRequest.ts | 12 + .../schemas/commentVoteRequestType.ts | 16 + .../generated/schemas/createGameRequest.ts | 20 + .../schemas/createGameRequestGameMode.ts | 17 + .../generated/schemas/createReportRequest.ts | 17 + .../schemas/createReportRequestContentType.ts | 18 + .../schemas/createReportRequestProblemType.ts | 19 + .../api/generated/schemas/createRequest.ts | 17 + .../schemas/createRequestDifficulty.ts | 25 + .../schemas/createRequestValidatorsItem.ts | 13 + .../api/generated/schemas/errorResponse.ts | 14 + .../generated/schemas/errorResponseErrors.ts | 9 + .../api/generated/schemas/executeRequest.ts | 16 + .../api/generated/schemas/executeResponse.ts | 17 + .../schemas/executeResponseCompile.ts | 12 + .../executeResponsePuzzleResultInformation.ts | 23 + ...teResponsePuzzleResultInformationResult.ts | 16 + .../generated/schemas/executeResponseRun.ts | 9 + .../lib/api/generated/schemas/gameResponse.ts | 27 + .../generated/schemas/gameResponseOwner.ts | 12 + .../schemas/gameResponsePlayersItem.ts | 14 + .../generated/schemas/gameResponsePuzzle.ts | 13 + .../schemas/gameSubmitCodeRequest.ts | 15 + .../schemas/globalLeaderboardResponse.ts | 19 + .../globalLeaderboardResponseRankingsItem.ts | 23 + ...alLeaderboardResponseRankingsItemGlicko.ts | 14 + .../src/lib/api/generated/schemas/index.ts | 215 + .../generated/schemas/leaveGameResponse.ts | 11 + .../lib/api/generated/schemas/loginRequest.ts | 13 + .../api/generated/schemas/messageResponse.ts | 11 + .../schemas/paginatedListResponse.ts | 23 + .../schemas/paginatedListResponseItemsItem.ts | 39 + .../paginatedListResponseItemsItemAuthor.ts | 18 + ...natedListResponseItemsItemAuthorProfile.ts | 18 + .../paginatedListResponseItemsItemSolution.ts | 13 + ...atedListResponseItemsItemValidatorsItem.ts | 17 + .../schemas/passwordResetCompleteResponse.ts | 11 + .../generated/schemas/passwordResetPayload.ts | 13 + .../generated/schemas/passwordResetRequest.ts | 11 + .../schemas/passwordResetResponse.ts | 11 + .../schemas/platformMetricsResponse.ts | 17 + ...atformMetricsResponsePopularPuzzlesItem.ts | 14 + .../generated/schemas/preferencesPayload.ts | 18 + .../schemas/preferencesPayloadEditor.ts | 9 + .../schemas/preferencesPayloadTheme.ts | 20 + .../src/lib/api/generated/schemas/profile.ts | 18 + .../generated/schemas/profileUpdateRequest.ts | 17 + .../schemas/profileUpdateResponse.ts | 13 + .../schemas/profileUpdateResponseProfile.ts | 18 + .../schemas/programmingLanguageSummary.ts | 20 + .../generated/schemas/puzzleCreateRequest.ts | 30 + .../schemas/puzzleCreateRequestDifficulty.ts | 25 + .../puzzleCreateRequestValidatorsItem.ts | 13 + .../schemas/puzzleLeaderboardResponse.ts | 14 + .../puzzleLeaderboardResponseRankingsItem.ts | 16 + .../schemas/puzzlePaginatedListResponse.ts | 23 + .../puzzlePaginatedListResponseItemsItem.ts | 39 + ...zlePaginatedListResponseItemsItemAuthor.ts | 18 + ...natedListResponseItemsItemAuthorProfile.ts | 18 + ...ePaginatedListResponseItemsItemSolution.ts | 13 + ...atedListResponseItemsItemValidatorsItem.ts | 17 + .../api/generated/schemas/puzzleResponse.ts | 39 + .../generated/schemas/puzzleResponseAuthor.ts | 18 + .../schemas/puzzleResponseAuthorProfile.ts | 18 + .../schemas/puzzleResponseSolution.ts | 13 + .../schemas/puzzleResponseValidatorsItem.ts | 17 + .../schemas/puzzleResultInformation.ts | 23 + .../schemas/puzzleResultInformationResult.ts | 16 + .../generated/schemas/puzzleStatsResponse.ts | 22 + ...leStatsResponseLanguageDistributionItem.ts | 12 + .../puzzleStatsResponseStatusBreakdown.ts | 14 + .../api/generated/schemas/puzzleSummary.ts | 16 + .../api/generated/schemas/registerRequest.ts | 19 + .../api/generated/schemas/reportResponse.ts | 28 + .../schemas/reportResponseReportedBy.ts | 15 + .../schemas/reportResponseResolvedBy.ts | 15 + .../generated/schemas/reportsListResponse.ts | 13 + .../api/generated/schemas/requestPayload.ts | 11 + .../api/generated/schemas/requestResponse.ts | 11 + .../lib/api/generated/schemas/resetPayload.ts | 13 + .../api/generated/schemas/resetResponse.ts | 11 + .../generated/schemas/resolveReportRequest.ts | 14 + .../schemas/resolveReportRequestStatus.ts | 16 + .../schemas/reviewDecisionRequest.ts | 14 + .../schemas/reviewDecisionRequestStatus.ts | 16 + .../api/generated/schemas/reviewResponse.ts | 43 + .../reviewResponseContextMessagesItem.ts | 14 + .../schemas/reviewResponseReviewer.ts | 15 + .../generated/schemas/reviewsListResponse.ts | 13 + .../lib/api/generated/schemas/showResponse.ts | 13 + .../api/generated/schemas/showResponseUser.ts | 33 + .../schemas/showResponseUserProfile.ts | 18 + .../src/lib/api/generated/schemas/solution.ts | 13 + .../schemas/submissionListResponse.ts | 10 + .../schemas/submissionListResponseItem.ts | 34 + ...sionListResponseItemProgrammingLanguage.ts | 20 + .../submissionListResponseItemPuzzle.ts | 16 + .../submissionListResponseItemResult.ts | 9 + .../schemas/submissionListResponseItemUser.ts | 33 + .../submissionListResponseItemUserProfile.ts | 18 + .../generated/schemas/submissionResponse.ts | 34 + .../submissionResponseProgrammingLanguage.ts | 20 + .../schemas/submissionResponsePuzzle.ts | 16 + .../schemas/submissionResponseResult.ts | 9 + .../schemas/submissionResponseUser.ts | 33 + .../schemas/submissionResponseUserProfile.ts | 18 + .../schemas/submissionSubmitRequest.ts | 15 + .../schemas/submissionSubmitResponse.ts | 20 + .../schemas/submissionSubmitResponseResult.ts | 21 + .../generated/schemas/submitCodeRequest.ts | 15 + .../generated/schemas/submitCodeResponse.ts | 20 + .../schemas/submitCodeResponseResult.ts | 21 + .../src/lib/api/generated/schemas/summary.ts | 33 + .../api/generated/schemas/summaryProfile.ts | 18 + .../generated/schemas/userActivityResponse.ts | 15 + .../schemas/userActivityResponseActivity.ts | 14 + ...userActivityResponseActivityPuzzlesItem.ts | 39 + ...tivityResponseActivityPuzzlesItemAuthor.ts | 18 + ...esponseActivityPuzzlesItemAuthorProfile.ts | 18 + ...vityResponseActivityPuzzlesItemSolution.ts | 13 + ...sponseActivityPuzzlesItemValidatorsItem.ts | 17 + ...ActivityResponseActivitySubmissionsItem.ts | 34 + ...ivitySubmissionsItemProgrammingLanguage.ts | 20 + ...tyResponseActivitySubmissionsItemPuzzle.ts | 16 + ...tyResponseActivitySubmissionsItemResult.ts | 11 + ...vityResponseActivitySubmissionsItemUser.ts | 33 + ...ponseActivitySubmissionsItemUserProfile.ts | 18 + .../schemas/userActivityResponseUser.ts | 33 + .../userActivityResponseUserProfile.ts | 18 + .../schemas/userAvailabilityResponse.ts | 11 + .../generated/schemas/userGamesResponse.ts | 13 + .../schemas/userGamesResponseGamesItem.ts | 27 + .../userGamesResponseGamesItemOwner.ts | 12 + .../userGamesResponseGamesItemPlayersItem.ts | 14 + .../userGamesResponseGamesItemPuzzle.ts | 13 + .../api/generated/schemas/userRankResponse.ts | 19 + .../api/generated/schemas/userShowResponse.ts | 13 + .../generated/schemas/userShowResponseUser.ts | 33 + .../schemas/userShowResponseUserProfile.ts | 18 + .../generated/schemas/userStatsResponse.ts | 24 + .../userStatsResponseDifficultyBreakdown.ts | 14 + .../userStatsResponseLanguageUsageItem.ts | 12 + .../lib/api/generated/schemas/userSummary.ts | 33 + .../generated/schemas/userSummaryProfile.ts | 18 + .../lib/api/generated/schemas/validator.ts | 17 + .../lib/api/generated/schemas/voteRequest.ts | 12 + .../api/generated/schemas/voteRequestType.ts | 16 + .../generated/schemas/waitingRoomsResponse.ts | 13 + .../schemas/waitingRoomsResponseRoomsItem.ts | 27 + .../waitingRoomsResponseRoomsItemOwner.ts | 12 + ...aitingRoomsResponseRoomsItemPlayersItem.ts | 14 + .../waitingRoomsResponseRoomsItemPuzzle.ts | 13 + .../api/generated/submission/submission.ts | 98 + .../src/lib/api/generated/user/user.ts | 217 + libs/frontend/src/lib/api/notifications.ts | 282 + .../codemirror-wrapper.svelte | 1 - .../nav/navigation/navigation.svelte | 25 +- .../lib/components/nav/toggle-theme.svelte | 2 +- .../lib/components/nav/user-dropdown.svelte | 4 +- .../lib/components/typography/markdown.svelte | 4 +- .../src/lib/components/ui/alert/index.ts | 10 +- .../src/lib/components/ui/avatar/index.ts | 12 +- .../src/lib/components/ui/badge/index.ts | 7 +- .../src/lib/components/ui/breadcrumb/index.ts | 22 +- .../lib/components/ui/button-group/index.ts | 12 +- .../src/lib/components/ui/button/index.ts | 6 +- .../src/lib/components/ui/card/index.ts | 16 +- .../src/lib/components/ui/checkbox/index.ts | 4 +- .../ui/countdown-timer/countdown-timer.svelte | 2 +- .../src/lib/components/ui/dialog/index.ts | 34 +- .../src/lib/components/ui/form/index.ts | 28 +- .../src/lib/components/ui/hover-card/index.ts | 6 +- .../src/lib/components/ui/input/index.ts | 4 +- .../src/lib/components/ui/label/index.ts | 4 +- .../src/lib/components/ui/menubar/index.ts | 36 +- .../src/lib/components/ui/resizable/index.ts | 10 +- .../lib/components/ui/scroll-area/index.ts | 4 +- .../src/lib/components/ui/select/index.ts | 22 +- .../lib/components/ui/sonner/sonner.svelte | 2 +- .../src/lib/components/ui/table/index.ts | 4 +- .../src/lib/components/ui/tabs/index.ts | 6 +- .../websocket/connection-status.svelte | 2 +- libs/frontend/src/lib/config/websocket.ts | 16 +- .../register/config/register-form-schema.ts | 3 +- .../utils/fetch-with-authentication-cookie.ts | 1 - .../utils/get-authenticated-user-info.ts | 109 +- .../utils/is-sveltekit-redirect.ts | 34 + .../authentication/utils/set-cookie.ts | 2 +- .../chat/components/chat-message.svelte | 5 +- .../chat/components/report-chat-dialog.svelte | 45 +- .../components/add-comment-form.svelte | 57 +- .../comment/components/comment.svelte | 54 +- .../game/components/codemirror.svelte | 5 +- .../components/standings-table.svelte | 13 +- .../components/custom-game-dialog.svelte | 2 +- .../components/edit-puzzle-form.svelte | 2 +- .../puzzles/components/language-select.svelte | 2 +- .../puzzles/components/play-puzzle.svelte | 29 +- .../components/puzzle-meta-info.svelte | 45 +- .../puzzles/components/user-hover-card.svelte | 17 +- libs/frontend/src/lib/stores/auth.store.ts | 79 + ...{current-time.ts => current-time.store.ts} | 0 .../src/lib/stores/languages.store.ts | 187 + libs/frontend/src/lib/stores/languages.ts | 157 - .../{preferences.ts => preferences.store.ts} | 124 +- .../lib/stores/{index.ts => theme.store.ts} | 86 +- libs/frontend/src/lib/utils/debug-logger.ts | 138 + .../lib/websocket/websocket-manager.svelte.ts | 75 +- .../routes/(authenticated)/+layout.server.ts | 2 +- .../(authenticated)/logout/+page.server.ts | 54 +- .../(authenticated)/logout/+page.svelte | 2 +- .../(authenticated)/multiplayer/+page.svelte | 4 +- .../multiplayer/[id]/+page.svelte | 25 +- .../puzzles/[id]/edit/+page.server.ts | 194 +- .../puzzles/[id]/edit/+page.svelte | 2 +- .../puzzles/[id]/play/+page.server.ts | 29 +- .../puzzles/create/+page.server.ts | 78 +- .../settings/preferences/+page.svelte | 4 +- .../settings/profile/+page.svelte | 96 +- .../forgot-password/+page.server.ts | 41 + .../forgot-password/+page.svelte | 114 + .../login/+page.server.ts | 79 +- .../(unauthenticated-only)/login/+page.svelte | 2 +- .../register/+page.server.ts | 52 +- .../register/+page.svelte | 2 +- .../reset-password/+page.server.ts | 63 + .../reset-password/+page.svelte | 162 + libs/frontend/src/routes/+layout.server.ts | 19 + libs/frontend/src/routes/+layout.svelte | 45 +- .../src/routes/leaderboards/+page.svelte | 94 +- .../src/routes/moderation/+page.server.ts | 90 +- .../src/routes/moderation/+page.svelte | 204 +- .../routes/profile/[username]/+page.server.ts | 71 +- .../routes/profile/[username]/+page.svelte | 6 + .../[username]/puzzles/+page.server.ts | 27 +- .../profile/[username]/puzzles/+page.svelte | 4 +- .../src/routes/puzzles/+page.server.ts | 34 +- libs/frontend/src/routes/puzzles/+page.svelte | 34 +- .../src/routes/puzzles/[id]/+page.server.ts | 35 +- .../src/routes/puzzles/[id]/+page.svelte | 16 +- libs/frontend/tailwind.config.ts | 4 +- libs/frontend/vite.config.ts | 2 +- libs/types/package.json | 4 +- .../core/api/schema/user/user-api.schema.ts | 2 +- .../src/core/common/config/frontend-urls.ts | 2 + libs/types/src/core/common/config/test-ids.ts | 10 + .../src/core/common/config/web-socket-urls.ts | 14 +- .../types/src/core/common/schema/object-id.ts | 39 +- .../src/core/profile/config/profile-config.ts | 23 + .../submission/config/submission-config.ts | 12 + .../core/user/schema/user-entity.schema.ts | 30 +- libs/types/src/elixir-generated.ts | 30 + libs/types/src/generated/elixir-openapi.ts | 4139 ++++++ libs/types/src/index.ts | 3 + libs/types/tmp/elixir-generated.ts | 30 + libs/types/tmp/openapi.json | 564 + libs/types/tsconfig.json | 2 +- package.json | 3 + pnpm-lock.yaml | 2627 +++- 486 files changed, 57966 insertions(+), 1292 deletions(-) create mode 100644 libs/elixir-backend/.env.example create mode 100644 libs/elixir-backend/README.md create mode 100644 libs/elixir-backend/codincod_api/.dockerignore create mode 100644 libs/elixir-backend/codincod_api/.formatter.exs create mode 100644 libs/elixir-backend/codincod_api/.gitignore create mode 100644 libs/elixir-backend/codincod_api/AGENTS.md create mode 100644 libs/elixir-backend/codincod_api/ARCHITECTURE.md create mode 100644 libs/elixir-backend/codincod_api/Dockerfile create mode 100644 libs/elixir-backend/codincod_api/README.md create mode 100644 libs/elixir-backend/codincod_api/config/config.exs create mode 100644 libs/elixir-backend/codincod_api/config/dev.exs create mode 100644 libs/elixir-backend/codincod_api/config/prod.exs create mode 100644 libs/elixir-backend/codincod_api/config/runtime.exs create mode 100644 libs/elixir-backend/codincod_api/config/test.exs create mode 100644 libs/elixir-backend/codincod_api/cookies.txt create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/accounts.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/accounts/email.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/accounts/password.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/accounts/password_reset.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/accounts/preference.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/accounts/user.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/accounts/user_ban.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/application.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/chat.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/chat/chat_message.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/comments.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/comments/comment.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/comments/comment_vote.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/games.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/games/game.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/games/game_player.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/languages.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/languages/programming_language.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/mailer.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/metrics.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/metrics/leaderboard_snapshot.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/metrics/user_metric.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/moderation.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/moderation/moderation_review.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/moderation/report.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/piston.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/piston/client.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/piston/mock.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/puzzles.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle_example.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle_metric.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle_test_case.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle_validator.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/repo.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/submissions.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/submissions/evaluator.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/submissions/submission.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api/typegen.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/auth/error_handler.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/auth/guardian.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/auth/pipeline.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/channels/game_channel.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/channels/user_socket.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/account_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/account_preference_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/auth_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/comment_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/error_json.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/execute_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/fallback_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/game_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/health_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/leaderboard_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/metrics_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/moderation_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/open_api_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/password_reset_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/programming_language_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/puzzle_comment_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/puzzle_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/submission_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/user_controller.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/endpoint.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/gettext.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/open_api.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/account.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/auth.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/comment.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/common.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/execute.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/games.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/leaderboard.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/metrics.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/moderation.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/password_reset.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/puzzle.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/submission.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/user.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/plugs/attach_token_from_cookie.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/plugs/current_user.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/plugs/open_api_spec.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/plugs/render_open_api.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/router.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/serializers/helpers.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/serializers/puzzle_serializer.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/serializers/submission_serializer.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/serializers/user_serializer.ex create mode 100644 libs/elixir-backend/codincod_api/lib/codincod_api_web/telemetry.ex create mode 100644 libs/elixir-backend/codincod_api/lib/mix/tasks/codincod.gen_openapi_spec.ex create mode 100644 libs/elixir-backend/codincod_api/lib/mix/tasks/codincod.gen_types.ex create mode 100644 libs/elixir-backend/codincod_api/lib/mix/tasks/gen_typescript_types.ex create mode 100644 libs/elixir-backend/codincod_api/lib/mix/tasks/migrate_mongo.ex create mode 100644 libs/elixir-backend/codincod_api/lib/mix/tasks/mongo.inspect.ex create mode 100644 libs/elixir-backend/codincod_api/mix.exs create mode 100644 libs/elixir-backend/codincod_api/mix.lock create mode 100644 libs/elixir-backend/codincod_api/openapi.json create mode 100644 libs/elixir-backend/codincod_api/priv/gettext/en/LC_MESSAGES/errors.po create mode 100644 libs/elixir-backend/codincod_api/priv/gettext/errors.pot create mode 100644 libs/elixir-backend/codincod_api/priv/repo/migrations/.formatter.exs create mode 100644 libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090000_create_accounts_tables.exs create mode 100644 libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090100_create_programming_languages.exs create mode 100644 libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090300_create_puzzles_tables.exs create mode 100644 libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090400_create_submissions_tables.exs create mode 100644 libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090500_create_games_tables.exs create mode 100644 libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090600_create_comments_tables.exs create mode 100644 libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090700_create_reports_and_chat.exs create mode 100644 libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090800_create_metrics_tables.exs create mode 100644 libs/elixir-backend/codincod_api/priv/repo/migrations/20251102000001_create_puzzle_test_cases.exs create mode 100644 libs/elixir-backend/codincod_api/priv/repo/migrations/20251102000002_create_puzzle_examples.exs create mode 100644 libs/elixir-backend/codincod_api/priv/repo/migrations/20251102154346_create_password_resets.exs create mode 100644 libs/elixir-backend/codincod_api/priv/repo/scripts/extract_puzzle_sub_schemas.exs create mode 100644 libs/elixir-backend/codincod_api/priv/repo/scripts/seed_test_data_mongodb.exs create mode 100644 libs/elixir-backend/codincod_api/priv/repo/scripts/verify_migration.exs create mode 100644 libs/elixir-backend/codincod_api/priv/repo/seeds.exs create mode 100644 libs/elixir-backend/codincod_api/priv/static/favicon.ico create mode 100644 libs/elixir-backend/codincod_api/priv/static/openapi.json create mode 100644 libs/elixir-backend/codincod_api/priv/static/robots.txt create mode 100644 libs/elixir-backend/codincod_api/test/codincod_api_web/controllers/error_json_test.exs create mode 100644 libs/elixir-backend/codincod_api/test/codincod_api_web/controllers/submission_controller_test.exs create mode 100644 libs/elixir-backend/codincod_api/test/codincod_api_web/controllers/user_controller_test.exs create mode 100644 libs/elixir-backend/codincod_api/test/support/conn_case.ex create mode 100644 libs/elixir-backend/codincod_api/test/support/data_case.ex create mode 100644 libs/elixir-backend/codincod_api/test/test_helper.exs create mode 100644 libs/elixir-backend/codincod_api/test_migration.sh create mode 100644 libs/elixir-backend/complete_migration.exs create mode 100644 libs/elixir-backend/docker-compose.yml create mode 100644 libs/elixir-backend/init.sql create mode 100644 libs/elixir-backend/migrate.sh create mode 100644 libs/elixir-backend/validate_migration.exs create mode 100644 libs/frontend/knip.json create mode 100644 libs/frontend/orval.config.ts create mode 100644 libs/frontend/src/lib/api/custom-client.ts create mode 100644 libs/frontend/src/lib/api/error-handler.ts create mode 100644 libs/frontend/src/lib/api/errors.ts create mode 100644 libs/frontend/src/lib/api/generated/account-preferences/account-preferences.ts create mode 100644 libs/frontend/src/lib/api/generated/account/account.ts create mode 100644 libs/frontend/src/lib/api/generated/auth/auth.ts create mode 100644 libs/frontend/src/lib/api/generated/default/default.ts create mode 100644 libs/frontend/src/lib/api/generated/execute/execute.ts create mode 100644 libs/frontend/src/lib/api/generated/games/games.ts create mode 100644 libs/frontend/src/lib/api/generated/health/health.ts create mode 100644 libs/frontend/src/lib/api/generated/index.ts create mode 100644 libs/frontend/src/lib/api/generated/leaderboard/leaderboard.ts create mode 100644 libs/frontend/src/lib/api/generated/metrics/metrics.ts create mode 100644 libs/frontend/src/lib/api/generated/moderation/moderation.ts create mode 100644 libs/frontend/src/lib/api/generated/password-reset/password-reset.ts create mode 100644 libs/frontend/src/lib/api/generated/puzzle/puzzle.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/accountPreferences.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/accountPreferencesEditor.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/accountPreferencesTheme.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateResponseProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/accountStatusResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/activityResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/activityResponseActivity.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemAuthor.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemAuthorProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemSolution.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemValidatorsItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemProgrammingLanguage.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemPuzzle.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemResult.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemUser.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemUserProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/activityResponseUser.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/activityResponseUserProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/authMessageResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/author.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/authorProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/availabilityResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/banResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/banUserRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebHealthControllerShow200.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebHealthControllerShow2200.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobal2GameMode.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobal2Params.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobalGameMode.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobalParams.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerPuzzle2Params.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerPuzzleParams.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2Params.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2ProblemType.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2Status.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsParams.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsProblemType.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsStatus.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviews2Params.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviews2Status.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviewsParams.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviewsStatus.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebProgrammingLanguageControllerIndex200Item.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebProgrammingLanguageControllerIndex2200Item.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebPuzzleControllerIndex2Params.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebPuzzleControllerIndexParams.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebUserControllerPuzzles2Params.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/codincodApiWebUserControllerPuzzlesParams.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/commentCreateRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/commentResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/commentResponseAuthor.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/commentResponseCommentType.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/commentVoteRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/commentVoteRequestType.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/createGameRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/createGameRequestGameMode.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/createReportRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/createReportRequestContentType.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/createReportRequestProblemType.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/createRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/createRequestDifficulty.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/createRequestValidatorsItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/errorResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/errorResponseErrors.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/executeRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/executeResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/executeResponseCompile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/executeResponsePuzzleResultInformation.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/executeResponsePuzzleResultInformationResult.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/executeResponseRun.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/gameResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/gameResponseOwner.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/gameResponsePlayersItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/gameResponsePuzzle.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/gameSubmitCodeRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponseRankingsItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponseRankingsItemGlicko.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/index.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/leaveGameResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/loginRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/messageResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/paginatedListResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemAuthor.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemAuthorProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemSolution.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemValidatorsItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/passwordResetCompleteResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/passwordResetPayload.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/passwordResetRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/passwordResetResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/platformMetricsResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/platformMetricsResponsePopularPuzzlesItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/preferencesPayload.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/preferencesPayloadEditor.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/preferencesPayloadTheme.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/profile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/profileUpdateRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/profileUpdateResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/profileUpdateResponseProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/programmingLanguageSummary.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequestDifficulty.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequestValidatorsItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzleLeaderboardResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzleLeaderboardResponseRankingsItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemAuthor.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemAuthorProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemSolution.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemValidatorsItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzleResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzleResponseAuthor.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzleResponseAuthorProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzleResponseSolution.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzleResponseValidatorsItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzleResultInformation.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzleResultInformationResult.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponseLanguageDistributionItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponseStatusBreakdown.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/puzzleSummary.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/registerRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/reportResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/reportResponseReportedBy.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/reportResponseResolvedBy.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/reportsListResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/requestPayload.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/requestResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/resetPayload.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/resetResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/resolveReportRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/resolveReportRequestStatus.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/reviewDecisionRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/reviewDecisionRequestStatus.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/reviewResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/reviewResponseContextMessagesItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/reviewResponseReviewer.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/reviewsListResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/showResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/showResponseUser.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/showResponseUserProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/solution.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submissionListResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submissionListResponseItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemProgrammingLanguage.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemPuzzle.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemResult.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemUser.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemUserProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submissionResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submissionResponseProgrammingLanguage.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submissionResponsePuzzle.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submissionResponseResult.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submissionResponseUser.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submissionResponseUserProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submissionSubmitRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submissionSubmitResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submissionSubmitResponseResult.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submitCodeRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submitCodeResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/submitCodeResponseResult.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/summary.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/summaryProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userActivityResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivity.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemAuthor.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemAuthorProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemSolution.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemValidatorsItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemProgrammingLanguage.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemPuzzle.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemResult.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemUser.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemUserProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userActivityResponseUser.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userActivityResponseUserProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userAvailabilityResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userGamesResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemOwner.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemPlayersItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemPuzzle.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userRankResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userShowResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userShowResponseUser.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userShowResponseUserProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userStatsResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userStatsResponseDifficultyBreakdown.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userStatsResponseLanguageUsageItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userSummary.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/userSummaryProfile.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/validator.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/voteRequest.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/voteRequestType.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponse.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemOwner.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemPlayersItem.ts create mode 100644 libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemPuzzle.ts create mode 100644 libs/frontend/src/lib/api/generated/submission/submission.ts create mode 100644 libs/frontend/src/lib/api/generated/user/user.ts create mode 100644 libs/frontend/src/lib/api/notifications.ts create mode 100644 libs/frontend/src/lib/features/authentication/utils/is-sveltekit-redirect.ts create mode 100644 libs/frontend/src/lib/stores/auth.store.ts rename libs/frontend/src/lib/stores/{current-time.ts => current-time.store.ts} (100%) create mode 100644 libs/frontend/src/lib/stores/languages.store.ts delete mode 100644 libs/frontend/src/lib/stores/languages.ts rename libs/frontend/src/lib/stores/{preferences.ts => preferences.store.ts} (54%) rename libs/frontend/src/lib/stores/{index.ts => theme.store.ts} (52%) create mode 100644 libs/frontend/src/lib/utils/debug-logger.ts create mode 100644 libs/frontend/src/routes/(unauthenticated-only)/forgot-password/+page.server.ts create mode 100644 libs/frontend/src/routes/(unauthenticated-only)/forgot-password/+page.svelte create mode 100644 libs/frontend/src/routes/(unauthenticated-only)/reset-password/+page.server.ts create mode 100644 libs/frontend/src/routes/(unauthenticated-only)/reset-password/+page.svelte create mode 100644 libs/types/src/core/profile/config/profile-config.ts create mode 100644 libs/types/src/core/submission/config/submission-config.ts create mode 100644 libs/types/src/elixir-generated.ts create mode 100644 libs/types/src/generated/elixir-openapi.ts create mode 100644 libs/types/tmp/elixir-generated.ts create mode 100644 libs/types/tmp/openapi.json diff --git a/libs/backend/src/services/game-mode.service.ts b/libs/backend/src/services/game-mode.service.ts index b04e16ca..3c3a8de1 100644 --- a/libs/backend/src/services/game-mode.service.ts +++ b/libs/backend/src/services/game-mode.service.ts @@ -12,8 +12,15 @@ type PopulatedSubmission = Omit & { user: ObjectId | { _id: ObjectId; username: string }; }; -function isPopulatedUser(user: ObjectId | { _id: ObjectId; username: string }): user is { _id: ObjectId; username: string } { - return typeof user === "object" && user !== null && "_id" in user && "username" in user; +function isPopulatedUser( + user: ObjectId | { _id: ObjectId; username: string } +): user is { _id: ObjectId; username: string } { + return ( + typeof user === "object" && + user !== null && + "_id" in user && + "username" in user + ); } /** diff --git a/libs/elixir-backend/.env.example b/libs/elixir-backend/.env.example new file mode 100644 index 00000000..c96c7955 --- /dev/null +++ b/libs/elixir-backend/.env.example @@ -0,0 +1,128 @@ +# =================================== +# Database Configuration +# =================================== + +# Local PostgreSQL (Docker) +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/codincod_dev +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=codincod_dev +DATABASE_POOL_SIZE=10 + +# Cloud PostgreSQL (Production - example) +# DATABASE_URL=postgresql://user:pass@your-db.example.com:5432/codincod_prod?ssl=true +# DATABASE_SSL=true +# DATABASE_IPV6=false + +# Test Database +TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5433/codincod_test + +# =================================== +# MongoDB (Legacy - for migration) +# =================================== + +MONGO_URI=mongodb://codincod-dev:hunter2@localhost:27017 +MONGO_DB_NAME=codincod-development +MONGO_USERNAME=codincod-dev +MONGO_PASSWORD=hunter2 + +# =================================== +# Phoenix Application +# =================================== + +# Generate with: mix phx.gen.secret +SECRET_KEY_BASE=your_secret_key_base_here_at_least_64_chars_long_generate_with_mix_phx_gen_secret + +PHX_HOST=localhost +PHX_PORT=4000 +PHX_SERVER=true + +# URLs +FRONTEND_URL=http://localhost:5173 +BACKEND_URL=http://localhost:4000 + +# =================================== +# Authentication & Security +# =================================== + +# Bcrypt work factor (higher = more secure but slower) +BCRYPT_ROUNDS=12 + +# JWT Configuration +JWT_SECRET=your_jwt_secret_here +JWT_EXPIRY=7d +JWT_ISSUER=codincod_api + +# Session Configuration +SESSION_SIGNING_SALT=your_session_signing_salt_here +SESSION_ENCRYPTION_SALT=your_session_encryption_salt_here +SESSION_TTL_DAYS=14 +PASSWORD_RESET_TTL_HOURS=1 +EMAIL_CONFIRMATION_TTL_HOURS=24 + +# =================================== +# External Services +# =================================== + +# Piston API (Code Execution) +PISTON_URI=http://localhost:2000 + +# Redis (Caching & Rate Limiting) +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD=redis_password +REDIS_DATABASE=0 + +# =================================== +# CORS Configuration +# =================================== + +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 + +# =================================== +# Email Configuration (Swoosh) +# =================================== + +# For development (using local adapter) +MAILER_ADAPTER=local + +# For production (example with SendGrid) +# MAILER_ADAPTER=sendgrid +# SENDGRID_API_KEY=your_sendgrid_api_key_here +# MAILER_FROM_EMAIL=noreply@codincod.com +# MAILER_FROM_NAME=CodinCod + +# =================================== +# Background Jobs (Oban) +# =================================== + +OBAN_ENABLED=true +OBAN_QUEUES_DEFAULT=10 +OBAN_QUEUES_MAILER=5 +OBAN_QUEUES_EVENTS=20 + +# =================================== +# Rate Limiting +# =================================== + +RATE_LIMIT_ENABLED=true +RATE_LIMIT_PER_MINUTE=60 +RATE_LIMIT_LOGIN_PER_HOUR=5 + +# =================================== +# Logging & Monitoring +# =================================== + +LOG_LEVEL=info + +# Sentry (optional - for production error tracking) +# SENTRY_DSN=https://your_sentry_dsn_here + +# =================================== +# Development & Testing +# =================================== + +MIX_ENV=dev +NODE_ENV=development diff --git a/libs/elixir-backend/README.md b/libs/elixir-backend/README.md new file mode 100644 index 00000000..b40f5b97 --- /dev/null +++ b/libs/elixir-backend/README.md @@ -0,0 +1,425 @@ +# CodinCod Elixir API Backend + +Modern Elixir/Phoenix backend for the CodinCod coding puzzle platform. This is a complete rewrite of the Node.js/Fastify backend with improved performance, scalability, and real-time capabilities. + +## Features + +- 🔐 **JWT Authentication** with Guardian +- 🎮 **Real-Time Multiplayer** with Phoenix Channels +- 🧩 **Puzzle Management** with advanced search and filtering +- 💻 **Code Execution** via Piston API integration +- 📊 **Leaderboards & Statistics** with ELO-style rankings +- 💬 **Real-Time Chat** in multiplayer games +- 🛡️ **Moderation Tools** for content review +- 📈 **Background Jobs** with Oban +- ⚡ **Rate Limiting** with Hammer +- 🗄️ **PostgreSQL** database with UUIDs + +## Tech Stack + +- **Elixir** 1.15+ +- **Phoenix** 1.8+ (API-only) +- **PostgreSQL** 16+ +- **Ecto** 3.13+ (ORM) +- **Guardian** 2.3+ (JWT) +- **Oban** 2.18+ (Background jobs) +- **Phoenix Channels** (WebSockets) + +## Prerequisites + +- Elixir 1.15 or higher +- Erlang/OTP 26 or higher +- PostgreSQL 16 or higher +- Docker & Docker Compose (optional) + +## Installation + +### 1. Install Dependencies + +```bash +cd libs/elixir-backend/codincod_api +mix deps.get +``` +### 2. Start Infrastructure (Docker) + +```bash +cd libs/elixir-backend +docker compose up -d postgres piston redis +``` + +> The compose file also provides an `api` service. Run `docker compose up --build api` +> if you prefer the Phoenix server to run inside Docker instead of your local Elixir toolchain. + +### 3. Configure Environment + +```bash +cp .env.example .env +# Edit .env with your configuration +``` + +### 4. Create and Migrate Database + +```bash +mix ecto.create +mix ecto.migrate +``` + +### 5. Seed Database (Optional) + +```bash +mix run priv/repo/seeds.exs +``` + +### 6. Start Phoenix Server + +```bash +mix phx.server +``` + +Or inside IEx: + +```bash +iex -S mix phx.server +``` + +The API will be available at http://localhost:4000 + +## Development + +### Running Tests + +```bash +# Run all tests +mix test + +# Run with coverage +mix test --cover + +# Watch mode +mix test.watch +``` + +### Code Quality + +```bash +# Linting with Credo +mix credo + +# Static analysis with Dialyzer +mix dialyzer + +# Format code +mix format +``` + +### Database + +```bash +# Create migration +mix ecto.gen.migration migration_name + +# Run migrations +mix ecto.migrate + +# Rollback +mix ecto.rollback + +# Reset database +mix ecto.reset +``` + +### Generating Code + +```bash +# Generate context with schema +mix phx.gen.context Accounts User users email:string username:string + +# Generate schema only +mix phx.gen.schema Accounts.User users email:string + +# Generate JSON API +mix phx.gen.json Accounts User users email:string +``` + +## Project Structure + +``` +codincod_api/ +├── config/ # Application configuration +│ ├── config.exs # Base configuration +│ ├── dev.exs # Development config +│ ├── test.exs # Test config +│ ├── prod.exs # Production config +│ └── runtime.exs # Runtime config (env vars) +├── lib/ +│ ├── codincod_api/ # Core application +│ │ ├── accounts/ # User authentication & management +│ │ ├── puzzles/ # Puzzle system +│ │ ├── submissions/ # Code submissions +│ │ ├── games/ # Multiplayer games +│ │ ├── chat/ # Real-time chat +│ │ ├── comments/ # Comments & voting +│ │ ├── moderation/ # Content moderation +│ │ ├── metrics/ # Statistics & leaderboards +│ │ ├── languages/ # Programming languages +│ │ └── repo.ex # Database repository +│ └── codincod_api_web/ # Web interface +│ ├── channels/ # WebSocket channels +│ ├── controllers/ # HTTP controllers +│ ├── auth/ # Authentication (Guardian) +│ ├── plugs/ # Custom plugs +│ ├── views/ # JSON views +│ └── router.ex # Route definitions +├── priv/ +│ ├── repo/ +│ │ ├── migrations/ # Database migrations +│ │ └── seeds.exs # Seed data +│ └── static/ # Static files +├── test/ # Tests +│ ├── codincod_api/ # Context tests +│ ├── codincod_api_web/ # Controller tests +│ └── support/ # Test helpers & factories +└── mix.exs # Dependencies & config +``` + +## API Documentation + +### Authentication Endpoints + +``` +POST /api/register - Register new user +POST /api/login - Login user +POST /api/logout - Logout user +POST /api/refresh - Refresh JWT token +GET /api/user - Get current user +``` + +### User Endpoints + +``` +GET /api/users/:id - Get user profile +PUT /api/users/:id - Update user +GET /api/users/:username/activity +GET /api/users/:username/puzzles +``` + +### Puzzle Endpoints + +``` +GET /api/puzzle - List puzzles +POST /api/puzzle - Create puzzle +GET /api/puzzle/:id - Get puzzle +PUT /api/puzzle/:id - Update puzzle +DELETE /api/puzzle/:id - Delete puzzle +GET /api/puzzle/:id/comments +POST /api/puzzle/:id/comments +``` + +### Submission Endpoints + +``` +GET /api/submission - List user submissions +POST /api/submission - Submit code +GET /api/submission/:id - Get submission +``` + +### Game Endpoints + +``` +WebSocket: /socket/waiting_room - Waiting room lobby +WebSocket: /socket/game/:id - Game room +``` + +## WebSocket Events + +### Waiting Room Channel + +```elixir +# Join waiting room +Phoenix.Channel.join("waiting_room:lobby") + +# Host a room +push("room:host", %{options: %{visibility: "public"}}) + +# Join a room +push("room:join", %{room_id: "abc123"}) + +# Events received +handle_in("rooms:overview", payload) +handle_in("game:start", %{game_url: url}) +``` + +### Game Channel + +```elixir +# Join game +Phoenix.Channel.join("game:#{game_id}") + +# Submit code +push("game:submit", %{code: code, language: "python"}) + +# Send chat message +push("chat:message", %{message: "Hello!"}) + +# Events received +handle_in("game:update", game_state) +handle_in("player:submitted", %{user: user}) +handle_in("chat:message", message) +``` + +## Environment Variables + +See `.env.example` for all available configuration options. + +Key variables: + +```bash +DATABASE_URL=postgresql://user:pass@localhost/db +SECRET_KEY_BASE=generate_with_mix_phx_gen_secret +JWT_SECRET=your_jwt_secret +PISTON_URI=http://localhost:2000 +FRONTEND_URL=http://localhost:5173 +``` + +## Background Jobs + +The application uses Oban for background job processing: + +```elixir +# Queue a code execution job +%{submission_id: submission.id} +|> CodincodApi.Workers.ExecuteSubmission.new() +|> Oban.insert() + +# Queue a statistics update +%{user_id: user.id} +|> CodincodApi.Workers.UpdateStatistics.new() +|> Oban.insert() +``` + +## Deployment + +### Using Docker + +```bash +# Build image +docker build -t codincod-api . + +# Run container +docker run -p 4000:4000 \ + -e DATABASE_URL=... \ + -e SECRET_KEY_BASE=... \ + codincod-api +``` + +### Using Mix Release + +```bash +# Build release +MIX_ENV=prod mix release + +# Run release +_build/prod/rel/codincod_api/bin/codincod_api start +``` + +## Monitoring + +Access Phoenix LiveDashboard at: http://localhost:4000/dev/dashboard + +Metrics include: +- Request rates and latencies +- Database query performance +- Background job statistics +- WebSocket connection counts +- System resource usage + +## Migration from MongoDB + +To migrate data from the existing MongoDB database: + +```bash +# Run full migration +mix migrate_mongo + +# Migrate specific entities +mix migrate_mongo --only users +mix migrate_mongo --only puzzles + +# Validate migration +mix migrate_mongo --validate +``` + +## TypeScript Type Generation + +The Elixir backend publishes an OpenAPI document that feeds the shared `libs/types` package. + +```bash +# From libs/elixir-backend/codincod_api +mix codincod.gen_openapi_spec + +# From repo root (requires pnpm) +pnpm --filter types run openapi:types +``` + +The second command regenerates `libs/types/src/generated/elixir-openapi.ts`, keeping the frontend +contracts in sync with the Phoenix controllers. Run these steps after adding or changing endpoints +or schemas. + +## Troubleshooting + +### Database Connection Issues + +```bash +# Check if PostgreSQL is running +docker compose ps + +docker-compose restart postgres +# Restart PostgreSQL +docker compose restart postgres + +docker-compose logs postgres +# Check logs +docker compose logs postgres +``` + +### Compilation Errors + +```bash +# Clean and recompile +mix clean +mix compile +``` + +### Port Already in Use + +```bash +# Kill process on port 4000 +lsof -ti:4000 | xargs kill -9 +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## Resources + +- [Phoenix Framework](https://phoenixframework.org/) +- [Ecto Documentation](https://hexdocs.pm/ecto/) +- [Guardian Documentation](https://hexdocs.pm/guardian/) +- [Oban Documentation](https://hexdocs.pm/oban/) +- [Phoenix Channels Guide](https://hexdocs.pm/phoenix/channels.html) + +## License + +Copyright © 2024 CodinCod + +## Support + +For issues and questions: +- Open an issue on GitHub +- Check the [Migration Guide](./MIGRATION_GUIDE.md) +- Consult the Phoenix documentation diff --git a/libs/elixir-backend/codincod_api/.dockerignore b/libs/elixir-backend/codincod_api/.dockerignore new file mode 100644 index 00000000..2cfdc246 --- /dev/null +++ b/libs/elixir-backend/codincod_api/.dockerignore @@ -0,0 +1,5 @@ +_build +cover +deps +node_modules +*.ez diff --git a/libs/elixir-backend/codincod_api/.formatter.exs b/libs/elixir-backend/codincod_api/.formatter.exs new file mode 100644 index 00000000..5971023f --- /dev/null +++ b/libs/elixir-backend/codincod_api/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}", "priv/*/seeds.exs"] +] diff --git a/libs/elixir-backend/codincod_api/.gitignore b/libs/elixir-backend/codincod_api/.gitignore new file mode 100644 index 00000000..fd35d612 --- /dev/null +++ b/libs/elixir-backend/codincod_api/.gitignore @@ -0,0 +1,27 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +codincod_api-*.tar + diff --git a/libs/elixir-backend/codincod_api/AGENTS.md b/libs/elixir-backend/codincod_api/AGENTS.md new file mode 100644 index 00000000..f96a4024 --- /dev/null +++ b/libs/elixir-backend/codincod_api/AGENTS.md @@ -0,0 +1,99 @@ +This is a web application written using the Phoenix web framework. + +## Project guidelines + +- Use `mix precommit` alias when you are done with all changes and fix any pending issues +- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps + +### Phoenix v1.8 guidelines + +- **Always** begin your LiveView templates with `` which wraps all inner content +- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again +- Anytime you run into errors with no `current_scope` assign: + - You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `` + - **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed +- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module +- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar +- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will will save steps and prevent errors +- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your +custom classes must fully style the input + + + + + +## Elixir guidelines + +- Elixir lists **do not support index based access via the access syntax** + + **Never do this (invalid)**: + + i = 0 + mylist = ["blue", "green"] + mylist[i] + + Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie: + + i = 0 + mylist = ["blue", "green"] + Enum.at(mylist, i) + +- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc + you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie: + + # INVALID: we are rebinding inside the `if` and the result never gets assigned + if connected?(socket) do + socket = assign(socket, :val, val) + end + + # VALID: we rebind the result of the `if` to a new variable + socket = + if connected?(socket) do + assign(socket, :val, val) + end + +- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors +- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets +- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package) +- Don't use `String.to_atom/1` on user input (memory leak risk) +- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards +- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)` +- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option + +## Mix guidelines + +- Read the docs and options before using tasks (by using `mix help task_name`) +- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed` +- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason + + + +## Phoenix guidelines + +- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes. + +- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie: + + scope "/admin", AppWeb.Admin do + pipe_through :browser + + live "/users", UserLive, :index + end + + the UserLive route would point to the `AppWeb.Admin.UserLive` module + +- `Phoenix.View` no longer is needed or included with Phoenix, don't use it + + + +## Ecto Guidelines + +- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email` +- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs` +- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string` +- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed +- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields +- Fields which are set programatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct + + + \ No newline at end of file diff --git a/libs/elixir-backend/codincod_api/ARCHITECTURE.md b/libs/elixir-backend/codincod_api/ARCHITECTURE.md new file mode 100644 index 00000000..229255b3 --- /dev/null +++ b/libs/elixir-backend/codincod_api/ARCHITECTURE.md @@ -0,0 +1,202 @@ +# CodinCod Elixir Backend Architecture Plan + +This document translates the current Fastify/MongoDB backend into Phoenix/Ecto building blocks. It is derived from the live TypeScript codebase (`libs/backend`) and does not rely on the legacy migration draft. + +## High-Level Overview + +- **Runtime:** Phoenix 1.8 (API-only) with Bandit. +- **Persistence:** PostgreSQL 16 via Ecto with UUID primary keys. +- **Auth & Sessions:** Guardian JWT pipeline, stateless cookies for the frontend. +- **Realtime:** Phoenix Channels for waiting room and in-game events. +- **Background Work:** Oban queues for code execution, leaderboard recomputes, and moderation notifications. +- **External Services:** Piston HTTP API for code execution. +- **Type Interop:** Automated TypeScript type export for request/response DTOs and persisted schemas. + +## Domain Contexts + +| Context | Responsibilities | References in TS backend | +| --- | --- | --- | +| `CodincodApi.Accounts` | User CRUD, registration, credential verification, ban tracking | `models/user`, `services/user.service.ts`, `routes/login`, `routes/register`, `routes/user` | +| `CodincodApi.Accounts.Preferences` | Editor/UX settings, blocked users, preferred language | `models/preferences`, `routes/account/preferences` | +| `CodincodApi.Auth` | Guardian integration, cookie/session helpers, rate limit overlays | `plugins/config/jwt.ts`, `utils/functions/generate-token.ts` | +| `CodincodApi.Puzzles` | Puzzle authoring, publishing workflow, validator management, metrics | `models/puzzle`, `services/puzzle.service.ts`, `routes/puzzle/**`, `routes/moderation/puzzle/**` | +| `CodincodApi.Submissions` | User submissions, result storage, piston dispatch, puzzle/game linking | `models/submission`, `routes/submission/**`, `services/submission.service.ts` | +| `CodincodApi.Languages` | Supported programming languages and versions | `models/programming-language`, `routes/programming-language` | +| `CodincodApi.Comments` | Nested comment threads, votes, moderation flags | `models/comment`, `routes/comment/**` | +| `CodincodApi.Chat` | Game chat persistence and moderation | `models/chat`, `websocket/game`, `routes/report` | +| `CodincodApi.Games` | Waiting rooms, matchmaking, game state lifecycle | `websocket/waiting-room`, `websocket/game`, `services/game.service.ts`, `routes/submission/game` | +| `CodincodApi.Leaderboard` | Metrics aggregation, hourly cron rebuild, admin recalculation endpoint | `services/leaderboard.service.ts`, `routes/leaderboard/**`, `config/cron.ts` | +| `CodincodApi.Moderation` | Reports, review queue, bans, puzzle approvals | `models/moderation/**`, `routes/moderation/**`, `routes/report` | +| `CodincodApi.Execute` | REST proxy to Piston, runtime cache | `routes/execute`, `plugins/decorators/piston.ts` | +| `CodincodApi.Migration` | Mongo -> Postgres data import tooling | `scripts/migration_attempt.md`, direct access to Mongo collections | + +Each context will expose a boundary module (e.g., `CodincodApi.Puzzles`) with public functions that align with the service layer in the TypeScript backend. + +## Data Model Plan + +Tables use snake_case; Ecto schemas expose camelCase fields where the API contracts require them. + +### Core Entities + +- `users` + - `username` (citext, unique), `email` (citext, unique), `password_hash`, `profile` (jsonb), `role` (enum), `report_count`, `ban_count` + - `current_ban_id` FK → `user_bans` + +- `user_bans` + - `user_id`, `banned_by_id`, `reason`, `ban_type` (`temporary`/`permanent`), `expires_at`, `metadata` + +- `preferences` + - `user_id` (unique FK), `blocked_user_ids` (uuid[]), `theme`, `preferred_language_id`, `editor` (jsonb) + +- `programming_languages` + - Aligned with `ProgrammingLanguageEntity` (name, slug, version, runtime key, aliases, is_active) + +- `puzzles` + - `author_id`, `title`, `statement`, `constraints`, `difficulty`, `visibility`, `solution` (jsonb), `tags` (text[]), `metrics_id` + - `validators` stored in `puzzle_validators` + +- `puzzle_validators` + - `puzzle_id`, `name`, `description`, `input`, `output`, `is_public` + +- `puzzle_metrics` + - `puzzle_id`, `attempt_count`, `success_count`, `avg_execution_ms` + +- `submissions` + - `puzzle_id`, `user_id`, `game_id`, `language_id`, `code`, `result` (jsonb), `status`, execution metadata + +- `games` + - `owner_id`, `puzzle_id`, `options` (jsonb), `visibility`, `state`, `started_at`, `ended_at` + +- `game_players` + - Join table: `game_id`, `user_id`, `joined_at`, `submission_id`, `score` + +- `chat_messages` + - `game_id`, `user_id`, `username_snapshot`, `message`, `is_deleted` + +- `comments` + - `author_id`, `commentable_type` (`puzzle`/`submission`), `commentable_id`, `body`, `comment_type`, `upvotes`, `downvotes`, `parent_id` + +- `comment_votes` + - `comment_id`, `user_id`, `vote_type` + +- `reports` + - `reported_user_id`, `reporter_id`, `reason`, `status`, `payload`, `resolved_by_id` + +- `moderation_reviews` + - `puzzle_id`, `reviewer_id`, `status`, `notes` + +- `user_metrics` + - `user_id`, `rating`, `rank`, `puzzles_solved`, `puzzles_attempted`, `win_rate`, `streak` + +- `leaderboard_snapshots` + - `game_mode`, `captured_at`, `entries` (array of user metrics slice) + +### Supporting Tables + +- `api_keys` (service-to-service auth, future use) +- `audit_logs` (record sensitive moderation/admin actions) +- `legacy_ids` (maps Mongo `_id` to new UUID for migration idempotency) + +## REST API Mapping + +Routers will be structured under `CodincodApiWeb.Router` as: + +``` +scope "/api" do + pipe_through [:api, :rate_limit] + + post "/register", AuthController, :register + post "/login", AuthController, :login + post "/logout", AuthController, :logout + post "/refresh", AuthController, :refresh + + scope "/user" do + pipe_through [:jwt_optional] + get "/:username", UserController, :show_by_username + get "/:username/activity", UserController, :activity + get "/:username/puzzle", UserController, :puzzles + get "/:username/isAvailable", UserController, :is_available + end + + scope "/account" do + pipe_through [:jwt_required] + get "/preferences", AccountController, :preferences + put "/preferences", AccountController, :update_preferences + end + + resources "/puzzle", PuzzleController, except: [:new, :edit] + resources "/submission", SubmissionController, only: [:index, :create, :show] + + post "/submission/game", GameSubmissionController, :create + get "/execute", ExecuteController, :show + + resources "/comment", CommentController, only: [:show, :update, :delete] + post "/comment/:id/comment", CommentController, :reply + post "/comment/:id/vote", CommentVoteController, :vote + + resources "/programming-language", ProgrammingLanguageController, only: [:index, :show] + + scope "/leaderboard" do + get "/:game_mode", LeaderboardController, :show + get "/user/:id", LeaderboardController, :user + post "/recalculate", LeaderboardController, :recalculate + end + + scope "/moderation" do + pipe_through [:jwt_required, :ensure_moderator] + # Approvals, bans, reports + end + + get "/health", HealthController, :index +end +``` + +## Phoenix Channels + +- **WaitingRoomChannel** (`"waiting_room:lobby"`) + - events: `room:host`, `room:join`, `room:leave`, `rooms:overview`, `game:start` + - backed by `CodincodApi.Games.WaitingRoomRegistry` (Registry + DynamicSupervisor) + +- **GameChannel** (`"game:" <> game_id`) + - events: `game:join`, `game:leave`, `game:submit`, `player:submitted`, `chat:message`, `game:update` + - backed by per-game GenServers tracking state and timers + +## Background Processing + +Oban queues and workers: + +- `CodincodApi.Workers.ExecuteSubmission` → dispatches to Piston, updates submission result +- `CodincodApi.Workers.UpdatePuzzleMetrics` → updates puzzle stats after submission +- `CodincodApi.Workers.UpdateUserMetrics` → updates ELO / streak metrics +- `CodincodApi.Workers.LeaderboardRecalculate` → scheduled hourly instead of node-cron +- `CodincodApi.Workers.ProcessReport` → asynchronous moderation notifications + +## TypeScript Type Generation + +- Implementation module `CodincodApi.Typegen` will introspect Ecto schemas and API view definitions to output `.d.ts`/`.ts` into `libs/types/src/elixir-generated.ts`. +- Exports will include: + - Schema DTOs (User, Puzzle, Submission, Comment, Game, LeaderboardEntry, Report) + - API route payloads (request/response bodies) respecting Zod contracts currently used by the frontend + - Enum exports for roles, difficulties, visibilities, ban types, etc. +- Generation triggered via `mix codincod.gen_types --dest ../../types/src/elixir-generated.ts`. + +## Migration Strategy + +- Create migrations partitioned by domain (`2025xxxx_create_accounts.exs`, `..._puzzles.exs`, etc.) with explicit indexes reflecting Mongo usage (e.g., `users.username` unique, `puzzles.tags` gin index). +- Populate `legacy_ids` and `*_legacy_id` columns to allow idempotent Mongo import. +- Provide mix tasks `mix codincod.migrate_legacy users|puzzles|all`. + +## Testing Plan + +- Context-level unit tests for each public boundary. +- Controller tests using JSON API assertions mirroring Fastify behavior. +- Channel tests for WebSocket events (join, broadcast, game transitions). +- Integration tests for auth flows, leaderboard recalculation, moderation approvals. + +## Deployment Notes + +- Replace node cron with Oban Cron plugin (hourly leaderboard job). +- Rate limiting enforced via `Hammer.Plug` in router pipeline and `Guardian` for authentication. +- Security headers replicated using Plug-based configuration. + +This plan will guide the actual implementation files and ensure feature parity with the existing TypeScript backend. diff --git a/libs/elixir-backend/codincod_api/Dockerfile b/libs/elixir-backend/codincod_api/Dockerfile new file mode 100644 index 00000000..ad5704f2 --- /dev/null +++ b/libs/elixir-backend/codincod_api/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1 + +FROM hexpm/elixir:1.16.2-erlang-26.2.2-debian-bullseye-20240222 + +ENV LANG=C.UTF-8 \ + MIX_ENV=dev \ + HOME=/app + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + inotify-tools \ + git \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +RUN mix local.hex --force \ + && mix local.rebar --force + +COPY mix.exs mix.lock ./ +COPY config config + +RUN mix deps.get + +CMD ["sh", "-c", "mix deps.get && mix ecto.create && mix ecto.migrate && mix phx.server"] diff --git a/libs/elixir-backend/codincod_api/README.md b/libs/elixir-backend/codincod_api/README.md new file mode 100644 index 00000000..269273c0 --- /dev/null +++ b/libs/elixir-backend/codincod_api/README.md @@ -0,0 +1,18 @@ +# CodincodApi + +To start your Phoenix server: + +* Run `mix setup` to install and setup dependencies +* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` + +Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. + +Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). + +## Learn more + +* Official website: https://www.phoenixframework.org/ +* Guides: https://hexdocs.pm/phoenix/overview.html +* Docs: https://hexdocs.pm/phoenix +* Forum: https://elixirforum.com/c/phoenix-forum +* Source: https://github.com/phoenixframework/phoenix diff --git a/libs/elixir-backend/codincod_api/config/config.exs b/libs/elixir-backend/codincod_api/config/config.exs new file mode 100644 index 00000000..276b6266 --- /dev/null +++ b/libs/elixir-backend/codincod_api/config/config.exs @@ -0,0 +1,76 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +import Config + +config :codincod_api, + ecto_repos: [CodincodApi.Repo], + generators: [timestamp_type: :utc_datetime, binary_id: true] + +# Configures the endpoint +config :codincod_api, CodincodApiWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [json: CodincodApiWeb.ErrorJSON], + layout: false + ], + pubsub_server: CodincodApi.PubSub, + live_view: [signing_salt: "H/Z/XSwb"] + +# Configures the mailer +# +# By default it uses the "Local" adapter which stores the emails +# locally. You can see the emails in your browser, at "/dev/mailbox". +# +# For production it's recommended to configure a different adapter +# at the `config/runtime.exs`. +config :codincod_api, CodincodApi.Mailer, adapter: Swoosh.Adapters.Local + +# Configure Guardian for JWT authentication +config :codincod_api, CodincodApiWeb.Auth.Guardian, + issuer: "codincod_api", + secret_key: "your_guardian_secret_key_here_change_in_runtime" + +# Password hashing configuration +config :codincod_api, :password_adapter, Pbkdf2 + +# Runtime environment hints +config :codincod_api, :runtime_env, config_env() + +# Authentication cookie defaults +config :codincod_api, :auth_cookie, + name: "token", + max_age: 7 * 24 * 60 * 60 + +# Default Piston client implementation +config :codincod_api, :piston_client, CodincodApi.Piston.Client + +# Configure Oban for background jobs +config :codincod_api, Oban, + engine: Oban.Engines.Basic, + queues: [default: 10, mailer: 5, events: 20], + repo: CodincodApi.Repo + +# Configure Tesla HTTP client +config :tesla, adapter: Tesla.Adapter.Finch + +# Configure Hammer for rate limiting +config :hammer, + backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4, cleanup_interval_ms: 60_000 * 10]} + +# Configures Elixir's Logger +config :logger, :default_formatter, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id, :user_id, :remote_ip] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{config_env()}.exs" diff --git a/libs/elixir-backend/codincod_api/config/dev.exs b/libs/elixir-backend/codincod_api/config/dev.exs new file mode 100644 index 00000000..6532b9a2 --- /dev/null +++ b/libs/elixir-backend/codincod_api/config/dev.exs @@ -0,0 +1,46 @@ +import Config + +# Configure your database +config :codincod_api, CodincodApi.Repo, + username: System.get_env("POSTGRES_USER") || "postgres", + password: System.get_env("POSTGRES_PASSWORD") || "postgres", + hostname: System.get_env("POSTGRES_HOST") || "localhost", + database: System.get_env("POSTGRES_DB") || "codincod_dev", + stacktrace: true, + show_sensitive_data_on_connection_error: true, + pool_size: String.to_integer(System.get_env("DATABASE_POOL_SIZE") || "10") + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we can use it +# to bundle .js and .css sources. +config :codincod_api, CodincodApiWeb.Endpoint, + # Binding to loopback ipv4 address prevents access from other machines. + # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. + http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PHX_PORT") || "4000")], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "zl1WcfqPspXQdJKM7z7g5tW6586do4B+9RvPvdRDCft9yH9MEQ9F+AzeBczOiz3x", + watchers: [] + +# Enable dev routes for dashboard and mailbox +config :codincod_api, dev_routes: true + +# Do not include metadata nor timestamps in development logs +config :logger, :default_formatter, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime + +# Disable swoosh api client as it is only required for production adapters. +config :swoosh, :api_client, false + +# PBKDF2 lower cost for development +config :pbkdf2_elixir, :rounds, 16_000 diff --git a/libs/elixir-backend/codincod_api/config/prod.exs b/libs/elixir-backend/codincod_api/config/prod.exs new file mode 100644 index 00000000..30c73506 --- /dev/null +++ b/libs/elixir-backend/codincod_api/config/prod.exs @@ -0,0 +1,13 @@ +import Config + +# Configures Swoosh API Client +config :swoosh, api_client: Swoosh.ApiClient.Req + +# Disable Swoosh Local Memory Storage +config :swoosh, local: false + +# Do not print debug messages in production +config :logger, level: :info + +# Runtime production configuration, including reading +# of environment variables, is done on config/runtime.exs. diff --git a/libs/elixir-backend/codincod_api/config/runtime.exs b/libs/elixir-backend/codincod_api/config/runtime.exs new file mode 100644 index 00000000..e003089f --- /dev/null +++ b/libs/elixir-backend/codincod_api/config/runtime.exs @@ -0,0 +1,94 @@ +import Config + +# config/runtime.exs is executed for all environments, including +# during releases. It is executed after compilation and before the +# system starts, so it is typically used to load production configuration +# and secrets from environment variables or elsewhere. Do not define +# any compile-time configuration in here, as it won't be applied. + +# ## Using releases +# +# If you use `mix release`, you need to explicitly enable the server +# by passing the PHX_SERVER=true when you start it: +# +# PHX_SERVER=true bin/codincod_api start +# +# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` +# script that automatically sets the env var above. +if System.get_env("PHX_SERVER") do + config :codincod_api, CodincodApiWeb.Endpoint, server: true +end + +if config_env() == :prod do + database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://USER:PASS@HOST/DATABASE + """ + + maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] + + config :codincod_api, CodincodApi.Repo, + ssl: System.get_env("DATABASE_SSL") == "true", + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + socket_options: maybe_ipv6 + + # The secret key base is used to sign/encrypt cookies and other secrets. + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "example.com" + port = String.to_integer(System.get_env("PORT") || "4000") + + config :codincod_api, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") + + config :codincod_api, CodincodApiWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ + ip: {0, 0, 0, 0, 0, 0, 0, 0}, + port: port + ], + secret_key_base: secret_key_base + + # Guardian JWT configuration + config :codincod_api, CodincodApiWeb.Auth.Guardian, + issuer: System.get_env("JWT_ISSUER") || "codincod_api", + secret_key: System.get_env("JWT_SECRET") || secret_key_base + + # Piston API configuration + config :codincod_api, :piston, base_url: System.get_env("PISTON_URI") || "http://localhost:2000" + + # CORS configuration + config :cors_plug, + origin: String.split(System.get_env("CORS_ALLOWED_ORIGINS") || "http://localhost:5173", ",") + + # Mailer configuration + mailer_adapter = System.get_env("MAILER_ADAPTER") || "local" + + mailer_module = + case mailer_adapter do + "sendgrid" -> Swoosh.Adapters.Sendgrid + "mailgun" -> Swoosh.Adapters.Mailgun + "smtp" -> Swoosh.Adapters.SMTP + _ -> Swoosh.Adapters.Local + end + + config :codincod_api, CodincodApi.Mailer, adapter: mailer_module + + # Rate limiting configuration + if System.get_env("RATE_LIMIT_ENABLED") == "true" do + config :hammer, + backend: + {Hammer.Backend.ETS, + [ + expiry_ms: 60_000 * 60 * 4, + cleanup_interval_ms: 60_000 * 10 + ]} + end +end diff --git a/libs/elixir-backend/codincod_api/config/test.exs b/libs/elixir-backend/codincod_api/config/test.exs new file mode 100644 index 00000000..5381ef9a --- /dev/null +++ b/libs/elixir-backend/codincod_api/config/test.exs @@ -0,0 +1,42 @@ +import Config + +# Configure your database +# +# The MIX_TEST_PARTITION environment variable can be used +# to provide built-in test partitioning in CI environment. +# Run `mix help test` for more information. +test_db = System.get_env("POSTGRES_DB") || "codincod_api_test" +test_partition = System.get_env("MIX_TEST_PARTITION") + +config :codincod_api, CodincodApi.Repo, + username: System.get_env("POSTGRES_USER") || "postgres", + password: System.get_env("POSTGRES_PASSWORD") || "postgres", + hostname: System.get_env("POSTGRES_HOST") || "localhost", + database: test_db <> (test_partition || ""), + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: System.schedulers_online() * 2 + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :codincod_api, CodincodApiWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "c7sts8bQHiU24YfV7shYKABl1vrkugz+Hc2rtgp6AzEWLPCgxinjIGiNG/dVHT0w", + server: false + +# In test we don't send emails +config :codincod_api, CodincodApi.Mailer, adapter: Swoosh.Adapters.Test + +# Disable swoosh api client as it is only required for production adapters +config :swoosh, :api_client, false + +# Use in-memory piston mock for tests +config :codincod_api, :piston_client, CodincodApi.Piston.Mock + +# Print only warnings and errors during test +config :logger, level: :warning + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime + +# PBKDF2 minimal cost for tests +config :pbkdf2_elixir, :rounds, 1 diff --git a/libs/elixir-backend/codincod_api/cookies.txt b/libs/elixir-backend/codincod_api/cookies.txt new file mode 100644 index 00000000..c31d9899 --- /dev/null +++ b/libs/elixir-backend/codincod_api/cookies.txt @@ -0,0 +1,4 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api.ex b/libs/elixir-backend/codincod_api/lib/codincod_api.ex new file mode 100644 index 00000000..f3714ff9 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api.ex @@ -0,0 +1,9 @@ +defmodule CodincodApi do + @moduledoc """ + CodincodApi keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/accounts.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/accounts.ex new file mode 100644 index 00000000..6829208c --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/accounts.ex @@ -0,0 +1,287 @@ +defmodule CodincodApi.Accounts do + @moduledoc """ + Accounts context responsible for user management, authentication and preferences. + + This module is the Elixir counterpart for the Node services defined in + `libs/backend/src/services/user.service.ts` and the login/register routes. + """ + + import Ecto.Query, warn: false + alias CodincodApi.Repo + + alias CodincodApi.Accounts.{User, UserBan, Preference, Password, PasswordReset, Email} + alias CodincodApi.Mailer + + @type user_params :: map() + + ## Retrieval ----------------------------------------------------------------- + + @spec get_user!(Ecto.UUID.t()) :: User.t() + def get_user!(id) do + Repo.get!(User, id) + end + + @spec get_user(Ecto.UUID.t()) :: User.t() | nil + def get_user(id), do: Repo.get(User, id) + + @spec get_user_by_username(String.t()) :: User.t() | nil + def get_user_by_username(username) when is_binary(username) do + Repo.get_by(User, username: username) + end + + @spec get_user_with_preferences(Ecto.UUID.t()) :: User.t() | nil + def get_user_with_preferences(id) do + User + |> preload(:preferences) + |> Repo.get(id) + end + + ## Registration & profile ---------------------------------------------------- + + @spec register_user(user_params()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def register_user(attrs) do + %User{} + |> User.registration_changeset(attrs) + |> Repo.insert() + end + + @spec update_profile(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def update_profile(%User{} = user, attrs) do + user + |> User.profile_changeset(attrs) + |> Repo.update() + end + + @spec change_user_profile(User.t(), map()) :: Ecto.Changeset.t() + def change_user_profile(%User{} = user, attrs \\ %{}) do + User.profile_changeset(user, attrs) + end + + ## Preferences --------------------------------------------------------------- + + @spec upsert_preferences(User.t(), map()) :: + {:ok, Preference.t()} | {:error, Ecto.Changeset.t()} + def upsert_preferences(%User{id: user_id}, attrs) do + preference = Repo.get_by(Preference, user_id: user_id) || %Preference{user_id: user_id} + + preference + |> Preference.changeset(Map.put(attrs, :user_id, user_id)) + |> Repo.insert_or_update() + end + + @spec get_preferences(User.t()) :: Preference.t() | nil + def get_preferences(%User{id: user_id}) do + Repo.get_by(Preference, user_id: user_id) + end + + @spec delete_preferences(User.t()) :: :ok | {:error, :not_found | Ecto.Changeset.t()} + def delete_preferences(%User{id: user_id}) do + case Repo.get_by(Preference, user_id: user_id) do + nil -> + {:error, :not_found} + + preference -> + case Repo.delete(preference) do + {:ok, _} -> :ok + {:error, changeset} -> {:error, changeset} + end + end + end + + ## Authentication ------------------------------------------------------------ + + @spec authenticate(String.t(), String.t()) :: + {:ok, User.t()} | {:error, :invalid_credentials | :banned} + def authenticate(identifier, password) when is_binary(identifier) and is_binary(password) do + query = + from u in User, + where: ilike(u.email, ^identifier) or u.username == ^identifier, + preload: [:current_ban] + + with %User{} = user <- Repo.one(query), + true <- Password.verify?(password, user.password_hash) do + if active_ban?(user) do + {:error, :banned} + else + {:ok, user} + end + else + _ -> {:error, :invalid_credentials} + end + end + + defp active_ban?(%User{current_ban: nil}), do: false + defp active_ban?(%User{current_ban: %UserBan{expires_at: nil}}), do: true + + defp active_ban?(%User{current_ban: %UserBan{expires_at: expires_at}}) do + DateTime.compare(expires_at, DateTime.utc_now()) == :gt + end + + ## Ban management ------------------------------------------------------------ + + @spec ban_user(User.t(), map()) :: {:ok, UserBan.t()} | {:error, Ecto.Changeset.t()} + def ban_user(%User{id: user_id}, attrs) do + with {:ok, ban} <- + %UserBan{user_id: user_id} + |> UserBan.changeset(attrs) + |> Repo.insert() do + Repo.update_all(from(u in User, where: u.id == ^user_id), + set: [current_ban_id: ban.id], + inc: [ban_count: 1] + ) + + {:ok, ban} + end + end + + @spec lift_ban(UserBan.t()) :: :ok | {:error, term()} + def lift_ban(%UserBan{id: id, user_id: user_id} = ban) do + now = DateTime.utc_now() + + case Repo.transaction(fn -> + Repo.update!( + Ecto.Changeset.change(ban, %{ + expires_at: ban.expires_at || now, + metadata: Map.put(ban.metadata || %{}, "lifted_at", now) + }) + ) + + Repo.update_all( + from(u in User, where: u.id == ^user_id and u.current_ban_id == ^id), + set: [current_ban_id: nil] + ) + + :ok + end) do + {:ok, :ok} -> :ok + {:error, reason} -> {:error, reason} + end + end + + ## Helpers ------------------------------------------------------------------- + + @spec change_user(User.t(), map()) :: Ecto.Changeset.t() + def change_user(%User{} = user, attrs \\ %{}) do + User.registration_changeset(user, attrs) + end + + @doc """ + Returns true when no existing user (case-insensitive) owns the given username. + """ + @spec username_available?(String.t()) :: boolean() + def username_available?(username) when is_binary(username) do + normalized = username |> String.trim() |> String.downcase() + + if normalized == "" do + false + else + query = + from u in User, + select: 1, + where: fragment("lower(?) = ?", u.username, ^normalized), + limit: 1 + + Repo.one(query) == nil + end + end + + def username_available?(_), do: false + + ## Password Reset ------------------------------------------------------------ + + @doc """ + Initiates a password reset by creating a token and sending email. + """ + @spec request_password_reset(String.t(), String.t()) :: + {:ok, PasswordReset.t()} | {:error, :user_not_found | term()} + def request_password_reset(email, base_url) when is_binary(email) do + with %User{} = user <- Repo.get_by(User, email: String.downcase(email)), + token <- generate_secure_token(), + expires_at <- DateTime.add(DateTime.utc_now(), 3600, :second), + {:ok, reset} <- create_password_reset(user.id, token, expires_at), + reset_url <- build_reset_url(base_url, token), + email <- Email.password_reset_email(user, reset_url), + {:ok, _result} <- Mailer.deliver(email) do + {:ok, reset} + else + nil -> {:error, :user_not_found} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Validates and consumes a password reset token, updating the user's password. + """ + @spec reset_password_with_token(String.t(), String.t()) :: + {:ok, User.t()} | {:error, :invalid_token | :expired_token | Ecto.Changeset.t()} + def reset_password_with_token(token, new_password) when is_binary(token) do + now = DateTime.utc_now() + + with %PasswordReset{} = reset <- Repo.get_by(PasswordReset, token: token), + true <- is_nil(reset.used_at) || {:error, :invalid_token}, + :gt <- DateTime.compare(reset.expires_at, now) || {:error, :expired_token}, + %User{} = user <- Repo.get(User, reset.user_id), + {:ok, updated_user} <- update_password(user, new_password), + {:ok, _used_reset} <- + reset |> PasswordReset.mark_as_used() |> Repo.update() do + {:ok, updated_user} + else + nil -> {:error, :invalid_token} + {:error, reason} -> {:error, reason} + _ -> {:error, :invalid_token} + end + end + + defp create_password_reset(user_id, token, expires_at) do + %PasswordReset{} + |> PasswordReset.create_changeset(%{ + user_id: user_id, + token: token, + expires_at: expires_at + }) + |> Repo.insert() + end + + defp update_password(%User{} = user, new_password) do + {:ok, password_hash} = Password.hash(new_password) + + user + |> Ecto.Changeset.change(%{password_hash: password_hash}) + |> Repo.update() + end + + defp generate_secure_token do + :crypto.strong_rand_bytes(32) + |> Base.url_encode64(padding: false) + end + + defp build_reset_url(base_url, token) do + "#{base_url}/reset-password?token=#{token}" + end + + @doc """ + Fetches a user by ID, returning {:ok, user} or {:error, :not_found}. + """ + @spec fetch_user(Ecto.UUID.t()) :: {:ok, User.t()} | {:error, :not_found} + def fetch_user(user_id) do + case get_user(user_id) do + nil -> {:error, :not_found} + user -> {:ok, user} + end + end + + @doc """ + Removes the active ban for a user by calling lift_ban. + """ + @spec unban_user(User.t()) :: {:ok, User.t()} | {:error, :no_active_ban} + def unban_user(%User{current_ban_id: nil}), do: {:error, :no_active_ban} + + def unban_user(%User{current_ban_id: ban_id} = user) when not is_nil(ban_id) do + ban = Repo.get!(UserBan, ban_id) + + case lift_ban(ban) do + :ok -> {:ok, Repo.preload(user, :current_ban, force: true)} + {:error, reason} -> {:error, reason} + end + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/email.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/email.ex new file mode 100644 index 00000000..46a6f939 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/email.ex @@ -0,0 +1,49 @@ +defmodule CodincodApi.Accounts.Email do + @moduledoc """ + Constructs email messages for account actions like password resets. + """ + + import Swoosh.Email + + alias CodincodApi.Accounts.User + + @from_email Application.compile_env(:codincod_api, :from_email, "noreply@codincod.com") + + @doc """ + Builds password reset email with reset link. + """ + @spec password_reset_email(User.t(), String.t()) :: Swoosh.Email.t() + def password_reset_email(%User{email: email, username: username}, reset_url) do + new() + |> to({username, email}) + |> from({"CodinCod", @from_email}) + |> subject("Password Reset Request") + |> html_body(""" +

Password Reset

+

Hello #{username},

+

You requested a password reset for your CodinCod account.

+

Click the link below to reset your password:

+

Reset Password

+

This link will expire in 1 hour.

+

If you did not request this reset, please ignore this email.

+

Thanks,
The CodinCod Team

+ """) + |> text_body(""" + Password Reset + + Hello #{username}, + + You requested a password reset for your CodinCod account. + + Click the link below to reset your password: + #{reset_url} + + This link will expire in 1 hour. + + If you did not request this reset, please ignore this email. + + Thanks, + The CodinCod Team + """) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/password.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/password.ex new file mode 100644 index 00000000..a2f84e5c --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/password.ex @@ -0,0 +1,82 @@ +defmodule CodincodApi.Accounts.Password do + @moduledoc """ + Centralised password hashing and verification utilities. + + Wraps the configured password adapter so we can mock or swap hashing + algorithms without touching the rest of the codebase. Defaults to + `Pbkdf2` for improved Windows compatibility while still allowing an + optional legacy adapter (for example, `Bcrypt`) to be configured during + the data migration window. + """ + + @type hash :: String.t() + + @spec hash(String.t()) :: {:ok, hash()} | {:error, String.t()} + def hash(password) when is_binary(password) do + adapter = adapter_module() + + with :ok <- ensure_adapter_loaded(adapter) do + {:ok, adapter.hash_pwd_salt(password)} + else + {:error, reason} -> {:error, reason} + end + rescue + error -> {:error, Exception.message(error)} + end + + @spec verify?(String.t(), hash()) :: boolean() + def verify?(password, hash) when is_binary(password) and is_binary(hash) do + adapter = pick_adapter(hash) + + case ensure_adapter_loaded(adapter) do + :ok -> adapter.verify_pass(password, hash) + {:error, _} -> false + end + rescue + _ -> false + end + + @spec needs_rehash?(hash()) :: boolean() + def needs_rehash?(hash) when is_binary(hash) do + adapter = pick_adapter(hash) + + case ensure_adapter_loaded(adapter) do + :ok -> adapter.needs_rehash?(hash) + {:error, _} -> true + end + rescue + _ -> true + end + + defp ensure_adapter_loaded(nil) do + {:error, "no password adapter configured"} + end + + defp ensure_adapter_loaded(module) do + if Code.ensure_loaded?(module) and + function_exported?(module, :hash_pwd_salt, 1) and + function_exported?(module, :verify_pass, 2) do + :ok + else + {:error, "password adapter #{inspect(module)} is not available"} + end + end + + defp pick_adapter(hash) do + cond do + pbkdf2_hash?(hash) -> adapter_module() + legacy_adapter = legacy_adapter_module() -> legacy_adapter + true -> adapter_module() + end + end + + defp pbkdf2_hash?(hash), do: String.starts_with?(hash, "$pbkdf2-") + + defp adapter_module do + Application.get_env(:codincod_api, :password_adapter, Pbkdf2) + end + + defp legacy_adapter_module do + Application.get_env(:codincod_api, :legacy_password_adapter) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/password_reset.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/password_reset.ex new file mode 100644 index 00000000..1e94691d --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/password_reset.ex @@ -0,0 +1,53 @@ +defmodule CodincodApi.Accounts.PasswordReset do + @moduledoc """ + Schema for tracking password reset requests with tokens and expiry. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "password_resets" do + field :token, :string + field :expires_at, :utc_datetime_usec + field :used_at, :utc_datetime_usec + + belongs_to :user, User + + timestamps(type: :utc_datetime_usec) + end + + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + token: String.t() | nil, + expires_at: DateTime.t() | nil, + used_at: DateTime.t() | nil, + user_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @doc """ + Changeset for creating a new password reset request. + """ + @spec create_changeset(t(), map()) :: Ecto.Changeset.t() + def create_changeset(reset, attrs) do + reset + |> cast(attrs, [:user_id, :token, :expires_at]) + |> validate_required([:user_id, :token, :expires_at]) + |> unique_constraint(:token) + end + + @doc """ + Marks the reset token as used. + """ + @spec mark_as_used(t()) :: Ecto.Changeset.t() + def mark_as_used(reset) do + reset + |> change(%{used_at: DateTime.utc_now()}) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/preference.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/preference.ex new file mode 100644 index 00000000..523afd04 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/preference.ex @@ -0,0 +1,77 @@ +defmodule CodincodApi.Accounts.Preference do + @moduledoc """ + User preferences including editor configuration and personalization. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + @theme_options ["dark", "light"] + + schema "user_preferences" do + field :legacy_id, :string + field :preferred_language, :string + field :theme, :string + field :blocked_user_ids, {:array, :binary_id}, default: [] + field :editor, :map, default: %{} + + belongs_to :user, User + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Persistent preferences associated with a user." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + preferred_language: String.t() | nil, + theme: String.t() | nil, + blocked_user_ids: [Ecto.UUID.t()], + editor: map(), + user_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(preference, attrs) do + preference + |> cast(attrs, [ + :legacy_id, + :preferred_language, + :theme, + :blocked_user_ids, + :editor, + :user_id + ]) + |> validate_required([:user_id]) + |> unique_constraint(:user_id) + |> validate_change(:theme, &validate_theme/2) + |> normalize_editor() + end + + @doc "Available theme options mirrored from the frontend." + @spec theme_options() :: [String.t()] + def theme_options, do: @theme_options + + defp normalize_editor(changeset) do + update_change(changeset, :editor, fn + nil -> %{} + editor when is_map(editor) -> editor + _ -> %{} + end) + end + + defp validate_theme(:theme, nil), do: [] + defp validate_theme(:theme, value) when value in @theme_options, do: [] + + defp validate_theme(:theme, _value) do + [theme: "must be one of #{Enum.join(@theme_options, ", ")} or null"] + end + + defp validate_theme(_field, _value), do: [] +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/user.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/user.ex new file mode 100644 index 00000000..9e41815c --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/user.ex @@ -0,0 +1,175 @@ +defmodule CodincodApi.Accounts.User do + @moduledoc """ + User schema mapping the Fastify/Mongo user document to PostgreSQL. + + Mirrors the fields exposed by `UserEntity` from the TypeScript backend: + - `username` + - `email` + - `profile` + - `role` + - moderation counters and ban linkage + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.{User, UserBan, Preference} + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @username_min_length 3 + @username_max_length 20 + @username_regex ~r/^[A-Za-z0-9_-]+$/ + @password_min_length 14 + @email_regex ~r/^[^\s@]+@[^\s@]+$/ + + @typedoc """ + Serializable profile payload stored as JSONB. + """ + @type profile :: %{ + optional(String.t()) => String.t() | [String.t()] | nil + } + + schema "users" do + field :legacy_id, :string + field :legacy_username, :string + field :username, :string + field :email, :string + field :password, :string, virtual: true + field :password_confirmation, :string, virtual: true + field :password_hash, :string + field :profile, :map, default: %{} + field :role, :string, default: "user" + field :report_count, :integer, default: 0 + field :ban_count, :integer, default: 0 + field :legacy_current_ban_id, :string + + belongs_to :current_ban, UserBan, foreign_key: :current_ban_id + + has_one :preferences, Preference, foreign_key: :user_id + has_many :user_bans, UserBan, foreign_key: :user_id + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Registered user account record." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + legacy_username: String.t() | nil, + username: String.t() | nil, + email: String.t() | nil, + password: String.t() | nil, + password_confirmation: String.t() | nil, + password_hash: String.t() | nil, + profile: map(), + role: String.t() | nil, + report_count: non_neg_integer() | nil, + ban_count: non_neg_integer() | nil, + legacy_current_ban_id: String.t() | nil, + current_ban_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @doc """ + Changeset for user registration. + """ + @spec registration_changeset(User.t(), map()) :: Ecto.Changeset.t() + def registration_changeset(%User{} = user, attrs) do + user + |> cast(attrs, [ + :legacy_id, + :legacy_username, + :username, + :email, + :password, + :password_confirmation, + :profile, + :role + ]) + |> validate_required([:username, :email, :password]) + |> validate_format(:email, @email_regex) + |> validate_length(:username, min: @username_min_length, max: @username_max_length) + |> validate_format(:username, @username_regex) + |> validate_length(:password, min: @password_min_length) + |> validate_confirmation(:password, with: :password_confirmation) + |> put_default_profile() + |> unique_constraint(:username) + |> unique_constraint(:email) + |> put_password_hash() + end + + @doc """ + Changeset for updating profile information. + """ + @spec profile_changeset(User.t(), map()) :: Ecto.Changeset.t() + def profile_changeset(%User{} = user, attrs) do + user + |> cast(attrs, [:profile]) + |> put_default_profile() + end + + @doc """ + Changeset for administrative fields such as role. + """ + @spec admin_changeset(User.t(), map()) :: Ecto.Changeset.t() + def admin_changeset(%User{} = user, attrs) do + user + |> cast(attrs, [:role, :report_count, :ban_count, :current_ban_id]) + |> validate_inclusion(:role, ["user", "moderator", "admin"]) + end + + @doc false + def reset_password_changeset(%User{} = user, attrs) do + user + |> cast(attrs, [:password, :password_confirmation]) + |> validate_required([:password]) + |> validate_confirmation(:password, with: :password_confirmation) + |> put_password_hash() + end + + @doc "Minimum username length enforced by the backend." + @spec username_min_length() :: pos_integer() + def username_min_length, do: @username_min_length + + @doc "Maximum username length enforced by the backend." + @spec username_max_length() :: pos_integer() + def username_max_length, do: @username_max_length + + @doc "Username format regex used for validation." + @spec username_regex() :: Regex.t() + def username_regex, do: @username_regex + + @doc "Minimum password length enforced by the backend." + @spec password_min_length() :: pos_integer() + def password_min_length, do: @password_min_length + + @doc "Email format regex used for validation." + @spec email_regex() :: Regex.t() + def email_regex, do: @email_regex + + defp put_default_profile(changeset) do + update_change(changeset, :profile, fn + nil -> %{} + profile when is_map(profile) -> profile + _ -> %{} + end) + end + + defp put_password_hash(%Ecto.Changeset{valid?: true} = changeset) do + case fetch_change(changeset, :password) do + {:ok, password} -> + case CodincodApi.Accounts.Password.hash(password) do + {:ok, hash} -> put_change(changeset, :password_hash, hash) + {:error, reason} -> add_error(changeset, :password, reason) + end + + :error -> + changeset + end + end + + defp put_password_hash(changeset), do: changeset +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/user_ban.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/user_ban.ex new file mode 100644 index 00000000..0ac3ea19 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/accounts/user_ban.ex @@ -0,0 +1,57 @@ +defmodule CodincodApi.Accounts.UserBan do + @moduledoc """ + Represents a moderation ban applied to a user. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "user_bans" do + field :legacy_id, :string + field :ban_type, :string + field :reason, :string + field :metadata, :map, default: %{} + field :expires_at, :utc_datetime_usec + + belongs_to :user, User + belongs_to :banned_by, User + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Ban metadata tying moderator actions to affected users." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + ban_type: String.t() | nil, + reason: String.t() | nil, + metadata: map(), + expires_at: DateTime.t() | nil, + user_id: Ecto.UUID.t() | nil, + banned_by_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(ban, attrs) do + ban + |> cast(attrs, [ + :legacy_id, + :ban_type, + :reason, + :metadata, + :expires_at, + :user_id, + :banned_by_id + ]) + |> validate_required([:ban_type, :reason, :user_id, :banned_by_id]) + |> validate_length(:reason, min: 10, max: 500) + |> validate_inclusion(:ban_type, ["temporary", "permanent"]) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/application.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/application.ex new file mode 100644 index 00000000..e4baad9c --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/application.ex @@ -0,0 +1,35 @@ +defmodule CodincodApi.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + CodincodApiWeb.Telemetry, + CodincodApi.Repo, + {DNSCluster, query: Application.get_env(:codincod_api, :dns_cluster_query) || :ignore}, + {Phoenix.PubSub, name: CodincodApi.PubSub}, + {Finch, name: CodincodApiFinch}, + # Start a worker by calling: CodincodApi.Worker.start_link(arg) + # {CodincodApi.Worker, arg}, + # Start to serve requests, typically the last entry + CodincodApiWeb.Endpoint + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: CodincodApi.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + CodincodApiWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/chat.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/chat.ex new file mode 100644 index 00000000..73da0c44 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/chat.ex @@ -0,0 +1,81 @@ +defmodule CodincodApi.Chat do + @moduledoc """ + Provides persistence helpers for multiplayer chat transcripts. + """ + + import Ecto.Query, warn: false + alias CodincodApi.Repo + + alias CodincodApi.Chat.ChatMessage + + @default_preloads [user: [], game: []] + + @spec list_messages_for_game(Ecto.UUID.t(), keyword()) :: [ChatMessage.t()] + def list_messages_for_game(game_id, opts \\ []) do + ChatMessage + |> where([m], m.game_id == ^game_id) + |> maybe_include_deleted(opts) + |> order_by([m], asc: m.inserted_at) + |> maybe_limit(opts) + |> maybe_preload(opts) + |> Repo.all() + end + + @spec get_message!(Ecto.UUID.t(), keyword()) :: ChatMessage.t() + def get_message!(id, opts \\ []) do + ChatMessage + |> maybe_preload(opts) + |> Repo.get!(id) + end + + @spec post_message(map(), keyword()) :: {:ok, ChatMessage.t()} | {:error, Ecto.Changeset.t()} + def post_message(attrs, opts \\ []) do + %ChatMessage{} + |> ChatMessage.create_changeset(attrs) + |> Repo.insert() + |> maybe_preload_result(opts) + end + + @spec soft_delete_message(ChatMessage.t(), map()) :: + {:ok, ChatMessage.t()} | {:error, Ecto.Changeset.t()} + def soft_delete_message(%ChatMessage{} = message, attrs \\ %{}) do + message + |> ChatMessage.delete_changeset(attrs) + |> Repo.update() + end + + defp maybe_include_deleted(query, opts) do + if Keyword.get(opts, :include_deleted, false) do + query + else + where(query, [m], m.is_deleted == false) + end + end + + defp maybe_limit(query, opts) do + case Keyword.get(opts, :limit) do + nil -> query + limit when is_integer(limit) and limit > 0 -> limit(query, ^limit) + _ -> query + end + end + + defp maybe_preload(query, opts) do + case Keyword.get(opts, :preload, @default_preloads) do + nil -> query + preloads -> preload(query, ^preloads) + end + end + + defp maybe_preload_result({:ok, record}, opts) do + preloads = Keyword.get(opts, :preload, @default_preloads) + + {:ok, + case preloads do + nil -> record + _ -> Repo.preload(record, preloads) + end} + end + + defp maybe_preload_result(other, _opts), do: other +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/chat/chat_message.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/chat/chat_message.ex new file mode 100644 index 00000000..dce8aa49 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/chat/chat_message.ex @@ -0,0 +1,65 @@ +defmodule CodincodApi.Chat.ChatMessage do + @moduledoc """ + Persisted chat messages exchanged inside multiplayer game rooms. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Games.Game + alias CodincodApi.Accounts.User + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "chat_messages" do + field :legacy_id, :string + field :username_snapshot, :string + field :message, :string + field :is_deleted, :boolean, default: false + field :deleted_at, :utc_datetime_usec + + belongs_to :game, Game + belongs_to :user, User + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "In-game chat message." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + username_snapshot: String.t() | nil, + message: String.t() | nil, + is_deleted: boolean(), + deleted_at: DateTime.t() | nil, + game_id: Ecto.UUID.t() | nil, + user_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec create_changeset(t(), map()) :: Ecto.Changeset.t() + def create_changeset(message, attrs) do + message + |> cast(attrs, [ + :legacy_id, + :username_snapshot, + :message, + :is_deleted, + :deleted_at, + :game_id, + :user_id + ]) + |> validate_required([:username_snapshot, :message, :game_id, :user_id]) + |> validate_length(:message, min: 1, max: 5_000) + end + + @spec delete_changeset(t(), map()) :: Ecto.Changeset.t() + def delete_changeset(message, attrs \\ %{}) do + message + |> cast(attrs, [:is_deleted, :deleted_at]) + |> change(is_deleted: true) + |> put_change(:deleted_at, Map.get(attrs, :deleted_at, DateTime.utc_now())) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/comments.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/comments.ex new file mode 100644 index 00000000..73d20900 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/comments.ex @@ -0,0 +1,172 @@ +defmodule CodincodApi.Comments do + @moduledoc """ + Commenting system with nested replies, soft deletion and vote tracking. + """ + + import Ecto.Query, warn: false + alias Ecto.Multi + alias CodincodApi.Repo + + alias CodincodApi.Comments.{Comment, CommentVote} + + @vote_types ["upvote", "downvote"] + @default_preloads [author: [], children: [author: []]] + + @type comment_params :: map() + + @spec list_for_puzzle(Ecto.UUID.t(), keyword()) :: [Comment.t()] + def list_for_puzzle(puzzle_id, opts \\ []) do + Comment + |> where([c], c.puzzle_id == ^puzzle_id) + |> order_by([c], asc: c.inserted_at) + |> exclude_deleted(opts) + |> maybe_preload(opts) + |> Repo.all() + end + + @spec list_replies(Ecto.UUID.t(), keyword()) :: [Comment.t()] + def list_replies(parent_comment_id, opts \\ []) do + Comment + |> where([c], c.parent_comment_id == ^parent_comment_id) + |> order_by([c], asc: c.inserted_at) + |> exclude_deleted(opts) + |> maybe_preload(opts) + |> Repo.all() + end + + @spec get_comment!(Ecto.UUID.t(), keyword()) :: Comment.t() + def get_comment!(id, opts \\ []) do + Comment + |> maybe_preload(opts) + |> Repo.get!(id) + end + + @spec get_comment(Ecto.UUID.t(), keyword()) :: Comment.t() | nil + def get_comment(id, opts \\ []) do + Comment + |> maybe_preload(opts) + |> Repo.get(id) + end + + @spec create_comment(comment_params(), keyword()) :: + {:ok, Comment.t()} | {:error, Ecto.Changeset.t()} + def create_comment(attrs, opts \\ []) do + %Comment{} + |> Comment.changeset(attrs) + |> Repo.insert() + |> preload_result(opts) + end + + @spec reply(Comment.t(), comment_params(), keyword()) :: + {:ok, Comment.t()} | {:error, Ecto.Changeset.t()} + def reply(%Comment{id: parent_id, puzzle_id: puzzle_id}, attrs, opts \\ []) do + attrs = + attrs + |> Map.put(:parent_comment_id, parent_id) + |> Map.put_new(:puzzle_id, puzzle_id) + + create_comment(attrs, opts) + end + + @spec soft_delete(Comment.t(), map()) :: {:ok, Comment.t()} | {:error, Ecto.Changeset.t()} + def soft_delete(%Comment{} = comment, attrs \\ %{}) do + comment + |> Comment.delete_changeset(attrs) + |> Repo.update() + end + + @spec toggle_vote(Comment.t(), Ecto.UUID.t(), String.t()) :: + {:ok, Comment.t()} | {:error, term()} + def toggle_vote(%Comment{} = comment, user_id, vote_type) when vote_type in @vote_types do + Multi.new() + |> Multi.run(:existing_vote, fn repo, _changes -> + {:ok, repo.get_by(CommentVote, comment_id: comment.id, user_id: user_id)} + end) + |> Multi.run(:upsert_vote, fn repo, %{existing_vote: existing_vote} -> + handle_vote_transition(repo, existing_vote, comment, user_id, vote_type) + end) + |> Multi.run(:refresh_counts, fn repo, _changes -> + {:ok, recalculate_vote_totals(repo, comment.id)} + end) + |> Multi.run(:comment, fn repo, _changes -> + {:ok, repo.get!(Comment, comment.id)} + end) + |> Repo.transaction() + |> case do + {:ok, %{comment: updated}} -> {:ok, Repo.preload(updated, [:author])} + {:error, _step, reason, _} -> {:error, reason} + end + end + + def toggle_vote(_comment, _user_id, vote_type), do: {:error, {:invalid_vote_type, vote_type}} + + defp handle_vote_transition(repo, nil, comment, user_id, vote_type) do + %CommentVote{} + |> CommentVote.changeset(%{comment_id: comment.id, user_id: user_id, vote_type: vote_type}) + |> repo.insert() + end + + defp handle_vote_transition( + repo, + %CommentVote{vote_type: vote_type} = vote, + _comment, + _user_id, + vote_type + ) do + repo.delete(vote) + end + + defp handle_vote_transition(repo, %CommentVote{} = vote, _comment, _user_id, vote_type) do + vote + |> CommentVote.changeset(%{vote_type: vote_type}) + |> repo.update() + end + + defp recalculate_vote_totals(repo, comment_id) do + counts = + from(v in CommentVote, + where: v.comment_id == ^comment_id, + group_by: v.vote_type, + select: {v.vote_type, count(v.id)} + ) + |> repo.all() + |> Map.new() + + upvotes = Map.get(counts, "upvote", 0) + downvotes = Map.get(counts, "downvote", 0) + + repo.update_all( + from(c in Comment, where: c.id == ^comment_id), + set: [upvote_count: upvotes, downvote_count: downvotes] + ) + + {:ok, %{upvote_count: upvotes, downvote_count: downvotes}} + end + + defp exclude_deleted(query, opts) do + if Keyword.get(opts, :include_deleted, false) do + query + else + where(query, [c], is_nil(c.deleted_at)) + end + end + + defp maybe_preload(query, opts) do + case Keyword.get(opts, :preload, @default_preloads) do + nil -> query + preloads -> preload(query, ^preloads) + end + end + + defp preload_result({:ok, comment}, opts) do + preloads = Keyword.get(opts, :preload, @default_preloads) + + {:ok, + case preloads do + nil -> comment + _ -> Repo.preload(comment, preloads) + end} + end + + defp preload_result(other, _opts), do: other +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/comments/comment.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/comments/comment.ex new file mode 100644 index 00000000..8553b349 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/comments/comment.ex @@ -0,0 +1,110 @@ +defmodule CodincodApi.Comments.Comment do + @moduledoc """ + Persistent representation of user-authored comments across puzzles and submissions. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles.Puzzle + alias CodincodApi.Submissions.Submission + alias CodincodApi.Comments.{Comment, CommentVote} + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @comment_types ["puzzle-comment", "comment-comment"] + + schema "comments" do + field :legacy_id, :string + field :body, :string + field :comment_type, :string, default: "comment-comment" + field :upvote_count, :integer, default: 0 + field :downvote_count, :integer, default: 0 + field :metadata, :map, default: %{} + field :deleted_at, :utc_datetime_usec + + belongs_to :author, User + belongs_to :puzzle, Puzzle + belongs_to :submission, Submission + belongs_to :parent_comment, Comment + + has_many :children, Comment, foreign_key: :parent_comment_id + has_many :votes, CommentVote + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Domain comment entity." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + body: String.t() | nil, + comment_type: String.t(), + upvote_count: non_neg_integer(), + downvote_count: non_neg_integer(), + metadata: map(), + deleted_at: DateTime.t() | nil, + author_id: Ecto.UUID.t() | nil, + puzzle_id: Ecto.UUID.t() | nil, + submission_id: Ecto.UUID.t() | nil, + parent_comment_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @doc """ + Changeset used when creating or updating a comment. + """ + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(comment, attrs) do + comment + |> cast(attrs, [ + :legacy_id, + :body, + :comment_type, + :upvote_count, + :downvote_count, + :metadata, + :deleted_at, + :author_id, + :puzzle_id, + :submission_id, + :parent_comment_id + ]) + |> validate_required([:body, :author_id]) + |> validate_length(:body, min: 1, max: 5_000) + |> put_comment_type_default() + |> validate_inclusion(:comment_type, @comment_types) + |> normalize_metadata() + end + + @doc """ + Changeset to mark a comment as deleted (soft delete). + """ + @spec delete_changeset(t(), map()) :: Ecto.Changeset.t() + def delete_changeset(comment, attrs) do + comment + |> cast(attrs, [:deleted_at, :metadata]) + |> put_change(:deleted_at, Map.get(attrs, :deleted_at, DateTime.utc_now())) + |> normalize_metadata() + end + + defp put_comment_type_default(%Ecto.Changeset{} = changeset) do + case {get_field(changeset, :comment_type), get_field(changeset, :parent_comment_id), + get_field(changeset, :puzzle_id)} do + {nil, nil, _puzzle_id} -> put_change(changeset, :comment_type, "puzzle-comment") + {nil, _parent_id, _} -> put_change(changeset, :comment_type, "comment-comment") + _ -> changeset + end + end + + defp normalize_metadata(%Ecto.Changeset{} = changeset) do + update_change(changeset, :metadata, fn + nil -> %{} + metadata when is_map(metadata) -> metadata + _ -> %{} + end) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/comments/comment_vote.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/comments/comment_vote.ex new file mode 100644 index 00000000..758e7962 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/comments/comment_vote.ex @@ -0,0 +1,44 @@ +defmodule CodincodApi.Comments.CommentVote do + @moduledoc """ + Represents a single user's vote (upvote/downvote) on a comment. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + alias CodincodApi.Comments.Comment + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @vote_types ["upvote", "downvote"] + + schema "comment_votes" do + field :vote_type, :string + + belongs_to :comment, Comment + belongs_to :user, User + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "User's vote on a comment." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + vote_type: String.t(), + comment_id: Ecto.UUID.t() | nil, + user_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(vote, attrs) do + vote + |> cast(attrs, [:vote_type, :comment_id, :user_id]) + |> validate_required([:vote_type, :comment_id, :user_id]) + |> validate_inclusion(:vote_type, @vote_types) + |> unique_constraint([:comment_id, :user_id]) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/games.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/games.ex new file mode 100644 index 00000000..fa95ca6f --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/games.ex @@ -0,0 +1,115 @@ +defmodule CodincodApi.Games do + @moduledoc """ + Games context encapsulating multiplayer lobby management and player membership. + """ + + import Ecto.Query, warn: false + alias Ecto.{Changeset, Multi} + alias CodincodApi.Repo + + alias CodincodApi.Games.{Game, GamePlayer} + + @type game_params :: map() + + @spec list_waiting_rooms() :: [Game.t()] + def list_waiting_rooms do + Game + |> where([g], g.status == "waiting") + |> preload([:owner, :puzzle, players: :user]) + |> Repo.all() + end + + @spec get_game!(Ecto.UUID.t(), keyword()) :: Game.t() + def get_game!(id, opts \\ []) do + Game + |> maybe_preload(opts) + |> Repo.get!(id) + end + + @spec create_game(game_params()) :: {:ok, Game.t()} | {:error, Ecto.Changeset.t()} + def create_game(attrs) do + with {:ok, owner_id} <- fetch_owner_id(attrs) do + Multi.new() + |> Multi.insert(:game, Game.changeset(%Game{}, attrs)) + |> Multi.run(:host, fn repo, %{game: game} -> + %GamePlayer{} + |> GamePlayer.changeset(%{ + user_id: owner_id, + game_id: game.id, + joined_at: DateTime.utc_now(), + role: "host" + }) + |> repo.insert() + end) + |> Repo.transaction() + |> case do + {:ok, %{game: game}} -> {:ok, preload_assocs(game)} + {:error, _step, changeset, _} -> {:error, changeset} + end + end + end + + @spec join_game(Game.t(), map()) :: {:ok, GamePlayer.t()} | {:error, Ecto.Changeset.t()} + def join_game(%Game{id: game_id}, %{user_id: _user_id} = attrs) do + %GamePlayer{} + |> GamePlayer.changeset( + attrs + |> Map.put(:game_id, game_id) + |> Map.put_new(:joined_at, DateTime.utc_now()) + |> Map.put_new(:role, "player") + ) + |> Repo.insert() + end + + @spec leave_game(Game.t(), Ecto.UUID.t()) :: :ok + def leave_game(%Game{id: game_id}, user_id) do + Repo.delete_all( + from gp in GamePlayer, where: gp.game_id == ^game_id and gp.user_id == ^user_id + ) + + :ok + end + + @spec transition_game(Game.t(), String.t(), map()) :: + {:ok, Game.t()} | {:error, Ecto.Changeset.t()} + def transition_game(%Game{} = game, status, attrs \\ %{}) do + game + |> Game.changeset(Map.merge(attrs, %{status: status})) + |> Repo.update() + end + + @spec list_games_for_user(Ecto.UUID.t()) :: [Game.t()] + def list_games_for_user(user_id) do + Game + |> join(:inner, [g], gp in assoc(g, :players)) + |> where([_g, gp], gp.user_id == ^user_id) + |> preload([:owner, :puzzle, players: :user]) + |> Repo.all() + end + + defp maybe_preload(query, opts) do + case Keyword.get(opts, :preload) do + nil -> query + preloads -> preload(query, ^preloads) + end + end + + defp preload_assocs(game) do + Repo.preload(game, [:owner, :puzzle, players: :user]) + end + + defp fetch_owner_id(attrs) do + case Map.get(attrs, :owner_id) || Map.get(attrs, "owner_id") do + nil -> + changeset = + %Game{} + |> Game.changeset(attrs) + |> Changeset.add_error(:owner_id, "can't be blank") + + {:error, changeset} + + owner_id -> + {:ok, owner_id} + end + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/games/game.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/games/game.ex new file mode 100644 index 00000000..8177992e --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/games/game.ex @@ -0,0 +1,96 @@ +defmodule CodincodApi.Games.Game do + @moduledoc """ + Game schema representing multiplayer sessions. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles.Puzzle + alias CodincodApi.Games.GamePlayer + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "games" do + field :legacy_id, :string + field :visibility, :string + field :mode, :string + field :rated, :boolean, default: true + field :status, :string, default: "waiting" + field :max_duration_seconds, :integer, default: 600 + field :allowed_language_ids, {:array, :binary_id}, default: [] + field :options, :map, default: %{} + field :started_at, :utc_datetime_usec + field :ended_at, :utc_datetime_usec + + belongs_to :owner, User + belongs_to :puzzle, Puzzle + + has_many :players, GamePlayer + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Multiplayer game session metadata." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + visibility: String.t() | nil, + mode: String.t() | nil, + rated: boolean() | nil, + status: String.t() | nil, + max_duration_seconds: integer() | nil, + allowed_language_ids: [Ecto.UUID.t()], + options: map(), + started_at: DateTime.t() | nil, + ended_at: DateTime.t() | nil, + owner_id: Ecto.UUID.t() | nil, + puzzle_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(game, attrs) do + game + |> cast(attrs, [ + :legacy_id, + :owner_id, + :puzzle_id, + :visibility, + :mode, + :rated, + :status, + :max_duration_seconds, + :allowed_language_ids, + :options, + :started_at, + :ended_at + ]) + |> validate_required([:owner_id, :puzzle_id, :visibility, :mode]) + |> validate_inclusion(:visibility, ["public", "private", "friends"]) + |> validate_inclusion(:status, ["waiting", "in_progress", "completed", "cancelled"]) + |> validate_inclusion(:mode, [ + "FASTEST", + "SHORTEST", + "BACKWARDS", + "HARDCORE", + "DEBUG", + "TYPERACER", + "EFFICIENCY", + "INCREMENTAL", + "RANDOM" + ]) + |> put_default_options() + end + + defp put_default_options(changeset) do + update_change(changeset, :options, fn + nil -> %{} + options when is_map(options) -> options + _ -> %{} + end) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/games/game_player.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/games/game_player.ex new file mode 100644 index 00000000..c01bf2c4 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/games/game_player.ex @@ -0,0 +1,61 @@ +defmodule CodincodApi.Games.GamePlayer do + @moduledoc """ + Join table linking users to games with metadata about their participation. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Games.Game + alias CodincodApi.Accounts.User + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "game_players" do + field :legacy_id, :string + field :joined_at, :utc_datetime_usec + field :left_at, :utc_datetime_usec + field :role, :string, default: "player" + field :score, :integer + field :placement, :integer + + belongs_to :game, Game + belongs_to :user, User + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Join association for users participating in a multiplayer game." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + joined_at: DateTime.t() | nil, + left_at: DateTime.t() | nil, + role: String.t() | nil, + score: integer() | nil, + placement: integer() | nil, + game_id: Ecto.UUID.t() | nil, + user_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(player, attrs) do + player + |> cast(attrs, [ + :legacy_id, + :joined_at, + :left_at, + :role, + :score, + :placement, + :game_id, + :user_id + ]) + |> validate_required([:joined_at, :game_id, :user_id]) + |> validate_inclusion(:role, ["player", "spectator", "host"]) + |> unique_constraint([:game_id, :user_id]) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/languages.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/languages.ex new file mode 100644 index 00000000..2699c905 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/languages.ex @@ -0,0 +1,47 @@ +defmodule CodincodApi.Languages do + @moduledoc """ + Context for managing programming languages leveraged by submissions and puzzles. + """ + + import Ecto.Query, warn: false + alias CodincodApi.Repo + + alias CodincodApi.Languages.ProgrammingLanguage + + @doc """ + Lists all active programming languages sorted by display order and name. + """ + def list_languages(opts \\ []) do + include_inactive = Keyword.get(opts, :include_inactive, false) + + ProgrammingLanguage + |> where([pl], pl.is_active == true or ^include_inactive) + |> order_by([pl], asc_nulls_last: pl.display_order, asc: pl.language, asc: pl.version) + |> Repo.all() + end + + @doc """ + Retrieves a language by identifier. + """ + def get_language!(id), do: Repo.get!(ProgrammingLanguage, id) + + @spec get_language(Ecto.UUID.t()) :: ProgrammingLanguage.t() | nil + def get_language(id), do: Repo.get(ProgrammingLanguage, id) + + @spec fetch_language(Ecto.UUID.t()) :: {:ok, ProgrammingLanguage.t()} | {:error, :not_found} + def fetch_language(id) do + case get_language(id) do + nil -> {:error, :not_found} + language -> {:ok, language} + end + end + + def upsert_language(attrs) do + %ProgrammingLanguage{} + |> ProgrammingLanguage.changeset(attrs) + |> Repo.insert( + conflict_target: [:language, :version], + on_conflict: {:replace_all_except, [:id, :inserted_at]} + ) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/languages/programming_language.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/languages/programming_language.ex new file mode 100644 index 00000000..3af4d011 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/languages/programming_language.ex @@ -0,0 +1,55 @@ +defmodule CodincodApi.Languages.ProgrammingLanguage do + @moduledoc """ + Programming language entity mirrored from the Node backend. + """ + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "programming_languages" do + field :legacy_id, :string + field :language, :string + field :version, :string + field :aliases, {:array, :string}, default: [] + field :runtime, :string + field :display_order, :integer + field :is_active, :boolean, default: true + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Data representation of a programming language runtime entry." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + language: String.t() | nil, + version: String.t() | nil, + aliases: [String.t()], + runtime: String.t() | nil, + display_order: integer() | nil, + is_active: boolean() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(language, attrs) do + language + |> cast(attrs, [ + :legacy_id, + :language, + :version, + :aliases, + :runtime, + :display_order, + :is_active + ]) + |> validate_required([:language, :version]) + |> unique_constraint([:language, :version], + name: :programming_languages_language_version_index + ) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/mailer.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/mailer.ex new file mode 100644 index 00000000..cc90f56f --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/mailer.ex @@ -0,0 +1,3 @@ +defmodule CodincodApi.Mailer do + use Swoosh.Mailer, otp_app: :codincod_api +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/metrics.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/metrics.ex new file mode 100644 index 00000000..e1892ede --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/metrics.ex @@ -0,0 +1,86 @@ +defmodule CodincodApi.Metrics do + @moduledoc """ + Centralises leaderboard statistics, ratings and cached leaderboard snapshots. + """ + + import Ecto.Query, warn: false + alias CodincodApi.Repo + + alias CodincodApi.Metrics.{UserMetric, LeaderboardSnapshot} + + ## User metrics -------------------------------------------------------------- + + @spec get_user_metric(Ecto.UUID.t()) :: UserMetric.t() | nil + def get_user_metric(user_id) do + Repo.get_by(UserMetric, user_id: user_id) + end + + @spec get_user_metric!(Ecto.UUID.t()) :: UserMetric.t() + def get_user_metric!(user_id) do + Repo.get_by!(UserMetric, user_id: user_id) + end + + @spec upsert_user_metric(map()) :: {:ok, UserMetric.t()} | {:error, Ecto.Changeset.t()} + def upsert_user_metric(attrs) do + %UserMetric{} + |> UserMetric.changeset(attrs) + |> Repo.insert( + conflict_target: [:user_id], + on_conflict: {:replace_all_except, [:id, :inserted_at, :user_id]} + ) + end + + @spec update_user_metric(UserMetric.t(), map()) :: + {:ok, UserMetric.t()} | {:error, Ecto.Changeset.t()} + def update_user_metric(%UserMetric{} = metric, attrs) do + metric + |> UserMetric.changeset(attrs) + |> Repo.update() + end + + ## Leaderboard snapshots ----------------------------------------------------- + + @spec list_snapshots(keyword()) :: [LeaderboardSnapshot.t()] + def list_snapshots(opts \\ []) do + LeaderboardSnapshot + |> maybe_filter_snapshots(opts) + |> order_by([s], desc: s.captured_at) + |> maybe_limit(opts) + |> Repo.all() + end + + @spec latest_snapshot(String.t()) :: LeaderboardSnapshot.t() | nil + def latest_snapshot(game_mode) do + LeaderboardSnapshot + |> where([s], s.game_mode == ^game_mode) + |> order_by([s], desc: s.captured_at) + |> limit(1) + |> Repo.one() + end + + @spec record_snapshot(map()) :: {:ok, LeaderboardSnapshot.t()} | {:error, Ecto.Changeset.t()} + def record_snapshot(attrs) do + %LeaderboardSnapshot{} + |> LeaderboardSnapshot.changeset(attrs) + |> Repo.insert() + end + + ## Helpers ------------------------------------------------------------------ + + defp maybe_filter_snapshots(query, opts) do + Enum.reduce(opts, query, fn + {:game_mode, mode}, acc when is_binary(mode) -> where(acc, [s], s.game_mode == ^mode) + {:captured_after, %DateTime{} = dt}, acc -> where(acc, [s], s.captured_at >= ^dt) + {:captured_before, %DateTime{} = dt}, acc -> where(acc, [s], s.captured_at <= ^dt) + {_key, _value}, acc -> acc + end) + end + + defp maybe_limit(query, opts) do + case Keyword.get(opts, :limit) do + nil -> query + limit when is_integer(limit) and limit > 0 -> limit(query, ^limit) + _ -> query + end + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/metrics/leaderboard_snapshot.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/metrics/leaderboard_snapshot.ex new file mode 100644 index 00000000..a105f45e --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/metrics/leaderboard_snapshot.ex @@ -0,0 +1,57 @@ +defmodule CodincodApi.Metrics.LeaderboardSnapshot do + @moduledoc """ + Immutable snapshot of leaderboard standings for a specific game mode. + """ + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "leaderboard_snapshots" do + field :game_mode, :string + field :captured_at, :utc_datetime_usec + field :entries, {:array, :map}, default: [] + field :metadata, :map, default: %{} + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Leaderboard capture for auditing or caching leaderboard responses." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + game_mode: String.t() | nil, + captured_at: DateTime.t() | nil, + entries: [map()], + metadata: map(), + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(snapshot, attrs) do + snapshot + |> cast(attrs, [:game_mode, :captured_at, :entries, :metadata]) + |> validate_required([:game_mode]) + |> put_change(:captured_at, Map.get(attrs, :captured_at, DateTime.utc_now())) + |> normalize_entries() + |> normalize_metadata() + end + + defp normalize_entries(changeset) do + update_change(changeset, :entries, fn + nil -> [] + value when is_list(value) -> value + _ -> [] + end) + end + + defp normalize_metadata(changeset) do + update_change(changeset, :metadata, fn + nil -> %{} + value when is_map(value) -> value + _ -> %{} + end) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/metrics/user_metric.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/metrics/user_metric.ex new file mode 100644 index 00000000..2348a984 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/metrics/user_metric.ex @@ -0,0 +1,72 @@ +defmodule CodincodApi.Metrics.UserMetric do + @moduledoc """ + Aggregated rating information for a user across all multiplayer modes. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "user_metrics" do + field :legacy_id, :string + field :global_rating, :float, default: 1_500.0 + field :global_rating_deviation, :float, default: 350.0 + field :global_rating_volatility, :float, default: 0.06 + field :modes, :map, default: %{} + field :totals, :map, default: %{} + field :last_processed_game_at, :utc_datetime_usec + field :last_calculated_at, :utc_datetime_usec + + belongs_to :user, User + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Statistics for a user's performance across game modes." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + global_rating: float(), + global_rating_deviation: float(), + global_rating_volatility: float(), + modes: map(), + totals: map(), + last_processed_game_at: DateTime.t() | nil, + last_calculated_at: DateTime.t() | nil, + user_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(metric, attrs) do + metric + |> cast(attrs, [ + :legacy_id, + :global_rating, + :global_rating_deviation, + :global_rating_volatility, + :modes, + :totals, + :last_processed_game_at, + :last_calculated_at, + :user_id + ]) + |> validate_required([:user_id]) + |> normalize_maps([:modes, :totals]) + end + + defp normalize_maps(changeset, fields) do + Enum.reduce(fields, changeset, fn field, acc -> + update_change(acc, field, fn + nil -> %{} + value when is_map(value) -> value + _ -> %{} + end) + end) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/moderation.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/moderation.ex new file mode 100644 index 00000000..8b2f41fd --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/moderation.ex @@ -0,0 +1,129 @@ +defmodule CodincodApi.Moderation do + @moduledoc """ + Moderation workflows for handling reports, reviews, and automated escalation hooks. + """ + + import Ecto.Query, warn: false + alias CodincodApi.Repo + + alias CodincodApi.Moderation.{Report, ModerationReview} + + @type report_filters :: %{ + optional(:status) => String.t(), + optional(:problem_type) => String.t(), + optional(:reported_by_id) => Ecto.UUID.t(), + optional(:resolved_by_id) => Ecto.UUID.t() + } + @type review_filters :: %{ + optional(:status) => String.t(), + optional(:puzzle_id) => Ecto.UUID.t() + } + + ## Reports ------------------------------------------------------------------ + + @spec list_reports(report_filters(), keyword()) :: [Report.t()] + def list_reports(filters \\ %{}, opts \\ []) do + Report + |> apply_report_filters(filters) + |> order_by([r], desc: r.inserted_at) + |> maybe_preload(opts) + |> Repo.all() + end + + @spec get_report!(Ecto.UUID.t(), keyword()) :: Report.t() + def get_report!(id, opts \\ []) do + Report + |> maybe_preload(opts) + |> Repo.get!(id) + end + + @spec create_report(map(), keyword()) :: {:ok, Report.t()} | {:error, Ecto.Changeset.t()} + def create_report(attrs, opts \\ []) do + %Report{} + |> Report.create_changeset(attrs) + |> Repo.insert() + |> maybe_preload_result(opts) + end + + @spec resolve_report(Report.t(), map(), keyword()) :: + {:ok, Report.t()} | {:error, Ecto.Changeset.t()} + def resolve_report(%Report{} = report, attrs, opts \\ []) do + report + |> Report.resolve_changeset(attrs) + |> Repo.update() + |> maybe_preload_result(opts) + end + + ## Reviews ------------------------------------------------------------------ + + @spec list_reviews(review_filters(), keyword()) :: [ModerationReview.t()] + def list_reviews(filters \\ %{}, opts \\ []) do + ModerationReview + |> apply_review_filters(filters) + |> order_by([r], asc: r.inserted_at) + |> maybe_preload(opts) + |> Repo.all() + end + + @spec get_review!(Ecto.UUID.t(), keyword()) :: ModerationReview.t() + def get_review!(id, opts \\ []) do + ModerationReview + |> maybe_preload(opts) + |> Repo.get!(id) + end + + @spec queue_review(map(), keyword()) :: + {:ok, ModerationReview.t()} | {:error, Ecto.Changeset.t()} + def queue_review(attrs, opts \\ []) do + %ModerationReview{} + |> ModerationReview.create_changeset(attrs) + |> Repo.insert() + |> maybe_preload_result(opts) + end + + @spec update_review(ModerationReview.t(), map(), keyword()) :: + {:ok, ModerationReview.t()} | {:error, Ecto.Changeset.t()} + def update_review(%ModerationReview{} = review, attrs, opts \\ []) do + review + |> ModerationReview.update_changeset(attrs) + |> Repo.update() + |> maybe_preload_result(opts) + end + + ## Helpers ------------------------------------------------------------------ + + defp apply_report_filters(query, filters) do + Enum.reduce(filters, query, fn + {:status, status}, acc -> where(acc, [r], r.status == ^status) + {:problem_type, type}, acc -> where(acc, [r], r.problem_type == ^type) + {:reported_by_id, user_id}, acc -> where(acc, [r], r.reported_by_id == ^user_id) + {:resolved_by_id, user_id}, acc -> where(acc, [r], r.resolved_by_id == ^user_id) + {_key, _value}, acc -> acc + end) + end + + defp apply_review_filters(query, filters) do + Enum.reduce(filters, query, fn + {:status, status}, acc -> where(acc, [r], r.status == ^status) + {:puzzle_id, puzzle_id}, acc -> where(acc, [r], r.puzzle_id == ^puzzle_id) + {:reviewer_id, reviewer_id}, acc -> where(acc, [r], r.reviewer_id == ^reviewer_id) + {_key, _value}, acc -> acc + end) + end + + defp maybe_preload(query, opts) do + case Keyword.get(opts, :preload) do + nil -> query + preloads -> preload(query, ^preloads) + end + end + + defp maybe_preload_result({:ok, record}, opts) do + case Keyword.get(opts, :preload) do + nil -> {:ok, record} + preloads -> {:ok, Repo.preload(record, preloads)} + end + end + + defp maybe_preload_result(other, _opts), do: other +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/moderation/moderation_review.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/moderation/moderation_review.ex new file mode 100644 index 00000000..3565ee6a --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/moderation/moderation_review.ex @@ -0,0 +1,81 @@ +defmodule CodincodApi.Moderation.ModerationReview do + @moduledoc """ + Represents a moderation workflow entry for a puzzle awaiting approval. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles.Puzzle + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @statuses ["pending", "approved", "rejected", "revision_requested"] + + schema "moderation_reviews" do + field :legacy_id, :string + field :status, :string, default: "pending" + field :notes, :string + field :submitted_at, :utc_datetime_usec + field :resolved_at, :utc_datetime_usec + + belongs_to :puzzle, Puzzle + belongs_to :reviewer, User + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Puzzle moderation review lifecycle entity." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + status: String.t(), + notes: String.t() | nil, + submitted_at: DateTime.t() | nil, + resolved_at: DateTime.t() | nil, + puzzle_id: Ecto.UUID.t() | nil, + reviewer_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec create_changeset(t(), map()) :: Ecto.Changeset.t() + def create_changeset(review, attrs) do + review + |> cast(attrs, [ + :legacy_id, + :status, + :notes, + :submitted_at, + :resolved_at, + :puzzle_id, + :reviewer_id + ]) + |> validate_required([:puzzle_id]) + |> validate_inclusion(:status, @statuses) + |> put_change(:submitted_at, Map.get(attrs, :submitted_at, DateTime.utc_now())) + end + + @spec update_changeset(t(), map()) :: Ecto.Changeset.t() + def update_changeset(review, attrs) do + review + |> cast(attrs, [:status, :notes, :resolved_at, :reviewer_id]) + |> validate_inclusion(:status, @statuses) + |> maybe_put_resolved_at(attrs) + end + + defp maybe_put_resolved_at(changeset, attrs) do + case {get_field(changeset, :status), Map.get(attrs, :resolved_at)} do + {status, nil} when status in ["approved", "rejected", "revision_requested"] -> + put_change(changeset, :resolved_at, DateTime.utc_now()) + + {_, %DateTime{} = resolved_at} -> + put_change(changeset, :resolved_at, resolved_at) + + _ -> + changeset + end + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/moderation/report.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/moderation/report.ex new file mode 100644 index 00000000..e8ef8a60 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/moderation/report.ex @@ -0,0 +1,91 @@ +defmodule CodincodApi.Moderation.Report do + @moduledoc """ + User submitted report describing problematic content or behaviour. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @problem_types ["puzzle", "user", "comment", "game_chat"] + @statuses ["pending", "resolved", "rejected"] + + schema "reports" do + field :legacy_id, :string + field :problem_type, :string + field :problem_reference_id, :binary_id + field :problem_reference_snapshot, :map, default: %{} + field :explanation, :string + field :status, :string, default: "pending" + field :resolution_notes, :string + field :resolved_at, :utc_datetime_usec + field :metadata, :map, default: %{} + + belongs_to :reported_by, User + belongs_to :resolved_by, User + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Report awaiting moderation handling." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + problem_type: String.t(), + problem_reference_id: Ecto.UUID.t() | nil, + problem_reference_snapshot: map(), + explanation: String.t() | nil, + status: String.t(), + resolution_notes: String.t() | nil, + resolved_at: DateTime.t() | nil, + metadata: map(), + reported_by_id: Ecto.UUID.t() | nil, + resolved_by_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec create_changeset(t(), map()) :: Ecto.Changeset.t() + def create_changeset(report, attrs) do + report + |> cast(attrs, [ + :legacy_id, + :problem_type, + :problem_reference_id, + :problem_reference_snapshot, + :explanation, + :status, + :metadata, + :reported_by_id + ]) + |> validate_required([:problem_type, :problem_reference_id, :explanation, :reported_by_id]) + |> validate_length(:explanation, min: 10, max: 2_000) + |> validate_inclusion(:problem_type, @problem_types) + |> validate_inclusion(:status, @statuses) + |> normalize_map_fields([:problem_reference_snapshot, :metadata]) + end + + @spec resolve_changeset(t(), map()) :: Ecto.Changeset.t() + def resolve_changeset(report, attrs) do + report + |> cast(attrs, [:status, :resolution_notes, :resolved_by_id, :resolved_at, :metadata]) + |> validate_required([:status, :resolved_by_id]) + |> validate_inclusion(:status, @statuses) + |> normalize_map_fields([:metadata]) + |> put_change(:resolved_at, Map.get(attrs, :resolved_at, DateTime.utc_now())) + end + + defp normalize_map_fields(changeset, fields) do + Enum.reduce(fields, changeset, fn field, acc -> + update_change(acc, field, fn + nil -> %{} + value when is_map(value) -> value + _ -> %{} + end) + end) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/piston.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/piston.ex new file mode 100644 index 00000000..8254fb4b --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/piston.ex @@ -0,0 +1,38 @@ +defmodule CodincodApi.Piston do + @moduledoc """ + Facade module for interacting with the Piston execution service. The concrete + client module can be swapped in configuration via the + `:codincod_api, :piston_client` setting which defaults to + `CodincodApi.Piston.Client`. + """ + + @typedoc "Represents a single language runtime entry exposed by Piston." + @type runtime :: %{ + required(:language) => String.t(), + required(:version) => String.t(), + optional(:aliases) => list(String.t()), + optional(:runtime) => String.t() + } + + @typedoc "Response map returned by Piston's execute endpoint." + @type execution_response :: map() + + @callback list_runtimes() :: {:ok, [runtime()]} | {:error, term()} + @callback execute(map()) :: {:ok, execution_response()} | {:error, term()} + + @doc "Returns the list of Piston runtimes available for code execution." + @spec list_runtimes() :: {:ok, [runtime()]} | {:error, term()} + def list_runtimes do + client().list_runtimes() + end + + @doc "Executes code by delegating to the configured client module." + @spec execute(map()) :: {:ok, execution_response()} | {:error, term()} + def execute(request) when is_map(request) do + client().execute(request) + end + + defp client do + Application.get_env(:codincod_api, :piston_client, CodincodApi.Piston.Client) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/piston/client.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/piston/client.ex new file mode 100644 index 00000000..81d7bfe7 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/piston/client.ex @@ -0,0 +1,57 @@ +defmodule CodincodApi.Piston.Client do + @moduledoc """ + Tesla-powered implementation that communicates with a Piston server. + """ + + @behaviour CodincodApi.Piston + + alias Tesla.Env + + @execute_path "/api/v2/execute" + @runtimes_path "/api/v2/runtimes" + + @impl CodincodApi.Piston + def list_runtimes do + case Tesla.get(client(), @runtimes_path) do + {:ok, %Env{status: status, body: body}} when status in 200..299 and is_list(body) -> + {:ok, body} + + {:ok, %Env{status: status, body: body}} -> + {:error, {:unexpected_status, status, body}} + + {:error, reason} -> + {:error, reason} + end + end + + @impl CodincodApi.Piston + def execute(request) when is_map(request) do + case Tesla.post(client(), @execute_path, request) do + {:ok, %Env{status: status, body: body}} when status in 200..299 and is_map(body) -> + {:ok, body} + + {:ok, %Env{status: status, body: body}} -> + {:error, {:unexpected_status, status, body}} + + {:error, reason} -> + {:error, reason} + end + end + + defp client do + middleware = [ + {Tesla.Middleware.BaseUrl, base_url()}, + Tesla.Middleware.JSON, + {Tesla.Middleware.Timeout, timeout: 15_000} + ] + + adapter = {Tesla.Adapter.Finch, name: CodincodApiFinch} + + Tesla.client(middleware, adapter) + end + + defp base_url do + config = Application.get_env(:codincod_api, :piston, []) + Keyword.get(config, :base_url, "http://localhost:2000") + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/piston/mock.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/piston/mock.ex new file mode 100644 index 00000000..dccc65de --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/piston/mock.ex @@ -0,0 +1,47 @@ +defmodule CodincodApi.Piston.Mock do + @moduledoc """ + In-memory mock client used in tests to avoid hitting a real Piston instance. + By default it echoes the provided stdin as stdout so validators expecting + matching output succeed. Tests can override the behaviour by setting the + `:piston_mock_execute` application environment to a `fun/1`. + """ + + @behaviour CodincodApi.Piston + + @impl CodincodApi.Piston + def list_runtimes do + {:ok, + [ + %{ + "language" => "python", + "version" => "3.10.0", + "aliases" => ["py"], + "runtime" => "cpython" + } + ]} + end + + @impl CodincodApi.Piston + def execute(request) when is_map(request) do + case Application.get_env(:codincod_api, :piston_mock_execute) do + fun when is_function(fun, 1) -> fun.(request) + _ -> {:ok, default_success(request)} + end + end + + defp default_success(request) do + stdin = Map.get(request, "stdin") || Map.get(request, :stdin) || "" + + %{ + "language" => Map.get(request, "language") || Map.get(request, :language) || "python", + "version" => Map.get(request, "version") || Map.get(request, :version) || "3.10.0", + "run" => %{ + "output" => to_string(stdin), + "stdout" => to_string(stdin), + "stderr" => "", + "signal" => nil, + "code" => 0 + } + } + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles.ex new file mode 100644 index 00000000..577d768e --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles.ex @@ -0,0 +1,334 @@ +defmodule CodincodApi.Puzzles do + @moduledoc """ + Puzzle context that encapsulates authoring flows, moderation transitions and + validator management. + """ + + import Ecto.Query, warn: false + alias Ecto.Multi + alias CodincodApi.Repo + + alias CodincodApi.Puzzles.{Puzzle, PuzzleValidator, PuzzleMetric} + + @default_page 1 + @default_page_size 20 + @min_page 1 + @min_page_size 1 + @max_page_size 100 + + @type puzzle_params :: map() + @type pagination_opts :: %{optional(:page) => integer(), optional(:page_size) => integer()} + + @doc """ + Paginate puzzles mirroring the Fastify `/puzzle` index route behaviour. + + Ensures bounds on `page` and `page_size`, preloads associations required by the + API and returns the aggregated counts needed for the paginated response. + """ + @spec paginate_all(pagination_opts() | keyword()) :: %{ + items: [Puzzle.t()], + page: pos_integer(), + page_size: pos_integer(), + total_items: non_neg_integer(), + total_pages: non_neg_integer() + } + def paginate_all(params \\ %{}) do + %{page: page, page_size: page_size} = normalize_pagination(params) + + offset = (page - 1) * page_size + + items = + base_query() + |> order_by([p], desc: p.inserted_at) + |> limit(^page_size) + |> offset(^offset) + |> Repo.all() + + total_items = Repo.aggregate(from(p in Puzzle), :count, :id) + + total_pages = + if total_items == 0 do + 0 + else + total_items + |> Kernel./(page_size) + |> Float.ceil() + |> trunc() + end + + %{ + items: items, + page: page, + page_size: page_size, + total_items: total_items, + total_pages: total_pages + } + end + + @doc """ + Paginate puzzles authored by a specific user while applying visibility rules. + + Mirrors the behaviour of the Fastify `/user/:username/puzzle` route where the + owner can see all of their puzzles, but other viewers are limited to + `approved` visibility. + """ + @spec paginate_for_author(Ecto.UUID.t(), map() | keyword(), keyword()) :: %{ + items: [Puzzle.t()], + page: pos_integer(), + page_size: pos_integer(), + total_items: non_neg_integer(), + total_pages: non_neg_integer() + } + def paginate_for_author(author_id, params \\ %{}, opts \\ []) do + %{page: page, page_size: page_size} = normalize_pagination(params) + + viewer_id = Keyword.get(opts, :viewer_id) + include_private = viewer_id == author_id || Keyword.get(opts, :include_private, false) + + filtered_query = + base_query() + |> where([p], p.author_id == ^author_id) + |> maybe_filter_visibility(include_private) + + offset = (page - 1) * page_size + + items = + filtered_query + |> order_by([p], desc: p.inserted_at) + |> limit(^page_size) + |> offset(^offset) + |> Repo.all() + + total_items = + Puzzle + |> where([p], p.author_id == ^author_id) + |> maybe_filter_visibility(include_private) + |> Repo.aggregate(:count, :id) + + total_pages = + if total_items == 0 do + 0 + else + total_items + |> Kernel./(page_size) + |> Float.ceil() + |> trunc() + end + + %{ + items: items, + page: page, + page_size: page_size, + total_items: total_items, + total_pages: total_pages + } + end + + @spec list_published(keyword()) :: [Puzzle.t()] + def list_published(opts \\ []) do + base_query() + |> maybe_filter_visibility(false) + |> maybe_filter_by_author(opts) + |> maybe_filter_by_tags(opts) + |> order_by([p], desc: p.inserted_at) + |> Repo.all() + end + + @doc """ + Lists public (approved) puzzles authored by the given user. + """ + @spec list_author_public(Ecto.UUID.t()) :: [Puzzle.t()] + def list_author_public(author_id) do + base_query() + |> where([p], p.author_id == ^author_id) + |> maybe_filter_visibility(false) + |> order_by([p], desc: p.inserted_at) + |> Repo.all() + end + + @doc """ + Lists every puzzle authored by the given user regardless of visibility. + Intended for authenticated owners viewing their own content. + """ + @spec list_author_all(Ecto.UUID.t()) :: [Puzzle.t()] + def list_author_all(author_id) do + base_query() + |> where([p], p.author_id == ^author_id) + |> order_by([p], desc: p.inserted_at) + |> Repo.all() + end + + @spec get_puzzle!(Ecto.UUID.t(), keyword()) :: Puzzle.t() + def get_puzzle!(id, opts \\ []) do + base_query() + |> maybe_preload(opts) + |> Repo.get!(id) + end + + @spec get_puzzle(Ecto.UUID.t()) :: Puzzle.t() | nil + def get_puzzle(id) do + base_query() + |> Repo.get(id) + end + + @spec fetch_puzzle_with_validators(Ecto.UUID.t()) :: {:ok, Puzzle.t()} | {:error, :not_found} + def fetch_puzzle_with_validators(id) do + case get_puzzle(id) do + nil -> {:error, :not_found} + puzzle -> {:ok, puzzle} + end + end + + @doc """ + Fetches a puzzle by ID, returning {:ok, puzzle} or {:error, :not_found}. + """ + @spec fetch_puzzle(Ecto.UUID.t()) :: {:ok, Puzzle.t()} | {:error, :not_found} + def fetch_puzzle(id) do + case get_puzzle(id) do + nil -> {:error, :not_found} + puzzle -> {:ok, puzzle} + end + end + + @spec create_puzzle(puzzle_params()) :: {:ok, Puzzle.t()} | {:error, Ecto.Changeset.t()} + def create_puzzle(attrs) do + Multi.new() + |> Multi.insert(:puzzle, Puzzle.changeset(%Puzzle{}, attrs)) + |> Multi.run(:validators, fn repo, %{puzzle: puzzle} -> + upsert_validators(repo, puzzle, Map.get(attrs, :validators, [])) + end) + |> Repo.transaction() + |> case do + {:ok, %{puzzle: puzzle}} -> {:ok, preload_assocs(puzzle)} + {:error, _step, changeset, _} -> {:error, changeset} + end + end + + @spec update_puzzle(Puzzle.t(), map()) :: {:ok, Puzzle.t()} | {:error, Ecto.Changeset.t()} + def update_puzzle(%Puzzle{} = puzzle, attrs) do + Multi.new() + |> Multi.update(:puzzle, Puzzle.changeset(puzzle, attrs)) + |> Multi.run(:validators, fn repo, %{puzzle: puzzle} -> + upsert_validators(repo, puzzle, Map.get(attrs, :validators, [])) + end) + |> Repo.transaction() + |> case do + {:ok, %{puzzle: puzzle}} -> {:ok, preload_assocs(puzzle)} + {:error, _step, changeset, _} -> {:error, changeset} + end + end + + @spec delete_puzzle(Puzzle.t()) :: {:ok, Puzzle.t()} | {:error, Ecto.Changeset.t()} + def delete_puzzle(%Puzzle{} = puzzle) do + Repo.delete(puzzle) + end + + @spec attach_metrics(Puzzle.t(), map()) :: + {:ok, PuzzleMetric.t()} | {:error, Ecto.Changeset.t()} + def attach_metrics(%Puzzle{id: puzzle_id}, attrs) do + %PuzzleMetric{puzzle_id: puzzle_id} + |> PuzzleMetric.changeset(attrs) + |> Repo.insert( + on_conflict: {:replace_all_except, [:id, :inserted_at]}, + conflict_target: :puzzle_id + ) + end + + defp upsert_validators(repo, puzzle, validators) when is_list(validators) do + repo.delete_all(from v in PuzzleValidator, where: v.puzzle_id == ^puzzle.id) + + validators + |> Enum.map(fn validator_attrs -> + validator_attrs + |> Map.put(:puzzle_id, puzzle.id) + |> PuzzleValidator.changeset(%PuzzleValidator{}) + end) + |> Enum.reduce_while({:ok, []}, fn + %Ecto.Changeset{valid?: true} = changeset, {:ok, acc} -> + case repo.insert(changeset) do + {:ok, validator} -> {:cont, {:ok, [validator | acc]}} + {:error, changeset} -> {:halt, {:error, changeset}} + end + + changeset, _ -> + {:halt, {:error, changeset}} + end) + end + + defp upsert_validators(_repo, _puzzle, _), do: {:ok, []} + + defp base_query do + from p in Puzzle, + preload: [:author, :validators, :metrics] + end + + defp maybe_preload(query, opts) do + case Keyword.get(opts, :preload) do + nil -> query + preload -> preload(query, ^preload) + end + end + + defp maybe_filter_by_author(query, opts) do + case Keyword.get(opts, :author_id) do + nil -> query + author_id -> where(query, [p], p.author_id == ^author_id) + end + end + + defp maybe_filter_by_tags(query, opts) do + case Keyword.get(opts, :tags) do + nil -> query + [] -> query + tags -> where(query, [p], fragment("tags && ?", ^tags)) + end + end + + defp maybe_filter_visibility(query, true), do: query + + defp maybe_filter_visibility(query, _include_private) do + where(query, [p], fragment("lower(?) = ?", p.visibility, ^"approved")) + end + + defp normalize_pagination(params) do + page = + params + |> fetch_param(:page, @default_page) + |> coerce_integer(@default_page) + |> max(@min_page) + + page_size = + params + |> fetch_param(:page_size, @default_page_size) + |> coerce_integer(@default_page_size) + |> max(@min_page_size) + |> min(@max_page_size) + + %{page: page, page_size: page_size} + end + + defp fetch_param(params, key, default) when is_map(params) do + Map.get(params, key, Map.get(params, to_string(key), default)) + end + + defp fetch_param(params, key, default) when is_list(params) do + Keyword.get(params, key, default) + end + + defp fetch_param(_params, _key, default), do: default + + defp coerce_integer(value, _default) when is_integer(value), do: value + + defp coerce_integer(value, default) when is_binary(value) do + case Integer.parse(value) do + {int, _rest} -> int + :error -> default + end + end + + defp coerce_integer(_value, default), do: default + + defp preload_assocs(puzzle) do + Repo.preload(puzzle, [:author, :validators, :metrics]) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle.ex new file mode 100644 index 00000000..d50f1b8d --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle.ex @@ -0,0 +1,104 @@ +defmodule CodincodApi.Puzzles.Puzzle do + @moduledoc """ + Puzzle domain schema capturing authoring information, difficulty, solution metadata and + moderation feedback. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles.{Puzzle, PuzzleValidator, PuzzleMetric, PuzzleTestCase, PuzzleExample} + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "puzzles" do + field :legacy_id, :string + field :title, :string + field :statement, :string + field :constraints, :string + field :difficulty, :string + field :visibility, :string + field :tags, {:array, :string}, default: [] + field :solution, :map, default: %{} # Deprecated: being normalized to test_cases/examples + field :moderation_feedback, :string + field :legacy_metrics_id, :string + field :legacy_comments, {:array, :string}, default: [] + + belongs_to :author, User + has_many :validators, PuzzleValidator + has_many :test_cases, PuzzleTestCase + has_many :examples, PuzzleExample + has_one :metrics, PuzzleMetric + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Puzzle authored by users for single-player or multiplayer experiences." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + title: String.t() | nil, + statement: String.t() | nil, + constraints: String.t() | nil, + difficulty: String.t() | nil, + visibility: String.t() | nil, + tags: [String.t()], + solution: map(), + moderation_feedback: String.t() | nil, + legacy_metrics_id: String.t() | nil, + legacy_comments: [String.t()], + author_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @doc """ + Base changeset for puzzle authoring. + """ + @spec changeset(Puzzle.t(), map()) :: Ecto.Changeset.t() + def changeset(puzzle, attrs) do + puzzle + |> cast(attrs, [ + :legacy_id, + :title, + :statement, + :constraints, + :difficulty, + :visibility, + :tags, + :solution, + :moderation_feedback, + :author_id + ]) + |> validate_required([:title, :difficulty, :visibility, :author_id]) + |> validate_length(:title, min: 4, max: 128) + |> validate_inclusion(:difficulty, [ + "BEGINNER", + "EASY", + "INTERMEDIATE", + "ADVANCED", + "HARD", + "EXPERT" + ]) + |> validate_inclusion(:visibility, [ + "DRAFT", + "READY", + "REVIEW", + "REVISE", + "APPROVED", + "INACTIVE", + "ARCHIVED" + ]) + |> put_default_solution() + end + + defp put_default_solution(changeset) do + update_change(changeset, :solution, fn + nil -> %{} + solution when is_map(solution) -> solution + _ -> %{} + end) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle_example.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle_example.ex new file mode 100644 index 00000000..40540eaa --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle_example.ex @@ -0,0 +1,62 @@ +defmodule CodincodApi.Puzzles.PuzzleExample do + @moduledoc """ + Example schema for puzzle illustrations. + Examples help users understand puzzle requirements with sample inputs/outputs. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Puzzles.Puzzle + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "puzzle_examples" do + field :legacy_id, :string + field :input, :string + field :output, :string + field :explanation, :string + field :order, :integer + field :metadata, :map, default: %{} + + belongs_to :puzzle, Puzzle + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Example for illustrating puzzle behavior." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + input: String.t() | nil, + output: String.t() | nil, + explanation: String.t() | nil, + order: integer() | nil, + metadata: map(), + puzzle_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @doc """ + Changeset for creating or updating examples. + """ + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(example, attrs) do + example + |> cast(attrs, [ + :legacy_id, + :puzzle_id, + :input, + :output, + :explanation, + :order, + :metadata + ]) + |> validate_required([:puzzle_id, :input, :output, :order]) + |> validate_number(:order, greater_than_or_equal_to: 0) + |> foreign_key_constraint(:puzzle_id) + |> unique_constraint(:legacy_id) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle_metric.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle_metric.ex new file mode 100644 index 00000000..e5fe95de --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle_metric.ex @@ -0,0 +1,52 @@ +defmodule CodincodApi.Puzzles.PuzzleMetric do + @moduledoc """ + Aggregated statistics for a puzzle used by leaderboards and filtering. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Puzzles.Puzzle + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "puzzle_metrics" do + field :legacy_id, :string + field :attempt_count, :integer, default: 0 + field :success_count, :integer, default: 0 + field :average_execution_ms, :float, default: 0.0 + field :average_code_length, :integer, default: 0 + + belongs_to :puzzle, Puzzle + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Rolled-up statistics for puzzle performance insights." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + attempt_count: non_neg_integer() | nil, + success_count: non_neg_integer() | nil, + average_execution_ms: float() | nil, + average_code_length: integer() | nil, + puzzle_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(metric, attrs) do + metric + |> cast(attrs, [ + :legacy_id, + :attempt_count, + :success_count, + :average_execution_ms, + :average_code_length, + :puzzle_id + ]) + |> validate_required([:puzzle_id]) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle_test_case.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle_test_case.ex new file mode 100644 index 00000000..446bd442 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle_test_case.ex @@ -0,0 +1,62 @@ +defmodule CodincodApi.Puzzles.PuzzleTestCase do + @moduledoc """ + Test case schema for puzzle validation. + Each puzzle can have multiple test cases that validate submitted solutions. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Puzzles.Puzzle + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "puzzle_test_cases" do + field :legacy_id, :string + field :input, :string + field :expected_output, :string + field :is_sample, :boolean, default: false + field :order, :integer + field :metadata, :map, default: %{} + + belongs_to :puzzle, Puzzle + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Test case for validating puzzle solutions." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + input: String.t() | nil, + expected_output: String.t() | nil, + is_sample: boolean(), + order: integer() | nil, + metadata: map(), + puzzle_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @doc """ + Changeset for creating or updating test cases. + """ + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(test_case, attrs) do + test_case + |> cast(attrs, [ + :legacy_id, + :puzzle_id, + :input, + :expected_output, + :is_sample, + :order, + :metadata + ]) + |> validate_required([:puzzle_id, :input, :expected_output, :order]) + |> validate_number(:order, greater_than_or_equal_to: 0) + |> foreign_key_constraint(:puzzle_id) + |> unique_constraint(:legacy_id) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle_validator.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle_validator.ex new file mode 100644 index 00000000..7536ed7f --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/puzzles/puzzle_validator.ex @@ -0,0 +1,43 @@ +defmodule CodincodApi.Puzzles.PuzzleValidator do + @moduledoc """ + Represents a single validator/test-case for a puzzle. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Puzzles.Puzzle + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "puzzle_validators" do + field :legacy_id, :string + field :input, :string + field :output, :string + field :is_public, :boolean, default: false + + belongs_to :puzzle, Puzzle + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Test cases executed to verify puzzle solutions." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + input: String.t() | nil, + output: String.t() | nil, + is_public: boolean() | nil, + puzzle_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(validator, attrs) do + validator + |> cast(attrs, [:legacy_id, :input, :output, :is_public, :puzzle_id]) + |> validate_required([:input, :output, :puzzle_id]) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/repo.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/repo.ex new file mode 100644 index 00000000..a1a5210c --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/repo.ex @@ -0,0 +1,5 @@ +defmodule CodincodApi.Repo do + use Ecto.Repo, + otp_app: :codincod_api, + adapter: Ecto.Adapters.Postgres +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/submissions.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/submissions.ex new file mode 100644 index 00000000..09333943 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/submissions.ex @@ -0,0 +1,96 @@ +defmodule CodincodApi.Submissions do + @moduledoc """ + Submissions context providing persistence and query helpers for code submissions. + Mirrors the behaviour of `libs/backend/src/services/submission.service.ts`. + """ + + import Ecto.Query, warn: false + alias CodincodApi.Repo + + alias CodincodApi.Submissions.Submission + + @type submission_params :: map() + + @spec get_submission(Ecto.UUID.t(), keyword()) :: Submission.t() | nil + def get_submission(id, opts \\ []) do + Submission + |> Repo.get(id) + |> maybe_preload(opts) + end + + @spec get_submission!(Ecto.UUID.t()) :: Submission.t() + def get_submission!(id), do: Repo.get!(Submission, id) + + @spec fetch_submission(Ecto.UUID.t(), keyword()) :: {:ok, Submission.t()} | {:error, :not_found} + def fetch_submission(id, opts \\ []) do + case get_submission(id, opts) do + nil -> {:error, :not_found} + submission -> {:ok, submission} + end + end + + @spec list_by_user(Ecto.UUID.t(), keyword()) :: [Submission.t()] + def list_by_user(user_id, opts \\ []) do + Submission + |> where([s], s.user_id == ^user_id) + |> order_by([s], desc: s.inserted_at) + |> maybe_limit(opts) + |> preload([:puzzle, :programming_language, :game]) + |> Repo.all() + end + + @spec list_by_puzzle(Ecto.UUID.t()) :: [Submission.t()] + def list_by_puzzle(puzzle_id) do + Submission + |> where([s], s.puzzle_id == ^puzzle_id) + |> order_by([s], desc: s.inserted_at) + |> preload([:user, :programming_language]) + |> Repo.all() + end + + @spec create_submission(submission_params()) :: + {:ok, Submission.t()} | {:error, Ecto.Changeset.t()} + def create_submission(attrs) do + %Submission{} + |> Submission.create_changeset(attrs) + |> Repo.insert() + end + + @spec update_result(Submission.t(), map()) :: + {:ok, Submission.t()} | {:error, Ecto.Changeset.t()} + def update_result(%Submission{} = submission, attrs) do + submission + |> Submission.update_result_changeset(attrs) + |> Repo.update() + end + + @spec link_to_game(Submission.t(), Ecto.UUID.t()) :: + {:ok, Submission.t()} | {:error, Ecto.Changeset.t()} + def link_to_game(%Submission{} = submission, game_id) do + submission + |> Ecto.Changeset.change(%{game_id: game_id}) + |> Repo.update() + end + + @spec delete_submissions([Ecto.UUID.t()]) :: {non_neg_integer(), nil} + def delete_submissions(ids) when is_list(ids) do + Repo.delete_all(from s in Submission, where: s.id in ^ids) + end + + defp maybe_preload(nil, _opts), do: nil + + defp maybe_preload(submission, opts) do + case Keyword.get(opts, :preload) do + nil -> submission + preloads -> Repo.preload(submission, preloads) + end + end + + defp maybe_limit(query, opts) do + case Keyword.get(opts, :limit) do + nil -> query + limit when is_integer(limit) and limit > 0 -> limit(query, ^limit) + _ -> query + end + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/submissions/evaluator.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/submissions/evaluator.ex new file mode 100644 index 00000000..da72e785 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/submissions/evaluator.ex @@ -0,0 +1,147 @@ +defmodule CodincodApi.Submissions.Evaluator do + @moduledoc """ + Executes puzzle validators against the Piston service and collates the + resulting success metrics used when creating submissions. + """ + + alias CodincodApi.Languages.ProgrammingLanguage + alias CodincodApi.Puzzles.{Puzzle, PuzzleValidator} + + @type evaluation_summary :: %{ + passed: non_neg_integer(), + failed: non_neg_integer(), + total: non_neg_integer(), + success_rate: float(), + result: String.t() + } + + @type evaluation_result :: %{ + summary: evaluation_summary(), + responses: [{PuzzleValidator.t(), map()}] + } + + @default_timeout 20_000 + + @spec evaluate(String.t(), Puzzle.t(), ProgrammingLanguage.t(), keyword()) :: + {:ok, evaluation_result()} | {:error, term()} + def evaluate(code, %Puzzle{} = puzzle, %ProgrammingLanguage{} = language, opts \\ []) + when is_binary(code) do + validators = puzzle.validators || [] + + cond do + validators == [] -> + {:error, :no_validators} + + true -> + with {:ok, runtime} <- resolve_runtime(language), + {:ok, responses} <- run_validators(code, runtime, validators, opts), + {:ok, summary} <- summarise(responses) do + {:ok, %{summary: summary, responses: responses}} + end + end + end + + defp resolve_runtime(%ProgrammingLanguage{version: nil}) do + {:error, :missing_version} + end + + defp resolve_runtime(%ProgrammingLanguage{} = language) do + runtime_language = language.runtime || language.language + + if runtime_language do + {:ok, + %{ + language: runtime_language, + version: language.version + }} + else + {:error, :missing_runtime} + end + end + + defp run_validators(code, runtime, validators, opts) do + timeout = Keyword.get(opts, :timeout, @default_timeout) + concurrency = Keyword.get(opts, :max_concurrency, System.schedulers_online()) + + validators + |> Task.async_stream( + fn validator -> + inputs = build_request(runtime, code, validator) + + case CodincodApi.Piston.execute(inputs) do + {:ok, response} -> {:ok, {validator, response}} + {:error, reason} -> {:error, reason} + end + end, + timeout: timeout, + max_concurrency: concurrency, + ordered: true + ) + |> Enum.reduce_while({:ok, []}, fn + {:ok, {:ok, result}}, {:ok, acc} -> {:cont, {:ok, [result | acc]}} + {:ok, {:error, reason}}, _ -> {:halt, {:error, reason}} + {:exit, reason}, _ -> {:halt, {:error, reason}} + end) + |> case do + {:ok, responses} -> {:ok, Enum.reverse(responses)} + {:error, reason} -> {:error, reason} + end + end + + defp build_request(runtime, code, validator) do + %{ + "language" => runtime.language, + "version" => runtime.version, + "files" => [%{"content" => code}], + "stdin" => validator.input || "" + } + end + + defp summarise(responses) when is_list(responses) do + total = length(responses) + + {passed, failed} = + Enum.reduce(responses, {0, 0}, fn {validator, response}, {p_acc, f_acc} -> + if successful?(validator, response) do + {p_acc + 1, f_acc} + else + {p_acc, f_acc + 1} + end + end) + + success_rate = if total > 0, do: passed / total, else: 0.0 + + summary = %{ + passed: passed, + failed: failed, + total: total, + success_rate: success_rate, + result: if(failed == 0 and total > 0, do: "success", else: "error") + } + + {:ok, summary} + end + + defp successful?(%PuzzleValidator{output: expected}, response) do + cond do + not is_map(response) -> + false + + is_integer(get_in(response, ["run", "code"])) and get_in(response, ["run", "code"]) != 0 -> + false + + true -> + actual_output = + (get_in(response, ["run", "output"]) || get_in(response, ["run", "stdout"]) || "") + |> to_string() + + compare_outputs(expected, actual_output) + end + end + + defp compare_outputs(nil, actual), do: String.trim_trailing(actual) == "" + + defp compare_outputs(expected, actual) do + String.trim_trailing(to_string(expected)) == String.trim_trailing(actual) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/submissions/submission.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/submissions/submission.ex new file mode 100644 index 00000000..a1a49943 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/submissions/submission.ex @@ -0,0 +1,71 @@ +defmodule CodincodApi.Submissions.Submission do + @moduledoc """ + Submission schema storing the code, execution result and linkage to puzzles and games. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.{Accounts.User, Puzzles.Puzzle} + alias CodincodApi.Games.Game + alias CodincodApi.Languages.ProgrammingLanguage + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "submissions" do + field :legacy_id, :string + field :code, :string + field :result, :map, default: %{} + field :score, :float + field :legacy_game_submission_id, :string + + belongs_to :puzzle, Puzzle + belongs_to :user, User + belongs_to :programming_language, ProgrammingLanguage + belongs_to :game, Game + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Code run submitted by a user for evaluation." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + code: String.t() | nil, + result: map(), + score: float() | nil, + legacy_game_submission_id: String.t() | nil, + puzzle_id: Ecto.UUID.t() | nil, + user_id: Ecto.UUID.t() | nil, + programming_language_id: Ecto.UUID.t() | nil, + game_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec create_changeset(t(), map()) :: Ecto.Changeset.t() + def create_changeset(submission, attrs) do + submission + |> cast(attrs, [ + :legacy_id, + :puzzle_id, + :user_id, + :programming_language_id, + :game_id, + :code, + :result, + :score, + :legacy_game_submission_id + ]) + |> validate_required([:puzzle_id, :user_id, :programming_language_id, :code]) + |> validate_length(:code, min: 1) + end + + @spec update_result_changeset(t(), map()) :: Ecto.Changeset.t() + def update_result_changeset(submission, attrs) do + submission + |> cast(attrs, [:result, :score]) + |> validate_required([:result]) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api/typegen.ex b/libs/elixir-backend/codincod_api/lib/codincod_api/typegen.ex new file mode 100644 index 00000000..b6fb71fb --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api/typegen.ex @@ -0,0 +1,136 @@ +defmodule CodincodApi.Typegen do + @moduledoc """ + Utilities for generating TypeScript definitions that mirror the backend's + validation rules and response payloads. + + This generator focuses on the portions of the API that already exist in the + Phoenix migration (authentication and account preferences). As more routes are + migrated the generator can be extended with additional sections. + """ + + alias CodincodApi.Accounts.{Preference, User} + + @default_destination Path.expand( + Path.join([ + __DIR__, + "..", + "..", + "..", + "..", + "types", + "src", + "elixir-generated.ts" + ]) + ) + + @doc "Returns the default output path for the generated TypeScript file." + @spec default_destination() :: String.t() + def default_destination, do: @default_destination + + @doc "Generates the TypeScript file using the provided options." + @spec generate(keyword()) :: {:ok, String.t()} | {:error, term()} + def generate(opts \\ []) do + dest = + opts + |> Keyword.get(:dest, default_destination()) + |> Path.expand(File.cwd!()) + + data = %{ + auth: auth_config(), + preferences: preferences_config(), + generated_at: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + } + + content = render_typescript(data) + + with :ok <- File.mkdir_p(Path.dirname(dest)), + :ok <- File.write(dest, content) do + {:ok, dest} + end + end + + defp auth_config do + %{ + username: %{ + min_length: User.username_min_length(), + max_length: User.username_max_length(), + regex: User.username_regex() + }, + password: %{ + min_length: User.password_min_length() + }, + email: %{ + regex: User.email_regex() + } + } + end + + defp preferences_config do + %{ + theme_options: Preference.theme_options() + } + end + + defp render_typescript(%{auth: auth, preferences: prefs, generated_at: generated_at}) do + username_regex = regex_literal(auth.username.regex) + email_regex = regex_literal(auth.email.regex) + theme_options = format_array(prefs.theme_options) + + [ + "/* eslint-disable */", + "// Auto-generated by `mix codincod.gen_types`. Do not edit manually.", + "// Last generated: #{generated_at}", + "", + "export const AUTH_VALIDATION = {", + " username: {", + " minLength: #{auth.username.min_length},", + " maxLength: #{auth.username.max_length},", + " allowedCharacters: #{username_regex},", + " },", + " email: {", + " pattern: #{email_regex},", + " },", + " password: {", + " minLength: #{auth.password.min_length},", + " },", + "} as const;", + "", + "export const ACCOUNT_PREFERENCES = {", + " themeOptions: #{theme_options},", + "} as const;", + "", + "export type AccountPreferencesPayload = {", + " preferredLanguage: string | null;", + " theme: (typeof ACCOUNT_PREFERENCES.themeOptions)[number] | null;", + " blockedUsers: string[];", + " editor: Record;", + "};", + "", + "export type AccountPreferencesResponse = AccountPreferencesPayload;", + "" + ] + |> Enum.join("\n") + end + + defp regex_literal(%Regex{} = regex) do + source = Regex.source(regex) |> String.replace("/", "\\/") + opts = Regex.opts(regex) + + if opts == "" do + "/#{source}/" + else + "/#{source}/#{opts}" + end + end + + defp format_array([]), do: "[]" + + defp format_array(list) when is_list(list) do + values = + list + |> Enum.map(&inspect/1) + |> Enum.join(", ") + + "[#{values}]" + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web.ex new file mode 100644 index 00000000..b2fbf967 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web.ex @@ -0,0 +1,65 @@ +defmodule CodincodApiWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + + This can be used in your application as: + + use CodincodApiWeb, :controller + use CodincodApiWeb, :html + + The definitions below will be executed for every controller, + component, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define additional modules and import + those modules here. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + # Import common connection and controller functions to use in pipelines + import Plug.Conn + import Phoenix.Controller + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, formats: [:html, :json] + + use Gettext, backend: CodincodApiWeb.Gettext + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: CodincodApiWeb.Endpoint, + router: CodincodApiWeb.Router, + statics: CodincodApiWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/live_view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/auth/error_handler.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/auth/error_handler.ex new file mode 100644 index 00000000..220967b9 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/auth/error_handler.ex @@ -0,0 +1,23 @@ +defmodule CodincodApiWeb.Auth.ErrorHandler do + @moduledoc """ + Handles authentication errors for Guardian. + """ + import Plug.Conn + + @behaviour Guardian.Plug.ErrorHandler + + @impl Guardian.Plug.ErrorHandler + def auth_error(conn, {type, _reason}, _opts) do + body = Jason.encode!(%{error: to_string(type), message: error_message(type)}) + + conn + |> put_resp_content_type("application/json") + |> send_resp(401, body) + end + + defp error_message(:invalid_token), do: "Invalid authentication token" + defp error_message(:token_expired), do: "Authentication token has expired" + defp error_message(:no_resource_found), do: "User not found" + defp error_message(:unauthenticated), do: "Authentication required" + defp error_message(_), do: "Authentication failed" +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/auth/guardian.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/auth/guardian.ex new file mode 100644 index 00000000..8592c855 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/auth/guardian.ex @@ -0,0 +1,53 @@ +defmodule CodincodApiWeb.Auth.Guardian do + @moduledoc """ + Guardian implementation for JWT authentication. + Handles encoding/decoding of tokens and user resource management. + """ + use Guardian, otp_app: :codincod_api + + alias CodincodApi.Accounts + alias CodincodApi.Accounts.User + + @doc """ + Encodes the user ID as the subject claim. + """ + def subject_for_token(%User{id: id}, _claims) do + {:ok, to_string(id)} + end + + def subject_for_token(_, _) do + {:error, :invalid_resource} + end + + @doc """ + Retrieves the user from the subject claim. + """ + def resource_from_claims(%{"sub" => id}) do + case Accounts.get_user(id) do + nil -> {:error, :user_not_found} + user -> {:ok, user} + end + end + + def resource_from_claims(_claims) do + {:error, :invalid_claims} + end + + @doc """ + Generates a JWT token for a user with custom claims. + """ + def generate_token(user, token_type \\ :access) do + claims = %{ + "typ" => Atom.to_string(token_type), + "username" => user.username, + "role" => user.role + } + + encode_and_sign(user, claims, ttl: get_ttl(token_type)) + end + + defp get_ttl(:access), do: {7, :days} + defp get_ttl(:refresh), do: {30, :days} + defp get_ttl(:password_reset), do: {1, :hour} + defp get_ttl(:email_confirmation), do: {24, :hours} +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/auth/pipeline.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/auth/pipeline.ex new file mode 100644 index 00000000..8b790174 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/auth/pipeline.ex @@ -0,0 +1,13 @@ +defmodule CodincodApiWeb.Auth.Pipeline do + @moduledoc """ + Guardian authentication pipeline for protected routes. + """ + use Guardian.Plug.Pipeline, + otp_app: :codincod_api, + module: CodincodApiWeb.Auth.Guardian, + error_handler: CodincodApiWeb.Auth.ErrorHandler + + plug Guardian.Plug.VerifyHeader, scheme: "Bearer" + plug Guardian.Plug.EnsureAuthenticated + plug Guardian.Plug.LoadResource +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/channels/game_channel.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/channels/game_channel.ex new file mode 100644 index 00000000..a6381a51 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/channels/game_channel.ex @@ -0,0 +1,265 @@ +defmodule CodincodApiWeb.GameChannel do + @moduledoc """ + Phoenix Channel for real-time multiplayer game communication. + + Handles: + - Player joining/leaving + - Code updates during gameplay + - Submission results broadcasting + - Turn-based coordination + - Game state synchronization + """ + + use CodincodApiWeb, :channel + require Logger + + alias CodincodApi.{Games, Accounts} + alias CodincodApi.Games.Game + + @impl true + def join("game:" <> game_id, payload, socket) do + Logger.debug("Attempting to join game channel: game:#{game_id}") + + with {:ok, game_uuid} <- parse_uuid(game_id), + {:ok, user_id} <- get_user_id(payload, socket), + {:ok, user} <- Accounts.fetch_user(user_id), + game <- Games.get_game!(game_uuid, preload: [:owner, :puzzle, players: :user]), + :ok <- verify_player_in_game(game, user_id) do + # Track user presence + send(self(), :after_join) + + socket = + socket + |> assign(:game_id, game_uuid) + |> assign(:game, game) + |> assign(:user_id, user_id) + |> assign(:username, user.username) + + {:ok, %{game: serialize_game(game), userId: user_id}, socket} + else + {:error, :invalid_uuid} -> + {:error, %{reason: "Invalid game ID"}} + + {:error, :not_in_game} -> + {:error, %{reason: "You are not a player in this game"}} + + {:error, :not_found} -> + {:error, %{reason: "Game not found"}} + + {:error, :unauthorized} -> + {:error, %{reason: "Authentication required"}} + + error -> + Logger.error("Failed to join game channel: #{inspect(error)}") + {:error, %{reason: "Failed to join game"}} + end + end + + @impl true + def handle_info(:after_join, socket) do + _game_id = socket.assigns.game_id + user_id = socket.assigns.user_id + username = socket.assigns.username + + # Announce player presence to others + broadcast_from!(socket, "player_online", %{ + userId: user_id, + username: username, + timestamp: DateTime.utc_now() + }) + + # Track presence + push(socket, "presence_state", %{}) + + {:noreply, socket} + end + + ## Incoming events + + @impl true + def handle_in("code_update", %{"code" => code, "language" => language}, socket) do + user_id = socket.assigns.user_id + username = socket.assigns.username + + # Broadcast code changes to other players (for spectating/collaborative modes) + broadcast_from!(socket, "player_code_updated", %{ + userId: user_id, + username: username, + code: code, + language: language, + timestamp: DateTime.utc_now() + }) + + {:reply, :ok, socket} + end + + @impl true + def handle_in("submission_result", payload, socket) do + user_id = socket.assigns.user_id + username = socket.assigns.username + + # Broadcast submission results to all players + broadcast!(socket, "player_submitted", %{ + userId: user_id, + username: username, + status: payload["status"], + executionTime: payload["executionTime"], + timestamp: DateTime.utc_now() + }) + + # Check if game should end (first to solve or all submitted) + check_game_completion(socket) + + {:reply, :ok, socket} + end + + @impl true + def handle_in("ready", _payload, socket) do + user_id = socket.assigns.user_id + username = socket.assigns.username + + # Announce player is ready + broadcast!(socket, "player_ready", %{ + userId: user_id, + username: username, + timestamp: DateTime.utc_now() + }) + + {:reply, :ok, socket} + end + + @impl true + def handle_in("chat_message", %{"message" => message}, socket) do + user_id = socket.assigns.user_id + username = socket.assigns.username + + if String.trim(message) != "" && String.length(message) <= 500 do + broadcast!(socket, "chat_message", %{ + userId: user_id, + username: username, + message: String.trim(message), + timestamp: DateTime.utc_now() + }) + + {:reply, :ok, socket} + else + {:reply, {:error, %{reason: "Invalid message"}}, socket} + end + end + + @impl true + def handle_in("request_hint", _payload, socket) do + user_id = socket.assigns.user_id + username = socket.assigns.username + + # Broadcast hint request (may consume hint credits) + broadcast!(socket, "hint_requested", %{ + userId: user_id, + username: username, + timestamp: DateTime.utc_now() + }) + + {:reply, :ok, socket} + end + + @impl true + def handle_in("typing", %{"isTyping" => is_typing}, socket) do + user_id = socket.assigns.user_id + username = socket.assigns.username + + # Broadcast typing indicator + broadcast_from!(socket, "player_typing", %{ + userId: user_id, + username: username, + isTyping: is_typing + }) + + {:noreply, socket} + end + + # Catch-all for unknown events + @impl true + def handle_in(event, _payload, socket) do + Logger.warning("Unknown game channel event: #{event}") + {:reply, {:error, %{reason: "Unknown event"}}, socket} + end + + ## Private functions + + defp get_user_id(%{"userId" => user_id}, _socket) when is_binary(user_id) do + parse_uuid(user_id) + end + + defp get_user_id(_payload, socket) do + # Try to get from socket assigns (set by authentication) + case socket.assigns[:current_user_id] do + nil -> {:error, :unauthorized} + user_id -> {:ok, user_id} + end + end + + defp verify_player_in_game(%Game{players: players}, user_id) do + if Enum.any?(players, fn p -> p.user_id == user_id end) do + :ok + else + {:error, :not_in_game} + end + end + + defp check_game_completion(socket) do + game_id = socket.assigns.game_id + + # Reload game to check current state + game = Games.get_game!(game_id, preload: [:owner, :puzzle, players: :user]) + + # Logic to determine if game is complete + # This could check if: + # - Someone has finished (first to finish mode) + # - All players have submitted + # - Time limit reached + # For now, we'll just broadcast game state + broadcast!(socket, "game_state_updated", %{ + status: game.status, + timestamp: DateTime.utc_now() + }) + end + + defp serialize_game(%Game{} = game) do + %{ + id: game.id, + status: game.status, + mode: game.mode, + visibility: game.visibility, + maxDurationSeconds: game.max_duration_seconds, + rated: game.rated, + owner: %{ + id: game.owner.id, + username: game.owner.username + }, + puzzle: %{ + id: game.puzzle.id, + title: game.puzzle.title, + difficulty: game.puzzle.difficulty, + description: game.puzzle.description + }, + players: + Enum.map(game.players, fn player -> + %{ + id: player.user.id, + username: player.user.username, + role: player.role, + joinedAt: player.joined_at + } + end), + startedAt: game.started_at, + endedAt: game.ended_at + } + end + + defp parse_uuid(value) when is_binary(value) do + case Ecto.UUID.cast(value) do + {:ok, uuid} -> {:ok, uuid} + :error -> {:error, :invalid_uuid} + end + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/channels/user_socket.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/channels/user_socket.ex new file mode 100644 index 00000000..0eea4ee8 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/channels/user_socket.ex @@ -0,0 +1,30 @@ +defmodule CodincodApiWeb.UserSocket do + @moduledoc """ + WebSocket endpoint for real-time features. + """ + + use Phoenix.Socket + + # Channels + channel "game:*", CodincodApiWeb.GameChannel + + @impl true + def connect(%{"token" => token}, socket, _connect_info) do + # Verify JWT token and extract user_id + case CodincodApiWeb.Auth.Guardian.decode_and_verify(token) do + {:ok, claims} -> + user_id = claims["sub"] + {:ok, assign(socket, :current_user_id, user_id)} + + {:error, _reason} -> + :error + end + end + + def connect(_params, _socket, _connect_info) do + :error + end + + @impl true + def id(socket), do: "user_socket:#{socket.assigns.current_user_id}" +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/account_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/account_controller.ex new file mode 100644 index 00000000..2228945d --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/account_controller.ex @@ -0,0 +1,284 @@ +defmodule CodincodApiWeb.AccountController do + @moduledoc """ + Account endpoints mirroring the Fastify account routes (`/account`). + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + import Ecto.Query + alias CodincodApi.{Accounts, Games, Metrics, Repo} + alias CodincodApi.Accounts.User + alias CodincodApi.Games.Game + alias CodincodApi.Metrics.UserMetric + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + @profile_schema %{ + "bio" => {:string, 0, 500}, + "location" => {:string, 0, 100}, + "picture" => :string_url, + "socials" => :string_url_list + } + @profile_fields Map.keys(@profile_schema) + + tags(["Account"]) + + operation(:show, + summary: "Current account status", + responses: %{ + 200 => {"Authenticated account", "application/json", Schemas.Account.StatusResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Account.StatusResponse}, + 500 => {"Server error", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def show(conn, _params) do + with %User{id: user_id, username: username} <- conn.assigns[:current_user], + %User{} = user <- Accounts.get_user!(user_id) do + json(conn, %{ + isAuthenticated: true, + userId: user.id, + username: username, + role: user.role + }) + else + _ -> + conn + |> put_status(:unauthorized) + |> json(%{isAuthenticated: false, message: "Not authenticated"}) + end + end + + operation(:update_profile, + summary: "Update profile", + request_body: + {"Profile properties", "application/json", Schemas.Account.ProfileUpdateRequest}, + responses: %{ + 200 => {"Profile updated", "application/json", Schemas.Account.ProfileUpdateResponse}, + 400 => {"Validation error", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def update_profile(conn, params) do + with %User{} = current_user <- conn.assigns[:current_user], + {:ok, updates} <- normalize_profile_params(params), + {:ok, %User{} = user} <- Accounts.update_profile(current_user, %{profile: updates}) do + json(conn, %{message: "Profile updated successfully", profile: user.profile}) + else + {:error, :invalid_payload, details} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid profile payload", errors: details}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Failed to update profile", errors: translate_errors(changeset)}) + + _ -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Not authenticated"}) + end + end + + operation(:leaderboard_rank, + summary: "Get current user's leaderboard ranking", + responses: %{ + 200 => {"User ranking", "application/json", Schemas.Leaderboard.UserRankResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def leaderboard_rank(conn, _params) do + with %User{id: user_id} <- conn.assigns[:current_user] do + metric = Metrics.get_user_metric(user_id) + + rank = + if metric do + calculate_user_rank(user_id, metric.rating) + else + nil + end + + conn + |> put_status(:ok) + |> json(%{ + userId: user_id, + rank: rank, + rating: metric && metric.rating, + puzzlesSolved: metric && metric.puzzles_solved, + totalSubmissions: metric && metric.total_submissions + }) + else + _ -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + end + end + + operation(:games, + summary: "Get games for current user", + responses: %{ + 200 => {"User games", "application/json", Schemas.Games.UserGamesResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def games(conn, _params) do + with %User{id: user_id} <- conn.assigns[:current_user] do + user_games = Games.list_games_for_user(user_id) + + conn + |> put_status(:ok) + |> json(%{ + games: Enum.map(user_games, &serialize_game/1), + count: length(user_games) + }) + else + _ -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + end + end + + ## Private helper functions + + defp serialize_game(%Game{} = game) do + %{ + id: game.id, + status: game.status, + mode: game.mode, + visibility: game.visibility, + maxDurationSeconds: game.max_duration_seconds, + rated: game.rated, + owner: + game.owner && + %{ + id: game.owner.id, + username: game.owner.username + }, + puzzle: + game.puzzle && + %{ + id: game.puzzle.id, + title: game.puzzle.title, + difficulty: game.puzzle.difficulty + }, + players: + Enum.map(game.players || [], fn player -> + %{ + id: player.user.id, + username: player.user.username, + role: player.role, + joinedAt: player.joined_at + } + end), + createdAt: game.inserted_at, + startedAt: game.started_at, + endedAt: game.ended_at + } + end + + defp calculate_user_rank(user_id, rating) do + # Count how many users have a higher rating + count = + UserMetric + |> where([m], m.rating > ^rating or (m.rating == ^rating and m.user_id < ^user_id)) + |> Repo.aggregate(:count) + + count + 1 + end + + defp normalize_profile_params(params) when is_map(params) do + params + |> Enum.reduce_while(%{}, fn + {key, value}, acc when key in @profile_fields -> + case validate_profile_field(key, value) do + {:ok, normalized} -> {:cont, Map.put(acc, key, normalized)} + {:error, reason} -> {:halt, {:error, reason}} + end + + {_key, _value}, acc -> + {:cont, acc} + end) + |> case do + {:error, reason} -> {:error, :invalid_payload, reason} + result -> {:ok, result} + end + end + + defp normalize_profile_params(_), + do: {:error, :invalid_payload, %{message: "Expected JSON object"}} + + defp validate_profile_field("bio", value), do: validate_string(value, 0, 500, "bio") + defp validate_profile_field("location", value), do: validate_string(value, 0, 100, "location") + + defp validate_profile_field("picture", value) when value in [nil, ""], do: {:ok, value} + + defp validate_profile_field("picture", value) do + if valid_url?(value) do + {:ok, value} + else + {:error, %{field: "picture", message: "must be a valid URL"}} + end + end + + defp validate_profile_field("socials", value) when is_list(value) do + urls = Enum.with_index(value) + + case Enum.reduce_while(urls, [], fn {url, index}, acc -> + if valid_url?(url) do + {:cont, [url | acc]} + else + {:halt, + {:error, %{field: "socials", index: index, message: "must contain valid URLs"}}} + end + end) do + {:error, reason} -> {:error, reason} + urls -> {:ok, Enum.reverse(urls)} + end + end + + defp validate_profile_field("socials", _value), + do: {:error, %{field: "socials", message: "must be an array of URLs"}} + + defp validate_profile_field(_key, _value), do: {:ok, nil} + + defp validate_string(value, min, max, field) when is_binary(value) do + if String.length(value) <= max and String.length(value) >= min do + {:ok, value} + else + {:error, %{field: field, message: "must be between #{min} and #{max} characters"}} + end + end + + defp validate_string(nil, _min, _max, _field), do: {:ok, nil} + defp validate_string("", _min, _max, _field), do: {:ok, ""} + + defp validate_string(_value, _min, _max, field), + do: {:error, %{field: field, message: "must be a string"}} + + defp valid_url?(value) do + case URI.parse(value) do + %URI{scheme: scheme, host: host} when scheme in ["http", "https"] and is_binary(host) -> + true + + _ -> + false + end + end + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/account_preference_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/account_preference_controller.ex new file mode 100644 index 00000000..002a84a8 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/account_preference_controller.ex @@ -0,0 +1,229 @@ +defmodule CodincodApiWeb.AccountPreferenceController do + @moduledoc """ + Handles account preference endpoints mirroring the Fastify implementation. + + Supports full replacement (PUT), partial updates (PATCH), retrieval and + deletion of the authenticated user's preferences. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.Accounts + alias CodincodApi.Accounts.{Preference, User} + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + @spec show(Plug.Conn.t(), map()) :: Plug.Conn.t() + tags(["Account Preferences"]) + + operation(:show, + summary: "Get account preferences", + responses: %{ + 200 => {"Preferences", "application/json", Schemas.Account.PreferencesPayload}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def show(conn, _params) do + with %User{} = user <- conn.assigns[:current_user], + %Preference{} = preference <- Accounts.get_preferences(user) do + json(conn, serialize(preference)) + else + %User{} -> + conn + |> put_status(:not_found) + |> json(%{error: "Preferences not found"}) + + _ -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Invalid credentials"}) + end + end + + @spec replace(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation(:replace, + summary: "Replace preferences", + request_body: + {"Preferences payload", "application/json", Schemas.Account.PreferencesPayload, + required: true}, + responses: %{ + 200 => {"Updated preferences", "application/json", Schemas.Account.PreferencesPayload}, + 400 => {"Invalid payload", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def replace(conn, params) do + persist_preferences(conn, params, :replace) + end + + @spec patch(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation(:patch, + summary: "Patch preferences", + request_body: {"Partial preferences", "application/json", Schemas.Account.PreferencesPayload}, + responses: %{ + 200 => {"Updated preferences", "application/json", Schemas.Account.PreferencesPayload}, + 400 => {"Invalid payload", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def patch(conn, params) do + persist_preferences(conn, params, :patch) + end + + @spec delete(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation(:delete, + summary: "Delete preferences", + responses: %{ + 204 => {"Preferences deleted", "application/json", nil}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def delete(conn, _params) do + with %User{} = user <- conn.assigns[:current_user], + :ok <- Accounts.delete_preferences(user) do + send_resp(conn, :no_content, "") + else + %User{} -> + conn + |> put_status(:not_found) + |> json(%{error: "Preferences not found"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Preferences not found"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Failed to delete preferences", errors: translate_errors(changeset)}) + + _ -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Invalid credentials"}) + end + end + + defp persist_preferences(conn, params, _mode) do + with %User{} = user <- conn.assigns[:current_user], + {:ok, attrs} <- normalize_params(params), + {:ok, %Preference{} = preference} <- Accounts.upsert_preferences(user, attrs) do + json(conn, serialize(preference)) + else + {:error, :invalid_payload, errors} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid payload", errors: errors}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Failed to save preferences", errors: translate_errors(changeset)}) + + _ -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Invalid credentials"}) + end + end + + defp normalize_params(params) when is_map(params) do + theme_options = Preference.theme_options() + + Enum.reduce(params, {:ok, %{}}, fn + {"preferredLanguage", value}, {:ok, acc} when is_binary(value) or is_nil(value) -> + {:ok, Map.put(acc, :preferred_language, value)} + + {"preferredLanguage", _value}, {:ok, _acc} -> + {:error, :invalid_payload, [%{field: "preferredLanguage", message: "must be a string"}]} + + {"theme", value}, {:ok, acc} when is_binary(value) -> + if value in theme_options do + {:ok, Map.put(acc, :theme, value)} + else + {:error, :invalid_payload, + [%{field: "theme", message: "must be one of #{Enum.join(theme_options, ", ")}"}]} + end + + {"theme", nil}, {:ok, acc} -> + {:ok, Map.put(acc, :theme, nil)} + + {"theme", _value}, {:ok, _acc} -> + {:error, :invalid_payload, [%{field: "theme", message: "must be a string or null"}]} + + {"blockedUsers", value}, {:ok, acc} when is_list(value) -> + with {:ok, ids} <- cast_blocked_users(value) do + {:ok, Map.put(acc, :blocked_user_ids, ids)} + else + {:error, error} -> {:error, :invalid_payload, [error]} + end + + {"blockedUsers", nil}, {:ok, acc} -> + {:ok, Map.put(acc, :blocked_user_ids, [])} + + {"blockedUsers", _value}, {:ok, _acc} -> + {:error, :invalid_payload, [%{field: "blockedUsers", message: "must be an array"}]} + + {"editor", value}, {:ok, acc} when is_map(value) or is_nil(value) -> + {:ok, Map.put(acc, :editor, value || %{})} + + {"editor", _value}, {:ok, _acc} -> + {:error, :invalid_payload, [%{field: "editor", message: "must be an object"}]} + + {_other, _value}, {:ok, acc} -> + {:ok, acc} + + {_key, _value}, {:error, reason, errors} -> + {:error, reason, errors} + end) + |> case do + {:ok, attrs} when map_size(attrs) > 0 -> {:ok, attrs} + {:ok, _} -> {:error, :invalid_payload, [%{field: nil, message: "No changes provided"}]} + {:error, reason, errors} -> {:error, reason, errors} + end + end + + defp normalize_params(_), + do: {:error, :invalid_payload, [%{field: nil, message: "Expected JSON object"}]} + + defp cast_blocked_users(values) do + values + |> Enum.reduce_while({:ok, []}, fn value, {:ok, acc} -> + case Ecto.UUID.cast(value) do + {:ok, uuid} -> + {:cont, {:ok, [uuid | acc]}} + + :error -> + {:halt, {:error, %{field: "blockedUsers", message: "must contain valid UUID strings"}}} + end + end) + |> case do + {:ok, ids} -> {:ok, Enum.reverse(ids)} + {:error, reason} -> {:error, reason} + end + end + + defp serialize(%Preference{} = preference) do + %{ + preferredLanguage: preference.preferred_language, + theme: preference.theme, + blockedUsers: Enum.map(preference.blocked_user_ids || [], & &1), + editor: preference.editor || %{} + } + end + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/auth_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/auth_controller.ex new file mode 100644 index 00000000..74ec32f8 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/auth_controller.ex @@ -0,0 +1,328 @@ +defmodule CodincodApiWeb.AuthController do + @moduledoc """ + Authentication endpoints mirroring the legacy Fastify routes. + + Handles user registration, login, logout, and token refresh while keeping the + token delivery mechanism (HTTP-only cookie) compatible with the existing + frontend expectations. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + require Logger + + alias CodincodApi.Accounts + alias CodincodApi.Accounts.User + alias CodincodApiWeb.Auth.Guardian + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + @token_cookie Application.compile_env(:codincod_api, :auth_cookie, []) + |> Keyword.get(:name, "token") + @cookie_max_age Application.compile_env(:codincod_api, :auth_cookie, []) + |> Keyword.get(:max_age, 7 * 24 * 60 * 60) + + tags(["Auth"]) + + operation(:register, + summary: "Register new user", + request_body: + {"Registration payload", "application/json", Schemas.Auth.RegisterRequest, required: true}, + responses: %{ + 200 => {"Registration success", "application/json", Schemas.Auth.MessageResponse}, + 400 => {"Validation error", "application/json", Schemas.Common.ErrorResponse}, + 500 => {"Server error", "application/json", Schemas.Common.ErrorResponse} + } + ) + + @spec register(Plug.Conn.t(), map()) :: Plug.Conn.t() + def register(conn, params) do + require Logger + + attrs = %{ + username: Map.get(params, "username"), + email: Map.get(params, "email"), + password: Map.get(params, "password"), + password_confirmation: + Map.get(params, "passwordConfirmation") || Map.get(params, "password_confirmation") + } + + # Log registration attempt (without sensitive data) + Logger.info("Registration attempt for username: #{attrs.username}, email: #{attrs.email}") + + with {:ok, %User{} = user} <- Accounts.register_user(attrs), + {:ok, token, _claims} <- Guardian.generate_token(user) do + Logger.info("User registered successfully: #{user.username} (#{user.id})") + + conn + |> put_auth_cookie(token) + |> put_status(:ok) + |> json(%{message: "User registered successfully"}) + else + {:error, %Ecto.Changeset{} = changeset} -> + errors = translate_errors(changeset) + + # Log validation errors for debugging + Logger.warning("Registration validation failed for #{attrs.username}: #{inspect(errors)}") + + # Provide user-friendly error messages + message = + cond do + Map.has_key?(errors, :username) -> "Username validation failed" + Map.has_key?(errors, :email) -> "Email validation failed" + Map.has_key?(errors, :password) -> "Password validation failed" + true -> "Registration validation failed" + end + + conn + |> put_status(:bad_request) + |> json(%{ + message: message, + errors: errors + }) + + {:error, reason} -> + # Log the actual error for debugging + Logger.error("Registration failed for #{attrs.username}: #{inspect(reason)}") + + conn + |> put_status(:internal_server_error) + |> json(%{ + message: "Registration failed. Please try again later.", + error: "INTERNAL_ERROR" + }) + end + end + + operation(:login, + summary: "Authenticate user", + request_body: {"Credentials", "application/json", Schemas.Auth.LoginRequest, required: true}, + responses: %{ + 200 => {"Login success", "application/json", Schemas.Auth.MessageResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 500 => {"Server error", "application/json", Schemas.Common.ErrorResponse} + } + ) + + @spec login(Plug.Conn.t(), map()) :: Plug.Conn.t() + def login(conn, params) do + identifier = Map.get(params, "identifier") + password = Map.get(params, "password") + + cond do + !valid_identifier?(identifier) -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid username or email"}) + + !is_binary(password) or password == "" -> + conn + |> put_status(:bad_request) + |> json(%{message: "Password is required"}) + + true -> + do_login(conn, identifier, password) + end + end + + defp do_login(conn, identifier, password) do + case Accounts.authenticate(identifier, password) do + {:ok, %User{} = user} -> + with {:ok, token, claims} <- Guardian.generate_token(user) do + require Logger + Logger.info("=== LOGIN TOKEN GENERATED ===") + Logger.info("User ID: #{user.id}") + Logger.info("Token (first 50 chars): #{String.slice(token, 0..50)}...") + Logger.info("Claims: #{inspect(claims)}") + Logger.info("Cookie name: #{@token_cookie}") + Logger.info("Setting cookie with options: #{inspect(cookie_options(:set))}") + Logger.info("============================") + + conn + |> put_auth_cookie(token) + |> tap(fn conn -> + Logger.info("Response cookies being set: #{inspect(conn.resp_cookies)}") + end) + |> put_status(:ok) + |> json(%{message: "Login successful"}) + else + {:error, reason} -> + Logger.error("Failed to generate token: #{inspect(reason)}") + conn + |> put_status(:internal_server_error) + |> json(%{message: "Failed to generate token", reason: inspect(reason)}) + end + + {:error, :invalid_credentials} -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Invalid email/username or password"}) + + {:error, :banned} -> + conn + |> put_status(:forbidden) + |> json(%{message: "User is banned"}) + end +end + + operation(:logout, + summary: "Logout current user", + responses: %{ + 200 => {"Logout success", "application/json", Schemas.Auth.MessageResponse} + } + ) + + @spec logout(Plug.Conn.t(), map()) :: Plug.Conn.t() + def logout(conn, _params) do + conn + |> clear_auth_cookie() + |> put_status(:ok) + |> json(%{message: "Logout successful"}) + end + + operation(:refresh, + summary: "Refresh authentication token", + responses: %{ + 200 => {"Token refreshed", "application/json", Schemas.Auth.MessageResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 500 => {"Server error", "application/json", Schemas.Common.ErrorResponse} + } + ) + + @spec refresh(Plug.Conn.t(), map()) :: Plug.Conn.t() + def refresh(conn, _params) do + case conn.assigns[:current_user] do + %User{} = user -> + with {:ok, token, _claims} <- Guardian.generate_token(user) do + conn + |> put_auth_cookie(token) + |> put_status(:ok) + |> json(%{message: "Token refreshed"}) + else + {:error, reason} -> + conn + |> put_status(:internal_server_error) + |> json(%{message: "Failed to refresh token", reason: inspect(reason)}) + end + + _ -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Authentication required"}) + end + end + + defp valid_identifier?(identifier) when is_binary(identifier) do + username_regex = User.username_regex() + email_regex = User.email_regex() + username_min = User.username_min_length() + username_max = User.username_max_length() + + identifier != "" and + (Regex.match?(email_regex, identifier) or + (Regex.match?(username_regex, identifier) and + String.length(identifier) in username_min..username_max)) + end + + defp valid_identifier?(_), do: false + + defp put_auth_cookie(conn, token) do + Plug.Conn.put_resp_cookie(conn, @token_cookie, token, cookie_options(:set)) + end + + defp clear_auth_cookie(conn) do + Plug.Conn.delete_resp_cookie(conn, @token_cookie, cookie_options(:delete)) + end + + defp cookie_options(:set) do + base_cookie_options() + |> Keyword.put(:max_age, @cookie_max_age) + end + + defp cookie_options(:delete) do + base_cookie_options() + end + + defp base_cookie_options do + prod? = production?() + + # In development, use SameSite=None to allow cross-origin cookies + # (frontend on :5173, backend on :4000) + # In production, use SameSite=None with Secure for cross-domain + options = [ + path: "/", + http_only: true, + # Secure must be true when SameSite=None, browsers allow this for localhost + secure: true, + same_site: "None" + ] + + options + |> maybe_put_domain(prod?) + end + + defp maybe_put_domain(options, true) do + case System.get_env("FRONTEND_HOST") do + host when is_binary(host) and host != "" -> Keyword.put(options, :domain, host) + _ -> options + end + end + + defp maybe_put_domain(options, _), do: options + + defp production? do + Application.get_env(:codincod_api, :runtime_env, :dev) == :prod + end + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + |> enhance_error_messages() + end + + # Enhance error messages for better UX + defp enhance_error_messages(errors) do + errors + |> Enum.map(fn {field, messages} -> + enhanced = + Enum.map(messages, fn msg -> + case {field, msg} do + {:username, "has already been taken"} -> + "This username is already registered. Please choose a different username." + + {:email, "has already been taken"} -> + "This email address is already registered. Please use a different email or try logging in." + + {:password, "should be at least " <> _} -> + "Password must be at least 14 characters long for security." + + {:password_confirmation, "does not match confirmation"} -> + "Password confirmation does not match. Please ensure both passwords are identical." + + {:username, "has invalid format"} -> + "Username can only contain letters, numbers, hyphens, and underscores." + + {:username, "should be at least " <> _} -> + "Username must be at least 3 characters long." + + {:username, "should be at most " <> _} -> + "Username cannot be longer than 20 characters." + + {:email, "has invalid format"} -> + "Please enter a valid email address." + + {_, msg} -> + msg + end + end) + + {field, enhanced} + end) + |> Enum.into(%{}) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/comment_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/comment_controller.ex new file mode 100644 index 00000000..deaddaf3 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/comment_controller.ex @@ -0,0 +1,166 @@ +defmodule CodincodApiWeb.CommentController do + @moduledoc """ + Handles comment retrieval, deletion, and voting endpoints. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.Comments + alias CodincodApi.Comments.Comment + alias CodincodApi.Accounts.User + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + @preloads [ + author: [], + children: [author: []] + ] + + operation(:show, + summary: "Get comment by ID", + parameters: [ + id: [ + in: :path, + description: "Comment ID", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + responses: [ + ok: {"Comment details", "application/json", Schemas.Comment.CommentResponse}, + not_found: {"Comment not found", "application/json", Schemas.Common.ErrorResponse} + ] + ) + + def show(conn, %{"id" => id}) do + comment = Comments.get_comment!(id, preload: @preloads) + json(conn, serialize_comment(comment)) + end + + operation(:delete, + summary: "Delete a comment", + parameters: [ + id: [ + in: :path, + description: "Comment ID", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + responses: [ + no_content: "Comment deleted successfully", + forbidden: {"Not authorized to delete this comment", "application/json", Schemas.Common.ErrorResponse}, + not_found: {"Comment not found", "application/json", Schemas.Common.ErrorResponse} + ] + ) + + def delete(conn, %{"id" => id}) do + with %Comment{} = comment <- Comments.get_comment!(id, preload: [:author]), + %User{} = current_user <- conn.assigns[:current_user], + :ok <- authorize_comment_delete(comment, current_user), + {:ok, _comment} <- Comments.soft_delete(comment) do + send_resp(conn, :no_content, "") + else + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{message: "You cannot delete this comment"}) + + error -> + CodincodApiWeb.FallbackController.call(conn, error) + end + end + + operation(:vote, + summary: "Vote on a comment", + parameters: [ + id: [ + in: :path, + description: "Comment ID", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + request_body: {"Vote request", "application/json", Schemas.Comment.VoteRequest}, + responses: [ + ok: {"Updated comment with vote", "application/json", Schemas.Comment.CommentResponse}, + bad_request: {"Invalid vote type", "application/json", Schemas.Common.ErrorResponse}, + not_found: {"Comment not found", "application/json", Schemas.Common.ErrorResponse}, + unprocessable_entity: {"Unable to process vote", "application/json", Schemas.Common.ErrorResponse} + ] + ) + + def vote(conn, %{"id" => id} = params) do + with %Comment{} = comment <- Comments.get_comment!(id), + %User{id: user_id} <- conn.assigns[:current_user], + {:ok, vote_type} <- extract_vote_type(conn.body_params, params), + {:ok, %Comment{} = updated} <- Comments.toggle_vote(comment, user_id, vote_type) do + json(conn, serialize_comment(updated)) + else + {:error, {:invalid_vote_type, _}} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid vote type", allowed: ["upvote", "downvote"]}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Unable to update vote", errors: translate_errors(changeset)}) + + error -> + CodincodApiWeb.FallbackController.call(conn, error) + end + end + + defp authorize_comment_delete(%Comment{author_id: author_id}, %User{id: user_id, role: role}) do + if author_id == user_id or role in ["moderator", "admin"] do + :ok + else + {:error, :forbidden} + end + end + + defp extract_vote_type(%{"type" => type}, _params) when type in ["upvote", "downvote"], + do: {:ok, type} + + defp extract_vote_type(_body_params, %{"type" => type}) when type in ["upvote", "downvote"], + do: {:ok, type} + + defp extract_vote_type(_, _), do: {:error, {:invalid_vote_type, nil}} + + defp serialize_comment(%Comment{} = comment) do + %{ + id: comment.id, + body: comment.body, + commentType: comment.comment_type, + upvote: comment.upvote_count, + downvote: comment.downvote_count, + authorId: comment.author_id, + puzzleId: comment.puzzle_id, + submissionId: comment.submission_id, + parentCommentId: comment.parent_comment_id, + deletedAt: comment.deleted_at, + insertedAt: comment.inserted_at, + updatedAt: comment.updated_at, + author: serialize_author(comment.author), + children: Enum.map(comment.children || [], &serialize_comment/1) + } + end + + defp serialize_author(%User{} = user) do + %{ + id: user.id, + username: user.username, + role: user.role + } + end + + defp serialize_author(_), do: nil + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/error_json.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/error_json.ex new file mode 100644 index 00000000..9eaaca75 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule CodincodApiWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/execute_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/execute_controller.ex new file mode 100644 index 00000000..48f76181 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/execute_controller.ex @@ -0,0 +1,190 @@ +defmodule CodincodApiWeb.ExecuteController do + @moduledoc """ + Handles code execution without persistence, allowing users to test code + against custom inputs before creating submissions. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.Accounts.User + alias CodincodApi.Piston + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + tags(["Execute"]) + + operation(:create, + summary: "Execute code without saving", + description: "Runs code against Piston with custom test input/output for validation", + request_body: {"Execute request", "application/json", Schemas.Execute.ExecuteRequest}, + responses: %{ + 200 => {"Execution result", "application/json", Schemas.Execute.ExecuteResponse}, + 400 => {"Invalid request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 503 => {"Service unavailable", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def create(conn, params) do + with %User{} <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, attrs} <- normalize_execute_params(params), + {:ok, runtimes} <- Piston.list_runtimes(), + {:ok, runtime} <- find_runtime(runtimes, attrs.language), + {:ok, execution_result} <- execute_code(runtime, attrs) do + result = calculate_result(execution_result, attrs.test_output) + + response = %{ + run: execution_result["run"], + compile: execution_result["compile"], + puzzleResultInformation: result + } + + conn + |> put_status(:ok) + |> json(response) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Not authenticated"}) + + {:error, :invalid_payload, errors} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid execution payload", errors: errors}) + + {:error, :runtime_not_found} -> + conn + |> put_status(:bad_request) + |> json(%{ + error: "Unsupported language", + message: "At the moment we don't support this language." + }) + + {:error, :service_unavailable} -> + conn + |> put_status(:service_unavailable) + |> json(%{ + error: "Internal server error", + message: "Network error occurred" + }) + + {:error, reason} -> + conn + |> put_status(:internal_server_error) + |> json(%{ + error: "Internal server error", + message: "Something went wrong", + reason: inspect(reason) + }) + end + end + + defp normalize_execute_params(params) when is_map(params) do + {code, errors} = validate_required_string(Map.get(params, "code"), "code") + {language, errors} = validate_required_string(Map.get(params, "language"), "language", errors) + test_input = Map.get(params, "testInput", "") + test_output = Map.get(params, "testOutput", "") + + if errors == [] do + {:ok, + %{ + code: code, + language: language, + test_input: test_input, + test_output: test_output + }} + else + {:error, :invalid_payload, errors} + end + end + + defp normalize_execute_params(_params), do: {:error, :invalid_payload, []} + + defp validate_required_string(value, field, errors \\ []) + + defp validate_required_string(value, field, errors) when is_binary(value) do + if String.trim(value) == "" do + {nil, [%{field: field, message: "cannot be empty"} | errors]} + else + {value, errors} + end + end + + defp validate_required_string(_value, field, errors), + do: {nil, [%{field: field, message: "is required"} | errors]} + + defp find_runtime(runtimes, language) when is_list(runtimes) and is_binary(language) do + normalized = String.downcase(language) + + runtime = + Enum.find(runtimes, fn rt -> + runtime_lang = Map.get(rt, "language") || Map.get(rt, :language) + runtime_lang && String.downcase(to_string(runtime_lang)) == normalized + end) + + case runtime do + nil -> {:error, :runtime_not_found} + rt -> {:ok, rt} + end + end + + defp find_runtime(_runtimes, _language), do: {:error, :runtime_not_found} + + defp execute_code(runtime, attrs) do + request = %{ + "language" => Map.get(runtime, "language") || Map.get(runtime, :language), + "version" => Map.get(runtime, "version") || Map.get(runtime, :version), + "files" => [%{"content" => attrs.code}], + "stdin" => attrs.test_input + } + + case Piston.execute(request) do + {:ok, result} -> + if is_successful_execution?(result) do + {:ok, result} + else + {:error, {:piston_error, result}} + end + + {:error, _reason} -> + {:error, :service_unavailable} + end + end + + defp is_successful_execution?(result) when is_map(result) do + # Piston returns successful executions with run.code == 0 or similar structure + # We consider it successful if we got a response (errors are in the response itself) + Map.has_key?(result, "run") || Map.has_key?(result, :run) + end + + defp is_successful_execution?(_result), do: false + + defp calculate_result(execution_result, expected_output) do + run = execution_result["run"] || execution_result[:run] || %{} + output = run["output"] || run["stdout"] || run[:output] || run[:stdout] || "" + exit_code = run["code"] || run[:code] || 0 + + passed = + if exit_code == 0 do + trimmed_output = String.trim_trailing(to_string(output)) + trimmed_expected = String.trim_trailing(to_string(expected_output)) + if trimmed_output == trimmed_expected, do: 1, else: 0 + else + 0 + end + + failed = 1 - passed + success_rate = if passed == 1, do: 1.0, else: 0.0 + + %{ + result: if(passed == 1, do: "SUCCESS", else: "ERROR"), + successRate: success_rate, + passed: passed, + failed: failed, + total: 1 + } + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/fallback_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/fallback_controller.ex new file mode 100644 index 00000000..6086c4a8 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/fallback_controller.ex @@ -0,0 +1,45 @@ +defmodule CodincodApiWeb.FallbackController do + @moduledoc """ + Translates controller action results into valid Plug responses. + """ + + use CodincodApiWeb, :controller + + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> json(%{message: "Resource not found"}) + end + + def call(conn, {:error, :unauthorized}) do + conn + |> put_status(:unauthorized) + |> json(%{message: "Unauthorized"}) + end + + def call(conn, {:error, :forbidden}) do + conn + |> put_status(:forbidden) + |> json(%{message: "Forbidden"}) + end + + def call(conn, {:error, %Ecto.Changeset{} = changeset}) do + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: translate_errors(changeset)}) + end + + def call(conn, {:error, reason}) do + conn + |> put_status(:internal_server_error) + |> json(%{message: "Internal server error", reason: inspect(reason)}) + end + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/game_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/game_controller.ex new file mode 100644 index 00000000..e96f0b87 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/game_controller.ex @@ -0,0 +1,519 @@ +defmodule CodincodApiWeb.GameController do + @moduledoc """ + Handles game lobby creation, joining, and management for multiplayer coding challenges. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.{Games, Puzzles} + alias CodincodApi.Accounts.User + alias CodincodApi.Games.Game + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + tags(["Games"]) + + operation(:list_waiting_rooms, + summary: "List all waiting game lobbies", + responses: %{ + 200 => {"Waiting rooms", "application/json", Schemas.Games.WaitingRoomsResponse} + } + ) + + def list_waiting_rooms(conn, _params) do + rooms = Games.list_waiting_rooms() + + conn + |> put_status(:ok) + |> json(%{ + rooms: Enum.map(rooms, &serialize_game/1), + count: length(rooms) + }) + end + + operation(:create, + summary: "Create a new game lobby", + request_body: {"Game creation payload", "application/json", Schemas.Games.CreateGameRequest}, + responses: %{ + 201 => {"Game created", "application/json", Schemas.Games.GameResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Puzzle not found", "application/json", Schemas.Common.ErrorResponse}, + 422 => {"Validation error", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def create(conn, params) do + with %User{id: user_id} <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, attrs} <- normalize_create_params(params, user_id), + {:ok, _puzzle} <- Puzzles.fetch_puzzle(attrs.puzzle_id), + {:ok, game} <- Games.create_game(attrs) do + conn + |> put_status(:created) + |> json(serialize_game(game)) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :invalid_payload} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid game creation payload"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Puzzle not found"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Validation failed", details: translate_errors(changeset)}) + end + end + + operation(:show, + summary: "Get game details", + parameters: [ + id: [ + in: :path, + description: "Game identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + responses: %{ + 200 => {"Game details", "application/json", Schemas.Games.GameResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Game not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def show(conn, %{"id" => game_id}) do + with {:ok, game_uuid} <- parse_uuid(game_id) do + game = Games.get_game!(game_uuid, preload: [:owner, :puzzle, players: :user]) + + conn + |> put_status(:ok) + |> json(serialize_game(game)) + else + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid game ID"}) + end + rescue + Ecto.NoResultsError -> + conn + |> put_status(:not_found) + |> json(%{error: "Game not found"}) + end + + operation(:join, + summary: "Join a game lobby", + parameters: [ + id: [ + in: :path, + description: "Game identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + responses: %{ + 200 => {"Joined game", "application/json", Schemas.Games.GameResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Game not found", "application/json", Schemas.Common.ErrorResponse}, + 409 => {"Game full or already started", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def join(conn, %{"id" => game_id}) do + with %User{id: user_id} <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, game_uuid} <- parse_uuid(game_id), + game <- Games.get_game!(game_uuid, preload: [:owner, :puzzle, players: :user]), + :ok <- validate_can_join(game, user_id), + {:ok, _game_player} <- Games.join_game(game, %{user_id: user_id}) do + # Reload game with updated players + updated_game = Games.get_game!(game_uuid, preload: [:owner, :puzzle, players: :user]) + + # Broadcast to game channel that player joined + CodincodApiWeb.Endpoint.broadcast( + "game:#{game_id}", + "player_joined", + serialize_game(updated_game) + ) + + conn + |> put_status(:ok) + |> json(serialize_game(updated_game)) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid game ID"}) + + {:error, :game_full} -> + conn + |> put_status(:conflict) + |> json(%{error: "Game is full"}) + + {:error, :game_started} -> + conn + |> put_status(:conflict) + |> json(%{error: "Game has already started"}) + + {:error, :already_joined} -> + conn + |> put_status(:conflict) + |> json(%{error: "Already in this game"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to join game", details: translate_errors(changeset)}) + end + rescue + Ecto.NoResultsError -> + conn + |> put_status(:not_found) + |> json(%{error: "Game not found"}) + end + + operation(:leave, + summary: "Leave a game lobby", + parameters: [ + id: [ + in: :path, + description: "Game identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + responses: %{ + 200 => {"Left game", "application/json", Schemas.Games.LeaveGameResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Game not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def leave(conn, %{"id" => game_id}) do + with %User{id: user_id} <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, game_uuid} <- parse_uuid(game_id), + game <- Games.get_game!(game_uuid), + :ok <- Games.leave_game(game, user_id) do + # Broadcast to game channel that player left + CodincodApiWeb.Endpoint.broadcast( + "game:#{game_id}", + "player_left", + %{userId: user_id} + ) + + conn + |> put_status(:ok) + |> json(%{message: "Left game successfully"}) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid game ID"}) + end + rescue + Ecto.NoResultsError -> + conn + |> put_status(:not_found) + |> json(%{error: "Game not found"}) + end + + operation(:start, + summary: "Start a game (host only)", + parameters: [ + id: [ + in: :path, + description: "Game identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + responses: %{ + 200 => {"Game started", "application/json", Schemas.Games.GameResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Not game host", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Game not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def start(conn, %{"id" => game_id}) do + with %User{id: user_id} <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, game_uuid} <- parse_uuid(game_id), + game <- Games.get_game!(game_uuid, preload: [:owner, :puzzle, players: :user]), + :ok <- validate_is_host(game, user_id), + {:ok, _updated_game} <- + Games.transition_game(game, "in_progress", %{started_at: DateTime.utc_now()}) do + # Reload to get associations + game_with_assocs = Games.get_game!(game_uuid, preload: [:owner, :puzzle, players: :user]) + + # Broadcast game start + CodincodApiWeb.Endpoint.broadcast( + "game:#{game_id}", + "game_started", + serialize_game(game_with_assocs) + ) + + conn + |> put_status(:ok) + |> json(serialize_game(game_with_assocs)) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid game ID"}) + + {:error, :not_host} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Only the host can start the game"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to start game", details: translate_errors(changeset)}) + end + rescue + Ecto.NoResultsError -> + conn + |> put_status(:not_found) + |> json(%{error: "Game not found"}) + end + + operation(:submit_code, + summary: "Submit code for a game", + description: "Links an existing submission to a game, marking it as a player's game submission.", + parameters: [ + id: [ + in: :path, + description: "Game identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + request_body: {"Game submission", "application/json", Schemas.Games.GameSubmitCodeRequest}, + responses: %{ + 200 => {"Submission linked to game", "application/json", Schemas.Games.SubmitCodeResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Not a game participant", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Game or submission not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def submit_code(conn, %{"id" => game_id, "submissionId" => submission_id}) do + alias CodincodApi.Submissions + + with %User{id: user_id} <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, game_uuid} <- parse_uuid(game_id), + {:ok, submission_uuid} <- parse_uuid(submission_id), + game <- Games.get_game!(game_uuid, preload: [:players]), + :ok <- validate_is_participant(game, user_id), + {:ok, submission} <- Submissions.get_submission(submission_uuid), + :ok <- validate_submission_owner(submission, user_id), + {:ok, updated_submission} <- + Submissions.link_to_game(submission, game_uuid) do + # Broadcast to game channel + CodincodApiWeb.Endpoint.broadcast( + "game:#{game_id}", + "player_submitted", + %{ + userId: user_id, + submissionId: submission_id, + gameId: game_id + } + ) + + conn + |> put_status(:ok) + |> json(%{ + message: "Submission linked to game", + submissionId: updated_submission.id, + gameId: game_id + }) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid game or submission ID"}) + + {:error, :not_participant} -> + conn + |> put_status(:forbidden) + |> json(%{error: "You are not a participant in this game"}) + + {:error, :not_owner} -> + conn + |> put_status(:forbidden) + |> json(%{error: "You can only submit your own code"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Submission not found"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to link submission", details: translate_errors(changeset)}) + end + rescue + Ecto.NoResultsError -> + conn + |> put_status(:not_found) + |> json(%{error: "Game not found"}) + end + + ## Private functions + + defp normalize_create_params(params, user_id) when is_map(params) do + with {:ok, puzzle_id} <- get_and_parse_uuid(params, "puzzleId") do + # Map frontend fields to actual schema fields + mode = Map.get(params, "gameMode", "FASTEST") + visibility = Map.get(params, "visibility", "public") + max_duration = Map.get(params, "timeLimit", 600) + + {:ok, + %{ + owner_id: user_id, + puzzle_id: puzzle_id, + mode: mode, + visibility: visibility, + max_duration_seconds: max_duration, + status: "waiting" + }} + else + _ -> {:error, :invalid_payload} + end + end + + defp validate_can_join(%Game{status: status}, _user_id) when status != "waiting" do + {:error, :game_started} + end + + defp validate_can_join(%Game{players: players}, user_id) do + # Note: max_players is not in schema, so we just check if already joined + # You may need to add max_players field to games table if needed + cond do + Enum.any?(players, fn p -> p.user_id == user_id end) -> + {:error, :already_joined} + + true -> + :ok + end + end + + defp validate_is_host(%Game{owner_id: owner_id}, user_id) do + if owner_id == user_id do + :ok + else + {:error, :not_host} + end + end + + defp validate_is_participant(%Game{players: players}, user_id) do + if Enum.any?(players, fn p -> p.user_id == user_id end) do + :ok + else + {:error, :not_participant} + end + end + + defp validate_submission_owner(%{user_id: submission_user_id}, user_id) do + if submission_user_id == user_id do + :ok + else + {:error, :not_owner} + end + end + + defp serialize_game(%Game{} = game) do + %{ + id: game.id, + status: game.status, + mode: game.mode, + visibility: game.visibility, + maxDurationSeconds: game.max_duration_seconds, + rated: game.rated, + owner: + game.owner && + %{ + id: game.owner.id, + username: game.owner.username + }, + puzzle: + game.puzzle && + %{ + id: game.puzzle.id, + title: game.puzzle.title, + difficulty: game.puzzle.difficulty + }, + players: + Enum.map(game.players || [], fn player -> + %{ + id: player.user.id, + username: player.user.username, + role: player.role, + joinedAt: player.joined_at + } + end), + createdAt: game.inserted_at, + startedAt: game.started_at, + endedAt: game.ended_at + } + end + + defp get_and_parse_uuid(params, key) do + case Map.get(params, key) do + nil -> {:error, :missing_field} + value -> parse_uuid(value) + end + end + + defp parse_uuid(value) when is_binary(value) do + case Ecto.UUID.cast(value) do + {:ok, uuid} -> {:ok, uuid} + :error -> {:error, :invalid_uuid} + end + end + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/health_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/health_controller.ex new file mode 100644 index 00000000..c29db530 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/health_controller.ex @@ -0,0 +1,33 @@ +defmodule CodincodApiWeb.HealthController do + @moduledoc """ + Health check endpoint for monitoring service availability. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + tags(["Health"]) + + operation(:show, + summary: "Health check", + description: "Returns service health status", + responses: %{ + 200 => { + "Health status", + "application/json", + %OpenApiSpex.Schema{ + type: :object, + properties: %{ + status: %OpenApiSpex.Schema{type: :string, example: "OK"} + } + } + } + } + ) + + def show(conn, _params) do + conn + |> put_status(:ok) + |> json(%{status: "OK"}) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/leaderboard_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/leaderboard_controller.ex new file mode 100644 index 00000000..303ce246 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/leaderboard_controller.ex @@ -0,0 +1,221 @@ +defmodule CodincodApiWeb.LeaderboardController do + @moduledoc """ + Handles leaderboard and ranking queries for users across different game modes and puzzles. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + import Ecto.Query + alias CodincodApi.{Metrics, Puzzles, Repo} + alias CodincodApi.Accounts.User + alias CodincodApi.Metrics.UserMetric + alias CodincodApi.Submissions.Submission + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + tags(["Leaderboard"]) + + operation(:global, + summary: "Get global leaderboard rankings", + parameters: [ + game_mode: [ + in: :query, + description: "Game mode filter", + schema: %OpenApiSpex.Schema{type: :string, enum: ["standard", "timed", "ranked"]}, + required: false + ], + limit: [ + in: :query, + description: "Number of entries to return (1-100)", + schema: %OpenApiSpex.Schema{type: :integer, minimum: 1, maximum: 100}, + required: false + ], + offset: [ + in: :query, + description: "Pagination offset", + schema: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + required: false + ] + ], + responses: %{ + 200 => + {"Leaderboard rankings", "application/json", + Schemas.Leaderboard.GlobalLeaderboardResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def global(conn, params) do + game_mode = Map.get(params, "game_mode", "standard") + limit = parse_int(params["limit"], 50, 1, 100) + offset = parse_int(params["offset"], 0, 0, 10_000) + + # Try to use cached snapshot if available + snapshot = Metrics.latest_snapshot(game_mode) + + rankings = + if snapshot && fresh_snapshot?(snapshot) do + # Use cached snapshot + snapshot.rankings + |> Enum.slice(offset, limit) + else + # Compute live rankings + compute_global_rankings(game_mode, limit, offset) + end + + conn + |> put_status(:ok) + |> json(%{ + gameMode: game_mode, + rankings: rankings, + limit: limit, + offset: offset, + cachedAt: snapshot && snapshot.captured_at + }) + end + + operation(:puzzle, + summary: "Get puzzle-specific leaderboard", + parameters: [ + puzzle_id: [ + in: :path, + description: "Puzzle identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ], + limit: [ + in: :query, + description: "Number of entries to return (1-100)", + schema: %OpenApiSpex.Schema{type: :integer, minimum: 1, maximum: 100}, + required: false + ] + ], + responses: %{ + 200 => + {"Puzzle leaderboard", "application/json", Schemas.Leaderboard.PuzzleLeaderboardResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Puzzle not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def puzzle(conn, %{"puzzle_id" => puzzle_id} = params) do + limit = parse_int(params["limit"], 50, 1, 100) + + with {:ok, puzzle_uuid} <- parse_uuid(puzzle_id), + {:ok, _puzzle} <- Puzzles.fetch_puzzle(puzzle_uuid) do + rankings = compute_puzzle_rankings(puzzle_uuid, limit) + + conn + |> put_status(:ok) + |> json(%{ + puzzleId: puzzle_id, + rankings: rankings, + limit: limit + }) + else + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid puzzle ID format"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Puzzle not found"}) + end + end + + ## Private functions + + defp fresh_snapshot?(snapshot) do + # Consider snapshot fresh if less than 5 minutes old + DateTime.diff(DateTime.utc_now(), snapshot.captured_at, :second) < 300 + end + + defp compute_global_rankings(game_mode, limit, offset) do + UserMetric + |> where([m], m.game_mode == ^game_mode) + |> order_by([m], desc: m.rating, desc: m.puzzles_solved) + |> limit(^limit) + |> offset(^offset) + |> join(:inner, [m], u in User, on: m.user_id == u.id) + |> select([m, u], %{ + rank: over(row_number(), order_by: [desc: m.rating, desc: m.puzzles_solved]), + userId: u.id, + username: u.username, + rating: m.rating, + puzzlesSolved: m.puzzles_solved, + totalSubmissions: m.total_submissions + }) + |> Repo.all() + |> Enum.with_index(offset + 1) + |> Enum.map(fn {entry, idx} -> Map.put(entry, :rank, idx) end) + end + + defp compute_puzzle_rankings(puzzle_id, limit) do + # Get best submission per user for this puzzle + subquery = + from s in Submission, + where: s.puzzle_id == ^puzzle_id and s.status == "accepted", + group_by: s.user_id, + select: %{ + user_id: s.user_id, + best_time: + min( + fragment( + "CAST(? ->> 'executionTime' AS INTEGER)", + s.result + ) + ), + best_memory: + min( + fragment( + "CAST(? ->> 'memoryUsed' AS INTEGER)", + s.result + ) + ), + submitted_at: max(s.inserted_at) + } + + from(sq in subquery(subquery), + join: u in User, + on: sq.user_id == u.id, + order_by: [asc: sq.best_time, asc: sq.best_memory], + limit: ^limit, + select: %{ + userId: u.id, + username: u.username, + executionTime: sq.best_time, + memoryUsed: sq.best_memory, + submittedAt: sq.submitted_at + } + ) + |> Repo.all() + |> Enum.with_index(1) + |> Enum.map(fn {entry, idx} -> Map.put(entry, :rank, idx) end) + end + + defp parse_int(nil, default, _min, _max), do: default + + defp parse_int(value, default, min, max) when is_binary(value) do + case Integer.parse(value) do + {int, ""} when int >= min and int <= max -> int + _ -> default + end + end + + defp parse_int(value, _default, min, max) + when is_integer(value) and value >= min and value <= max, + do: value + + defp parse_int(_value, default, _min, _max), do: default + + defp parse_uuid(value) when is_binary(value) do + case Ecto.UUID.cast(value) do + {:ok, uuid} -> {:ok, uuid} + :error -> {:error, :invalid_uuid} + end + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/metrics_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/metrics_controller.ex new file mode 100644 index 00000000..ffa92e28 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/metrics_controller.ex @@ -0,0 +1,324 @@ +defmodule CodincodApiWeb.MetricsController do + @moduledoc """ + Provides platform-wide metrics, user statistics, and puzzle analytics. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + import Ecto.Query + alias CodincodApi.{Accounts, Puzzles, Repo} + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles.Puzzle + alias CodincodApi.Submissions.Submission + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + tags(["Metrics"]) + + operation(:platform, + summary: "Get platform-wide statistics", + responses: %{ + 200 => {"Platform metrics", "application/json", Schemas.Metrics.PlatformMetricsResponse} + } + ) + + def platform(conn, _params) do + metrics = %{ + totalUsers: Repo.aggregate(User, :count), + totalPuzzles: Repo.aggregate(from(p in Puzzle, where: p.is_published == true), :count), + totalSubmissions: Repo.aggregate(Submission, :count), + acceptedSubmissions: + Repo.aggregate(from(s in Submission, where: s.status == "accepted"), :count), + activeUsers: count_active_users(7), + # Active in last 7 days + popularPuzzles: get_popular_puzzles(5) + } + + conn + |> put_status(:ok) + |> json(metrics) + end + + operation(:user_stats, + summary: "Get detailed statistics for a user", + parameters: [ + user_id: [ + in: :path, + description: "User identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + responses: %{ + 200 => {"User statistics", "application/json", Schemas.Metrics.UserStatsResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"User not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def user_stats(conn, %{"user_id" => user_id}) do + with {:ok, user_uuid} <- parse_uuid(user_id), + {:ok, user} <- Accounts.fetch_user(user_uuid) do + stats = compute_user_stats(user_uuid) + + conn + |> put_status(:ok) + |> json( + Map.merge(stats, %{ + userId: user.id, + username: user.username + }) + ) + else + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid user ID format"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "User not found"}) + end + end + + operation(:puzzle_stats, + summary: "Get detailed statistics for a puzzle", + parameters: [ + puzzle_id: [ + in: :path, + description: "Puzzle identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + responses: %{ + 200 => {"Puzzle statistics", "application/json", Schemas.Metrics.PuzzleStatsResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Puzzle not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def puzzle_stats(conn, %{"puzzle_id" => puzzle_id}) do + with {:ok, puzzle_uuid} <- parse_uuid(puzzle_id), + {:ok, puzzle} <- Puzzles.fetch_puzzle(puzzle_uuid) do + stats = compute_puzzle_stats(puzzle_uuid) + + conn + |> put_status(:ok) + |> json( + Map.merge(stats, %{ + puzzleId: puzzle.id, + title: puzzle.title + }) + ) + else + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid puzzle ID format"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Puzzle not found"}) + end + end + + ## Private functions + + defp count_active_users(days) do + cutoff = DateTime.utc_now() |> DateTime.add(-days * 24 * 60 * 60, :second) + + Submission + |> where([s], s.inserted_at >= ^cutoff) + |> select([s], s.user_id) + |> distinct(true) + |> Repo.aggregate(:count) + end + + defp get_popular_puzzles(limit) do + # Get puzzles with most submissions in last 30 days + cutoff = DateTime.utc_now() |> DateTime.add(-30 * 24 * 60 * 60, :second) + + from(s in Submission, + where: s.inserted_at >= ^cutoff, + group_by: s.puzzle_id, + join: p in Puzzle, + on: s.puzzle_id == p.id, + select: %{ + puzzleId: p.id, + title: p.title, + difficulty: p.difficulty, + submissionCount: count(s.id) + }, + order_by: [desc: count(s.id)], + limit: ^limit + ) + |> Repo.all() + end + + defp compute_user_stats(user_id) do + # Get submission stats + submission_stats = + from(s in Submission, + where: s.user_id == ^user_id, + select: %{ + total: count(s.id), + accepted: filter(count(s.id), s.status == "accepted"), + wrong_answer: filter(count(s.id), s.status == "wrong_answer"), + time_limit: filter(count(s.id), s.status == "time_limit_exceeded"), + runtime_error: filter(count(s.id), s.status == "runtime_error") + } + ) + |> Repo.one() + + # Get unique puzzles solved + puzzles_solved = + from(s in Submission, + where: s.user_id == ^user_id and s.status == "accepted", + select: s.puzzle_id, + distinct: true + ) + |> Repo.aggregate(:count) + + # Get difficulty breakdown + difficulty_breakdown = + from(s in Submission, + where: s.user_id == ^user_id and s.status == "accepted", + join: p in Puzzle, + on: s.puzzle_id == p.id, + group_by: p.difficulty, + select: {p.difficulty, count(s.id)}, + distinct: [s.puzzle_id, p.difficulty] + ) + |> Repo.all() + |> Enum.into(%{}) + + # Get language usage + language_usage = + from(s in Submission, + where: s.user_id == ^user_id, + join: pl in assoc(s, :programming_language), + group_by: pl.name, + select: %{ + language: pl.name, + count: count(s.id) + }, + order_by: [desc: count(s.id)], + limit: 10 + ) + |> Repo.all() + + # Get recent activity (last 30 days) + cutoff = DateTime.utc_now() |> DateTime.add(-30 * 24 * 60 * 60, :second) + + recent_submissions = + Submission + |> where([s], s.user_id == ^user_id and s.inserted_at >= ^cutoff) + |> Repo.aggregate(:count) + + %{ + totalSubmissions: submission_stats.total, + acceptedSubmissions: submission_stats.accepted, + wrongAnswerSubmissions: submission_stats.wrong_answer, + timeLimitExceeded: submission_stats.time_limit, + runtimeErrors: submission_stats.runtime_error, + puzzlesSolved: puzzles_solved, + acceptanceRate: + if(submission_stats.total > 0, + do: Float.round(submission_stats.accepted / submission_stats.total * 100, 2), + else: 0.0 + ), + difficultyBreakdown: %{ + easy: Map.get(difficulty_breakdown, "easy", 0), + medium: Map.get(difficulty_breakdown, "medium", 0), + hard: Map.get(difficulty_breakdown, "hard", 0), + expert: Map.get(difficulty_breakdown, "expert", 0) + }, + languageUsage: language_usage, + recentActivity: recent_submissions + } + end + + defp compute_puzzle_stats(puzzle_id) do + # Get submission stats + submission_stats = + from(s in Submission, + where: s.puzzle_id == ^puzzle_id, + select: %{ + total: count(s.id), + accepted: filter(count(s.id), s.status == "accepted"), + wrong_answer: filter(count(s.id), s.status == "wrong_answer"), + time_limit: filter(count(s.id), s.status == "time_limit_exceeded"), + runtime_error: filter(count(s.id), s.status == "runtime_error") + } + ) + |> Repo.one() + + # Get unique solvers + unique_solvers = + from(s in Submission, + where: s.puzzle_id == ^puzzle_id and s.status == "accepted", + select: s.user_id, + distinct: true + ) + |> Repo.aggregate(:count) + + # Get average execution time for accepted submissions + avg_execution_time = + from(s in Submission, + where: s.puzzle_id == ^puzzle_id and s.status == "accepted", + select: + avg( + fragment( + "CAST(? ->> 'executionTime' AS INTEGER)", + s.result + ) + ) + ) + |> Repo.one() + + # Get language distribution + language_distribution = + from(s in Submission, + where: s.puzzle_id == ^puzzle_id and s.status == "accepted", + join: pl in assoc(s, :programming_language), + group_by: pl.name, + select: %{ + language: pl.name, + count: count(s.id) + }, + order_by: [desc: count(s.id)] + ) + |> Repo.all() + + %{ + totalSubmissions: submission_stats.total, + acceptedSubmissions: submission_stats.accepted, + uniqueSolvers: unique_solvers, + acceptanceRate: + if(submission_stats.total > 0, + do: Float.round(submission_stats.accepted / submission_stats.total * 100, 2), + else: 0.0 + ), + averageExecutionTime: avg_execution_time && Float.round(avg_execution_time, 2), + languageDistribution: language_distribution, + statusBreakdown: %{ + accepted: submission_stats.accepted, + wrongAnswer: submission_stats.wrong_answer, + timeLimitExceeded: submission_stats.time_limit, + runtimeError: submission_stats.runtime_error + } + } + end + + defp parse_uuid(value) when is_binary(value) do + case Ecto.UUID.cast(value) do + {:ok, uuid} -> {:ok, uuid} + :error -> {:error, :invalid_uuid} + end + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/moderation_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/moderation_controller.ex new file mode 100644 index 00000000..3393771b --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/moderation_controller.ex @@ -0,0 +1,549 @@ +defmodule CodincodApiWeb.ModerationController do + @moduledoc """ + Handles content moderation, reporting, and admin review workflows. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.{Accounts, Moderation} + alias CodincodApi.Accounts.User + alias CodincodApi.Moderation.{ModerationReview, Report} + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + tags(["Moderation"]) + + ## Reports + + operation(:create_report, + summary: "Create a new report for inappropriate content", + request_body: {"Report payload", "application/json", Schemas.Moderation.CreateReportRequest}, + responses: %{ + 201 => {"Report created", "application/json", Schemas.Moderation.ReportResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 422 => {"Validation error", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def create_report(conn, params) do + with %User{id: user_id} <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, attrs} <- normalize_report_params(params, user_id), + {:ok, report} <- Moderation.create_report(attrs, preload: [:reported_by, :resolved_by]) do + conn + |> put_status(:created) + |> json(serialize_report(report)) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :invalid_payload} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid report payload"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Validation failed", details: translate_errors(changeset)}) + end + end + + operation(:list_reports, + summary: "List reports (admin only)", + parameters: [ + status: [ + in: :query, + description: "Filter by status", + schema: %OpenApiSpex.Schema{ + type: :string, + enum: ["pending", "reviewing", "resolved", "dismissed"] + }, + required: false + ], + problem_type: [ + in: :query, + description: "Filter by problem type", + schema: %OpenApiSpex.Schema{ + type: :string, + enum: ["spam", "inappropriate", "copyright", "harassment", "other"] + }, + required: false + ] + ], + responses: %{ + 200 => {"Reports list", "application/json", Schemas.Moderation.ReportsListResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def list_reports(conn, params) do + with %User{} = user <- conn.assigns[:current_user] || {:error, :unauthorized}, + :ok <- ensure_admin(user) do + filters = build_report_filters(params) + reports = Moderation.list_reports(filters, preload: [:reported_by, :resolved_by]) + + conn + |> put_status(:ok) + |> json(%{ + reports: Enum.map(reports, &serialize_report/1), + count: length(reports) + }) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Admin access required"}) + end + end + + operation(:resolve_report, + summary: "Resolve a report (admin only)", + parameters: [ + id: [ + in: :path, + description: "Report identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + request_body: + {"Resolution payload", "application/json", Schemas.Moderation.ResolveReportRequest}, + responses: %{ + 200 => {"Report resolved", "application/json", Schemas.Moderation.ReportResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Report not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def resolve_report(conn, %{"id" => report_id} = params) do + with %User{id: user_id} = user <- conn.assigns[:current_user] || {:error, :unauthorized}, + :ok <- ensure_admin(user), + {:ok, report_uuid} <- parse_uuid(report_id), + report <- Moderation.get_report!(report_uuid), + {:ok, attrs} <- normalize_resolution_params(params, user_id), + {:ok, updated_report} <- + Moderation.resolve_report(report, attrs, preload: [:reported_by, :resolved_by]) do + conn + |> put_status(:ok) + |> json(serialize_report(updated_report)) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Admin access required"}) + + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid report ID"}) + + {:error, :invalid_payload} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid resolution payload"}) + end + end + + ## Moderation Reviews + + operation(:list_reviews, + summary: "List pending moderation reviews (moderator only)", + parameters: [ + status: [ + in: :query, + description: "Filter by status", + schema: %OpenApiSpex.Schema{ + type: :string, + enum: ["pending", "approved", "rejected"] + }, + required: false + ] + ], + responses: %{ + 200 => {"Reviews list", "application/json", Schemas.Moderation.ReviewsListResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def list_reviews(conn, params) do + with %User{} = user <- conn.assigns[:current_user] || {:error, :unauthorized}, + :ok <- ensure_moderator(user) do + filters = build_review_filters(params) + reviews = Moderation.list_reviews(filters, preload: [:puzzle, :reviewer]) + + conn + |> put_status(:ok) + |> json(%{ + reviews: Enum.map(reviews, &serialize_review/1), + count: length(reviews) + }) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Moderator access required"}) + end + end + + operation(:review_content, + summary: "Review and approve/reject content (moderator only)", + parameters: [ + id: [ + in: :path, + description: "Review identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + request_body: + {"Review decision", "application/json", Schemas.Moderation.ReviewDecisionRequest}, + responses: %{ + 200 => {"Review updated", "application/json", Schemas.Moderation.ReviewResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Review not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def review_content(conn, %{"id" => review_id} = params) do + with %User{id: user_id} = user <- conn.assigns[:current_user] || {:error, :unauthorized}, + :ok <- ensure_moderator(user), + {:ok, review_uuid} <- parse_uuid(review_id), + review <- Moderation.get_review!(review_uuid), + {:ok, attrs} <- normalize_review_decision_params(params, user_id), + {:ok, updated_review} <- + Moderation.update_review(review, attrs, preload: [:puzzle, :reviewer]) do + conn + |> put_status(:ok) + |> json(serialize_review(updated_review)) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Moderator access required"}) + + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid review ID"}) + + {:error, :invalid_payload} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid decision payload"}) + end + end + + ## User Management (Admin) + + operation(:ban_user, + summary: "Ban a user (admin only)", + parameters: [ + user_id: [ + in: :path, + description: "User identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + request_body: {"Ban details", "application/json", Schemas.Moderation.BanUserRequest}, + responses: %{ + 200 => {"User banned", "application/json", Schemas.Moderation.BanResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"User not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def ban_user(conn, %{"user_id" => target_user_id} = params) do + with %User{} = admin <- conn.assigns[:current_user] || {:error, :unauthorized}, + :ok <- ensure_admin(admin), + {:ok, user_uuid} <- parse_uuid(target_user_id), + {:ok, user} <- Accounts.fetch_user(user_uuid), + {:ok, attrs} <- normalize_ban_params(params), + {:ok, updated_user} <- Accounts.ban_user(user, attrs) do + conn + |> put_status(:ok) + |> json(%{ + userId: updated_user.id, + banned: true, + bannedUntil: updated_user.banned_until, + reason: attrs[:ban_reason] + }) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Admin access required"}) + + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid user ID"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "User not found"}) + + {:error, :invalid_payload} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid ban payload"}) + end + end + + operation(:unban_user, + summary: "Unban a user (admin only)", + parameters: [ + user_id: [ + in: :path, + description: "User identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + responses: %{ + 200 => {"User unbanned", "application/json", Schemas.Moderation.BanResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"User not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def unban_user(conn, %{"user_id" => target_user_id}) do + with %User{} = admin <- conn.assigns[:current_user] || {:error, :unauthorized}, + :ok <- ensure_admin(admin), + {:ok, user_uuid} <- parse_uuid(target_user_id), + {:ok, user} <- Accounts.fetch_user(user_uuid), + {:ok, updated_user} <- Accounts.unban_user(user) do + conn + |> put_status(:ok) + |> json(%{ + userId: updated_user.id, + banned: false, + bannedUntil: nil + }) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Admin access required"}) + + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid user ID"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "User not found"}) + end + end + + ## Private functions + + defp ensure_admin(%User{role: role}) do + if role in ["admin", "moderator"] do + :ok + else + {:error, :forbidden} + end + end + + defp ensure_moderator(%User{role: role}) do + if role in ["admin", "moderator"] do + :ok + else + {:error, :forbidden} + end + end + + defp normalize_report_params(params, user_id) when is_map(params) do + with {:ok, _content_type} <- get_required_field(params, "contentType"), + {:ok, content_id} <- get_required_field(params, "contentId"), + {:ok, problem_type} <- get_required_field(params, "problemType") do + {:ok, + %{ + reported_by_id: user_id, + problem_type: problem_type, + problem_reference_id: content_id, + explanation: Map.get(params, "description"), + status: "pending" + }} + else + _ -> {:error, :invalid_payload} + end + end + + defp normalize_resolution_params(params, admin_id) when is_map(params) do + with {:ok, status} <- get_required_field(params, "status") do + {:ok, + %{ + status: status, + resolved_by_id: admin_id, + resolution_notes: Map.get(params, "resolutionNotes"), + resolved_at: DateTime.utc_now() + }} + else + _ -> {:error, :invalid_payload} + end + end + + defp normalize_review_decision_params(params, reviewer_id) when is_map(params) do + with {:ok, status} <- get_required_field(params, "status") do + {:ok, + %{ + status: status, + reviewer_id: reviewer_id, + notes: Map.get(params, "reviewerNotes"), + resolved_at: DateTime.utc_now() + }} + else + _ -> {:error, :invalid_payload} + end + end + + defp normalize_ban_params(params) when is_map(params) do + duration_days = Map.get(params, "durationDays") + + banned_until = + if duration_days && is_integer(duration_days) do + DateTime.utc_now() |> DateTime.add(duration_days * 24 * 60 * 60, :second) + else + Map.get(params, "bannedUntil") + end + + {:ok, + %{ + banned_until: banned_until, + ban_reason: Map.get(params, "reason") + }} + end + + defp build_report_filters(params) do + %{} + |> maybe_add_filter(params, "status", :status) + |> maybe_add_filter(params, "problemType", :problem_type) + end + + defp build_review_filters(params) do + %{} + |> maybe_add_filter(params, "status", :status) + end + + defp maybe_add_filter(filters, params, key, filter_key) do + case Map.get(params, key) do + nil -> filters + value -> Map.put(filters, filter_key, value) + end + end + + defp get_required_field(params, key) do + case Map.get(params, key) do + nil -> {:error, :missing_field} + value -> {:ok, value} + end + end + + defp serialize_report(%Report{} = report) do + %{ + id: report.id, + contentType: report.problem_type, + contentId: report.problem_reference_id, + problemType: report.problem_type, + description: report.explanation, + status: report.status, + reportedBy: + report.reported_by && + %{ + id: report.reported_by.id, + username: report.reported_by.username + }, + resolvedBy: + report.resolved_by && + %{ + id: report.resolved_by.id, + username: report.resolved_by.username + }, + resolutionNotes: report.resolution_notes, + createdAt: report.inserted_at, + resolvedAt: report.resolved_at + } + end + + defp serialize_review(%ModerationReview{} = review) do + %{ + id: review.id, + puzzleId: review.puzzle_id, + status: review.status, + reviewer: + review.reviewer && + %{ + id: review.reviewer.id, + username: review.reviewer.username + }, + reviewerNotes: review.notes, + createdAt: review.inserted_at, + reviewedAt: review.resolved_at + } + end + + defp parse_uuid(value) when is_binary(value) do + case Ecto.UUID.cast(value) do + {:ok, uuid} -> {:ok, uuid} + :error -> {:error, :invalid_uuid} + end + end + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/open_api_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/open_api_controller.ex new file mode 100644 index 00000000..36ff49e8 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/open_api_controller.ex @@ -0,0 +1,10 @@ +defmodule CodincodApiWeb.OpenApiController do + @moduledoc "Serves the OpenAPI specification." + + use CodincodApiWeb, :controller + + def show(conn, _params) do + spec = CodincodApiWeb.OpenAPI.spec() |> OpenApiSpex.OpenApi.to_map() + json(conn, spec) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/password_reset_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/password_reset_controller.ex new file mode 100644 index 00000000..31058fff --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/password_reset_controller.ex @@ -0,0 +1,179 @@ +defmodule CodincodApiWeb.PasswordResetController do + @moduledoc """ + Handles password reset requests and token validation. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.Accounts + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + tags(["Password Reset"]) + + operation(:request_reset, + summary: "Request password reset", + description: "Sends password reset email if user exists", + request_body: {"Reset request", "application/json", Schemas.PasswordReset.RequestPayload}, + responses: %{ + 200 => {"Reset email sent", "application/json", Schemas.PasswordReset.RequestResponse}, + 400 => {"Invalid payload", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def request_reset(conn, params) do + with {:ok, attrs} <- normalize_request_params(params), + base_url <- get_base_url(conn), + {:ok, _reset} <- Accounts.request_password_reset(attrs.email, base_url) do + # Always return success to avoid email enumeration attacks + conn + |> put_status(:ok) + |> json(%{ + message: "If an account exists with this email, a password reset link has been sent." + }) + else + {:error, :user_not_found} -> + # Return same success message to prevent user enumeration + conn + |> put_status(:ok) + |> json(%{ + message: "If an account exists with this email, a password reset link has been sent." + }) + + {:error, :invalid_payload, errors} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid request", errors: errors}) + + {:error, _reason} -> + # Log error internally but show generic success to user + conn + |> put_status(:ok) + |> json(%{ + message: "If an account exists with this email, a password reset link has been sent." + }) + end + end + + operation(:reset_password, + summary: "Reset password with token", + description: "Validates token and updates user password", + request_body: {"Reset payload", "application/json", Schemas.PasswordReset.ResetPayload}, + responses: %{ + 200 => {"Password reset", "application/json", Schemas.PasswordReset.ResetResponse}, + 400 => {"Invalid payload or token", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def reset_password(conn, params) do + with {:ok, attrs} <- normalize_reset_params(params), + {:ok, _user} <- Accounts.reset_password_with_token(attrs.token, attrs.password) do + conn + |> put_status(:ok) + |> json(%{message: "Password successfully reset"}) + else + {:error, :invalid_token} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid or already used reset token"}) + + {:error, :expired_token} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Reset token has expired"}) + + {:error, :invalid_payload, errors} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid reset payload", errors: errors}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Failed to reset password", errors: translate_errors(changeset)}) + end + end + + defp normalize_request_params(params) when is_map(params) do + case Map.get(params, "email") do + email when is_binary(email) and byte_size(email) > 0 -> + {:ok, %{email: String.downcase(String.trim(email))}} + + _ -> + {:error, :invalid_payload, [%{field: "email", message: "is required"}]} + end + end + + defp normalize_request_params(_params), do: {:error, :invalid_payload, []} + + defp normalize_reset_params(params) when is_map(params) do + {token, errors} = validate_required_string(Map.get(params, "token"), "token") + {password, errors} = validate_password(Map.get(params, "password"), "password", errors) + + if errors == [] do + {:ok, %{token: token, password: password}} + else + {:error, :invalid_payload, errors} + end + end + + defp normalize_reset_params(_params), do: {:error, :invalid_payload, []} + + defp validate_required_string(value, field, errors \\ []) + + defp validate_required_string(value, field, errors) when is_binary(value) do + trimmed = String.trim(value) + + if trimmed == "" do + {nil, [%{field: field, message: "cannot be empty"} | errors]} + else + {trimmed, errors} + end + end + + defp validate_required_string(_value, field, errors), + do: {nil, [%{field: field, message: "is required"} | errors]} + + defp validate_password(value, field, errors) when is_binary(value) do + trimmed = String.trim(value) + + cond do + trimmed == "" -> + {nil, [%{field: field, message: "cannot be empty"} | errors]} + + String.length(trimmed) < 8 -> + {nil, [%{field: field, message: "must be at least 8 characters"} | errors]} + + true -> + {trimmed, errors} + end + end + + defp validate_password(_value, field, errors), + do: {nil, [%{field: field, message: "is required"} | errors]} + + defp get_base_url(conn) do + scheme = if conn.scheme == :https, do: "https", else: "http" + host = conn.host + port = conn.port + + port_part = + if (scheme == "https" and port == 443) or (scheme == "http" and port == 80) do + "" + else + ":#{port}" + end + + "#{scheme}://#{host}#{port_part}" + end + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/programming_language_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/programming_language_controller.ex new file mode 100644 index 00000000..bc491547 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/programming_language_controller.ex @@ -0,0 +1,57 @@ +defmodule CodincodApiWeb.ProgrammingLanguageController do + @moduledoc """ + Controller for programming language endpoints. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.Languages + + action_fallback CodincodApiWeb.FallbackController + + @doc """ + List all available programming languages. + """ + operation(:index, + summary: "List all programming languages", + responses: %{ + 200 => { + "Programming languages list", + "application/json", + %OpenApiSpex.Schema{ + type: :array, + items: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + language: %OpenApiSpex.Schema{type: :string}, + version: %OpenApiSpex.Schema{type: :string}, + isActive: %OpenApiSpex.Schema{type: :boolean}, + runtime: %OpenApiSpex.Schema{type: :string}, + aliases: %OpenApiSpex.Schema{type: :array, items: %OpenApiSpex.Schema{type: :string}} + } + } + } + } + } + ) + + def index(conn, _params) do + languages = Languages.list_languages() + + # Serialize languages + serialized_languages = Enum.map(languages, fn language -> + %{ + id: language.id, + language: language.language, + version: language.version, + isActive: language.is_active, + runtime: language.runtime, + aliases: language.aliases || [] + } + end) + + json(conn, serialized_languages) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/puzzle_comment_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/puzzle_comment_controller.ex new file mode 100644 index 00000000..a683acf6 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/puzzle_comment_controller.ex @@ -0,0 +1,183 @@ +defmodule CodincodApiWeb.PuzzleCommentController do + @moduledoc """ + Creates puzzle comments and replies (mirrors Fastify `/puzzle/:id/comment`). + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.{Comments, Puzzles} + alias CodincodApi.Comments.Comment + alias CodincodApi.Accounts.User + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + @min_length 1 + @max_length 320 + + operation(:create, + summary: "Create a comment on a puzzle", + parameters: [ + id: [ + in: :path, + description: "Puzzle ID", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + request_body: {"Comment creation payload", "application/json", Schemas.Comment.CreateRequest}, + responses: %{ + 201 => {"Comment created successfully", "application/json", Schemas.Comment.CommentResponse}, + 400 => {"Invalid payload", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Puzzle or parent comment not found", "application/json", Schemas.Common.ErrorResponse}, + 422 => {"Cannot reply to deleted comment or invalid parent", "application/json", Schemas.Common.ErrorResponse} + } + ) + + @spec create(Plug.Conn.t(), map()) :: Plug.Conn.t() + def create(conn, %{"id" => puzzle_id} = params) do + with %User{id: user_id} = current_user <- conn.assigns[:current_user], + {:ok, %{text: text, reply_on: reply_on}} <- validate_payload(conn.body_params, params), + _puzzle <- Puzzles.get_puzzle!(puzzle_id), + {:ok, parent_comment} <- load_parent_comment(reply_on, puzzle_id), + attrs <- build_comment_attrs(puzzle_id, user_id, text, parent_comment), + {:ok, %Comment{} = comment} <- Comments.create_comment(attrs, preload: [:author]) do + conn + |> put_status(:created) + |> json(%{ + id: comment.id, + body: comment.body, + commentType: comment.comment_type, + upvote: comment.upvote_count, + downvote: comment.downvote_count, + authorId: comment.author_id, + puzzleId: comment.puzzle_id, + parentCommentId: comment.parent_comment_id, + insertedAt: comment.inserted_at, + updatedAt: comment.updated_at, + author: serialize_author(comment.author || current_user) + }) + else + {:error, :invalid_payload, errors} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid payload", errors: errors}) + + {:error, :parent_not_found} -> + conn + |> put_status(:not_found) + |> json(%{message: "Parent comment not found"}) + + {:error, :parent_deleted} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Cannot reply to a deleted comment"}) + + {:error, :invalid_parent} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Parent comment does not belong to this puzzle"}) + + error -> + CodincodApiWeb.FallbackController.call(conn, error) + end + end + + defp validate_payload(body_params, path_params) do + params = + body_params + |> normalize_params() + |> Map.merge(normalize_params(path_params)) + + text = Map.get(params, "text") || Map.get(params, "body") + reply_on = Map.get(params, "replyOn") || Map.get(params, "reply_on") + + with :ok <- validate_text(text), + {:ok, reply_on_id} <- parse_optional_uuid(reply_on) do + {:ok, %{text: text, reply_on: reply_on_id}} + else + {:error, reason} -> {:error, :invalid_payload, reason} + end + end + + defp normalize_params(%{} = params), do: params + defp normalize_params(_), do: %{} + + defp validate_text(text) when is_binary(text) do + len = String.length(text) + + cond do + len < @min_length -> + {:error, %{field: "text", message: "must be at least #{@min_length} characters"}} + + len > @max_length -> + {:error, %{field: "text", message: "must be at most #{@max_length} characters"}} + + true -> + :ok + end + end + + defp validate_text(_), do: {:error, %{field: "text", message: "must be a string"}} + + defp parse_optional_uuid(nil), do: {:ok, nil} + defp parse_optional_uuid(""), do: {:ok, nil} + + defp parse_optional_uuid(value) when is_binary(value) do + case Ecto.UUID.cast(value) do + {:ok, uuid} -> {:ok, uuid} + :error -> {:error, %{field: "replyOn", message: "must be a valid UUID"}} + end + end + + defp parse_optional_uuid(_), do: {:error, %{field: "replyOn", message: "must be a UUID string"}} + + defp load_parent_comment(nil, _puzzle_id), do: {:ok, nil} + + defp load_parent_comment(parent_comment_id, puzzle_id) do + case Comments.get_comment(parent_comment_id) do + nil -> + {:error, :parent_not_found} + + %Comment{deleted_at: deleted_at} when not is_nil(deleted_at) -> + {:error, :parent_deleted} + + %Comment{puzzle_id: parent_puzzle_id} = comment when parent_puzzle_id == puzzle_id -> + {:ok, comment} + + _comment -> + {:error, :invalid_parent} + end + end + + defp build_comment_attrs(puzzle_id, user_id, text, nil) do + %{ + puzzle_id: puzzle_id, + author_id: user_id, + body: text, + comment_type: "puzzle-comment" + } + end + + defp build_comment_attrs(_puzzle_id, user_id, text, %Comment{} = parent) do + %{ + puzzle_id: parent.puzzle_id, + submission_id: parent.submission_id, + author_id: user_id, + body: text, + comment_type: "comment-comment", + parent_comment_id: parent.id + } + end + + defp serialize_author(%User{} = user) do + %{ + id: user.id, + username: user.username, + role: user.role + } + end + + defp serialize_author(_), do: nil +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/puzzle_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/puzzle_controller.ex new file mode 100644 index 00000000..0ea83b99 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/puzzle_controller.ex @@ -0,0 +1,692 @@ +defmodule CodincodApiWeb.PuzzleController do + @moduledoc """ + Puzzle endpoints mirroring Fastify puzzle routes for listing and creation. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles + alias CodincodApi.Puzzles.Puzzle + alias CodincodApiWeb.OpenAPI.Schemas + alias CodincodApiWeb.Serializers.PuzzleSerializer + + action_fallback CodincodApiWeb.FallbackController + + @default_page 1 + @default_page_size 20 + @min_page 1 + @min_page_size 1 + @max_page_size 100 + + tags(["Puzzle"]) + + operation(:index, + summary: "List puzzles", + description: + "Returns paginated puzzles matching the legacy Fastify `/puzzle` listing response.", + parameters: [ + page: [ + in: :query, + description: "Page number", + schema: %OpenApiSpex.Schema{type: :integer, minimum: 1, default: 1} + ], + pageSize: [ + in: :query, + description: "Number of puzzles per page", + schema: %OpenApiSpex.Schema{type: :integer, minimum: 1, maximum: 100, default: 20} + ] + ], + responses: %{ + 200 => {"Paginated puzzles", "application/json", Schemas.Puzzle.PaginatedListResponse}, + 400 => {"Invalid query", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def index(conn, params) do + case validate_pagination(params) do + {:ok, pagination} -> + %{ + items: items, + page: page, + page_size: page_size, + total_items: total_items, + total_pages: total_pages + } = + Puzzles.paginate_all(pagination) + + response = %{ + items: PuzzleSerializer.render_many(items), + page: page, + pageSize: page_size, + totalItems: total_items, + totalPages: total_pages + } + + json(conn, response) + + {:error, errors} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid pagination parameters", errors: errors}) + end + end + + operation(:create, + summary: "Create puzzle", + request_body: {"Puzzle creation payload", "application/json", Schemas.Puzzle.PuzzleCreateRequest}, + responses: %{ + 201 => {"Puzzle created", "application/json", Schemas.Puzzle.PuzzleResponse}, + 400 => {"Validation error", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 422 => {"Unprocessable entity", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def create(conn, params) do + with %User{id: user_id} <- conn.assigns[:current_user], + {:ok, attrs} <- normalize_create_params(params), + # Set defaults for required DB fields that are optional in API + attrs_with_defaults = Map.merge( + %{ + author_id: user_id, + visibility: "DRAFT", + difficulty: "BEGINNER" # Default difficulty for new puzzles + }, + attrs + ), + {:ok, %Puzzle{} = puzzle} <- Puzzles.create_puzzle(attrs_with_defaults) do + conn + |> put_status(:created) + |> json(PuzzleSerializer.render(puzzle)) + else + nil -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Not authenticated"}) + + {:error, :invalid_payload, details} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid puzzle payload", errors: details}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Unable to create puzzle", errors: translate_errors(changeset)}) + + {:error, reason} -> + CodincodApiWeb.FallbackController.call(conn, {:error, reason}) + end + end + + operation(:show, + summary: "Get puzzle by ID", + description: "Returns a single puzzle by ID (public view, no solution details).", + parameters: [ + id: [ + in: :path, + description: "Puzzle UUID", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + responses: %{ + 200 => {"Puzzle found", "application/json", Schemas.Puzzle.PuzzleResponse}, + 404 => {"Puzzle not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def show(conn, %{"id" => id}) do + case Puzzles.fetch_puzzle(id) do + {:ok, puzzle} -> + json(conn, PuzzleSerializer.render(puzzle)) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{message: "Puzzle not found"}) + end + end + + operation(:solution, + summary: "Get puzzle solution for editing", + description: "Returns puzzle with full solution details. Only available to puzzle author or admins.", + parameters: [ + id: [ + in: :path, + description: "Puzzle UUID", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + responses: %{ + 200 => {"Puzzle solution", "application/json", Schemas.Puzzle.PuzzleResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Puzzle not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def solution(conn, %{"id" => id}) do + with %User{id: user_id, role: role} <- conn.assigns[:current_user], + {:ok, puzzle} <- Puzzles.fetch_puzzle_with_validators(id), + :ok <- authorize_puzzle_access(puzzle, user_id, role) do + json(conn, PuzzleSerializer.render(puzzle)) + else + nil -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Not authenticated"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{message: "Puzzle not found"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{message: "You don't have permission to access this puzzle's solution"}) + end + end + + operation(:update, + summary: "Update puzzle", + description: "Updates an existing puzzle. Only available to puzzle author or admins.", + parameters: [ + id: [ + in: :path, + description: "Puzzle UUID", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + request_body: {"Puzzle update payload", "application/json", Schemas.Puzzle.PuzzleCreateRequest}, + responses: %{ + 200 => {"Puzzle updated", "application/json", Schemas.Puzzle.PuzzleResponse}, + 400 => {"Validation error", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Puzzle not found", "application/json", Schemas.Common.ErrorResponse}, + 422 => {"Unprocessable entity", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def update(conn, %{"id" => id} = params) do + with %User{id: user_id, role: role} <- conn.assigns[:current_user], + {:ok, puzzle} <- Puzzles.fetch_puzzle(id), + :ok <- authorize_puzzle_access(puzzle, user_id, role), + {:ok, attrs} <- normalize_update_params(params), + {:ok, %Puzzle{} = updated_puzzle} <- Puzzles.update_puzzle(puzzle, attrs) do + json(conn, PuzzleSerializer.render(updated_puzzle)) + else + nil -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Not authenticated"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{message: "Puzzle not found"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{message: "You don't have permission to update this puzzle"}) + + {:error, :invalid_payload, details} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid puzzle payload", errors: details}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Unable to update puzzle", errors: translate_errors(changeset)}) + + {:error, reason} -> + CodincodApiWeb.FallbackController.call(conn, {:error, reason}) + end + end + + operation(:delete, + summary: "Delete puzzle", + description: "Deletes a puzzle. Only available to puzzle author or admins.", + parameters: [ + id: [ + in: :path, + description: "Puzzle UUID", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + responses: %{ + 204 => {"Puzzle deleted", nil, nil}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Puzzle not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def delete(conn, %{"id" => id}) do + with %User{id: user_id, role: role} <- conn.assigns[:current_user], + {:ok, puzzle} <- Puzzles.fetch_puzzle(id), + :ok <- authorize_puzzle_access(puzzle, user_id, role), + {:ok, _puzzle} <- Puzzles.delete_puzzle(puzzle) do + send_resp(conn, :no_content, "") + else + nil -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Not authenticated"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{message: "Puzzle not found"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{message: "You don't have permission to delete this puzzle"}) + + {:error, reason} -> + CodincodApiWeb.FallbackController.call(conn, {:error, reason}) + end + end + + defp validate_pagination(params) do + {page, page_errors} = + coerce_pagination_param(Map.get(params, "page"), "page", @default_page, min: @min_page) + + {page_size, size_errors} = + coerce_pagination_param( + Map.get(params, "pageSize"), + "pageSize", + @default_page_size, + min: @min_page_size, + max: @max_page_size + ) + + errors = page_errors ++ size_errors + + if errors == [] do + {:ok, %{page: page, page_size: page_size}} + else + {:error, errors} + end + end + + defp coerce_pagination_param(nil, _field, default, _opts), do: {default, []} + + defp coerce_pagination_param(value, field, default, opts) when is_binary(value) do + value + |> String.trim() + |> case do + "" -> + {default, []} + + trimmed -> + case Integer.parse(trimmed) do + {int, ""} -> coerce_pagination_param(int, field, default, opts) + _ -> {default, [%{field: field, message: "must be an integer"}]} + end + end + end + + defp coerce_pagination_param(value, field, default, opts) when is_integer(value) do + min = Keyword.get(opts, :min) + max = Keyword.get(opts, :max) + + cond do + min && value < min -> + {default, [%{field: field, message: "must be >= #{min}"}]} + + max && value > max -> + {default, [%{field: field, message: "must be <= #{max}"}]} + + true -> + {value, []} + end + end + + defp coerce_pagination_param(_value, field, default, _opts), + do: {default, [%{field: field, message: "must be an integer"}]} + + @allowed_difficulties %{ + "easy" => "BEGINNER", + "beginner" => "BEGINNER", + "medium" => "INTERMEDIATE", + "intermediate" => "INTERMEDIATE", + "hard" => "ADVANCED", + "advanced" => "ADVANCED", + "expert" => "EXPERT" + } + + defp normalize_create_params(params) when is_map(params) do + errors = [] + + # Title is the only required field for initial puzzle creation + {title, errors} = + case Map.get(params, "title") do + title when is_binary(title) -> + trimmed = String.trim(title) + + if String.length(trimmed) in 4..128 do + {trimmed, errors} + else + {nil, [%{field: "title", message: "must be between 4 and 128 characters"} | errors]} + end + + _ -> + {nil, [%{field: "title", message: "must be between 4 and 128 characters"} | errors]} + end + + # All other fields are optional during creation - can be filled in step-by-step via edit + statement = + case Map.get(params, "description") do + description when is_binary(description) -> + trimmed = String.trim(description) + if String.length(trimmed) >= 1, do: trimmed, else: nil + + _ -> + nil + end + + difficulty = + case Map.get(params, "difficulty") do + difficulty when is_binary(difficulty) -> + value = String.downcase(String.trim(difficulty)) + Map.get(@allowed_difficulties, value) + + _ -> + nil + end + + validators = + case Map.get(params, "validators") do + validators when is_list(validators) and validators != [] -> + parsed = + Enum.reduce(validators, {[], [], 0}, fn + %{"input" => input, "output" => output} = validator, {acc, errs, index} -> + cond do + not is_binary(input) or input == "" -> + {acc, + [%{field: "validators", index: index, message: "input is required"} | errs], + index + 1} + + not is_binary(output) or output == "" -> + {acc, + [%{field: "validators", index: index, message: "output is required"} | errs], + index + 1} + + true -> + validator_map = %{ + input: input, + output: output, + is_public: Map.get(validator, "isPublic", false) + } + + {[validator_map | acc], errs, index + 1} + end + + _validator, {acc, errs, index} -> + {acc, + [ + %{ + field: "validators", + index: index, + message: "must be objects with input/output" + } + | errs + ], index + 1} + end) + + case parsed do + {acc, [], _} -> Enum.reverse(acc) + {_acc, errs, _} -> {:error, errs} + end + + _ -> + [] + end + + # Check if validators parsing had errors + errors = + case validators do + {:error, validator_errors} -> errors ++ validator_errors + _ -> errors + end + + validators = if is_list(validators), do: validators, else: [] + + tags = + params + |> Map.get("tags") + |> normalize_tags() + + constraints = + params + |> Map.get("constraints") + |> normalize_optional_string() + + if errors == [] do + puzzle_attrs = + %{ + title: title, + statement: statement, + constraints: constraints, + difficulty: difficulty, + tags: tags, + validators: validators, + solution: %{} + } + |> Enum.reject(fn {_k, v} -> is_nil(v) or v == [] end) + |> Enum.into(%{}) + + {:ok, puzzle_attrs} + else + {:error, :invalid_payload, Enum.reverse(errors)} + end + end + + defp normalize_create_params(_), + do: {:error, :invalid_payload, [%{message: "Expected JSON body"}]} + + defp normalize_update_params(params) when is_map(params) do + # For updates, all fields are optional (only include what's being changed) + errors = [] + + # Title (optional for update, but if provided must be valid) + {title, errors} = + case Map.get(params, "title") do + nil -> + {nil, errors} + + title when is_binary(title) -> + trimmed = String.trim(title) + + if String.length(trimmed) in 4..128 do + {trimmed, errors} + else + {nil, [%{field: "title", message: "must be between 4 and 128 characters"} | errors]} + end + + _ -> + {nil, [%{field: "title", message: "must be a string"} | errors]} + end + + # Statement/description (optional) + statement = + case Map.get(params, "description") do + nil -> + nil + + description when is_binary(description) -> + trimmed = String.trim(description) + if String.length(trimmed) >= 1, do: trimmed, else: nil + + _ -> + nil + end + + # Difficulty (optional) + difficulty = + case Map.get(params, "difficulty") do + nil -> + nil + + difficulty when is_binary(difficulty) -> + value = String.downcase(String.trim(difficulty)) + Map.get(@allowed_difficulties, value) + + _ -> + nil + end + + # Visibility (optional) + visibility = + case Map.get(params, "visibility") do + nil -> + nil + + vis when is_binary(vis) -> + normalized = String.upcase(String.trim(vis)) + if normalized in ["DRAFT", "PUBLIC", "PRIVATE"], do: normalized, else: nil + + _ -> + nil + end + + # Validators (optional, but if provided must be valid) + validators = + case Map.get(params, "validators") do + nil -> + nil + + validators when is_list(validators) and validators != [] -> + parsed = + Enum.reduce(validators, {[], [], 0}, fn + %{"input" => input, "output" => output} = validator, {acc, errs, index} -> + cond do + not is_binary(input) or input == "" -> + {acc, + [%{field: "validators", index: index, message: "input is required"} | errs], + index + 1} + + not is_binary(output) or output == "" -> + {acc, + [%{field: "validators", index: index, message: "output is required"} | errs], + index + 1} + + true -> + validator_map = %{ + input: input, + output: output, + is_public: Map.get(validator, "isPublic", false) + } + + {[validator_map | acc], errs, index + 1} + end + + _validator, {acc, errs, index} -> + {acc, + [ + %{ + field: "validators", + index: index, + message: "must be objects with input/output" + } + | errs + ], index + 1} + end) + + case parsed do + {acc, [], _} -> Enum.reverse(acc) + {_acc, errs, _} -> {:error, errs} + end + + [] -> + [] + + _ -> + nil + end + + # Check if validators parsing had errors + errors = + case validators do + {:error, validator_errors} -> errors ++ validator_errors + _ -> errors + end + + validators = if is_list(validators), do: validators, else: nil + + # Tags (optional) + tags = + case Map.get(params, "tags") do + nil -> nil + tags_value -> normalize_tags(tags_value) + end + + # Constraints (optional) + constraints = + case Map.get(params, "constraints") do + nil -> nil + constraints_value -> normalize_optional_string(constraints_value) + end + + if errors == [] do + puzzle_attrs = + %{ + title: title, + statement: statement, + constraints: constraints, + difficulty: difficulty, + visibility: visibility, + tags: tags, + validators: validators + } + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Enum.into(%{}) + + {:ok, puzzle_attrs} + else + {:error, :invalid_payload, Enum.reverse(errors)} + end + end + + defp normalize_update_params(_), + do: {:error, :invalid_payload, [%{message: "Expected JSON body"}]} + + # Authorization helper - checks if user can access/modify puzzle + defp authorize_puzzle_access(%Puzzle{author_id: author_id}, user_id, _role) + when author_id == user_id do + :ok + end + + defp authorize_puzzle_access(_puzzle, _user_id, "ADMIN"), do: :ok + defp authorize_puzzle_access(_puzzle, _user_id, _role), do: {:error, :forbidden} + + defp normalize_tags(nil), do: [] + + defp normalize_tags(tags) when is_list(tags) do + tags + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp normalize_tags(_), do: [] + + defp normalize_optional_string(nil), do: nil + defp normalize_optional_string(value) when is_binary(value), do: String.trim(value) + defp normalize_optional_string(_), do: nil + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/submission_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/submission_controller.ex new file mode 100644 index 00000000..4fe8fbac --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/submission_controller.ex @@ -0,0 +1,312 @@ +defmodule CodincodApiWeb.SubmissionController do + @moduledoc """ + Handles submission creation and retrieval, mirroring the Fastify submission routes. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias Ecto.UUID + + alias CodincodApi.Accounts.User + alias CodincodApi.{Languages, Puzzles, Repo, Submissions} + alias CodincodApi.Puzzles.Puzzle + alias CodincodApi.Languages.ProgrammingLanguage + alias CodincodApi.Submissions.{Evaluator, Submission} + alias CodincodApiWeb.OpenAPI.Schemas + alias CodincodApiWeb.Serializers.{Helpers, SubmissionSerializer} + + action_fallback CodincodApiWeb.FallbackController + + tags(["Submission"]) + + operation(:create, + summary: "Submit code for evaluation", + request_body: + {"Submission payload", "application/json", Schemas.Submission.SubmitCodeRequest}, + responses: %{ + 201 => {"Submission created", "application/json", Schemas.Submission.SubmitCodeResponse}, + 400 => {"Invalid payload", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Puzzle not found", "application/json", Schemas.Common.ErrorResponse}, + 422 => {"Validation error", "application/json", Schemas.Common.ErrorResponse}, + 503 => {"Execution unavailable", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def create(conn, params) do + with %User{id: user_id} = user <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, attrs} <- normalize_submit_params(params, user_id), + {:ok, puzzle} <- ensure_puzzle(attrs.puzzle_id), + {:ok, language} <- ensure_language(attrs.programming_language_id), + {:ok, evaluation} <- Evaluator.evaluate(attrs.code, puzzle, language), + {:ok, submission} <- + persist_submission(attrs, user, puzzle, language, evaluation.summary) do + response = build_submit_response(submission, evaluation.summary) + + conn + |> put_status(:created) + |> json(response) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Not authenticated"}) + + {:error, :user_mismatch} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Authenticated user does not match submission payload"}) + + {:error, :invalid_payload, errors} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid submission payload", errors: errors}) + + {:error, {:puzzle, :not_found}} -> + conn + |> put_status(:not_found) + |> json(%{error: "Puzzle not found"}) + + {:error, {:puzzle, :no_validators}} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Failed to update the puzzle"}) + + {:error, {:language, :not_found}} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid programming language"}) + + {:error, {:invalid_field, field, message}} -> + conn + |> put_status(:bad_request) + |> json(%{ + message: "Invalid submission payload", + errors: [%{field: field, message: message}] + }) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Failed to create submission", errors: translate_errors(changeset)}) + + {:error, reason} -> + handle_execution_error(conn, reason) + end + end + + operation(:show, + summary: "Fetch submission by id", + parameters: [ + id: [ + in: :path, + description: "Submission identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + responses: %{ + 200 => {"Submission", "application/json", Schemas.Submission.SubmissionResponse}, + 400 => {"Invalid id", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def show(conn, %{"id" => id}) do + with %User{} <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, submission_id} <- cast_uuid(id, "id"), + {:ok, submission} <- + Submissions.fetch_submission(submission_id, + preload: [:programming_language, :puzzle, :user] + ) do + conn + |> put_status(:ok) + |> json(SubmissionSerializer.render(submission)) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Not authenticated"}) + + {:error, {:invalid_field, field, message}} -> + conn + |> put_status(:bad_request) + |> json(%{ + message: "Invalid submission identifier", + errors: [%{field: field, message: message}] + }) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Submission not found"}) + end + end + + defp normalize_submit_params(params, current_user_id) when is_map(params) do + with {:ok, _user_id} <- ensure_user_matches(Map.get(params, "userId"), current_user_id) do + {code, errors} = validate_code(Map.get(params, "code")) + {puzzle_id, errors} = validate_uuid(Map.get(params, "puzzleId"), "puzzleId", errors) + + {language_id, errors} = + validate_uuid(Map.get(params, "programmingLanguageId"), "programmingLanguageId", errors) + + if errors == [] do + {:ok, + %{ + code: code, + puzzle_id: puzzle_id, + programming_language_id: language_id + }} + else + {:error, :invalid_payload, errors} + end + end + end + + defp normalize_submit_params(_params, _current_user_id), do: {:error, :invalid_payload, []} + + defp ensure_user_matches(nil, _current_user_id), + do: {:error, {:invalid_field, "userId", "is required"}} + + defp ensure_user_matches(user_id, current_user_id) when is_binary(user_id) do + with {:ok, uuid} <- cast_uuid(user_id, "userId") do + if uuid == current_user_id do + {:ok, uuid} + else + {:error, :user_mismatch} + end + end + end + + defp ensure_user_matches(_user_id, _current_user_id), + do: {:error, {:invalid_field, "userId", "must be a valid UUID"}} + + defp ensure_puzzle(puzzle_id) do + case Puzzles.fetch_puzzle_with_validators(puzzle_id) do + {:ok, %Puzzle{} = puzzle} -> + puzzle = Repo.preload(puzzle, :validators) + validators = Map.get(puzzle, :validators, []) + + if Enum.empty?(validators) do + {:error, {:puzzle, :no_validators}} + else + {:ok, puzzle} + end + + {:error, :not_found} -> + {:error, {:puzzle, :not_found}} + end + end + + defp ensure_language(language_id) do + case Languages.fetch_language(language_id) do + {:ok, %ProgrammingLanguage{} = language} -> {:ok, language} + {:error, :not_found} -> {:error, {:language, :not_found}} + end + end + + defp persist_submission( + attrs, + %User{id: user_id}, + %Puzzle{id: puzzle_id}, + %ProgrammingLanguage{id: language_id}, + summary + ) do + result_payload = build_result_payload(summary) + + attrs = %{ + code: attrs.code, + puzzle_id: puzzle_id, + user_id: user_id, + programming_language_id: language_id, + result: result_payload + } + + case Submissions.create_submission(attrs) do + {:ok, %Submission{} = submission} -> {:ok, submission} + {:error, %Ecto.Changeset{} = changeset} -> {:error, changeset} + end + end + + defp validate_code(code) when is_binary(code) do + if String.trim(code) == "" do + {code, [%{field: "code", message: "must not be empty"}]} + else + {code, []} + end + end + + defp validate_code(_code), do: {nil, [%{field: "code", message: "must not be empty"}]} + + defp validate_uuid(value, field, errors) when is_binary(value) do + case UUID.cast(value) do + {:ok, uuid} -> {uuid, errors} + :error -> {nil, [%{field: field, message: "must be a valid UUID"} | errors]} + end + end + + defp validate_uuid(_value, field, errors), + do: {nil, [%{field: field, message: "must be a valid UUID"} | errors]} + + defp build_submit_response(%Submission{} = submission, summary) do + code = submission.code || "" + + %{ + submissionId: submission.id, + code: submission.code, + puzzleId: submission.puzzle_id, + programmingLanguageId: submission.programming_language_id, + userId: submission.user_id, + codeLength: String.length(code), + result: %{ + successRate: summary.success_rate, + passed: summary.passed, + failed: summary.failed, + total: summary.total + }, + createdAt: Helpers.format_datetime(submission.inserted_at) + } + end + + defp build_result_payload(summary) do + %{ + "result" => summary.result, + "successRate" => summary.success_rate, + "passed" => summary.passed, + "failed" => summary.failed, + "total" => summary.total + } + end + + defp handle_execution_error(conn, {:unexpected_status, status, _body}) do + conn + |> put_status(:bad_gateway) + |> json(%{error: "Execution service error", status: status}) + end + + defp handle_execution_error(conn, reason) do + conn + |> put_status(:service_unavailable) + |> json(%{error: "Execution service unavailable", reason: inspect(reason)}) + end + + defp cast_uuid(value, field) when is_binary(value) do + case UUID.cast(value) do + {:ok, uuid} -> {:ok, uuid} + :error -> {:error, {:invalid_field, field, "must be a valid UUID"}} + end + end + + defp cast_uuid(_value, field), do: {:error, {:invalid_field, field, "must be a valid UUID"}} + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/user_controller.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/user_controller.ex new file mode 100644 index 00000000..8c56e51c --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/controllers/user_controller.ex @@ -0,0 +1,230 @@ +defmodule CodincodApiWeb.UserController do + @moduledoc """ + User endpoints that expose profile data, availability checks and author-specific + resources. The responses are compatible with the legacy Fastify backend. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + require Logger + + alias CodincodApi.Accounts + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles + alias CodincodApi.Submissions + alias CodincodApiWeb.OpenAPI.Schemas + alias CodincodApiWeb.Serializers.{PuzzleSerializer, SubmissionSerializer, UserSerializer} + + action_fallback CodincodApiWeb.FallbackController + + tags(["User"]) + + operation(:show, + summary: "Get user by username", + parameters: [ + username: [ + in: :path, + description: "Username to look up", + schema: %OpenApiSpex.Schema{type: :string} + ] + ], + responses: %{ + 200 => {"User", "application/json", Schemas.User.ShowResponse}, + 400 => {"Invalid username", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + @spec show(Plug.Conn.t(), map()) :: Plug.Conn.t() + def show(conn, %{"username" => username_param}) do + with {:ok, username} <- normalize_username(username_param), + %User{} = user <- Accounts.get_user_by_username(username) do + json(conn, %{message: "User found", user: UserSerializer.render(user)}) + else + {:error, :invalid_username, error} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid username", error: error}) + + nil -> + conn + |> put_status(:not_found) + |> json(%{message: "User not found"}) + end + end + + operation(:activity, + summary: "Get user activity (puzzles and submissions)", + parameters: [ + username: [ + in: :path, + description: "Username to inspect", + schema: %OpenApiSpex.Schema{type: :string} + ] + ], + responses: %{ + 200 => {"Activity", "application/json", Schemas.User.ActivityResponse}, + 400 => {"Invalid username", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Not found", "application/json", Schemas.Common.ErrorResponse}, + 500 => {"Server error", "application/json", Schemas.Common.ErrorResponse} + } + ) + + @spec activity(Plug.Conn.t(), map()) :: Plug.Conn.t() + def activity(conn, %{"username" => username_param}) do + with {:ok, username} <- normalize_username(username_param), + %User{} = user <- Accounts.get_user_by_username(username) do + viewer_id = current_user_id(conn) + + try do + puzzles = + if viewer_id == user.id do + Puzzles.list_author_all(user.id) + else + Puzzles.list_author_public(user.id) + end + + submissions = Submissions.list_by_user(user.id) + + json(conn, %{ + message: "User activity found", + user: UserSerializer.render(user), + activity: %{ + puzzles: PuzzleSerializer.render_many(puzzles), + submissions: SubmissionSerializer.render_many(submissions) + } + }) + rescue + error -> + Logger.error("Failed to fetch user activity: #{inspect(error)}") + + conn + |> put_status(:internal_server_error) + |> json(%{message: "Internal Server Error"}) + end + else + {:error, :invalid_username, error} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid username", error: error}) + + nil -> + conn + |> put_status(:not_found) + |> json(%{message: "User not found"}) + end + end + + operation(:puzzles, + summary: "List puzzles authored by a user", + parameters: [ + username: [ + in: :path, + description: "Username whose puzzles will be listed", + schema: %OpenApiSpex.Schema{type: :string} + ], + page: [ + in: :query, + schema: %OpenApiSpex.Schema{type: :integer, minimum: 1, default: 1} + ], + pageSize: [ + in: :query, + schema: %OpenApiSpex.Schema{type: :integer, minimum: 1, maximum: 100, default: 20} + ] + ], + responses: %{ + 200 => {"Paginated puzzles", "application/json", Schemas.Puzzle.PaginatedListResponse}, + 400 => {"Invalid parameters", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + @spec puzzles(Plug.Conn.t(), map()) :: Plug.Conn.t() + def puzzles(conn, params = %{"username" => username_param}) do + with {:ok, username} <- normalize_username(username_param), + %User{} = user <- Accounts.get_user_by_username(username) do + viewer_id = current_user_id(conn) + pagination = Puzzles.paginate_for_author(user.id, params, viewer_id: viewer_id) + + response = %{ + items: PuzzleSerializer.render_many(pagination.items), + page: pagination.page, + pageSize: pagination.page_size, + totalItems: pagination.total_items, + totalPages: pagination.total_pages + } + + json(conn, response) + else + {:error, :invalid_username, error} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid username", error: error}) + + nil -> + conn + |> put_status(:not_found) + |> json(%{message: "User not found"}) + end + end + + operation(:availability, + summary: "Check username availability", + parameters: [ + username: [ + in: :path, + description: "Desired username", + schema: %OpenApiSpex.Schema{type: :string} + ] + ], + responses: %{ + 200 => {"Availability", "application/json", Schemas.User.AvailabilityResponse}, + 400 => {"Invalid username", "application/json", Schemas.Common.ErrorResponse} + } + ) + + @spec availability(Plug.Conn.t(), map()) :: Plug.Conn.t() + def availability(conn, %{"username" => username_param}) do + with {:ok, username} <- normalize_username(username_param) do + json(conn, %{available: Accounts.username_available?(username)}) + else + {:error, :invalid_username, error} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid username", error: error}) + end + end + + defp normalize_username(username) when is_binary(username) do + trimmed = String.trim(username) + regex = User.username_regex() + min_len = User.username_min_length() + max_len = User.username_max_length() + + cond do + trimmed == "" -> + {:error, :invalid_username, %{field: "username", message: "is required"}} + + String.length(trimmed) < min_len or String.length(trimmed) > max_len -> + {:error, :invalid_username, + %{field: "username", message: "must be between #{min_len} and #{max_len} characters"}} + + not Regex.match?(regex, trimmed) -> + {:error, :invalid_username, %{field: "username", message: "contains invalid characters"}} + + true -> + {:ok, trimmed} + end + end + + defp normalize_username(_), + do: {:error, :invalid_username, %{field: "username", message: "must be a string"}} + + defp current_user_id(conn) do + case conn.assigns[:current_user] do + %User{id: id} -> id + _ -> nil + end + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/endpoint.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/endpoint.ex new file mode 100644 index 00000000..56c6c83a --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/endpoint.ex @@ -0,0 +1,68 @@ +defmodule CodincodApiWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :codincod_api + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_codincod_api_key", + signing_salt: "lOUmvnf8", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + # WebSocket endpoint for real-time features (games, notifications, etc.) + socket "/socket", CodincodApiWeb.UserSocket, + websocket: true, + longpoll: false + + # Serve at "/" the static files from "priv/static" directory. + # + # When code reloading is disabled (e.g., in production), + # the `gzip` option is enabled to serve compressed + # static files generated by running `phx.digest`. + plug Plug.Static, + at: "/", + from: :codincod_api, + gzip: not code_reloading?, + only: CodincodApiWeb.static_paths() + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :codincod_api + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug CORSPlug, + origin: [ + ~r/^https?:\/\/localhost:5173$/, + ~r/^https?:\/\/localhost:3000$/, # Common React dev port + ~r/^https?:\/\/(www\.)?codincod\.com$/, + ], + credentials: true, # CRITICAL for cookies! + max_age: 86400, + headers: ["Authorization", "Content-Type", "Accept", "Origin"], + expose: ["Set-Cookie"] # Explicitly expose Set-Cookie header + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug CodincodApiWeb.Router +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/gettext.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/gettext.ex new file mode 100644 index 00000000..072e3ff7 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/gettext.ex @@ -0,0 +1,25 @@ +defmodule CodincodApiWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations + that you can use in your application. To use this Gettext backend module, + call `use Gettext` and pass it as an option: + + use Gettext, backend: CodincodApiWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext.Backend, otp_app: :codincod_api +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/open_api.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/open_api.ex new file mode 100644 index 00000000..eb84e804 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/open_api.ex @@ -0,0 +1,29 @@ +defmodule CodincodApiWeb.OpenAPI do + @moduledoc """ + OpenAPI specification entry point for the CodinCod Phoenix backend. + """ + + alias OpenApiSpex.{Components, Info, OpenApi, Paths, Server} + + @spec spec() :: OpenApi.t() + def spec do + %OpenApi{ + info: %Info{ + title: "CodinCod API", + version: "0.1.0", + description: "Phoenix implementation of the CodinCod backend" + }, + servers: [Server.from_endpoint(CodincodApiWeb.Endpoint)], + paths: Paths.from_router(CodincodApiWeb.Router), + components: components() + } + # Discover request/response schemas from path specs and resolve module references to $ref + |> OpenApiSpex.resolve_schema_modules() + end + + defp components do + %Components{ + schemas: CodincodApiWeb.OpenAPI.Schemas.registry() + } + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas.ex new file mode 100644 index 00000000..4edb7b23 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas.ex @@ -0,0 +1,74 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas do + @moduledoc """ + Registry of OpenAPI schemas shared across the API specification. + """ + + def registry do + %{ + LoginRequest: CodincodApiWeb.OpenAPI.Schemas.Auth.LoginRequest.schema(), + RegisterRequest: CodincodApiWeb.OpenAPI.Schemas.Auth.RegisterRequest.schema(), + AuthMessageResponse: CodincodApiWeb.OpenAPI.Schemas.Auth.MessageResponse.schema(), + ErrorResponse: CodincodApiWeb.OpenAPI.Schemas.Common.ErrorResponse.schema(), + AccountStatusResponse: CodincodApiWeb.OpenAPI.Schemas.Account.StatusResponse.schema(), + AccountProfileUpdateRequest: + CodincodApiWeb.OpenAPI.Schemas.Account.ProfileUpdateRequest.schema(), + AccountProfileUpdateResponse: + CodincodApiWeb.OpenAPI.Schemas.Account.ProfileUpdateResponse.schema(), + AccountPreferences: CodincodApiWeb.OpenAPI.Schemas.Account.PreferencesPayload.schema(), + PuzzlePaginatedListResponse: + CodincodApiWeb.OpenAPI.Schemas.Puzzle.PaginatedListResponse.schema(), + PuzzleCreateRequest: CodincodApiWeb.OpenAPI.Schemas.Puzzle.PuzzleCreateRequest.schema(), + PuzzleResponse: CodincodApiWeb.OpenAPI.Schemas.Puzzle.PuzzleResponse.schema(), + UserSummary: CodincodApiWeb.OpenAPI.Schemas.User.Summary.schema(), + UserShowResponse: CodincodApiWeb.OpenAPI.Schemas.User.ShowResponse.schema(), + UserAvailabilityResponse: CodincodApiWeb.OpenAPI.Schemas.User.AvailabilityResponse.schema(), + UserActivityResponse: CodincodApiWeb.OpenAPI.Schemas.User.ActivityResponse.schema(), + SubmissionResponse: CodincodApiWeb.OpenAPI.Schemas.Submission.SubmissionResponse.schema(), + SubmissionSubmitRequest: + CodincodApiWeb.OpenAPI.Schemas.Submission.SubmitCodeRequest.schema(), + SubmissionSubmitResponse: + CodincodApiWeb.OpenAPI.Schemas.Submission.SubmitCodeResponse.schema(), + ExecuteRequest: CodincodApiWeb.OpenAPI.Schemas.Execute.ExecuteRequest.schema(), + ExecuteResponse: CodincodApiWeb.OpenAPI.Schemas.Execute.ExecuteResponse.schema(), + PasswordResetRequest: CodincodApiWeb.OpenAPI.Schemas.PasswordReset.RequestPayload.schema(), + PasswordResetResponse: + CodincodApiWeb.OpenAPI.Schemas.PasswordReset.RequestResponse.schema(), + PasswordResetPayload: CodincodApiWeb.OpenAPI.Schemas.PasswordReset.ResetPayload.schema(), + PasswordResetCompleteResponse: + CodincodApiWeb.OpenAPI.Schemas.PasswordReset.ResetResponse.schema(), + # Leaderboard schemas + GlobalLeaderboardResponse: + CodincodApiWeb.OpenAPI.Schemas.Leaderboard.GlobalLeaderboardResponse.schema(), + PuzzleLeaderboardResponse: + CodincodApiWeb.OpenAPI.Schemas.Leaderboard.PuzzleLeaderboardResponse.schema(), + UserRankResponse: CodincodApiWeb.OpenAPI.Schemas.Leaderboard.UserRankResponse.schema(), + # Metrics schemas + PlatformMetricsResponse: + CodincodApiWeb.OpenAPI.Schemas.Metrics.PlatformMetricsResponse.schema(), + UserStatsResponse: CodincodApiWeb.OpenAPI.Schemas.Metrics.UserStatsResponse.schema(), + PuzzleStatsResponse: CodincodApiWeb.OpenAPI.Schemas.Metrics.PuzzleStatsResponse.schema(), + # Moderation schemas + CreateReportRequest: CodincodApiWeb.OpenAPI.Schemas.Moderation.CreateReportRequest.schema(), + ReportResponse: CodincodApiWeb.OpenAPI.Schemas.Moderation.ReportResponse.schema(), + ReportsListResponse: CodincodApiWeb.OpenAPI.Schemas.Moderation.ReportsListResponse.schema(), + ResolveReportRequest: + CodincodApiWeb.OpenAPI.Schemas.Moderation.ResolveReportRequest.schema(), + ReviewResponse: CodincodApiWeb.OpenAPI.Schemas.Moderation.ReviewResponse.schema(), + ReviewsListResponse: CodincodApiWeb.OpenAPI.Schemas.Moderation.ReviewsListResponse.schema(), + ReviewDecisionRequest: + CodincodApiWeb.OpenAPI.Schemas.Moderation.ReviewDecisionRequest.schema(), + BanUserRequest: CodincodApiWeb.OpenAPI.Schemas.Moderation.BanUserRequest.schema(), + BanResponse: CodincodApiWeb.OpenAPI.Schemas.Moderation.BanResponse.schema(), + # Games schemas + CreateGameRequest: CodincodApiWeb.OpenAPI.Schemas.Games.CreateGameRequest.schema(), + GameResponse: CodincodApiWeb.OpenAPI.Schemas.Games.GameResponse.schema(), + WaitingRoomsResponse: CodincodApiWeb.OpenAPI.Schemas.Games.WaitingRoomsResponse.schema(), + UserGamesResponse: CodincodApiWeb.OpenAPI.Schemas.Games.UserGamesResponse.schema(), + LeaveGameResponse: CodincodApiWeb.OpenAPI.Schemas.Games.LeaveGameResponse.schema(), + # Comment schemas + CommentCreateRequest: CodincodApiWeb.OpenAPI.Schemas.Comment.CreateRequest.schema(), + CommentResponse: CodincodApiWeb.OpenAPI.Schemas.Comment.CommentResponse.schema(), + CommentVoteRequest: CodincodApiWeb.OpenAPI.Schemas.Comment.VoteRequest.schema() + } + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/account.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/account.ex new file mode 100644 index 00000000..0f9177ab --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/account.ex @@ -0,0 +1,74 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Account do + @moduledoc """ + Account related schema definitions. + """ + + require OpenApiSpex + alias CodincodApiWeb.OpenAPI.Schemas.User + + defmodule StatusResponse do + @moduledoc false + OpenApiSpex.schema(%{ + title: "AccountStatusResponse", + type: :object, + required: [:isAuthenticated], + properties: %{ + isAuthenticated: %OpenApiSpex.Schema{type: :boolean}, + userId: %OpenApiSpex.Schema{type: :string, format: :uuid}, + username: %OpenApiSpex.Schema{type: :string}, + role: %OpenApiSpex.Schema{type: :string} + } + }) + end + + defmodule ProfileUpdateRequest do + @moduledoc false + OpenApiSpex.schema(%{ + title: "ProfileUpdateRequest", + type: :object, + properties: %{ + bio: %OpenApiSpex.Schema{type: :string, maxLength: 500}, + location: %OpenApiSpex.Schema{type: :string, maxLength: 100}, + picture: %OpenApiSpex.Schema{type: :string, format: :uri}, + socials: %OpenApiSpex.Schema{ + type: :array, + items: %OpenApiSpex.Schema{type: :string, format: :uri}, + maxItems: 5 + } + } + }) + end + + defmodule PreferencesPayload do + @moduledoc false + OpenApiSpex.schema(%{ + title: "PreferencesPayload", + type: :object, + properties: %{ + preferredLanguage: %OpenApiSpex.Schema{type: :string, nullable: true}, + theme: %OpenApiSpex.Schema{ + type: :string, + enum: CodincodApi.Accounts.Preference.theme_options(), + nullable: true + }, + blockedUsers: %OpenApiSpex.Schema{ + type: :array, + items: %OpenApiSpex.Schema{type: :string, format: :uuid} + }, + editor: %OpenApiSpex.Schema{type: :object} + } + }) + end + + defmodule ProfileUpdateResponse do + @moduledoc false + OpenApiSpex.schema(%{ + title: "ProfileUpdateResponse", + type: :object, + properties: %{ + message: %OpenApiSpex.Schema{type: :string}, + profile: User.Profile.schema() + } + }) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/auth.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/auth.ex new file mode 100644 index 00000000..3a118432 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/auth.ex @@ -0,0 +1,46 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Auth do + @moduledoc """ + Auth related OpenAPI schemas. + """ + + require OpenApiSpex + + defmodule LoginRequest do + @moduledoc false + OpenApiSpex.schema(%{ + title: "LoginRequest", + type: :object, + required: [:identifier, :password], + properties: %{ + identifier: %OpenApiSpex.Schema{type: :string, description: "Username or email"}, + password: %OpenApiSpex.Schema{type: :string, format: :password} + } + }) + end + + defmodule RegisterRequest do + @moduledoc false + OpenApiSpex.schema(%{ + title: "RegisterRequest", + type: :object, + required: [:username, :email, :password], + properties: %{ + username: %OpenApiSpex.Schema{type: :string, minLength: 3, maxLength: 20}, + email: %OpenApiSpex.Schema{type: :string, format: :email}, + password: %OpenApiSpex.Schema{type: :string, format: :password, minLength: 14}, + passwordConfirmation: %OpenApiSpex.Schema{type: :string, format: :password} + } + }) + end + + defmodule MessageResponse do + @moduledoc false + OpenApiSpex.schema(%{ + title: "MessageResponse", + type: :object, + properties: %{ + message: %OpenApiSpex.Schema{type: :string} + } + }) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/comment.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/comment.ex new file mode 100644 index 00000000..ccdaebf5 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/comment.ex @@ -0,0 +1,66 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Comment do + @moduledoc """ + Comment schemas used across OpenAPI responses and requests. + """ + + require OpenApiSpex + + defmodule Author do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + username: %OpenApiSpex.Schema{type: :string}, + role: %OpenApiSpex.Schema{type: :string} + } + }) + end + + defmodule CommentResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + body: %OpenApiSpex.Schema{type: :string}, + commentType: %OpenApiSpex.Schema{ + type: :string, + enum: ["puzzle-comment", "comment-comment", "submission-comment"] + }, + upvote: %OpenApiSpex.Schema{type: :integer, default: 0}, + downvote: %OpenApiSpex.Schema{type: :integer, default: 0}, + authorId: %OpenApiSpex.Schema{type: :string, format: :uuid}, + puzzleId: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + parentCommentId: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + insertedAt: %OpenApiSpex.Schema{type: :string, format: :"date-time"}, + updatedAt: %OpenApiSpex.Schema{type: :string, format: :"date-time"}, + author: Author.schema() + }, + required: [:id, :body, :commentType, :authorId] + }) + end + + defmodule CreateRequest do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + required: [:text], + properties: %{ + text: %OpenApiSpex.Schema{type: :string, minLength: 1, maxLength: 320}, + replyOn: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true} + } + }) + end + + defmodule VoteRequest do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + required: [:type], + properties: %{ + type: %OpenApiSpex.Schema{type: :string, enum: ["upvote", "downvote"]} + } + }) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/common.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/common.ex new file mode 100644 index 00000000..9937ca78 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/common.ex @@ -0,0 +1,20 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Common do + @moduledoc """ + Shared schema utilities. + """ + + require OpenApiSpex + + defmodule ErrorResponse do + @moduledoc false + OpenApiSpex.schema(%{ + title: "ErrorResponse", + type: :object, + properties: %{ + message: %OpenApiSpex.Schema{type: :string}, + errors: %OpenApiSpex.Schema{type: :object}, + error: %OpenApiSpex.Schema{type: :string} + } + }) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/execute.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/execute.ex new file mode 100644 index 00000000..f6d2b296 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/execute.ex @@ -0,0 +1,54 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Execute do + @moduledoc """ + Execute API schemas for code execution without persistence. + """ + + require OpenApiSpex + + defmodule ExecuteRequest do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + required: ["code", "language"], + properties: %{ + code: %OpenApiSpex.Schema{type: :string, minLength: 1}, + language: %OpenApiSpex.Schema{type: :string, minLength: 1}, + testInput: %OpenApiSpex.Schema{type: :string, default: ""}, + testOutput: %OpenApiSpex.Schema{type: :string, default: ""} + } + }) + end + + defmodule PuzzleResultInformation do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + result: %OpenApiSpex.Schema{type: :string, enum: ["SUCCESS", "ERROR"]}, + successRate: %OpenApiSpex.Schema{type: :number, minimum: 0, maximum: 1}, + passed: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + failed: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + total: %OpenApiSpex.Schema{type: :integer, minimum: 1} + } + }) + end + + defmodule ExecuteResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + run: %OpenApiSpex.Schema{ + type: :object, + additionalProperties: true + }, + compile: %OpenApiSpex.Schema{ + type: :object, + nullable: true, + additionalProperties: true + }, + puzzleResultInformation: PuzzleResultInformation.schema() + } + }) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/games.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/games.ex new file mode 100644 index 00000000..c18202d9 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/games.ex @@ -0,0 +1,156 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Games do + @moduledoc """ + OpenAPI schemas for game/multiplayer endpoints. + """ + + alias OpenApiSpex.{Schema, Reference} + + defmodule CreateGameRequest do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "CreateGameRequest", + type: :object, + properties: %{ + puzzleId: %Schema{type: :string, format: :uuid}, + maxPlayers: %Schema{type: :integer, minimum: 2, maximum: 10, default: 2}, + gameMode: %Schema{ + type: :string, + enum: ["standard", "timed", "ranked"], + default: "standard" + }, + timeLimit: %Schema{type: :integer, nullable: true} + }, + required: [:puzzleId] + }) + end + + defmodule GameResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "GameResponse", + type: :object, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + status: %Schema{type: :string}, + gameMode: %Schema{type: :string}, + maxPlayers: %Schema{type: :integer}, + timeLimit: %Schema{type: :integer, nullable: true}, + owner: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string} + } + }, + puzzle: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + title: %Schema{type: :string}, + difficulty: %Schema{type: :string} + } + }, + players: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string}, + role: %Schema{type: :string}, + joinedAt: %Schema{type: :string, format: :"date-time"} + } + } + }, + createdAt: %Schema{type: :string, format: :"date-time"}, + startedAt: %Schema{type: :string, format: :"date-time", nullable: true}, + finishedAt: %Schema{type: :string, format: :"date-time", nullable: true} + } + }) + end + + defmodule WaitingRoomsResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "WaitingRoomsResponse", + type: :object, + properties: %{ + rooms: %Schema{ + type: :array, + items: GameResponse.schema() + }, + count: %Schema{type: :integer} + } + }) + end + + defmodule UserGamesResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "UserGamesResponse", + type: :object, + properties: %{ + games: %Schema{ + type: :array, + items: GameResponse.schema() + }, + count: %Schema{type: :integer} + } + }) + end + + defmodule LeaveGameResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "LeaveGameResponse", + type: :object, + properties: %{ + message: %Schema{type: :string} + } + }) + end + + defmodule GameSubmitCodeRequest do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "GameSubmitCodeRequest", + description: "Request to link a submission to a game. This is the correct type for game submissions (not to be confused with SubmitCodeRequest for direct code submission)", + type: :object, + properties: %{ + submissionId: %Schema{ + type: :string, + format: :uuid, + description: "The ID of the submission to link to the game" + } + }, + required: [:submissionId] + }) + end + + defmodule SubmitCodeResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "SubmitCodeResponse", + type: :object, + properties: %{ + message: %Schema{type: :string}, + submissionId: %Schema{type: :string, format: :uuid}, + gameId: %Schema{type: :string, format: :uuid} + } + }) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/leaderboard.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/leaderboard.ex new file mode 100644 index 00000000..a2c46a1e --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/leaderboard.ex @@ -0,0 +1,95 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Leaderboard do + @moduledoc """ + OpenAPI schemas for leaderboard endpoints. + """ + + alias OpenApiSpex.Schema + + defmodule GlobalLeaderboardResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + gameMode: %Schema{type: :string}, + rankings: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + rank: %Schema{type: :integer}, + userId: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string}, + rating: %Schema{type: :integer}, + puzzlesSolved: %Schema{type: :integer}, + totalSubmissions: %Schema{type: :integer}, + # Glicko rating system properties + glicko: %Schema{ + type: :object, + properties: %{ + rd: %Schema{type: :number, description: "Rating deviation"}, + vol: %Schema{type: :number, description: "Volatility"} + } + }, + # Game statistics + gamesPlayed: %Schema{type: :integer}, + gamesWon: %Schema{type: :integer}, + winRate: %Schema{type: :number, format: :float}, + bestScore: %Schema{type: :number}, + averageScore: %Schema{type: :number} + } + } + }, + limit: %Schema{type: :integer}, + offset: %Schema{type: :integer}, + totalPages: %Schema{type: :integer}, + totalEntries: %Schema{type: :integer}, + cachedAt: %Schema{type: :string, format: :"date-time", nullable: true} + } + }) + end + + defmodule PuzzleLeaderboardResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + puzzleId: %Schema{type: :string, format: :uuid}, + rankings: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + rank: %Schema{type: :integer}, + userId: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string}, + executionTime: %Schema{type: :integer}, + memoryUsed: %Schema{type: :integer}, + submittedAt: %Schema{type: :string, format: :"date-time"} + } + } + }, + limit: %Schema{type: :integer} + } + }) + end + + defmodule UserRankResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + userId: %Schema{type: :string, format: :uuid}, + rank: %Schema{type: :integer, nullable: true}, + rating: %Schema{type: :integer, nullable: true}, + puzzlesSolved: %Schema{type: :integer, nullable: true}, + totalSubmissions: %Schema{type: :integer, nullable: true} + } + }) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/metrics.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/metrics.ex new file mode 100644 index 00000000..1291e5d2 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/metrics.ex @@ -0,0 +1,112 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Metrics do + @moduledoc """ + OpenAPI schemas for metrics endpoints. + """ + + alias OpenApiSpex.Schema + + defmodule PlatformMetricsResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + totalUsers: %Schema{type: :integer}, + totalPuzzles: %Schema{type: :integer}, + totalSubmissions: %Schema{type: :integer}, + acceptedSubmissions: %Schema{type: :integer}, + activeUsers: %Schema{type: :integer}, + popularPuzzles: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + puzzleId: %Schema{type: :string, format: :uuid}, + title: %Schema{type: :string}, + difficulty: %Schema{type: :string}, + submissionCount: %Schema{type: :integer} + } + } + } + } + }) + end + + defmodule UserStatsResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + userId: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string}, + totalSubmissions: %Schema{type: :integer}, + acceptedSubmissions: %Schema{type: :integer}, + wrongAnswerSubmissions: %Schema{type: :integer}, + timeLimitExceeded: %Schema{type: :integer}, + runtimeErrors: %Schema{type: :integer}, + puzzlesSolved: %Schema{type: :integer}, + acceptanceRate: %Schema{type: :number}, + difficultyBreakdown: %Schema{ + type: :object, + properties: %{ + easy: %Schema{type: :integer}, + medium: %Schema{type: :integer}, + hard: %Schema{type: :integer}, + expert: %Schema{type: :integer} + } + }, + languageUsage: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + language: %Schema{type: :string}, + count: %Schema{type: :integer} + } + } + }, + recentActivity: %Schema{type: :integer} + } + }) + end + + defmodule PuzzleStatsResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + puzzleId: %Schema{type: :string, format: :uuid}, + title: %Schema{type: :string}, + totalSubmissions: %Schema{type: :integer}, + acceptedSubmissions: %Schema{type: :integer}, + uniqueSolvers: %Schema{type: :integer}, + acceptanceRate: %Schema{type: :number}, + averageExecutionTime: %Schema{type: :number, nullable: true}, + languageDistribution: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + language: %Schema{type: :string}, + count: %Schema{type: :integer} + } + } + }, + statusBreakdown: %Schema{ + type: :object, + properties: %{ + accepted: %Schema{type: :integer}, + wrongAnswer: %Schema{type: :integer}, + timeLimitExceeded: %Schema{type: :integer}, + runtimeError: %Schema{type: :integer} + } + } + } + }) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/moderation.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/moderation.ex new file mode 100644 index 00000000..3dea107f --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/moderation.ex @@ -0,0 +1,205 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Moderation do + @moduledoc """ + OpenAPI schemas for moderation endpoints. + """ + + alias OpenApiSpex.{Schema, Reference} + + defmodule CreateReportRequest do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "CreateReportRequest", + type: :object, + properties: %{ + contentType: %Schema{type: :string, enum: ["puzzle", "comment", "submission", "user"]}, + contentId: %Schema{type: :string, format: :uuid}, + problemType: %Schema{ + type: :string, + enum: ["spam", "inappropriate", "copyright", "harassment", "other"] + }, + description: %Schema{type: :string, nullable: true} + }, + required: [:contentType, :contentId, :problemType] + }) + end + + defmodule ReportResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ReportResponse", + type: :object, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + contentType: %Schema{type: :string}, + contentId: %Schema{type: :string, format: :uuid}, + problemType: %Schema{type: :string}, + description: %Schema{type: :string, nullable: true}, + status: %Schema{type: :string}, + reportedBy: %Schema{ + type: :object, + nullable: true, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string} + } + }, + resolvedBy: %Schema{ + type: :object, + nullable: true, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string} + } + }, + resolutionNotes: %Schema{type: :string, nullable: true}, + createdAt: %Schema{type: :string, format: :"date-time"}, + resolvedAt: %Schema{type: :string, format: :"date-time", nullable: true} + } + }) + end + + defmodule ReportsListResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ReportsListResponse", + type: :object, + properties: %{ + reports: %Schema{type: :array, items: %Reference{"$ref": "#/components/schemas/ReportResponse"}}, + count: %Schema{type: :integer} + } + }) + end + + defmodule ResolveReportRequest do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ResolveReportRequest", + type: :object, + properties: %{ + status: %Schema{type: :string, enum: ["resolved", "dismissed"]}, + resolutionNotes: %Schema{type: :string, nullable: true} + }, + required: [:status] + }) + end + + defmodule ReviewResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ReviewResponse", + type: :object, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + puzzleId: %Schema{type: :string, format: :uuid, nullable: true}, + status: %Schema{type: :string}, + # Fields for puzzle reviews + title: %Schema{type: :string, nullable: true}, + description: %Schema{type: :string, nullable: true}, + authorName: %Schema{type: :string, nullable: true}, + # Fields for report reviews + reportExplanation: %Schema{type: :string, nullable: true}, + reportedBy: %Schema{type: :string, nullable: true}, + reportedUserId: %Schema{type: :string, format: :uuid, nullable: true}, + reportedUserName: %Schema{type: :string, nullable: true}, + # Fields for game chat reports + gameId: %Schema{type: :string, format: :uuid, nullable: true}, + reportedMessageId: %Schema{type: :string, format: :uuid, nullable: true}, + contextMessages: %Schema{ + type: :array, + nullable: true, + items: %Schema{ + type: :object, + properties: %{ + _id: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string}, + message: %Schema{type: :string}, + timestamp: %Schema{type: :string, format: :"date-time"} + } + } + }, + # Review metadata + reviewer: %Schema{ + type: :object, + nullable: true, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string} + } + }, + reviewerNotes: %Schema{type: :string, nullable: true}, + createdAt: %Schema{type: :string, format: :"date-time"}, + reviewedAt: %Schema{type: :string, format: :"date-time", nullable: true} + } + }) + end + + defmodule ReviewsListResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ReviewsListResponse", + type: :object, + properties: %{ + reviews: %Schema{type: :array, items: %Reference{"$ref": "#/components/schemas/ReviewResponse"}}, + count: %Schema{type: :integer} + } + }) + end + + defmodule ReviewDecisionRequest do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ReviewDecisionRequest", + type: :object, + properties: %{ + status: %Schema{type: :string, enum: ["approved", "rejected"]}, + reviewerNotes: %Schema{type: :string, nullable: true} + }, + required: [:status] + }) + end + + defmodule BanUserRequest do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "BanUserRequest", + type: :object, + properties: %{ + durationDays: %Schema{type: :integer, nullable: true}, + bannedUntil: %Schema{type: :string, format: :"date-time", nullable: true}, + reason: %Schema{type: :string, nullable: true} + } + }) + end + + defmodule BanResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "BanResponse", + type: :object, + properties: %{ + userId: %Schema{type: :string, format: :uuid}, + banned: %Schema{type: :boolean}, + bannedUntil: %Schema{type: :string, format: :"date-time", nullable: true}, + reason: %Schema{type: :string, nullable: true} + } + }) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/password_reset.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/password_reset.ex new file mode 100644 index 00000000..e233b19f --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/password_reset.ex @@ -0,0 +1,50 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.PasswordReset do + @moduledoc """ + Password reset API schemas. + """ + + require OpenApiSpex + + defmodule RequestPayload do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + required: ["email"], + properties: %{ + email: %OpenApiSpex.Schema{type: :string, format: :email} + } + }) + end + + defmodule RequestResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + message: %OpenApiSpex.Schema{type: :string} + } + }) + end + + defmodule ResetPayload do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + required: ["token", "password"], + properties: %{ + token: %OpenApiSpex.Schema{type: :string}, + password: %OpenApiSpex.Schema{type: :string, minLength: 8} + } + }) + end + + defmodule ResetResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + message: %OpenApiSpex.Schema{type: :string} + } + }) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/puzzle.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/puzzle.ex new file mode 100644 index 00000000..6d41d8d6 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/puzzle.ex @@ -0,0 +1,128 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Puzzle do + @moduledoc """ + Puzzle schemas used across OpenAPI responses and requests. + """ + + require OpenApiSpex + + alias CodincodApiWeb.OpenAPI.Schemas.User + + defmodule Validator do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + input: %OpenApiSpex.Schema{type: :string, description: "Validator input payload"}, + output: %OpenApiSpex.Schema{type: :string, description: "Expected validator output"}, + isPublic: %OpenApiSpex.Schema{type: :boolean, default: false}, + createdAt: %OpenApiSpex.Schema{type: :string, format: :"date-time"}, + updatedAt: %OpenApiSpex.Schema{type: :string, format: :"date-time"} + } + }) + end + + defmodule Solution do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + code: %OpenApiSpex.Schema{type: :string, default: ""}, + programmingLanguage: %OpenApiSpex.Schema{type: :string, nullable: true} + } + }) + end + + defmodule Author do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + _id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + username: %OpenApiSpex.Schema{type: :string}, + profile: User.Profile.schema(), + role: %OpenApiSpex.Schema{type: :string}, + createdAt: %OpenApiSpex.Schema{type: :string, format: :"date-time"}, + updatedAt: %OpenApiSpex.Schema{type: :string, format: :"date-time"} + } + }) + end + + defmodule PuzzleResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + _id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + legacyId: %OpenApiSpex.Schema{type: :string, nullable: true}, + title: %OpenApiSpex.Schema{type: :string}, + statement: %OpenApiSpex.Schema{type: :string, nullable: true}, + constraints: %OpenApiSpex.Schema{type: :string, nullable: true}, + author: Author.schema(), + validators: %OpenApiSpex.Schema{type: :array, items: Validator.schema()}, + difficulty: %OpenApiSpex.Schema{type: :string}, + visibility: %OpenApiSpex.Schema{type: :string}, + createdAt: %OpenApiSpex.Schema{type: :string, format: :"date-time", nullable: true}, + updatedAt: %OpenApiSpex.Schema{type: :string, format: :"date-time", nullable: true}, + solution: Solution.schema(), + puzzleMetrics: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + legacyMetricsId: %OpenApiSpex.Schema{type: :string, nullable: true}, + tags: %OpenApiSpex.Schema{type: :array, items: %OpenApiSpex.Schema{type: :string}}, + comments: %OpenApiSpex.Schema{type: :array, items: %OpenApiSpex.Schema{type: :string}}, + moderationFeedback: %OpenApiSpex.Schema{type: :string, nullable: true} + } + }) + end + + defmodule PuzzleCreateRequest do + @moduledoc false + OpenApiSpex.schema(%{ + title: "PuzzleCreateRequest", + type: :object, + required: [:title], + properties: %{ + title: %OpenApiSpex.Schema{type: :string, minLength: 4, maxLength: 128}, + description: %OpenApiSpex.Schema{type: :string, minLength: 1, nullable: true}, + difficulty: %OpenApiSpex.Schema{ + type: :string, + enum: ["easy", "medium", "hard", "beginner", "intermediate", "advanced", "expert"], + nullable: true + }, + validators: %OpenApiSpex.Schema{ + type: :array, + items: %OpenApiSpex.Schema{ + type: :object, + required: [:input, :output], + properties: %{ + input: %OpenApiSpex.Schema{type: :string}, + output: %OpenApiSpex.Schema{type: :string}, + isPublic: %OpenApiSpex.Schema{type: :boolean} + } + }, + nullable: true + }, + tags: %OpenApiSpex.Schema{ + type: :array, + nullable: true, + items: %OpenApiSpex.Schema{type: :string} + }, + constraints: %OpenApiSpex.Schema{type: :string, nullable: true} + } + }) + end + + defmodule PaginatedListResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + page: %OpenApiSpex.Schema{type: :integer, minimum: 1, default: 1}, + pageSize: %OpenApiSpex.Schema{type: :integer, minimum: 1, maximum: 100, default: 20}, + totalItems: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + totalPages: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + items: %OpenApiSpex.Schema{type: :array, items: PuzzleResponse.schema()} + } + }) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/submission.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/submission.ex new file mode 100644 index 00000000..1bd6748a --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/submission.ex @@ -0,0 +1,115 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Submission do + @moduledoc """ + Submission schemas used within OpenAPI responses. + """ + + require OpenApiSpex + + alias CodincodApiWeb.OpenAPI.Schemas.User + + defmodule ProgrammingLanguageSummary do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + _id: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + id: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + language: %OpenApiSpex.Schema{type: :string, nullable: true}, + version: %OpenApiSpex.Schema{type: :string, nullable: true}, + runtime: %OpenApiSpex.Schema{type: :string, nullable: true} + } + }) + end + + defmodule PuzzleSummary do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + _id: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + id: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + title: %OpenApiSpex.Schema{type: :string, nullable: true} + } + }) + end + + defmodule SubmissionResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + _id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + legacyId: %OpenApiSpex.Schema{type: :string, nullable: true}, + code: %OpenApiSpex.Schema{type: :string, nullable: true}, + result: %OpenApiSpex.Schema{type: :object, additionalProperties: true}, + score: %OpenApiSpex.Schema{type: :number, nullable: true}, + createdAt: %OpenApiSpex.Schema{type: :string, format: "date-time", nullable: true}, + updatedAt: %OpenApiSpex.Schema{type: :string, format: "date-time", nullable: true}, + puzzle: PuzzleSummary.schema(), + programmingLanguage: ProgrammingLanguageSummary.schema(), + user: User.Summary.schema(), + gameId: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + legacyGameSubmissionId: %OpenApiSpex.Schema{type: :string, nullable: true} + } + }) + end + + defmodule SubmissionListResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :array, + items: SubmissionResponse.schema() + }) + end + + defmodule SubmitCodeRequest do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + required: ["puzzleId", "programmingLanguageId", "code", "userId"], + properties: %{ + puzzleId: %OpenApiSpex.Schema{type: :string, format: :uuid}, + programmingLanguageId: %OpenApiSpex.Schema{type: :string, format: :uuid}, + code: %OpenApiSpex.Schema{type: :string, minLength: 1}, + userId: %OpenApiSpex.Schema{type: :string, format: :uuid} + } + }) + end + + defmodule SubmitCodeResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + required: [ + "submissionId", + "code", + "puzzleId", + "programmingLanguageId", + "userId", + "codeLength", + "result", + "createdAt" + ], + properties: %{ + submissionId: %OpenApiSpex.Schema{type: :string, format: :uuid}, + code: %OpenApiSpex.Schema{type: :string}, + puzzleId: %OpenApiSpex.Schema{type: :string, format: :uuid}, + programmingLanguageId: %OpenApiSpex.Schema{type: :string, format: :uuid}, + userId: %OpenApiSpex.Schema{type: :string, format: :uuid}, + codeLength: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + result: %OpenApiSpex.Schema{ + type: :object, + required: ["successRate", "passed", "failed", "total"], + properties: %{ + successRate: %OpenApiSpex.Schema{type: :number, minimum: 0, maximum: 1}, + passed: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + failed: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + total: %OpenApiSpex.Schema{type: :integer, minimum: 1} + } + }, + createdAt: %OpenApiSpex.Schema{type: :string, format: "date-time"} + } + }) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/user.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/user.ex new file mode 100644 index 00000000..fc138e7b --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/openapi/schemas/user.ex @@ -0,0 +1,97 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.User do + @moduledoc """ + User-related OpenAPI schemas shared across responses. + """ + + require OpenApiSpex + + alias CodincodApiWeb.OpenAPI.Schemas.{Puzzle, Submission} + + defmodule Profile do + @moduledoc false + OpenApiSpex.schema(%{ + title: "Profile", + type: :object, + properties: %{ + bio: %OpenApiSpex.Schema{type: :string, nullable: true}, + location: %OpenApiSpex.Schema{type: :string, nullable: true}, + picture: %OpenApiSpex.Schema{type: :string, nullable: true}, + socials: %OpenApiSpex.Schema{type: :array, items: %OpenApiSpex.Schema{type: :string}, nullable: true} + } + }) + end + + defmodule Summary do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + _id: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + id: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + legacyId: %OpenApiSpex.Schema{type: :string, nullable: true}, + legacyUsername: %OpenApiSpex.Schema{type: :string, nullable: true}, + username: %OpenApiSpex.Schema{type: :string}, + profile: Profile.schema(), + role: %OpenApiSpex.Schema{type: :string, nullable: true}, + reportCount: %OpenApiSpex.Schema{type: :integer, nullable: true}, + banCount: %OpenApiSpex.Schema{type: :integer, nullable: true}, + currentBan: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + createdAt: %OpenApiSpex.Schema{type: :string, format: "date-time", nullable: true}, + updatedAt: %OpenApiSpex.Schema{type: :string, format: "date-time", nullable: true} + } + }) + end + + defmodule ShowResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + message: %OpenApiSpex.Schema{type: :string}, + user: Summary.schema() + } + }) + end + + defmodule AvailabilityResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + available: %OpenApiSpex.Schema{type: :boolean} + } + }) + end + + defmodule ActivityResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + message: %OpenApiSpex.Schema{type: :string}, + user: Summary.schema(), + activity: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + puzzles: %OpenApiSpex.Schema{type: :array, items: Puzzle.PuzzleResponse.schema()}, + submissions: Submission.SubmissionListResponse.schema() + } + } + } + }) + end + + defmodule PaginatedPuzzlesResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + page: %OpenApiSpex.Schema{type: :integer, minimum: 1}, + pageSize: %OpenApiSpex.Schema{type: :integer, minimum: 1, maximum: 100}, + totalItems: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + totalPages: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + items: %OpenApiSpex.Schema{type: :array, items: Puzzle.PuzzleResponse.schema()} + } + }) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/plugs/attach_token_from_cookie.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/plugs/attach_token_from_cookie.ex new file mode 100644 index 00000000..125c50c0 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/plugs/attach_token_from_cookie.ex @@ -0,0 +1,35 @@ +defmodule CodincodApiWeb.Plugs.AttachTokenFromCookie do + @moduledoc """ + Ensures Bearer tokens stored in cookies are exposed to Guardian pipelines. + + The legacy Fastify backend set an HTTP-only cookie named `token`. Since the + frontend continues to rely on that behaviour, this plug mirrors it by + promoting the cookie value to the `Authorization` header when a header is not + already present. + """ + + import Plug.Conn + + @behaviour Plug + + @impl Plug + def init(opts), do: opts + + @impl Plug + def call(conn, _opts) do + conn = fetch_cookies(conn) + + case {get_req_header(conn, "authorization"), Map.get(conn.req_cookies, cookie_name())} do + {[], token} when is_binary(token) and byte_size(token) > 0 -> + put_req_header(conn, "authorization", "Bearer " <> token) + + _ -> + conn + end + end + + defp cookie_name do + Application.get_env(:codincod_api, :auth_cookie, []) + |> Keyword.get(:name, "token") + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/plugs/current_user.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/plugs/current_user.ex new file mode 100644 index 00000000..262581f7 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/plugs/current_user.ex @@ -0,0 +1,15 @@ +defmodule CodincodApiWeb.Plugs.CurrentUser do + @moduledoc """ + Plug to assign the current authenticated user to the connection. + """ + import Plug.Conn + + def init(opts), do: opts + + def call(conn, _opts) do + case Guardian.Plug.current_resource(conn) do + nil -> conn + user -> assign(conn, :current_user, user) + end + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/plugs/open_api_spec.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/plugs/open_api_spec.ex new file mode 100644 index 00000000..cce04ab3 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/plugs/open_api_spec.ex @@ -0,0 +1,19 @@ +defmodule CodincodApiWeb.Plugs.OpenApiSpec do + @moduledoc """ + Wrapper plug to attach the generated OpenAPI spec to the connection. + """ + + @behaviour Plug + + @impl Plug + def init(opts) do + opts + |> Keyword.put_new(:module, CodincodApiWeb.OpenAPI) + |> OpenApiSpex.Plug.PutApiSpec.init() + end + + @impl Plug + def call(conn, opts) do + OpenApiSpex.Plug.PutApiSpec.call(conn, opts) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/plugs/render_open_api.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/plugs/render_open_api.ex new file mode 100644 index 00000000..61bcc7d5 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/plugs/render_open_api.ex @@ -0,0 +1,18 @@ +defmodule CodincodApiWeb.Plugs.RenderOpenApi do + @moduledoc """ + Wrapper plug to render the OpenAPI specification. + """ + + @behaviour Plug + + @impl Plug + def init(opts) do + Keyword.put_new(opts, :json_library, Jason) + |> OpenApiSpex.Plug.RenderSpec.init() + end + + @impl Plug + def call(conn, opts) do + OpenApiSpex.Plug.RenderSpec.call(conn, opts) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/router.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/router.ex new file mode 100644 index 00000000..fa46ab39 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/router.ex @@ -0,0 +1,143 @@ +defmodule CodincodApiWeb.Router do + use CodincodApiWeb, :router + + pipeline :api do + plug :accepts, ["json"] + end + + pipeline :auth do + plug :debug_auth_request # ADD THIS + plug CodincodApiWeb.Plugs.AttachTokenFromCookie + plug CodincodApiWeb.Auth.Pipeline + plug CodincodApiWeb.Plugs.CurrentUser + end + + defp debug_auth_request(conn, _opts) do + require Logger + + conn = Plug.Conn.fetch_cookies(conn) + + Logger.info("=== AUTH REQUEST DEBUG ===") + Logger.info("Path: #{conn.request_path}") + Logger.info("All cookies: #{inspect(conn.req_cookies)}") + Logger.info("Token cookie exists?: #{Map.has_key?(conn.req_cookies, "token")}") + Logger.info("Token value: #{inspect(Map.get(conn.req_cookies, "token"))}") + Logger.info("Auth header: #{inspect(Plug.Conn.get_req_header(conn, "authorization"))}") + Logger.info("========================") + + conn +end + + pipeline :maybe_auth do + plug CodincodApiWeb.Plugs.AttachTokenFromCookie + + plug Guardian.Plug.VerifyHeader, + scheme: "Bearer", + module: CodincodApiWeb.Auth.Guardian, + allow_blank: true + + plug Guardian.Plug.LoadResource, + module: CodincodApiWeb.Auth.Guardian, + allow_blank: true + + plug CodincodApiWeb.Plugs.CurrentUser + end + + @api_versions ["/api", "/api/v1"] + + for base_path <- @api_versions do + scope base_path, CodincodApiWeb do + pipe_through [:api] + + get "/openapi.json", OpenApiController, :show + get "/health", HealthController, :show + get "/puzzles", PuzzleController, :index + get "/programming-languages", ProgrammingLanguageController, :index + get "/user/:username", UserController, :show + get "/user/:username/activity", UserController, :activity + get "/user/:username/isAvailable", UserController, :availability + + post "/login", AuthController, :login + post "/register", AuthController, :register + post "/password-reset/request", PasswordResetController, :request_reset + post "/password-reset/reset", PasswordResetController, :reset_password + end + + scope base_path, CodincodApiWeb do + pipe_through [:api, :maybe_auth] + + get "/user/:username/puzzle", UserController, :puzzles + get "/comment/:id", CommentController, :show + get "/puzzle/:id", PuzzleController, :show + end + + scope base_path, CodincodApiWeb do + pipe_through [:api, :auth] + + post "/logout", AuthController, :logout + post "/refresh", AuthController, :refresh + + get "/account", AccountController, :show + patch "/account/profile", AccountController, :update_profile + get "/account/leaderboard", AccountController, :leaderboard_rank + get "/account/games", AccountController, :games + + get "/account/preferences", AccountPreferenceController, :show + put "/account/preferences", AccountPreferenceController, :replace + patch "/account/preferences", AccountPreferenceController, :patch + delete "/account/preferences", AccountPreferenceController, :delete + + delete "/comment/:id", CommentController, :delete + post "/comment/:id/vote", CommentController, :vote + + post "/puzzles", PuzzleController, :create + get "/puzzle/:id/solution", PuzzleController, :solution + patch "/puzzle/:id", PuzzleController, :update + delete "/puzzle/:id", PuzzleController, :delete + post "/puzzle/:id/comment", PuzzleCommentController, :create + post "/submission", SubmissionController, :create + get "/submission/:id", SubmissionController, :show + post "/execute", ExecuteController, :create + + get "/leaderboard/global", LeaderboardController, :global + get "/leaderboard/puzzle/:puzzle_id", LeaderboardController, :puzzle + + get "/metrics/platform", MetricsController, :platform + get "/metrics/user/:user_id", MetricsController, :user_stats + get "/metrics/puzzle/:puzzle_id", MetricsController, :puzzle_stats + + post "/moderation/report", ModerationController, :create_report + get "/moderation/reports", ModerationController, :list_reports + post "/moderation/report/:id/resolve", ModerationController, :resolve_report + get "/moderation/reviews", ModerationController, :list_reviews + post "/moderation/review/:id", ModerationController, :review_content + post "/moderation/user/:user_id/ban", ModerationController, :ban_user + post "/moderation/user/:user_id/unban", ModerationController, :unban_user + + get "/games/waiting", GameController, :list_waiting_rooms + post "/games", GameController, :create + get "/games/:id", GameController, :show + post "/games/:id/join", GameController, :join + post "/games/:id/leave", GameController, :leave + post "/games/:id/start", GameController, :start + post "/games/:id/submit", GameController, :submit_code + end + end + + # Enable LiveDashboard and Swoosh mailbox preview in development + if Application.compile_env(:codincod_api, :dev_routes) do + # If you want to use the LiveDashboard in production, you should put + # it behind authentication and allow only admins to access it. + # If your application does not have an admins-only section yet, + # you can use Plug.BasicAuth to set up some basic authentication + # as long as you are also using SSL (which you should anyway). + import Phoenix.LiveDashboard.Router + + scope "/dev" do + pipe_through [:fetch_session, :protect_from_forgery] + + live_dashboard "/dashboard", metrics: CodincodApiWeb.Telemetry + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/serializers/helpers.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/serializers/helpers.ex new file mode 100644 index 00000000..3ff6458a --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/serializers/helpers.ex @@ -0,0 +1,16 @@ +defmodule CodincodApiWeb.Serializers.Helpers do + @moduledoc false + + @spec format_datetime(DateTime.t() | NaiveDateTime.t() | nil | term()) :: String.t() | nil + def format_datetime(nil), do: nil + def format_datetime(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime) + def format_datetime(%NaiveDateTime{} = datetime), do: NaiveDateTime.to_iso8601(datetime) + def format_datetime(_), do: nil + + @spec coalesce([term()], term()) :: term() + def coalesce(values, default \\ nil) + + def coalesce([], default), do: default + def coalesce([nil | rest], default), do: coalesce(rest, default) + def coalesce([value | _], _default), do: value +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/serializers/puzzle_serializer.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/serializers/puzzle_serializer.ex new file mode 100644 index 00000000..6eaa281d --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/serializers/puzzle_serializer.ex @@ -0,0 +1,110 @@ +defmodule CodincodApiWeb.Serializers.PuzzleSerializer do + @moduledoc """ + Converts `CodincodApi.Puzzles.Puzzle` structs into JSON-ready maps aligned with the + legacy Fastify responses. + """ + + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles.{Puzzle, PuzzleValidator} + alias CodincodApiWeb.Serializers.Helpers + + @spec render(Puzzle.t()) :: map() + def render(%Puzzle{} = puzzle) do + %{ + _id: puzzle.id, + id: puzzle.id, + legacyId: puzzle.legacy_id, + title: puzzle.title, + statement: puzzle.statement, + constraints: puzzle.constraints, + author: render_author(puzzle.author), + validators: render_validators(puzzle.validators || []), + difficulty: normalize_difficulty(puzzle.difficulty), + visibility: normalize_visibility(puzzle.visibility), + createdAt: Helpers.format_datetime(puzzle.inserted_at), + updatedAt: Helpers.format_datetime(puzzle.updated_at), + solution: normalize_solution(puzzle.solution), + puzzleMetrics: puzzle.metrics && puzzle.metrics.id, + legacyMetricsId: puzzle.legacy_metrics_id, + tags: puzzle.tags || [], + comments: puzzle.legacy_comments || [], + moderationFeedback: puzzle.moderation_feedback + } + end + + @spec render_many([Puzzle.t()]) :: [map()] + def render_many(puzzles) when is_list(puzzles) do + Enum.map(puzzles, &render/1) + end + + defp render_author(%User{} = user) do + %{ + _id: user.id, + id: user.id, + username: user.username, + profile: user.profile, + role: user.role, + createdAt: Helpers.format_datetime(user.inserted_at), + updatedAt: Helpers.format_datetime(user.updated_at) + } + end + + defp render_author(_), do: nil + + defp render_validators(validators) do + validators + |> Enum.map(fn + %PuzzleValidator{} = validator -> + %{ + input: validator.input, + output: validator.output, + isPublic: validator.is_public, + createdAt: Helpers.format_datetime(validator.inserted_at), + updatedAt: Helpers.format_datetime(validator.updated_at) + } + + _ -> + nil + end) + |> Enum.reject(&is_nil/1) + end + + defp normalize_solution(solution) when is_map(solution) do + %{ + code: + Helpers.coalesce( + [Map.get(solution, "code"), Map.get(solution, :code)], + "" + ), + programmingLanguage: + Helpers.coalesce([ + Map.get(solution, "programmingLanguage"), + Map.get(solution, :programmingLanguage), + Map.get(solution, "programming_language"), + Map.get(solution, :programming_language) + ]) + } + end + + defp normalize_solution(_), do: %{code: "", programmingLanguage: nil} + + defp normalize_difficulty(nil), do: nil + + defp normalize_difficulty(difficulty) when is_binary(difficulty) do + difficulty + |> String.trim() + |> String.downcase() + end + + defp normalize_difficulty(_), do: nil + + defp normalize_visibility(nil), do: nil + + defp normalize_visibility(visibility) when is_binary(visibility) do + visibility + |> String.trim() + |> String.downcase() + end + + defp normalize_visibility(_), do: nil +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/serializers/submission_serializer.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/serializers/submission_serializer.ex new file mode 100644 index 00000000..fc59de38 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/serializers/submission_serializer.ex @@ -0,0 +1,86 @@ +defmodule CodincodApiWeb.Serializers.SubmissionSerializer do + @moduledoc """ + Serializes `CodincodApi.Submissions.Submission` structs for HTTP responses. + """ + + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles.Puzzle + alias CodincodApi.Submissions.Submission + alias CodincodApi.Languages.ProgrammingLanguage + alias CodincodApiWeb.Serializers.Helpers + alias CodincodApiWeb.Serializers.UserSerializer + + @spec render(Submission.t()) :: map() + def render(%Submission{} = submission) do + %{ + _id: submission.id, + id: submission.id, + legacyId: submission.legacy_id, + code: submission.code, + result: submission.result || %{}, + score: submission.score, + createdAt: Helpers.format_datetime(submission.inserted_at), + updatedAt: Helpers.format_datetime(submission.updated_at), + puzzle: render_puzzle(submission.puzzle, submission.puzzle_id), + programmingLanguage: + render_programming_language( + submission.programming_language, + submission.programming_language_id + ), + user: render_user(submission.user, submission.user_id), + gameId: submission.game_id, + legacyGameSubmissionId: submission.legacy_game_submission_id + } + end + + @spec render_many([Submission.t()]) :: [map()] + def render_many(submissions) when is_list(submissions) do + Enum.map(submissions, &render/1) + end + + defp render_user(%User{} = user, _id), do: UserSerializer.render(user) + defp render_user(_user, nil), do: nil + + defp render_user(_user, id) do + %{ + _id: id, + id: id + } + end + + defp render_puzzle(%Puzzle{} = puzzle, _id) do + %{ + _id: puzzle.id, + id: puzzle.id, + title: puzzle.title + } + end + + defp render_puzzle(_puzzle, nil), do: nil + + defp render_puzzle(_puzzle, id) do + %{ + _id: id, + id: id + } + end + + defp render_programming_language(%ProgrammingLanguage{} = language, _id) do + %{ + _id: language.id, + id: language.id, + language: language.language, + version: language.version, + runtime: language.runtime + } + end + + defp render_programming_language(_language, nil), do: nil + + defp render_programming_language(_language, id) do + %{ + _id: id, + id: id + } + end +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/serializers/user_serializer.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/serializers/user_serializer.ex new file mode 100644 index 00000000..f37d31c8 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/serializers/user_serializer.ex @@ -0,0 +1,29 @@ +defmodule CodincodApiWeb.Serializers.UserSerializer do + @moduledoc """ + Serializes `CodincodApi.Accounts.User` structs into API responses consistent with the + legacy Node implementation. + """ + + alias CodincodApi.Accounts.User + alias CodincodApiWeb.Serializers.Helpers + + @spec render(User.t() | nil) :: map() | nil + def render(%User{} = user) do + %{ + _id: user.id, + id: user.id, + legacyId: user.legacy_id, + legacyUsername: user.legacy_username, + username: user.username, + profile: user.profile || %{}, + role: user.role, + reportCount: user.report_count, + banCount: user.ban_count, + currentBan: user.current_ban_id, + createdAt: Helpers.format_datetime(user.inserted_at), + updatedAt: Helpers.format_datetime(user.updated_at) + } + end + + def render(_), do: nil +end diff --git a/libs/elixir-backend/codincod_api/lib/codincod_api_web/telemetry.ex b/libs/elixir-backend/codincod_api/lib/codincod_api_web/telemetry.ex new file mode 100644 index 00000000..8cf1ef41 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/codincod_api_web/telemetry.ex @@ -0,0 +1,93 @@ +defmodule CodincodApiWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + sum("phoenix.socket_drain.count"), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("codincod_api.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("codincod_api.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("codincod_api.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("codincod_api.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("codincod_api.repo.query.idle_time", + unit: {:native, :millisecond}, + description: + "The time the connection spent waiting before being checked out for the query" + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {CodincodApiWeb, :count_users, []} + ] + end +end diff --git a/libs/elixir-backend/codincod_api/lib/mix/tasks/codincod.gen_openapi_spec.ex b/libs/elixir-backend/codincod_api/lib/mix/tasks/codincod.gen_openapi_spec.ex new file mode 100644 index 00000000..e7974f3c --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/mix/tasks/codincod.gen_openapi_spec.ex @@ -0,0 +1,37 @@ +defmodule Mix.Tasks.Codincod.GenOpenapiSpec do + @moduledoc "Generate OpenAPI specification JSON from the Phoenix router." + + use Mix.Task + + @shortdoc "Emit OpenAPI JSON" + + @switches [dest: :string] + @aliases [d: :dest] + + @impl Mix.Task + def run(args) do + Mix.Task.run("app.start") + + {opts, _argv, _invalid} = OptionParser.parse(args, switches: @switches, aliases: @aliases) + + dest = + opts + |> Keyword.get(:dest, default_destination()) + |> Path.expand(File.cwd!()) + + spec = CodincodApiWeb.OpenAPI.spec() + + # Use render_spec instead of to_map to properly resolve references + json = spec + |> OpenApiSpex.OpenApi.json_encoder().encode!(pretty: true) + + :ok = File.mkdir_p!(Path.dirname(dest)) + :ok = File.write(dest, json) + + Mix.shell().info("OpenAPI spec written to #{dest}") + end + + defp default_destination do + Path.join(["priv", "static", "openapi.json"]) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/mix/tasks/codincod.gen_types.ex b/libs/elixir-backend/codincod_api/lib/mix/tasks/codincod.gen_types.ex new file mode 100644 index 00000000..d47945a0 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/mix/tasks/codincod.gen_types.ex @@ -0,0 +1,27 @@ +defmodule Mix.Tasks.Codincod.GenTypes do + @moduledoc "Generates TypeScript definitions that mirror the Phoenix backend." + + use Mix.Task + + @shortdoc "Generate TypeScript types for the frontend" + + @switches [dest: :string] + @aliases [d: :dest] + + @impl Mix.Task + def run(args) do + Mix.Task.run("compile") + + {opts, _argv, _invalid} = OptionParser.parse(args, switches: @switches, aliases: @aliases) + + opts = Keyword.take(opts, [:dest]) + + case CodincodApi.Typegen.generate(opts) do + {:ok, path} -> + Mix.shell().info("TypeScript definitions written to #{path}") + + {:error, reason} -> + Mix.raise("Failed to generate TypeScript types: #{inspect(reason)}") + end + end +end diff --git a/libs/elixir-backend/codincod_api/lib/mix/tasks/gen_typescript_types.ex b/libs/elixir-backend/codincod_api/lib/mix/tasks/gen_typescript_types.ex new file mode 100644 index 00000000..23629653 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/mix/tasks/gen_typescript_types.ex @@ -0,0 +1,12 @@ +defmodule Mix.Tasks.GenTypescriptTypes do + @moduledoc "Legacy wrapper that delegates to `mix codincod.gen_types`." + + use Mix.Task + + @shortdoc "Generate TypeScript types (compat shortcut)" + + @impl Mix.Task + def run(args) do + Mix.Tasks.Codincod.GenTypes.run(args) + end +end diff --git a/libs/elixir-backend/codincod_api/lib/mix/tasks/migrate_mongo.ex b/libs/elixir-backend/codincod_api/lib/mix/tasks/migrate_mongo.ex new file mode 100644 index 00000000..95b3f100 --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/mix/tasks/migrate_mongo.ex @@ -0,0 +1,1107 @@ +defmodule Mix.Tasks.MigrateMongo do + @moduledoc """ + Migrates data from MongoDB to PostgreSQL. + + This task is idempotent and can be safely re-run multiple times. + It will skip already-migrated records based on legacy_mongo_id. + + ## Usage + + # Migrate everything (recommended order) + mix migrate_mongo + + # Migrate specific collections + mix migrate_mongo --only users + mix migrate_mongo --only puzzles + mix migrate_mongo --only submissions + + # Dry run (show what would be migrated) + mix migrate_mongo --dry-run + + # Validate migration without migrating + mix migrate_mongo --validate + + ## Environment Variables + + MONGO_URI - MongoDB connection string + MONGO_DB_NAME - MongoDB database name (default: codincod-development) + + ## Migration Order (important!) + + 1. Users (no dependencies) + 2. Puzzles (depends on users for author_id) + 3. Submissions (depends on users and puzzles) + 4. Games (depends on users and puzzles) + 5. Comments (depends on users and puzzles) + 6. Reports (depends on users) + 7. Preferences (depends on users) + """ + + use Mix.Task + require Logger + + alias CodincodApi.Repo + alias CodincodApi.Accounts.{User, Preference} + alias CodincodApi.Puzzles.{Puzzle, PuzzleTestCase, PuzzleExample} + alias CodincodApi.Submissions.Submission + alias CodincodApi.Games.Game + alias CodincodApi.Comments.Comment + alias CodincodApi.Moderation.Report + + @shortdoc "Migrates data from MongoDB to PostgreSQL" + + @batch_size 100 + + def run(args) do + Mix.Task.run("app.start") + + {opts, _, _} = OptionParser.parse(args, + switches: [only: :string, dry_run: :boolean, validate: :boolean], + aliases: [o: :only, d: :dry_run, v: :validate] + ) + + mongo_uri = System.get_env("MONGO_URI") || + raise "MONGO_URI environment variable required" + mongo_db = System.get_env("MONGO_DB_NAME") || "codincod-development" + + ssl_opts = if String.contains?(mongo_uri, "mongodb+srv://") do + [verify: :verify_none] + else + [] + end + + Logger.info("🚀 Starting MongoDB → PostgreSQL migration") + Logger.info(" Database: #{mongo_db}") + + case Mongo.start_link(url: mongo_uri, name: :mongo_migration, database: mongo_db, pool_size: 5, ssl_opts: ssl_opts) do + {:ok, _pid} -> + cond do + opts[:validate] -> + validate_migration() + opts[:dry_run] -> + dry_run(opts[:only]) + true -> + perform_migration(opts[:only]) + end + + {:error, reason} -> + Logger.error("❌ Failed to connect to MongoDB: #{inspect(reason)}") + exit(:mongodb_connection_failed) + end + end + + defp perform_migration(only) do + migrations = case only do + "users" -> [:users] + "puzzles" -> [:puzzles] + "submissions" -> [:submissions] + "games" -> [:games] + "comments" -> [:comments] + "reports" -> [:reports] + "preferences" -> [:preferences] + nil -> [:users, :puzzles, :submissions, :games, :comments, :reports, :preferences] + _ -> + Logger.error("Unknown collection: #{only}") + exit(:invalid_collection) + end + + start_time = System.monotonic_time(:millisecond) + + results = Enum.map(migrations, fn migration -> + case migration do + :users -> migrate_users() + :puzzles -> migrate_puzzles() + :submissions -> migrate_submissions() + :games -> migrate_games() + :comments -> migrate_comments() + :reports -> migrate_reports() + :preferences -> migrate_preferences() + end + end) + + duration = System.monotonic_time(:millisecond) - start_time + + Logger.info("\n" <> IO.ANSI.green() <> "✅ Migration completed in #{duration}ms" <> IO.ANSI.reset()) + print_summary(results) + end + + defp migrate_users do + Logger.info("\n📊 Migrating users...") + + case Mongo.find(:mongo_migration, "users", %{}) |> Enum.to_list() do + [] -> + Logger.warning(" No users found in MongoDB") + %{collection: "users", migrated: 0, skipped: 0, failed: 0} + + mongo_users -> + total = length(mongo_users) + Logger.info(" Found #{total} users in MongoDB") + + {migrated, skipped, failed} = mongo_users + |> Enum.chunk_every(@batch_size) + |> Enum.with_index(1) + |> Enum.reduce({0, 0, 0}, fn {batch, batch_num}, {m, s, f} -> + Logger.info(" Processing batch #{batch_num}/#{ceil(total / @batch_size)}") + + Enum.reduce(batch, {m, s, f}, fn user, {migrated, skipped, failed} -> + case migrate_single_user(user) do + {:ok, :created} -> {migrated + 1, skipped, failed} + {:ok, :skipped} -> {migrated, skipped + 1, failed} + {:error, _} -> {migrated, skipped, failed + 1} + end + end) + end) + + Logger.info(" ✓ Users: #{migrated} migrated, #{skipped} skipped, #{failed} failed") + %{collection: "users", migrated: migrated, skipped: skipped, failed: failed, total: total} + end + end + + defp migrate_single_user(mongo_user) do + mongo_id = extract_mongo_id(mongo_user["_id"]) + + # Check if already migrated + case Repo.get_by(User, legacy_id: mongo_id) do + %User{} = _existing -> + {:ok, :skipped} + + nil -> + # Build profile from MongoDB structure + profile = %{} + |> Map.put("avatarUrl", get_in(mongo_user, ["profile", "avatarUrl"]) || get_in(mongo_user, ["profile", "picture"])) + |> Map.put("bio", get_in(mongo_user, ["profile", "bio"])) + |> Map.put("location", get_in(mongo_user, ["profile", "location"])) + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Enum.into(%{}) + + # Sanitize username to match regex ^[A-Za-z0-9_-]+$ + raw_username = mongo_user["username"] || mongo_user["email"] |> String.split("@") |> hd() + sanitized_username = raw_username + |> String.replace(~r/[^A-Za-z0-9_-]/, "_") + |> String.slice(0, 20) + + attrs = %{ + email: mongo_user["email"], + username: sanitized_username, + password: "TemporaryPassword123!", # Will use actual hash below + password_confirmation: "TemporaryPassword123!", + profile: profile, + role: parse_role(mongo_user["role"]), + legacy_id: mongo_id, + legacy_username: raw_username, # Store original username + ban_count: mongo_user["banCount"] || 0 + } + + changeset = User.registration_changeset(%User{}, attrs) + + # Override the password_hash with the actual MongoDB hash + changeset = if mongo_user["password"] do + Ecto.Changeset.put_change(changeset, :password_hash, mongo_user["password"]) + else + changeset + end + + # Set timestamps + changeset = changeset + |> Ecto.Changeset.put_change(:inserted_at, parse_datetime(mongo_user["createdAt"]) || DateTime.utc_now()) + |> Ecto.Changeset.put_change(:updated_at, parse_datetime(mongo_user["updatedAt"]) || DateTime.utc_now()) + + case Repo.insert(changeset) do + {:ok, _user} -> + {:ok, :created} + + {:error, changeset} -> + Logger.error(" Failed to migrate user #{mongo_user["email"]}: #{inspect(changeset.errors)}") + {:error, changeset} + end + end + end + + defp migrate_puzzles do + Logger.info("\n🧩 Migrating puzzles...") + + case Mongo.find(:mongo_migration, "puzzles", %{}) |> Enum.to_list() do + [] -> + Logger.warning(" No puzzles found") + %{collection: "puzzles", migrated: 0, skipped: 0, failed: 0} + + mongo_puzzles -> + total = length(mongo_puzzles) + Logger.info(" Found #{total} puzzles") + + {migrated, skipped, failed} = mongo_puzzles + |> Enum.chunk_every(@batch_size) + |> Enum.with_index(1) + |> Enum.reduce({0, 0, 0}, fn {batch, batch_num}, {m, s, f} -> + Logger.info(" Processing batch #{batch_num}/#{ceil(total / @batch_size)}") + + Enum.reduce(batch, {m, s, f}, fn puzzle, {migrated, skipped, failed} -> + case migrate_single_puzzle(puzzle) do + {:ok, :created} -> {migrated + 1, skipped, failed} + {:ok, :skipped} -> {migrated, skipped + 1, failed} + {:error, _} -> {migrated, skipped, failed + 1} + end + end) + end) + + Logger.info(" ✓ Puzzles: #{migrated} migrated, #{skipped} skipped, #{failed} failed") + %{collection: "puzzles", migrated: migrated, skipped: skipped, failed: failed, total: total} + end + end + + defp migrate_single_puzzle(mongo_puzzle) do + mongo_id = extract_mongo_id(mongo_puzzle["_id"]) + + case Repo.get_by(Puzzle, legacy_id: mongo_id) do + %Puzzle{} -> {:ok, :skipped} + nil -> + # Find author by legacy_id + author_mongo_id = extract_mongo_id(mongo_puzzle["author"]) + author = Repo.get_by(User, legacy_id: author_mongo_id) + + if is_nil(author) do + Logger.warning(" Skipping puzzle '#{mongo_puzzle["title"]}' - author not found (#{author_mongo_id})") + {:error, :author_not_found} + else + # Clean solution field from BSON ObjectIds + solution = clean_bson_objectids(mongo_puzzle["solution"] || %{}) + + attrs = %{ + title: mongo_puzzle["title"] || "Untitled Puzzle", + statement: mongo_puzzle["statement"] || mongo_puzzle["description"] || "", + constraints: mongo_puzzle["constraints"], + difficulty: parse_difficulty(mongo_puzzle["difficulty"]), + visibility: parse_visibility(mongo_puzzle["visibility"]), + tags: mongo_puzzle["tags"] || [], + solution: solution, + author_id: author.id, + legacy_id: mongo_id + } + + changeset = Puzzle.changeset(%Puzzle{}, attrs) + + # Set timestamps + changeset = changeset + |> Ecto.Changeset.put_change(:inserted_at, parse_datetime(mongo_puzzle["createdAt"]) || DateTime.utc_now()) + |> Ecto.Changeset.put_change(:updated_at, parse_datetime(mongo_puzzle["updatedAt"]) || DateTime.utc_now()) + + case Repo.insert(changeset) do + {:ok, puzzle} -> + # Migrate test cases to their own table + # Validators can be at top level or in solution field + migrate_test_cases(puzzle, mongo_puzzle, solution, mongo_id) + + # Migrate examples to their own table + migrate_examples(puzzle, solution, mongo_id) + + {:ok, :created} + {:error, changeset} -> + Logger.error(" Failed to migrate puzzle '#{mongo_puzzle["title"]}': #{inspect(changeset.errors)}") + {:error, changeset} + end + end + end + end + + defp migrate_test_cases(puzzle, mongo_puzzle, solution, mongo_id) do + # MongoDB stores test cases in "validators" array at top level + # Also check solution field and "testCases" for backward compatibility + test_cases = mongo_puzzle["validators"] || solution["testCases"] || solution["validators"] || [] + + test_cases + |> Enum.with_index() + |> Enum.each(fn {tc, idx} -> + # Check if already exists by legacy_id + legacy_id = "#{mongo_id}_tc_#{idx}" + + unless Repo.get_by(PuzzleTestCase, legacy_id: legacy_id) do + attrs = %{ + puzzle_id: puzzle.id, + input: tc["input"] || "", + # MongoDB uses "output", newer format might use "expectedOutput" + expected_output: tc["expectedOutput"] || tc["output"] || "", + # Default to false if not specified (hidden test cases) + is_sample: tc["isSample"] || tc["is_sample"] || false, + order: idx, + legacy_id: legacy_id, + metadata: %{} + } + + case PuzzleTestCase.changeset(%PuzzleTestCase{}, attrs) |> Repo.insert() do + {:ok, _} -> :ok + {:error, changeset} -> + Logger.warning(" Failed to migrate test case #{idx} for puzzle #{puzzle.title}: #{inspect(changeset.errors)}") + end + end + end) + end + + defp migrate_examples(puzzle, solution, mongo_id) do + examples = solution["examples"] || [] + + examples + |> Enum.with_index() + |> Enum.each(fn {ex, idx} -> + # Check if already exists by legacy_id + legacy_id = "#{mongo_id}_ex_#{idx}" + + unless Repo.get_by(PuzzleExample, legacy_id: legacy_id) do + attrs = %{ + puzzle_id: puzzle.id, + input: ex["input"] || "", + output: ex["output"] || "", + explanation: ex["explanation"], + order: idx, + legacy_id: legacy_id, + metadata: %{} + } + + case PuzzleExample.changeset(%PuzzleExample{}, attrs) |> Repo.insert() do + {:ok, _} -> :ok + {:error, changeset} -> + Logger.warning(" Failed to migrate example #{idx} for puzzle #{puzzle.title}: #{inspect(changeset.errors)}") + end + end + end) + end + + defp migrate_submissions do + Logger.info("\n📝 Migrating submissions...") + + case Mongo.find(:mongo_migration, "submissions", %{}) |> Enum.to_list() do + [] -> + Logger.warning(" No submissions found") + %{collection: "submissions", migrated: 0, skipped: 0, failed: 0} + + mongo_submissions -> + total = length(mongo_submissions) + Logger.info(" Found #{total} submissions") + + {migrated, skipped, failed} = mongo_submissions + |> Enum.chunk_every(@batch_size) + |> Enum.with_index(1) + |> Enum.reduce({0, 0, 0}, fn {batch, batch_num}, {m, s, f} -> + Logger.info(" Processing batch #{batch_num}/#{ceil(total / @batch_size)}") + + Enum.reduce(batch, {m, s, f}, fn submission, {migrated, skipped, failed} -> + case migrate_single_submission(submission) do + {:ok, :created} -> {migrated + 1, skipped, failed} + {:ok, :skipped} -> {migrated, skipped + 1, failed} + {:error, _} -> {migrated, skipped, failed + 1} + end + end) + end) + + Logger.info(" ✓ Submissions: #{migrated} migrated, #{skipped} skipped, #{failed} failed") + %{collection: "submissions", migrated: migrated, skipped: skipped, failed: failed, total: total} + end + end + + defp migrate_single_submission(mongo_submission) do + mongo_id = extract_mongo_id(mongo_submission["_id"]) + + case Repo.get_by(Submission, legacy_id: mongo_id) do + %Submission{} -> {:ok, :skipped} + nil -> + user_mongo_id = extract_mongo_id(mongo_submission["user"]) + puzzle_mongo_id = extract_mongo_id(mongo_submission["puzzle"]) + + user = Repo.get_by(User, legacy_id: user_mongo_id) + puzzle = Repo.get_by(Puzzle, legacy_id: puzzle_mongo_id) + + cond do + is_nil(user) -> + {:error, :user_not_found} + is_nil(puzzle) -> + {:error, :puzzle_not_found} + true -> + # Get or create programming language + language_data = mongo_submission["programmingLanguage"] + language_name = cond do + is_struct(language_data, BSON.ObjectId) -> "unknown" + is_map(language_data) -> language_data["language"] + is_binary(language_data) -> language_data + true -> "unknown" + end + + programming_language = get_or_create_language(language_name || "unknown") + + result = mongo_submission["result"] || %{} + # Clean BSON ObjectIds from result + result = clean_bson_objectids(result) + + attrs = %{ + user_id: user.id, + puzzle_id: puzzle.id, + programming_language_id: programming_language && programming_language.id, + code: mongo_submission["code"] || "", + result: result, + score: calculate_score(result), + legacy_id: mongo_id + } + + changeset = Submission.create_changeset(%Submission{}, attrs) + + # Set timestamps + changeset = changeset + |> Ecto.Changeset.put_change(:inserted_at, parse_datetime(mongo_submission["createdAt"]) || DateTime.utc_now()) + |> Ecto.Changeset.put_change(:updated_at, parse_datetime(mongo_submission["updatedAt"]) || DateTime.utc_now()) + + case Repo.insert(changeset) do + {:ok, _} -> {:ok, :created} + {:error, changeset} -> + Logger.debug(" Failed to migrate submission: #{inspect(changeset.errors)}") + {:error, changeset} + end + end + end + end + + defp migrate_games do + Logger.info("\n🎮 Migrating games...") + + case Mongo.find(:mongo_migration, "games", %{}) |> Enum.to_list() do + [] -> + Logger.warning(" No games found in MongoDB") + %{collection: "games", migrated: 0, skipped: 0, failed: 0} + + mongo_games -> + total = length(mongo_games) + Logger.info(" Found #{total} games") + + {migrated, skipped, failed} = mongo_games + |> Enum.chunk_every(@batch_size) + |> Enum.with_index(1) + |> Enum.reduce({0, 0, 0}, fn {batch, batch_num}, {m, s, f} -> + Logger.info(" Processing batch #{batch_num}/#{ceil(total / @batch_size)}") + + batch_results = Enum.map(batch, &migrate_single_game/1) + + migrated_count = Enum.count(batch_results, &match?({:ok, :created}, &1)) + skipped_count = Enum.count(batch_results, &match?({:ok, :skipped}, &1)) + failed_count = Enum.count(batch_results, &match?({:error, _}, &1)) + + {m + migrated_count, s + skipped_count, f + failed_count} + end) + + Logger.info(" ✓ Games: #{migrated} migrated, #{skipped} skipped, #{failed} failed") + %{collection: "games", migrated: migrated, skipped: skipped, failed: failed, total: total} + end + end + + defp migrate_single_game(mongo_game) do + mongo_id = mongo_game["_id"] |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + + # Check if already migrated + case Repo.get_by(Game, legacy_id: mongo_id) do + %Game{} -> {:ok, :skipped} + nil -> + # Get owner + owner = case mongo_game["owner"] do + %BSON.ObjectId{} = oid -> + owner_id = oid |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + Repo.get_by(User, legacy_id: owner_id) + _ -> nil + end + + # Get puzzle + puzzle = case mongo_game["puzzle"] do + %BSON.ObjectId{} = oid -> + puzzle_id = oid |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + Repo.get_by(Puzzle, legacy_id: puzzle_id) + _ -> nil + end + + cond do + is_nil(owner) -> + {:error, :owner_not_found} + is_nil(puzzle) -> + {:error, :puzzle_not_found} + true -> + # Clean BSON ObjectIds from options + options = mongo_game["options"] || %{} + options = clean_bson_objectids(options) + + # Parse timestamps + started_at = parse_datetime(mongo_game["startedAt"]) + ended_at = parse_datetime(mongo_game["endedAt"]) + created_at = parse_datetime(mongo_game["createdAt"]) || DateTime.utc_now() + updated_at = parse_datetime(mongo_game["updatedAt"]) || DateTime.utc_now() + + attrs = %{ + owner_id: owner.id, + puzzle_id: puzzle.id, + visibility: parse_game_visibility(mongo_game["visibility"]), + mode: parse_game_mode(mongo_game["mode"]), + rated: mongo_game["ranked"] || true, + status: parse_game_status(mongo_game["status"]), + max_duration_seconds: mongo_game["maxDuration"] || 600, + allowed_language_ids: [], + options: options, + started_at: started_at, + ended_at: ended_at, + legacy_id: mongo_id + } + + changeset = Game.changeset(%Game{}, attrs) + + # Set timestamps + changeset = changeset + |> Ecto.Changeset.put_change(:inserted_at, created_at) + |> Ecto.Changeset.put_change(:updated_at, updated_at) + + case Repo.insert(changeset) do + {:ok, _} -> {:ok, :created} + {:error, changeset} -> + Logger.debug(" Failed to migrate game: #{inspect(changeset.errors)}") + {:error, changeset} + end + end + end + end + + defp migrate_comments do + Logger.info("\n💬 Migrating comments...") + + case Mongo.find(:mongo_migration, "comments", %{}) |> Enum.to_list() do + [] -> + Logger.warning(" No comments found in MongoDB") + %{collection: "comments", migrated: 0, skipped: 0, failed: 0} + + mongo_comments -> + total = length(mongo_comments) + Logger.info(" Found #{total} comments") + + {migrated, skipped, failed} = mongo_comments + |> Enum.chunk_every(@batch_size) + |> Enum.with_index(1) + |> Enum.reduce({0, 0, 0}, fn {batch, batch_num}, {m, s, f} -> + Logger.info(" Processing batch #{batch_num}/#{ceil(total / @batch_size)}") + + batch_results = Enum.map(batch, &migrate_single_comment/1) + + migrated_count = Enum.count(batch_results, &match?({:ok, :created}, &1)) + skipped_count = Enum.count(batch_results, &match?({:ok, :skipped}, &1)) + failed_count = Enum.count(batch_results, &match?({:error, _}, &1)) + + {m + migrated_count, s + skipped_count, f + failed_count} + end) + + Logger.info(" ✓ Comments: #{migrated} migrated, #{skipped} skipped, #{failed} failed") + %{collection: "comments", migrated: migrated, skipped: skipped, failed: failed, total: total} + end + end + + defp migrate_single_comment(mongo_comment) do + mongo_id = mongo_comment["_id"] |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + + # Check if already migrated + case Repo.get_by(Comment, legacy_id: mongo_id) do + %Comment{} -> {:ok, :skipped} + nil -> + # Get author + author = case mongo_comment["author"] do + %BSON.ObjectId{} = oid -> + author_id = oid |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + Repo.get_by(User, legacy_id: author_id) + _ -> nil + end + + # Get puzzle (optional) + puzzle = case mongo_comment["puzzle"] do + %BSON.ObjectId{} = oid -> + puzzle_id = oid |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + Repo.get_by(Puzzle, legacy_id: puzzle_id) + _ -> nil + end + + # Get parent comment (optional) + parent_comment = case mongo_comment["parent"] do + %BSON.ObjectId{} = oid -> + parent_id = oid |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + Repo.get_by(Comment, legacy_id: parent_id) + _ -> nil + end + + if is_nil(author) do + {:error, :author_not_found} + else + # Parse timestamps + created_at = parse_datetime(mongo_comment["createdAt"]) || DateTime.utc_now() + updated_at = parse_datetime(mongo_comment["updatedAt"]) || DateTime.utc_now() + + # Get votes + votes = mongo_comment["votes"] || %{} + upvotes = if is_list(votes["up"]), do: length(votes["up"]), else: 0 + downvotes = if is_list(votes["down"]), do: length(votes["down"]), else: 0 + + attrs = %{ + author_id: author.id, + puzzle_id: puzzle && puzzle.id, + parent_comment_id: parent_comment && parent_comment.id, + body: mongo_comment["text"] || "", + comment_type: parse_comment_type(mongo_comment["commentType"], parent_comment), + upvote_count: upvotes, + downvote_count: downvotes, + metadata: %{}, + legacy_id: mongo_id + } + + changeset = Comment.changeset(%Comment{}, attrs) + + # Set timestamps + changeset = changeset + |> Ecto.Changeset.put_change(:inserted_at, created_at) + |> Ecto.Changeset.put_change(:updated_at, updated_at) + + case Repo.insert(changeset) do + {:ok, _} -> {:ok, :created} + {:error, changeset} -> + Logger.debug(" Failed to migrate comment: #{inspect(changeset.errors)}") + {:error, changeset} + end + end + end + end + + defp migrate_reports do + Logger.info("\n🚨 Migrating reports...") + + case Mongo.find(:mongo_migration, "reports", %{}) |> Enum.to_list() do + [] -> + Logger.warning(" No reports found in MongoDB") + %{collection: "reports", migrated: 0, skipped: 0, failed: 0} + + mongo_reports -> + total = length(mongo_reports) + Logger.info(" Found #{total} reports") + + {migrated, skipped, failed} = mongo_reports + |> Enum.chunk_every(@batch_size) + |> Enum.with_index(1) + |> Enum.reduce({0, 0, 0}, fn {batch, batch_num}, {m, s, f} -> + Logger.info(" Processing batch #{batch_num}/#{ceil(total / @batch_size)}") + + batch_results = Enum.map(batch, &migrate_single_report/1) + + migrated_count = Enum.count(batch_results, &match?({:ok, :created}, &1)) + skipped_count = Enum.count(batch_results, &match?({:ok, :skipped}, &1)) + failed_count = Enum.count(batch_results, &match?({:error, _}, &1)) + + {m + migrated_count, s + skipped_count, f + failed_count} + end) + + Logger.info(" ✓ Reports: #{migrated} migrated, #{skipped} skipped, #{failed} failed") + %{collection: "reports", migrated: migrated, skipped: skipped, failed: failed, total: total} + end + end + + defp migrate_single_report(mongo_report) do + mongo_id = mongo_report["_id"] |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + + # Check if already migrated + case Repo.get_by(Report, legacy_id: mongo_id) do + %Report{} -> {:ok, :skipped} + nil -> + # Get reporter + reporter = case mongo_report["reportedBy"] do + %BSON.ObjectId{} = oid -> + reporter_id = oid |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + Repo.get_by(User, legacy_id: reporter_id) + _ -> nil + end + + if is_nil(reporter) do + {:error, :reporter_not_found} + else + # Get problem reference ID and try to find the PostgreSQL UUID + problem_ref_id = case {mongo_report["problematicCollection"], mongo_report["problematicIdentifier"]} do + {collection, %BSON.ObjectId{} = oid} when not is_nil(collection) -> + legacy_id = oid |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + + # Try to find the migrated entity's PostgreSQL UUID + case String.downcase(collection || "") do + "users" -> + case Repo.get_by(User, legacy_id: legacy_id) do + %User{id: id} -> id + _ -> nil + end + "puzzles" -> + case Repo.get_by(Puzzle, legacy_id: legacy_id) do + %Puzzle{id: id} -> id + _ -> nil + end + "comments" -> + case Repo.get_by(Comment, legacy_id: legacy_id) do + %Comment{id: id} -> id + _ -> nil + end + "games" -> + case Repo.get_by(Game, legacy_id: legacy_id) do + %Game{id: id} -> id + _ -> nil + end + _ -> nil + end + _ -> nil + end + + # If problem_ref_id is still nil, generate a placeholder UUID (referenced entity doesn't exist in PostgreSQL) + # The snapshot field contains the original data anyway + problem_ref_id = problem_ref_id || Ecto.UUID.generate() + + # Parse timestamps + created_at = parse_datetime(mongo_report["createdAt"]) || DateTime.utc_now() + updated_at = parse_datetime(mongo_report["updatedAt"]) || DateTime.utc_now() + resolved_at = parse_datetime(mongo_report["resolvedAt"]) + + # Get explanation (min 10 chars required) + explanation = case mongo_report["reason"] do + nil -> "No explanation provided (migrated from legacy data)" + "" -> "No explanation provided (migrated from legacy data)" + reason when is_binary(reason) and byte_size(reason) < 10 -> + "#{reason} (migrated from legacy data)" + reason -> reason + end + + attrs = %{ + reported_by_id: reporter.id, + problem_type: parse_problem_type(mongo_report["problematicCollection"]), + problem_reference_id: problem_ref_id, + problem_reference_snapshot: clean_bson_objectids(mongo_report["snapshot"] || %{}), + explanation: explanation, + status: parse_report_status(mongo_report["status"]), + resolution_notes: mongo_report["resolutionNotes"], + resolved_at: resolved_at, + metadata: %{}, + legacy_id: mongo_id + } + + # For migration, we bypass the strict validation and build changeset manually + # since many reports may not have valid problem_reference_ids in PostgreSQL + changeset = %Report{} + |> Ecto.Changeset.cast(attrs, [ + :legacy_id, + :problem_type, + :problem_reference_id, + :problem_reference_snapshot, + :explanation, + :status, + :metadata, + :reported_by_id, + :resolution_notes, + :resolved_at + ]) + |> Ecto.Changeset.validate_required([:problem_type, :explanation, :reported_by_id]) + |> Ecto.Changeset.validate_length(:explanation, min: 10, max: 2_000) + |> Ecto.Changeset.validate_inclusion(:problem_type, ["puzzle", "user", "comment", "game_chat"]) + |> Ecto.Changeset.validate_inclusion(:status, ["pending", "resolved", "rejected"]) + + # Set timestamps + changeset = changeset + |> Ecto.Changeset.put_change(:inserted_at, created_at) + |> Ecto.Changeset.put_change(:updated_at, updated_at) + + case Repo.insert(changeset) do + {:ok, _} -> {:ok, :created} + {:error, changeset} -> + Logger.debug(" Failed to migrate report: #{inspect(changeset.errors)}") + {:error, changeset} + end + end + end + end + + defp migrate_preferences do + Logger.info("\n⚙️ Migrating preferences...") + + case Mongo.find(:mongo_migration, "preferences", %{}) |> Enum.to_list() do + [] -> + Logger.warning(" No preferences found in MongoDB") + %{collection: "preferences", migrated: 0, skipped: 0, failed: 0} + + mongo_preferences -> + total = length(mongo_preferences) + Logger.info(" Found #{total} preferences") + + {migrated, skipped, failed} = mongo_preferences + |> Enum.chunk_every(@batch_size) + |> Enum.with_index(1) + |> Enum.reduce({0, 0, 0}, fn {batch, batch_num}, {m, s, f} -> + Logger.info(" Processing batch #{batch_num}/#{ceil(total / @batch_size)}") + + batch_results = Enum.map(batch, &migrate_single_preference/1) + + migrated_count = Enum.count(batch_results, &match?({:ok, :created}, &1)) + skipped_count = Enum.count(batch_results, &match?({:ok, :skipped}, &1)) + failed_count = Enum.count(batch_results, &match?({:error, _}, &1)) + + {m + migrated_count, s + skipped_count, f + failed_count} + end) + + Logger.info(" ✓ Preferences: #{migrated} migrated, #{skipped} skipped, #{failed} failed") + %{collection: "preferences", migrated: migrated, skipped: skipped, failed: failed, total: total} + end + end + + defp migrate_single_preference(mongo_preference) do + mongo_id = mongo_preference["_id"] |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + + # Check if already migrated + case Repo.get_by(Preference, legacy_id: mongo_id) do + %Preference{} -> {:ok, :skipped} + nil -> + # Get user - try "owner", "userId", and "user" fields + user = case mongo_preference["owner"] || mongo_preference["userId"] || mongo_preference["user"] do + %BSON.ObjectId{} = oid -> + user_id = oid |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + Repo.get_by(User, legacy_id: user_id) + user_id when is_binary(user_id) -> + # Already a string ID + Repo.get_by(User, legacy_id: user_id) + _ -> nil + end + + if is_nil(user) do + Logger.debug(" User not found for preference: #{mongo_id}, user field: #{inspect(mongo_preference["owner"] || mongo_preference["userId"] || mongo_preference["user"])}") + {:error, :user_not_found} + else + # Parse timestamps + created_at = parse_datetime(mongo_preference["createdAt"]) || DateTime.utc_now() + updated_at = parse_datetime(mongo_preference["updatedAt"]) || DateTime.utc_now() + + # Clean editor config + editor = clean_bson_objectids(mongo_preference["editor"] || %{}) + + attrs = %{ + user_id: user.id, + preferred_language: mongo_preference["preferredLanguage"], + theme: parse_theme(mongo_preference["theme"]), + blocked_user_ids: [], + editor: editor, + legacy_id: mongo_id + } + + changeset = Preference.changeset(%Preference{}, attrs) + + # Set timestamps + changeset = changeset + |> Ecto.Changeset.put_change(:inserted_at, created_at) + |> Ecto.Changeset.put_change(:updated_at, updated_at) + + case Repo.insert(changeset) do + {:ok, _} -> {:ok, :created} + {:error, changeset} -> + Logger.debug(" Failed to migrate preference: #{inspect(changeset.errors)}") + {:error, changeset} + end + end + end + end + + defp validate_migration do + Logger.info("\n🔍 Validating migration...") + + validations = [ + {"users", count_mongo("users"), Repo.aggregate(User, :count)}, + {"puzzles", count_mongo("puzzles"), Repo.aggregate(Puzzle, :count)}, + {"submissions", count_mongo("submissions"), Repo.aggregate(Submission, :count)}, + {"games", count_mongo("games"), Repo.aggregate(Game, :count)}, + {"comments", count_mongo("comments"), Repo.aggregate(Comment, :count)}, + {"reports", count_mongo("reports"), Repo.aggregate(Report, :count)}, + {"preferences", count_mongo("preferences"), Repo.aggregate(Preference, :count)} + ] + + Enum.each(validations, fn {name, mongo_count, pg_count} -> + status = if mongo_count == pg_count, do: "✅", else: "❌" + Logger.info(" #{status} #{String.pad_trailing(name, 15)} MongoDB: #{mongo_count}, PostgreSQL: #{pg_count}") + end) + + Logger.info("\n✅ Validation complete") + end + + defp dry_run(only) do + Logger.info("\n🔍 DRY RUN - No data will be migrated\n") + + collections = case only do + nil -> ["users", "puzzles", "submissions", "games", "comments", "reports", "preferences"] + collection -> [collection] + end + + Enum.each(collections, fn collection -> + mongo_count = count_mongo(collection) + pg_count = case collection do + "users" -> Repo.aggregate(User, :count) + "puzzles" -> Repo.aggregate(Puzzle, :count) + "submissions" -> Repo.aggregate(Submission, :count) + _ -> 0 + end + + to_migrate = mongo_count - pg_count + Logger.info(" #{collection}: #{mongo_count} in MongoDB, #{pg_count} in PostgreSQL → would migrate #{max(0, to_migrate)}") + end) + end + + defp print_summary(results) do + Logger.info("\n📊 Migration Summary:") + Logger.info(" " <> String.duplicate("=", 60)) + + Enum.each(results, fn result -> + collection = String.pad_trailing(result.collection, 15) + + if Map.has_key?(result, :note) do + Logger.info(" #{collection} - #{result.note}") + else + migrated = String.pad_leading("#{result.migrated}", 4) + skipped = String.pad_leading("#{result.skipped}", 4) + failed = String.pad_leading("#{result.failed}", 4) + total = Map.get(result, :total, result.migrated + result.skipped + result.failed) + + Logger.info(" #{collection} - #{migrated} migrated, #{skipped} skipped, #{failed} failed (#{total} total)") + end + end) + + Logger.info(" " <> String.duplicate("=", 60)) + end + + # Helper functions + + defp extract_mongo_id(%BSON.ObjectId{} = oid), do: BSON.ObjectId.encode!(oid) |> Base.encode16(case: :lower) + defp extract_mongo_id(id) when is_binary(id), do: id + defp extract_mongo_id(_), do: nil + + defp parse_datetime(%DateTime{} = dt) do + # Ensure microsecond precision for :utc_datetime_usec + %{dt | microsecond: {elem(dt.microsecond, 0), 6}} + end + defp parse_datetime(nil), do: nil + defp parse_datetime(_), do: DateTime.utc_now() + + defp parse_role("admin"), do: "admin" + defp parse_role("moderator"), do: "moderator" + defp parse_role(_), do: "user" + + defp parse_difficulty("BEGINNER"), do: "BEGINNER" + defp parse_difficulty("EASY"), do: "EASY" + defp parse_difficulty("MEDIUM"), do: "INTERMEDIATE" + defp parse_difficulty("INTERMEDIATE"), do: "INTERMEDIATE" + defp parse_difficulty("HARD"), do: "HARD" + defp parse_difficulty("EXPERT"), do: "EXPERT" + defp parse_difficulty(_), do: "INTERMEDIATE" + + defp parse_visibility("APPROVED"), do: "APPROVED" + defp parse_visibility("REVIEW"), do: "REVIEW" + defp parse_visibility("DRAFT"), do: "DRAFT" + defp parse_visibility("REVISE"), do: "REVISE" + defp parse_visibility("INACTIVE"), do: "INACTIVE" + defp parse_visibility(_), do: "DRAFT" + + defp parse_submission_status("success"), do: :accepted + defp parse_submission_status("error"), do: :wrong_answer + defp parse_submission_status(_), do: :pending + + defp calculate_score(%{"result" => "success"}), do: 100.0 + defp calculate_score(%{"result" => "error"}), do: 0.0 + defp calculate_score(_), do: nil + + defp parse_game_visibility("public"), do: "public" + defp parse_game_visibility("private"), do: "private" + defp parse_game_visibility("friends"), do: "friends" + defp parse_game_visibility(_), do: "public" + + defp parse_game_mode("FASTEST"), do: "FASTEST" + defp parse_game_mode("SHORTEST"), do: "SHORTEST" + defp parse_game_mode("BACKWARDS"), do: "BACKWARDS" + defp parse_game_mode("HARDCORE"), do: "HARDCORE" + defp parse_game_mode("DEBUG"), do: "DEBUG" + defp parse_game_mode("TYPERACER"), do: "TYPERACER" + defp parse_game_mode("EFFICIENCY"), do: "EFFICIENCY" + defp parse_game_mode("INCREMENTAL"), do: "INCREMENTAL" + defp parse_game_mode("RANDOM"), do: "RANDOM" + defp parse_game_mode(_), do: "FASTEST" + + defp parse_game_status("waiting"), do: "waiting" + defp parse_game_status("in_progress"), do: "in_progress" + defp parse_game_status("completed"), do: "completed" + defp parse_game_status("cancelled"), do: "cancelled" + defp parse_game_status(_), do: "waiting" + + defp parse_comment_type(nil, nil), do: "puzzle-comment" + defp parse_comment_type(nil, _parent), do: "comment-comment" + defp parse_comment_type("puzzle-comment", _), do: "puzzle-comment" + defp parse_comment_type("comment-comment", _), do: "comment-comment" + defp parse_comment_type(_, nil), do: "puzzle-comment" + defp parse_comment_type(_, _parent), do: "comment-comment" + + defp parse_problem_type("puzzles"), do: "puzzle" + defp parse_problem_type("users"), do: "user" + defp parse_problem_type("comments"), do: "comment" + defp parse_problem_type("game_chat"), do: "game_chat" + defp parse_problem_type(_), do: "puzzle" + + defp parse_report_status("pending"), do: "pending" + defp parse_report_status("resolved"), do: "resolved" + defp parse_report_status("rejected"), do: "rejected" + defp parse_report_status(_), do: "pending" + + defp parse_theme("dark"), do: "dark" + defp parse_theme("light"), do: "light" + defp parse_theme(_), do: nil + + defp get_or_create_language(language_name) do + alias CodincodApi.Languages.ProgrammingLanguage + + case Repo.get_by(ProgrammingLanguage, language: language_name) do + %ProgrammingLanguage{} = lang -> + lang + + nil -> + # Create it if it doesn't exist + attrs = %{ + language: language_name, + version: "unknown", + runtime: "unknown" + } + + case Repo.insert(ProgrammingLanguage.changeset(%ProgrammingLanguage{}, attrs)) do + {:ok, lang} -> lang + {:error, _} -> nil + end + end + rescue + _ -> nil + end + + defp generate_slug(title) do + title + |> String.downcase() + |> String.replace(~r/[^a-z0-9\s-]/, "") + |> String.replace(~r/\s+/, "-") + |> String.slice(0, 100) + end + + defp count_mongo(collection) do + case Mongo.count_documents(:mongo_migration, collection, %{}) do + {:ok, count} -> count + _ -> 0 + end + rescue + _ -> 0 + end + + defp clean_bson_objectids(%BSON.ObjectId{} = oid) do + BSON.ObjectId.encode!(oid) |> Base.encode16(case: :lower) + end + + defp clean_bson_objectids(data) when is_map(data) do + data + |> Enum.map(fn {k, v} -> {k, clean_bson_objectids(v)} end) + |> Enum.into(%{}) + end + + defp clean_bson_objectids(data) when is_list(data) do + Enum.map(data, &clean_bson_objectids/1) + end + + defp clean_bson_objectids(data), do: data +end diff --git a/libs/elixir-backend/codincod_api/lib/mix/tasks/mongo.inspect.ex b/libs/elixir-backend/codincod_api/lib/mix/tasks/mongo.inspect.ex new file mode 100644 index 00000000..f0249eef --- /dev/null +++ b/libs/elixir-backend/codincod_api/lib/mix/tasks/mongo.inspect.ex @@ -0,0 +1,89 @@ +defmodule Mix.Tasks.Mongo.Inspect do + @moduledoc """ + Inspects MongoDB database to show what data is available for migration. + + Usage: + mix mongo.inspect + """ + + use Mix.Task + require Logger + + @shortdoc "Inspect MongoDB database contents" + + def run(_args) do + Mix.Task.run("app.start") + + # MongoDB connection from TypeScript backend env + mongo_uri = System.get_env("MONGO_URI") || "mongodb://codincod-dev:hunter2@localhost:27017" + mongo_db = System.get_env("MONGO_DB_NAME") || "codincod-development" + + Logger.info("Connecting to MongoDB: #{mongo_db}") + Logger.info("URI: #{String.replace(mongo_uri, ~r/:[^:@]+@/, ":***@")}") + + # MongoDB Atlas requires SSL with CA certs + ssl_opts = if String.contains?(mongo_uri, "mongodb+srv://") do + [ + verify: :verify_none # For development - disable cert verification + ] + else + [] + end + + case Mongo.start_link(url: mongo_uri, name: :mongo, database: mongo_db, pool_size: 2, ssl_opts: ssl_opts) do + {:ok, _pid} -> + inspect_database(mongo_db) + # Don't call Mongo.stop - just let it terminate naturally + :ok + {:error, reason} -> + Logger.error("Failed to connect to MongoDB: #{inspect(reason)}") + Logger.error("Make sure MongoDB is running and MONGO_URI is correct") + end + end + + defp inspect_database(database) do + IO.puts("\n" <> IO.ANSI.cyan() <> "=== MongoDB Database: #{database} ===" <> IO.ANSI.reset() <> "\n") + + collections = [ + "users", + "puzzles", + "submissions", + "games", + "programming_languages", + "programmingLanguages", + "comments", + "reports", + "user_metrics", + "usermetrics", + "preferences" + ] + + Enum.each(collections, fn collection -> + count = count_documents(collection) + + if count > 0 do + IO.puts("#{IO.ANSI.green()}✓#{IO.ANSI.reset()} #{String.pad_trailing(collection, 25)} #{IO.ANSI.yellow()}#{count}#{IO.ANSI.reset()} documents") + + # Show sample document + case Mongo.find_one(:mongo, collection, %{}) do + nil -> :ok + doc -> + IO.puts(" Sample keys: #{inspect(Map.keys(doc) |> Enum.take(10))}") + end + else + IO.puts("#{IO.ANSI.red()}✗#{IO.ANSI.reset()} #{String.pad_trailing(collection, 25)} (empty)") + end + end) + + IO.puts("\n") + end + + defp count_documents(collection) do + case Mongo.count_documents(:mongo, collection, %{}) do + {:ok, count} -> count + _ -> 0 + end + rescue + _ -> 0 + end +end diff --git a/libs/elixir-backend/codincod_api/mix.exs b/libs/elixir-backend/codincod_api/mix.exs new file mode 100644 index 00000000..d93264ea --- /dev/null +++ b/libs/elixir-backend/codincod_api/mix.exs @@ -0,0 +1,114 @@ +defmodule CodincodApi.MixProject do + use Mix.Project + + def project do + [ + app: :codincod_api, + version: "0.1.0", + elixir: "~> 1.15", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps(), + listeners: [Phoenix.CodeReloader] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {CodincodApi.Application, []}, + extra_applications: [:logger, :runtime_tools, :os_mon, :crypto] + ] + end + + def cli do + [ + preferred_envs: [precommit: :test] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + # Phoenix Framework + {:phoenix, "~> 1.8.1"}, + {:phoenix_ecto, "~> 4.5"}, + {:ecto_sql, "~> 3.13"}, + {:postgrex, ">= 0.0.0"}, + {:phoenix_live_dashboard, "~> 0.8.3"}, + {:swoosh, "~> 1.16"}, + {:req, "~> 0.5"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:gettext, "~> 0.26"}, + {:jason, "~> 1.2"}, + {:dns_cluster, "~> 0.2.0"}, + {:bandit, "~> 1.5"}, + + # Authentication & Security + {:pbkdf2_elixir, "~> 2.0"}, + {:guardian, "~> 2.3"}, + {:comeonin, "~> 5.4"}, + + # API & Utilities + {:cors_plug, "~> 3.0"}, + {:plug_crypto, "~> 2.1"}, + + # HTTP Client for Piston + {:finch, "~> 0.19"}, + {:tesla, "~> 1.13"}, + + # Background Jobs + {:oban, "~> 2.18"}, + + # Rate Limiting + {:hammer, "~> 6.2"}, + {:hammer_plug, "~> 3.1"}, + + # MongoDB (for migration) + {:mongodb_driver, "~> 1.5"}, + + # OpenAPI generation + {:open_api_spex, "~> 3.18"}, + + # WebSockets/Channels + {:phoenix_pubsub, "~> 2.1"}, + + # Caching + {:cachex, "~> 4.0"}, + + # Development & Testing + {:phoenix_live_reload, "~> 1.5", only: :dev}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:ex_machina, "~> 2.8", only: :test}, + {:faker, "~> 0.18", only: [:dev, :test]}, + {:mix_test_watch, "~> 1.2", only: [:dev, :test], runtime: false} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "ecto.setup"], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"] + ] + end +end diff --git a/libs/elixir-backend/codincod_api/mix.lock b/libs/elixir-backend/codincod_api/mix.lock new file mode 100644 index 00000000..99f0cecd --- /dev/null +++ b/libs/elixir-backend/codincod_api/mix.lock @@ -0,0 +1,67 @@ +%{ + "argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"}, + "bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "cachex": {:hex, :cachex, "4.1.1", "574c5cd28473db313a0a76aac8c945fe44191659538ca6a1e8946ec300b1a19f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "d6b7449ff98d6bb92dda58bd4fc3189cae9f99e7042054d669596f56dc503cd8"}, + "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, + "cors_plug": {:hex, :cors_plug, "3.0.3", "7c3ac52b39624bc616db2e937c282f3f623f25f8d550068b6710e58d04a0e330", [:mix], [{:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3f2d759e8c272ed3835fab2ef11b46bddab8c1ab9528167bd463b6452edf830d"}, + "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, + "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, + "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, + "ecto": {:hex, :ecto, "3.13.4", "27834b45d58075d4a414833d9581e8b7bb18a8d9f264a21e42f653d500dbeeb5", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ad7d1505685dfa7aaf86b133d54f5ad6c42df0b4553741a1ff48796736e88b2"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, + "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, + "ex_hash_ring": {:hex, :ex_hash_ring, "6.0.4", "bef9d2d796afbbe25ab5b5a7ed746e06b99c76604f558113c273466d52fa6d6b", [:mix], [], "hexpm", "89adabf31f7d3dfaa36802ce598ce918e9b5b33bae8909ac1a4d052e1e567d18"}, + "ex_machina": {:hex, :ex_machina, "2.8.0", "a0e847b5712065055ec3255840e2c78ef9366634d62390839d4880483be38abe", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "79fe1a9c64c0c1c1fab6c4fa5d871682cb90de5885320c187d117004627a7729"}, + "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, + "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, + "guardian": {:hex, :guardian, "2.4.0", "efbbb397ecca881bb548560169922fc4433a05bc98c2eb96a7ed88ede9e17d64", [:mix], [{:jose, "~> 1.11.9", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "5c80103a9c538fbc2505bf08421a82e8f815deba9eaedb6e734c66443154c518"}, + "hammer": {:hex, :hammer, "6.2.1", "5ae9c33e3dceaeb42de0db46bf505bd9c35f259c8defb03390cd7556fea67ee2", [:mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "b9476d0c13883d2dc0cc72e786bac6ac28911fba7cc2e04b70ce6a6d9c4b2bdc"}, + "hammer_plug": {:hex, :hammer_plug, "3.2.0", "47db6ed67d5cdf09fb6035f26b0b4b2335c3ae08a7ac061e3303bbb756fe9a09", [:mix], [{:hammer, "~> 6.0", [hex: :hammer, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "1ee7084732414c7a32f467717d13e6fba95c60b70c3f56d51f7c08a4183aadfe"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, + "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mix_test_watch": {:hex, :mix_test_watch, "1.4.0", "d88bcc4fbe3198871266e9d2f00cd8ae350938efbb11d3fa1da091586345adbb", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "2b4693e17c8ead2ef56d4f48a0329891e8c2d0d73752c0f09272a2b17dc38d1b"}, + "mongodb_driver": {:hex, :mongodb_driver, "1.5.6", "7dc920872d3a65821c12aebde2cbf62002961498f3739c04b1f08c0800538dc5", [:mix], [{:db_connection, "~> 2.6", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, ">= 2.1.1 and < 3.0.0-0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ezstd, "~> 1.1", [hex: :ezstd, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fdb83112e8aab60b690e382b7e0d2e9d848bd81a40bcdaf4dfcd14af5d7ab882"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "oban": {:hex, :oban, "2.20.1", "39d0b68787e5cf251541c0d657a698f6142a24d8744e1e40b2cf045d4fa232a6", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17a45277dbeb41a455040b41dd8c467163fad685d1366f2f59207def3bcdd1d8"}, + "open_api_spex": {:hex, :open_api_spex, "3.22.0", "fbf90dc82681dc042a4ee79853c8e989efbba73d9e87439085daf849bbf8bc20", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "dd751ddbdd709bb4a5313e9a24530da6e66594773c7242a0c2592cbd9f589063"}, + "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "2.3.1", "073866b593887365d0ff50bb806d860a50f454bcda49b5b6f4658c9173c53889", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "ab4da7db8aeb2db20e02a1d416cbb46d0690658aafb4396878acef8748c9c319"}, + "phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.16", "e42f95337b912a73a1c4ddb077af2eb13491712d7ab79b67e13de4237dfcac50", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f2a0093895b8ef4880af76d41de4a9cf7cff6c66ad130e15a70bdabc4d279feb"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, + "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, + "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, + "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, + "swoosh": {:hex, :swoosh, "1.19.8", "0576f2ea96d1bb3a6e02cc9f79cbd7d497babc49a353eef8dce1a1f9f82d7915", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d7503c2daf0f9899afd8eba9923eeddef4b62e70816e1d3b6766e4d6c60e94ad"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "tesla": {:hex, :tesla, "1.15.3", "3a2b5c37f09629b8dcf5d028fbafc9143c0099753559d7fe567eaabfbd9b8663", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "98bb3d4558abc67b92fb7be4cd31bb57ca8d80792de26870d362974b58caeda7"}, + "thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, +} diff --git a/libs/elixir-backend/codincod_api/openapi.json b/libs/elixir-backend/codincod_api/openapi.json new file mode 100644 index 00000000..a5a69d5b --- /dev/null +++ b/libs/elixir-backend/codincod_api/openapi.json @@ -0,0 +1,12272 @@ +{ + "components": { + "responses": {}, + "schemas": { + "UserAvailabilityResponse": { + "properties": { + "available": { + "type": "boolean", + "x-struct": null, + "x-validate": null + } + }, + "title": "AvailabilityResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.AvailabilityResponse", + "x-validate": null + }, + "ProfileUpdateResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + } + }, + "title": "ProfileUpdateResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Account.ProfileUpdateResponse", + "x-validate": null + }, + "RequestPayload": { + "properties": { + "email": { + "format": "email", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "email" + ], + "title": "RequestPayload", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.PasswordReset.RequestPayload", + "x-validate": null + }, + "CommentCreateRequest": { + "properties": { + "replyOn": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "text": { + "maxLength": 320, + "minLength": 1, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "text" + ], + "title": "CreateRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Comment.CreateRequest", + "x-validate": null + }, + "ReviewDecisionRequest": { + "properties": { + "reviewerNotes": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "status": { + "enum": [ + "approved", + "rejected" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "status" + ], + "title": "ReviewDecisionRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.ReviewDecisionRequest", + "x-validate": null + }, + "RegisterRequest": { + "properties": { + "email": { + "format": "email", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "password": { + "format": "password", + "minLength": 14, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "passwordConfirmation": { + "format": "password", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "maxLength": 20, + "minLength": 3, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "username", + "email", + "password" + ], + "title": "RegisterRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Auth.RegisterRequest", + "x-validate": null + }, + "AccountPreferences": { + "properties": { + "blockedUsers": { + "items": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "editor": { + "type": "object", + "x-struct": null, + "x-validate": null + }, + "preferredLanguage": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "theme": { + "enum": [ + "dark", + "light" + ], + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PreferencesPayload", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Account.PreferencesPayload", + "x-validate": null + }, + "PuzzleResponse": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "author": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Author", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Author", + "x-validate": null + }, + "comments": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "constraints": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyMetricsId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "moderationFeedback": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleMetrics": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "solution": { + "properties": { + "code": { + "default": "", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Solution", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Solution", + "x-validate": null + }, + "statement": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "tags": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "validators": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "input": { + "description": "Validator input payload", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isPublic": { + "default": false, + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "output": { + "description": "Expected validator output", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Validator", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Validator", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "visibility": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.PuzzleResponse", + "x-validate": null + }, + "PuzzleCreateRequest": { + "properties": { + "constraints": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "description": { + "minLength": 1, + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "difficulty": { + "enum": [ + "easy", + "medium", + "hard", + "beginner", + "intermediate", + "advanced", + "expert" + ], + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "tags": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "title": { + "maxLength": 128, + "minLength": 4, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "validators": { + "items": { + "properties": { + "input": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isPublic": { + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "output": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "input", + "output" + ], + "type": "object", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "title" + ], + "title": "PuzzleCreateRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.PuzzleCreateRequest", + "x-validate": null + }, + "ReportResponse": { + "properties": { + "contentId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "contentType": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "description": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "problemType": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reportedBy": { + "nullable": true, + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "resolutionNotes": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "resolvedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "resolvedBy": { + "nullable": true, + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "status": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ReportResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.ReportResponse", + "x-validate": null + }, + "SubmitCodeRequest": { + "properties": { + "code": { + "minLength": 1, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguageId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "puzzleId", + "programmingLanguageId", + "code", + "userId" + ], + "title": "SubmitCodeRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmitCodeRequest", + "x-validate": null + }, + "AvailabilityResponse": { + "properties": { + "available": { + "type": "boolean", + "x-struct": null, + "x-validate": null + } + }, + "title": "AvailabilityResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.AvailabilityResponse", + "x-validate": null + }, + "AuthMessageResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "MessageResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Auth.MessageResponse", + "x-validate": null + }, + "SubmissionListResponse": { + "items": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "code": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyGameSubmissionId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "language": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "runtime": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "version": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ProgrammingLanguageSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.ProgrammingLanguageSummary", + "x-validate": null + }, + "puzzle": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.PuzzleSummary", + "x-validate": null + }, + "result": { + "additionalProperties": true, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "score": { + "nullable": true, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "user": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + } + }, + "title": "SubmissionResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmissionResponse", + "x-validate": null + }, + "title": "SubmissionListResponse", + "type": "array", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmissionListResponse", + "x-validate": null + }, + "PasswordResetCompleteResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ResetResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.PasswordReset.ResetResponse", + "x-validate": null + }, + "AccountProfileUpdateRequest": { + "properties": { + "bio": { + "maxLength": 500, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "maxLength": 100, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "format": "uri", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "format": "uri", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "maxItems": 5, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "ProfileUpdateRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Account.ProfileUpdateRequest", + "x-validate": null + }, + "Profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "UserShowResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "user": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + } + }, + "title": "ShowResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.ShowResponse", + "x-validate": null + }, + "LoginRequest": { + "properties": { + "identifier": { + "description": "Username or email", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "password": { + "format": "password", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "identifier", + "password" + ], + "title": "LoginRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Auth.LoginRequest", + "x-validate": null + }, + "SubmissionSubmitResponse": { + "properties": { + "code": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "codeLength": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguageId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "result": { + "properties": { + "failed": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "passed": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "successRate": { + "maximum": 1, + "minimum": 0, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "total": { + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "successRate", + "passed", + "failed", + "total" + ], + "type": "object", + "x-struct": null, + "x-validate": null + }, + "submissionId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "submissionId", + "code", + "puzzleId", + "programmingLanguageId", + "userId", + "codeLength", + "result", + "createdAt" + ], + "title": "SubmitCodeResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmitCodeResponse", + "x-validate": null + }, + "BanResponse": { + "properties": { + "banned": { + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "bannedUntil": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reason": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "BanResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.BanResponse", + "x-validate": null + }, + "VoteRequest": { + "properties": { + "type": { + "enum": [ + "upvote", + "downvote" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "type" + ], + "title": "VoteRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Comment.VoteRequest", + "x-validate": null + }, + "ShowResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "user": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + } + }, + "title": "ShowResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.ShowResponse", + "x-validate": null + }, + "UserStatsResponse": { + "properties": { + "acceptanceRate": { + "type": "number", + "x-struct": null, + "x-validate": null + }, + "acceptedSubmissions": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "difficultyBreakdown": { + "properties": { + "easy": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "expert": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "hard": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "medium": { + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "languageUsage": { + "items": { + "properties": { + "count": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "language": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "puzzlesSolved": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "recentActivity": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "runtimeErrors": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "timeLimitExceeded": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalSubmissions": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "wrongAnswerSubmissions": { + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "UserStatsResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Metrics.UserStatsResponse", + "x-validate": null + }, + "ProfileUpdateRequest": { + "properties": { + "bio": { + "maxLength": 500, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "maxLength": 100, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "format": "uri", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "format": "uri", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "maxItems": 5, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "ProfileUpdateRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Account.ProfileUpdateRequest", + "x-validate": null + }, + "UserRankResponse": { + "properties": { + "puzzlesSolved": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "rank": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "rating": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalSubmissions": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "UserRankResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Leaderboard.UserRankResponse", + "x-validate": null + }, + "WaitingRoomsResponse": { + "properties": { + "count": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "rooms": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "finishedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameMode": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "maxPlayers": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "owner": { + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "players": { + "items": { + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "joinedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "puzzle": { + "properties": { + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "startedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "status": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "timeLimit": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "GameResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Games.GameResponse", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "WaitingRoomsResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Games.WaitingRoomsResponse", + "x-validate": null + }, + "GlobalLeaderboardResponse": { + "properties": { + "cachedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameMode": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "limit": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "offset": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "rankings": { + "items": { + "properties": { + "averageScore": { + "type": "number", + "x-struct": null, + "x-validate": null + }, + "bestScore": { + "type": "number", + "x-struct": null, + "x-validate": null + }, + "gamesPlayed": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "gamesWon": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "glicko": { + "properties": { + "rd": { + "description": "Rating deviation", + "type": "number", + "x-struct": null, + "x-validate": null + }, + "vol": { + "description": "Volatility", + "type": "number", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "puzzlesSolved": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "rank": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "rating": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalSubmissions": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "winRate": { + "format": "float", + "type": "number", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "totalEntries": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalPages": { + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "GlobalLeaderboardResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Leaderboard.GlobalLeaderboardResponse", + "x-validate": null + }, + "UserSummary": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + }, + "Solution": { + "properties": { + "code": { + "default": "", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Solution", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Solution", + "x-validate": null + }, + "CommentVoteRequest": { + "properties": { + "type": { + "enum": [ + "upvote", + "downvote" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "type" + ], + "title": "VoteRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Comment.VoteRequest", + "x-validate": null + }, + "GameSubmitCodeRequest": { + "description": "Request to link a submission to a game. This is the correct type for game submissions (not to be confused with SubmitCodeRequest for direct code submission)", + "properties": { + "submissionId": { + "description": "The ID of the submission to link to the game", + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "submissionId" + ], + "title": "GameSubmitCodeRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Games.GameSubmitCodeRequest", + "x-validate": null + }, + "PuzzlePaginatedListResponse": { + "properties": { + "items": { + "items": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "author": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Author", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Author", + "x-validate": null + }, + "comments": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "constraints": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyMetricsId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "moderationFeedback": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleMetrics": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "solution": { + "properties": { + "code": { + "default": "", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Solution", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Solution", + "x-validate": null + }, + "statement": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "tags": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "validators": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "input": { + "description": "Validator input payload", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isPublic": { + "default": false, + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "output": { + "description": "Expected validator output", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Validator", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Validator", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "visibility": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.PuzzleResponse", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "page": { + "default": 1, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "pageSize": { + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalItems": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalPages": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "PaginatedListResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.PaginatedListResponse", + "x-validate": null + }, + "Validator": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "input": { + "description": "Validator input payload", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isPublic": { + "default": false, + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "output": { + "description": "Expected validator output", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Validator", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Validator", + "x-validate": null + }, + "SubmitCodeResponse": { + "properties": { + "code": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "codeLength": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguageId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "result": { + "properties": { + "failed": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "passed": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "successRate": { + "maximum": 1, + "minimum": 0, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "total": { + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "successRate", + "passed", + "failed", + "total" + ], + "type": "object", + "x-struct": null, + "x-validate": null + }, + "submissionId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "submissionId", + "code", + "puzzleId", + "programmingLanguageId", + "userId", + "codeLength", + "result", + "createdAt" + ], + "title": "SubmitCodeResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmitCodeResponse", + "x-validate": null + }, + "ReportsListResponse": { + "properties": { + "count": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "reports": { + "items": { + "$ref": "#/components/schemas/ReportResponse" + }, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "ReportsListResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.ReportsListResponse", + "x-validate": null + }, + "PreferencesPayload": { + "properties": { + "blockedUsers": { + "items": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "editor": { + "type": "object", + "x-struct": null, + "x-validate": null + }, + "preferredLanguage": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "theme": { + "enum": [ + "dark", + "light" + ], + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PreferencesPayload", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Account.PreferencesPayload", + "x-validate": null + }, + "RequestResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "RequestResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.PasswordReset.RequestResponse", + "x-validate": null + }, + "PuzzleLeaderboardResponse": { + "properties": { + "limit": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "rankings": { + "items": { + "properties": { + "executionTime": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "memoryUsed": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "rank": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "submittedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleLeaderboardResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Leaderboard.PuzzleLeaderboardResponse", + "x-validate": null + }, + "ReviewsListResponse": { + "properties": { + "count": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "reviews": { + "items": { + "$ref": "#/components/schemas/ReviewResponse" + }, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "ReviewsListResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.ReviewsListResponse", + "x-validate": null + }, + "ActivityResponse": { + "properties": { + "activity": { + "properties": { + "puzzles": { + "items": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "author": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Author", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Author", + "x-validate": null + }, + "comments": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "constraints": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyMetricsId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "moderationFeedback": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleMetrics": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "solution": { + "properties": { + "code": { + "default": "", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Solution", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Solution", + "x-validate": null + }, + "statement": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "tags": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "validators": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "input": { + "description": "Validator input payload", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isPublic": { + "default": false, + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "output": { + "description": "Expected validator output", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Validator", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Validator", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "visibility": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.PuzzleResponse", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "submissions": { + "items": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "code": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyGameSubmissionId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "language": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "runtime": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "version": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ProgrammingLanguageSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.ProgrammingLanguageSummary", + "x-validate": null + }, + "puzzle": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.PuzzleSummary", + "x-validate": null + }, + "result": { + "additionalProperties": true, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "score": { + "nullable": true, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "user": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + } + }, + "title": "SubmissionResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmissionResponse", + "x-validate": null + }, + "title": "SubmissionListResponse", + "type": "array", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmissionListResponse", + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "user": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + } + }, + "title": "ActivityResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.ActivityResponse", + "x-validate": null + }, + "PaginatedListResponse": { + "properties": { + "items": { + "items": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "author": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Author", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Author", + "x-validate": null + }, + "comments": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "constraints": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyMetricsId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "moderationFeedback": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleMetrics": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "solution": { + "properties": { + "code": { + "default": "", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Solution", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Solution", + "x-validate": null + }, + "statement": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "tags": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "validators": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "input": { + "description": "Validator input payload", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isPublic": { + "default": false, + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "output": { + "description": "Expected validator output", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Validator", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Validator", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "visibility": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.PuzzleResponse", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "page": { + "default": 1, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "pageSize": { + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalItems": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalPages": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "PaginatedListResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.PaginatedListResponse", + "x-validate": null + }, + "PuzzleStatsResponse": { + "properties": { + "acceptanceRate": { + "type": "number", + "x-struct": null, + "x-validate": null + }, + "acceptedSubmissions": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "averageExecutionTime": { + "nullable": true, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "languageDistribution": { + "items": { + "properties": { + "count": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "language": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "statusBreakdown": { + "properties": { + "accepted": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "runtimeError": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "timeLimitExceeded": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "wrongAnswer": { + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "totalSubmissions": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "uniqueSolvers": { + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleStatsResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Metrics.PuzzleStatsResponse", + "x-validate": null + }, + "ResolveReportRequest": { + "properties": { + "resolutionNotes": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "status": { + "enum": [ + "resolved", + "dismissed" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "status" + ], + "title": "ResolveReportRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.ResolveReportRequest", + "x-validate": null + }, + "PuzzleSummary": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.PuzzleSummary", + "x-validate": null + }, + "ResetPayload": { + "properties": { + "password": { + "minLength": 8, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "token": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "token", + "password" + ], + "title": "ResetPayload", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.PasswordReset.ResetPayload", + "x-validate": null + }, + "Summary": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + }, + "ResetResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ResetResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.PasswordReset.ResetResponse", + "x-validate": null + }, + "PasswordResetResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "RequestResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.PasswordReset.RequestResponse", + "x-validate": null + }, + "ReviewResponse": { + "properties": { + "authorName": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "contextMessages": { + "items": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "timestamp": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "description": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reportExplanation": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reportedBy": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reportedMessageId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reportedUserId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reportedUserName": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reviewedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reviewer": { + "nullable": true, + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "reviewerNotes": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "status": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ReviewResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.ReviewResponse", + "x-validate": null + }, + "ExecuteResponse": { + "properties": { + "compile": { + "additionalProperties": true, + "nullable": true, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "puzzleResultInformation": { + "properties": { + "failed": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "passed": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "result": { + "enum": [ + "SUCCESS", + "ERROR" + ], + "type": "string", + "x-struct": null, + "x-validate": null + }, + "successRate": { + "maximum": 1, + "minimum": 0, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "total": { + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleResultInformation", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Execute.PuzzleResultInformation", + "x-validate": null + }, + "run": { + "additionalProperties": true, + "type": "object", + "x-struct": null, + "x-validate": null + } + }, + "title": "ExecuteResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Execute.ExecuteResponse", + "x-validate": null + }, + "SubmissionSubmitRequest": { + "properties": { + "code": { + "minLength": 1, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguageId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "puzzleId", + "programmingLanguageId", + "code", + "userId" + ], + "title": "SubmitCodeRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmitCodeRequest", + "x-validate": null + }, + "UserActivityResponse": { + "properties": { + "activity": { + "properties": { + "puzzles": { + "items": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "author": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Author", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Author", + "x-validate": null + }, + "comments": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "constraints": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyMetricsId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "moderationFeedback": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleMetrics": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "solution": { + "properties": { + "code": { + "default": "", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Solution", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Solution", + "x-validate": null + }, + "statement": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "tags": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "validators": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "input": { + "description": "Validator input payload", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isPublic": { + "default": false, + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "output": { + "description": "Expected validator output", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Validator", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Validator", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "visibility": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.PuzzleResponse", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "submissions": { + "items": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "code": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyGameSubmissionId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "language": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "runtime": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "version": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ProgrammingLanguageSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.ProgrammingLanguageSummary", + "x-validate": null + }, + "puzzle": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.PuzzleSummary", + "x-validate": null + }, + "result": { + "additionalProperties": true, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "score": { + "nullable": true, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "user": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + } + }, + "title": "SubmissionResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmissionResponse", + "x-validate": null + }, + "title": "SubmissionListResponse", + "type": "array", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmissionListResponse", + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "user": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + } + }, + "title": "ActivityResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.ActivityResponse", + "x-validate": null + }, + "SubmissionResponse": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "code": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyGameSubmissionId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "language": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "runtime": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "version": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ProgrammingLanguageSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.ProgrammingLanguageSummary", + "x-validate": null + }, + "puzzle": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.PuzzleSummary", + "x-validate": null + }, + "result": { + "additionalProperties": true, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "score": { + "nullable": true, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "user": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + } + }, + "title": "SubmissionResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmissionResponse", + "x-validate": null + }, + "ProgrammingLanguageSummary": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "language": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "runtime": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "version": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ProgrammingLanguageSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.ProgrammingLanguageSummary", + "x-validate": null + }, + "GameResponse": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "finishedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameMode": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "maxPlayers": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "owner": { + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "players": { + "items": { + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "joinedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "puzzle": { + "properties": { + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "startedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "status": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "timeLimit": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "GameResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Games.GameResponse", + "x-validate": null + }, + "PlatformMetricsResponse": { + "properties": { + "acceptedSubmissions": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "activeUsers": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "popularPuzzles": { + "items": { + "properties": { + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "submissionCount": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "totalPuzzles": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalSubmissions": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalUsers": { + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "PlatformMetricsResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Metrics.PlatformMetricsResponse", + "x-validate": null + }, + "PasswordResetPayload": { + "properties": { + "password": { + "minLength": 8, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "token": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "token", + "password" + ], + "title": "ResetPayload", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.PasswordReset.ResetPayload", + "x-validate": null + }, + "PasswordResetRequest": { + "properties": { + "email": { + "format": "email", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "email" + ], + "title": "RequestPayload", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.PasswordReset.RequestPayload", + "x-validate": null + }, + "CommentResponse": { + "properties": { + "author": { + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Author", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Comment.Author", + "x-validate": null + }, + "authorId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "body": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "commentType": { + "enum": [ + "puzzle-comment", + "comment-comment", + "submission-comment" + ], + "type": "string", + "x-struct": null, + "x-validate": null + }, + "downvote": { + "default": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "insertedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "parentCommentId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "upvote": { + "default": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "id", + "body", + "commentType", + "authorId" + ], + "title": "CommentResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Comment.CommentResponse", + "x-validate": null + }, + "ErrorResponse": { + "properties": { + "error": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "errors": { + "type": "object", + "x-struct": null, + "x-validate": null + }, + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ErrorResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Common.ErrorResponse", + "x-validate": null + }, + "AccountStatusResponse": { + "properties": { + "isAuthenticated": { + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "isAuthenticated" + ], + "title": "AccountStatusResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Account.StatusResponse", + "x-validate": null + }, + "AccountProfileUpdateResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + } + }, + "title": "ProfileUpdateResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Account.ProfileUpdateResponse", + "x-validate": null + }, + "LeaveGameResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "LeaveGameResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Games.LeaveGameResponse", + "x-validate": null + }, + "ExecuteRequest": { + "properties": { + "code": { + "minLength": 1, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "language": { + "minLength": 1, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "testInput": { + "default": "", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "testOutput": { + "default": "", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "code", + "language" + ], + "title": "ExecuteRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Execute.ExecuteRequest", + "x-validate": null + }, + "MessageResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "MessageResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Auth.MessageResponse", + "x-validate": null + }, + "Author": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Author", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Author", + "x-validate": null + }, + "UserGamesResponse": { + "properties": { + "count": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "games": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "finishedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameMode": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "maxPlayers": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "owner": { + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "players": { + "items": { + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "joinedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "puzzle": { + "properties": { + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "startedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "status": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "timeLimit": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "GameResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Games.GameResponse", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "UserGamesResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Games.UserGamesResponse", + "x-validate": null + }, + "PuzzleResultInformation": { + "properties": { + "failed": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "passed": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "result": { + "enum": [ + "SUCCESS", + "ERROR" + ], + "type": "string", + "x-struct": null, + "x-validate": null + }, + "successRate": { + "maximum": 1, + "minimum": 0, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "total": { + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleResultInformation", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Execute.PuzzleResultInformation", + "x-validate": null + }, + "CreateRequest": { + "properties": { + "replyOn": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "text": { + "maxLength": 320, + "minLength": 1, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "text" + ], + "title": "CreateRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Comment.CreateRequest", + "x-validate": null + }, + "BanUserRequest": { + "properties": { + "bannedUntil": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "durationDays": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "reason": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "BanUserRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.BanUserRequest", + "x-validate": null + }, + "CreateReportRequest": { + "properties": { + "contentId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "contentType": { + "enum": [ + "puzzle", + "comment", + "submission", + "user" + ], + "type": "string", + "x-struct": null, + "x-validate": null + }, + "description": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "problemType": { + "enum": [ + "spam", + "inappropriate", + "copyright", + "harassment", + "other" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "contentType", + "contentId", + "problemType" + ], + "title": "CreateReportRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.CreateReportRequest", + "x-validate": null + }, + "CreateGameRequest": { + "properties": { + "gameMode": { + "default": "standard", + "enum": [ + "standard", + "timed", + "ranked" + ], + "type": "string", + "x-struct": null, + "x-validate": null + }, + "maxPlayers": { + "default": 2, + "maximum": 10, + "minimum": 2, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "timeLimit": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "puzzleId" + ], + "title": "CreateGameRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Games.CreateGameRequest", + "x-validate": null + } + } + }, + "info": { + "description": "Phoenix implementation of the CodinCod backend", + "title": "CodinCod API", + "version": "0.1.0" + }, + "openapi": "3.0.0", + "paths": { + "/api/v1/games/waiting": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.list_waiting_rooms (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WaitingRoomsResponse" + } + } + }, + "description": "Waiting rooms" + } + }, + "summary": "List all waiting game lobbies", + "tags": [ + "Games" + ] + } + }, + "/api/login": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AuthController.login (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "description": "Credentials", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + }, + "description": "Login success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Authenticate user", + "tags": [ + "Auth" + ] + } + }, + "/api/execute": { + "post": { + "callbacks": {}, + "description": "Runs code against Piston with custom test input/output for validation", + "operationId": "CodincodApiWeb.ExecuteController.create (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteRequest" + } + } + }, + "description": "Execute request", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteResponse" + } + } + }, + "description": "Execution result" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service unavailable" + } + }, + "summary": "Execute code without saving", + "tags": [ + "Execute" + ] + } + }, + "/api/v1/games/{id}/submit": { + "post": { + "callbacks": {}, + "description": "Links an existing submission to a game, marking it as a player's game submission.", + "operationId": "CodincodApiWeb.GameController.submit_code (2)", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameSubmitCodeRequest" + } + } + }, + "description": "Game submission", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitCodeResponse" + } + } + }, + "description": "Submission linked to game" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not a game participant" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game or submission not found" + } + }, + "summary": "Submit code for a game", + "tags": [ + "Games" + ] + } + }, + "/api/submission/{id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.SubmissionController.show (2)", + "parameters": [ + { + "description": "Submission identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmissionResponse" + } + } + }, + "description": "Submission" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid id" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not found" + } + }, + "summary": "Fetch submission by id", + "tags": [ + "Submission" + ] + } + }, + "/api/v1/moderation/reviews": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.list_reviews (2)", + "parameters": [ + { + "description": "Filter by status", + "in": "query", + "name": "status", + "required": false, + "schema": { + "enum": [ + "pending", + "approved", + "rejected" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewsListResponse" + } + } + }, + "description": "Reviews list" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + } + }, + "summary": "List pending moderation reviews (moderator only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/games/{id}/start": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.start (2)", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameResponse" + } + } + }, + "description": "Game started" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not game host" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game not found" + } + }, + "summary": "Start a game (host only)", + "tags": [ + "Games" + ] + } + }, + "/api/register": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AuthController.register (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + } + } + }, + "description": "Registration payload", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + }, + "description": "Registration success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Register new user", + "tags": [ + "Auth" + ] + } + }, + "/api/user/{username}/activity": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.UserController.activity (2)", + "parameters": [ + { + "description": "Username to inspect", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityResponse" + } + } + }, + "description": "Activity" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid username" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Get user activity (puzzles and submissions)", + "tags": [ + "User" + ] + } + }, + "/api/moderation/user/{user_id}/unban": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.unban_user (2)", + "parameters": [ + { + "description": "User identifier", + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BanResponse" + } + } + }, + "description": "User unbanned" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User not found" + } + }, + "summary": "Unban a user (admin only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/moderation/reports": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.list_reports (2)", + "parameters": [ + { + "description": "Filter by status", + "in": "query", + "name": "status", + "required": false, + "schema": { + "enum": [ + "pending", + "reviewing", + "resolved", + "dismissed" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Filter by problem type", + "in": "query", + "name": "problem_type", + "required": false, + "schema": { + "enum": [ + "spam", + "inappropriate", + "copyright", + "harassment", + "other" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportsListResponse" + } + } + }, + "description": "Reports list" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + } + }, + "summary": "List reports (admin only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/puzzles": { + "get": { + "callbacks": {}, + "description": "Returns paginated puzzles matching the legacy Fastify `/puzzle` listing response.", + "operationId": "CodincodApiWeb.PuzzleController.index (2)", + "parameters": [ + { + "description": "Page number", + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Number of puzzles per page", + "in": "query", + "name": "pageSize", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedListResponse" + } + } + }, + "description": "Paginated puzzles" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid query" + } + }, + "summary": "List puzzles", + "tags": [ + "Puzzle" + ] + }, + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.PuzzleController.create (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleCreateRequest" + } + } + }, + "description": "Puzzle creation payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleResponse" + } + } + }, + "description": "Puzzle created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable entity" + } + }, + "summary": "Create puzzle", + "tags": [ + "Puzzle" + ] + } + }, + "/api/v1/refresh": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AuthController.refresh (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + }, + "description": "Token refreshed" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Refresh authentication token", + "tags": [ + "Auth" + ] + } + }, + "/api/v1/password-reset/request": { + "post": { + "callbacks": {}, + "description": "Sends password reset email if user exists", + "operationId": "CodincodApiWeb.PasswordResetController.request_reset (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestPayload" + } + } + }, + "description": "Reset request", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestResponse" + } + } + }, + "description": "Reset email sent" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + } + }, + "summary": "Request password reset", + "tags": [ + "Password Reset" + ] + } + }, + "/api/leaderboard/puzzle/{puzzle_id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.LeaderboardController.puzzle (2)", + "parameters": [ + { + "description": "Puzzle identifier", + "in": "path", + "name": "puzzle_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Number of entries to return (1-100)", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleLeaderboardResponse" + } + } + }, + "description": "Puzzle leaderboard" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Get puzzle-specific leaderboard", + "tags": [ + "Leaderboard" + ] + } + }, + "/api/account/preferences": { + "delete": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountPreferenceController.delete (2)", + "parameters": [], + "responses": { + "204": { + "content": { + "application/json": {} + }, + "description": "Preferences deleted" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Delete preferences", + "tags": [ + "Account Preferences" + ] + }, + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountPreferenceController.show (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Preferences" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Get account preferences", + "tags": [ + "Account Preferences" + ] + }, + "patch": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountPreferenceController.patch (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Partial preferences", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Updated preferences" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Patch preferences", + "tags": [ + "Account Preferences" + ] + }, + "put": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountPreferenceController.replace (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Preferences payload", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Updated preferences" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Replace preferences", + "tags": [ + "Account Preferences" + ] + } + }, + "/api/metrics/puzzle/{puzzle_id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.MetricsController.puzzle_stats (2)", + "parameters": [ + { + "description": "Puzzle identifier", + "in": "path", + "name": "puzzle_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleStatsResponse" + } + } + }, + "description": "Puzzle statistics" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Get detailed statistics for a puzzle", + "tags": [ + "Metrics" + ] + } + }, + "/api/games/{id}/join": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.join (2)", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameResponse" + } + } + }, + "description": "Joined game" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game full or already started" + } + }, + "summary": "Join a game lobby", + "tags": [ + "Games" + ] + } + }, + "/api/v1/games/{id}/join": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.join", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameResponse" + } + } + }, + "description": "Joined game" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game full or already started" + } + }, + "summary": "Join a game lobby", + "tags": [ + "Games" + ] + } + }, + "/api/v1/moderation/user/{user_id}/ban": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.ban_user (2)", + "parameters": [ + { + "description": "User identifier", + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BanUserRequest" + } + } + }, + "description": "Ban details", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BanResponse" + } + } + }, + "description": "User banned" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User not found" + } + }, + "summary": "Ban a user (admin only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/puzzle/{id}/solution": { + "get": { + "callbacks": {}, + "description": "Returns puzzle with full solution details. Only available to puzzle author or admins.", + "operationId": "CodincodApiWeb.PuzzleController.solution (2)", + "parameters": [ + { + "description": "Puzzle UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleResponse" + } + } + }, + "description": "Puzzle solution" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Get puzzle solution for editing", + "tags": [ + "Puzzle" + ] + } + }, + "/api/password-reset/request": { + "post": { + "callbacks": {}, + "description": "Sends password reset email if user exists", + "operationId": "CodincodApiWeb.PasswordResetController.request_reset", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestPayload" + } + } + }, + "description": "Reset request", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestResponse" + } + } + }, + "description": "Reset email sent" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + } + }, + "summary": "Request password reset", + "tags": [ + "Password Reset" + ] + } + }, + "/api/v1/puzzle/{id}": { + "delete": { + "callbacks": {}, + "description": "Deletes a puzzle. Only available to puzzle author or admins.", + "operationId": "CodincodApiWeb.PuzzleController.delete (2)", + "parameters": [ + { + "description": "Puzzle UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "204": { + "description": "Puzzle deleted" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Delete puzzle", + "tags": [ + "Puzzle" + ] + }, + "get": { + "callbacks": {}, + "description": "Returns a single puzzle by ID (public view, no solution details).", + "operationId": "CodincodApiWeb.PuzzleController.show (2)", + "parameters": [ + { + "description": "Puzzle UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleResponse" + } + } + }, + "description": "Puzzle found" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Get puzzle by ID", + "tags": [ + "Puzzle" + ] + }, + "patch": { + "callbacks": {}, + "description": "Updates an existing puzzle. Only available to puzzle author or admins.", + "operationId": "CodincodApiWeb.PuzzleController.update (2)", + "parameters": [ + { + "description": "Puzzle UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleCreateRequest" + } + } + }, + "description": "Puzzle update payload", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleResponse" + } + } + }, + "description": "Puzzle updated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable entity" + } + }, + "summary": "Update puzzle", + "tags": [ + "Puzzle" + ] + } + }, + "/api/v1/moderation/report/{id}/resolve": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.resolve_report (2)", + "parameters": [ + { + "description": "Report identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveReportRequest" + } + } + }, + "description": "Resolution payload", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportResponse" + } + } + }, + "description": "Report resolved" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Report not found" + } + }, + "summary": "Resolve a report (admin only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/execute": { + "post": { + "callbacks": {}, + "description": "Runs code against Piston with custom test input/output for validation", + "operationId": "CodincodApiWeb.ExecuteController.create", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteRequest" + } + } + }, + "description": "Execute request", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteResponse" + } + } + }, + "description": "Execution result" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service unavailable" + } + }, + "summary": "Execute code without saving", + "tags": [ + "Execute" + ] + } + }, + "/api/user/{username}/puzzle": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.UserController.puzzles (2)", + "parameters": [ + { + "description": "Username whose puzzles will be listed", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "", + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "", + "in": "query", + "name": "pageSize", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedListResponse" + } + } + }, + "description": "Paginated puzzles" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid parameters" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not found" + } + }, + "summary": "List puzzles authored by a user", + "tags": [ + "User" + ] + } + }, + "/api/password-reset/reset": { + "post": { + "callbacks": {}, + "description": "Validates token and updates user password", + "operationId": "CodincodApiWeb.PasswordResetController.reset_password (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPayload" + } + } + }, + "description": "Reset payload", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetResponse" + } + } + }, + "description": "Password reset" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload or token" + } + }, + "summary": "Reset password with token", + "tags": [ + "Password Reset" + ] + } + }, + "/api/user/{username}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.UserController.show (2)", + "parameters": [ + { + "description": "Username to look up", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShowResponse" + } + } + }, + "description": "User" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid username" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not found" + } + }, + "summary": "Get user by username", + "tags": [ + "User" + ] + } + }, + "/api/account/leaderboard": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountController.leaderboard_rank (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserRankResponse" + } + } + }, + "description": "User ranking" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Get current user's leaderboard ranking", + "tags": [ + "Account" + ] + } + }, + "/api/logout": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AuthController.logout (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + }, + "description": "Logout success" + } + }, + "summary": "Logout current user", + "tags": [ + "Auth" + ] + } + }, + "/api/account": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountController.show (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountStatusResponse" + } + } + }, + "description": "Authenticated account" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountStatusResponse" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Current account status", + "tags": [ + "Account" + ] + } + }, + "/api/v1/submission/{id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.SubmissionController.show", + "parameters": [ + { + "description": "Submission identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmissionResponse" + } + } + }, + "description": "Submission" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid id" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not found" + } + }, + "summary": "Fetch submission by id", + "tags": [ + "Submission" + ] + } + }, + "/api/puzzle/{id}/comment": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.PuzzleCommentController.create (2)", + "parameters": [ + { + "description": "Puzzle ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRequest" + } + } + }, + "description": "Comment creation payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentResponse" + } + } + }, + "description": "Comment created successfully" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle or parent comment not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Cannot reply to deleted comment or invalid parent" + } + }, + "summary": "Create a comment on a puzzle", + "tags": [] + } + }, + "/api/v1/register": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AuthController.register", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + } + } + }, + "description": "Registration payload", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + }, + "description": "Registration success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Register new user", + "tags": [ + "Auth" + ] + } + }, + "/api/v1/user/{username}/activity": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.UserController.activity", + "parameters": [ + { + "description": "Username to inspect", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityResponse" + } + } + }, + "description": "Activity" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid username" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Get user activity (puzzles and submissions)", + "tags": [ + "User" + ] + } + }, + "/api/leaderboard/global": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.LeaderboardController.global (2)", + "parameters": [ + { + "description": "Game mode filter", + "in": "query", + "name": "game_mode", + "required": false, + "schema": { + "enum": [ + "standard", + "timed", + "ranked" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Number of entries to return (1-100)", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Pagination offset", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GlobalLeaderboardResponse" + } + } + }, + "description": "Leaderboard rankings" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + } + }, + "summary": "Get global leaderboard rankings", + "tags": [ + "Leaderboard" + ] + } + }, + "/api/v1/password-reset/reset": { + "post": { + "callbacks": {}, + "description": "Validates token and updates user password", + "operationId": "CodincodApiWeb.PasswordResetController.reset_password", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPayload" + } + } + }, + "description": "Reset payload", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetResponse" + } + } + }, + "description": "Password reset" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload or token" + } + }, + "summary": "Reset password with token", + "tags": [ + "Password Reset" + ] + } + }, + "/api/moderation/user/{user_id}/ban": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.ban_user", + "parameters": [ + { + "description": "User identifier", + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BanUserRequest" + } + } + }, + "description": "Ban details", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BanResponse" + } + } + }, + "description": "User banned" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User not found" + } + }, + "summary": "Ban a user (admin only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/health": { + "get": { + "callbacks": {}, + "description": "Returns service health status", + "operationId": "CodincodApiWeb.HealthController.show (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "status": { + "example": "OK", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + } + } + }, + "description": "Health status" + } + }, + "summary": "Health check", + "tags": [ + "Health" + ] + } + }, + "/api/v1/moderation/reports": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.list_reports", + "parameters": [ + { + "description": "Filter by status", + "in": "query", + "name": "status", + "required": false, + "schema": { + "enum": [ + "pending", + "reviewing", + "resolved", + "dismissed" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Filter by problem type", + "in": "query", + "name": "problem_type", + "required": false, + "schema": { + "enum": [ + "spam", + "inappropriate", + "copyright", + "harassment", + "other" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportsListResponse" + } + } + }, + "description": "Reports list" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + } + }, + "summary": "List reports (admin only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/games": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.create (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateGameRequest" + } + } + }, + "description": "Game creation payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameResponse" + } + } + }, + "description": "Game created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + } + }, + "summary": "Create a new game lobby", + "tags": [ + "Games" + ] + } + }, + "/api/games/waiting": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.list_waiting_rooms", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WaitingRoomsResponse" + } + } + }, + "description": "Waiting rooms" + } + }, + "summary": "List all waiting game lobbies", + "tags": [ + "Games" + ] + } + }, + "/api/v1/account/preferences": { + "delete": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountPreferenceController.delete", + "parameters": [], + "responses": { + "204": { + "content": { + "application/json": {} + }, + "description": "Preferences deleted" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Delete preferences", + "tags": [ + "Account Preferences" + ] + }, + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountPreferenceController.show", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Preferences" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Get account preferences", + "tags": [ + "Account Preferences" + ] + }, + "patch": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountPreferenceController.patch", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Partial preferences", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Updated preferences" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Patch preferences", + "tags": [ + "Account Preferences" + ] + }, + "put": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountPreferenceController.replace", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Preferences payload", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Updated preferences" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Replace preferences", + "tags": [ + "Account Preferences" + ] + } + }, + "/api/puzzle/{id}/solution": { + "get": { + "callbacks": {}, + "description": "Returns puzzle with full solution details. Only available to puzzle author or admins.", + "operationId": "CodincodApiWeb.PuzzleController.solution", + "parameters": [ + { + "description": "Puzzle UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleResponse" + } + } + }, + "description": "Puzzle solution" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Get puzzle solution for editing", + "tags": [ + "Puzzle" + ] + } + }, + "/api/v1/metrics/user/{user_id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.MetricsController.user_stats (2)", + "parameters": [ + { + "description": "User identifier", + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStatsResponse" + } + } + }, + "description": "User statistics" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User not found" + } + }, + "summary": "Get detailed statistics for a user", + "tags": [ + "Metrics" + ] + } + }, + "/api/v1/games/{id}/leave": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.leave (2)", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LeaveGameResponse" + } + } + }, + "description": "Left game" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game not found" + } + }, + "summary": "Leave a game lobby", + "tags": [ + "Games" + ] + } + }, + "/api/v1/user/{username}/puzzle": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.UserController.puzzles", + "parameters": [ + { + "description": "Username whose puzzles will be listed", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "", + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "", + "in": "query", + "name": "pageSize", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedListResponse" + } + } + }, + "description": "Paginated puzzles" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid parameters" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not found" + } + }, + "summary": "List puzzles authored by a user", + "tags": [ + "User" + ] + } + }, + "/api/moderation/reviews": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.list_reviews", + "parameters": [ + { + "description": "Filter by status", + "in": "query", + "name": "status", + "required": false, + "schema": { + "enum": [ + "pending", + "approved", + "rejected" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewsListResponse" + } + } + }, + "description": "Reviews list" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + } + }, + "summary": "List pending moderation reviews (moderator only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/user/{username}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.UserController.show", + "parameters": [ + { + "description": "Username to look up", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShowResponse" + } + } + }, + "description": "User" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid username" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not found" + } + }, + "summary": "Get user by username", + "tags": [ + "User" + ] + } + }, + "/api/games/{id}/start": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.start", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameResponse" + } + } + }, + "description": "Game started" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not game host" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game not found" + } + }, + "summary": "Start a game (host only)", + "tags": [ + "Games" + ] + } + }, + "/api/moderation/review/{id}": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.review_content (2)", + "parameters": [ + { + "description": "Review identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewDecisionRequest" + } + } + }, + "description": "Review decision", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewResponse" + } + } + }, + "description": "Review updated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Review not found" + } + }, + "summary": "Review and approve/reject content (moderator only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/login": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AuthController.login", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "description": "Credentials", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + }, + "description": "Login success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Authenticate user", + "tags": [ + "Auth" + ] + } + }, + "/api/user/{username}/isAvailable": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.UserController.availability (2)", + "parameters": [ + { + "description": "Desired username", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AvailabilityResponse" + } + } + }, + "description": "Availability" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid username" + } + }, + "summary": "Check username availability", + "tags": [ + "User" + ] + } + }, + "/api/account/games": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountController.games (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserGamesResponse" + } + } + }, + "description": "User games" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Get games for current user", + "tags": [ + "Account" + ] + } + }, + "/api/v1/logout": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AuthController.logout", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + }, + "description": "Logout success" + } + }, + "summary": "Logout current user", + "tags": [ + "Auth" + ] + } + }, + "/api/v1/leaderboard/global": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.LeaderboardController.global", + "parameters": [ + { + "description": "Game mode filter", + "in": "query", + "name": "game_mode", + "required": false, + "schema": { + "enum": [ + "standard", + "timed", + "ranked" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Number of entries to return (1-100)", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Pagination offset", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GlobalLeaderboardResponse" + } + } + }, + "description": "Leaderboard rankings" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + } + }, + "summary": "Get global leaderboard rankings", + "tags": [ + "Leaderboard" + ] + } + }, + "/api/v1/programming-languages": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ProgrammingLanguageController.index (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "properties": { + "aliases": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isActive": { + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "language": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "runtime": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "version": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + } + } + }, + "description": "Programming languages list" + } + }, + "summary": "List all programming languages", + "tags": [] + } + }, + "/api/moderation/report": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.create_report (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReportRequest" + } + } + }, + "description": "Report payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportResponse" + } + } + }, + "description": "Report created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + } + }, + "summary": "Create a new report for inappropriate content", + "tags": [ + "Moderation" + ] + } + }, + "/api/games/{id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.show (2)", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameResponse" + } + } + }, + "description": "Game details" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game not found" + } + }, + "summary": "Get game details", + "tags": [ + "Games" + ] + } + }, + "/api/metrics/user/{user_id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.MetricsController.user_stats", + "parameters": [ + { + "description": "User identifier", + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStatsResponse" + } + } + }, + "description": "User statistics" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User not found" + } + }, + "summary": "Get detailed statistics for a user", + "tags": [ + "Metrics" + ] + } + }, + "/api/games/{id}/submit": { + "post": { + "callbacks": {}, + "description": "Links an existing submission to a game, marking it as a player's game submission.", + "operationId": "CodincodApiWeb.GameController.submit_code", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameSubmitCodeRequest" + } + } + }, + "description": "Game submission", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitCodeResponse" + } + } + }, + "description": "Submission linked to game" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not a game participant" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game or submission not found" + } + }, + "summary": "Submit code for a game", + "tags": [ + "Games" + ] + } + }, + "/api/comment/{id}/vote": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.CommentController.vote (2)", + "parameters": [ + { + "description": "Comment ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoteRequest" + } + } + }, + "description": "Vote request", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentResponse" + } + } + }, + "description": "Updated comment with vote" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid vote type" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Comment not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unable to process vote" + } + }, + "summary": "Vote on a comment", + "tags": [] + } + }, + "/api/moderation/report/{id}/resolve": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.resolve_report", + "parameters": [ + { + "description": "Report identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveReportRequest" + } + } + }, + "description": "Resolution payload", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportResponse" + } + } + }, + "description": "Report resolved" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Report not found" + } + }, + "summary": "Resolve a report (admin only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/account": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountController.show", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountStatusResponse" + } + } + }, + "description": "Authenticated account" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountStatusResponse" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Current account status", + "tags": [ + "Account" + ] + } + }, + "/api/account/profile": { + "patch": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountController.update_profile (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileUpdateRequest" + } + } + }, + "description": "Profile properties", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileUpdateResponse" + } + } + }, + "description": "Profile updated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Update profile", + "tags": [ + "Account" + ] + } + }, + "/api/v1/user/{username}/isAvailable": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.UserController.availability", + "parameters": [ + { + "description": "Desired username", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AvailabilityResponse" + } + } + }, + "description": "Availability" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid username" + } + }, + "summary": "Check username availability", + "tags": [ + "User" + ] + } + }, + "/api/metrics/platform": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.MetricsController.platform (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformMetricsResponse" + } + } + }, + "description": "Platform metrics" + } + }, + "summary": "Get platform-wide statistics", + "tags": [ + "Metrics" + ] + } + }, + "/api/v1/games/{id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.show", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameResponse" + } + } + }, + "description": "Game details" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game not found" + } + }, + "summary": "Get game details", + "tags": [ + "Games" + ] + } + }, + "/api/puzzle/{id}": { + "delete": { + "callbacks": {}, + "description": "Deletes a puzzle. Only available to puzzle author or admins.", + "operationId": "CodincodApiWeb.PuzzleController.delete", + "parameters": [ + { + "description": "Puzzle UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "204": { + "description": "Puzzle deleted" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Delete puzzle", + "tags": [ + "Puzzle" + ] + }, + "get": { + "callbacks": {}, + "description": "Returns a single puzzle by ID (public view, no solution details).", + "operationId": "CodincodApiWeb.PuzzleController.show", + "parameters": [ + { + "description": "Puzzle UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleResponse" + } + } + }, + "description": "Puzzle found" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Get puzzle by ID", + "tags": [ + "Puzzle" + ] + }, + "patch": { + "callbacks": {}, + "description": "Updates an existing puzzle. Only available to puzzle author or admins.", + "operationId": "CodincodApiWeb.PuzzleController.update", + "parameters": [ + { + "description": "Puzzle UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleCreateRequest" + } + } + }, + "description": "Puzzle update payload", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleResponse" + } + } + }, + "description": "Puzzle updated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable entity" + } + }, + "summary": "Update puzzle", + "tags": [ + "Puzzle" + ] + } + }, + "/api/puzzles": { + "get": { + "callbacks": {}, + "description": "Returns paginated puzzles matching the legacy Fastify `/puzzle` listing response.", + "operationId": "CodincodApiWeb.PuzzleController.index", + "parameters": [ + { + "description": "Page number", + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Number of puzzles per page", + "in": "query", + "name": "pageSize", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedListResponse" + } + } + }, + "description": "Paginated puzzles" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid query" + } + }, + "summary": "List puzzles", + "tags": [ + "Puzzle" + ] + }, + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.PuzzleController.create", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleCreateRequest" + } + } + }, + "description": "Puzzle creation payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleResponse" + } + } + }, + "description": "Puzzle created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable entity" + } + }, + "summary": "Create puzzle", + "tags": [ + "Puzzle" + ] + } + }, + "/api/refresh": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AuthController.refresh", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + }, + "description": "Token refreshed" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Refresh authentication token", + "tags": [ + "Auth" + ] + } + }, + "/api/v1/puzzle/{id}/comment": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.PuzzleCommentController.create", + "parameters": [ + { + "description": "Puzzle ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRequest" + } + } + }, + "description": "Comment creation payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentResponse" + } + } + }, + "description": "Comment created successfully" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle or parent comment not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Cannot reply to deleted comment or invalid parent" + } + }, + "summary": "Create a comment on a puzzle", + "tags": [] + } + }, + "/api/v1/metrics/puzzle/{puzzle_id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.MetricsController.puzzle_stats", + "parameters": [ + { + "description": "Puzzle identifier", + "in": "path", + "name": "puzzle_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleStatsResponse" + } + } + }, + "description": "Puzzle statistics" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Get detailed statistics for a puzzle", + "tags": [ + "Metrics" + ] + } + }, + "/api/submission": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.SubmissionController.create (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitCodeRequest" + } + } + }, + "description": "Submission payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitCodeResponse" + } + } + }, + "description": "Submission created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Execution unavailable" + } + }, + "summary": "Submit code for evaluation", + "tags": [ + "Submission" + ] + } + }, + "/api/comment/{id}": { + "delete": { + "callbacks": {}, + "operationId": "CodincodApiWeb.CommentController.delete (2)", + "parameters": [ + { + "description": "Comment ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "204": { + "description": "Comment deleted successfully" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not authorized to delete this comment" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Comment not found" + } + }, + "summary": "Delete a comment", + "tags": [] + }, + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.CommentController.show (2)", + "parameters": [ + { + "description": "Comment ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentResponse" + } + } + }, + "description": "Comment details" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Comment not found" + } + }, + "summary": "Get comment by ID", + "tags": [] + } + }, + "/api/v1/moderation/report": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.create_report", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReportRequest" + } + } + }, + "description": "Report payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportResponse" + } + } + }, + "description": "Report created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + } + }, + "summary": "Create a new report for inappropriate content", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/account/profile": { + "patch": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountController.update_profile", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileUpdateRequest" + } + } + }, + "description": "Profile properties", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileUpdateResponse" + } + } + }, + "description": "Profile updated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Update profile", + "tags": [ + "Account" + ] + } + }, + "/api/v1/moderation/user/{user_id}/unban": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.unban_user", + "parameters": [ + { + "description": "User identifier", + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BanResponse" + } + } + }, + "description": "User unbanned" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User not found" + } + }, + "summary": "Unban a user (admin only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/comment/{id}/vote": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.CommentController.vote", + "parameters": [ + { + "description": "Comment ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoteRequest" + } + } + }, + "description": "Vote request", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentResponse" + } + } + }, + "description": "Updated comment with vote" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid vote type" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Comment not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unable to process vote" + } + }, + "summary": "Vote on a comment", + "tags": [] + } + }, + "/api/v1/account/games": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountController.games", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserGamesResponse" + } + } + }, + "description": "User games" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Get games for current user", + "tags": [ + "Account" + ] + } + }, + "/api/v1/account/leaderboard": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountController.leaderboard_rank", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserRankResponse" + } + } + }, + "description": "User ranking" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Get current user's leaderboard ranking", + "tags": [ + "Account" + ] + } + }, + "/api/v1/comment/{id}": { + "delete": { + "callbacks": {}, + "operationId": "CodincodApiWeb.CommentController.delete", + "parameters": [ + { + "description": "Comment ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "204": { + "description": "Comment deleted successfully" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not authorized to delete this comment" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Comment not found" + } + }, + "summary": "Delete a comment", + "tags": [] + }, + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.CommentController.show", + "parameters": [ + { + "description": "Comment ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentResponse" + } + } + }, + "description": "Comment details" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Comment not found" + } + }, + "summary": "Get comment by ID", + "tags": [] + } + }, + "/api/games/{id}/leave": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.leave", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LeaveGameResponse" + } + } + }, + "description": "Left game" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game not found" + } + }, + "summary": "Leave a game lobby", + "tags": [ + "Games" + ] + } + }, + "/api/v1/moderation/review/{id}": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.review_content", + "parameters": [ + { + "description": "Review identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewDecisionRequest" + } + } + }, + "description": "Review decision", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewResponse" + } + } + }, + "description": "Review updated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Review not found" + } + }, + "summary": "Review and approve/reject content (moderator only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/games": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.create", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateGameRequest" + } + } + }, + "description": "Game creation payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameResponse" + } + } + }, + "description": "Game created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + } + }, + "summary": "Create a new game lobby", + "tags": [ + "Games" + ] + } + }, + "/api/v1/metrics/platform": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.MetricsController.platform", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformMetricsResponse" + } + } + }, + "description": "Platform metrics" + } + }, + "summary": "Get platform-wide statistics", + "tags": [ + "Metrics" + ] + } + }, + "/api/programming-languages": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ProgrammingLanguageController.index", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "properties": { + "aliases": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isActive": { + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "language": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "runtime": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "version": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + } + } + }, + "description": "Programming languages list" + } + }, + "summary": "List all programming languages", + "tags": [] + } + }, + "/api/v1/leaderboard/puzzle/{puzzle_id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.LeaderboardController.puzzle", + "parameters": [ + { + "description": "Puzzle identifier", + "in": "path", + "name": "puzzle_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Number of entries to return (1-100)", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleLeaderboardResponse" + } + } + }, + "description": "Puzzle leaderboard" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Get puzzle-specific leaderboard", + "tags": [ + "Leaderboard" + ] + } + }, + "/api/v1/submission": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.SubmissionController.create", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitCodeRequest" + } + } + }, + "description": "Submission payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitCodeResponse" + } + } + }, + "description": "Submission created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Execution unavailable" + } + }, + "summary": "Submit code for evaluation", + "tags": [ + "Submission" + ] + } + }, + "/api/health": { + "get": { + "callbacks": {}, + "description": "Returns service health status", + "operationId": "CodincodApiWeb.HealthController.show", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "status": { + "example": "OK", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + } + } + }, + "description": "Health status" + } + }, + "summary": "Health check", + "tags": [ + "Health" + ] + } + } + }, + "security": [], + "servers": [ + { + "url": "http://localhost:4000", + "variables": {} + } + ], + "tags": [] +} diff --git a/libs/elixir-backend/codincod_api/priv/gettext/en/LC_MESSAGES/errors.po b/libs/elixir-backend/codincod_api/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 00000000..844c4f5c --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,112 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/libs/elixir-backend/codincod_api/priv/gettext/errors.pot b/libs/elixir-backend/codincod_api/priv/gettext/errors.pot new file mode 100644 index 00000000..eef2de2b --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/gettext/errors.pot @@ -0,0 +1,109 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/libs/elixir-backend/codincod_api/priv/repo/migrations/.formatter.exs b/libs/elixir-backend/codincod_api/priv/repo/migrations/.formatter.exs new file mode 100644 index 00000000..49f9151e --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090000_create_accounts_tables.exs b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090000_create_accounts_tables.exs new file mode 100644 index 00000000..f6ae5128 --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090000_create_accounts_tables.exs @@ -0,0 +1,66 @@ +defmodule CodincodApi.Repo.Migrations.CreateAccountsTables do + use Ecto.Migration + + def change do + execute("CREATE EXTENSION IF NOT EXISTS citext;", "") + + create table(:users, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :legacy_username, :string + add :username, :citext, null: false + add :email, :citext, null: false + add :password_hash, :string, null: false + add :profile, :map, null: false, default: fragment("'{}'::jsonb") + add :role, :string, null: false, default: "user" + add :report_count, :integer, null: false, default: 0 + add :ban_count, :integer, null: false, default: 0 + add :legacy_current_ban_id, :string + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:users, [:username]) + create unique_index(:users, [:email]) + create index(:users, [:role]) + create index(:users, [:inserted_at]) + + create table(:user_bans, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :banned_by_id, references(:users, type: :binary_id, on_delete: :nilify_all) + add :ban_type, :string, null: false + add :reason, :text + add :metadata, :map, null: false, default: fragment("'{}'::jsonb") + add :expires_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + create index(:user_bans, [:user_id]) + create index(:user_bans, [:ban_type]) + create index(:user_bans, [:expires_at]) + + alter table(:users) do + add :current_ban_id, references(:user_bans, type: :binary_id, on_delete: :nilify_all) + end + + create index(:users, [:current_ban_id]) + + create table(:user_preferences, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :preferred_language, :string + add :theme, :string + add :blocked_user_ids, {:array, :binary_id}, null: false, default: [] + add :editor, :map, null: false, default: fragment("'{}'::jsonb") + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:user_preferences, [:user_id]) + create index(:user_preferences, [:preferred_language]) + end +end diff --git a/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090100_create_programming_languages.exs b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090100_create_programming_languages.exs new file mode 100644 index 00000000..f553b073 --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090100_create_programming_languages.exs @@ -0,0 +1,22 @@ +defmodule CodincodApi.Repo.Migrations.CreateProgrammingLanguages do + use Ecto.Migration + + def change do + create table(:programming_languages, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :language, :string, null: false + add :version, :string, null: false + add :aliases, {:array, :string}, null: false, default: [] + add :runtime, :string + add :display_order, :integer + add :is_active, :boolean, null: false, default: true + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:programming_languages, [:language, :version]) + create index(:programming_languages, [:is_active]) + create index(:programming_languages, [:display_order]) + end +end diff --git a/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090300_create_puzzles_tables.exs b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090300_create_puzzles_tables.exs new file mode 100644 index 00000000..fece76d5 --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090300_create_puzzles_tables.exs @@ -0,0 +1,64 @@ +defmodule CodincodApi.Repo.Migrations.CreatePuzzlesTables do + use Ecto.Migration + + def change do + execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;", "") + execute("CREATE EXTENSION IF NOT EXISTS btree_gin;", "") + + create table(:puzzles, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :title, :string, null: false + add :statement, :text + add :constraints, :text + add :author_id, references(:users, type: :binary_id, on_delete: :nothing), null: false + add :difficulty, :string, null: false + add :visibility, :string, null: false + add :tags, {:array, :string}, null: false, default: [] + add :solution, :map, null: false, default: fragment("'{}'::jsonb") + add :moderation_feedback, :text + add :legacy_metrics_id, :string + add :legacy_comments, {:array, :string}, null: false, default: [] + + timestamps(type: :utc_datetime_usec) + end + + create index(:puzzles, [:author_id]) + create index(:puzzles, [:difficulty]) + create index(:puzzles, [:visibility]) + create index(:puzzles, [:inserted_at]) + + execute( + "CREATE INDEX puzzles_tags_gin_index ON puzzles USING gin (tags);", + "DROP INDEX IF EXISTS puzzles_tags_gin_index;" + ) + + create table(:puzzle_validators, primary_key: false) do + add :id, :binary_id, primary_key: true + add :puzzle_id, references(:puzzles, type: :binary_id, on_delete: :delete_all), null: false + add :legacy_id, :string + add :input, :text, null: false + add :output, :text, null: false + add :is_public, :boolean, null: false, default: false + + timestamps(type: :utc_datetime_usec) + end + + create index(:puzzle_validators, [:puzzle_id]) + create index(:puzzle_validators, [:is_public]) + + create table(:puzzle_metrics, primary_key: false) do + add :id, :binary_id, primary_key: true + add :puzzle_id, references(:puzzles, type: :binary_id, on_delete: :delete_all), null: false + add :legacy_id, :string + add :attempt_count, :integer, null: false, default: 0 + add :success_count, :integer, null: false, default: 0 + add :average_execution_ms, :float, null: false, default: 0.0 + add :average_code_length, :integer, null: false, default: 0 + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:puzzle_metrics, [:puzzle_id]) + end +end diff --git a/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090400_create_submissions_tables.exs b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090400_create_submissions_tables.exs new file mode 100644 index 00000000..e743d599 --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090400_create_submissions_tables.exs @@ -0,0 +1,27 @@ +defmodule CodincodApi.Repo.Migrations.CreateSubmissionsTables do + use Ecto.Migration + + def change do + create table(:submissions, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :puzzle_id, references(:puzzles, type: :binary_id, on_delete: :delete_all), null: false + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + + add :programming_language_id, + references(:programming_languages, type: :binary_id, on_delete: :restrict), + null: false + + add :code, :text, null: false + add :result, :map, null: false, default: fragment("'{}'::jsonb") + add :score, :float + add :legacy_game_submission_id, :string + + timestamps(type: :utc_datetime_usec) + end + + create index(:submissions, [:puzzle_id]) + create index(:submissions, [:user_id]) + create index(:submissions, [:inserted_at]) + end +end diff --git a/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090500_create_games_tables.exs b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090500_create_games_tables.exs new file mode 100644 index 00000000..824f252b --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090500_create_games_tables.exs @@ -0,0 +1,52 @@ +defmodule CodincodApi.Repo.Migrations.CreateGamesTables do + use Ecto.Migration + + def change do + create table(:games, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :owner_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :puzzle_id, references(:puzzles, type: :binary_id, on_delete: :restrict), null: false + add :visibility, :string, null: false + add :mode, :string, null: false + add :rated, :boolean, null: false, default: true + add :status, :string, null: false, default: "waiting" + add :max_duration_seconds, :integer, null: false, default: 600 + add :allowed_language_ids, {:array, :binary_id}, null: false, default: [] + add :options, :map, null: false, default: fragment("'{}'::jsonb") + add :started_at, :utc_datetime_usec + add :ended_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + create index(:games, [:owner_id]) + create index(:games, [:puzzle_id]) + create index(:games, [:status]) + create index(:games, [:mode]) + create index(:games, [:visibility]) + + create table(:game_players, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :game_id, references(:games, type: :binary_id, on_delete: :delete_all), null: false + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :joined_at, :utc_datetime_usec, null: false + add :left_at, :utc_datetime_usec + add :role, :string, null: false, default: "player" + add :score, :integer + add :placement, :integer + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:game_players, [:game_id, :user_id]) + create index(:game_players, [:role]) + + alter table(:submissions) do + add :game_id, references(:games, type: :binary_id, on_delete: :delete_all) + end + + create index(:submissions, [:game_id]) + end +end diff --git a/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090600_create_comments_tables.exs b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090600_create_comments_tables.exs new file mode 100644 index 00000000..fba90797 --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090600_create_comments_tables.exs @@ -0,0 +1,43 @@ +defmodule CodincodApi.Repo.Migrations.CreateCommentsTables do + use Ecto.Migration + + def change do + create table(:comments, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :author_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :puzzle_id, references(:puzzles, type: :binary_id, on_delete: :delete_all) + add :submission_id, references(:submissions, type: :binary_id, on_delete: :delete_all) + add :parent_comment_id, references(:comments, type: :binary_id, on_delete: :delete_all) + add :body, :text, null: false + add :comment_type, :string, null: false, default: "comment" + add :upvote_count, :integer, null: false, default: 0 + add :downvote_count, :integer, null: false, default: 0 + add :metadata, :map, null: false, default: fragment("'{}'::jsonb") + add :deleted_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + create index(:comments, [:author_id]) + create index(:comments, [:puzzle_id]) + create index(:comments, [:submission_id]) + create index(:comments, [:parent_comment_id]) + create index(:comments, [:comment_type]) + + create table(:comment_votes, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :comment_id, references(:comments, type: :binary_id, on_delete: :delete_all), + null: false + + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :vote_type, :string, null: false + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:comment_votes, [:comment_id, :user_id]) + create index(:comment_votes, [:vote_type]) + end +end diff --git a/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090700_create_reports_and_chat.exs b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090700_create_reports_and_chat.exs new file mode 100644 index 00000000..bbf9eb91 --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090700_create_reports_and_chat.exs @@ -0,0 +1,63 @@ +defmodule CodincodApi.Repo.Migrations.CreateReportsAndChat do + use Ecto.Migration + + def change do + create table(:reports, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :problem_type, :string, null: false + add :problem_reference_id, :binary_id, null: false + add :problem_reference_snapshot, :map, null: false, default: fragment("'{}'::jsonb") + + add :reported_by_id, references(:users, type: :binary_id, on_delete: :delete_all), + null: false + + add :resolved_by_id, references(:users, type: :binary_id, on_delete: :nilify_all) + add :explanation, :text, null: false + add :status, :string, null: false, default: "pending" + add :resolution_notes, :text + add :resolved_at, :utc_datetime_usec + add :metadata, :map, null: false, default: fragment("'{}'::jsonb") + + timestamps(type: :utc_datetime_usec) + end + + create index(:reports, [:problem_type]) + create index(:reports, [:status]) + create index(:reports, [:reported_by_id]) + create index(:reports, [:resolved_by_id]) + + create table(:moderation_reviews, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :puzzle_id, references(:puzzles, type: :binary_id, on_delete: :delete_all), null: false + add :reviewer_id, references(:users, type: :binary_id, on_delete: :nilify_all) + add :status, :string, null: false, default: "pending" + add :notes, :text + add :submitted_at, :utc_datetime_usec, null: false, default: fragment("now()") + add :resolved_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + create index(:moderation_reviews, [:puzzle_id]) + create index(:moderation_reviews, [:status]) + + create table(:chat_messages, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :game_id, references(:games, type: :binary_id, on_delete: :delete_all), null: false + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :username_snapshot, :string, null: false + add :message, :text, null: false + add :is_deleted, :boolean, null: false, default: false + add :deleted_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + create index(:chat_messages, [:game_id]) + create index(:chat_messages, [:user_id]) + create index(:chat_messages, [:inserted_at]) + end +end diff --git a/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090800_create_metrics_tables.exs b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090800_create_metrics_tables.exs new file mode 100644 index 00000000..95f4e11d --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251101090800_create_metrics_tables.exs @@ -0,0 +1,36 @@ +defmodule CodincodApi.Repo.Migrations.CreateMetricsTables do + use Ecto.Migration + + def change do + create table(:user_metrics, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :global_rating, :float, null: false, default: 1500.0 + add :global_rating_deviation, :float, null: false, default: 350.0 + add :global_rating_volatility, :float, null: false, default: 0.06 + add :modes, :map, null: false, default: fragment("'{}'::jsonb") + add :totals, :map, null: false, default: fragment("'{}'::jsonb") + add :last_processed_game_at, :utc_datetime_usec + add :last_calculated_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:user_metrics, [:user_id]) + create index(:user_metrics, [:global_rating]) + + create table(:leaderboard_snapshots, primary_key: false) do + add :id, :binary_id, primary_key: true + add :game_mode, :string, null: false + add :captured_at, :utc_datetime_usec, null: false + add :entries, :map, null: false, default: fragment("'[]'::jsonb") + add :metadata, :map, null: false, default: fragment("'{}'::jsonb") + + timestamps(type: :utc_datetime_usec) + end + + create index(:leaderboard_snapshots, [:game_mode]) + create index(:leaderboard_snapshots, [:captured_at]) + end +end diff --git a/libs/elixir-backend/codincod_api/priv/repo/migrations/20251102000001_create_puzzle_test_cases.exs b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251102000001_create_puzzle_test_cases.exs new file mode 100644 index 00000000..1c09ce34 --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251102000001_create_puzzle_test_cases.exs @@ -0,0 +1,24 @@ +defmodule CodincodApi.Repo.Migrations.CreatePuzzleTestCases do + use Ecto.Migration + + def change do + create table(:puzzle_test_cases, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :puzzle_id, references(:puzzles, type: :binary_id, on_delete: :delete_all), null: false + + add :input, :text, null: false + add :expected_output, :text, null: false + add :is_sample, :boolean, default: false, null: false + add :order, :integer, null: false + add :metadata, :map, default: %{}, null: false + + timestamps(type: :utc_datetime_usec) + end + + create index(:puzzle_test_cases, [:puzzle_id]) + create index(:puzzle_test_cases, [:puzzle_id, :order]) + create index(:puzzle_test_cases, [:puzzle_id, :is_sample]) + create unique_index(:puzzle_test_cases, [:legacy_id], where: "legacy_id IS NOT NULL") + end +end diff --git a/libs/elixir-backend/codincod_api/priv/repo/migrations/20251102000002_create_puzzle_examples.exs b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251102000002_create_puzzle_examples.exs new file mode 100644 index 00000000..58132f2c --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251102000002_create_puzzle_examples.exs @@ -0,0 +1,23 @@ +defmodule CodincodApi.Repo.Migrations.CreatePuzzleExamples do + use Ecto.Migration + + def change do + create table(:puzzle_examples, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :puzzle_id, references(:puzzles, type: :binary_id, on_delete: :delete_all), null: false + + add :input, :text, null: false + add :output, :text, null: false + add :explanation, :text + add :order, :integer, null: false + add :metadata, :map, default: %{}, null: false + + timestamps(type: :utc_datetime_usec) + end + + create index(:puzzle_examples, [:puzzle_id]) + create index(:puzzle_examples, [:puzzle_id, :order]) + create unique_index(:puzzle_examples, [:legacy_id], where: "legacy_id IS NOT NULL") + end +end diff --git a/libs/elixir-backend/codincod_api/priv/repo/migrations/20251102154346_create_password_resets.exs b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251102154346_create_password_resets.exs new file mode 100644 index 00000000..6df3b2d2 --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/migrations/20251102154346_create_password_resets.exs @@ -0,0 +1,19 @@ +defmodule CodincodApi.Repo.Migrations.CreatePasswordResets do + use Ecto.Migration + + def change do + create table(:password_resets, primary_key: false) do + add :id, :binary_id, primary_key: true + add :token, :string, null: false + add :expires_at, :utc_datetime_usec, null: false + add :used_at, :utc_datetime_usec + add :user_id, references(:users, on_delete: :delete_all, type: :binary_id), null: false + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:password_resets, [:token]) + create index(:password_resets, [:user_id]) + create index(:password_resets, [:expires_at]) + end +end diff --git a/libs/elixir-backend/codincod_api/priv/repo/scripts/extract_puzzle_sub_schemas.exs b/libs/elixir-backend/codincod_api/priv/repo/scripts/extract_puzzle_sub_schemas.exs new file mode 100644 index 00000000..4c3d5cdf --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/scripts/extract_puzzle_sub_schemas.exs @@ -0,0 +1,93 @@ +# Script to extract test cases and examples from puzzle.solution JSONB field +# into their own tables (puzzle_test_cases and puzzle_examples) +# +# Run with: mix run priv/repo/scripts/extract_puzzle_sub_schemas.exs + +require Logger + +alias CodincodApi.Repo +alias CodincodApi.Puzzles.{Puzzle, PuzzleTestCase, PuzzleExample} + +import Ecto.Query + +Logger.info("🔄 Extracting puzzle test cases and examples...") + +# Get all puzzles with solution data +puzzles_with_solutions = + from(p in Puzzle, + where: not is_nil(p.solution), + where: p.solution != ^%{}, + preload: [:test_cases, :examples] + ) + |> Repo.all() + +Logger.info("Found #{length(puzzles_with_solutions)} puzzles with solution data") + +Enum.each(puzzles_with_solutions, fn puzzle -> + solution = puzzle.solution || %{} + + # Extract test cases + test_cases = solution["testCases"] || [] + Logger.info(" Processing puzzle '#{puzzle.title}' - #{length(test_cases)} test cases, #{length(solution["examples"] || [])} examples") + + test_cases + |> Enum.with_index() + |> Enum.each(fn {tc, idx} -> + legacy_id = "#{puzzle.legacy_id}_tc_#{idx}" + + # Skip if already exists + unless Repo.get_by(PuzzleTestCase, legacy_id: legacy_id) do + attrs = %{ + puzzle_id: puzzle.id, + input: tc["input"] || "", + expected_output: tc["expectedOutput"] || tc["output"] || "", + is_sample: tc["isSample"] || false, + order: idx, + legacy_id: legacy_id, + metadata: %{} + } + + case PuzzleTestCase.changeset(%PuzzleTestCase{}, attrs) |> Repo.insert() do + {:ok, _} -> :ok + {:error, changeset} -> + Logger.warning(" Failed to insert test case #{idx}: #{inspect(changeset.errors)}") + end + end + end) + + # Extract examples + examples = solution["examples"] || [] + + examples + |> Enum.with_index() + |> Enum.each(fn {ex, idx} -> + legacy_id = "#{puzzle.legacy_id}_ex_#{idx}" + + # Skip if already exists + unless Repo.get_by(PuzzleExample, legacy_id: legacy_id) do + attrs = %{ + puzzle_id: puzzle.id, + input: ex["input"] || "", + output: ex["output"] || "", + explanation: ex["explanation"], + order: idx, + legacy_id: legacy_id, + metadata: %{} + } + + case PuzzleExample.changeset(%PuzzleExample{}, attrs) |> Repo.insert() do + {:ok, _} -> :ok + {:error, changeset} -> + Logger.warning(" Failed to insert example #{idx}: #{inspect(changeset.errors)}") + end + end + end) +end) + +# Count results +test_case_count = Repo.aggregate(PuzzleTestCase, :count) +example_count = Repo.aggregate(PuzzleExample, :count) + +Logger.info("✅ Extraction complete!") +Logger.info(" Total test cases: #{test_case_count}") +Logger.info(" Total examples: #{example_count}") diff --git a/libs/elixir-backend/codincod_api/priv/repo/scripts/seed_test_data_mongodb.exs b/libs/elixir-backend/codincod_api/priv/repo/scripts/seed_test_data_mongodb.exs new file mode 100644 index 00000000..e8571071 --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/scripts/seed_test_data_mongodb.exs @@ -0,0 +1,367 @@ +#!/usr/bin/env elixir + +# Script to seed MongoDB with test data for migration verification +# This creates known test objects that we can verify after migration +# +# Usage: +# MONGO_URI="..." MONGO_DB_NAME="..." mix run priv/repo/scripts/seed_test_data_mongodb.exs + +require Logger + +# Deterministic seed for reproducible test data +:rand.seed(:exsplus, {42, 42, 42}) + +# Generate deterministic test IDs +defmodule TestData do + def generate_object_id(prefix) do + # Create deterministic ObjectIds for testing + # MongoDB ObjectId is 12 bytes (24 hex chars) + hash = :crypto.hash(:md5, prefix) |> binary_part(0, 12) + %BSON.ObjectId{value: hash} + end + + def test_timestamp(days_ago \\ 0) do + DateTime.utc_now() + |> DateTime.add(-days_ago * 24 * 3600, :second) + end + + # Test user data + def test_user(index) do + %{ + "_id" => generate_object_id("test_user_#{index}"), + "username" => "test_user_#{index}", + "email" => "test#{index}@migration-test.com", + "password" => "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyJSawHJK7tW", # "password123" + "role" => if(index == 1, do: "admin", else: "user"), + "isActive" => true, + "createdAt" => test_timestamp(30), + "updatedAt" => test_timestamp(20) + } + end + + # Test puzzle data + def test_puzzle(index, author_id) do + %{ + "_id" => generate_object_id("test_puzzle_#{index}"), + "title" => "Test Puzzle #{index}: Two Sum", + "statement" => "Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target.", + "constraints" => "- Each input has exactly one solution\n- You may not use the same element twice\n- Array length: 2 ≤ n ≤ 10^4", + "author" => author_id, + "difficulty" => Enum.at(["beginner", "intermediate", "advanced", "expert"], rem(index, 4)), + "visibility" => "approved", + "tags" => ["array", "hash-table", "test-migration"], + "validators" => [ + %{ + "input" => "[2,7,11,15]\n9", + "output" => "[0,1]", + "createdAt" => test_timestamp(25), + "updatedAt" => test_timestamp(25) + }, + %{ + "input" => "[3,2,4]\n6", + "output" => "[1,2]", + "createdAt" => test_timestamp(25), + "updatedAt" => test_timestamp(25) + }, + %{ + "input" => "[3,3]\n6", + "output" => "[0,1]", + "createdAt" => test_timestamp(25), + "updatedAt" => test_timestamp(25) + } + ], + "solution" => %{ + "code" => "def two_sum(nums, target):\n seen = {}\n for i, num in enumerate(nums):\n complement = target - num\n if complement in seen:\n return [seen[complement], i]\n seen[num] = i\n return []", + "programmingLanguage" => generate_object_id("lang_python"), + "explanation" => "Use a hash map to store numbers we've seen and check for complements.", + "examples" => [ + %{ + "input" => "[2,7,11,15]\n9", + "output" => "[0,1]", + "explanation" => "nums[0] + nums[1] = 2 + 7 = 9" + } + ] + }, + "createdAt" => test_timestamp(28), + "updatedAt" => test_timestamp(15) + } + end + + # Test submission data + def test_submission(index, user_id, puzzle_id) do + statuses = ["pending", "accepted", "wrong_answer", "runtime_error", "time_limit_exceeded"] + status = Enum.at(statuses, rem(index, 5)) + + %{ + "_id" => generate_object_id("test_submission_#{index}"), + "user" => user_id, + "puzzle" => puzzle_id, + "code" => "def solution(nums, target):\n # Test submission #{index}\n return [0, 1]", + "programmingLanguage" => %{ + "_id" => generate_object_id("lang_python"), + "name" => "python", + "version" => "3.11.0" + }, + "status" => status, + "result" => %{ + "testResults" => [ + %{"passed" => true, "executionTime" => 45}, + %{"passed" => status == "accepted", "executionTime" => 52}, + %{"passed" => status == "accepted", "executionTime" => 38} + ], + "totalTests" => 3, + "passedTests" => if(status == "accepted", do: 3, else: 1), + "executionTime" => 135, + "memoryUsed" => 15_234_567 + }, + "createdAt" => test_timestamp(10 + index), + "updatedAt" => test_timestamp(10 + index) + } + end + + # Test game data + def test_game(index, player_ids, puzzle_id) do + [owner_id | _] = player_ids # First player is the owner + + %{ + "_id" => generate_object_id("test_game_#{index}"), + "owner" => owner_id, # Add owner field + "puzzle" => puzzle_id, + "players" => player_ids, + "status" => Enum.at(["waiting", "in_progress", "completed"], rem(index, 3)), + "mode" => Enum.at(["competitive", "collaborative", "practice"], rem(index, 3)), # Changed from gameMode + "visibility" => "public", # Add visibility + "options" => %{ + "timeLimit" => 3600, + "maxPlayers" => length(player_ids), + "allowLateJoin" => true, + "showLeaderboard" => true, + "difficulty" => "intermediate" + }, + "scores" => Enum.map(player_ids, fn player_id -> + %{ + "player" => player_id, + "score" => :rand.uniform(1000), + "completedAt" => test_timestamp(5) + } + end), + "createdAt" => test_timestamp(12), + "updatedAt" => test_timestamp(5) + } + end + + # Test comment data + def test_comment(index, author_id, puzzle_id) do + %{ + "_id" => generate_object_id("test_comment_#{index}"), + "author" => author_id, + "puzzle" => puzzle_id, + "text" => "This is test comment #{index}. Great puzzle! I learned a lot about hash tables.", + "commentType" => Enum.at(["discussion", "solution", "question"], rem(index, 3)), + "votes" => %{ + "up" => [author_id], + "down" => [] + }, + "createdAt" => test_timestamp(8), + "updatedAt" => test_timestamp(7) + } + end + + # Test report data + def test_report(index, reporter_id, puzzle_id) do + %{ + "_id" => generate_object_id("test_report_#{index}"), + "reportedBy" => reporter_id, # Changed from "reporter" to match schema + "problematicCollection" => "puzzles", # Add collection type + "problematicIdentifier" => puzzle_id, # Changed from problemReferenceId + "problemType" => Enum.at(["puzzle", "comment", "user"], rem(index, 3)), + "problemReferenceSnapshot" => %{ + "title" => "Test Puzzle #{index}", + "statement" => "Original statement...", + "capturedAt" => test_timestamp(6) + }, + "reason" => "Test report #{index}: This content violates community guidelines.", # Use reason instead of description + "status" => Enum.at(["pending", "reviewed", "resolved"], rem(index, 3)), + "createdAt" => test_timestamp(6), + "updatedAt" => test_timestamp(4) + } + end + + # Test preference data + def test_preference(index, user_id) do + %{ + "_id" => generate_object_id("test_pref_#{index}"), + "owner" => user_id, # MongoDB uses "owner" instead of "user" + "editor" => %{ + "theme" => Enum.at(["light", "dark", "monokai"], rem(index, 3)), + "fontSize" => 12 + rem(index, 4), + "tabSize" => 2 + rem(index, 2), + "wordWrap" => rem(index, 2) == 0, + "autoComplete" => true, + "keyBindings" => "default" + }, + "notifications" => %{ + "email" => true, + "push" => false, + "comments" => true, + "submissions" => true + }, + "createdAt" => test_timestamp(15), + "updatedAt" => test_timestamp(3) + } + end +end + +# Connect to MongoDB +Logger.info("🔌 Connecting to MongoDB...") + +mongo_uri = System.get_env("MONGO_URI") || raise "MONGO_URI environment variable required" +mongo_db = System.get_env("MONGO_DB_NAME") || "codincod-development" + +# Parse connection string to check if it's Atlas (mongodb+srv) +is_atlas = String.starts_with?(mongo_uri, "mongodb+srv://") + +connect_opts = [ + url: mongo_uri, + name: :mongo_test_seed, + database: mongo_db, + pool_size: 1 +] + +# Add SSL options for Atlas +connect_opts = if is_atlas do + Keyword.merge(connect_opts, [ + ssl: true, + ssl_opts: [verify: :verify_none] + ]) +else + connect_opts +end + +{:ok, conn} = Mongo.start_link(connect_opts) + +Logger.info("✅ Connected to MongoDB: #{mongo_db}") +Logger.info("📝 Creating test data with deterministic values...") + +# Create test data +try do + # 1. Create test users (5 users) + Logger.info("\n👤 Creating test users...") + test_users = for i <- 1..5, do: TestData.test_user(i) + + # Delete existing test users + Mongo.delete_many(conn, "users", %{"email" => %{"$regex" => "@migration-test.com"}}) + + # Insert test users + {:ok, _} = Mongo.insert_many(conn, "users", test_users) + Logger.info(" Created #{length(test_users)} test users") + + # Get user IDs + user_ids = Enum.map(test_users, & &1["_id"]) + [author_id | other_user_ids] = user_ids + + # 2. Create test puzzles (3 puzzles) + Logger.info("\n🧩 Creating test puzzles...") + test_puzzles = for i <- 1..3, do: TestData.test_puzzle(i, author_id) + + Mongo.delete_many(conn, "puzzles", %{"tags" => "test-migration"}) + {:ok, _} = Mongo.insert_many(conn, "puzzles", test_puzzles) + Logger.info(" Created #{length(test_puzzles)} test puzzles") + + puzzle_ids = Enum.map(test_puzzles, & &1["_id"]) + [puzzle_id | _] = puzzle_ids + + # 3. Create test submissions (10 submissions) + Logger.info("\n📝 Creating test submissions...") + test_submissions = for i <- 1..10 do + TestData.test_submission( + i, + Enum.at(user_ids, rem(i, length(user_ids))), + Enum.at(puzzle_ids, rem(i, length(puzzle_ids))) + ) + end + + # Delete test submissions + submission_ids = Enum.map(test_submissions, & &1["_id"]) + Mongo.delete_many(conn, "submissions", %{"_id" => %{"$in" => submission_ids}}) + + {:ok, _} = Mongo.insert_many(conn, "submissions", test_submissions) + Logger.info(" Created #{length(test_submissions)} test submissions") + + # 4. Create test games (4 games) + Logger.info("\n🎮 Creating test games...") + test_games = for i <- 1..4 do + player_count = 2 + rem(i, 3) + players = Enum.take(user_ids, player_count) + TestData.test_game(i, players, Enum.at(puzzle_ids, rem(i, length(puzzle_ids)))) + end + + game_ids = Enum.map(test_games, & &1["_id"]) + Mongo.delete_many(conn, "games", %{"_id" => %{"$in" => game_ids}}) + + {:ok, _} = Mongo.insert_many(conn, "games", test_games) + Logger.info(" Created #{length(test_games)} test games") + + # 5. Create test comments (6 comments) + Logger.info("\n💬 Creating test comments...") + test_comments = for i <- 1..6 do + TestData.test_comment( + i, + Enum.at(user_ids, rem(i, length(user_ids))), + Enum.at(puzzle_ids, rem(i, length(puzzle_ids))) + ) + end + + comment_ids = Enum.map(test_comments, & &1["_id"]) + Mongo.delete_many(conn, "comments", %{"_id" => %{"$in" => comment_ids}}) + + {:ok, _} = Mongo.insert_many(conn, "comments", test_comments) + Logger.info(" Created #{length(test_comments)} test comments") + + # 6. Create test reports (3 reports) + Logger.info("\n🚩 Creating test reports...") + test_reports = for i <- 1..3 do + TestData.test_report(i, author_id, Enum.at(puzzle_ids, rem(i, length(puzzle_ids)))) + end + + report_ids = Enum.map(test_reports, & &1["_id"]) + Mongo.delete_many(conn, "reports", %{"_id" => %{"$in" => report_ids}}) + + {:ok, _} = Mongo.insert_many(conn, "reports", test_reports) + Logger.info(" Created #{length(test_reports)} test reports") + + # 7. Create test preferences (5 preferences - one per user) + Logger.info("\n⚙️ Creating test preferences...") + test_preferences = for i <- 1..5 do + TestData.test_preference(i, Enum.at(user_ids, i - 1)) + end + + # Delete existing test preferences by ID + pref_ids = Enum.map(test_preferences, & &1["_id"]) + Mongo.delete_many(conn, "preferences", %{"_id" => %{"$in" => pref_ids}}) + Mongo.delete_many(conn, "preferences", %{"owner" => %{"$in" => user_ids}}) + + {:ok, _} = Mongo.insert_many(conn, "preferences", test_preferences) + Logger.info(" Created #{length(test_preferences)} test preferences") + + # Summary + Logger.info("\n" <> String.duplicate("=", 60)) + Logger.info("✅ Test Data Creation Complete!") + Logger.info(String.duplicate("=", 60)) + Logger.info("📊 Summary:") + Logger.info(" • Users: #{length(test_users)}") + Logger.info(" • Puzzles: #{length(test_puzzles)}") + Logger.info(" • Submissions: #{length(test_submissions)}") + Logger.info(" • Games: #{length(test_games)}") + Logger.info(" • Comments: #{length(test_comments)}") + Logger.info(" • Reports: #{length(test_reports)}") + Logger.info(" • Preferences: #{length(test_preferences)}") + Logger.info(" " <> String.duplicate("-", 58)) + Logger.info(" Total: #{length(test_users) + length(test_puzzles) + length(test_submissions) + length(test_games) + length(test_comments) + length(test_reports) + length(test_preferences)}") + Logger.info(String.duplicate("=", 60)) + Logger.info("\n🚀 Ready for migration testing!") + Logger.info(" Run: mix migrate_mongo") + +after + GenServer.stop(conn) +end diff --git a/libs/elixir-backend/codincod_api/priv/repo/scripts/verify_migration.exs b/libs/elixir-backend/codincod_api/priv/repo/scripts/verify_migration.exs new file mode 100644 index 00000000..2b3dc88f --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/scripts/verify_migration.exs @@ -0,0 +1,559 @@ +#!/usr/bin/env elixir + +# Script to verify MongoDB to PostgreSQL migration accuracy +# Compares test objects in MongoDB with their migrated counterparts in PostgreSQL +# +# Usage: +# MONGO_URI="..." MONGO_DB_NAME="..." mix run priv/repo/scripts/verify_migration.exs + +require Logger +import Ecto.Query + +alias CodincodApi.Repo +alias CodincodApi.Accounts.{User, Preference} +alias CodincodApi.Puzzles.{Puzzle, PuzzleTestCase, PuzzleExample} +alias CodincodApi.Submissions.Submission +alias CodincodApi.Games.Game +alias CodincodApi.Comments.Comment +alias CodincodApi.Moderation.Report + +defmodule MigrationVerifier do + require Logger + + def generate_test_object_id(prefix) do + # Generate same deterministic ObjectIds as seed script + hash = :crypto.hash(:md5, prefix) |> binary_part(0, 12) + %BSON.ObjectId{value: hash} + end + + def extract_mongo_id(%BSON.ObjectId{value: value}), do: Base.encode16(value, case: :lower) + def extract_mongo_id(value) when is_binary(value) and byte_size(value) == 12 do + Base.encode16(value, case: :lower) + end + def extract_mongo_id(value) when is_binary(value), do: value + def extract_mongo_id(_), do: nil + + def verify_user(mongo_user, pg_user) do + errors = [] + + errors = if mongo_user["username"] != pg_user.username do + ["Username mismatch: #{mongo_user["username"]} != #{pg_user.username}" | errors] + else + errors + end + + errors = if mongo_user["email"] != pg_user.email do + ["Email mismatch: #{mongo_user["email"]} != #{pg_user.email}" | errors] + else + errors + end + + errors = if mongo_user["role"] != pg_user.role do + ["Role mismatch: #{mongo_user["role"]} != #{pg_user.role}" | errors] + else + errors + end + + if errors == [] do + {:ok, "✅ User '#{pg_user.username}' verified"} + else + {:error, "❌ User '#{pg_user.username}' has mismatches", errors} + end + end + + def verify_puzzle(mongo_puzzle, pg_puzzle, conn) do + errors = [] + + errors = if mongo_puzzle["title"] != pg_puzzle.title do + ["Title mismatch: #{mongo_puzzle["title"]} != #{pg_puzzle.title}" | errors] + else + errors + end + + errors = if mongo_puzzle["statement"] != pg_puzzle.statement do + ["Statement mismatch" | errors] + else + errors + end + + errors = if mongo_puzzle["constraints"] != pg_puzzle.constraints do + ["Constraints mismatch" | errors] + else + errors + end + + # Verify difficulty + mongo_difficulty = String.downcase(mongo_puzzle["difficulty"] || "") + pg_difficulty = String.downcase(pg_puzzle.difficulty || "") + errors = if mongo_difficulty != pg_difficulty do + ["Difficulty mismatch: #{mongo_difficulty} != #{pg_difficulty}" | errors] + else + errors + end + + # Verify tags + mongo_tags = Enum.sort(mongo_puzzle["tags"] || []) + pg_tags = Enum.sort(pg_puzzle.tags || []) + errors = if mongo_tags != pg_tags do + ["Tags mismatch: #{inspect(mongo_tags)} != #{inspect(pg_tags)}" | errors] + else + errors + end + + # Verify test cases (validators in MongoDB) + mongo_validators = mongo_puzzle["validators"] || [] + pg_test_cases = Repo.all( + from tc in PuzzleTestCase, + where: tc.puzzle_id == ^pg_puzzle.id, + order_by: [asc: tc.order] + ) + + if length(mongo_validators) != length(pg_test_cases) do + errors = ["Test case count mismatch: #{length(mongo_validators)} != #{length(pg_test_cases)}" | errors] + else + # Verify each test case + validator_errors = Enum.zip(mongo_validators, pg_test_cases) + |> Enum.with_index() + |> Enum.flat_map(fn {{mv, tc}, idx} -> + tc_errors = [] + tc_errors = if mv["input"] != tc.input do + ["TC#{idx} input mismatch" | tc_errors] + else + tc_errors + end + + tc_errors = if mv["output"] != tc.expected_output do + ["TC#{idx} output mismatch: #{mv["output"]} != #{tc.expected_output}" | tc_errors] + else + tc_errors + end + + tc_errors + end) + + errors = errors ++ validator_errors + end + + # Verify examples if present + mongo_examples = get_in(mongo_puzzle, ["solution", "examples"]) || [] + pg_examples = Repo.all( + from ex in PuzzleExample, + where: ex.puzzle_id == ^pg_puzzle.id, + order_by: [asc: ex.order] + ) + + if length(mongo_examples) > 0 and length(mongo_examples) != length(pg_examples) do + errors = ["Example count mismatch: #{length(mongo_examples)} != #{length(pg_examples)}" | errors] + end + + if errors == [] do + {:ok, "✅ Puzzle '#{pg_puzzle.title}' verified (#{length(pg_test_cases)} test cases, #{length(pg_examples)} examples)"} + else + {:error, "❌ Puzzle '#{pg_puzzle.title}' has mismatches", errors} + end + end + + def verify_submission(mongo_sub, pg_sub) do + errors = [] + + errors = if mongo_sub["code"] != pg_sub.code do + ["Code mismatch" | errors] + else + errors + end + + # Verify status + mongo_status = String.downcase(mongo_sub["status"] || "pending") + pg_status = String.downcase(Atom.to_string(pg_sub.status)) + + # Map MongoDB statuses to PostgreSQL + status_map = %{ + "accepted" => "accepted", + "wrong_answer" => "wrong_answer", + "runtime_error" => "runtime_error", + "time_limit_exceeded" => "time_limit_exceeded", + "pending" => "pending" + } + + expected_status = Map.get(status_map, mongo_status, mongo_status) + errors = if expected_status != pg_status do + ["Status mismatch: #{mongo_status} != #{pg_status}" | errors] + else + errors + end + + if errors == [] do + {:ok, "✅ Submission verified (status: #{pg_status})"} + else + {:error, "❌ Submission has mismatches", errors} + end + end + + def verify_game(mongo_game, pg_game) do + errors = [] + + # Verify player count + mongo_player_count = length(mongo_game["players"] || []) + pg_player_count = length(pg_game.player_ids || []) + + errors = if mongo_player_count != pg_player_count do + ["Player count mismatch: #{mongo_player_count} != #{pg_player_count}" | errors] + else + errors + end + + # Verify game mode + mongo_mode = String.downcase(mongo_game["gameMode"] || "") + pg_mode = String.downcase(Atom.to_string(pg_game.game_mode)) + + errors = if mongo_mode != pg_mode do + ["Game mode mismatch: #{mongo_mode} != #{pg_mode}" | errors] + else + errors + end + + # Verify options are preserved + mongo_options = mongo_game["options"] || %{} + pg_options = pg_game.options || %{} + + errors = if is_map(mongo_options) and map_size(mongo_options) > 0 and map_size(pg_options) == 0 do + ["Options not migrated" | errors] + else + errors + end + + if errors == [] do + {:ok, "✅ Game verified (#{pg_player_count} players, mode: #{pg_mode})"} + else + {:error, "❌ Game has mismatches", errors} + end + end + + def verify_comment(mongo_comment, pg_comment) do + errors = [] + + errors = if mongo_comment["text"] != pg_comment.text do + ["Text mismatch" | errors] + else + errors + end + + # Verify comment type + mongo_type = String.downcase(mongo_comment["commentType"] || "discussion") + pg_type = String.downcase(Atom.to_string(pg_comment.comment_type)) + + errors = if mongo_type != pg_type do + ["Type mismatch: #{mongo_type} != #{pg_type}" | errors] + else + errors + end + + if errors == [] do + {:ok, "✅ Comment verified (type: #{pg_type})"} + else + {:error, "❌ Comment has mismatches", errors} + end + end + + def verify_report(mongo_report, pg_report) do + errors = [] + + errors = if mongo_report["description"] != pg_report.description do + ["Description mismatch" | errors] + else + errors + end + + # Verify reason + mongo_reason = String.downcase(mongo_report["reason"] || "") + pg_reason = String.downcase(Atom.to_string(pg_report.reason)) + + errors = if mongo_reason != pg_reason do + ["Reason mismatch: #{mongo_reason} != #{pg_reason}" | errors] + else + errors + end + + # Verify snapshot is preserved + mongo_snapshot = mongo_report["problemReferenceSnapshot"] + pg_snapshot = pg_report.problem_reference_snapshot + + errors = if is_map(mongo_snapshot) and is_nil(pg_snapshot) do + ["Snapshot not migrated" | errors] + else + errors + end + + if errors == [] do + {:ok, "✅ Report verified (reason: #{pg_reason})"} + else + {:error, "❌ Report has mismatches", errors} + end + end + + def verify_preference(mongo_pref, pg_pref) do + errors = [] + + # Verify editor preferences are preserved + mongo_editor = mongo_pref["editor"] || %{} + pg_editor = pg_pref.editor || %{} + + errors = if is_map(mongo_editor) and map_size(mongo_editor) > 0 and map_size(pg_editor) == 0 do + ["Editor preferences not migrated" | errors] + else + errors + end + + # Check specific editor settings if both exist + if map_size(mongo_editor) > 0 and map_size(pg_editor) > 0 do + if mongo_editor["theme"] != pg_editor["theme"] do + errors = ["Theme mismatch: #{mongo_editor["theme"]} != #{pg_editor["theme"]}" | errors] + end + end + + if errors == [] do + {:ok, "✅ Preference verified"} + else + {:error, "❌ Preference has mismatches", errors} + end + end +end + +# Main verification logic +Logger.info("🔍 Starting Migration Verification") +Logger.info(String.duplicate("=", 60)) + +# Connect to MongoDB +mongo_uri = System.get_env("MONGO_URI") || raise "MONGO_URI environment variable required" +mongo_db = System.get_env("MONGO_DB_NAME") || "codincod-development" + +is_atlas = String.starts_with?(mongo_uri, "mongodb+srv://") + +connect_opts = [ + url: mongo_uri, + name: :mongo_verify, + database: mongo_db, + pool_size: 1 +] + +connect_opts = if is_atlas do + Keyword.merge(connect_opts, [ssl: true, ssl_opts: [verify: :verify_none]]) +else + connect_opts +end + +{:ok, conn} = Mongo.start_link(connect_opts) + +try do + total_verified = 0 + total_errors = 0 + + # 1. Verify Users + Logger.info("\n👤 Verifying Users...") + test_users = Mongo.find(conn, "users", %{"email" => %{"$regex" => "@migration-test.com"}}) |> Enum.to_list() + + user_results = Enum.map(test_users, fn mongo_user -> + mongo_id = MigrationVerifier.extract_mongo_id(mongo_user["_id"]) + pg_user = Repo.get_by(User, legacy_id: mongo_id) + + if pg_user do + MigrationVerifier.verify_user(mongo_user, pg_user) + else + {:error, "❌ User not found in PostgreSQL", ["Missing migration"]} + end + end) + + Enum.each(user_results, fn + {:ok, msg} -> + Logger.info(" #{msg}") + total_verified = total_verified + 1 + {:error, msg, errors} -> + Logger.error(" #{msg}") + Enum.each(errors, &Logger.error(" - #{&1}")) + total_errors = total_errors + 1 + end) + + # 2. Verify Puzzles + Logger.info("\n🧩 Verifying Puzzles...") + test_puzzles = Mongo.find(conn, "puzzles", %{"tags" => "test-migration"}) |> Enum.to_list() + + puzzle_results = Enum.map(test_puzzles, fn mongo_puzzle -> + mongo_id = MigrationVerifier.extract_mongo_id(mongo_puzzle["_id"]) + pg_puzzle = Repo.get_by(Puzzle, legacy_id: mongo_id) + + if pg_puzzle do + MigrationVerifier.verify_puzzle(mongo_puzzle, pg_puzzle, conn) + else + {:error, "❌ Puzzle not found in PostgreSQL", ["Missing migration"]} + end + end) + + Enum.each(puzzle_results, fn + {:ok, msg} -> + Logger.info(" #{msg}") + total_verified = total_verified + 1 + {:error, msg, errors} -> + Logger.error(" #{msg}") + Enum.each(errors, &Logger.error(" - #{&1}")) + total_errors = total_errors + 1 + end) + + # 3. Verify Submissions + Logger.info("\n📝 Verifying Submissions...") + test_submission_ids = Enum.map(1..10, fn i -> + MigrationVerifier.generate_test_object_id("test_submission_#{i}") + end) + + test_submissions = Mongo.find(conn, "submissions", %{"_id" => %{"$in" => test_submission_ids}}) |> Enum.to_list() + + submission_results = Enum.map(test_submissions, fn mongo_sub -> + mongo_id = MigrationVerifier.extract_mongo_id(mongo_sub["_id"]) + pg_sub = Repo.get_by(Submission, legacy_id: mongo_id) + + if pg_sub do + MigrationVerifier.verify_submission(mongo_sub, pg_sub) + else + {:error, "❌ Submission not found in PostgreSQL", ["Missing migration"]} + end + end) + + Enum.each(submission_results, fn + {:ok, msg} -> + Logger.info(" #{msg}") + total_verified = total_verified + 1 + {:error, msg, errors} -> + Logger.error(" #{msg}") + Enum.each(errors, &Logger.error(" - #{&1}")) + total_errors = total_errors + 1 + end) + + # 4. Verify Games + Logger.info("\n🎮 Verifying Games...") + test_game_ids = Enum.map(1..4, fn i -> + MigrationVerifier.generate_test_object_id("test_game_#{i}") + end) + + test_games = Mongo.find(conn, "games", %{"_id" => %{"$in" => test_game_ids}}) |> Enum.to_list() + + game_results = Enum.map(test_games, fn mongo_game -> + mongo_id = MigrationVerifier.extract_mongo_id(mongo_game["_id"]) + pg_game = Repo.get_by(Game, legacy_id: mongo_id) + + if pg_game do + MigrationVerifier.verify_game(mongo_game, pg_game) + else + {:error, "❌ Game not found in PostgreSQL", ["Missing migration"]} + end + end) + + Enum.each(game_results, fn + {:ok, msg} -> + Logger.info(" #{msg}") + total_verified = total_verified + 1 + {:error, msg, errors} -> + Logger.error(" #{msg}") + Enum.each(errors, &Logger.error(" - #{&1}")) + total_errors = total_errors + 1 + end) + + # 5. Verify Comments + Logger.info("\n💬 Verifying Comments...") + test_comment_ids = Enum.map(1..6, fn i -> + MigrationVerifier.generate_test_object_id("test_comment_#{i}") + end) + + test_comments = Mongo.find(conn, "comments", %{"_id" => %{"$in" => test_comment_ids}}) |> Enum.to_list() + + comment_results = Enum.map(test_comments, fn mongo_comment -> + mongo_id = MigrationVerifier.extract_mongo_id(mongo_comment["_id"]) + pg_comment = Repo.get_by(Comment, legacy_id: mongo_id) + + if pg_comment do + MigrationVerifier.verify_comment(mongo_comment, pg_comment) + else + {:error, "❌ Comment not found in PostgreSQL", ["Missing migration"]} + end + end) + + Enum.each(comment_results, fn + {:ok, msg} -> + Logger.info(" #{msg}") + total_verified = total_verified + 1 + {:error, msg, errors} -> + Logger.error(" #{msg}") + Enum.each(errors, &Logger.error(" - #{&1}")) + total_errors = total_errors + 1 + end) + + # 6. Verify Reports + Logger.info("\n🚩 Verifying Reports...") + test_report_ids = Enum.map(1..3, fn i -> + MigrationVerifier.generate_test_object_id("test_report_#{i}") + end) + + test_reports = Mongo.find(conn, "reports", %{"_id" => %{"$in" => test_report_ids}}) |> Enum.to_list() + + report_results = Enum.map(test_reports, fn mongo_report -> + mongo_id = MigrationVerifier.extract_mongo_id(mongo_report["_id"]) + pg_report = Repo.get_by(Report, legacy_id: mongo_id) + + if pg_report do + MigrationVerifier.verify_report(mongo_report, pg_report) + else + {:error, "❌ Report not found in PostgreSQL", ["Missing migration"]} + end + end) + + Enum.each(report_results, fn + {:ok, msg} -> + Logger.info(" #{msg}") + total_verified = total_verified + 1 + {:error, msg, errors} -> + Logger.error(" #{msg}") + Enum.each(errors, &Logger.error(" - #{&1}")) + total_errors = total_errors + 1 + end) + + # 7. Verify Preferences + Logger.info("\n⚙️ Verifying Preferences...") + test_user_ids = Enum.map(1..5, fn i -> + MigrationVerifier.generate_test_object_id("test_user_#{i}") + end) + + test_preferences = Mongo.find(conn, "preferences", %{"owner" => %{"$in" => test_user_ids}}) |> Enum.to_list() + + pref_results = Enum.map(test_preferences, fn mongo_pref -> + mongo_id = MigrationVerifier.extract_mongo_id(mongo_pref["_id"]) + pg_pref = Repo.get_by(Preference, legacy_id: mongo_id) + + if pg_pref do + MigrationVerifier.verify_preference(mongo_pref, pg_pref) + else + {:error, "❌ Preference not found in PostgreSQL", ["Missing migration"]} + end + end) + + Enum.each(pref_results, fn + {:ok, msg} -> + Logger.info(" #{msg}") + total_verified = total_verified + 1 + {:error, msg, errors} -> + Logger.error(" #{msg}") + Enum.each(errors, &Logger.error(" - #{&1}")) + total_errors = total_errors + 1 + end) + + # Final Summary + Logger.info("\n" <> String.duplicate("=", 60)) + if total_errors == 0 do + Logger.info("✅ ALL VERIFICATIONS PASSED!") + Logger.info(" #{total_verified} objects verified successfully") + else + Logger.error("❌ VERIFICATION FAILED") + Logger.error(" #{total_verified} passed, #{total_errors} failed") + end + Logger.info(String.duplicate("=", 60)) + +after + GenServer.stop(conn) +end diff --git a/libs/elixir-backend/codincod_api/priv/repo/seeds.exs b/libs/elixir-backend/codincod_api/priv/repo/seeds.exs new file mode 100644 index 00000000..218a29a2 --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/seeds.exs @@ -0,0 +1,11 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# CodincodApi.Repo.insert!(%CodincodApi.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. diff --git a/libs/elixir-backend/codincod_api/priv/static/favicon.ico b/libs/elixir-backend/codincod_api/priv/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7f372bfc21cdd8cb47585339d5fa4d9dd424402f GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=@t!V@Ar*{oFEH`~d50E!_s``s q?{G*w(7?#d#v@^nKnY_HKaYb01EZMZjMqTJ89ZJ6T-G@yGywoKK_h|y literal 0 HcmV?d00001 diff --git a/libs/elixir-backend/codincod_api/priv/static/openapi.json b/libs/elixir-backend/codincod_api/priv/static/openapi.json new file mode 100644 index 00000000..a5a69d5b --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/static/openapi.json @@ -0,0 +1,12272 @@ +{ + "components": { + "responses": {}, + "schemas": { + "UserAvailabilityResponse": { + "properties": { + "available": { + "type": "boolean", + "x-struct": null, + "x-validate": null + } + }, + "title": "AvailabilityResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.AvailabilityResponse", + "x-validate": null + }, + "ProfileUpdateResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + } + }, + "title": "ProfileUpdateResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Account.ProfileUpdateResponse", + "x-validate": null + }, + "RequestPayload": { + "properties": { + "email": { + "format": "email", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "email" + ], + "title": "RequestPayload", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.PasswordReset.RequestPayload", + "x-validate": null + }, + "CommentCreateRequest": { + "properties": { + "replyOn": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "text": { + "maxLength": 320, + "minLength": 1, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "text" + ], + "title": "CreateRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Comment.CreateRequest", + "x-validate": null + }, + "ReviewDecisionRequest": { + "properties": { + "reviewerNotes": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "status": { + "enum": [ + "approved", + "rejected" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "status" + ], + "title": "ReviewDecisionRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.ReviewDecisionRequest", + "x-validate": null + }, + "RegisterRequest": { + "properties": { + "email": { + "format": "email", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "password": { + "format": "password", + "minLength": 14, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "passwordConfirmation": { + "format": "password", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "maxLength": 20, + "minLength": 3, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "username", + "email", + "password" + ], + "title": "RegisterRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Auth.RegisterRequest", + "x-validate": null + }, + "AccountPreferences": { + "properties": { + "blockedUsers": { + "items": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "editor": { + "type": "object", + "x-struct": null, + "x-validate": null + }, + "preferredLanguage": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "theme": { + "enum": [ + "dark", + "light" + ], + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PreferencesPayload", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Account.PreferencesPayload", + "x-validate": null + }, + "PuzzleResponse": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "author": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Author", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Author", + "x-validate": null + }, + "comments": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "constraints": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyMetricsId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "moderationFeedback": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleMetrics": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "solution": { + "properties": { + "code": { + "default": "", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Solution", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Solution", + "x-validate": null + }, + "statement": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "tags": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "validators": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "input": { + "description": "Validator input payload", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isPublic": { + "default": false, + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "output": { + "description": "Expected validator output", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Validator", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Validator", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "visibility": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.PuzzleResponse", + "x-validate": null + }, + "PuzzleCreateRequest": { + "properties": { + "constraints": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "description": { + "minLength": 1, + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "difficulty": { + "enum": [ + "easy", + "medium", + "hard", + "beginner", + "intermediate", + "advanced", + "expert" + ], + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "tags": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "title": { + "maxLength": 128, + "minLength": 4, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "validators": { + "items": { + "properties": { + "input": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isPublic": { + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "output": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "input", + "output" + ], + "type": "object", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "title" + ], + "title": "PuzzleCreateRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.PuzzleCreateRequest", + "x-validate": null + }, + "ReportResponse": { + "properties": { + "contentId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "contentType": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "description": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "problemType": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reportedBy": { + "nullable": true, + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "resolutionNotes": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "resolvedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "resolvedBy": { + "nullable": true, + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "status": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ReportResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.ReportResponse", + "x-validate": null + }, + "SubmitCodeRequest": { + "properties": { + "code": { + "minLength": 1, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguageId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "puzzleId", + "programmingLanguageId", + "code", + "userId" + ], + "title": "SubmitCodeRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmitCodeRequest", + "x-validate": null + }, + "AvailabilityResponse": { + "properties": { + "available": { + "type": "boolean", + "x-struct": null, + "x-validate": null + } + }, + "title": "AvailabilityResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.AvailabilityResponse", + "x-validate": null + }, + "AuthMessageResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "MessageResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Auth.MessageResponse", + "x-validate": null + }, + "SubmissionListResponse": { + "items": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "code": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyGameSubmissionId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "language": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "runtime": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "version": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ProgrammingLanguageSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.ProgrammingLanguageSummary", + "x-validate": null + }, + "puzzle": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.PuzzleSummary", + "x-validate": null + }, + "result": { + "additionalProperties": true, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "score": { + "nullable": true, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "user": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + } + }, + "title": "SubmissionResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmissionResponse", + "x-validate": null + }, + "title": "SubmissionListResponse", + "type": "array", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmissionListResponse", + "x-validate": null + }, + "PasswordResetCompleteResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ResetResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.PasswordReset.ResetResponse", + "x-validate": null + }, + "AccountProfileUpdateRequest": { + "properties": { + "bio": { + "maxLength": 500, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "maxLength": 100, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "format": "uri", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "format": "uri", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "maxItems": 5, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "ProfileUpdateRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Account.ProfileUpdateRequest", + "x-validate": null + }, + "Profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "UserShowResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "user": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + } + }, + "title": "ShowResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.ShowResponse", + "x-validate": null + }, + "LoginRequest": { + "properties": { + "identifier": { + "description": "Username or email", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "password": { + "format": "password", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "identifier", + "password" + ], + "title": "LoginRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Auth.LoginRequest", + "x-validate": null + }, + "SubmissionSubmitResponse": { + "properties": { + "code": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "codeLength": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguageId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "result": { + "properties": { + "failed": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "passed": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "successRate": { + "maximum": 1, + "minimum": 0, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "total": { + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "successRate", + "passed", + "failed", + "total" + ], + "type": "object", + "x-struct": null, + "x-validate": null + }, + "submissionId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "submissionId", + "code", + "puzzleId", + "programmingLanguageId", + "userId", + "codeLength", + "result", + "createdAt" + ], + "title": "SubmitCodeResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmitCodeResponse", + "x-validate": null + }, + "BanResponse": { + "properties": { + "banned": { + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "bannedUntil": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reason": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "BanResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.BanResponse", + "x-validate": null + }, + "VoteRequest": { + "properties": { + "type": { + "enum": [ + "upvote", + "downvote" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "type" + ], + "title": "VoteRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Comment.VoteRequest", + "x-validate": null + }, + "ShowResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "user": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + } + }, + "title": "ShowResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.ShowResponse", + "x-validate": null + }, + "UserStatsResponse": { + "properties": { + "acceptanceRate": { + "type": "number", + "x-struct": null, + "x-validate": null + }, + "acceptedSubmissions": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "difficultyBreakdown": { + "properties": { + "easy": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "expert": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "hard": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "medium": { + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "languageUsage": { + "items": { + "properties": { + "count": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "language": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "puzzlesSolved": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "recentActivity": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "runtimeErrors": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "timeLimitExceeded": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalSubmissions": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "wrongAnswerSubmissions": { + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "UserStatsResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Metrics.UserStatsResponse", + "x-validate": null + }, + "ProfileUpdateRequest": { + "properties": { + "bio": { + "maxLength": 500, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "maxLength": 100, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "format": "uri", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "format": "uri", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "maxItems": 5, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "ProfileUpdateRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Account.ProfileUpdateRequest", + "x-validate": null + }, + "UserRankResponse": { + "properties": { + "puzzlesSolved": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "rank": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "rating": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalSubmissions": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "UserRankResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Leaderboard.UserRankResponse", + "x-validate": null + }, + "WaitingRoomsResponse": { + "properties": { + "count": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "rooms": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "finishedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameMode": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "maxPlayers": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "owner": { + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "players": { + "items": { + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "joinedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "puzzle": { + "properties": { + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "startedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "status": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "timeLimit": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "GameResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Games.GameResponse", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "WaitingRoomsResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Games.WaitingRoomsResponse", + "x-validate": null + }, + "GlobalLeaderboardResponse": { + "properties": { + "cachedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameMode": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "limit": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "offset": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "rankings": { + "items": { + "properties": { + "averageScore": { + "type": "number", + "x-struct": null, + "x-validate": null + }, + "bestScore": { + "type": "number", + "x-struct": null, + "x-validate": null + }, + "gamesPlayed": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "gamesWon": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "glicko": { + "properties": { + "rd": { + "description": "Rating deviation", + "type": "number", + "x-struct": null, + "x-validate": null + }, + "vol": { + "description": "Volatility", + "type": "number", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "puzzlesSolved": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "rank": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "rating": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalSubmissions": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "winRate": { + "format": "float", + "type": "number", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "totalEntries": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalPages": { + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "GlobalLeaderboardResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Leaderboard.GlobalLeaderboardResponse", + "x-validate": null + }, + "UserSummary": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + }, + "Solution": { + "properties": { + "code": { + "default": "", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Solution", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Solution", + "x-validate": null + }, + "CommentVoteRequest": { + "properties": { + "type": { + "enum": [ + "upvote", + "downvote" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "type" + ], + "title": "VoteRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Comment.VoteRequest", + "x-validate": null + }, + "GameSubmitCodeRequest": { + "description": "Request to link a submission to a game. This is the correct type for game submissions (not to be confused with SubmitCodeRequest for direct code submission)", + "properties": { + "submissionId": { + "description": "The ID of the submission to link to the game", + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "submissionId" + ], + "title": "GameSubmitCodeRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Games.GameSubmitCodeRequest", + "x-validate": null + }, + "PuzzlePaginatedListResponse": { + "properties": { + "items": { + "items": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "author": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Author", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Author", + "x-validate": null + }, + "comments": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "constraints": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyMetricsId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "moderationFeedback": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleMetrics": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "solution": { + "properties": { + "code": { + "default": "", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Solution", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Solution", + "x-validate": null + }, + "statement": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "tags": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "validators": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "input": { + "description": "Validator input payload", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isPublic": { + "default": false, + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "output": { + "description": "Expected validator output", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Validator", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Validator", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "visibility": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.PuzzleResponse", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "page": { + "default": 1, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "pageSize": { + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalItems": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalPages": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "PaginatedListResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.PaginatedListResponse", + "x-validate": null + }, + "Validator": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "input": { + "description": "Validator input payload", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isPublic": { + "default": false, + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "output": { + "description": "Expected validator output", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Validator", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Validator", + "x-validate": null + }, + "SubmitCodeResponse": { + "properties": { + "code": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "codeLength": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguageId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "result": { + "properties": { + "failed": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "passed": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "successRate": { + "maximum": 1, + "minimum": 0, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "total": { + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "successRate", + "passed", + "failed", + "total" + ], + "type": "object", + "x-struct": null, + "x-validate": null + }, + "submissionId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "submissionId", + "code", + "puzzleId", + "programmingLanguageId", + "userId", + "codeLength", + "result", + "createdAt" + ], + "title": "SubmitCodeResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmitCodeResponse", + "x-validate": null + }, + "ReportsListResponse": { + "properties": { + "count": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "reports": { + "items": { + "$ref": "#/components/schemas/ReportResponse" + }, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "ReportsListResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.ReportsListResponse", + "x-validate": null + }, + "PreferencesPayload": { + "properties": { + "blockedUsers": { + "items": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "editor": { + "type": "object", + "x-struct": null, + "x-validate": null + }, + "preferredLanguage": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "theme": { + "enum": [ + "dark", + "light" + ], + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PreferencesPayload", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Account.PreferencesPayload", + "x-validate": null + }, + "RequestResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "RequestResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.PasswordReset.RequestResponse", + "x-validate": null + }, + "PuzzleLeaderboardResponse": { + "properties": { + "limit": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "rankings": { + "items": { + "properties": { + "executionTime": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "memoryUsed": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "rank": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "submittedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleLeaderboardResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Leaderboard.PuzzleLeaderboardResponse", + "x-validate": null + }, + "ReviewsListResponse": { + "properties": { + "count": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "reviews": { + "items": { + "$ref": "#/components/schemas/ReviewResponse" + }, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "ReviewsListResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.ReviewsListResponse", + "x-validate": null + }, + "ActivityResponse": { + "properties": { + "activity": { + "properties": { + "puzzles": { + "items": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "author": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Author", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Author", + "x-validate": null + }, + "comments": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "constraints": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyMetricsId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "moderationFeedback": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleMetrics": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "solution": { + "properties": { + "code": { + "default": "", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Solution", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Solution", + "x-validate": null + }, + "statement": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "tags": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "validators": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "input": { + "description": "Validator input payload", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isPublic": { + "default": false, + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "output": { + "description": "Expected validator output", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Validator", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Validator", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "visibility": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.PuzzleResponse", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "submissions": { + "items": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "code": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyGameSubmissionId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "language": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "runtime": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "version": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ProgrammingLanguageSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.ProgrammingLanguageSummary", + "x-validate": null + }, + "puzzle": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.PuzzleSummary", + "x-validate": null + }, + "result": { + "additionalProperties": true, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "score": { + "nullable": true, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "user": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + } + }, + "title": "SubmissionResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmissionResponse", + "x-validate": null + }, + "title": "SubmissionListResponse", + "type": "array", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmissionListResponse", + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "user": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + } + }, + "title": "ActivityResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.ActivityResponse", + "x-validate": null + }, + "PaginatedListResponse": { + "properties": { + "items": { + "items": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "author": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Author", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Author", + "x-validate": null + }, + "comments": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "constraints": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyMetricsId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "moderationFeedback": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleMetrics": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "solution": { + "properties": { + "code": { + "default": "", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Solution", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Solution", + "x-validate": null + }, + "statement": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "tags": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "validators": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "input": { + "description": "Validator input payload", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isPublic": { + "default": false, + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "output": { + "description": "Expected validator output", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Validator", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Validator", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "visibility": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.PuzzleResponse", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "page": { + "default": 1, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "pageSize": { + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalItems": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalPages": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "PaginatedListResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.PaginatedListResponse", + "x-validate": null + }, + "PuzzleStatsResponse": { + "properties": { + "acceptanceRate": { + "type": "number", + "x-struct": null, + "x-validate": null + }, + "acceptedSubmissions": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "averageExecutionTime": { + "nullable": true, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "languageDistribution": { + "items": { + "properties": { + "count": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "language": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "statusBreakdown": { + "properties": { + "accepted": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "runtimeError": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "timeLimitExceeded": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "wrongAnswer": { + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "totalSubmissions": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "uniqueSolvers": { + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleStatsResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Metrics.PuzzleStatsResponse", + "x-validate": null + }, + "ResolveReportRequest": { + "properties": { + "resolutionNotes": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "status": { + "enum": [ + "resolved", + "dismissed" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "status" + ], + "title": "ResolveReportRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.ResolveReportRequest", + "x-validate": null + }, + "PuzzleSummary": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.PuzzleSummary", + "x-validate": null + }, + "ResetPayload": { + "properties": { + "password": { + "minLength": 8, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "token": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "token", + "password" + ], + "title": "ResetPayload", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.PasswordReset.ResetPayload", + "x-validate": null + }, + "Summary": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + }, + "ResetResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ResetResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.PasswordReset.ResetResponse", + "x-validate": null + }, + "PasswordResetResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "RequestResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.PasswordReset.RequestResponse", + "x-validate": null + }, + "ReviewResponse": { + "properties": { + "authorName": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "contextMessages": { + "items": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "timestamp": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "description": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reportExplanation": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reportedBy": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reportedMessageId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reportedUserId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reportedUserName": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reviewedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "reviewer": { + "nullable": true, + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "reviewerNotes": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "status": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ReviewResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.ReviewResponse", + "x-validate": null + }, + "ExecuteResponse": { + "properties": { + "compile": { + "additionalProperties": true, + "nullable": true, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "puzzleResultInformation": { + "properties": { + "failed": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "passed": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "result": { + "enum": [ + "SUCCESS", + "ERROR" + ], + "type": "string", + "x-struct": null, + "x-validate": null + }, + "successRate": { + "maximum": 1, + "minimum": 0, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "total": { + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleResultInformation", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Execute.PuzzleResultInformation", + "x-validate": null + }, + "run": { + "additionalProperties": true, + "type": "object", + "x-struct": null, + "x-validate": null + } + }, + "title": "ExecuteResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Execute.ExecuteResponse", + "x-validate": null + }, + "SubmissionSubmitRequest": { + "properties": { + "code": { + "minLength": 1, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguageId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "puzzleId", + "programmingLanguageId", + "code", + "userId" + ], + "title": "SubmitCodeRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmitCodeRequest", + "x-validate": null + }, + "UserActivityResponse": { + "properties": { + "activity": { + "properties": { + "puzzles": { + "items": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "author": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Author", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Author", + "x-validate": null + }, + "comments": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "constraints": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyMetricsId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "moderationFeedback": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleMetrics": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "solution": { + "properties": { + "code": { + "default": "", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Solution", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Solution", + "x-validate": null + }, + "statement": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "tags": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "validators": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "input": { + "description": "Validator input payload", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isPublic": { + "default": false, + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "output": { + "description": "Expected validator output", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Validator", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Validator", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "visibility": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.PuzzleResponse", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "submissions": { + "items": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "code": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyGameSubmissionId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "language": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "runtime": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "version": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ProgrammingLanguageSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.ProgrammingLanguageSummary", + "x-validate": null + }, + "puzzle": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.PuzzleSummary", + "x-validate": null + }, + "result": { + "additionalProperties": true, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "score": { + "nullable": true, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "user": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + } + }, + "title": "SubmissionResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmissionResponse", + "x-validate": null + }, + "title": "SubmissionListResponse", + "type": "array", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmissionListResponse", + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "user": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + } + }, + "title": "ActivityResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.ActivityResponse", + "x-validate": null + }, + "SubmissionResponse": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "code": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyGameSubmissionId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "programmingLanguage": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "language": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "runtime": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "version": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ProgrammingLanguageSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.ProgrammingLanguageSummary", + "x-validate": null + }, + "puzzle": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.PuzzleSummary", + "x-validate": null + }, + "result": { + "additionalProperties": true, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "score": { + "nullable": true, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "user": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "banCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "currentBan": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyId": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "legacyUsername": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "reportCount": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "role": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Summary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Summary", + "x-validate": null + } + }, + "title": "SubmissionResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.SubmissionResponse", + "x-validate": null + }, + "ProgrammingLanguageSummary": { + "properties": { + "_id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "language": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "runtime": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "version": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ProgrammingLanguageSummary", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Submission.ProgrammingLanguageSummary", + "x-validate": null + }, + "GameResponse": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "finishedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameMode": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "maxPlayers": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "owner": { + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "players": { + "items": { + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "joinedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "puzzle": { + "properties": { + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "startedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "status": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "timeLimit": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "GameResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Games.GameResponse", + "x-validate": null + }, + "PlatformMetricsResponse": { + "properties": { + "acceptedSubmissions": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "activeUsers": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "popularPuzzles": { + "items": { + "properties": { + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "submissionCount": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "totalPuzzles": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalSubmissions": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "totalUsers": { + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "PlatformMetricsResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Metrics.PlatformMetricsResponse", + "x-validate": null + }, + "PasswordResetPayload": { + "properties": { + "password": { + "minLength": 8, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "token": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "token", + "password" + ], + "title": "ResetPayload", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.PasswordReset.ResetPayload", + "x-validate": null + }, + "PasswordResetRequest": { + "properties": { + "email": { + "format": "email", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "email" + ], + "title": "RequestPayload", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.PasswordReset.RequestPayload", + "x-validate": null + }, + "CommentResponse": { + "properties": { + "author": { + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Author", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Comment.Author", + "x-validate": null + }, + "authorId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "body": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "commentType": { + "enum": [ + "puzzle-comment", + "comment-comment", + "submission-comment" + ], + "type": "string", + "x-struct": null, + "x-validate": null + }, + "downvote": { + "default": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "insertedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "parentCommentId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "upvote": { + "default": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "id", + "body", + "commentType", + "authorId" + ], + "title": "CommentResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Comment.CommentResponse", + "x-validate": null + }, + "ErrorResponse": { + "properties": { + "error": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "errors": { + "type": "object", + "x-struct": null, + "x-validate": null + }, + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "ErrorResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Common.ErrorResponse", + "x-validate": null + }, + "AccountStatusResponse": { + "properties": { + "isAuthenticated": { + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "userId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "isAuthenticated" + ], + "title": "AccountStatusResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Account.StatusResponse", + "x-validate": null + }, + "AccountProfileUpdateResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + } + }, + "title": "ProfileUpdateResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Account.ProfileUpdateResponse", + "x-validate": null + }, + "LeaveGameResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "LeaveGameResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Games.LeaveGameResponse", + "x-validate": null + }, + "ExecuteRequest": { + "properties": { + "code": { + "minLength": 1, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "language": { + "minLength": 1, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "testInput": { + "default": "", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "testOutput": { + "default": "", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "code", + "language" + ], + "title": "ExecuteRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Execute.ExecuteRequest", + "x-validate": null + }, + "MessageResponse": { + "properties": { + "message": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "MessageResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Auth.MessageResponse", + "x-validate": null + }, + "Author": { + "properties": { + "_id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "profile": { + "properties": { + "bio": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "location": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "picture": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "socials": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "nullable": true, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "Profile", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.User.Profile", + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "Author", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Puzzle.Author", + "x-validate": null + }, + "UserGamesResponse": { + "properties": { + "count": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "games": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "finishedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "gameMode": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "maxPlayers": { + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "owner": { + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "players": { + "items": { + "properties": { + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "joinedAt": { + "format": "date-time", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "role": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "username": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "puzzle": { + "properties": { + "difficulty": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "title": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "startedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "status": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "timeLimit": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "GameResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Games.GameResponse", + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + } + }, + "title": "UserGamesResponse", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Games.UserGamesResponse", + "x-validate": null + }, + "PuzzleResultInformation": { + "properties": { + "failed": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "passed": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "result": { + "enum": [ + "SUCCESS", + "ERROR" + ], + "type": "string", + "x-struct": null, + "x-validate": null + }, + "successRate": { + "maximum": 1, + "minimum": 0, + "type": "number", + "x-struct": null, + "x-validate": null + }, + "total": { + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "title": "PuzzleResultInformation", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Execute.PuzzleResultInformation", + "x-validate": null + }, + "CreateRequest": { + "properties": { + "replyOn": { + "format": "uuid", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "text": { + "maxLength": 320, + "minLength": 1, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "text" + ], + "title": "CreateRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Comment.CreateRequest", + "x-validate": null + }, + "BanUserRequest": { + "properties": { + "bannedUntil": { + "format": "date-time", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "durationDays": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "reason": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "title": "BanUserRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.BanUserRequest", + "x-validate": null + }, + "CreateReportRequest": { + "properties": { + "contentId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "contentType": { + "enum": [ + "puzzle", + "comment", + "submission", + "user" + ], + "type": "string", + "x-struct": null, + "x-validate": null + }, + "description": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, + "problemType": { + "enum": [ + "spam", + "inappropriate", + "copyright", + "harassment", + "other" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "contentType", + "contentId", + "problemType" + ], + "title": "CreateReportRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Moderation.CreateReportRequest", + "x-validate": null + }, + "CreateGameRequest": { + "properties": { + "gameMode": { + "default": "standard", + "enum": [ + "standard", + "timed", + "ranked" + ], + "type": "string", + "x-struct": null, + "x-validate": null + }, + "maxPlayers": { + "default": 2, + "maximum": 10, + "minimum": 2, + "type": "integer", + "x-struct": null, + "x-validate": null + }, + "puzzleId": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "timeLimit": { + "nullable": true, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + "required": [ + "puzzleId" + ], + "title": "CreateGameRequest", + "type": "object", + "x-struct": "Elixir.CodincodApiWeb.OpenAPI.Schemas.Games.CreateGameRequest", + "x-validate": null + } + } + }, + "info": { + "description": "Phoenix implementation of the CodinCod backend", + "title": "CodinCod API", + "version": "0.1.0" + }, + "openapi": "3.0.0", + "paths": { + "/api/v1/games/waiting": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.list_waiting_rooms (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WaitingRoomsResponse" + } + } + }, + "description": "Waiting rooms" + } + }, + "summary": "List all waiting game lobbies", + "tags": [ + "Games" + ] + } + }, + "/api/login": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AuthController.login (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "description": "Credentials", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + }, + "description": "Login success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Authenticate user", + "tags": [ + "Auth" + ] + } + }, + "/api/execute": { + "post": { + "callbacks": {}, + "description": "Runs code against Piston with custom test input/output for validation", + "operationId": "CodincodApiWeb.ExecuteController.create (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteRequest" + } + } + }, + "description": "Execute request", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteResponse" + } + } + }, + "description": "Execution result" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service unavailable" + } + }, + "summary": "Execute code without saving", + "tags": [ + "Execute" + ] + } + }, + "/api/v1/games/{id}/submit": { + "post": { + "callbacks": {}, + "description": "Links an existing submission to a game, marking it as a player's game submission.", + "operationId": "CodincodApiWeb.GameController.submit_code (2)", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameSubmitCodeRequest" + } + } + }, + "description": "Game submission", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitCodeResponse" + } + } + }, + "description": "Submission linked to game" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not a game participant" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game or submission not found" + } + }, + "summary": "Submit code for a game", + "tags": [ + "Games" + ] + } + }, + "/api/submission/{id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.SubmissionController.show (2)", + "parameters": [ + { + "description": "Submission identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmissionResponse" + } + } + }, + "description": "Submission" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid id" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not found" + } + }, + "summary": "Fetch submission by id", + "tags": [ + "Submission" + ] + } + }, + "/api/v1/moderation/reviews": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.list_reviews (2)", + "parameters": [ + { + "description": "Filter by status", + "in": "query", + "name": "status", + "required": false, + "schema": { + "enum": [ + "pending", + "approved", + "rejected" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewsListResponse" + } + } + }, + "description": "Reviews list" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + } + }, + "summary": "List pending moderation reviews (moderator only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/games/{id}/start": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.start (2)", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameResponse" + } + } + }, + "description": "Game started" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not game host" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game not found" + } + }, + "summary": "Start a game (host only)", + "tags": [ + "Games" + ] + } + }, + "/api/register": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AuthController.register (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + } + } + }, + "description": "Registration payload", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + }, + "description": "Registration success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Register new user", + "tags": [ + "Auth" + ] + } + }, + "/api/user/{username}/activity": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.UserController.activity (2)", + "parameters": [ + { + "description": "Username to inspect", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityResponse" + } + } + }, + "description": "Activity" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid username" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Get user activity (puzzles and submissions)", + "tags": [ + "User" + ] + } + }, + "/api/moderation/user/{user_id}/unban": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.unban_user (2)", + "parameters": [ + { + "description": "User identifier", + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BanResponse" + } + } + }, + "description": "User unbanned" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User not found" + } + }, + "summary": "Unban a user (admin only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/moderation/reports": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.list_reports (2)", + "parameters": [ + { + "description": "Filter by status", + "in": "query", + "name": "status", + "required": false, + "schema": { + "enum": [ + "pending", + "reviewing", + "resolved", + "dismissed" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Filter by problem type", + "in": "query", + "name": "problem_type", + "required": false, + "schema": { + "enum": [ + "spam", + "inappropriate", + "copyright", + "harassment", + "other" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportsListResponse" + } + } + }, + "description": "Reports list" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + } + }, + "summary": "List reports (admin only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/puzzles": { + "get": { + "callbacks": {}, + "description": "Returns paginated puzzles matching the legacy Fastify `/puzzle` listing response.", + "operationId": "CodincodApiWeb.PuzzleController.index (2)", + "parameters": [ + { + "description": "Page number", + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Number of puzzles per page", + "in": "query", + "name": "pageSize", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedListResponse" + } + } + }, + "description": "Paginated puzzles" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid query" + } + }, + "summary": "List puzzles", + "tags": [ + "Puzzle" + ] + }, + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.PuzzleController.create (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleCreateRequest" + } + } + }, + "description": "Puzzle creation payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleResponse" + } + } + }, + "description": "Puzzle created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable entity" + } + }, + "summary": "Create puzzle", + "tags": [ + "Puzzle" + ] + } + }, + "/api/v1/refresh": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AuthController.refresh (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + }, + "description": "Token refreshed" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Refresh authentication token", + "tags": [ + "Auth" + ] + } + }, + "/api/v1/password-reset/request": { + "post": { + "callbacks": {}, + "description": "Sends password reset email if user exists", + "operationId": "CodincodApiWeb.PasswordResetController.request_reset (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestPayload" + } + } + }, + "description": "Reset request", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestResponse" + } + } + }, + "description": "Reset email sent" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + } + }, + "summary": "Request password reset", + "tags": [ + "Password Reset" + ] + } + }, + "/api/leaderboard/puzzle/{puzzle_id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.LeaderboardController.puzzle (2)", + "parameters": [ + { + "description": "Puzzle identifier", + "in": "path", + "name": "puzzle_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Number of entries to return (1-100)", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleLeaderboardResponse" + } + } + }, + "description": "Puzzle leaderboard" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Get puzzle-specific leaderboard", + "tags": [ + "Leaderboard" + ] + } + }, + "/api/account/preferences": { + "delete": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountPreferenceController.delete (2)", + "parameters": [], + "responses": { + "204": { + "content": { + "application/json": {} + }, + "description": "Preferences deleted" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Delete preferences", + "tags": [ + "Account Preferences" + ] + }, + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountPreferenceController.show (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Preferences" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Get account preferences", + "tags": [ + "Account Preferences" + ] + }, + "patch": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountPreferenceController.patch (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Partial preferences", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Updated preferences" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Patch preferences", + "tags": [ + "Account Preferences" + ] + }, + "put": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountPreferenceController.replace (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Preferences payload", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Updated preferences" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Replace preferences", + "tags": [ + "Account Preferences" + ] + } + }, + "/api/metrics/puzzle/{puzzle_id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.MetricsController.puzzle_stats (2)", + "parameters": [ + { + "description": "Puzzle identifier", + "in": "path", + "name": "puzzle_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleStatsResponse" + } + } + }, + "description": "Puzzle statistics" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Get detailed statistics for a puzzle", + "tags": [ + "Metrics" + ] + } + }, + "/api/games/{id}/join": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.join (2)", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameResponse" + } + } + }, + "description": "Joined game" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game full or already started" + } + }, + "summary": "Join a game lobby", + "tags": [ + "Games" + ] + } + }, + "/api/v1/games/{id}/join": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.join", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameResponse" + } + } + }, + "description": "Joined game" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game full or already started" + } + }, + "summary": "Join a game lobby", + "tags": [ + "Games" + ] + } + }, + "/api/v1/moderation/user/{user_id}/ban": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.ban_user (2)", + "parameters": [ + { + "description": "User identifier", + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BanUserRequest" + } + } + }, + "description": "Ban details", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BanResponse" + } + } + }, + "description": "User banned" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User not found" + } + }, + "summary": "Ban a user (admin only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/puzzle/{id}/solution": { + "get": { + "callbacks": {}, + "description": "Returns puzzle with full solution details. Only available to puzzle author or admins.", + "operationId": "CodincodApiWeb.PuzzleController.solution (2)", + "parameters": [ + { + "description": "Puzzle UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleResponse" + } + } + }, + "description": "Puzzle solution" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Get puzzle solution for editing", + "tags": [ + "Puzzle" + ] + } + }, + "/api/password-reset/request": { + "post": { + "callbacks": {}, + "description": "Sends password reset email if user exists", + "operationId": "CodincodApiWeb.PasswordResetController.request_reset", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestPayload" + } + } + }, + "description": "Reset request", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestResponse" + } + } + }, + "description": "Reset email sent" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + } + }, + "summary": "Request password reset", + "tags": [ + "Password Reset" + ] + } + }, + "/api/v1/puzzle/{id}": { + "delete": { + "callbacks": {}, + "description": "Deletes a puzzle. Only available to puzzle author or admins.", + "operationId": "CodincodApiWeb.PuzzleController.delete (2)", + "parameters": [ + { + "description": "Puzzle UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "204": { + "description": "Puzzle deleted" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Delete puzzle", + "tags": [ + "Puzzle" + ] + }, + "get": { + "callbacks": {}, + "description": "Returns a single puzzle by ID (public view, no solution details).", + "operationId": "CodincodApiWeb.PuzzleController.show (2)", + "parameters": [ + { + "description": "Puzzle UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleResponse" + } + } + }, + "description": "Puzzle found" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Get puzzle by ID", + "tags": [ + "Puzzle" + ] + }, + "patch": { + "callbacks": {}, + "description": "Updates an existing puzzle. Only available to puzzle author or admins.", + "operationId": "CodincodApiWeb.PuzzleController.update (2)", + "parameters": [ + { + "description": "Puzzle UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleCreateRequest" + } + } + }, + "description": "Puzzle update payload", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleResponse" + } + } + }, + "description": "Puzzle updated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable entity" + } + }, + "summary": "Update puzzle", + "tags": [ + "Puzzle" + ] + } + }, + "/api/v1/moderation/report/{id}/resolve": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.resolve_report (2)", + "parameters": [ + { + "description": "Report identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveReportRequest" + } + } + }, + "description": "Resolution payload", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportResponse" + } + } + }, + "description": "Report resolved" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Report not found" + } + }, + "summary": "Resolve a report (admin only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/execute": { + "post": { + "callbacks": {}, + "description": "Runs code against Piston with custom test input/output for validation", + "operationId": "CodincodApiWeb.ExecuteController.create", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteRequest" + } + } + }, + "description": "Execute request", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteResponse" + } + } + }, + "description": "Execution result" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service unavailable" + } + }, + "summary": "Execute code without saving", + "tags": [ + "Execute" + ] + } + }, + "/api/user/{username}/puzzle": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.UserController.puzzles (2)", + "parameters": [ + { + "description": "Username whose puzzles will be listed", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "", + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "", + "in": "query", + "name": "pageSize", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedListResponse" + } + } + }, + "description": "Paginated puzzles" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid parameters" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not found" + } + }, + "summary": "List puzzles authored by a user", + "tags": [ + "User" + ] + } + }, + "/api/password-reset/reset": { + "post": { + "callbacks": {}, + "description": "Validates token and updates user password", + "operationId": "CodincodApiWeb.PasswordResetController.reset_password (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPayload" + } + } + }, + "description": "Reset payload", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetResponse" + } + } + }, + "description": "Password reset" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload or token" + } + }, + "summary": "Reset password with token", + "tags": [ + "Password Reset" + ] + } + }, + "/api/user/{username}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.UserController.show (2)", + "parameters": [ + { + "description": "Username to look up", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShowResponse" + } + } + }, + "description": "User" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid username" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not found" + } + }, + "summary": "Get user by username", + "tags": [ + "User" + ] + } + }, + "/api/account/leaderboard": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountController.leaderboard_rank (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserRankResponse" + } + } + }, + "description": "User ranking" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Get current user's leaderboard ranking", + "tags": [ + "Account" + ] + } + }, + "/api/logout": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AuthController.logout (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + }, + "description": "Logout success" + } + }, + "summary": "Logout current user", + "tags": [ + "Auth" + ] + } + }, + "/api/account": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountController.show (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountStatusResponse" + } + } + }, + "description": "Authenticated account" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountStatusResponse" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Current account status", + "tags": [ + "Account" + ] + } + }, + "/api/v1/submission/{id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.SubmissionController.show", + "parameters": [ + { + "description": "Submission identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmissionResponse" + } + } + }, + "description": "Submission" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid id" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not found" + } + }, + "summary": "Fetch submission by id", + "tags": [ + "Submission" + ] + } + }, + "/api/puzzle/{id}/comment": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.PuzzleCommentController.create (2)", + "parameters": [ + { + "description": "Puzzle ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRequest" + } + } + }, + "description": "Comment creation payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentResponse" + } + } + }, + "description": "Comment created successfully" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle or parent comment not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Cannot reply to deleted comment or invalid parent" + } + }, + "summary": "Create a comment on a puzzle", + "tags": [] + } + }, + "/api/v1/register": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AuthController.register", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + } + } + }, + "description": "Registration payload", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + }, + "description": "Registration success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Register new user", + "tags": [ + "Auth" + ] + } + }, + "/api/v1/user/{username}/activity": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.UserController.activity", + "parameters": [ + { + "description": "Username to inspect", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityResponse" + } + } + }, + "description": "Activity" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid username" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Get user activity (puzzles and submissions)", + "tags": [ + "User" + ] + } + }, + "/api/leaderboard/global": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.LeaderboardController.global (2)", + "parameters": [ + { + "description": "Game mode filter", + "in": "query", + "name": "game_mode", + "required": false, + "schema": { + "enum": [ + "standard", + "timed", + "ranked" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Number of entries to return (1-100)", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Pagination offset", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GlobalLeaderboardResponse" + } + } + }, + "description": "Leaderboard rankings" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + } + }, + "summary": "Get global leaderboard rankings", + "tags": [ + "Leaderboard" + ] + } + }, + "/api/v1/password-reset/reset": { + "post": { + "callbacks": {}, + "description": "Validates token and updates user password", + "operationId": "CodincodApiWeb.PasswordResetController.reset_password", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPayload" + } + } + }, + "description": "Reset payload", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetResponse" + } + } + }, + "description": "Password reset" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload or token" + } + }, + "summary": "Reset password with token", + "tags": [ + "Password Reset" + ] + } + }, + "/api/moderation/user/{user_id}/ban": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.ban_user", + "parameters": [ + { + "description": "User identifier", + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BanUserRequest" + } + } + }, + "description": "Ban details", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BanResponse" + } + } + }, + "description": "User banned" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User not found" + } + }, + "summary": "Ban a user (admin only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/health": { + "get": { + "callbacks": {}, + "description": "Returns service health status", + "operationId": "CodincodApiWeb.HealthController.show (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "status": { + "example": "OK", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + } + } + }, + "description": "Health status" + } + }, + "summary": "Health check", + "tags": [ + "Health" + ] + } + }, + "/api/v1/moderation/reports": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.list_reports", + "parameters": [ + { + "description": "Filter by status", + "in": "query", + "name": "status", + "required": false, + "schema": { + "enum": [ + "pending", + "reviewing", + "resolved", + "dismissed" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Filter by problem type", + "in": "query", + "name": "problem_type", + "required": false, + "schema": { + "enum": [ + "spam", + "inappropriate", + "copyright", + "harassment", + "other" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportsListResponse" + } + } + }, + "description": "Reports list" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + } + }, + "summary": "List reports (admin only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/games": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.create (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateGameRequest" + } + } + }, + "description": "Game creation payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameResponse" + } + } + }, + "description": "Game created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + } + }, + "summary": "Create a new game lobby", + "tags": [ + "Games" + ] + } + }, + "/api/games/waiting": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.list_waiting_rooms", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WaitingRoomsResponse" + } + } + }, + "description": "Waiting rooms" + } + }, + "summary": "List all waiting game lobbies", + "tags": [ + "Games" + ] + } + }, + "/api/v1/account/preferences": { + "delete": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountPreferenceController.delete", + "parameters": [], + "responses": { + "204": { + "content": { + "application/json": {} + }, + "description": "Preferences deleted" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Delete preferences", + "tags": [ + "Account Preferences" + ] + }, + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountPreferenceController.show", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Preferences" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Get account preferences", + "tags": [ + "Account Preferences" + ] + }, + "patch": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountPreferenceController.patch", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Partial preferences", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Updated preferences" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Patch preferences", + "tags": [ + "Account Preferences" + ] + }, + "put": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountPreferenceController.replace", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Preferences payload", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesPayload" + } + } + }, + "description": "Updated preferences" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Replace preferences", + "tags": [ + "Account Preferences" + ] + } + }, + "/api/puzzle/{id}/solution": { + "get": { + "callbacks": {}, + "description": "Returns puzzle with full solution details. Only available to puzzle author or admins.", + "operationId": "CodincodApiWeb.PuzzleController.solution", + "parameters": [ + { + "description": "Puzzle UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleResponse" + } + } + }, + "description": "Puzzle solution" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Get puzzle solution for editing", + "tags": [ + "Puzzle" + ] + } + }, + "/api/v1/metrics/user/{user_id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.MetricsController.user_stats (2)", + "parameters": [ + { + "description": "User identifier", + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStatsResponse" + } + } + }, + "description": "User statistics" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User not found" + } + }, + "summary": "Get detailed statistics for a user", + "tags": [ + "Metrics" + ] + } + }, + "/api/v1/games/{id}/leave": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.leave (2)", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LeaveGameResponse" + } + } + }, + "description": "Left game" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game not found" + } + }, + "summary": "Leave a game lobby", + "tags": [ + "Games" + ] + } + }, + "/api/v1/user/{username}/puzzle": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.UserController.puzzles", + "parameters": [ + { + "description": "Username whose puzzles will be listed", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "", + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "", + "in": "query", + "name": "pageSize", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedListResponse" + } + } + }, + "description": "Paginated puzzles" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid parameters" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not found" + } + }, + "summary": "List puzzles authored by a user", + "tags": [ + "User" + ] + } + }, + "/api/moderation/reviews": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.list_reviews", + "parameters": [ + { + "description": "Filter by status", + "in": "query", + "name": "status", + "required": false, + "schema": { + "enum": [ + "pending", + "approved", + "rejected" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewsListResponse" + } + } + }, + "description": "Reviews list" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + } + }, + "summary": "List pending moderation reviews (moderator only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/user/{username}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.UserController.show", + "parameters": [ + { + "description": "Username to look up", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShowResponse" + } + } + }, + "description": "User" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid username" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not found" + } + }, + "summary": "Get user by username", + "tags": [ + "User" + ] + } + }, + "/api/games/{id}/start": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.start", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameResponse" + } + } + }, + "description": "Game started" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not game host" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game not found" + } + }, + "summary": "Start a game (host only)", + "tags": [ + "Games" + ] + } + }, + "/api/moderation/review/{id}": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.review_content (2)", + "parameters": [ + { + "description": "Review identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewDecisionRequest" + } + } + }, + "description": "Review decision", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewResponse" + } + } + }, + "description": "Review updated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Review not found" + } + }, + "summary": "Review and approve/reject content (moderator only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/login": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AuthController.login", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "description": "Credentials", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + }, + "description": "Login success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Authenticate user", + "tags": [ + "Auth" + ] + } + }, + "/api/user/{username}/isAvailable": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.UserController.availability (2)", + "parameters": [ + { + "description": "Desired username", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AvailabilityResponse" + } + } + }, + "description": "Availability" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid username" + } + }, + "summary": "Check username availability", + "tags": [ + "User" + ] + } + }, + "/api/account/games": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountController.games (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserGamesResponse" + } + } + }, + "description": "User games" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Get games for current user", + "tags": [ + "Account" + ] + } + }, + "/api/v1/logout": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AuthController.logout", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + }, + "description": "Logout success" + } + }, + "summary": "Logout current user", + "tags": [ + "Auth" + ] + } + }, + "/api/v1/leaderboard/global": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.LeaderboardController.global", + "parameters": [ + { + "description": "Game mode filter", + "in": "query", + "name": "game_mode", + "required": false, + "schema": { + "enum": [ + "standard", + "timed", + "ranked" + ], + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Number of entries to return (1-100)", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Pagination offset", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "minimum": 0, + "type": "integer", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GlobalLeaderboardResponse" + } + } + }, + "description": "Leaderboard rankings" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + } + }, + "summary": "Get global leaderboard rankings", + "tags": [ + "Leaderboard" + ] + } + }, + "/api/v1/programming-languages": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ProgrammingLanguageController.index (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "properties": { + "aliases": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isActive": { + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "language": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "runtime": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "version": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + } + } + }, + "description": "Programming languages list" + } + }, + "summary": "List all programming languages", + "tags": [] + } + }, + "/api/moderation/report": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.create_report (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReportRequest" + } + } + }, + "description": "Report payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportResponse" + } + } + }, + "description": "Report created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + } + }, + "summary": "Create a new report for inappropriate content", + "tags": [ + "Moderation" + ] + } + }, + "/api/games/{id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.show (2)", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameResponse" + } + } + }, + "description": "Game details" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game not found" + } + }, + "summary": "Get game details", + "tags": [ + "Games" + ] + } + }, + "/api/metrics/user/{user_id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.MetricsController.user_stats", + "parameters": [ + { + "description": "User identifier", + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStatsResponse" + } + } + }, + "description": "User statistics" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User not found" + } + }, + "summary": "Get detailed statistics for a user", + "tags": [ + "Metrics" + ] + } + }, + "/api/games/{id}/submit": { + "post": { + "callbacks": {}, + "description": "Links an existing submission to a game, marking it as a player's game submission.", + "operationId": "CodincodApiWeb.GameController.submit_code", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameSubmitCodeRequest" + } + } + }, + "description": "Game submission", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitCodeResponse" + } + } + }, + "description": "Submission linked to game" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not a game participant" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game or submission not found" + } + }, + "summary": "Submit code for a game", + "tags": [ + "Games" + ] + } + }, + "/api/comment/{id}/vote": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.CommentController.vote (2)", + "parameters": [ + { + "description": "Comment ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoteRequest" + } + } + }, + "description": "Vote request", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentResponse" + } + } + }, + "description": "Updated comment with vote" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid vote type" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Comment not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unable to process vote" + } + }, + "summary": "Vote on a comment", + "tags": [] + } + }, + "/api/moderation/report/{id}/resolve": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.resolve_report", + "parameters": [ + { + "description": "Report identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveReportRequest" + } + } + }, + "description": "Resolution payload", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportResponse" + } + } + }, + "description": "Report resolved" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Report not found" + } + }, + "summary": "Resolve a report (admin only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/account": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountController.show", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountStatusResponse" + } + } + }, + "description": "Authenticated account" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountStatusResponse" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Current account status", + "tags": [ + "Account" + ] + } + }, + "/api/account/profile": { + "patch": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountController.update_profile (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileUpdateRequest" + } + } + }, + "description": "Profile properties", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileUpdateResponse" + } + } + }, + "description": "Profile updated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Update profile", + "tags": [ + "Account" + ] + } + }, + "/api/v1/user/{username}/isAvailable": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.UserController.availability", + "parameters": [ + { + "description": "Desired username", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AvailabilityResponse" + } + } + }, + "description": "Availability" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid username" + } + }, + "summary": "Check username availability", + "tags": [ + "User" + ] + } + }, + "/api/metrics/platform": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.MetricsController.platform (2)", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformMetricsResponse" + } + } + }, + "description": "Platform metrics" + } + }, + "summary": "Get platform-wide statistics", + "tags": [ + "Metrics" + ] + } + }, + "/api/v1/games/{id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.show", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameResponse" + } + } + }, + "description": "Game details" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game not found" + } + }, + "summary": "Get game details", + "tags": [ + "Games" + ] + } + }, + "/api/puzzle/{id}": { + "delete": { + "callbacks": {}, + "description": "Deletes a puzzle. Only available to puzzle author or admins.", + "operationId": "CodincodApiWeb.PuzzleController.delete", + "parameters": [ + { + "description": "Puzzle UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "204": { + "description": "Puzzle deleted" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Delete puzzle", + "tags": [ + "Puzzle" + ] + }, + "get": { + "callbacks": {}, + "description": "Returns a single puzzle by ID (public view, no solution details).", + "operationId": "CodincodApiWeb.PuzzleController.show", + "parameters": [ + { + "description": "Puzzle UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleResponse" + } + } + }, + "description": "Puzzle found" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Get puzzle by ID", + "tags": [ + "Puzzle" + ] + }, + "patch": { + "callbacks": {}, + "description": "Updates an existing puzzle. Only available to puzzle author or admins.", + "operationId": "CodincodApiWeb.PuzzleController.update", + "parameters": [ + { + "description": "Puzzle UUID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleCreateRequest" + } + } + }, + "description": "Puzzle update payload", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleResponse" + } + } + }, + "description": "Puzzle updated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable entity" + } + }, + "summary": "Update puzzle", + "tags": [ + "Puzzle" + ] + } + }, + "/api/puzzles": { + "get": { + "callbacks": {}, + "description": "Returns paginated puzzles matching the legacy Fastify `/puzzle` listing response.", + "operationId": "CodincodApiWeb.PuzzleController.index", + "parameters": [ + { + "description": "Page number", + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Number of puzzles per page", + "in": "query", + "name": "pageSize", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedListResponse" + } + } + }, + "description": "Paginated puzzles" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid query" + } + }, + "summary": "List puzzles", + "tags": [ + "Puzzle" + ] + }, + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.PuzzleController.create", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleCreateRequest" + } + } + }, + "description": "Puzzle creation payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleResponse" + } + } + }, + "description": "Puzzle created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable entity" + } + }, + "summary": "Create puzzle", + "tags": [ + "Puzzle" + ] + } + }, + "/api/refresh": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AuthController.refresh", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + }, + "description": "Token refreshed" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Server error" + } + }, + "summary": "Refresh authentication token", + "tags": [ + "Auth" + ] + } + }, + "/api/v1/puzzle/{id}/comment": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.PuzzleCommentController.create", + "parameters": [ + { + "description": "Puzzle ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRequest" + } + } + }, + "description": "Comment creation payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentResponse" + } + } + }, + "description": "Comment created successfully" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle or parent comment not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Cannot reply to deleted comment or invalid parent" + } + }, + "summary": "Create a comment on a puzzle", + "tags": [] + } + }, + "/api/v1/metrics/puzzle/{puzzle_id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.MetricsController.puzzle_stats", + "parameters": [ + { + "description": "Puzzle identifier", + "in": "path", + "name": "puzzle_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleStatsResponse" + } + } + }, + "description": "Puzzle statistics" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Get detailed statistics for a puzzle", + "tags": [ + "Metrics" + ] + } + }, + "/api/submission": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.SubmissionController.create (2)", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitCodeRequest" + } + } + }, + "description": "Submission payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitCodeResponse" + } + } + }, + "description": "Submission created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Execution unavailable" + } + }, + "summary": "Submit code for evaluation", + "tags": [ + "Submission" + ] + } + }, + "/api/comment/{id}": { + "delete": { + "callbacks": {}, + "operationId": "CodincodApiWeb.CommentController.delete (2)", + "parameters": [ + { + "description": "Comment ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "204": { + "description": "Comment deleted successfully" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not authorized to delete this comment" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Comment not found" + } + }, + "summary": "Delete a comment", + "tags": [] + }, + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.CommentController.show (2)", + "parameters": [ + { + "description": "Comment ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentResponse" + } + } + }, + "description": "Comment details" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Comment not found" + } + }, + "summary": "Get comment by ID", + "tags": [] + } + }, + "/api/v1/moderation/report": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.create_report", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReportRequest" + } + } + }, + "description": "Report payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportResponse" + } + } + }, + "description": "Report created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + } + }, + "summary": "Create a new report for inappropriate content", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/account/profile": { + "patch": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountController.update_profile", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileUpdateRequest" + } + } + }, + "description": "Profile properties", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileUpdateResponse" + } + } + }, + "description": "Profile updated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Update profile", + "tags": [ + "Account" + ] + } + }, + "/api/v1/moderation/user/{user_id}/unban": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.unban_user", + "parameters": [ + { + "description": "User identifier", + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BanResponse" + } + } + }, + "description": "User unbanned" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User not found" + } + }, + "summary": "Unban a user (admin only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/v1/comment/{id}/vote": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.CommentController.vote", + "parameters": [ + { + "description": "Comment ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoteRequest" + } + } + }, + "description": "Vote request", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentResponse" + } + } + }, + "description": "Updated comment with vote" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid vote type" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Comment not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unable to process vote" + } + }, + "summary": "Vote on a comment", + "tags": [] + } + }, + "/api/v1/account/games": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountController.games", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserGamesResponse" + } + } + }, + "description": "User games" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Get games for current user", + "tags": [ + "Account" + ] + } + }, + "/api/v1/account/leaderboard": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.AccountController.leaderboard_rank", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserRankResponse" + } + } + }, + "description": "User ranking" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Get current user's leaderboard ranking", + "tags": [ + "Account" + ] + } + }, + "/api/v1/comment/{id}": { + "delete": { + "callbacks": {}, + "operationId": "CodincodApiWeb.CommentController.delete", + "parameters": [ + { + "description": "Comment ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "204": { + "description": "Comment deleted successfully" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not authorized to delete this comment" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Comment not found" + } + }, + "summary": "Delete a comment", + "tags": [] + }, + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.CommentController.show", + "parameters": [ + { + "description": "Comment ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentResponse" + } + } + }, + "description": "Comment details" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Comment not found" + } + }, + "summary": "Get comment by ID", + "tags": [] + } + }, + "/api/games/{id}/leave": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.leave", + "parameters": [ + { + "description": "Game identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LeaveGameResponse" + } + } + }, + "description": "Left game" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Game not found" + } + }, + "summary": "Leave a game lobby", + "tags": [ + "Games" + ] + } + }, + "/api/v1/moderation/review/{id}": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ModerationController.review_content", + "parameters": [ + { + "description": "Review identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewDecisionRequest" + } + } + }, + "description": "Review decision", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewResponse" + } + } + }, + "description": "Review updated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Review not found" + } + }, + "summary": "Review and approve/reject content (moderator only)", + "tags": [ + "Moderation" + ] + } + }, + "/api/games": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.GameController.create", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateGameRequest" + } + } + }, + "description": "Game creation payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GameResponse" + } + } + }, + "description": "Game created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + } + }, + "summary": "Create a new game lobby", + "tags": [ + "Games" + ] + } + }, + "/api/v1/metrics/platform": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.MetricsController.platform", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformMetricsResponse" + } + } + }, + "description": "Platform metrics" + } + }, + "summary": "Get platform-wide statistics", + "tags": [ + "Metrics" + ] + } + }, + "/api/programming-languages": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.ProgrammingLanguageController.index", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "properties": { + "aliases": { + "items": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + }, + "id": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "isActive": { + "type": "boolean", + "x-struct": null, + "x-validate": null + }, + "language": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "runtime": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "version": { + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + }, + "type": "array", + "x-struct": null, + "x-validate": null + } + } + }, + "description": "Programming languages list" + } + }, + "summary": "List all programming languages", + "tags": [] + } + }, + "/api/v1/leaderboard/puzzle/{puzzle_id}": { + "get": { + "callbacks": {}, + "operationId": "CodincodApiWeb.LeaderboardController.puzzle", + "parameters": [ + { + "description": "Puzzle identifier", + "in": "path", + "name": "puzzle_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + { + "description": "Number of entries to return (1-100)", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer", + "x-struct": null, + "x-validate": null + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PuzzleLeaderboardResponse" + } + } + }, + "description": "Puzzle leaderboard" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + } + }, + "summary": "Get puzzle-specific leaderboard", + "tags": [ + "Leaderboard" + ] + } + }, + "/api/v1/submission": { + "post": { + "callbacks": {}, + "operationId": "CodincodApiWeb.SubmissionController.create", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitCodeRequest" + } + } + }, + "description": "Submission payload", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitCodeResponse" + } + } + }, + "description": "Submission created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid payload" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Puzzle not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Execution unavailable" + } + }, + "summary": "Submit code for evaluation", + "tags": [ + "Submission" + ] + } + }, + "/api/health": { + "get": { + "callbacks": {}, + "description": "Returns service health status", + "operationId": "CodincodApiWeb.HealthController.show", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "status": { + "example": "OK", + "type": "string", + "x-struct": null, + "x-validate": null + } + }, + "type": "object", + "x-struct": null, + "x-validate": null + } + } + }, + "description": "Health status" + } + }, + "summary": "Health check", + "tags": [ + "Health" + ] + } + } + }, + "security": [], + "servers": [ + { + "url": "http://localhost:4000", + "variables": {} + } + ], + "tags": [] +} diff --git a/libs/elixir-backend/codincod_api/priv/static/robots.txt b/libs/elixir-backend/codincod_api/priv/static/robots.txt new file mode 100644 index 00000000..26e06b5f --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/libs/elixir-backend/codincod_api/test/codincod_api_web/controllers/error_json_test.exs b/libs/elixir-backend/codincod_api/test/codincod_api_web/controllers/error_json_test.exs new file mode 100644 index 00000000..afd407f1 --- /dev/null +++ b/libs/elixir-backend/codincod_api/test/codincod_api_web/controllers/error_json_test.exs @@ -0,0 +1,12 @@ +defmodule CodincodApiWeb.ErrorJSONTest do + use CodincodApiWeb.ConnCase, async: true + + test "renders 404" do + assert CodincodApiWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500" do + assert CodincodApiWeb.ErrorJSON.render("500.json", %{}) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/libs/elixir-backend/codincod_api/test/codincod_api_web/controllers/submission_controller_test.exs b/libs/elixir-backend/codincod_api/test/codincod_api_web/controllers/submission_controller_test.exs new file mode 100644 index 00000000..28ed8194 --- /dev/null +++ b/libs/elixir-backend/codincod_api/test/codincod_api_web/controllers/submission_controller_test.exs @@ -0,0 +1,207 @@ +defmodule CodincodApiWeb.SubmissionControllerTest do + use CodincodApiWeb.ConnCase, async: true + + import Ecto.Changeset + + alias CodincodApi.Accounts.{Password, User} + alias CodincodApi.Languages.ProgrammingLanguage + alias CodincodApi.Puzzles.{Puzzle, PuzzleValidator} + alias CodincodApi.Submissions.Submission + alias CodincodApi.Repo + + @valid_password "Sup3rSecurePass!" + + setup %{conn: conn} do + user = insert_user!(%{username: "submitter"}) + {:ok, token, _claims} = CodincodApiWeb.Auth.Guardian.generate_token(user) + + authed_conn = + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", "Bearer #{token}") + + on_exit(fn -> + Application.delete_env(:codincod_api, :piston_mock_execute) + end) + + {:ok, conn: authed_conn, user: user} + end + + describe "POST /api/v1/submission" do + test "creates a submission and returns result summary", %{conn: conn, user: user} do + language = insert_language!(%{language: "python", version: "3.10.0", runtime: "python"}) + author = insert_user!(%{username: "puzzle-author"}) + puzzle = insert_puzzle!(author, %{title: "Echo Puzzle"}) + insert_validator!(puzzle, %{input: "hello", output: "hello"}) + + body = %{ + "puzzleId" => puzzle.id, + "programmingLanguageId" => language.id, + "code" => "print('hello')", + "userId" => user.id + } + + response = + conn + |> post(~p"/api/v1/submission", body) + |> json_response(201) + + assert response["puzzleId"] == puzzle.id + assert response["programmingLanguageId"] == language.id + assert response["userId"] == user.id + assert response["codeLength"] == String.length(body["code"]) + + assert %{"successRate" => 1.0, "passed" => 1, "failed" => 0, "total" => 1} = + response["result"] + + submission = Repo.get!(Submission, response["submissionId"]) + assert submission.result["result"] == "success" + assert submission.result["successRate"] == 1.0 + end + + test "returns 404 when puzzle is missing", %{conn: conn, user: user} do + language = insert_language!(%{language: "python", version: "3.10.0", runtime: "python"}) + missing_id = Ecto.UUID.generate() + + body = %{ + "puzzleId" => missing_id, + "programmingLanguageId" => language.id, + "code" => "print('oops')", + "userId" => user.id + } + + response = + conn + |> post(~p"/api/v1/submission", body) + |> json_response(404) + + assert response["error"] == "Puzzle not found" + end + + test "returns 400 when puzzle lacks validators", %{conn: conn, user: user} do + language = insert_language!(%{language: "python", version: "3.10.0", runtime: "python"}) + author = insert_user!(%{username: "no-validators"}) + puzzle = insert_puzzle!(author, %{title: "Incomplete Puzzle"}) + + body = %{ + "puzzleId" => puzzle.id, + "programmingLanguageId" => language.id, + "code" => "print('test')", + "userId" => user.id + } + + response = + conn + |> post(~p"/api/v1/submission", body) + |> json_response(400) + + assert response["error"] == "Failed to update the puzzle" + end + end + + describe "GET /api/v1/submission/:id" do + test "returns submission details", %{conn: conn, user: user} do + language = insert_language!(%{language: "python", version: "3.10.0", runtime: "python"}) + author = insert_user!(%{username: "puzzle-owner"}) + puzzle = insert_puzzle!(author, %{title: "Stored Puzzle"}) + + submission = + %Submission{} + |> change(%{ + user_id: user.id, + puzzle_id: puzzle.id, + programming_language_id: language.id, + code: "print('stored')", + result: %{ + "result" => "success", + "successRate" => 1.0, + "passed" => 1, + "failed" => 0, + "total" => 1 + } + }) + |> Repo.insert!() + |> Repo.preload([:programming_language, :puzzle, :user]) + + response = + conn + |> get(~p"/api/v1/submission/#{submission.id}") + |> json_response(200) + + assert response["id"] == submission.id + assert response["code"] == submission.code + assert response["programmingLanguage"]["id"] == submission.programming_language_id + assert response["user"]["id"] == user.id + end + end + + defp insert_user!(attrs) do + base_attrs = %{ + username: "user" <> unique_suffix(), + email: unique_email(), + profile: %{}, + role: "user" + } + + attrs = Map.merge(base_attrs, attrs) + + {:ok, password_hash} = Password.hash(@valid_password) + + %User{} + |> change(%{ + username: attrs.username, + email: attrs.email, + profile: attrs.profile, + role: attrs.role, + password_hash: password_hash + }) + |> Repo.insert!() + end + + defp insert_language!(attrs) do + %ProgrammingLanguage{} + |> change(%{ + language: Map.get(attrs, :language, "python"), + version: Map.get(attrs, :version, "3.10.0"), + runtime: Map.get(attrs, :runtime, "python"), + aliases: Map.get(attrs, :aliases, []), + is_active: Map.get(attrs, :is_active, true) + }) + |> Repo.insert!() + end + + defp insert_puzzle!(%User{id: author_id}, attrs) do + %Puzzle{} + |> change(%{ + title: Map.get(attrs, :title, "Puzzle #{unique_suffix()}"), + statement: Map.get(attrs, :statement, "Solve me"), + constraints: Map.get(attrs, :constraints, nil), + author_id: author_id, + difficulty: Map.get(attrs, :difficulty, "BEGINNER"), + visibility: Map.get(attrs, :visibility, "APPROVED"), + tags: [], + solution: %{} + }) + |> Repo.insert!() + end + + defp insert_validator!(%Puzzle{id: puzzle_id}, attrs) do + %PuzzleValidator{} + |> change(%{ + puzzle_id: puzzle_id, + input: Map.get(attrs, :input, ""), + output: Map.get(attrs, :output, ""), + is_public: Map.get(attrs, :is_public, false) + }) + |> Repo.insert!() + end + + defp unique_suffix do + System.unique_integer([:positive]) |> Integer.to_string() + end + + defp unique_email do + "user-" <> unique_suffix() <> "@example.com" + end +end diff --git a/libs/elixir-backend/codincod_api/test/codincod_api_web/controllers/user_controller_test.exs b/libs/elixir-backend/codincod_api/test/codincod_api_web/controllers/user_controller_test.exs new file mode 100644 index 00000000..392a55b2 --- /dev/null +++ b/libs/elixir-backend/codincod_api/test/codincod_api_web/controllers/user_controller_test.exs @@ -0,0 +1,171 @@ +defmodule CodincodApiWeb.UserControllerTest do + use CodincodApiWeb.ConnCase, async: true + + import Ecto.Changeset + + alias CodincodApi.Accounts.{Password, User} + alias CodincodApi.Puzzles.Puzzle + alias CodincodApi.Repo + + @valid_password "Sup3rSecurePass!" + + describe "GET /api/v1/user/:username" do + test "returns user details", %{conn: conn} do + user = insert_user!(%{username: "ada", email: "ada@example.com"}) + + response = + conn + |> get(~p"/api/v1/user/#{user.username}") + |> json_response(200) + + assert response["message"] == "User found" + assert response["user"]["id"] == user.id + assert response["user"]["username"] == user.username + end + + test "returns 404 when user missing", %{conn: conn} do + response = + conn + |> get(~p"/api/v1/user/missing-user") + |> json_response(404) + + assert response["message"] == "User not found" + end + end + + describe "GET /api/v1/user/:username/isAvailable" do + test "reports username availability", %{conn: conn} do + insert_user!(%{username: "lovelace"}) + + response = + conn + |> get(~p"/api/v1/user/lovelace/isAvailable") + |> json_response(200) + + refute response["available"] + + response = + conn + |> get(~p"/api/v1/user/newhandle/isAvailable") + |> json_response(200) + + assert response["available"] + end + end + + describe "GET /api/v1/user/:username/puzzle" do + test "paginates public puzzles for non-owners", %{conn: conn} do + author = insert_user!(%{username: "puzzler"}) + + insert_puzzle!(author, %{title: "Approved Puzzle", visibility: "APPROVED"}) + insert_puzzle!(author, %{title: "Draft Puzzle", visibility: "DRAFT"}) + + response = + conn + |> get(~p"/api/v1/user/#{author.username}/puzzle", %{page: 1, pageSize: 10}) + |> json_response(200) + + assert response["totalItems"] == 1 + [puzzle] = response["items"] + assert puzzle["title"] == "Approved Puzzle" + assert puzzle["visibility"] == "approved" + + # Ensure hitting the /api route remains compatible + response_legacy = + conn + |> get(~p"/api/user/#{author.username}/puzzle", %{page: 1, pageSize: 10}) + |> json_response(200) + + assert response_legacy["totalItems"] == 1 + end + end + + describe "GET /api/v1/user/:username/activity" do + test "returns public activity", %{conn: conn} do + author = insert_user!(%{username: "activity"}) + language = insert_language!(%{language: "elixir", version: "1.16"}) + puzzle = insert_puzzle!(author, %{title: "Activity Puzzle", visibility: "APPROVED"}) + + insert_submission!(author, puzzle, language) + + response = + conn + |> get(~p"/api/v1/user/#{author.username}/activity") + |> json_response(200) + + assert response["message"] == "User activity found" + assert length(response["activity"]["puzzles"]) == 1 + assert length(response["activity"]["submissions"]) == 1 + end + end + + defp insert_user!(attrs) do + base_attrs = %{ + username: "tester" <> unique_suffix(), + email: unique_email(), + profile: %{}, + role: "user" + } + + attrs = Map.merge(base_attrs, attrs) + + {:ok, password_hash} = Password.hash(@valid_password) + + %User{} + |> change(%{ + username: attrs.username, + email: attrs.email, + profile: attrs.profile, + role: attrs.role, + password_hash: password_hash + }) + |> Repo.insert!() + end + + defp insert_puzzle!(%User{id: author_id}, attrs) do + %Puzzle{} + |> change(%{ + title: Map.get(attrs, :title, "Sample Puzzle #{unique_suffix()}"), + statement: "Solve it!", + constraints: nil, + author_id: author_id, + difficulty: Map.get(attrs, :difficulty, "BEGINNER"), + visibility: Map.get(attrs, :visibility, "APPROVED"), + tags: [], + solution: %{} + }) + |> Repo.insert!() + end + + defp insert_language!(attrs) do + %CodincodApi.Languages.ProgrammingLanguage{} + |> change(%{ + language: Map.get(attrs, :language, "elixir"), + version: Map.get(attrs, :version, "1.16"), + aliases: [], + runtime: Map.get(attrs, :runtime, "elixir"), + is_active: true + }) + |> Repo.insert!() + end + + defp insert_submission!(%User{id: user_id}, %Puzzle{id: puzzle_id}, language) do + %CodincodApi.Submissions.Submission{} + |> change(%{ + user_id: user_id, + puzzle_id: puzzle_id, + programming_language_id: language.id, + code: "IO.puts(:hello)", + result: %{"status" => "success"} + }) + |> Repo.insert!() + end + + defp unique_suffix do + System.unique_integer([:positive]) |> Integer.to_string() + end + + defp unique_email do + "user-" <> unique_suffix() <> "@example.com" + end +end diff --git a/libs/elixir-backend/codincod_api/test/support/conn_case.ex b/libs/elixir-backend/codincod_api/test/support/conn_case.ex new file mode 100644 index 00000000..3f1faa27 --- /dev/null +++ b/libs/elixir-backend/codincod_api/test/support/conn_case.ex @@ -0,0 +1,38 @@ +defmodule CodincodApiWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use CodincodApiWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint CodincodApiWeb.Endpoint + + use CodincodApiWeb, :verified_routes + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import CodincodApiWeb.ConnCase + end + end + + setup tags do + CodincodApi.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/libs/elixir-backend/codincod_api/test/support/data_case.ex b/libs/elixir-backend/codincod_api/test/support/data_case.ex new file mode 100644 index 00000000..34f06c13 --- /dev/null +++ b/libs/elixir-backend/codincod_api/test/support/data_case.ex @@ -0,0 +1,58 @@ +defmodule CodincodApi.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use CodincodApi.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + alias CodincodApi.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import CodincodApi.DataCase + end + end + + setup tags do + CodincodApi.DataCase.setup_sandbox(tags) + :ok + end + + @doc """ + Sets up the sandbox based on the test tags. + """ + def setup_sandbox(tags) do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(CodincodApi.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/libs/elixir-backend/codincod_api/test/test_helper.exs b/libs/elixir-backend/codincod_api/test/test_helper.exs new file mode 100644 index 00000000..fa96ea11 --- /dev/null +++ b/libs/elixir-backend/codincod_api/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(CodincodApi.Repo, :manual) diff --git a/libs/elixir-backend/codincod_api/test_migration.sh b/libs/elixir-backend/codincod_api/test_migration.sh new file mode 100644 index 00000000..b26d5eec --- /dev/null +++ b/libs/elixir-backend/codincod_api/test_migration.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +# Master test script for MongoDB to PostgreSQL migration +# This script: +# 1. Seeds MongoDB with test data +# 2. Runs the migration +# 3. Verifies the migrated data +# +# Usage: +# ./test_migration.sh + +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}========================================${NC}" +echo -e "${BLUE} MongoDB → PostgreSQL Migration Test${NC}" +echo -e "${BLUE}========================================${NC}" +echo "" + +# Check environment variables +if [ -z "$MONGO_URI" ]; then + echo -e "${RED}ERROR: MONGO_URI environment variable not set${NC}" + echo "Please set it to your MongoDB connection string:" + echo " export MONGO_URI='mongodb+srv://...'" + exit 1 +fi + +if [ -z "$MONGO_DB_NAME" ]; then + echo -e "${YELLOW}WARNING: MONGO_DB_NAME not set, using default: codincod-development${NC}" + export MONGO_DB_NAME="codincod-development" +fi + +echo -e "${GREEN}✓${NC} Environment variables set" +echo " MONGO_DB: $MONGO_DB_NAME" +echo "" + +# Step 1: Seed test data +echo -e "${BLUE}Step 1/3: Seeding MongoDB with test data...${NC}" +echo -e "${YELLOW}----------------------------------------${NC}" +mix run priv/repo/scripts/seed_test_data_mongodb.exs +if [ $? -ne 0 ]; then + echo -e "${RED}✗ Failed to seed test data${NC}" + exit 1 +fi +echo "" + +# Step 2: Run migration +echo -e "${BLUE}Step 2/3: Running migration...${NC}" +echo -e "${YELLOW}----------------------------------------${NC}" +mix migrate_mongo +if [ $? -ne 0 ]; then + echo -e "${RED}✗ Migration failed${NC}" + exit 1 +fi +echo "" + +# Step 3: Verify migration +echo -e "${BLUE}Step 3/3: Verifying migrated data...${NC}" +echo -e "${YELLOW}----------------------------------------${NC}" +mix run priv/repo/scripts/verify_migration.exs +if [ $? -ne 0 ]; then + echo -e "${RED}✗ Verification failed${NC}" + exit 1 +fi +echo "" + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN} ✓ Migration Test Complete!${NC}" +echo -e "${GREEN}========================================${NC}" diff --git a/libs/elixir-backend/complete_migration.exs b/libs/elixir-backend/complete_migration.exs new file mode 100644 index 00000000..095436bb --- /dev/null +++ b/libs/elixir-backend/complete_migration.exs @@ -0,0 +1,435 @@ +#!/usr/bin/env elixir + +# CodinCod Elixir Backend - Complete Migration Script +# This script guides you through completing the entire backend migration + +Mix.start() + +IO.puts """ +╔══════════════════════════════════════════════════════════════════════════╗ +║ ║ +║ CodinCod Backend Migration - Completion Guide ║ +║ ║ +║ TypeScript → Elixir Migration ║ +║ MongoDB → PostgreSQL Migration ║ +║ ║ +╚══════════════════════════════════════════════════════════════════════════╝ + +This guide will walk you through completing the backend migration. +Follow the steps carefully and run each command in sequence. + +""" + +defmodule MigrationGuide do + def step(number, title) do + IO.puts "\n┌────────────────────────────────────────────────────────────────────┐" + IO.puts "│ STEP #{number}: #{title}" + IO.puts "└────────────────────────────────────────────────────────────────────┘\n" + end + + def command(cmd, description) do + IO.puts " #{description}" + IO.puts " $ #{cmd}\n" + end + + def info(msg) do + IO.puts " ℹ️ #{msg}\n" + end + + def warn(msg) do + IO.ANSI.format([:yellow, " ⚠️ #{msg}\n"]) |> IO.write() + end + + def success(msg) do + IO.ANSI.format([:green, " ✅ #{msg}\n"]) |> IO.write() + end + + def pause() do + IO.gets("\n Press ENTER to continue...") + end +end + +import MigrationGuide + +step(1, "Fix bcrypt Compilation (Windows)") +info("Choose ONE of the following options:") +IO.puts """ + Option A: Use WSL2 (RECOMMENDED) + $ wsl --install + $ wsl + $ cd /mnt/c/Users/ReevenGovaert/Documents/projects/CodinCod/libs/elixir-backend/codincod_api + + Option B: Install Visual Studio Build Tools + Download from: https://visualstudio.microsoft.com/downloads/ + Install "Desktop development with C++" + + Option C: Use Docker + $ docker build -t codincod-api . + + Option D: Switch to pbkdf2 (dev only) + Replace bcrypt_elixir with pbkdf2_elixir in mix.exs +""" +pause() + +step(2, "Install Dependencies & Compile") +command("mix deps.get", "Download all dependencies") +command("mix deps.compile", "Compile dependencies") +command("mix compile", "Compile application") +pause() + +step(3, "Start PostgreSQL Database") +command("docker-compose up -d postgres redis", "Start database services") +command("docker-compose ps", "Verify services are running") +pause() + +step(4, "Create Database") +command("mix ecto.create", "Create development database") +command("mix ecto.create MIX_ENV=test", "Create test database") +success("Databases created!") +pause() + +step(5, "Generate Authentication System") +warn("This command will ask questions. Answer as follows:") +IO.puts """ + An authentication system can be created in two different ways: + - Using Phoenix.LiveView (default) + - Using Phoenix.Controller only + + CHOOSE: No (we want API-only) + + An accounts context already exists... + CHOOSE: No (or Yes to overwrite) +""" +command("mix phx.gen.auth Accounts User users --binary-id", "Generate auth system") +pause() + +step(6, "Customize User Schema") +info("Edit: lib/codincod_api/accounts/user.ex") +IO.puts """ + Add these fields to the schema block: + + field :profile_avatar_url, :string + field :profile_bio, :text + field :profile_location, :string + field :role, Ecto.Enum, values: [:user, :admin, :moderator], default: :user + field :report_count, :integer, default: 0 + field :ban_count, :integer, default: 0 + field :legacy_mongo_id, :string + belongs_to :current_ban, CodincodApi.Moderation.UserBan +""" +pause() + +step(7, "Create UserBan Schema") +command( + "mix phx.gen.schema Moderation.UserBan user_bans user_id:references:users banned_by_id:references:users reason:text ban_type:string expires_at:utc_datetime --binary-id", + "Create moderation schema" +) +pause() + +step(8, "Create ProgrammingLanguage Schema") +command( + "mix phx.gen.context Languages ProgrammingLanguage programming_languages name:string version:string piston_runtime:string is_active:boolean --binary-id", + "Create language context" +) +pause() + +step(9, "Create Puzzle Context & Schema") +command( + ~s(mix phx.gen.context Puzzles Puzzle puzzles title:string statement:text constraints:text difficulty:string visibility:string author_id:references:users --binary-id), + "Create puzzle context" +) +pause() + +step(10, "Create Submission Context & Schema") +command( + "mix phx.gen.context Submissions Submission submissions code:text result:map user_id:references:users puzzle_id:references:puzzles programming_language_id:references:programming_languages --binary-id", + "Create submission context" +) +pause() + +step(11, "Create Game Context & Schema") +command( + "mix phx.gen.context Games Game games owner_id:references:users puzzle_id:references:puzzles start_time:utc_datetime end_time:utc_datetime options:map --binary-id", + "Create game context" +) +pause() + +step(12, "Create GamePlayer Join Table") +command( + "mix phx.gen.schema Games.GamePlayer game_players game_id:references:games user_id:references:users joined_at:utc_datetime submission_id:references:submissions --binary-id", + "Create game player schema" +) +pause() + +step(13, "Create Comment Context & Schema") +command( + "mix phx.gen.context Comments Comment comments author_id:references:users text:text upvote:integer downvote:integer comment_type:string parent_id:references:comments --binary-id", + "Create comment context" +) +pause() + +step(14, "Create ChatMessage Schema") +command( + "mix phx.gen.context Chat ChatMessage chat_messages game_id:references:games user_id:references:users message:text is_deleted:boolean --binary-id", + "Create chat context" +) +pause() + +step(15, "Create UserVote Schema") +command( + "mix phx.gen.schema Comments.UserVote user_votes user_id:references:users comment_id:references:comments vote_type:string --binary-id", + "Create user vote schema" +) +pause() + +step(16, "Create Report Schema") +command( + "mix phx.gen.context Moderation Report reports reporter_id:references:users reported_user_id:references:users reason:text status:string resolved_by_id:references:users --binary-id", + "Create report context" +) +pause() + +step(17, "Create UserMetrics Schema") +command( + "mix phx.gen.schema Metrics.UserMetrics user_metrics user_id:references:users puzzles_solved:integer puzzles_attempted:integer total_submissions:integer rating:integer rank:integer --binary-id", + "Create metrics schema" +) +pause() + +step(18, "Run All Migrations") +command("mix ecto.migrate", "Apply all database migrations") +success("Database schema created!") +pause() + +step(19, "Create Authentication Controllers") +info("Create: lib/codincod_api_web/controllers/auth_controller.ex") +IO.puts """ + Implement endpoints: + - register/2 + - login/2 + - logout/2 + - refresh/2 + - current_user/2 +""" +pause() + +step(20, "Create Routes") +info("Edit: lib/codincod_api_web/router.ex") +IO.puts """ + Add routes: + + scope "/api", CodincodApiWeb do + pipe_through :api + + # Public routes + post "/register", AuthController, :register + post "/login", AuthController, :login + + # Protected routes + pipe_through :auth + post "/logout", AuthController, :logout + get "/user", AuthController, :current_user + # ... more routes + end +""" +pause() + +step(21, "Create Phoenix Channels") +command("mkdir -p lib/codincod_api_web/channels", "Create channels directory") +info("Create WaitingRoomChannel and GameChannel") +pause() + +step(22, "Implement Piston Client") +info("Create: lib/codincod_api/piston/client.ex") +IO.puts """ + Use Tesla/Finch to communicate with Piston API: + + defmodule CodincodApi.Piston.Client do + use Tesla + + plug Tesla.Middleware.BaseUrl, Application.get_env(:codincod_api, :piston)[:base_url] + plug Tesla.Middleware.JSON + + def execute(code, language, version) do + post("/execute", %{ + language: language, + version: version, + files: [%{content: code}] + }) + end + end +""" +pause() + +step(23, "Create Oban Workers") +info("Create background job workers:") +IO.puts """ + - lib/codincod_api/workers/execute_submission.ex + - lib/codincod_api/workers/update_statistics.ex + - lib/codincod_api/workers/recalculate_leaderboard.ex + - lib/codincod_api/workers/send_email.ex +""" +pause() + +step(24, "Implement Data Migration") +info("Create: lib/codincod_api/data_migration.ex") +IO.puts """ + Implement functions: + - migrate_all/0 + - migrate_users/0 + - migrate_puzzles/0 + - migrate_submissions/0 + - migrate_games/0 + - validate_migration/0 +""" +pause() + +step(25, "Create Mix Tasks") +info("Create migration mix tasks:") +command("touch lib/mix/tasks/migrate_mongo.ex", "Create migration task") +command("touch lib/mix/tasks/gen_typescript_types.ex", "Create type gen task") +pause() + +step(26, "Implement TypeScript Type Generator") +info("Generate TypeScript types from Ecto schemas for frontend") +pause() + +step(27, "Write Tests") +info("Create comprehensive tests:") +IO.puts """ + Unit Tests: + - test/codincod_api/accounts_test.exs + - test/codincod_api/puzzles_test.exs + - test/codincod_api/submissions_test.exs + - test/codincod_api/games_test.exs + + Integration Tests: + - test/codincod_api_web/controllers/*_test.exs + + Channel Tests: + - test/codincod_api_web/channels/*_test.exs +""" +pause() + +step(28, "Configure CORS & Security") +info("Edit: lib/codincod_api_web/endpoint.ex") +IO.puts """ + Add CORS plug: + + plug CORSPlug, + origin: ~r/^https?:\/\/(localhost:5173|codincod\.com)$/, + credentials: true +""" +pause() + +step(29, "Add Rate Limiting") +info("Create rate limiting plugs") +pause() + +step(30, "Create Health Check Endpoint") +command("mix phx.gen.json Health Check checks --no-context --no-schema", "Generate health controller") +pause() + +step(31, "Seed Database") +info("Edit: priv/repo/seeds.exs") +IO.puts """ + Create seed data: + - Admin user + - Sample users (10-20) + - Programming languages + - Sample puzzles (20-30) + - Sample submissions +""" +command("mix run priv/repo/seeds.exs", "Run seeds") +pause() + +step(32, "Run Data Migration") +warn("Ensure MongoDB is running with legacy data") +command("mix migrate_mongo", "Migrate data from MongoDB") +command("mix migrate_mongo --validate", "Validate migration") +pause() + +step(33, "Generate TypeScript Types") +command("mix gen_typescript_types", "Generate TypeScript types for frontend") +pause() + +step(34, "Run All Tests") +command("mix test", "Run test suite") +command("mix test --cover", "Check test coverage") +pause() + +step(35, "Performance Testing") +info("Test performance with:") +IO.puts """ + - Load testing tools (wrk, Apache Bench) + - Concurrent WebSocket connections + - Database query optimization + - N+1 query detection +""" +pause() + +step(36, "Production Configuration") +info("Configure for production:") +IO.puts """ + - Set environment variables + - Configure SSL + - Set up error tracking (Sentry) + - Configure CDN + - Set up monitoring +""" +pause() + +step(37, "Documentation") +info("Update documentation:") +IO.puts """ + - API documentation (OpenAPI/Swagger) + - WebSocket event documentation + - Deployment guide + - Runbook for operations +""" +pause() + +step(38, "Deployment") +info("Deploy to production:") +IO.puts """ + Option A: Docker + $ docker build -t codincod-api:latest . + $ docker push codincod-api:latest + + Option B: Mix Release + $ MIX_ENV=prod mix release + $ _build/prod/rel/codincod_api/bin/codincod_api start + + Option C: Fly.io / Gigalixir / Render + Follow platform-specific deployment guides +""" +pause() + +success("Migration Steps Complete!") + +IO.puts """ + +╔══════════════════════════════════════════════════════════════════════════╗ +║ ║ +║ 🎉 Migration Guide Complete! 🎉 ║ +║ ║ +║ You have completed all the steps for migrating the CodinCod backend ║ +║ from TypeScript/MongoDB to Elixir/PostgreSQL. ║ +║ ║ +║ Next Steps: ║ +║ 1. Verify all tests pass ║ +║ 2. Performance test the application ║ +║ 3. Update frontend to use new backend ║ +║ 4. Deploy to staging environment ║ +║ 5. Monitor and optimize ║ +║ ║ +╚══════════════════════════════════════════════════════════════════════════╝ + +For detailed information, see: +- MIGRATION_GUIDE.md - Comprehensive migration guide +- README.md - Project documentation +- STATUS.md - Current status and next steps +- WINDOWS_SETUP.md - Windows-specific setup + +Good luck with your migration! 🚀 +""" diff --git a/libs/elixir-backend/docker-compose.yml b/libs/elixir-backend/docker-compose.yml new file mode 100644 index 00000000..5eaae194 --- /dev/null +++ b/libs/elixir-backend/docker-compose.yml @@ -0,0 +1,124 @@ +services: + postgres: + image: postgres:16-alpine + container_name: codincod_postgres_dev + environment: + POSTGRES_DB: ${POSTGRES_DB:-codincod_dev} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - codincod_network + + piston: + image: ghcr.io/engineer-man/piston + container_name: codincod_piston + restart: unless-stopped + privileged: true + ports: + - "${PISTON_PORT:-2000}:2000" + volumes: + - ../data/piston/packages:/piston/packages + tmpfs: + - /tmp:exec + networks: + - codincod_network + + postgres_test: + image: postgres:16-alpine + container_name: codincod_postgres_test + environment: + POSTGRES_DB: codincod_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5433:5432" + tmpfs: + - /var/lib/postgresql/data + networks: + - codincod_network + + # MongoDB for migration period + mongodb: + image: mongo + container_name: codincod_mongodb + environment: + MONGO_INITDB_DATABASE: ${MONGO_DB_NAME:-codincod-development} + MONGO_INITDB_ROOT_USERNAME: ${MONGO_USERNAME:-codincod-dev} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-hunter2} + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + networks: + - codincod_network + + # Redis for caching and rate limiting + redis: + image: redis:8-alpine + container_name: codincod_redis + ports: + - "${REDIS_PORT:-6379}:6379" + command: redis-server --requirepass ${REDIS_PASSWORD:-redis_password} + volumes: + - redis_data:/data + networks: + - codincod_network + + api: + build: + context: ./codincod_api + dockerfile: Dockerfile + container_name: codincod_elixir_api + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + piston: + condition: service_started + environment: + MIX_ENV: dev + PHX_SERVER: "true" + PHX_PORT: ${PHX_PORT:-4000} + POSTGRES_HOST: postgres + POSTGRES_DB: ${POSTGRES_DB:-codincod_dev} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + PISTON_URI: http://piston:2000 + REDIS_HOST: redis + REDIS_PORT: ${REDIS_PORT:-6379} + REDIS_PASSWORD: ${REDIS_PASSWORD:-redis_password} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:5173} + JWT_SECRET: ${JWT_SECRET:-dev_secret} + JWT_ISSUER: ${JWT_ISSUER:-codincod_api} + command: sh -c "mix deps.get && mix ecto.create && mix ecto.migrate && mix phx.server" + ports: + - "${PHX_PORT:-4000}:4000" + volumes: + - ./codincod_api:/app + - codincod_deps:/app/deps + - codincod_build:/app/_build + networks: + - codincod_network + +volumes: + postgres_data: + mongodb_data: + redis_data: + codincod_deps: + codincod_build: + +networks: + codincod_network: + driver: bridge diff --git a/libs/elixir-backend/init.sql b/libs/elixir-backend/init.sql new file mode 100644 index 00000000..4aadf218 --- /dev/null +++ b/libs/elixir-backend/init.sql @@ -0,0 +1,18 @@ +-- PostgreSQL initialization script +-- This script runs when the PostgreSQL container is first created + +-- Enable required extensions +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "citext"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; +CREATE EXTENSION IF NOT EXISTS "btree_gin"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Create custom types if needed +-- (Will be created by Ecto migrations) + +-- Log successful initialization +DO $$ +BEGIN + RAISE NOTICE 'PostgreSQL extensions initialized successfully'; +END $$; diff --git a/libs/elixir-backend/migrate.sh b/libs/elixir-backend/migrate.sh new file mode 100644 index 00000000..ee47c761 --- /dev/null +++ b/libs/elixir-backend/migrate.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +# CodinCod Elixir Backend Migration Script +# This script continues the migration from TypeScript/MongoDB to Elixir/PostgreSQL + +set -e + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$PROJECT_ROOT/codincod_api" + +echo "🚀 Starting CodinCod Elixir Backend Migration..." + +# Step 1: Compile dependencies +echo "📦 Compiling dependencies..." +mix deps.compile + +# Step 2: Generate authentication system +echo "🔐 Generating authentication system with phx.gen.auth..." +mix phx.gen.auth Accounts User users --binary-id --no-prompt || true + +# Step 3: Create database +echo "🗄️ Creating database..." +mix ecto.create + +# Step 4: Run migrations +echo "⬆️ Running migrations..." +mix ecto.migrate + +echo "✅ Basic migration setup complete!" +echo "" +echo "Next steps:" +echo "1. Review generated authentication code in lib/codincod_api/accounts/" +echo "2. Customize User schema with additional fields (profile, roles, bans)" +echo "3. Create remaining schemas (Puzzle, Submission, Game, etc.)" +echo "4. Implement WebSocket channels for real-time features" +echo "5. Create data migration scripts from MongoDB" +echo "6. Implement TypeScript type generation" +echo "" +echo "📚 Documentation: See MIGRATION_GUIDE.md for detailed steps" diff --git a/libs/elixir-backend/validate_migration.exs b/libs/elixir-backend/validate_migration.exs new file mode 100644 index 00000000..aacda368 --- /dev/null +++ b/libs/elixir-backend/validate_migration.exs @@ -0,0 +1,599 @@ +#!/usr/bin/env elixir + +# Mix.install([{:mongodb_driver, "~> 1.0"}]) + +""" +Migration Validation Script +=========================== + +This script validates that data was successfully migrated from MongoDB to PostgreSQL. +It compares counts and samples data from both databases. + +Usage: + mix run validate_migration.exs + mix run validate_migration.exs --detailed + mix run validate_migration.exs --export-report + +Requirements: +- PostgreSQL must be running with migrated data +- MongoDB must be accessible (optional, for comparison) +""" + +defmodule MigrationValidator do + @moduledoc """ + Validates the migration from MongoDB to PostgreSQL by comparing data counts, + checking data integrity, and generating a detailed report. + """ + + require Logger + + alias CodincodApi.Repo + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles.Puzzle + alias CodincodApi.Submissions.Submission + alias CodincodApi.Games.Game + alias CodincodApi.Languages.ProgrammingLanguage + alias CodincodApi.Comments.Comment + alias CodincodApi.Moderation.Report + + import Ecto.Query + + def run(opts \\ []) do + IO.puts("\n" <> header()) + + case validate_all(opts) do + {:ok, report} -> + display_report(report, opts) + maybe_export_report(report, opts) + IO.puts(success_message()) + {:ok, report} + + {:error, reason} -> + IO.puts(error_message(reason)) + {:error, reason} + end + end + + def validate_all(opts) do + try do + mongo_available = opts[:skip_mongo] != true && check_mongo_connection() + + results = [ + validate_users(mongo_available), + validate_puzzles(mongo_available), + validate_submissions(mongo_available), + validate_games(mongo_available), + validate_languages(mongo_available), + validate_comments(mongo_available), + validate_reports(mongo_available), + validate_data_integrity(), + validate_indexes(), + validate_constraints() + ] + + report = %{ + timestamp: DateTime.utc_now(), + mongo_available: mongo_available, + results: results, + summary: summarize_results(results) + } + + {:ok, report} + rescue + e -> + {:error, Exception.message(e)} + end + end + + ## Validation Functions + + defp validate_users(mongo_available) do + pg_count = Repo.aggregate(User, :count) + mongo_count = if mongo_available, do: get_mongo_count("users"), else: nil + + sample_users = User |> limit(5) |> Repo.all() + + %{ + entity: "Users", + pg_count: pg_count, + mongo_count: mongo_count, + match: mongo_count == nil || pg_count >= mongo_count, + samples: length(sample_users), + details: %{ + with_email: count_users_with_email(), + with_username: count_users_with_username(), + admin_users: count_admin_users(), + banned_users: count_banned_users() + } + } + end + + defp validate_puzzles(mongo_available) do + pg_count = Repo.aggregate(Puzzle, :count) + mongo_count = if mongo_available, do: get_mongo_count("puzzles"), else: nil + + %{ + entity: "Puzzles", + pg_count: pg_count, + mongo_count: mongo_count, + match: mongo_count == nil || pg_count >= mongo_count, + details: %{ + published: count_published_puzzles(), + draft: count_draft_puzzles(), + by_difficulty: count_by_difficulty() + } + } + end + + defp validate_submissions(mongo_available) do + pg_count = Repo.aggregate(Submission, :count) + mongo_count = if mongo_available, do: get_mongo_count("submissions"), else: nil + + %{ + entity: "Submissions", + pg_count: pg_count, + mongo_count: mongo_count, + match: mongo_count == nil || pg_count >= mongo_count, + details: %{ + accepted: count_accepted_submissions(), + rejected: count_rejected_submissions(), + pending: count_pending_submissions() + } + } + end + + defp validate_games(mongo_available) do + pg_count = Repo.aggregate(Game, :count) + mongo_count = if mongo_available, do: get_mongo_count("games"), else: nil + + %{ + entity: "Games", + pg_count: pg_count, + mongo_count: mongo_count, + match: mongo_count == nil || pg_count >= mongo_count, + details: %{ + completed: count_completed_games(), + in_progress: count_in_progress_games(), + waiting: count_waiting_games() + } + } + end + + defp validate_languages(mongo_available) do + pg_count = Repo.aggregate(ProgrammingLanguage, :count) + mongo_count = if mongo_available, do: get_mongo_count("languages"), else: nil + + %{ + entity: "Programming Languages", + pg_count: pg_count, + mongo_count: mongo_count, + match: mongo_count == nil || pg_count >= mongo_count, + details: %{ + active: count_active_languages() + } + } + end + + defp validate_comments(mongo_available) do + pg_count = Repo.aggregate(Comment, :count) + mongo_count = if mongo_available, do: get_mongo_count("comments"), else: nil + + %{ + entity: "Comments", + pg_count: pg_count, + mongo_count: mongo_count, + match: mongo_count == nil || pg_count >= mongo_count, + details: %{} + } + end + + defp validate_reports(mongo_available) do + pg_count = Repo.aggregate(Report, :count) + mongo_count = if mongo_available, do: get_mongo_count("reports"), else: nil + + %{ + entity: "Reports", + pg_count: pg_count, + mongo_count: mongo_count, + match: mongo_count == nil || pg_count >= mongo_count, + details: %{ + pending: count_pending_reports(), + resolved: count_resolved_reports() + } + } + end + + defp validate_data_integrity do + checks = [ + check_orphaned_submissions(), + check_orphaned_games(), + check_orphaned_comments(), + check_duplicate_usernames(), + check_duplicate_emails(), + check_invalid_references() + ] + + passed = Enum.count(checks, & &1.passed) + total = length(checks) + + %{ + entity: "Data Integrity", + checks: checks, + passed: passed, + total: total, + match: passed == total + } + end + + defp validate_indexes do + # Check if critical indexes exist + indexes = get_table_indexes() + + %{ + entity: "Database Indexes", + indexes: indexes, + match: true + } + end + + defp validate_constraints do + # Check if foreign key constraints are in place + constraints = get_foreign_key_constraints() + + %{ + entity: "Foreign Key Constraints", + constraints: constraints, + match: true + } + end + + ## Helper Functions - Counts + + defp count_users_with_email do + User |> where([u], not is_nil(u.email)) |> Repo.aggregate(:count) + end + + defp count_users_with_username do + User |> where([u], not is_nil(u.username)) |> Repo.aggregate(:count) + end + + defp count_admin_users do + User |> where([u], u.role == :admin) |> Repo.aggregate(:count) + end + + defp count_banned_users do + User |> where([u], not is_nil(u.current_ban_id)) |> Repo.aggregate(:count) + end + + defp count_published_puzzles do + Puzzle |> where([p], p.is_published == true) |> Repo.aggregate(:count) + end + + defp count_draft_puzzles do + Puzzle |> where([p], p.is_published == false) |> Repo.aggregate(:count) + end + + defp count_by_difficulty do + Puzzle + |> group_by([p], p.difficulty) + |> select([p], {p.difficulty, count(p.id)}) + |> Repo.all() + |> Enum.into(%{}) + end + + defp count_accepted_submissions do + Submission |> where([s], s.status == "accepted") |> Repo.aggregate(:count) + end + + defp count_rejected_submissions do + Submission |> where([s], s.status == "rejected") |> Repo.aggregate(:count) + end + + defp count_pending_submissions do + Submission |> where([s], s.status == "pending") |> Repo.aggregate(:count) + end + + defp count_completed_games do + Game |> where([g], g.status == "completed") |> Repo.aggregate(:count) + end + + defp count_in_progress_games do + Game |> where([g], g.status == "in_progress") |> Repo.aggregate(:count) + end + + defp count_waiting_games do + Game |> where([g], g.status == "waiting") |> Repo.aggregate(:count) + end + + defp count_active_languages do + ProgrammingLanguage |> where([l], l.is_active == true) |> Repo.aggregate(:count) + end + + defp count_pending_reports do + Report |> where([r], r.status == "pending") |> Repo.aggregate(:count) + end + + defp count_resolved_reports do + Report |> where([r], r.status == "resolved") |> Repo.aggregate(:count) + end + + ## Helper Functions - Integrity Checks + + defp check_orphaned_submissions do + # Check for submissions without valid user or puzzle references + orphaned = + Submission + |> join(:left, [s], u in User, on: s.user_id == u.id) + |> join(:left, [s], p in Puzzle, on: s.puzzle_id == p.id) + |> where([s, u, p], is_nil(u.id) or is_nil(p.id)) + |> Repo.aggregate(:count) + + %{ + name: "Orphaned Submissions", + passed: orphaned == 0, + count: orphaned + } + end + + defp check_orphaned_games do + orphaned = + Game + |> join(:left, [g], u in User, on: g.owner_id == u.id) + |> join(:left, [g], p in Puzzle, on: g.puzzle_id == p.id) + |> where([g, u, p], is_nil(u.id) or is_nil(p.id)) + |> Repo.aggregate(:count) + + %{ + name: "Orphaned Games", + passed: orphaned == 0, + count: orphaned + } + end + + defp check_orphaned_comments do + orphaned = + Comment + |> join(:left, [c], u in User, on: c.author_id == u.id) + |> where([c, u], is_nil(u.id)) + |> Repo.aggregate(:count) + + %{ + name: "Orphaned Comments", + passed: orphaned == 0, + count: orphaned + } + end + + defp check_duplicate_usernames do + duplicates = + User + |> group_by([u], u.username) + |> having([u], count(u.id) > 1) + |> select([u], count(u.id)) + |> Repo.aggregate(:count) + + %{ + name: "Duplicate Usernames", + passed: duplicates == 0, + count: duplicates + } + end + + defp check_duplicate_emails do + duplicates = + User + |> group_by([u], u.email) + |> having([u], count(u.id) > 1) + |> select([u], count(u.id)) + |> Repo.aggregate(:count) + + %{ + name: "Duplicate Emails", + passed: duplicates == 0, + count: duplicates + } + end + + defp check_invalid_references do + # This is a placeholder - add specific checks as needed + %{ + name: "Invalid References", + passed: true, + count: 0 + } + end + + ## Database Introspection + + defp get_table_indexes do + query = """ + SELECT + tablename, + indexname, + indexdef + FROM pg_indexes + WHERE schemaname = 'public' + ORDER BY tablename, indexname + """ + + case Repo.query(query) do + {:ok, %{rows: rows}} -> length(rows) + _ -> 0 + end + end + + defp get_foreign_key_constraints do + query = """ + SELECT + tc.table_name, + kcu.column_name, + ccu.table_name AS foreign_table_name, + ccu.column_name AS foreign_column_name + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name + AND ccu.table_schema = tc.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = 'public' + """ + + case Repo.query(query) do + {:ok, %{rows: rows}} -> length(rows) + _ -> 0 + end + end + + ## MongoDB Functions (placeholder) + + defp check_mongo_connection do + # TODO: Implement MongoDB connection check + # This would require MongoDB driver to be installed + false + end + + defp get_mongo_count(_collection) do + # TODO: Implement MongoDB count + nil + end + + ## Report Functions + + defp summarize_results(results) do + total_entities = length(results) + + matches = + Enum.count(results, fn result -> + Map.get(result, :match, false) + end) + + %{ + total_entities: total_entities, + matched: matches, + percentage: if(total_entities > 0, do: matches / total_entities * 100, else: 0) + } + end + + defp display_report(report, opts) do + IO.puts("\n" <> String.duplicate("=", 80)) + IO.puts("MIGRATION VALIDATION REPORT") + IO.puts(String.duplicate("=", 80)) + IO.puts("Timestamp: #{report.timestamp}") + IO.puts("MongoDB Available: #{report.mongo_available}") + IO.puts("") + + Enum.each(report.results, fn result -> + display_result(result, opts) + end) + + IO.puts("\n" <> String.duplicate("=", 80)) + IO.puts("SUMMARY") + IO.puts(String.duplicate("=", 80)) + + summary = report.summary + IO.puts("Entities Validated: #{summary.total_entities}") + IO.puts("Matched: #{summary.matched}") + IO.puts("Success Rate: #{Float.round(summary.percentage, 2)}%") + IO.puts("") + end + + defp display_result(result, opts) do + entity = result.entity + pg_count = Map.get(result, :pg_count, "N/A") + mongo_count = Map.get(result, :mongo_count, "N/A") + match = Map.get(result, :match, false) + + status = if match, do: "✓", else: "✗" + IO.puts("\n#{status} #{entity}") + IO.puts(" PostgreSQL: #{pg_count}") + + if mongo_count != "N/A" do + IO.puts(" MongoDB: #{mongo_count}") + end + + if opts[:detailed] && Map.has_key?(result, :details) do + display_details(result.details) + end + + if Map.has_key?(result, :checks) do + display_checks(result.checks) + end + end + + defp display_details(details) do + IO.puts(" Details:") + + Enum.each(details, fn {key, value} -> + IO.puts(" #{key}: #{inspect(value)}") + end) + end + + defp display_checks(checks) do + IO.puts(" Integrity Checks:") + + Enum.each(checks, fn check -> + status = if check.passed, do: "✓", else: "✗" + IO.puts(" #{status} #{check.name}: #{check.count}") + end) + end + + defp maybe_export_report(report, opts) do + if opts[:export_report] do + filename = "migration_report_#{DateTime.to_unix(report.timestamp)}.json" + content = Jason.encode!(report, pretty: true) + File.write!(filename, content) + IO.puts("\n✓ Report exported to: #{filename}") + end + end + + ## Messages + + defp header do + """ + ╔══════════════════════════════════════════════════════════════════════════╗ + ║ ║ + ║ CodinCod Migration Validation Tool ║ + ║ ║ + ║ MongoDB → PostgreSQL Data Validation ║ + ║ ║ + ╚══════════════════════════════════════════════════════════════════════════╝ + """ + end + + defp success_message do + """ + + ╔══════════════════════════════════════════════════════════════════════════╗ + ║ ║ + ║ ✓ Validation Complete! ║ + ║ ║ + ╚══════════════════════════════════════════════════════════════════════════╝ + """ + end + + defp error_message(reason) do + """ + + ╔══════════════════════════════════════════════════════════════════════════╗ + ║ ║ + ║ ✗ Validation Failed ║ + ║ ║ + ║ Error: #{reason} + ║ ║ + ╚══════════════════════════════════════════════════════════════════════════╝ + """ + end +end + +# Parse command line arguments +args = System.argv() +opts = [ + detailed: Enum.member?(args, "--detailed"), + export_report: Enum.member?(args, "--export-report"), + skip_mongo: Enum.member?(args, "--skip-mongo") +] + +# Run validation +MigrationValidator.run(opts) diff --git a/libs/frontend/.prettierrc b/libs/frontend/.prettierrc index d72b66aa..e6559a74 100644 --- a/libs/frontend/.prettierrc +++ b/libs/frontend/.prettierrc @@ -3,6 +3,10 @@ "singleQuote": false, "trailingComma": "none", "printWidth": 80, - "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "plugins": [ + "prettier-plugin-organize-imports", + "prettier-plugin-svelte", + "prettier-plugin-tailwindcss" + ], "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] } diff --git a/libs/frontend/eslint.config.js b/libs/frontend/eslint.config.js index 6ad84a9d..011059a3 100644 --- a/libs/frontend/eslint.config.js +++ b/libs/frontend/eslint.config.js @@ -1,10 +1,10 @@ -import globals from "globals"; import pluginJs from "@eslint/js"; -import tseslint from "typescript-eslint"; -import svelte from "eslint-plugin-svelte"; import prettier from "eslint-config-prettier"; import sortDestructureKeys from "eslint-plugin-sort-destructure-keys"; import sortKeysFix from "eslint-plugin-sort-keys-fix"; +import svelte from "eslint-plugin-svelte"; +import globals from "globals"; +import tseslint from "typescript-eslint"; /** @type {import('eslint').Linter.Config[]} */ export default [ @@ -57,9 +57,9 @@ export default [ "sort-keys-fix": sortKeysFix }, rules: { - "@typescript-eslint/no-unused-vars": "warn", - "no-undef": "warn", - "no-unused-vars": "warn", + "@typescript-eslint/no-unused-vars": "error", + "no-undef": "error", + "no-unused-vars": "error", "sort-destructure-keys/sort-destructure-keys": [ 2, { caseSensitive: true } diff --git a/libs/frontend/knip.json b/libs/frontend/knip.json new file mode 100644 index 00000000..eda181b7 --- /dev/null +++ b/libs/frontend/knip.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "entry": [ + "src/routes/**/*.{ts,svelte}", + "src/lib/**/*.{ts,svelte}", + "src/hooks.server.ts", + "svelte.config.js", + "vite.config.ts", + "orval.config.ts" + ], + "project": ["src/**/*.{ts,svelte}"], + "ignore": [ + "build/**", + ".svelte-kit/**", + "src/lib/api/generated/**", + "**/*.spec.ts", + "**/*.test.ts" + ], + "ignoreDependencies": ["types"] +} diff --git a/libs/frontend/orval.config.ts b/libs/frontend/orval.config.ts new file mode 100644 index 00000000..3b114ec9 --- /dev/null +++ b/libs/frontend/orval.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "orval"; + +export default defineConfig({ + elixirApi: { + input: { + // Point to your Elixir backend OpenAPI spec + target: "../elixir-backend/codincod_api/priv/static/openapi.json" + }, + output: { + mode: "tags-split", + target: "./src/lib/api/generated/endpoints.ts", + schemas: "./src/lib/api/generated/schemas", + client: "fetch", // Use native fetch API + baseUrl: "", // Will be handled by custom mutator + mock: false, // Disabled: MSW mock generation has type issues with exactOptionalPropertyTypes + override: { + mutator: { + path: "./src/lib/api/custom-client.ts", + name: "customClient" + }, + fetch: { + includeHttpResponseReturnType: false // Return data directly, not { data, status } + } + } + }, + hooks: { + afterAllFilesWrite: "prettier --write" + } + } +}); diff --git a/libs/frontend/package.json b/libs/frontend/package.json index 1871be52..d182ddb3 100644 --- a/libs/frontend/package.json +++ b/libs/frontend/package.json @@ -12,7 +12,9 @@ "prettier": "npx prettier . --check", "prettier:fix": "npm run prettier -- --write", "lint": "prettier --check . && eslint .", - "format": "prettier --write ." + "format": "prettier --write .", + "generate:api": "orval", + "generate:api:watch": "orval --watch" }, "devDependencies": { "@eslint/js": "^9.7.0", @@ -37,9 +39,12 @@ "formsnap": "2.0.0-next.1", "globals": "^16.2.0", "mdsvex": "^0.12.3", + "openapi-typescript": "^6.7.6", + "orval": "^7.16.0", "paneforge": "1.0.0-next.5", "postcss": "^8.4.33", "prettier": "^3.3.3", + "prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-svelte": "^3.2.6", "prettier-plugin-tailwindcss": "^0.7.1", "svelte": "^5.0.0", diff --git a/libs/frontend/scripts/copy-maintenance.mjs b/libs/frontend/scripts/copy-maintenance.mjs index 76deda09..6f00c9c8 100644 --- a/libs/frontend/scripts/copy-maintenance.mjs +++ b/libs/frontend/scripts/copy-maintenance.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { fileURLToPath } from "url"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; -import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; +import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); diff --git a/libs/frontend/src/lib/api/custom-client.ts b/libs/frontend/src/lib/api/custom-client.ts new file mode 100644 index 00000000..59f19e7c --- /dev/null +++ b/libs/frontend/src/lib/api/custom-client.ts @@ -0,0 +1,43 @@ +/** + * Custom Orval client that uses native fetch with cookie-based authentication + * This matches the signature expected by Orval's fetch client mode + * + * Supports server-side rendering by accepting custom fetch functions from SvelteKit + */ + +// Extend RequestInit to support custom fetch for server-side rendering +type CustomRequestInit = RequestInit & { + fetch?: typeof fetch; +}; + +export async function customClient( + url: string, + options?: CustomRequestInit +): Promise { + // Use custom fetch if provided in options, otherwise use global fetch + const fetchFn = options?.fetch || fetch; + + // Remove custom fetch from options to avoid passing it to native fetch + const { fetch: _, ...fetchOptions } = options || {}; + + // Make the request using fetch + const response = await fetchFn(url, { + ...fetchOptions, + credentials: "include" // Important for cookie-based auth + }); + + // Handle errors + if (!response.ok) { + const error = await response.json().catch(() => ({ + message: response.statusText + })); + throw new Error(error.message || "API request failed"); + } + + // Handle 204 No Content + if (response.status === 204 || !response.body) { + return undefined as T; + } + + return response.json(); +} diff --git a/libs/frontend/src/lib/api/error-handler.ts b/libs/frontend/src/lib/api/error-handler.ts new file mode 100644 index 00000000..39bc0fcc --- /dev/null +++ b/libs/frontend/src/lib/api/error-handler.ts @@ -0,0 +1,201 @@ +/** + * Generic error handling utilities for API calls + * + * Provides consistent error handling patterns across the application + * with support for form errors, redirects, and user-friendly messages. + */ + +import type { ActionFailure } from "@sveltejs/kit"; +import { fail, redirect } from "@sveltejs/kit"; +import { ApiError } from "./errors"; + +export interface ErrorHandlerOptions { + /** Redirect to this URL on 401 unauthorized errors */ + redirectOnUnauthorized?: string; + /** Custom error messages for specific status codes */ + statusMessages?: Record; + /** Whether to return field errors from validation failures */ + includeFieldErrors?: boolean; + /** Default fallback message */ + defaultMessage?: string; +} + +export interface ApiErrorResult { + status: number; + message: string; + errors?: Record | undefined; + data?: unknown; +} + +/** + * Handle API errors consistently across the application + * + * @example + * ```ts + * try { + * await api.post('/api/login', credentials); + * } catch (error) { + * return handleApiError(error, { + * redirectOnUnauthorized: '/login', + * statusMessages: { + * 401: 'Invalid credentials', + * 429: 'Too many attempts. Please try again later.' + * } + * }); + * } + * ``` + */ +export function handleApiError( + error: unknown, + options: ErrorHandlerOptions = {} +): ActionFailure | never { + const { + redirectOnUnauthorized, + statusMessages = {}, + includeFieldErrors = true, + defaultMessage = "An unexpected error occurred" + } = options; + + // Handle redirect responses (from throw redirect()) + if (error instanceof Response) { + throw error; + } + + // Handle API errors + if (error instanceof ApiError) { + // Redirect on unauthorized if configured + if (error.status === 401 && redirectOnUnauthorized) { + throw redirect(302, redirectOnUnauthorized); + } + + // Get custom message for this status code + const message = + statusMessages[error.status] || error.data.message || error.message; + + // Extract field errors if requested + const fieldErrors = includeFieldErrors ? error.getFieldErrors() : undefined; + + return fail(error.status, { + status: error.status, + message, + errors: fieldErrors, + data: error.data + }); + } + + // Handle other errors + console.error("Unexpected error:", error); + return fail(500, { + status: 500, + message: defaultMessage + }); +} + +/** + * Wrap an async operation with standardized error handling + * + * @example + * ```ts + * export const actions = { + * submit: async ({ request, fetch }) => { + * return withErrorHandling(async () => { + * const api = createServerApi(fetch); + * const data = await request.formData(); + * return await api.post('/api/submit', { code: data.get('code') }); + * }, { + * redirectOnUnauthorized: '/login', + * defaultMessage: 'Failed to submit code' + * }); + * } + * }; + * ``` + */ +export async function withErrorHandling( + operation: () => Promise, + options: ErrorHandlerOptions = {} +): Promise> { + try { + return await operation(); + } catch (error) { + return handleApiError(error, options); + } +} + +/** + * Load data with error handling and optional fallback + * Useful for non-critical data that shouldn't break the page + * + * @example + * ```ts + * export async function load({ fetch }) { + * const api = createServerApi(fetch); + * + * const [puzzles, account] = await Promise.all([ + * api.get('/api/puzzles'), + * loadWithFallback(() => api.get('/api/account'), null) + * ]); + * + * return { puzzles, account }; // account is null if unauthenticated + * } + * ``` + */ +export async function loadWithFallback( + operation: () => Promise, + fallback: F +): Promise { + try { + return await operation(); + } catch (error) { + if (error instanceof ApiError) { + console.warn("API call failed, using fallback:", error.message); + return fallback; + } + throw error; + } +} + +/** + * Check if user is authenticated, redirect if not + * + * @example + * ```ts + * export async function load({ fetch }) { + * const api = createServerApi(fetch); + * await requireAuth(() => api.get('/api/account'), '/login'); + * // ... rest of load function + * } + * ``` + */ +export async function requireAuth( + operation: () => Promise, + loginUrl = "/login" +): Promise { + try { + return await operation(); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + throw redirect(302, loginUrl); + } + throw error; + } +} + +/** + * Get user-friendly error message from API error + */ +export function getErrorMessage(error: unknown): string { + if (error instanceof ApiError) { + return error.data.message || error.message; + } + if (error instanceof Error) { + return error.message; + } + return "An unexpected error occurred"; +} + +/** + * Check if an error is a specific HTTP status + */ +export function isHttpError(error: unknown, status: number): boolean { + return error instanceof ApiError && error.status === status; +} diff --git a/libs/frontend/src/lib/api/errors.ts b/libs/frontend/src/lib/api/errors.ts new file mode 100644 index 00000000..0c09b0e2 --- /dev/null +++ b/libs/frontend/src/lib/api/errors.ts @@ -0,0 +1,117 @@ +/** + * API Error Types + * + * Custom error classes for handling API errors from the Elixir backend. + * These errors are thrown by the Orval-generated API client. + */ + +/** + * Error response structure from the Elixir backend + * Can have either array format or object/map format for errors + */ +export interface ElixirApiError { + message?: string; + error?: string; + // Array format: [{ field: "username", message: "error" }] + errors?: + | Array<{ + field?: string; + message: string; + index?: number; + }> + | Record; // Object/map format: { username: ["error1"], email: "error2" } + status?: number; +} + +/** + * Custom error class for API errors + * + * Wraps HTTP errors from the API with structured error data. + * This allows for consistent error handling across the application. + * + * @example + * ```ts + * try { + * await api.createPuzzle(data); + * } catch (error) { + * if (error instanceof ApiError) { + * console.error('API error:', error.status, error.data.message); + * if (error.isStatus(400)) { + * const fieldErrors = error.getFieldErrors(); + * console.error('Validation errors:', fieldErrors); + * } + * } + * } + * ``` + */ +export class ApiError extends Error { + constructor( + public status: number, + public data: ElixirApiError, + public response?: Response + ) { + super(data.message || data.error || `API error: ${status}`); + this.name = "ApiError"; + } + + /** + * Check if this is a specific HTTP error status + */ + isStatus(status: number): boolean { + return this.status === status; + } + + /** + * Check if this is a network/connection error + */ + isNetworkError(): boolean { + return this.status === 0 || this.status >= 500; + } + + /** + * Check if this is a client error (4xx) + */ + isClientError(): boolean { + return this.status >= 400 && this.status < 500; + } + + /** + * Get field-specific errors + * Handles both array format and object/map format from Elixir backend + */ + getFieldErrors(): Record { + if (!this.data.errors) return {}; + + // Handle object/map format: { username: ["error1", "error2"], email: ["error3"] } + if ( + typeof this.data.errors === "object" && + !Array.isArray(this.data.errors) + ) { + const fieldErrors: Record = {}; + for (const [field, messages] of Object.entries(this.data.errors)) { + if (Array.isArray(messages)) { + fieldErrors[field] = messages; + } else if (typeof messages === "string") { + fieldErrors[field] = [messages]; + } + } + return fieldErrors; + } + + // Handle array format: [{ field: "username", message: "error" }] + if (Array.isArray(this.data.errors)) { + const fieldErrors: Record = {}; + for (const error of this.data.errors) { + if (error.field) { + if (!fieldErrors[error.field]) { + fieldErrors[error.field] = []; + } + fieldErrors[error.field].push(error.message); + } + } + return fieldErrors; + } + + return {}; + } +} diff --git a/libs/frontend/src/lib/api/generated/account-preferences/account-preferences.ts b/libs/frontend/src/lib/api/generated/account-preferences/account-preferences.ts new file mode 100644 index 00000000..ffe4e9f7 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/account-preferences/account-preferences.ts @@ -0,0 +1,174 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PreferencesPayload } from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Delete preferences + */ +export const getCodincodApiWebAccountPreferenceControllerDelete2Url = () => { + return `/api/account/preferences`; +}; + +export const codincodApiWebAccountPreferenceControllerDelete2 = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountPreferenceControllerDelete2Url(), + { + ...options, + method: "DELETE" + } + ); +}; + +/** + * @summary Get account preferences + */ +export const getCodincodApiWebAccountPreferenceControllerShow2Url = () => { + return `/api/account/preferences`; +}; + +export const codincodApiWebAccountPreferenceControllerShow2 = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountPreferenceControllerShow2Url(), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Patch preferences + */ +export const getCodincodApiWebAccountPreferenceControllerPatch2Url = () => { + return `/api/account/preferences`; +}; + +export const codincodApiWebAccountPreferenceControllerPatch2 = async ( + preferencesPayload?: PreferencesPayload, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountPreferenceControllerPatch2Url(), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(preferencesPayload) + } + ); +}; + +/** + * @summary Replace preferences + */ +export const getCodincodApiWebAccountPreferenceControllerReplace2Url = () => { + return `/api/account/preferences`; +}; + +export const codincodApiWebAccountPreferenceControllerReplace2 = async ( + preferencesPayload: PreferencesPayload, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountPreferenceControllerReplace2Url(), + { + ...options, + method: "PUT", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(preferencesPayload) + } + ); +}; + +/** + * @summary Delete preferences + */ +export const getCodincodApiWebAccountPreferenceControllerDeleteUrl = () => { + return `/api/v1/account/preferences`; +}; + +export const codincodApiWebAccountPreferenceControllerDelete = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountPreferenceControllerDeleteUrl(), + { + ...options, + method: "DELETE" + } + ); +}; + +/** + * @summary Get account preferences + */ +export const getCodincodApiWebAccountPreferenceControllerShowUrl = () => { + return `/api/v1/account/preferences`; +}; + +export const codincodApiWebAccountPreferenceControllerShow = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountPreferenceControllerShowUrl(), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Patch preferences + */ +export const getCodincodApiWebAccountPreferenceControllerPatchUrl = () => { + return `/api/v1/account/preferences`; +}; + +export const codincodApiWebAccountPreferenceControllerPatch = async ( + preferencesPayload?: PreferencesPayload, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountPreferenceControllerPatchUrl(), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(preferencesPayload) + } + ); +}; + +/** + * @summary Replace preferences + */ +export const getCodincodApiWebAccountPreferenceControllerReplaceUrl = () => { + return `/api/v1/account/preferences`; +}; + +export const codincodApiWebAccountPreferenceControllerReplace = async ( + preferencesPayload: PreferencesPayload, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountPreferenceControllerReplaceUrl(), + { + ...options, + method: "PUT", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(preferencesPayload) + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/account/account.ts b/libs/frontend/src/lib/api/generated/account/account.ts new file mode 100644 index 00000000..d92efbb5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/account/account.ts @@ -0,0 +1,174 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + AccountStatusResponse, + ProfileUpdateRequest, + ProfileUpdateResponse, + UserGamesResponse, + UserRankResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Get current user's leaderboard ranking + */ +export const getCodincodApiWebAccountControllerLeaderboardRank2Url = () => { + return `/api/account/leaderboard`; +}; + +export const codincodApiWebAccountControllerLeaderboardRank2 = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountControllerLeaderboardRank2Url(), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Current account status + */ +export const getCodincodApiWebAccountControllerShow2Url = () => { + return `/api/account`; +}; + +export const codincodApiWebAccountControllerShow2 = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountControllerShow2Url(), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get games for current user + */ +export const getCodincodApiWebAccountControllerGames2Url = () => { + return `/api/account/games`; +}; + +export const codincodApiWebAccountControllerGames2 = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountControllerGames2Url(), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Current account status + */ +export const getCodincodApiWebAccountControllerShowUrl = () => { + return `/api/v1/account`; +}; + +export const codincodApiWebAccountControllerShow = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountControllerShowUrl(), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Update profile + */ +export const getCodincodApiWebAccountControllerUpdateProfile2Url = () => { + return `/api/account/profile`; +}; + +export const codincodApiWebAccountControllerUpdateProfile2 = async ( + profileUpdateRequest?: ProfileUpdateRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountControllerUpdateProfile2Url(), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(profileUpdateRequest) + } + ); +}; + +/** + * @summary Update profile + */ +export const getCodincodApiWebAccountControllerUpdateProfileUrl = () => { + return `/api/v1/account/profile`; +}; + +export const codincodApiWebAccountControllerUpdateProfile = async ( + profileUpdateRequest?: ProfileUpdateRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountControllerUpdateProfileUrl(), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(profileUpdateRequest) + } + ); +}; + +/** + * @summary Get games for current user + */ +export const getCodincodApiWebAccountControllerGamesUrl = () => { + return `/api/v1/account/games`; +}; + +export const codincodApiWebAccountControllerGames = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountControllerGamesUrl(), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get current user's leaderboard ranking + */ +export const getCodincodApiWebAccountControllerLeaderboardRankUrl = () => { + return `/api/v1/account/leaderboard`; +}; + +export const codincodApiWebAccountControllerLeaderboardRank = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountControllerLeaderboardRankUrl(), + { + ...options, + method: "GET" + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/auth/auth.ts b/libs/frontend/src/lib/api/generated/auth/auth.ts new file mode 100644 index 00000000..4b4f2914 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/auth/auth.ts @@ -0,0 +1,178 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + LoginRequest, + MessageResponse, + RegisterRequest +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Authenticate user + */ +export const getCodincodApiWebAuthControllerLogin2Url = () => { + return `/api/login`; +}; + +export const codincodApiWebAuthControllerLogin2 = async ( + loginRequest: LoginRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAuthControllerLogin2Url(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(loginRequest) + } + ); +}; + +/** + * @summary Register new user + */ +export const getCodincodApiWebAuthControllerRegister2Url = () => { + return `/api/register`; +}; + +export const codincodApiWebAuthControllerRegister2 = async ( + registerRequest: RegisterRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAuthControllerRegister2Url(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(registerRequest) + } + ); +}; + +/** + * @summary Refresh authentication token + */ +export const getCodincodApiWebAuthControllerRefresh2Url = () => { + return `/api/v1/refresh`; +}; + +export const codincodApiWebAuthControllerRefresh2 = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAuthControllerRefresh2Url(), + { + ...options, + method: "POST" + } + ); +}; + +/** + * @summary Logout current user + */ +export const getCodincodApiWebAuthControllerLogout2Url = () => { + return `/api/logout`; +}; + +export const codincodApiWebAuthControllerLogout2 = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAuthControllerLogout2Url(), + { + ...options, + method: "POST" + } + ); +}; + +/** + * @summary Register new user + */ +export const getCodincodApiWebAuthControllerRegisterUrl = () => { + return `/api/v1/register`; +}; + +export const codincodApiWebAuthControllerRegister = async ( + registerRequest: RegisterRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAuthControllerRegisterUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(registerRequest) + } + ); +}; + +/** + * @summary Authenticate user + */ +export const getCodincodApiWebAuthControllerLoginUrl = () => { + return `/api/v1/login`; +}; + +export const codincodApiWebAuthControllerLogin = async ( + loginRequest: LoginRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAuthControllerLoginUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(loginRequest) + } + ); +}; + +/** + * @summary Logout current user + */ +export const getCodincodApiWebAuthControllerLogoutUrl = () => { + return `/api/v1/logout`; +}; + +export const codincodApiWebAuthControllerLogout = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAuthControllerLogoutUrl(), + { + ...options, + method: "POST" + } + ); +}; + +/** + * @summary Refresh authentication token + */ +export const getCodincodApiWebAuthControllerRefreshUrl = () => { + return `/api/refresh`; +}; + +export const codincodApiWebAuthControllerRefresh = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAuthControllerRefreshUrl(), + { + ...options, + method: "POST" + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/default/default.ts b/libs/frontend/src/lib/api/generated/default/default.ts new file mode 100644 index 00000000..bb0579e1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/default/default.ts @@ -0,0 +1,222 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + CodincodApiWebProgrammingLanguageControllerIndex200Item, + CodincodApiWebProgrammingLanguageControllerIndex2200Item, + CommentResponse, + CreateRequest, + VoteRequest +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Create a comment on a puzzle + */ +export const getCodincodApiWebPuzzleCommentControllerCreate2Url = ( + id: string +) => { + return `/api/puzzle/${id}/comment`; +}; + +export const codincodApiWebPuzzleCommentControllerCreate2 = async ( + id: string, + createRequest?: CreateRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleCommentControllerCreate2Url(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(createRequest) + } + ); +}; + +/** + * @summary List all programming languages + */ +export const getCodincodApiWebProgrammingLanguageControllerIndex2Url = () => { + return `/api/v1/programming-languages`; +}; + +export const codincodApiWebProgrammingLanguageControllerIndex2 = async ( + options?: RequestInit +): Promise => { + return customClient< + CodincodApiWebProgrammingLanguageControllerIndex2200Item[] + >(getCodincodApiWebProgrammingLanguageControllerIndex2Url(), { + ...options, + method: "GET" + }); +}; + +/** + * @summary Vote on a comment + */ +export const getCodincodApiWebCommentControllerVote2Url = (id: string) => { + return `/api/comment/${id}/vote`; +}; + +export const codincodApiWebCommentControllerVote2 = async ( + id: string, + voteRequest?: VoteRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebCommentControllerVote2Url(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(voteRequest) + } + ); +}; + +/** + * @summary Create a comment on a puzzle + */ +export const getCodincodApiWebPuzzleCommentControllerCreateUrl = ( + id: string +) => { + return `/api/v1/puzzle/${id}/comment`; +}; + +export const codincodApiWebPuzzleCommentControllerCreate = async ( + id: string, + createRequest?: CreateRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleCommentControllerCreateUrl(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(createRequest) + } + ); +}; + +/** + * @summary Delete a comment + */ +export const getCodincodApiWebCommentControllerDelete2Url = (id: string) => { + return `/api/comment/${id}`; +}; + +export const codincodApiWebCommentControllerDelete2 = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient(getCodincodApiWebCommentControllerDelete2Url(id), { + ...options, + method: "DELETE" + }); +}; + +/** + * @summary Get comment by ID + */ +export const getCodincodApiWebCommentControllerShow2Url = (id: string) => { + return `/api/comment/${id}`; +}; + +export const codincodApiWebCommentControllerShow2 = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebCommentControllerShow2Url(id), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Vote on a comment + */ +export const getCodincodApiWebCommentControllerVoteUrl = (id: string) => { + return `/api/v1/comment/${id}/vote`; +}; + +export const codincodApiWebCommentControllerVote = async ( + id: string, + voteRequest?: VoteRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebCommentControllerVoteUrl(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(voteRequest) + } + ); +}; + +/** + * @summary Delete a comment + */ +export const getCodincodApiWebCommentControllerDeleteUrl = (id: string) => { + return `/api/v1/comment/${id}`; +}; + +export const codincodApiWebCommentControllerDelete = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient(getCodincodApiWebCommentControllerDeleteUrl(id), { + ...options, + method: "DELETE" + }); +}; + +/** + * @summary Get comment by ID + */ +export const getCodincodApiWebCommentControllerShowUrl = (id: string) => { + return `/api/v1/comment/${id}`; +}; + +export const codincodApiWebCommentControllerShow = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebCommentControllerShowUrl(id), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary List all programming languages + */ +export const getCodincodApiWebProgrammingLanguageControllerIndexUrl = () => { + return `/api/programming-languages`; +}; + +export const codincodApiWebProgrammingLanguageControllerIndex = async ( + options?: RequestInit +): Promise => { + return customClient< + CodincodApiWebProgrammingLanguageControllerIndex200Item[] + >(getCodincodApiWebProgrammingLanguageControllerIndexUrl(), { + ...options, + method: "GET" + }); +}; diff --git a/libs/frontend/src/lib/api/generated/execute/execute.ts b/libs/frontend/src/lib/api/generated/execute/execute.ts new file mode 100644 index 00000000..ceddd8a1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/execute/execute.ts @@ -0,0 +1,56 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ExecuteRequest, ExecuteResponse } from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * Runs code against Piston with custom test input/output for validation + * @summary Execute code without saving + */ +export const getCodincodApiWebExecuteControllerCreate2Url = () => { + return `/api/execute`; +}; + +export const codincodApiWebExecuteControllerCreate2 = async ( + executeRequest?: ExecuteRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebExecuteControllerCreate2Url(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(executeRequest) + } + ); +}; + +/** + * Runs code against Piston with custom test input/output for validation + * @summary Execute code without saving + */ +export const getCodincodApiWebExecuteControllerCreateUrl = () => { + return `/api/v1/execute`; +}; + +export const codincodApiWebExecuteControllerCreate = async ( + executeRequest?: ExecuteRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebExecuteControllerCreateUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(executeRequest) + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/games/games.ts b/libs/frontend/src/lib/api/generated/games/games.ts new file mode 100644 index 00000000..cb010369 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/games/games.ts @@ -0,0 +1,307 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + CreateGameRequest, + GameResponse, + GameSubmitCodeRequest, + LeaveGameResponse, + SubmitCodeResponse, + WaitingRoomsResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary List all waiting game lobbies + */ +export const getCodincodApiWebGameControllerListWaitingRooms2Url = () => { + return `/api/v1/games/waiting`; +}; + +export const codincodApiWebGameControllerListWaitingRooms2 = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerListWaitingRooms2Url(), + { + ...options, + method: "GET" + } + ); +}; + +/** + * Links an existing submission to a game, marking it as a player's game submission. + * @summary Submit code for a game + */ +export const getCodincodApiWebGameControllerSubmitCode2Url = (id: string) => { + return `/api/v1/games/${id}/submit`; +}; + +export const codincodApiWebGameControllerSubmitCode2 = async ( + id: string, + gameSubmitCodeRequest?: GameSubmitCodeRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerSubmitCode2Url(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(gameSubmitCodeRequest) + } + ); +}; + +/** + * @summary Start a game (host only) + */ +export const getCodincodApiWebGameControllerStart2Url = (id: string) => { + return `/api/v1/games/${id}/start`; +}; + +export const codincodApiWebGameControllerStart2 = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerStart2Url(id), + { + ...options, + method: "POST" + } + ); +}; + +/** + * @summary Join a game lobby + */ +export const getCodincodApiWebGameControllerJoin2Url = (id: string) => { + return `/api/games/${id}/join`; +}; + +export const codincodApiWebGameControllerJoin2 = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerJoin2Url(id), + { + ...options, + method: "POST" + } + ); +}; + +/** + * @summary Join a game lobby + */ +export const getCodincodApiWebGameControllerJoinUrl = (id: string) => { + return `/api/v1/games/${id}/join`; +}; + +export const codincodApiWebGameControllerJoin = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerJoinUrl(id), + { + ...options, + method: "POST" + } + ); +}; + +/** + * @summary Create a new game lobby + */ +export const getCodincodApiWebGameControllerCreate2Url = () => { + return `/api/v1/games`; +}; + +export const codincodApiWebGameControllerCreate2 = async ( + createGameRequest?: CreateGameRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerCreate2Url(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(createGameRequest) + } + ); +}; + +/** + * @summary List all waiting game lobbies + */ +export const getCodincodApiWebGameControllerListWaitingRoomsUrl = () => { + return `/api/games/waiting`; +}; + +export const codincodApiWebGameControllerListWaitingRooms = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerListWaitingRoomsUrl(), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Leave a game lobby + */ +export const getCodincodApiWebGameControllerLeave2Url = (id: string) => { + return `/api/v1/games/${id}/leave`; +}; + +export const codincodApiWebGameControllerLeave2 = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerLeave2Url(id), + { + ...options, + method: "POST" + } + ); +}; + +/** + * @summary Start a game (host only) + */ +export const getCodincodApiWebGameControllerStartUrl = (id: string) => { + return `/api/games/${id}/start`; +}; + +export const codincodApiWebGameControllerStart = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerStartUrl(id), + { + ...options, + method: "POST" + } + ); +}; + +/** + * @summary Get game details + */ +export const getCodincodApiWebGameControllerShow2Url = (id: string) => { + return `/api/games/${id}`; +}; + +export const codincodApiWebGameControllerShow2 = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerShow2Url(id), + { + ...options, + method: "GET" + } + ); +}; + +/** + * Links an existing submission to a game, marking it as a player's game submission. + * @summary Submit code for a game + */ +export const getCodincodApiWebGameControllerSubmitCodeUrl = (id: string) => { + return `/api/games/${id}/submit`; +}; + +export const codincodApiWebGameControllerSubmitCode = async ( + id: string, + gameSubmitCodeRequest?: GameSubmitCodeRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerSubmitCodeUrl(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(gameSubmitCodeRequest) + } + ); +}; + +/** + * @summary Get game details + */ +export const getCodincodApiWebGameControllerShowUrl = (id: string) => { + return `/api/v1/games/${id}`; +}; + +export const codincodApiWebGameControllerShow = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerShowUrl(id), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Leave a game lobby + */ +export const getCodincodApiWebGameControllerLeaveUrl = (id: string) => { + return `/api/games/${id}/leave`; +}; + +export const codincodApiWebGameControllerLeave = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerLeaveUrl(id), + { + ...options, + method: "POST" + } + ); +}; + +/** + * @summary Create a new game lobby + */ +export const getCodincodApiWebGameControllerCreateUrl = () => { + return `/api/games`; +}; + +export const codincodApiWebGameControllerCreate = async ( + createGameRequest?: CreateGameRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerCreateUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(createGameRequest) + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/health/health.ts b/libs/frontend/src/lib/api/generated/health/health.ts new file mode 100644 index 00000000..eb967e40 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/health/health.ts @@ -0,0 +1,53 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + CodincodApiWebHealthControllerShow200, + CodincodApiWebHealthControllerShow2200 +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * Returns service health status + * @summary Health check + */ +export const getCodincodApiWebHealthControllerShow2Url = () => { + return `/api/v1/health`; +}; + +export const codincodApiWebHealthControllerShow2 = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebHealthControllerShow2Url(), + { + ...options, + method: "GET" + } + ); +}; + +/** + * Returns service health status + * @summary Health check + */ +export const getCodincodApiWebHealthControllerShowUrl = () => { + return `/api/health`; +}; + +export const codincodApiWebHealthControllerShow = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebHealthControllerShowUrl(), + { + ...options, + method: "GET" + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/index.ts b/libs/frontend/src/lib/api/generated/index.ts new file mode 100644 index 00000000..8e30e81d --- /dev/null +++ b/libs/frontend/src/lib/api/generated/index.ts @@ -0,0 +1,46 @@ +/** + * Barrel export for all generated API endpoints + * Import from here for convenience: import { codincodApiWebPuzzleControllerIndex, ... } from '$lib/api/generated' + */ + +// Account endpoints +export * from "./account/account"; + +// Account preferences endpoints +export * from "./account-preferences/account-preferences"; + +// Auth endpoints +export * from "./auth/auth"; + +// Execute endpoints +export * from "./execute/execute"; + +// Game endpoints +export * from "./games/games"; + +// Health endpoints +export * from "./health/health"; + +// Leaderboard endpoints +export * from "./leaderboard/leaderboard"; + +// Metrics endpoints +export * from "./metrics/metrics"; + +// Moderation endpoints +export * from "./moderation/moderation"; + +// Password reset endpoints +export * from "./password-reset/password-reset"; + +// Puzzle endpoints +export * from "./puzzle/puzzle"; + +// Submission endpoints +export * from "./submission/submission"; + +// User endpoints +export * from "./user/user"; + +// All schemas/types +export * from "./schemas"; diff --git a/libs/frontend/src/lib/api/generated/leaderboard/leaderboard.ts b/libs/frontend/src/lib/api/generated/leaderboard/leaderboard.ts new file mode 100644 index 00000000..eafbfecc --- /dev/null +++ b/libs/frontend/src/lib/api/generated/leaderboard/leaderboard.ts @@ -0,0 +1,157 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + CodincodApiWebLeaderboardControllerGlobal2Params, + CodincodApiWebLeaderboardControllerGlobalParams, + CodincodApiWebLeaderboardControllerPuzzle2Params, + CodincodApiWebLeaderboardControllerPuzzleParams, + GlobalLeaderboardResponse, + PuzzleLeaderboardResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Get puzzle-specific leaderboard + */ +export const getCodincodApiWebLeaderboardControllerPuzzle2Url = ( + puzzleId: string, + params?: CodincodApiWebLeaderboardControllerPuzzle2Params +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/leaderboard/puzzle/${puzzleId}?${stringifiedParams}` + : `/api/leaderboard/puzzle/${puzzleId}`; +}; + +export const codincodApiWebLeaderboardControllerPuzzle2 = async ( + puzzleId: string, + params?: CodincodApiWebLeaderboardControllerPuzzle2Params, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebLeaderboardControllerPuzzle2Url(puzzleId, params), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get global leaderboard rankings + */ +export const getCodincodApiWebLeaderboardControllerGlobal2Url = ( + params?: CodincodApiWebLeaderboardControllerGlobal2Params +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/leaderboard/global?${stringifiedParams}` + : `/api/leaderboard/global`; +}; + +export const codincodApiWebLeaderboardControllerGlobal2 = async ( + params?: CodincodApiWebLeaderboardControllerGlobal2Params, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebLeaderboardControllerGlobal2Url(params), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get global leaderboard rankings + */ +export const getCodincodApiWebLeaderboardControllerGlobalUrl = ( + params?: CodincodApiWebLeaderboardControllerGlobalParams +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/leaderboard/global?${stringifiedParams}` + : `/api/v1/leaderboard/global`; +}; + +export const codincodApiWebLeaderboardControllerGlobal = async ( + params?: CodincodApiWebLeaderboardControllerGlobalParams, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebLeaderboardControllerGlobalUrl(params), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get puzzle-specific leaderboard + */ +export const getCodincodApiWebLeaderboardControllerPuzzleUrl = ( + puzzleId: string, + params?: CodincodApiWebLeaderboardControllerPuzzleParams +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/leaderboard/puzzle/${puzzleId}?${stringifiedParams}` + : `/api/v1/leaderboard/puzzle/${puzzleId}`; +}; + +export const codincodApiWebLeaderboardControllerPuzzle = async ( + puzzleId: string, + params?: CodincodApiWebLeaderboardControllerPuzzleParams, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebLeaderboardControllerPuzzleUrl(puzzleId, params), + { + ...options, + method: "GET" + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/metrics/metrics.ts b/libs/frontend/src/lib/api/generated/metrics/metrics.ts new file mode 100644 index 00000000..389fc7ae --- /dev/null +++ b/libs/frontend/src/lib/api/generated/metrics/metrics.ts @@ -0,0 +1,140 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + PlatformMetricsResponse, + PuzzleStatsResponse, + UserStatsResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Get detailed statistics for a puzzle + */ +export const getCodincodApiWebMetricsControllerPuzzleStats2Url = ( + puzzleId: string +) => { + return `/api/metrics/puzzle/${puzzleId}`; +}; + +export const codincodApiWebMetricsControllerPuzzleStats2 = async ( + puzzleId: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebMetricsControllerPuzzleStats2Url(puzzleId), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get detailed statistics for a user + */ +export const getCodincodApiWebMetricsControllerUserStats2Url = ( + userId: string +) => { + return `/api/v1/metrics/user/${userId}`; +}; + +export const codincodApiWebMetricsControllerUserStats2 = async ( + userId: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebMetricsControllerUserStats2Url(userId), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get detailed statistics for a user + */ +export const getCodincodApiWebMetricsControllerUserStatsUrl = ( + userId: string +) => { + return `/api/metrics/user/${userId}`; +}; + +export const codincodApiWebMetricsControllerUserStats = async ( + userId: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebMetricsControllerUserStatsUrl(userId), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get platform-wide statistics + */ +export const getCodincodApiWebMetricsControllerPlatform2Url = () => { + return `/api/metrics/platform`; +}; + +export const codincodApiWebMetricsControllerPlatform2 = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebMetricsControllerPlatform2Url(), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get detailed statistics for a puzzle + */ +export const getCodincodApiWebMetricsControllerPuzzleStatsUrl = ( + puzzleId: string +) => { + return `/api/v1/metrics/puzzle/${puzzleId}`; +}; + +export const codincodApiWebMetricsControllerPuzzleStats = async ( + puzzleId: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebMetricsControllerPuzzleStatsUrl(puzzleId), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get platform-wide statistics + */ +export const getCodincodApiWebMetricsControllerPlatformUrl = () => { + return `/api/v1/metrics/platform`; +}; + +export const codincodApiWebMetricsControllerPlatform = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebMetricsControllerPlatformUrl(), + { + ...options, + method: "GET" + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/moderation/moderation.ts b/libs/frontend/src/lib/api/generated/moderation/moderation.ts new file mode 100644 index 00000000..cd296eda --- /dev/null +++ b/libs/frontend/src/lib/api/generated/moderation/moderation.ts @@ -0,0 +1,398 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + BanResponse, + BanUserRequest, + CodincodApiWebModerationControllerListReports2Params, + CodincodApiWebModerationControllerListReportsParams, + CodincodApiWebModerationControllerListReviews2Params, + CodincodApiWebModerationControllerListReviewsParams, + CreateReportRequest, + ReportResponse, + ReportsListResponse, + ResolveReportRequest, + ReviewDecisionRequest, + ReviewResponse, + ReviewsListResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary List pending moderation reviews (moderator only) + */ +export const getCodincodApiWebModerationControllerListReviews2Url = ( + params?: CodincodApiWebModerationControllerListReviews2Params +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/moderation/reviews?${stringifiedParams}` + : `/api/v1/moderation/reviews`; +}; + +export const codincodApiWebModerationControllerListReviews2 = async ( + params?: CodincodApiWebModerationControllerListReviews2Params, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerListReviews2Url(params), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Unban a user (admin only) + */ +export const getCodincodApiWebModerationControllerUnbanUser2Url = ( + userId: string +) => { + return `/api/moderation/user/${userId}/unban`; +}; + +export const codincodApiWebModerationControllerUnbanUser2 = async ( + userId: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerUnbanUser2Url(userId), + { + ...options, + method: "POST" + } + ); +}; + +/** + * @summary List reports (admin only) + */ +export const getCodincodApiWebModerationControllerListReports2Url = ( + params?: CodincodApiWebModerationControllerListReports2Params +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/moderation/reports?${stringifiedParams}` + : `/api/moderation/reports`; +}; + +export const codincodApiWebModerationControllerListReports2 = async ( + params?: CodincodApiWebModerationControllerListReports2Params, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerListReports2Url(params), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Ban a user (admin only) + */ +export const getCodincodApiWebModerationControllerBanUser2Url = ( + userId: string +) => { + return `/api/v1/moderation/user/${userId}/ban`; +}; + +export const codincodApiWebModerationControllerBanUser2 = async ( + userId: string, + banUserRequest?: BanUserRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerBanUser2Url(userId), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(banUserRequest) + } + ); +}; + +/** + * @summary Resolve a report (admin only) + */ +export const getCodincodApiWebModerationControllerResolveReport2Url = ( + id: string +) => { + return `/api/v1/moderation/report/${id}/resolve`; +}; + +export const codincodApiWebModerationControllerResolveReport2 = async ( + id: string, + resolveReportRequest?: ResolveReportRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerResolveReport2Url(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(resolveReportRequest) + } + ); +}; + +/** + * @summary Ban a user (admin only) + */ +export const getCodincodApiWebModerationControllerBanUserUrl = ( + userId: string +) => { + return `/api/moderation/user/${userId}/ban`; +}; + +export const codincodApiWebModerationControllerBanUser = async ( + userId: string, + banUserRequest?: BanUserRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerBanUserUrl(userId), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(banUserRequest) + } + ); +}; + +/** + * @summary List reports (admin only) + */ +export const getCodincodApiWebModerationControllerListReportsUrl = ( + params?: CodincodApiWebModerationControllerListReportsParams +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/moderation/reports?${stringifiedParams}` + : `/api/v1/moderation/reports`; +}; + +export const codincodApiWebModerationControllerListReports = async ( + params?: CodincodApiWebModerationControllerListReportsParams, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerListReportsUrl(params), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary List pending moderation reviews (moderator only) + */ +export const getCodincodApiWebModerationControllerListReviewsUrl = ( + params?: CodincodApiWebModerationControllerListReviewsParams +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/moderation/reviews?${stringifiedParams}` + : `/api/moderation/reviews`; +}; + +export const codincodApiWebModerationControllerListReviews = async ( + params?: CodincodApiWebModerationControllerListReviewsParams, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerListReviewsUrl(params), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Review and approve/reject content (moderator only) + */ +export const getCodincodApiWebModerationControllerReviewContent2Url = ( + id: string +) => { + return `/api/moderation/review/${id}`; +}; + +export const codincodApiWebModerationControllerReviewContent2 = async ( + id: string, + reviewDecisionRequest?: ReviewDecisionRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerReviewContent2Url(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(reviewDecisionRequest) + } + ); +}; + +/** + * @summary Create a new report for inappropriate content + */ +export const getCodincodApiWebModerationControllerCreateReport2Url = () => { + return `/api/moderation/report`; +}; + +export const codincodApiWebModerationControllerCreateReport2 = async ( + createReportRequest?: CreateReportRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerCreateReport2Url(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(createReportRequest) + } + ); +}; + +/** + * @summary Resolve a report (admin only) + */ +export const getCodincodApiWebModerationControllerResolveReportUrl = ( + id: string +) => { + return `/api/moderation/report/${id}/resolve`; +}; + +export const codincodApiWebModerationControllerResolveReport = async ( + id: string, + resolveReportRequest?: ResolveReportRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerResolveReportUrl(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(resolveReportRequest) + } + ); +}; + +/** + * @summary Create a new report for inappropriate content + */ +export const getCodincodApiWebModerationControllerCreateReportUrl = () => { + return `/api/v1/moderation/report`; +}; + +export const codincodApiWebModerationControllerCreateReport = async ( + createReportRequest?: CreateReportRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerCreateReportUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(createReportRequest) + } + ); +}; + +/** + * @summary Unban a user (admin only) + */ +export const getCodincodApiWebModerationControllerUnbanUserUrl = ( + userId: string +) => { + return `/api/v1/moderation/user/${userId}/unban`; +}; + +export const codincodApiWebModerationControllerUnbanUser = async ( + userId: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerUnbanUserUrl(userId), + { + ...options, + method: "POST" + } + ); +}; + +/** + * @summary Review and approve/reject content (moderator only) + */ +export const getCodincodApiWebModerationControllerReviewContentUrl = ( + id: string +) => { + return `/api/v1/moderation/review/${id}`; +}; + +export const codincodApiWebModerationControllerReviewContent = async ( + id: string, + reviewDecisionRequest?: ReviewDecisionRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerReviewContentUrl(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(reviewDecisionRequest) + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/password-reset/password-reset.ts b/libs/frontend/src/lib/api/generated/password-reset/password-reset.ts new file mode 100644 index 00000000..a5aa3034 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/password-reset/password-reset.ts @@ -0,0 +1,107 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + RequestPayload, + RequestResponse, + ResetPayload, + ResetResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * Sends password reset email if user exists + * @summary Request password reset + */ +export const getCodincodApiWebPasswordResetControllerRequestReset2Url = () => { + return `/api/v1/password-reset/request`; +}; + +export const codincodApiWebPasswordResetControllerRequestReset2 = async ( + requestPayload?: RequestPayload, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPasswordResetControllerRequestReset2Url(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(requestPayload) + } + ); +}; + +/** + * Sends password reset email if user exists + * @summary Request password reset + */ +export const getCodincodApiWebPasswordResetControllerRequestResetUrl = () => { + return `/api/password-reset/request`; +}; + +export const codincodApiWebPasswordResetControllerRequestReset = async ( + requestPayload?: RequestPayload, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPasswordResetControllerRequestResetUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(requestPayload) + } + ); +}; + +/** + * Validates token and updates user password + * @summary Reset password with token + */ +export const getCodincodApiWebPasswordResetControllerResetPassword2Url = () => { + return `/api/password-reset/reset`; +}; + +export const codincodApiWebPasswordResetControllerResetPassword2 = async ( + resetPayload?: ResetPayload, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPasswordResetControllerResetPassword2Url(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(resetPayload) + } + ); +}; + +/** + * Validates token and updates user password + * @summary Reset password with token + */ +export const getCodincodApiWebPasswordResetControllerResetPasswordUrl = () => { + return `/api/v1/password-reset/reset`; +}; + +export const codincodApiWebPasswordResetControllerResetPassword = async ( + resetPayload?: ResetPayload, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPasswordResetControllerResetPasswordUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(resetPayload) + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/puzzle/puzzle.ts b/libs/frontend/src/lib/api/generated/puzzle/puzzle.ts new file mode 100644 index 00000000..f35d56b6 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/puzzle/puzzle.ts @@ -0,0 +1,298 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + CodincodApiWebPuzzleControllerIndex2Params, + CodincodApiWebPuzzleControllerIndexParams, + PaginatedListResponse, + PuzzleCreateRequest, + PuzzleResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * Returns paginated puzzles matching the legacy Fastify `/puzzle` listing response. + * @summary List puzzles + */ +export const getCodincodApiWebPuzzleControllerIndex2Url = ( + params?: CodincodApiWebPuzzleControllerIndex2Params +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/puzzles?${stringifiedParams}` + : `/api/v1/puzzles`; +}; + +export const codincodApiWebPuzzleControllerIndex2 = async ( + params?: CodincodApiWebPuzzleControllerIndex2Params, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleControllerIndex2Url(params), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Create puzzle + */ +export const getCodincodApiWebPuzzleControllerCreate2Url = () => { + return `/api/v1/puzzles`; +}; + +export const codincodApiWebPuzzleControllerCreate2 = async ( + puzzleCreateRequest?: PuzzleCreateRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleControllerCreate2Url(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(puzzleCreateRequest) + } + ); +}; + +/** + * Returns puzzle with full solution details. Only available to puzzle author or admins. + * @summary Get puzzle solution for editing + */ +export const getCodincodApiWebPuzzleControllerSolution2Url = (id: string) => { + return `/api/v1/puzzle/${id}/solution`; +}; + +export const codincodApiWebPuzzleControllerSolution2 = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleControllerSolution2Url(id), + { + ...options, + method: "GET" + } + ); +}; + +/** + * Deletes a puzzle. Only available to puzzle author or admins. + * @summary Delete puzzle + */ +export const getCodincodApiWebPuzzleControllerDelete2Url = (id: string) => { + return `/api/v1/puzzle/${id}`; +}; + +export const codincodApiWebPuzzleControllerDelete2 = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient(getCodincodApiWebPuzzleControllerDelete2Url(id), { + ...options, + method: "DELETE" + }); +}; + +/** + * Returns a single puzzle by ID (public view, no solution details). + * @summary Get puzzle by ID + */ +export const getCodincodApiWebPuzzleControllerShow2Url = (id: string) => { + return `/api/v1/puzzle/${id}`; +}; + +export const codincodApiWebPuzzleControllerShow2 = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleControllerShow2Url(id), + { + ...options, + method: "GET" + } + ); +}; + +/** + * Updates an existing puzzle. Only available to puzzle author or admins. + * @summary Update puzzle + */ +export const getCodincodApiWebPuzzleControllerUpdate2Url = (id: string) => { + return `/api/v1/puzzle/${id}`; +}; + +export const codincodApiWebPuzzleControllerUpdate2 = async ( + id: string, + puzzleCreateRequest?: PuzzleCreateRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleControllerUpdate2Url(id), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(puzzleCreateRequest) + } + ); +}; + +/** + * Returns puzzle with full solution details. Only available to puzzle author or admins. + * @summary Get puzzle solution for editing + */ +export const getCodincodApiWebPuzzleControllerSolutionUrl = (id: string) => { + return `/api/puzzle/${id}/solution`; +}; + +export const codincodApiWebPuzzleControllerSolution = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleControllerSolutionUrl(id), + { + ...options, + method: "GET" + } + ); +}; + +/** + * Deletes a puzzle. Only available to puzzle author or admins. + * @summary Delete puzzle + */ +export const getCodincodApiWebPuzzleControllerDeleteUrl = (id: string) => { + return `/api/puzzle/${id}`; +}; + +export const codincodApiWebPuzzleControllerDelete = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient(getCodincodApiWebPuzzleControllerDeleteUrl(id), { + ...options, + method: "DELETE" + }); +}; + +/** + * Returns a single puzzle by ID (public view, no solution details). + * @summary Get puzzle by ID + */ +export const getCodincodApiWebPuzzleControllerShowUrl = (id: string) => { + return `/api/puzzle/${id}`; +}; + +export const codincodApiWebPuzzleControllerShow = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleControllerShowUrl(id), + { + ...options, + method: "GET" + } + ); +}; + +/** + * Updates an existing puzzle. Only available to puzzle author or admins. + * @summary Update puzzle + */ +export const getCodincodApiWebPuzzleControllerUpdateUrl = (id: string) => { + return `/api/puzzle/${id}`; +}; + +export const codincodApiWebPuzzleControllerUpdate = async ( + id: string, + puzzleCreateRequest?: PuzzleCreateRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleControllerUpdateUrl(id), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(puzzleCreateRequest) + } + ); +}; + +/** + * Returns paginated puzzles matching the legacy Fastify `/puzzle` listing response. + * @summary List puzzles + */ +export const getCodincodApiWebPuzzleControllerIndexUrl = ( + params?: CodincodApiWebPuzzleControllerIndexParams +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/puzzles?${stringifiedParams}` + : `/api/puzzles`; +}; + +export const codincodApiWebPuzzleControllerIndex = async ( + params?: CodincodApiWebPuzzleControllerIndexParams, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleControllerIndexUrl(params), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Create puzzle + */ +export const getCodincodApiWebPuzzleControllerCreateUrl = () => { + return `/api/puzzles`; +}; + +export const codincodApiWebPuzzleControllerCreate = async ( + puzzleCreateRequest?: PuzzleCreateRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleControllerCreateUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(puzzleCreateRequest) + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/accountPreferences.ts b/libs/frontend/src/lib/api/generated/schemas/accountPreferences.ts new file mode 100644 index 00000000..c9d35907 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/accountPreferences.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { AccountPreferencesEditor } from "./accountPreferencesEditor"; +import type { AccountPreferencesTheme } from "./accountPreferencesTheme"; + +export interface AccountPreferences { + blockedUsers?: string[]; + editor?: AccountPreferencesEditor; + /** @nullable */ + preferredLanguage?: string | null; + /** @nullable */ + theme?: AccountPreferencesTheme; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/accountPreferencesEditor.ts b/libs/frontend/src/lib/api/generated/schemas/accountPreferencesEditor.ts new file mode 100644 index 00000000..4833553d --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/accountPreferencesEditor.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type AccountPreferencesEditor = { [key: string]: unknown }; diff --git a/libs/frontend/src/lib/api/generated/schemas/accountPreferencesTheme.ts b/libs/frontend/src/lib/api/generated/schemas/accountPreferencesTheme.ts new file mode 100644 index 00000000..259ff8c8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/accountPreferencesTheme.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * @nullable + */ +export type AccountPreferencesTheme = + | (typeof AccountPreferencesTheme)[keyof typeof AccountPreferencesTheme] + | null; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const AccountPreferencesTheme = { + dark: "dark", + light: "light" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateRequest.ts b/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateRequest.ts new file mode 100644 index 00000000..35efb723 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateRequest.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface AccountProfileUpdateRequest { + /** @maxLength 500 */ + bio?: string; + /** @maxLength 100 */ + location?: string; + picture?: string; + /** @maxItems 5 */ + socials?: string[]; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateResponse.ts b/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateResponse.ts new file mode 100644 index 00000000..64151260 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { AccountProfileUpdateResponseProfile } from "./accountProfileUpdateResponseProfile"; + +export interface AccountProfileUpdateResponse { + message?: string; + profile?: AccountProfileUpdateResponseProfile; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateResponseProfile.ts b/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateResponseProfile.ts new file mode 100644 index 00000000..50ab3f7f --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateResponseProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type AccountProfileUpdateResponseProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/accountStatusResponse.ts b/libs/frontend/src/lib/api/generated/schemas/accountStatusResponse.ts new file mode 100644 index 00000000..dfae687a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/accountStatusResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface AccountStatusResponse { + isAuthenticated: boolean; + role?: string; + userId?: string; + username?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponse.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponse.ts new file mode 100644 index 00000000..36c9f675 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponse.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ActivityResponseActivity } from "./activityResponseActivity"; +import type { ActivityResponseUser } from "./activityResponseUser"; + +export interface ActivityResponse { + activity?: ActivityResponseActivity; + message?: string; + user?: ActivityResponseUser; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivity.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivity.ts new file mode 100644 index 00000000..4cc3e639 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivity.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ActivityResponseActivityPuzzlesItem } from "./activityResponseActivityPuzzlesItem"; +import type { ActivityResponseActivitySubmissionsItem } from "./activityResponseActivitySubmissionsItem"; + +export type ActivityResponseActivity = { + puzzles?: ActivityResponseActivityPuzzlesItem[]; + submissions?: ActivityResponseActivitySubmissionsItem[]; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItem.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItem.ts new file mode 100644 index 00000000..23f50f85 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItem.ts @@ -0,0 +1,39 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ActivityResponseActivityPuzzlesItemAuthor } from "./activityResponseActivityPuzzlesItemAuthor"; +import type { ActivityResponseActivityPuzzlesItemSolution } from "./activityResponseActivityPuzzlesItemSolution"; +import type { ActivityResponseActivityPuzzlesItemValidatorsItem } from "./activityResponseActivityPuzzlesItemValidatorsItem"; + +export type ActivityResponseActivityPuzzlesItem = { + _id?: string; + author?: ActivityResponseActivityPuzzlesItemAuthor; + comments?: string[]; + /** @nullable */ + constraints?: string | null; + /** @nullable */ + createdAt?: string | null; + difficulty?: string; + id?: string; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyMetricsId?: string | null; + /** @nullable */ + moderationFeedback?: string | null; + /** @nullable */ + puzzleMetrics?: string | null; + solution?: ActivityResponseActivityPuzzlesItemSolution; + /** @nullable */ + statement?: string | null; + tags?: string[]; + title?: string; + /** @nullable */ + updatedAt?: string | null; + validators?: ActivityResponseActivityPuzzlesItemValidatorsItem[]; + visibility?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemAuthor.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemAuthor.ts new file mode 100644 index 00000000..f2e6ea57 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemAuthor.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ActivityResponseActivityPuzzlesItemAuthorProfile } from "./activityResponseActivityPuzzlesItemAuthorProfile"; + +export type ActivityResponseActivityPuzzlesItemAuthor = { + _id?: string; + createdAt?: string; + id?: string; + profile?: ActivityResponseActivityPuzzlesItemAuthorProfile; + role?: string; + updatedAt?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemAuthorProfile.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemAuthorProfile.ts new file mode 100644 index 00000000..cf5a8fb3 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemAuthorProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ActivityResponseActivityPuzzlesItemAuthorProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemSolution.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemSolution.ts new file mode 100644 index 00000000..c8d83198 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemSolution.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ActivityResponseActivityPuzzlesItemSolution = { + code?: string; + /** @nullable */ + programmingLanguage?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemValidatorsItem.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemValidatorsItem.ts new file mode 100644 index 00000000..4e7563a4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemValidatorsItem.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ActivityResponseActivityPuzzlesItemValidatorsItem = { + createdAt?: string; + /** Validator input payload */ + input?: string; + isPublic?: boolean; + /** Expected validator output */ + output?: string; + updatedAt?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItem.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItem.ts new file mode 100644 index 00000000..99a958eb --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItem.ts @@ -0,0 +1,34 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ActivityResponseActivitySubmissionsItemProgrammingLanguage } from "./activityResponseActivitySubmissionsItemProgrammingLanguage"; +import type { ActivityResponseActivitySubmissionsItemPuzzle } from "./activityResponseActivitySubmissionsItemPuzzle"; +import type { ActivityResponseActivitySubmissionsItemResult } from "./activityResponseActivitySubmissionsItemResult"; +import type { ActivityResponseActivitySubmissionsItemUser } from "./activityResponseActivitySubmissionsItemUser"; + +export type ActivityResponseActivitySubmissionsItem = { + _id?: string; + /** @nullable */ + code?: string | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + gameId?: string | null; + id?: string; + /** @nullable */ + legacyGameSubmissionId?: string | null; + /** @nullable */ + legacyId?: string | null; + programmingLanguage?: ActivityResponseActivitySubmissionsItemProgrammingLanguage; + puzzle?: ActivityResponseActivitySubmissionsItemPuzzle; + result?: ActivityResponseActivitySubmissionsItemResult; + /** @nullable */ + score?: number | null; + /** @nullable */ + updatedAt?: string | null; + user?: ActivityResponseActivitySubmissionsItemUser; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemProgrammingLanguage.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemProgrammingLanguage.ts new file mode 100644 index 00000000..c4bce4e5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemProgrammingLanguage.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ActivityResponseActivitySubmissionsItemProgrammingLanguage = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + language?: string | null; + /** @nullable */ + runtime?: string | null; + /** @nullable */ + version?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemPuzzle.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemPuzzle.ts new file mode 100644 index 00000000..bb6feb52 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemPuzzle.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ActivityResponseActivitySubmissionsItemPuzzle = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + title?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemResult.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemResult.ts new file mode 100644 index 00000000..32f93e77 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemResult.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ActivityResponseActivitySubmissionsItemResult = { + [key: string]: unknown; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemUser.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemUser.ts new file mode 100644 index 00000000..1a0fd4f0 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemUser.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ActivityResponseActivitySubmissionsItemUserProfile } from "./activityResponseActivitySubmissionsItemUserProfile"; + +export type ActivityResponseActivitySubmissionsItemUser = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: ActivityResponseActivitySubmissionsItemUserProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemUserProfile.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemUserProfile.ts new file mode 100644 index 00000000..0a9ff4dc --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemUserProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ActivityResponseActivitySubmissionsItemUserProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseUser.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseUser.ts new file mode 100644 index 00000000..e0c6e6df --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseUser.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ActivityResponseUserProfile } from "./activityResponseUserProfile"; + +export type ActivityResponseUser = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: ActivityResponseUserProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseUserProfile.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseUserProfile.ts new file mode 100644 index 00000000..789bdf62 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseUserProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ActivityResponseUserProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/authMessageResponse.ts b/libs/frontend/src/lib/api/generated/schemas/authMessageResponse.ts new file mode 100644 index 00000000..05cac9b9 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/authMessageResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface AuthMessageResponse { + message?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/author.ts b/libs/frontend/src/lib/api/generated/schemas/author.ts new file mode 100644 index 00000000..0819e67e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/author.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { AuthorProfile } from "./authorProfile"; + +export interface Author { + _id?: string; + createdAt?: string; + id?: string; + profile?: AuthorProfile; + role?: string; + updatedAt?: string; + username?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/authorProfile.ts b/libs/frontend/src/lib/api/generated/schemas/authorProfile.ts new file mode 100644 index 00000000..7d8a37f8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/authorProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type AuthorProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/availabilityResponse.ts b/libs/frontend/src/lib/api/generated/schemas/availabilityResponse.ts new file mode 100644 index 00000000..bf17d777 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/availabilityResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface AvailabilityResponse { + available?: boolean; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/banResponse.ts b/libs/frontend/src/lib/api/generated/schemas/banResponse.ts new file mode 100644 index 00000000..b6e4f5b4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/banResponse.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface BanResponse { + banned?: boolean; + /** @nullable */ + bannedUntil?: string | null; + /** @nullable */ + reason?: string | null; + userId?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/banUserRequest.ts b/libs/frontend/src/lib/api/generated/schemas/banUserRequest.ts new file mode 100644 index 00000000..af8c29ab --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/banUserRequest.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface BanUserRequest { + /** @nullable */ + bannedUntil?: string | null; + /** @nullable */ + durationDays?: number | null; + /** @nullable */ + reason?: string | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebHealthControllerShow200.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebHealthControllerShow200.ts new file mode 100644 index 00000000..c29d0447 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebHealthControllerShow200.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebHealthControllerShow200 = { + status?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebHealthControllerShow2200.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebHealthControllerShow2200.ts new file mode 100644 index 00000000..2cc5139a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebHealthControllerShow2200.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebHealthControllerShow2200 = { + status?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobal2GameMode.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobal2GameMode.ts new file mode 100644 index 00000000..b42a5e0f --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobal2GameMode.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebLeaderboardControllerGlobal2GameMode = + (typeof CodincodApiWebLeaderboardControllerGlobal2GameMode)[keyof typeof CodincodApiWebLeaderboardControllerGlobal2GameMode]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CodincodApiWebLeaderboardControllerGlobal2GameMode = { + standard: "standard", + timed: "timed", + ranked: "ranked" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobal2Params.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobal2Params.ts new file mode 100644 index 00000000..76030409 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobal2Params.ts @@ -0,0 +1,26 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CodincodApiWebLeaderboardControllerGlobal2GameMode } from "./codincodApiWebLeaderboardControllerGlobal2GameMode"; + +export type CodincodApiWebLeaderboardControllerGlobal2Params = { + /** + * Game mode filter + */ + game_mode?: CodincodApiWebLeaderboardControllerGlobal2GameMode; + /** + * Number of entries to return (1-100) + * @minimum 1 + * @maximum 100 + */ + limit?: number; + /** + * Pagination offset + * @minimum 0 + */ + offset?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobalGameMode.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobalGameMode.ts new file mode 100644 index 00000000..ae1b43ec --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobalGameMode.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebLeaderboardControllerGlobalGameMode = + (typeof CodincodApiWebLeaderboardControllerGlobalGameMode)[keyof typeof CodincodApiWebLeaderboardControllerGlobalGameMode]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CodincodApiWebLeaderboardControllerGlobalGameMode = { + standard: "standard", + timed: "timed", + ranked: "ranked" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobalParams.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobalParams.ts new file mode 100644 index 00000000..8fe30600 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobalParams.ts @@ -0,0 +1,26 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CodincodApiWebLeaderboardControllerGlobalGameMode } from "./codincodApiWebLeaderboardControllerGlobalGameMode"; + +export type CodincodApiWebLeaderboardControllerGlobalParams = { + /** + * Game mode filter + */ + game_mode?: CodincodApiWebLeaderboardControllerGlobalGameMode; + /** + * Number of entries to return (1-100) + * @minimum 1 + * @maximum 100 + */ + limit?: number; + /** + * Pagination offset + * @minimum 0 + */ + offset?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerPuzzle2Params.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerPuzzle2Params.ts new file mode 100644 index 00000000..5c6a8587 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerPuzzle2Params.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebLeaderboardControllerPuzzle2Params = { + /** + * Number of entries to return (1-100) + * @minimum 1 + * @maximum 100 + */ + limit?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerPuzzleParams.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerPuzzleParams.ts new file mode 100644 index 00000000..60decddc --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerPuzzleParams.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebLeaderboardControllerPuzzleParams = { + /** + * Number of entries to return (1-100) + * @minimum 1 + * @maximum 100 + */ + limit?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2Params.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2Params.ts new file mode 100644 index 00000000..4d6300fa --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2Params.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CodincodApiWebModerationControllerListReports2ProblemType } from "./codincodApiWebModerationControllerListReports2ProblemType"; +import type { CodincodApiWebModerationControllerListReports2Status } from "./codincodApiWebModerationControllerListReports2Status"; + +export type CodincodApiWebModerationControllerListReports2Params = { + /** + * Filter by status + */ + status?: CodincodApiWebModerationControllerListReports2Status; + /** + * Filter by problem type + */ + problem_type?: CodincodApiWebModerationControllerListReports2ProblemType; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2ProblemType.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2ProblemType.ts new file mode 100644 index 00000000..74a7141e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2ProblemType.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebModerationControllerListReports2ProblemType = + (typeof CodincodApiWebModerationControllerListReports2ProblemType)[keyof typeof CodincodApiWebModerationControllerListReports2ProblemType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CodincodApiWebModerationControllerListReports2ProblemType = { + spam: "spam", + inappropriate: "inappropriate", + copyright: "copyright", + harassment: "harassment", + other: "other" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2Status.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2Status.ts new file mode 100644 index 00000000..b2f21a1a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2Status.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebModerationControllerListReports2Status = + (typeof CodincodApiWebModerationControllerListReports2Status)[keyof typeof CodincodApiWebModerationControllerListReports2Status]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CodincodApiWebModerationControllerListReports2Status = { + pending: "pending", + reviewing: "reviewing", + resolved: "resolved", + dismissed: "dismissed" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsParams.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsParams.ts new file mode 100644 index 00000000..eb3c1b4d --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsParams.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CodincodApiWebModerationControllerListReportsProblemType } from "./codincodApiWebModerationControllerListReportsProblemType"; +import type { CodincodApiWebModerationControllerListReportsStatus } from "./codincodApiWebModerationControllerListReportsStatus"; + +export type CodincodApiWebModerationControllerListReportsParams = { + /** + * Filter by status + */ + status?: CodincodApiWebModerationControllerListReportsStatus; + /** + * Filter by problem type + */ + problem_type?: CodincodApiWebModerationControllerListReportsProblemType; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsProblemType.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsProblemType.ts new file mode 100644 index 00000000..72aad3f8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsProblemType.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebModerationControllerListReportsProblemType = + (typeof CodincodApiWebModerationControllerListReportsProblemType)[keyof typeof CodincodApiWebModerationControllerListReportsProblemType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CodincodApiWebModerationControllerListReportsProblemType = { + spam: "spam", + inappropriate: "inappropriate", + copyright: "copyright", + harassment: "harassment", + other: "other" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsStatus.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsStatus.ts new file mode 100644 index 00000000..eba8678a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsStatus.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebModerationControllerListReportsStatus = + (typeof CodincodApiWebModerationControllerListReportsStatus)[keyof typeof CodincodApiWebModerationControllerListReportsStatus]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CodincodApiWebModerationControllerListReportsStatus = { + pending: "pending", + reviewing: "reviewing", + resolved: "resolved", + dismissed: "dismissed" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviews2Params.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviews2Params.ts new file mode 100644 index 00000000..ef953ac0 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviews2Params.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CodincodApiWebModerationControllerListReviews2Status } from "./codincodApiWebModerationControllerListReviews2Status"; + +export type CodincodApiWebModerationControllerListReviews2Params = { + /** + * Filter by status + */ + status?: CodincodApiWebModerationControllerListReviews2Status; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviews2Status.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviews2Status.ts new file mode 100644 index 00000000..16b36dfd --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviews2Status.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebModerationControllerListReviews2Status = + (typeof CodincodApiWebModerationControllerListReviews2Status)[keyof typeof CodincodApiWebModerationControllerListReviews2Status]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CodincodApiWebModerationControllerListReviews2Status = { + pending: "pending", + approved: "approved", + rejected: "rejected" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviewsParams.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviewsParams.ts new file mode 100644 index 00000000..f21dfce7 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviewsParams.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CodincodApiWebModerationControllerListReviewsStatus } from "./codincodApiWebModerationControllerListReviewsStatus"; + +export type CodincodApiWebModerationControllerListReviewsParams = { + /** + * Filter by status + */ + status?: CodincodApiWebModerationControllerListReviewsStatus; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviewsStatus.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviewsStatus.ts new file mode 100644 index 00000000..4d97af50 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviewsStatus.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebModerationControllerListReviewsStatus = + (typeof CodincodApiWebModerationControllerListReviewsStatus)[keyof typeof CodincodApiWebModerationControllerListReviewsStatus]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CodincodApiWebModerationControllerListReviewsStatus = { + pending: "pending", + approved: "approved", + rejected: "rejected" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebProgrammingLanguageControllerIndex200Item.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebProgrammingLanguageControllerIndex200Item.ts new file mode 100644 index 00000000..d8b36426 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebProgrammingLanguageControllerIndex200Item.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebProgrammingLanguageControllerIndex200Item = { + aliases?: string[]; + id?: string; + isActive?: boolean; + language?: string; + runtime?: string; + version?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebProgrammingLanguageControllerIndex2200Item.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebProgrammingLanguageControllerIndex2200Item.ts new file mode 100644 index 00000000..50803dde --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebProgrammingLanguageControllerIndex2200Item.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebProgrammingLanguageControllerIndex2200Item = { + aliases?: string[]; + id?: string; + isActive?: boolean; + language?: string; + runtime?: string; + version?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebPuzzleControllerIndex2Params.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebPuzzleControllerIndex2Params.ts new file mode 100644 index 00000000..bbcc07ae --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebPuzzleControllerIndex2Params.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebPuzzleControllerIndex2Params = { + /** + * Page number + * @minimum 1 + */ + page?: number; + /** + * Number of puzzles per page + * @minimum 1 + * @maximum 100 + */ + pageSize?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebPuzzleControllerIndexParams.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebPuzzleControllerIndexParams.ts new file mode 100644 index 00000000..9e6b6ca1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebPuzzleControllerIndexParams.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebPuzzleControllerIndexParams = { + /** + * Page number + * @minimum 1 + */ + page?: number; + /** + * Number of puzzles per page + * @minimum 1 + * @maximum 100 + */ + pageSize?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebUserControllerPuzzles2Params.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebUserControllerPuzzles2Params.ts new file mode 100644 index 00000000..cfa075c1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebUserControllerPuzzles2Params.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebUserControllerPuzzles2Params = { + /** + * @minimum 1 + */ + page?: number; + /** + * @minimum 1 + * @maximum 100 + */ + pageSize?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebUserControllerPuzzlesParams.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebUserControllerPuzzlesParams.ts new file mode 100644 index 00000000..2a4d8afd --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebUserControllerPuzzlesParams.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebUserControllerPuzzlesParams = { + /** + * @minimum 1 + */ + page?: number; + /** + * @minimum 1 + * @maximum 100 + */ + pageSize?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/commentCreateRequest.ts b/libs/frontend/src/lib/api/generated/schemas/commentCreateRequest.ts new file mode 100644 index 00000000..666c8721 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/commentCreateRequest.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface CommentCreateRequest { + /** @nullable */ + replyOn?: string | null; + /** + * @minLength 1 + * @maxLength 320 + */ + text: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/commentResponse.ts b/libs/frontend/src/lib/api/generated/schemas/commentResponse.ts new file mode 100644 index 00000000..18ba4701 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/commentResponse.ts @@ -0,0 +1,25 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CommentResponseAuthor } from "./commentResponseAuthor"; +import type { CommentResponseCommentType } from "./commentResponseCommentType"; + +export interface CommentResponse { + author?: CommentResponseAuthor; + authorId: string; + body: string; + commentType: CommentResponseCommentType; + downvote?: number; + id: string; + insertedAt?: string; + /** @nullable */ + parentCommentId?: string | null; + /** @nullable */ + puzzleId?: string | null; + updatedAt?: string; + upvote?: number; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/commentResponseAuthor.ts b/libs/frontend/src/lib/api/generated/schemas/commentResponseAuthor.ts new file mode 100644 index 00000000..ffdedfe9 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/commentResponseAuthor.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CommentResponseAuthor = { + id?: string; + role?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/commentResponseCommentType.ts b/libs/frontend/src/lib/api/generated/schemas/commentResponseCommentType.ts new file mode 100644 index 00000000..00ec99c8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/commentResponseCommentType.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CommentResponseCommentType = + (typeof CommentResponseCommentType)[keyof typeof CommentResponseCommentType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CommentResponseCommentType = { + "puzzle-comment": "puzzle-comment", + "comment-comment": "comment-comment", + "submission-comment": "submission-comment" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/commentVoteRequest.ts b/libs/frontend/src/lib/api/generated/schemas/commentVoteRequest.ts new file mode 100644 index 00000000..b4612dd1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/commentVoteRequest.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CommentVoteRequestType } from "./commentVoteRequestType"; + +export interface CommentVoteRequest { + type: CommentVoteRequestType; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/commentVoteRequestType.ts b/libs/frontend/src/lib/api/generated/schemas/commentVoteRequestType.ts new file mode 100644 index 00000000..61c068c5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/commentVoteRequestType.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CommentVoteRequestType = + (typeof CommentVoteRequestType)[keyof typeof CommentVoteRequestType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CommentVoteRequestType = { + upvote: "upvote", + downvote: "downvote" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/createGameRequest.ts b/libs/frontend/src/lib/api/generated/schemas/createGameRequest.ts new file mode 100644 index 00000000..81befc5e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/createGameRequest.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CreateGameRequestGameMode } from "./createGameRequestGameMode"; + +export interface CreateGameRequest { + gameMode?: CreateGameRequestGameMode; + /** + * @minimum 2 + * @maximum 10 + */ + maxPlayers?: number; + puzzleId: string; + /** @nullable */ + timeLimit?: number | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/createGameRequestGameMode.ts b/libs/frontend/src/lib/api/generated/schemas/createGameRequestGameMode.ts new file mode 100644 index 00000000..6f35bb1a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/createGameRequestGameMode.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CreateGameRequestGameMode = + (typeof CreateGameRequestGameMode)[keyof typeof CreateGameRequestGameMode]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CreateGameRequestGameMode = { + standard: "standard", + timed: "timed", + ranked: "ranked" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/createReportRequest.ts b/libs/frontend/src/lib/api/generated/schemas/createReportRequest.ts new file mode 100644 index 00000000..323452f4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/createReportRequest.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CreateReportRequestContentType } from "./createReportRequestContentType"; +import type { CreateReportRequestProblemType } from "./createReportRequestProblemType"; + +export interface CreateReportRequest { + contentId: string; + contentType: CreateReportRequestContentType; + /** @nullable */ + description?: string | null; + problemType: CreateReportRequestProblemType; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/createReportRequestContentType.ts b/libs/frontend/src/lib/api/generated/schemas/createReportRequestContentType.ts new file mode 100644 index 00000000..0c27ee73 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/createReportRequestContentType.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CreateReportRequestContentType = + (typeof CreateReportRequestContentType)[keyof typeof CreateReportRequestContentType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CreateReportRequestContentType = { + puzzle: "puzzle", + comment: "comment", + submission: "submission", + user: "user" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/createReportRequestProblemType.ts b/libs/frontend/src/lib/api/generated/schemas/createReportRequestProblemType.ts new file mode 100644 index 00000000..5b802521 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/createReportRequestProblemType.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CreateReportRequestProblemType = + (typeof CreateReportRequestProblemType)[keyof typeof CreateReportRequestProblemType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CreateReportRequestProblemType = { + spam: "spam", + inappropriate: "inappropriate", + copyright: "copyright", + harassment: "harassment", + other: "other" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/createRequest.ts b/libs/frontend/src/lib/api/generated/schemas/createRequest.ts new file mode 100644 index 00000000..bb9b168e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/createRequest.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface CreateRequest { + /** @nullable */ + replyOn?: string | null; + /** + * @minLength 1 + * @maxLength 320 + */ + text: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/createRequestDifficulty.ts b/libs/frontend/src/lib/api/generated/schemas/createRequestDifficulty.ts new file mode 100644 index 00000000..48d11b13 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/createRequestDifficulty.ts @@ -0,0 +1,25 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * @nullable + */ +export type CreateRequestDifficulty = + | (typeof CreateRequestDifficulty)[keyof typeof CreateRequestDifficulty] + | null; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CreateRequestDifficulty = { + easy: "easy", + medium: "medium", + hard: "hard", + beginner: "beginner", + intermediate: "intermediate", + advanced: "advanced", + expert: "expert" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/createRequestValidatorsItem.ts b/libs/frontend/src/lib/api/generated/schemas/createRequestValidatorsItem.ts new file mode 100644 index 00000000..96970cbf --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/createRequestValidatorsItem.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CreateRequestValidatorsItem = { + input: string; + isPublic?: boolean; + output: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/errorResponse.ts b/libs/frontend/src/lib/api/generated/schemas/errorResponse.ts new file mode 100644 index 00000000..ac5c8078 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/errorResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorResponseErrors } from "./errorResponseErrors"; + +export interface ErrorResponse { + error?: string; + errors?: ErrorResponseErrors; + message?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/errorResponseErrors.ts b/libs/frontend/src/lib/api/generated/schemas/errorResponseErrors.ts new file mode 100644 index 00000000..ac7bc7d7 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/errorResponseErrors.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ErrorResponseErrors = { [key: string]: unknown }; diff --git a/libs/frontend/src/lib/api/generated/schemas/executeRequest.ts b/libs/frontend/src/lib/api/generated/schemas/executeRequest.ts new file mode 100644 index 00000000..49e0c5c0 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/executeRequest.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface ExecuteRequest { + /** @minLength 1 */ + code: string; + /** @minLength 1 */ + language: string; + testInput?: string; + testOutput?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/executeResponse.ts b/libs/frontend/src/lib/api/generated/schemas/executeResponse.ts new file mode 100644 index 00000000..2ab5b975 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/executeResponse.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ExecuteResponseCompile } from "./executeResponseCompile"; +import type { ExecuteResponsePuzzleResultInformation } from "./executeResponsePuzzleResultInformation"; +import type { ExecuteResponseRun } from "./executeResponseRun"; + +export interface ExecuteResponse { + /** @nullable */ + compile?: ExecuteResponseCompile; + puzzleResultInformation?: ExecuteResponsePuzzleResultInformation; + run?: ExecuteResponseRun; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/executeResponseCompile.ts b/libs/frontend/src/lib/api/generated/schemas/executeResponseCompile.ts new file mode 100644 index 00000000..114af757 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/executeResponseCompile.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * @nullable + */ +export type ExecuteResponseCompile = { [key: string]: unknown } | null; diff --git a/libs/frontend/src/lib/api/generated/schemas/executeResponsePuzzleResultInformation.ts b/libs/frontend/src/lib/api/generated/schemas/executeResponsePuzzleResultInformation.ts new file mode 100644 index 00000000..7d12a151 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/executeResponsePuzzleResultInformation.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ExecuteResponsePuzzleResultInformationResult } from "./executeResponsePuzzleResultInformationResult"; + +export type ExecuteResponsePuzzleResultInformation = { + /** @minimum 0 */ + failed?: number; + /** @minimum 0 */ + passed?: number; + result?: ExecuteResponsePuzzleResultInformationResult; + /** + * @minimum 0 + * @maximum 1 + */ + successRate?: number; + /** @minimum 1 */ + total?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/executeResponsePuzzleResultInformationResult.ts b/libs/frontend/src/lib/api/generated/schemas/executeResponsePuzzleResultInformationResult.ts new file mode 100644 index 00000000..a54ff38a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/executeResponsePuzzleResultInformationResult.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ExecuteResponsePuzzleResultInformationResult = + (typeof ExecuteResponsePuzzleResultInformationResult)[keyof typeof ExecuteResponsePuzzleResultInformationResult]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ExecuteResponsePuzzleResultInformationResult = { + SUCCESS: "SUCCESS", + ERROR: "ERROR" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/executeResponseRun.ts b/libs/frontend/src/lib/api/generated/schemas/executeResponseRun.ts new file mode 100644 index 00000000..2659d7a4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/executeResponseRun.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ExecuteResponseRun = { [key: string]: unknown }; diff --git a/libs/frontend/src/lib/api/generated/schemas/gameResponse.ts b/libs/frontend/src/lib/api/generated/schemas/gameResponse.ts new file mode 100644 index 00000000..d7932807 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/gameResponse.ts @@ -0,0 +1,27 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { GameResponseOwner } from "./gameResponseOwner"; +import type { GameResponsePlayersItem } from "./gameResponsePlayersItem"; +import type { GameResponsePuzzle } from "./gameResponsePuzzle"; + +export interface GameResponse { + createdAt?: string; + /** @nullable */ + finishedAt?: string | null; + gameMode?: string; + id?: string; + maxPlayers?: number; + owner?: GameResponseOwner; + players?: GameResponsePlayersItem[]; + puzzle?: GameResponsePuzzle; + /** @nullable */ + startedAt?: string | null; + status?: string; + /** @nullable */ + timeLimit?: number | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/gameResponseOwner.ts b/libs/frontend/src/lib/api/generated/schemas/gameResponseOwner.ts new file mode 100644 index 00000000..54c4ec42 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/gameResponseOwner.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type GameResponseOwner = { + id?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/gameResponsePlayersItem.ts b/libs/frontend/src/lib/api/generated/schemas/gameResponsePlayersItem.ts new file mode 100644 index 00000000..bae94098 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/gameResponsePlayersItem.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type GameResponsePlayersItem = { + id?: string; + joinedAt?: string; + role?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/gameResponsePuzzle.ts b/libs/frontend/src/lib/api/generated/schemas/gameResponsePuzzle.ts new file mode 100644 index 00000000..3eb5131f --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/gameResponsePuzzle.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type GameResponsePuzzle = { + difficulty?: string; + id?: string; + title?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/gameSubmitCodeRequest.ts b/libs/frontend/src/lib/api/generated/schemas/gameSubmitCodeRequest.ts new file mode 100644 index 00000000..432832d6 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/gameSubmitCodeRequest.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * Request to link a submission to a game. This is the correct type for game submissions (not to be confused with SubmitCodeRequest for direct code submission) + */ +export interface GameSubmitCodeRequest { + /** The ID of the submission to link to the game */ + submissionId: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponse.ts b/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponse.ts new file mode 100644 index 00000000..51e1abfa --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponse.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { GlobalLeaderboardResponseRankingsItem } from "./globalLeaderboardResponseRankingsItem"; + +export interface GlobalLeaderboardResponse { + /** @nullable */ + cachedAt?: string | null; + gameMode?: string; + limit?: number; + offset?: number; + rankings?: GlobalLeaderboardResponseRankingsItem[]; + totalEntries?: number; + totalPages?: number; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponseRankingsItem.ts b/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponseRankingsItem.ts new file mode 100644 index 00000000..d9901d80 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponseRankingsItem.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { GlobalLeaderboardResponseRankingsItemGlicko } from "./globalLeaderboardResponseRankingsItemGlicko"; + +export type GlobalLeaderboardResponseRankingsItem = { + averageScore?: number; + bestScore?: number; + gamesPlayed?: number; + gamesWon?: number; + glicko?: GlobalLeaderboardResponseRankingsItemGlicko; + puzzlesSolved?: number; + rank?: number; + rating?: number; + totalSubmissions?: number; + userId?: string; + username?: string; + winRate?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponseRankingsItemGlicko.ts b/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponseRankingsItemGlicko.ts new file mode 100644 index 00000000..fe3578c8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponseRankingsItemGlicko.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type GlobalLeaderboardResponseRankingsItemGlicko = { + /** Rating deviation */ + rd?: number; + /** Volatility */ + vol?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/index.ts b/libs/frontend/src/lib/api/generated/schemas/index.ts new file mode 100644 index 00000000..961f0222 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/index.ts @@ -0,0 +1,215 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export * from "./accountPreferences"; +export * from "./accountPreferencesEditor"; +export * from "./accountPreferencesTheme"; +export * from "./accountProfileUpdateRequest"; +export * from "./accountProfileUpdateResponse"; +export * from "./accountProfileUpdateResponseProfile"; +export * from "./accountStatusResponse"; +export * from "./activityResponse"; +export * from "./activityResponseActivity"; +export * from "./activityResponseActivityPuzzlesItem"; +export * from "./activityResponseActivityPuzzlesItemAuthor"; +export * from "./activityResponseActivityPuzzlesItemAuthorProfile"; +export * from "./activityResponseActivityPuzzlesItemSolution"; +export * from "./activityResponseActivityPuzzlesItemValidatorsItem"; +export * from "./activityResponseActivitySubmissionsItem"; +export * from "./activityResponseActivitySubmissionsItemProgrammingLanguage"; +export * from "./activityResponseActivitySubmissionsItemPuzzle"; +export * from "./activityResponseActivitySubmissionsItemResult"; +export * from "./activityResponseActivitySubmissionsItemUser"; +export * from "./activityResponseActivitySubmissionsItemUserProfile"; +export * from "./activityResponseUser"; +export * from "./activityResponseUserProfile"; +export * from "./authMessageResponse"; +export * from "./author"; +export * from "./authorProfile"; +export * from "./availabilityResponse"; +export * from "./banResponse"; +export * from "./banUserRequest"; +export * from "./codincodApiWebHealthControllerShow200"; +export * from "./codincodApiWebHealthControllerShow2200"; +export * from "./codincodApiWebLeaderboardControllerGlobal2GameMode"; +export * from "./codincodApiWebLeaderboardControllerGlobal2Params"; +export * from "./codincodApiWebLeaderboardControllerGlobalGameMode"; +export * from "./codincodApiWebLeaderboardControllerGlobalParams"; +export * from "./codincodApiWebLeaderboardControllerPuzzle2Params"; +export * from "./codincodApiWebLeaderboardControllerPuzzleParams"; +export * from "./codincodApiWebModerationControllerListReports2Params"; +export * from "./codincodApiWebModerationControllerListReports2ProblemType"; +export * from "./codincodApiWebModerationControllerListReports2Status"; +export * from "./codincodApiWebModerationControllerListReportsParams"; +export * from "./codincodApiWebModerationControllerListReportsProblemType"; +export * from "./codincodApiWebModerationControllerListReportsStatus"; +export * from "./codincodApiWebModerationControllerListReviews2Params"; +export * from "./codincodApiWebModerationControllerListReviews2Status"; +export * from "./codincodApiWebModerationControllerListReviewsParams"; +export * from "./codincodApiWebModerationControllerListReviewsStatus"; +export * from "./codincodApiWebProgrammingLanguageControllerIndex200Item"; +export * from "./codincodApiWebProgrammingLanguageControllerIndex2200Item"; +export * from "./codincodApiWebPuzzleControllerIndex2Params"; +export * from "./codincodApiWebPuzzleControllerIndexParams"; +export * from "./codincodApiWebUserControllerPuzzles2Params"; +export * from "./codincodApiWebUserControllerPuzzlesParams"; +export * from "./commentCreateRequest"; +export * from "./commentResponse"; +export * from "./commentResponseAuthor"; +export * from "./commentResponseCommentType"; +export * from "./commentVoteRequest"; +export * from "./commentVoteRequestType"; +export * from "./createGameRequest"; +export * from "./createGameRequestGameMode"; +export * from "./createReportRequest"; +export * from "./createReportRequestContentType"; +export * from "./createReportRequestProblemType"; +export * from "./createRequest"; +export * from "./createRequestDifficulty"; +export * from "./createRequestValidatorsItem"; +export * from "./errorResponse"; +export * from "./errorResponseErrors"; +export * from "./executeRequest"; +export * from "./executeResponse"; +export * from "./executeResponseCompile"; +export * from "./executeResponsePuzzleResultInformation"; +export * from "./executeResponsePuzzleResultInformationResult"; +export * from "./executeResponseRun"; +export * from "./gameResponse"; +export * from "./gameResponseOwner"; +export * from "./gameResponsePlayersItem"; +export * from "./gameResponsePuzzle"; +export * from "./gameSubmitCodeRequest"; +export * from "./globalLeaderboardResponse"; +export * from "./globalLeaderboardResponseRankingsItem"; +export * from "./globalLeaderboardResponseRankingsItemGlicko"; +export * from "./leaveGameResponse"; +export * from "./loginRequest"; +export * from "./messageResponse"; +export * from "./paginatedListResponse"; +export * from "./paginatedListResponseItemsItem"; +export * from "./paginatedListResponseItemsItemAuthor"; +export * from "./paginatedListResponseItemsItemAuthorProfile"; +export * from "./paginatedListResponseItemsItemSolution"; +export * from "./paginatedListResponseItemsItemValidatorsItem"; +export * from "./passwordResetCompleteResponse"; +export * from "./passwordResetPayload"; +export * from "./passwordResetRequest"; +export * from "./passwordResetResponse"; +export * from "./platformMetricsResponse"; +export * from "./platformMetricsResponsePopularPuzzlesItem"; +export * from "./preferencesPayload"; +export * from "./preferencesPayloadEditor"; +export * from "./preferencesPayloadTheme"; +export * from "./profile"; +export * from "./profileUpdateRequest"; +export * from "./profileUpdateResponse"; +export * from "./profileUpdateResponseProfile"; +export * from "./programmingLanguageSummary"; +export * from "./puzzleCreateRequest"; +export * from "./puzzleCreateRequestDifficulty"; +export * from "./puzzleCreateRequestValidatorsItem"; +export * from "./puzzleLeaderboardResponse"; +export * from "./puzzleLeaderboardResponseRankingsItem"; +export * from "./puzzlePaginatedListResponse"; +export * from "./puzzlePaginatedListResponseItemsItem"; +export * from "./puzzlePaginatedListResponseItemsItemAuthor"; +export * from "./puzzlePaginatedListResponseItemsItemAuthorProfile"; +export * from "./puzzlePaginatedListResponseItemsItemSolution"; +export * from "./puzzlePaginatedListResponseItemsItemValidatorsItem"; +export * from "./puzzleResponse"; +export * from "./puzzleResponseAuthor"; +export * from "./puzzleResponseAuthorProfile"; +export * from "./puzzleResponseSolution"; +export * from "./puzzleResponseValidatorsItem"; +export * from "./puzzleResultInformation"; +export * from "./puzzleResultInformationResult"; +export * from "./puzzleStatsResponse"; +export * from "./puzzleStatsResponseLanguageDistributionItem"; +export * from "./puzzleStatsResponseStatusBreakdown"; +export * from "./puzzleSummary"; +export * from "./registerRequest"; +export * from "./reportResponse"; +export * from "./reportResponseReportedBy"; +export * from "./reportResponseResolvedBy"; +export * from "./reportsListResponse"; +export * from "./requestPayload"; +export * from "./requestResponse"; +export * from "./resetPayload"; +export * from "./resetResponse"; +export * from "./resolveReportRequest"; +export * from "./resolveReportRequestStatus"; +export * from "./reviewDecisionRequest"; +export * from "./reviewDecisionRequestStatus"; +export * from "./reviewResponse"; +export * from "./reviewResponseContextMessagesItem"; +export * from "./reviewResponseReviewer"; +export * from "./reviewsListResponse"; +export * from "./showResponse"; +export * from "./showResponseUser"; +export * from "./showResponseUserProfile"; +export * from "./solution"; +export * from "./submissionListResponse"; +export * from "./submissionListResponseItem"; +export * from "./submissionListResponseItemProgrammingLanguage"; +export * from "./submissionListResponseItemPuzzle"; +export * from "./submissionListResponseItemResult"; +export * from "./submissionListResponseItemUser"; +export * from "./submissionListResponseItemUserProfile"; +export * from "./submissionResponse"; +export * from "./submissionResponseProgrammingLanguage"; +export * from "./submissionResponsePuzzle"; +export * from "./submissionResponseResult"; +export * from "./submissionResponseUser"; +export * from "./submissionResponseUserProfile"; +export * from "./submissionSubmitRequest"; +export * from "./submissionSubmitResponse"; +export * from "./submissionSubmitResponseResult"; +export * from "./submitCodeRequest"; +export * from "./submitCodeResponse"; +export * from "./submitCodeResponseResult"; +export * from "./summary"; +export * from "./summaryProfile"; +export * from "./userActivityResponse"; +export * from "./userActivityResponseActivity"; +export * from "./userActivityResponseActivityPuzzlesItem"; +export * from "./userActivityResponseActivityPuzzlesItemAuthor"; +export * from "./userActivityResponseActivityPuzzlesItemAuthorProfile"; +export * from "./userActivityResponseActivityPuzzlesItemSolution"; +export * from "./userActivityResponseActivityPuzzlesItemValidatorsItem"; +export * from "./userActivityResponseActivitySubmissionsItem"; +export * from "./userActivityResponseActivitySubmissionsItemProgrammingLanguage"; +export * from "./userActivityResponseActivitySubmissionsItemPuzzle"; +export * from "./userActivityResponseActivitySubmissionsItemResult"; +export * from "./userActivityResponseActivitySubmissionsItemUser"; +export * from "./userActivityResponseActivitySubmissionsItemUserProfile"; +export * from "./userActivityResponseUser"; +export * from "./userActivityResponseUserProfile"; +export * from "./userAvailabilityResponse"; +export * from "./userGamesResponse"; +export * from "./userGamesResponseGamesItem"; +export * from "./userGamesResponseGamesItemOwner"; +export * from "./userGamesResponseGamesItemPlayersItem"; +export * from "./userGamesResponseGamesItemPuzzle"; +export * from "./userRankResponse"; +export * from "./userShowResponse"; +export * from "./userShowResponseUser"; +export * from "./userShowResponseUserProfile"; +export * from "./userStatsResponse"; +export * from "./userStatsResponseDifficultyBreakdown"; +export * from "./userStatsResponseLanguageUsageItem"; +export * from "./userSummary"; +export * from "./userSummaryProfile"; +export * from "./validator"; +export * from "./voteRequest"; +export * from "./voteRequestType"; +export * from "./waitingRoomsResponse"; +export * from "./waitingRoomsResponseRoomsItem"; +export * from "./waitingRoomsResponseRoomsItemOwner"; +export * from "./waitingRoomsResponseRoomsItemPlayersItem"; +export * from "./waitingRoomsResponseRoomsItemPuzzle"; diff --git a/libs/frontend/src/lib/api/generated/schemas/leaveGameResponse.ts b/libs/frontend/src/lib/api/generated/schemas/leaveGameResponse.ts new file mode 100644 index 00000000..c6ef785e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/leaveGameResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface LeaveGameResponse { + message?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/loginRequest.ts b/libs/frontend/src/lib/api/generated/schemas/loginRequest.ts new file mode 100644 index 00000000..afe2f06c --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/loginRequest.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface LoginRequest { + /** Username or email */ + identifier: string; + password: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/messageResponse.ts b/libs/frontend/src/lib/api/generated/schemas/messageResponse.ts new file mode 100644 index 00000000..87b015b5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/messageResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface MessageResponse { + message?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/paginatedListResponse.ts b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponse.ts new file mode 100644 index 00000000..7b8bb9d4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponse.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PaginatedListResponseItemsItem } from "./paginatedListResponseItemsItem"; + +export interface PaginatedListResponse { + items?: PaginatedListResponseItemsItem[]; + /** @minimum 1 */ + page?: number; + /** + * @minimum 1 + * @maximum 100 + */ + pageSize?: number; + /** @minimum 0 */ + totalItems?: number; + /** @minimum 0 */ + totalPages?: number; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItem.ts b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItem.ts new file mode 100644 index 00000000..92560abe --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItem.ts @@ -0,0 +1,39 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PaginatedListResponseItemsItemAuthor } from "./paginatedListResponseItemsItemAuthor"; +import type { PaginatedListResponseItemsItemSolution } from "./paginatedListResponseItemsItemSolution"; +import type { PaginatedListResponseItemsItemValidatorsItem } from "./paginatedListResponseItemsItemValidatorsItem"; + +export type PaginatedListResponseItemsItem = { + _id?: string; + author?: PaginatedListResponseItemsItemAuthor; + comments?: string[]; + /** @nullable */ + constraints?: string | null; + /** @nullable */ + createdAt?: string | null; + difficulty?: string; + id?: string; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyMetricsId?: string | null; + /** @nullable */ + moderationFeedback?: string | null; + /** @nullable */ + puzzleMetrics?: string | null; + solution?: PaginatedListResponseItemsItemSolution; + /** @nullable */ + statement?: string | null; + tags?: string[]; + title?: string; + /** @nullable */ + updatedAt?: string | null; + validators?: PaginatedListResponseItemsItemValidatorsItem[]; + visibility?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemAuthor.ts b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemAuthor.ts new file mode 100644 index 00000000..40712f96 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemAuthor.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PaginatedListResponseItemsItemAuthorProfile } from "./paginatedListResponseItemsItemAuthorProfile"; + +export type PaginatedListResponseItemsItemAuthor = { + _id?: string; + createdAt?: string; + id?: string; + profile?: PaginatedListResponseItemsItemAuthorProfile; + role?: string; + updatedAt?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemAuthorProfile.ts b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemAuthorProfile.ts new file mode 100644 index 00000000..f355adfd --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemAuthorProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PaginatedListResponseItemsItemAuthorProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemSolution.ts b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemSolution.ts new file mode 100644 index 00000000..379cb653 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemSolution.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PaginatedListResponseItemsItemSolution = { + code?: string; + /** @nullable */ + programmingLanguage?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemValidatorsItem.ts b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemValidatorsItem.ts new file mode 100644 index 00000000..4ee0f8c7 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemValidatorsItem.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PaginatedListResponseItemsItemValidatorsItem = { + createdAt?: string; + /** Validator input payload */ + input?: string; + isPublic?: boolean; + /** Expected validator output */ + output?: string; + updatedAt?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/passwordResetCompleteResponse.ts b/libs/frontend/src/lib/api/generated/schemas/passwordResetCompleteResponse.ts new file mode 100644 index 00000000..73fbf7ea --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/passwordResetCompleteResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface PasswordResetCompleteResponse { + message?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/passwordResetPayload.ts b/libs/frontend/src/lib/api/generated/schemas/passwordResetPayload.ts new file mode 100644 index 00000000..3e1963e5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/passwordResetPayload.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface PasswordResetPayload { + /** @minLength 8 */ + password: string; + token: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/passwordResetRequest.ts b/libs/frontend/src/lib/api/generated/schemas/passwordResetRequest.ts new file mode 100644 index 00000000..2ef76104 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/passwordResetRequest.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface PasswordResetRequest { + email: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/passwordResetResponse.ts b/libs/frontend/src/lib/api/generated/schemas/passwordResetResponse.ts new file mode 100644 index 00000000..7e82d8ff --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/passwordResetResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface PasswordResetResponse { + message?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/platformMetricsResponse.ts b/libs/frontend/src/lib/api/generated/schemas/platformMetricsResponse.ts new file mode 100644 index 00000000..4c514484 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/platformMetricsResponse.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PlatformMetricsResponsePopularPuzzlesItem } from "./platformMetricsResponsePopularPuzzlesItem"; + +export interface PlatformMetricsResponse { + acceptedSubmissions?: number; + activeUsers?: number; + popularPuzzles?: PlatformMetricsResponsePopularPuzzlesItem[]; + totalPuzzles?: number; + totalSubmissions?: number; + totalUsers?: number; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/platformMetricsResponsePopularPuzzlesItem.ts b/libs/frontend/src/lib/api/generated/schemas/platformMetricsResponsePopularPuzzlesItem.ts new file mode 100644 index 00000000..2b907ff6 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/platformMetricsResponsePopularPuzzlesItem.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PlatformMetricsResponsePopularPuzzlesItem = { + difficulty?: string; + puzzleId?: string; + submissionCount?: number; + title?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/preferencesPayload.ts b/libs/frontend/src/lib/api/generated/schemas/preferencesPayload.ts new file mode 100644 index 00000000..16e0cee2 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/preferencesPayload.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PreferencesPayloadEditor } from "./preferencesPayloadEditor"; +import type { PreferencesPayloadTheme } from "./preferencesPayloadTheme"; + +export interface PreferencesPayload { + blockedUsers?: string[]; + editor?: PreferencesPayloadEditor; + /** @nullable */ + preferredLanguage?: string | null; + /** @nullable */ + theme?: PreferencesPayloadTheme; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/preferencesPayloadEditor.ts b/libs/frontend/src/lib/api/generated/schemas/preferencesPayloadEditor.ts new file mode 100644 index 00000000..ddb82c14 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/preferencesPayloadEditor.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PreferencesPayloadEditor = { [key: string]: unknown }; diff --git a/libs/frontend/src/lib/api/generated/schemas/preferencesPayloadTheme.ts b/libs/frontend/src/lib/api/generated/schemas/preferencesPayloadTheme.ts new file mode 100644 index 00000000..3be87e99 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/preferencesPayloadTheme.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * @nullable + */ +export type PreferencesPayloadTheme = + | (typeof PreferencesPayloadTheme)[keyof typeof PreferencesPayloadTheme] + | null; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const PreferencesPayloadTheme = { + dark: "dark", + light: "light" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/profile.ts b/libs/frontend/src/lib/api/generated/schemas/profile.ts new file mode 100644 index 00000000..729d5e90 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/profile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface Profile { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/profileUpdateRequest.ts b/libs/frontend/src/lib/api/generated/schemas/profileUpdateRequest.ts new file mode 100644 index 00000000..c57f41c5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/profileUpdateRequest.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface ProfileUpdateRequest { + /** @maxLength 500 */ + bio?: string; + /** @maxLength 100 */ + location?: string; + picture?: string; + /** @maxItems 5 */ + socials?: string[]; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/profileUpdateResponse.ts b/libs/frontend/src/lib/api/generated/schemas/profileUpdateResponse.ts new file mode 100644 index 00000000..718cf80d --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/profileUpdateResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ProfileUpdateResponseProfile } from "./profileUpdateResponseProfile"; + +export interface ProfileUpdateResponse { + message?: string; + profile?: ProfileUpdateResponseProfile; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/profileUpdateResponseProfile.ts b/libs/frontend/src/lib/api/generated/schemas/profileUpdateResponseProfile.ts new file mode 100644 index 00000000..64011600 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/profileUpdateResponseProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ProfileUpdateResponseProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/programmingLanguageSummary.ts b/libs/frontend/src/lib/api/generated/schemas/programmingLanguageSummary.ts new file mode 100644 index 00000000..89b6f86e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/programmingLanguageSummary.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface ProgrammingLanguageSummary { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + language?: string | null; + /** @nullable */ + runtime?: string | null; + /** @nullable */ + version?: string | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequest.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequest.ts new file mode 100644 index 00000000..e0745380 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequest.ts @@ -0,0 +1,30 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzleCreateRequestDifficulty } from "./puzzleCreateRequestDifficulty"; +import type { PuzzleCreateRequestValidatorsItem } from "./puzzleCreateRequestValidatorsItem"; + +export interface PuzzleCreateRequest { + /** @nullable */ + constraints?: string | null; + /** + * @minLength 1 + * @nullable + */ + description?: string | null; + /** @nullable */ + difficulty?: PuzzleCreateRequestDifficulty; + /** @nullable */ + tags?: string[] | null; + /** + * @minLength 4 + * @maxLength 128 + */ + title: string; + /** @nullable */ + validators?: PuzzleCreateRequestValidatorsItem[] | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequestDifficulty.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequestDifficulty.ts new file mode 100644 index 00000000..1a4fb40c --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequestDifficulty.ts @@ -0,0 +1,25 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * @nullable + */ +export type PuzzleCreateRequestDifficulty = + | (typeof PuzzleCreateRequestDifficulty)[keyof typeof PuzzleCreateRequestDifficulty] + | null; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const PuzzleCreateRequestDifficulty = { + easy: "easy", + medium: "medium", + hard: "hard", + beginner: "beginner", + intermediate: "intermediate", + advanced: "advanced", + expert: "expert" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequestValidatorsItem.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequestValidatorsItem.ts new file mode 100644 index 00000000..672edd3d --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequestValidatorsItem.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzleCreateRequestValidatorsItem = { + input: string; + isPublic?: boolean; + output: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleLeaderboardResponse.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleLeaderboardResponse.ts new file mode 100644 index 00000000..e677f9b0 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleLeaderboardResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzleLeaderboardResponseRankingsItem } from "./puzzleLeaderboardResponseRankingsItem"; + +export interface PuzzleLeaderboardResponse { + limit?: number; + puzzleId?: string; + rankings?: PuzzleLeaderboardResponseRankingsItem[]; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleLeaderboardResponseRankingsItem.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleLeaderboardResponseRankingsItem.ts new file mode 100644 index 00000000..e5fb9098 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleLeaderboardResponseRankingsItem.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzleLeaderboardResponseRankingsItem = { + executionTime?: number; + memoryUsed?: number; + rank?: number; + submittedAt?: string; + userId?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponse.ts b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponse.ts new file mode 100644 index 00000000..54285ae1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponse.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzlePaginatedListResponseItemsItem } from "./puzzlePaginatedListResponseItemsItem"; + +export interface PuzzlePaginatedListResponse { + items?: PuzzlePaginatedListResponseItemsItem[]; + /** @minimum 1 */ + page?: number; + /** + * @minimum 1 + * @maximum 100 + */ + pageSize?: number; + /** @minimum 0 */ + totalItems?: number; + /** @minimum 0 */ + totalPages?: number; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItem.ts b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItem.ts new file mode 100644 index 00000000..dacc51ca --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItem.ts @@ -0,0 +1,39 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzlePaginatedListResponseItemsItemAuthor } from "./puzzlePaginatedListResponseItemsItemAuthor"; +import type { PuzzlePaginatedListResponseItemsItemSolution } from "./puzzlePaginatedListResponseItemsItemSolution"; +import type { PuzzlePaginatedListResponseItemsItemValidatorsItem } from "./puzzlePaginatedListResponseItemsItemValidatorsItem"; + +export type PuzzlePaginatedListResponseItemsItem = { + _id?: string; + author?: PuzzlePaginatedListResponseItemsItemAuthor; + comments?: string[]; + /** @nullable */ + constraints?: string | null; + /** @nullable */ + createdAt?: string | null; + difficulty?: string; + id?: string; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyMetricsId?: string | null; + /** @nullable */ + moderationFeedback?: string | null; + /** @nullable */ + puzzleMetrics?: string | null; + solution?: PuzzlePaginatedListResponseItemsItemSolution; + /** @nullable */ + statement?: string | null; + tags?: string[]; + title?: string; + /** @nullable */ + updatedAt?: string | null; + validators?: PuzzlePaginatedListResponseItemsItemValidatorsItem[]; + visibility?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemAuthor.ts b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemAuthor.ts new file mode 100644 index 00000000..bcae81bb --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemAuthor.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzlePaginatedListResponseItemsItemAuthorProfile } from "./puzzlePaginatedListResponseItemsItemAuthorProfile"; + +export type PuzzlePaginatedListResponseItemsItemAuthor = { + _id?: string; + createdAt?: string; + id?: string; + profile?: PuzzlePaginatedListResponseItemsItemAuthorProfile; + role?: string; + updatedAt?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemAuthorProfile.ts b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemAuthorProfile.ts new file mode 100644 index 00000000..86af91a8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemAuthorProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzlePaginatedListResponseItemsItemAuthorProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemSolution.ts b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemSolution.ts new file mode 100644 index 00000000..d8e18a6d --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemSolution.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzlePaginatedListResponseItemsItemSolution = { + code?: string; + /** @nullable */ + programmingLanguage?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemValidatorsItem.ts b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemValidatorsItem.ts new file mode 100644 index 00000000..fcff9d57 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemValidatorsItem.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzlePaginatedListResponseItemsItemValidatorsItem = { + createdAt?: string; + /** Validator input payload */ + input?: string; + isPublic?: boolean; + /** Expected validator output */ + output?: string; + updatedAt?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleResponse.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleResponse.ts new file mode 100644 index 00000000..2642940a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleResponse.ts @@ -0,0 +1,39 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzleResponseAuthor } from "./puzzleResponseAuthor"; +import type { PuzzleResponseSolution } from "./puzzleResponseSolution"; +import type { PuzzleResponseValidatorsItem } from "./puzzleResponseValidatorsItem"; + +export interface PuzzleResponse { + _id?: string; + author?: PuzzleResponseAuthor; + comments?: string[]; + /** @nullable */ + constraints?: string | null; + /** @nullable */ + createdAt?: string | null; + difficulty?: string; + id?: string; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyMetricsId?: string | null; + /** @nullable */ + moderationFeedback?: string | null; + /** @nullable */ + puzzleMetrics?: string | null; + solution?: PuzzleResponseSolution; + /** @nullable */ + statement?: string | null; + tags?: string[]; + title?: string; + /** @nullable */ + updatedAt?: string | null; + validators?: PuzzleResponseValidatorsItem[]; + visibility?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleResponseAuthor.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleResponseAuthor.ts new file mode 100644 index 00000000..8c6f1b70 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleResponseAuthor.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzleResponseAuthorProfile } from "./puzzleResponseAuthorProfile"; + +export type PuzzleResponseAuthor = { + _id?: string; + createdAt?: string; + id?: string; + profile?: PuzzleResponseAuthorProfile; + role?: string; + updatedAt?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleResponseAuthorProfile.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleResponseAuthorProfile.ts new file mode 100644 index 00000000..dc58bcd4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleResponseAuthorProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzleResponseAuthorProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleResponseSolution.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleResponseSolution.ts new file mode 100644 index 00000000..4f4c2a8c --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleResponseSolution.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzleResponseSolution = { + code?: string; + /** @nullable */ + programmingLanguage?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleResponseValidatorsItem.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleResponseValidatorsItem.ts new file mode 100644 index 00000000..f2f234b4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleResponseValidatorsItem.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzleResponseValidatorsItem = { + createdAt?: string; + /** Validator input payload */ + input?: string; + isPublic?: boolean; + /** Expected validator output */ + output?: string; + updatedAt?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleResultInformation.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleResultInformation.ts new file mode 100644 index 00000000..b2e7b351 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleResultInformation.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzleResultInformationResult } from "./puzzleResultInformationResult"; + +export interface PuzzleResultInformation { + /** @minimum 0 */ + failed?: number; + /** @minimum 0 */ + passed?: number; + result?: PuzzleResultInformationResult; + /** + * @minimum 0 + * @maximum 1 + */ + successRate?: number; + /** @minimum 1 */ + total?: number; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleResultInformationResult.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleResultInformationResult.ts new file mode 100644 index 00000000..3f566315 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleResultInformationResult.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzleResultInformationResult = + (typeof PuzzleResultInformationResult)[keyof typeof PuzzleResultInformationResult]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const PuzzleResultInformationResult = { + SUCCESS: "SUCCESS", + ERROR: "ERROR" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponse.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponse.ts new file mode 100644 index 00000000..ba2983b3 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponse.ts @@ -0,0 +1,22 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzleStatsResponseLanguageDistributionItem } from "./puzzleStatsResponseLanguageDistributionItem"; +import type { PuzzleStatsResponseStatusBreakdown } from "./puzzleStatsResponseStatusBreakdown"; + +export interface PuzzleStatsResponse { + acceptanceRate?: number; + acceptedSubmissions?: number; + /** @nullable */ + averageExecutionTime?: number | null; + languageDistribution?: PuzzleStatsResponseLanguageDistributionItem[]; + puzzleId?: string; + statusBreakdown?: PuzzleStatsResponseStatusBreakdown; + title?: string; + totalSubmissions?: number; + uniqueSolvers?: number; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponseLanguageDistributionItem.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponseLanguageDistributionItem.ts new file mode 100644 index 00000000..4205c854 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponseLanguageDistributionItem.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzleStatsResponseLanguageDistributionItem = { + count?: number; + language?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponseStatusBreakdown.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponseStatusBreakdown.ts new file mode 100644 index 00000000..e4168762 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponseStatusBreakdown.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzleStatsResponseStatusBreakdown = { + accepted?: number; + runtimeError?: number; + timeLimitExceeded?: number; + wrongAnswer?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleSummary.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleSummary.ts new file mode 100644 index 00000000..8d1333c5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleSummary.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface PuzzleSummary { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + title?: string | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/registerRequest.ts b/libs/frontend/src/lib/api/generated/schemas/registerRequest.ts new file mode 100644 index 00000000..883f67b4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/registerRequest.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface RegisterRequest { + email: string; + /** @minLength 14 */ + password: string; + passwordConfirmation?: string; + /** + * @minLength 3 + * @maxLength 20 + */ + username: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/reportResponse.ts b/libs/frontend/src/lib/api/generated/schemas/reportResponse.ts new file mode 100644 index 00000000..f73a1137 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reportResponse.ts @@ -0,0 +1,28 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ReportResponseReportedBy } from "./reportResponseReportedBy"; +import type { ReportResponseResolvedBy } from "./reportResponseResolvedBy"; + +export interface ReportResponse { + contentId?: string; + contentType?: string; + createdAt?: string; + /** @nullable */ + description?: string | null; + id?: string; + problemType?: string; + /** @nullable */ + reportedBy?: ReportResponseReportedBy; + /** @nullable */ + resolutionNotes?: string | null; + /** @nullable */ + resolvedAt?: string | null; + /** @nullable */ + resolvedBy?: ReportResponseResolvedBy; + status?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/reportResponseReportedBy.ts b/libs/frontend/src/lib/api/generated/schemas/reportResponseReportedBy.ts new file mode 100644 index 00000000..3411fbb7 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reportResponseReportedBy.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * @nullable + */ +export type ReportResponseReportedBy = { + id?: string; + username?: string; +} | null; diff --git a/libs/frontend/src/lib/api/generated/schemas/reportResponseResolvedBy.ts b/libs/frontend/src/lib/api/generated/schemas/reportResponseResolvedBy.ts new file mode 100644 index 00000000..408f372b --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reportResponseResolvedBy.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * @nullable + */ +export type ReportResponseResolvedBy = { + id?: string; + username?: string; +} | null; diff --git a/libs/frontend/src/lib/api/generated/schemas/reportsListResponse.ts b/libs/frontend/src/lib/api/generated/schemas/reportsListResponse.ts new file mode 100644 index 00000000..59379b02 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reportsListResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ReportResponse } from "./reportResponse"; + +export interface ReportsListResponse { + count?: number; + reports?: ReportResponse[]; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/requestPayload.ts b/libs/frontend/src/lib/api/generated/schemas/requestPayload.ts new file mode 100644 index 00000000..86f09814 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/requestPayload.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface RequestPayload { + email: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/requestResponse.ts b/libs/frontend/src/lib/api/generated/schemas/requestResponse.ts new file mode 100644 index 00000000..32684b35 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/requestResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface RequestResponse { + message?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/resetPayload.ts b/libs/frontend/src/lib/api/generated/schemas/resetPayload.ts new file mode 100644 index 00000000..18eb3739 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/resetPayload.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface ResetPayload { + /** @minLength 8 */ + password: string; + token: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/resetResponse.ts b/libs/frontend/src/lib/api/generated/schemas/resetResponse.ts new file mode 100644 index 00000000..8b05d064 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/resetResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface ResetResponse { + message?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/resolveReportRequest.ts b/libs/frontend/src/lib/api/generated/schemas/resolveReportRequest.ts new file mode 100644 index 00000000..f2f37ce1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/resolveReportRequest.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ResolveReportRequestStatus } from "./resolveReportRequestStatus"; + +export interface ResolveReportRequest { + /** @nullable */ + resolutionNotes?: string | null; + status: ResolveReportRequestStatus; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/resolveReportRequestStatus.ts b/libs/frontend/src/lib/api/generated/schemas/resolveReportRequestStatus.ts new file mode 100644 index 00000000..91e95d91 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/resolveReportRequestStatus.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ResolveReportRequestStatus = + (typeof ResolveReportRequestStatus)[keyof typeof ResolveReportRequestStatus]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ResolveReportRequestStatus = { + resolved: "resolved", + dismissed: "dismissed" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/reviewDecisionRequest.ts b/libs/frontend/src/lib/api/generated/schemas/reviewDecisionRequest.ts new file mode 100644 index 00000000..1da045e3 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reviewDecisionRequest.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ReviewDecisionRequestStatus } from "./reviewDecisionRequestStatus"; + +export interface ReviewDecisionRequest { + /** @nullable */ + reviewerNotes?: string | null; + status: ReviewDecisionRequestStatus; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/reviewDecisionRequestStatus.ts b/libs/frontend/src/lib/api/generated/schemas/reviewDecisionRequestStatus.ts new file mode 100644 index 00000000..3e712b1e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reviewDecisionRequestStatus.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ReviewDecisionRequestStatus = + (typeof ReviewDecisionRequestStatus)[keyof typeof ReviewDecisionRequestStatus]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ReviewDecisionRequestStatus = { + approved: "approved", + rejected: "rejected" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/reviewResponse.ts b/libs/frontend/src/lib/api/generated/schemas/reviewResponse.ts new file mode 100644 index 00000000..49352050 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reviewResponse.ts @@ -0,0 +1,43 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ReviewResponseContextMessagesItem } from "./reviewResponseContextMessagesItem"; +import type { ReviewResponseReviewer } from "./reviewResponseReviewer"; + +export interface ReviewResponse { + /** @nullable */ + authorName?: string | null; + /** @nullable */ + contextMessages?: ReviewResponseContextMessagesItem[] | null; + createdAt?: string; + /** @nullable */ + description?: string | null; + /** @nullable */ + gameId?: string | null; + id?: string; + /** @nullable */ + puzzleId?: string | null; + /** @nullable */ + reportExplanation?: string | null; + /** @nullable */ + reportedBy?: string | null; + /** @nullable */ + reportedMessageId?: string | null; + /** @nullable */ + reportedUserId?: string | null; + /** @nullable */ + reportedUserName?: string | null; + /** @nullable */ + reviewedAt?: string | null; + /** @nullable */ + reviewer?: ReviewResponseReviewer; + /** @nullable */ + reviewerNotes?: string | null; + status?: string; + /** @nullable */ + title?: string | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/reviewResponseContextMessagesItem.ts b/libs/frontend/src/lib/api/generated/schemas/reviewResponseContextMessagesItem.ts new file mode 100644 index 00000000..d8674ec1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reviewResponseContextMessagesItem.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ReviewResponseContextMessagesItem = { + _id?: string; + message?: string; + timestamp?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/reviewResponseReviewer.ts b/libs/frontend/src/lib/api/generated/schemas/reviewResponseReviewer.ts new file mode 100644 index 00000000..c514fe83 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reviewResponseReviewer.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * @nullable + */ +export type ReviewResponseReviewer = { + id?: string; + username?: string; +} | null; diff --git a/libs/frontend/src/lib/api/generated/schemas/reviewsListResponse.ts b/libs/frontend/src/lib/api/generated/schemas/reviewsListResponse.ts new file mode 100644 index 00000000..b086abff --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reviewsListResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ReviewResponse } from "./reviewResponse"; + +export interface ReviewsListResponse { + count?: number; + reviews?: ReviewResponse[]; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/showResponse.ts b/libs/frontend/src/lib/api/generated/schemas/showResponse.ts new file mode 100644 index 00000000..495e7b1e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/showResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ShowResponseUser } from "./showResponseUser"; + +export interface ShowResponse { + message?: string; + user?: ShowResponseUser; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/showResponseUser.ts b/libs/frontend/src/lib/api/generated/schemas/showResponseUser.ts new file mode 100644 index 00000000..ab2eea01 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/showResponseUser.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ShowResponseUserProfile } from "./showResponseUserProfile"; + +export type ShowResponseUser = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: ShowResponseUserProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/showResponseUserProfile.ts b/libs/frontend/src/lib/api/generated/schemas/showResponseUserProfile.ts new file mode 100644 index 00000000..13ece811 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/showResponseUserProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ShowResponseUserProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/solution.ts b/libs/frontend/src/lib/api/generated/schemas/solution.ts new file mode 100644 index 00000000..d2b80463 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/solution.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface Solution { + code?: string; + /** @nullable */ + programmingLanguage?: string | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionListResponse.ts b/libs/frontend/src/lib/api/generated/schemas/submissionListResponse.ts new file mode 100644 index 00000000..70d8ebb8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionListResponse.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { SubmissionListResponseItem } from "./submissionListResponseItem"; + +export type SubmissionListResponse = SubmissionListResponseItem[]; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItem.ts b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItem.ts new file mode 100644 index 00000000..bfa710cf --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItem.ts @@ -0,0 +1,34 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { SubmissionListResponseItemProgrammingLanguage } from "./submissionListResponseItemProgrammingLanguage"; +import type { SubmissionListResponseItemPuzzle } from "./submissionListResponseItemPuzzle"; +import type { SubmissionListResponseItemResult } from "./submissionListResponseItemResult"; +import type { SubmissionListResponseItemUser } from "./submissionListResponseItemUser"; + +export type SubmissionListResponseItem = { + _id?: string; + /** @nullable */ + code?: string | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + gameId?: string | null; + id?: string; + /** @nullable */ + legacyGameSubmissionId?: string | null; + /** @nullable */ + legacyId?: string | null; + programmingLanguage?: SubmissionListResponseItemProgrammingLanguage; + puzzle?: SubmissionListResponseItemPuzzle; + result?: SubmissionListResponseItemResult; + /** @nullable */ + score?: number | null; + /** @nullable */ + updatedAt?: string | null; + user?: SubmissionListResponseItemUser; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemProgrammingLanguage.ts b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemProgrammingLanguage.ts new file mode 100644 index 00000000..df243ebd --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemProgrammingLanguage.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionListResponseItemProgrammingLanguage = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + language?: string | null; + /** @nullable */ + runtime?: string | null; + /** @nullable */ + version?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemPuzzle.ts b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemPuzzle.ts new file mode 100644 index 00000000..ed27bda6 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemPuzzle.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionListResponseItemPuzzle = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + title?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemResult.ts b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemResult.ts new file mode 100644 index 00000000..95a1005d --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemResult.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionListResponseItemResult = { [key: string]: unknown }; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemUser.ts b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemUser.ts new file mode 100644 index 00000000..0f69aa1f --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemUser.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { SubmissionListResponseItemUserProfile } from "./submissionListResponseItemUserProfile"; + +export type SubmissionListResponseItemUser = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: SubmissionListResponseItemUserProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemUserProfile.ts b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemUserProfile.ts new file mode 100644 index 00000000..8a2a1e3a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemUserProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionListResponseItemUserProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionResponse.ts b/libs/frontend/src/lib/api/generated/schemas/submissionResponse.ts new file mode 100644 index 00000000..1e765921 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionResponse.ts @@ -0,0 +1,34 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { SubmissionResponseProgrammingLanguage } from "./submissionResponseProgrammingLanguage"; +import type { SubmissionResponsePuzzle } from "./submissionResponsePuzzle"; +import type { SubmissionResponseResult } from "./submissionResponseResult"; +import type { SubmissionResponseUser } from "./submissionResponseUser"; + +export interface SubmissionResponse { + _id?: string; + /** @nullable */ + code?: string | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + gameId?: string | null; + id?: string; + /** @nullable */ + legacyGameSubmissionId?: string | null; + /** @nullable */ + legacyId?: string | null; + programmingLanguage?: SubmissionResponseProgrammingLanguage; + puzzle?: SubmissionResponsePuzzle; + result?: SubmissionResponseResult; + /** @nullable */ + score?: number | null; + /** @nullable */ + updatedAt?: string | null; + user?: SubmissionResponseUser; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionResponseProgrammingLanguage.ts b/libs/frontend/src/lib/api/generated/schemas/submissionResponseProgrammingLanguage.ts new file mode 100644 index 00000000..d6e2ecbe --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionResponseProgrammingLanguage.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionResponseProgrammingLanguage = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + language?: string | null; + /** @nullable */ + runtime?: string | null; + /** @nullable */ + version?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionResponsePuzzle.ts b/libs/frontend/src/lib/api/generated/schemas/submissionResponsePuzzle.ts new file mode 100644 index 00000000..b443cd9b --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionResponsePuzzle.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionResponsePuzzle = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + title?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionResponseResult.ts b/libs/frontend/src/lib/api/generated/schemas/submissionResponseResult.ts new file mode 100644 index 00000000..20ab41ed --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionResponseResult.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionResponseResult = { [key: string]: unknown }; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionResponseUser.ts b/libs/frontend/src/lib/api/generated/schemas/submissionResponseUser.ts new file mode 100644 index 00000000..1d9b132b --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionResponseUser.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { SubmissionResponseUserProfile } from "./submissionResponseUserProfile"; + +export type SubmissionResponseUser = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: SubmissionResponseUserProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionResponseUserProfile.ts b/libs/frontend/src/lib/api/generated/schemas/submissionResponseUserProfile.ts new file mode 100644 index 00000000..d67ad89b --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionResponseUserProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionResponseUserProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionSubmitRequest.ts b/libs/frontend/src/lib/api/generated/schemas/submissionSubmitRequest.ts new file mode 100644 index 00000000..cff2d6d0 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionSubmitRequest.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface SubmissionSubmitRequest { + /** @minLength 1 */ + code: string; + programmingLanguageId: string; + puzzleId: string; + userId: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionSubmitResponse.ts b/libs/frontend/src/lib/api/generated/schemas/submissionSubmitResponse.ts new file mode 100644 index 00000000..cf2fec2a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionSubmitResponse.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { SubmissionSubmitResponseResult } from "./submissionSubmitResponseResult"; + +export interface SubmissionSubmitResponse { + code: string; + /** @minimum 0 */ + codeLength: number; + createdAt: string; + programmingLanguageId: string; + puzzleId: string; + result: SubmissionSubmitResponseResult; + submissionId: string; + userId: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionSubmitResponseResult.ts b/libs/frontend/src/lib/api/generated/schemas/submissionSubmitResponseResult.ts new file mode 100644 index 00000000..428d26b1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionSubmitResponseResult.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionSubmitResponseResult = { + /** @minimum 0 */ + failed: number; + /** @minimum 0 */ + passed: number; + /** + * @minimum 0 + * @maximum 1 + */ + successRate: number; + /** @minimum 1 */ + total: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submitCodeRequest.ts b/libs/frontend/src/lib/api/generated/schemas/submitCodeRequest.ts new file mode 100644 index 00000000..ecb30338 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submitCodeRequest.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface SubmitCodeRequest { + /** @minLength 1 */ + code: string; + programmingLanguageId: string; + puzzleId: string; + userId: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/submitCodeResponse.ts b/libs/frontend/src/lib/api/generated/schemas/submitCodeResponse.ts new file mode 100644 index 00000000..6e724baa --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submitCodeResponse.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { SubmitCodeResponseResult } from "./submitCodeResponseResult"; + +export interface SubmitCodeResponse { + code: string; + /** @minimum 0 */ + codeLength: number; + createdAt: string; + programmingLanguageId: string; + puzzleId: string; + result: SubmitCodeResponseResult; + submissionId: string; + userId: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/submitCodeResponseResult.ts b/libs/frontend/src/lib/api/generated/schemas/submitCodeResponseResult.ts new file mode 100644 index 00000000..6a57ffd7 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submitCodeResponseResult.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmitCodeResponseResult = { + /** @minimum 0 */ + failed: number; + /** @minimum 0 */ + passed: number; + /** + * @minimum 0 + * @maximum 1 + */ + successRate: number; + /** @minimum 1 */ + total: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/summary.ts b/libs/frontend/src/lib/api/generated/schemas/summary.ts new file mode 100644 index 00000000..51d0143e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/summary.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { SummaryProfile } from "./summaryProfile"; + +export interface Summary { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: SummaryProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/summaryProfile.ts b/libs/frontend/src/lib/api/generated/schemas/summaryProfile.ts new file mode 100644 index 00000000..8cbe6fab --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/summaryProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SummaryProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponse.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponse.ts new file mode 100644 index 00000000..bfb6aa4b --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponse.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserActivityResponseActivity } from "./userActivityResponseActivity"; +import type { UserActivityResponseUser } from "./userActivityResponseUser"; + +export interface UserActivityResponse { + activity?: UserActivityResponseActivity; + message?: string; + user?: UserActivityResponseUser; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivity.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivity.ts new file mode 100644 index 00000000..13bacc75 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivity.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserActivityResponseActivityPuzzlesItem } from "./userActivityResponseActivityPuzzlesItem"; +import type { UserActivityResponseActivitySubmissionsItem } from "./userActivityResponseActivitySubmissionsItem"; + +export type UserActivityResponseActivity = { + puzzles?: UserActivityResponseActivityPuzzlesItem[]; + submissions?: UserActivityResponseActivitySubmissionsItem[]; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItem.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItem.ts new file mode 100644 index 00000000..fbac0093 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItem.ts @@ -0,0 +1,39 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserActivityResponseActivityPuzzlesItemAuthor } from "./userActivityResponseActivityPuzzlesItemAuthor"; +import type { UserActivityResponseActivityPuzzlesItemSolution } from "./userActivityResponseActivityPuzzlesItemSolution"; +import type { UserActivityResponseActivityPuzzlesItemValidatorsItem } from "./userActivityResponseActivityPuzzlesItemValidatorsItem"; + +export type UserActivityResponseActivityPuzzlesItem = { + _id?: string; + author?: UserActivityResponseActivityPuzzlesItemAuthor; + comments?: string[]; + /** @nullable */ + constraints?: string | null; + /** @nullable */ + createdAt?: string | null; + difficulty?: string; + id?: string; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyMetricsId?: string | null; + /** @nullable */ + moderationFeedback?: string | null; + /** @nullable */ + puzzleMetrics?: string | null; + solution?: UserActivityResponseActivityPuzzlesItemSolution; + /** @nullable */ + statement?: string | null; + tags?: string[]; + title?: string; + /** @nullable */ + updatedAt?: string | null; + validators?: UserActivityResponseActivityPuzzlesItemValidatorsItem[]; + visibility?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemAuthor.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemAuthor.ts new file mode 100644 index 00000000..6466d803 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemAuthor.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserActivityResponseActivityPuzzlesItemAuthorProfile } from "./userActivityResponseActivityPuzzlesItemAuthorProfile"; + +export type UserActivityResponseActivityPuzzlesItemAuthor = { + _id?: string; + createdAt?: string; + id?: string; + profile?: UserActivityResponseActivityPuzzlesItemAuthorProfile; + role?: string; + updatedAt?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemAuthorProfile.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemAuthorProfile.ts new file mode 100644 index 00000000..0b143f25 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemAuthorProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserActivityResponseActivityPuzzlesItemAuthorProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemSolution.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemSolution.ts new file mode 100644 index 00000000..417c8fc5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemSolution.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserActivityResponseActivityPuzzlesItemSolution = { + code?: string; + /** @nullable */ + programmingLanguage?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemValidatorsItem.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemValidatorsItem.ts new file mode 100644 index 00000000..359dca86 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemValidatorsItem.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserActivityResponseActivityPuzzlesItemValidatorsItem = { + createdAt?: string; + /** Validator input payload */ + input?: string; + isPublic?: boolean; + /** Expected validator output */ + output?: string; + updatedAt?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItem.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItem.ts new file mode 100644 index 00000000..04665e70 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItem.ts @@ -0,0 +1,34 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserActivityResponseActivitySubmissionsItemProgrammingLanguage } from "./userActivityResponseActivitySubmissionsItemProgrammingLanguage"; +import type { UserActivityResponseActivitySubmissionsItemPuzzle } from "./userActivityResponseActivitySubmissionsItemPuzzle"; +import type { UserActivityResponseActivitySubmissionsItemResult } from "./userActivityResponseActivitySubmissionsItemResult"; +import type { UserActivityResponseActivitySubmissionsItemUser } from "./userActivityResponseActivitySubmissionsItemUser"; + +export type UserActivityResponseActivitySubmissionsItem = { + _id?: string; + /** @nullable */ + code?: string | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + gameId?: string | null; + id?: string; + /** @nullable */ + legacyGameSubmissionId?: string | null; + /** @nullable */ + legacyId?: string | null; + programmingLanguage?: UserActivityResponseActivitySubmissionsItemProgrammingLanguage; + puzzle?: UserActivityResponseActivitySubmissionsItemPuzzle; + result?: UserActivityResponseActivitySubmissionsItemResult; + /** @nullable */ + score?: number | null; + /** @nullable */ + updatedAt?: string | null; + user?: UserActivityResponseActivitySubmissionsItemUser; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemProgrammingLanguage.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemProgrammingLanguage.ts new file mode 100644 index 00000000..7b5a6183 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemProgrammingLanguage.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserActivityResponseActivitySubmissionsItemProgrammingLanguage = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + language?: string | null; + /** @nullable */ + runtime?: string | null; + /** @nullable */ + version?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemPuzzle.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemPuzzle.ts new file mode 100644 index 00000000..96e3ad7f --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemPuzzle.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserActivityResponseActivitySubmissionsItemPuzzle = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + title?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemResult.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemResult.ts new file mode 100644 index 00000000..c53e6dfd --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemResult.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserActivityResponseActivitySubmissionsItemResult = { + [key: string]: unknown; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemUser.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemUser.ts new file mode 100644 index 00000000..08652d43 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemUser.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserActivityResponseActivitySubmissionsItemUserProfile } from "./userActivityResponseActivitySubmissionsItemUserProfile"; + +export type UserActivityResponseActivitySubmissionsItemUser = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: UserActivityResponseActivitySubmissionsItemUserProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemUserProfile.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemUserProfile.ts new file mode 100644 index 00000000..25f49f55 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemUserProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserActivityResponseActivitySubmissionsItemUserProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseUser.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseUser.ts new file mode 100644 index 00000000..1d6ccff5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseUser.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserActivityResponseUserProfile } from "./userActivityResponseUserProfile"; + +export type UserActivityResponseUser = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: UserActivityResponseUserProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseUserProfile.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseUserProfile.ts new file mode 100644 index 00000000..e7191959 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseUserProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserActivityResponseUserProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userAvailabilityResponse.ts b/libs/frontend/src/lib/api/generated/schemas/userAvailabilityResponse.ts new file mode 100644 index 00000000..97161712 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userAvailabilityResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface UserAvailabilityResponse { + available?: boolean; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/userGamesResponse.ts b/libs/frontend/src/lib/api/generated/schemas/userGamesResponse.ts new file mode 100644 index 00000000..95e6b374 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userGamesResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserGamesResponseGamesItem } from "./userGamesResponseGamesItem"; + +export interface UserGamesResponse { + count?: number; + games?: UserGamesResponseGamesItem[]; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItem.ts b/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItem.ts new file mode 100644 index 00000000..fc3b33cb --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItem.ts @@ -0,0 +1,27 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserGamesResponseGamesItemOwner } from "./userGamesResponseGamesItemOwner"; +import type { UserGamesResponseGamesItemPlayersItem } from "./userGamesResponseGamesItemPlayersItem"; +import type { UserGamesResponseGamesItemPuzzle } from "./userGamesResponseGamesItemPuzzle"; + +export type UserGamesResponseGamesItem = { + createdAt?: string; + /** @nullable */ + finishedAt?: string | null; + gameMode?: string; + id?: string; + maxPlayers?: number; + owner?: UserGamesResponseGamesItemOwner; + players?: UserGamesResponseGamesItemPlayersItem[]; + puzzle?: UserGamesResponseGamesItemPuzzle; + /** @nullable */ + startedAt?: string | null; + status?: string; + /** @nullable */ + timeLimit?: number | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemOwner.ts b/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemOwner.ts new file mode 100644 index 00000000..3ca8d1cd --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemOwner.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserGamesResponseGamesItemOwner = { + id?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemPlayersItem.ts b/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemPlayersItem.ts new file mode 100644 index 00000000..44aa1f52 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemPlayersItem.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserGamesResponseGamesItemPlayersItem = { + id?: string; + joinedAt?: string; + role?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemPuzzle.ts b/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemPuzzle.ts new file mode 100644 index 00000000..eb7124c8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemPuzzle.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserGamesResponseGamesItemPuzzle = { + difficulty?: string; + id?: string; + title?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userRankResponse.ts b/libs/frontend/src/lib/api/generated/schemas/userRankResponse.ts new file mode 100644 index 00000000..ea51fe13 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userRankResponse.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface UserRankResponse { + /** @nullable */ + puzzlesSolved?: number | null; + /** @nullable */ + rank?: number | null; + /** @nullable */ + rating?: number | null; + /** @nullable */ + totalSubmissions?: number | null; + userId?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/userShowResponse.ts b/libs/frontend/src/lib/api/generated/schemas/userShowResponse.ts new file mode 100644 index 00000000..dbba5e33 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userShowResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserShowResponseUser } from "./userShowResponseUser"; + +export interface UserShowResponse { + message?: string; + user?: UserShowResponseUser; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/userShowResponseUser.ts b/libs/frontend/src/lib/api/generated/schemas/userShowResponseUser.ts new file mode 100644 index 00000000..061c3121 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userShowResponseUser.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserShowResponseUserProfile } from "./userShowResponseUserProfile"; + +export type UserShowResponseUser = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: UserShowResponseUserProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userShowResponseUserProfile.ts b/libs/frontend/src/lib/api/generated/schemas/userShowResponseUserProfile.ts new file mode 100644 index 00000000..dad95fd6 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userShowResponseUserProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserShowResponseUserProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userStatsResponse.ts b/libs/frontend/src/lib/api/generated/schemas/userStatsResponse.ts new file mode 100644 index 00000000..7acd65cc --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userStatsResponse.ts @@ -0,0 +1,24 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserStatsResponseDifficultyBreakdown } from "./userStatsResponseDifficultyBreakdown"; +import type { UserStatsResponseLanguageUsageItem } from "./userStatsResponseLanguageUsageItem"; + +export interface UserStatsResponse { + acceptanceRate?: number; + acceptedSubmissions?: number; + difficultyBreakdown?: UserStatsResponseDifficultyBreakdown; + languageUsage?: UserStatsResponseLanguageUsageItem[]; + puzzlesSolved?: number; + recentActivity?: number; + runtimeErrors?: number; + timeLimitExceeded?: number; + totalSubmissions?: number; + userId?: string; + username?: string; + wrongAnswerSubmissions?: number; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/userStatsResponseDifficultyBreakdown.ts b/libs/frontend/src/lib/api/generated/schemas/userStatsResponseDifficultyBreakdown.ts new file mode 100644 index 00000000..5094e47f --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userStatsResponseDifficultyBreakdown.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserStatsResponseDifficultyBreakdown = { + easy?: number; + expert?: number; + hard?: number; + medium?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userStatsResponseLanguageUsageItem.ts b/libs/frontend/src/lib/api/generated/schemas/userStatsResponseLanguageUsageItem.ts new file mode 100644 index 00000000..3f57a862 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userStatsResponseLanguageUsageItem.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserStatsResponseLanguageUsageItem = { + count?: number; + language?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userSummary.ts b/libs/frontend/src/lib/api/generated/schemas/userSummary.ts new file mode 100644 index 00000000..972dda81 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userSummary.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserSummaryProfile } from "./userSummaryProfile"; + +export interface UserSummary { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: UserSummaryProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/userSummaryProfile.ts b/libs/frontend/src/lib/api/generated/schemas/userSummaryProfile.ts new file mode 100644 index 00000000..b1a34334 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userSummaryProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserSummaryProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/validator.ts b/libs/frontend/src/lib/api/generated/schemas/validator.ts new file mode 100644 index 00000000..546db774 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/validator.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface Validator { + createdAt?: string; + /** Validator input payload */ + input?: string; + isPublic?: boolean; + /** Expected validator output */ + output?: string; + updatedAt?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/voteRequest.ts b/libs/frontend/src/lib/api/generated/schemas/voteRequest.ts new file mode 100644 index 00000000..821600db --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/voteRequest.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { VoteRequestType } from "./voteRequestType"; + +export interface VoteRequest { + type: VoteRequestType; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/voteRequestType.ts b/libs/frontend/src/lib/api/generated/schemas/voteRequestType.ts new file mode 100644 index 00000000..f7f2d9f9 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/voteRequestType.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type VoteRequestType = + (typeof VoteRequestType)[keyof typeof VoteRequestType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const VoteRequestType = { + upvote: "upvote", + downvote: "downvote" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponse.ts b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponse.ts new file mode 100644 index 00000000..54278446 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { WaitingRoomsResponseRoomsItem } from "./waitingRoomsResponseRoomsItem"; + +export interface WaitingRoomsResponse { + count?: number; + rooms?: WaitingRoomsResponseRoomsItem[]; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItem.ts b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItem.ts new file mode 100644 index 00000000..c28d3f1a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItem.ts @@ -0,0 +1,27 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { WaitingRoomsResponseRoomsItemOwner } from "./waitingRoomsResponseRoomsItemOwner"; +import type { WaitingRoomsResponseRoomsItemPlayersItem } from "./waitingRoomsResponseRoomsItemPlayersItem"; +import type { WaitingRoomsResponseRoomsItemPuzzle } from "./waitingRoomsResponseRoomsItemPuzzle"; + +export type WaitingRoomsResponseRoomsItem = { + createdAt?: string; + /** @nullable */ + finishedAt?: string | null; + gameMode?: string; + id?: string; + maxPlayers?: number; + owner?: WaitingRoomsResponseRoomsItemOwner; + players?: WaitingRoomsResponseRoomsItemPlayersItem[]; + puzzle?: WaitingRoomsResponseRoomsItemPuzzle; + /** @nullable */ + startedAt?: string | null; + status?: string; + /** @nullable */ + timeLimit?: number | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemOwner.ts b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemOwner.ts new file mode 100644 index 00000000..9cbbab24 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemOwner.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type WaitingRoomsResponseRoomsItemOwner = { + id?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemPlayersItem.ts b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemPlayersItem.ts new file mode 100644 index 00000000..5795e1f8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemPlayersItem.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type WaitingRoomsResponseRoomsItemPlayersItem = { + id?: string; + joinedAt?: string; + role?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemPuzzle.ts b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemPuzzle.ts new file mode 100644 index 00000000..2a7af1f2 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemPuzzle.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type WaitingRoomsResponseRoomsItemPuzzle = { + difficulty?: string; + id?: string; + title?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/submission/submission.ts b/libs/frontend/src/lib/api/generated/submission/submission.ts new file mode 100644 index 00000000..56ed2ef4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/submission/submission.ts @@ -0,0 +1,98 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + SubmissionResponse, + SubmitCodeRequest, + SubmitCodeResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Fetch submission by id + */ +export const getCodincodApiWebSubmissionControllerShow2Url = (id: string) => { + return `/api/submission/${id}`; +}; + +export const codincodApiWebSubmissionControllerShow2 = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebSubmissionControllerShow2Url(id), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Fetch submission by id + */ +export const getCodincodApiWebSubmissionControllerShowUrl = (id: string) => { + return `/api/v1/submission/${id}`; +}; + +export const codincodApiWebSubmissionControllerShow = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebSubmissionControllerShowUrl(id), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Submit code for evaluation + */ +export const getCodincodApiWebSubmissionControllerCreate2Url = () => { + return `/api/submission`; +}; + +export const codincodApiWebSubmissionControllerCreate2 = async ( + submitCodeRequest?: SubmitCodeRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebSubmissionControllerCreate2Url(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(submitCodeRequest) + } + ); +}; + +/** + * @summary Submit code for evaluation + */ +export const getCodincodApiWebSubmissionControllerCreateUrl = () => { + return `/api/v1/submission`; +}; + +export const codincodApiWebSubmissionControllerCreate = async ( + submitCodeRequest?: SubmitCodeRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebSubmissionControllerCreateUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(submitCodeRequest) + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/user/user.ts b/libs/frontend/src/lib/api/generated/user/user.ts new file mode 100644 index 00000000..82fb403d --- /dev/null +++ b/libs/frontend/src/lib/api/generated/user/user.ts @@ -0,0 +1,217 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + ActivityResponse, + AvailabilityResponse, + CodincodApiWebUserControllerPuzzles2Params, + CodincodApiWebUserControllerPuzzlesParams, + PaginatedListResponse, + ShowResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Get user activity (puzzles and submissions) + */ +export const getCodincodApiWebUserControllerActivity2Url = ( + username: string +) => { + return `/api/user/${username}/activity`; +}; + +export const codincodApiWebUserControllerActivity2 = async ( + username: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebUserControllerActivity2Url(username), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary List puzzles authored by a user + */ +export const getCodincodApiWebUserControllerPuzzles2Url = ( + username: string, + params?: CodincodApiWebUserControllerPuzzles2Params +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/user/${username}/puzzle?${stringifiedParams}` + : `/api/user/${username}/puzzle`; +}; + +export const codincodApiWebUserControllerPuzzles2 = async ( + username: string, + params?: CodincodApiWebUserControllerPuzzles2Params, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebUserControllerPuzzles2Url(username, params), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get user by username + */ +export const getCodincodApiWebUserControllerShow2Url = (username: string) => { + return `/api/user/${username}`; +}; + +export const codincodApiWebUserControllerShow2 = async ( + username: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebUserControllerShow2Url(username), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get user activity (puzzles and submissions) + */ +export const getCodincodApiWebUserControllerActivityUrl = ( + username: string +) => { + return `/api/v1/user/${username}/activity`; +}; + +export const codincodApiWebUserControllerActivity = async ( + username: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebUserControllerActivityUrl(username), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary List puzzles authored by a user + */ +export const getCodincodApiWebUserControllerPuzzlesUrl = ( + username: string, + params?: CodincodApiWebUserControllerPuzzlesParams +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/user/${username}/puzzle?${stringifiedParams}` + : `/api/v1/user/${username}/puzzle`; +}; + +export const codincodApiWebUserControllerPuzzles = async ( + username: string, + params?: CodincodApiWebUserControllerPuzzlesParams, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebUserControllerPuzzlesUrl(username, params), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get user by username + */ +export const getCodincodApiWebUserControllerShowUrl = (username: string) => { + return `/api/v1/user/${username}`; +}; + +export const codincodApiWebUserControllerShow = async ( + username: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebUserControllerShowUrl(username), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Check username availability + */ +export const getCodincodApiWebUserControllerAvailability2Url = ( + username: string +) => { + return `/api/user/${username}/isAvailable`; +}; + +export const codincodApiWebUserControllerAvailability2 = async ( + username: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebUserControllerAvailability2Url(username), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Check username availability + */ +export const getCodincodApiWebUserControllerAvailabilityUrl = ( + username: string +) => { + return `/api/v1/user/${username}/isAvailable`; +}; + +export const codincodApiWebUserControllerAvailability = async ( + username: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebUserControllerAvailabilityUrl(username), + { + ...options, + method: "GET" + } + ); +}; diff --git a/libs/frontend/src/lib/api/notifications.ts b/libs/frontend/src/lib/api/notifications.ts new file mode 100644 index 00000000..fd6ef198 --- /dev/null +++ b/libs/frontend/src/lib/api/notifications.ts @@ -0,0 +1,282 @@ +/** + * User-friendly error notifications for API errors + * + * Provides toast notifications for critical errors with appropriate messaging + * based on error type (network, auth, validation, server error, etc.) + */ + +import { toast } from "svelte-sonner"; +import { ApiError } from "./errors"; + +export interface NotificationOptions { + /** Show notification for this error? Default: true */ + showNotification?: boolean; + + /** Custom title for the notification */ + title?: string; + + /** Custom message override */ + message?: string; + + /** Duration in milliseconds (default: based on severity) */ + duration?: number; + + /** Action button config */ + action?: { + label: string; + onClick: () => void; + }; +} + +/** + * Show appropriate error notification based on error type + * + * @example + * ```typescript + * try { + * await api.post('/api/submit', data); + * } catch (error) { + * showErrorNotification(error, { + * title: 'Submission Failed', + * action: { + * label: 'Retry', + * onClick: () => submitAgain() + * } + * }); + * throw error; + * } + * ``` + */ +export function showErrorNotification( + error: unknown, + options: NotificationOptions = {} +): void { + const { showNotification = true, title, message, duration, action } = options; + + if (!showNotification) return; + + if (error instanceof ApiError) { + // Network/Connection errors (status 0 or 5xx) + if (error.isNetworkError()) { + const toastOptions: { + description: string; + duration: number; + action?: { label: string; onClick: () => void }; + } = { + description: + message || + error.data.message || + "Unable to reach the server. Please check your internet connection and try again.", + duration: duration || 6000 + }; + + if (action) { + toastOptions.action = { + label: action.label, + onClick: action.onClick + }; + } + + toast.error(title || "Connection Error", toastOptions); + return; + } + + // Authentication errors (401) + if (error.isStatus(401)) { + const defaultAction = { + label: "Log In", + onClick: () => (window.location.href = "/login") + }; + + toast.error(title || "Authentication Required", { + description: + message || error.data.message || "Please log in to continue.", + duration: duration || 5000, + action: action || defaultAction + }); + return; + } + + // Authorization errors (403) + if (error.isStatus(403)) { + toast.error(title || "Access Denied", { + description: + message || + error.data.message || + "You do not have permission to perform this action.", + duration: duration || 5000 + }); + return; + } + + // Not found errors (404) + if (error.isStatus(404)) { + toast.error(title || "Not Found", { + description: + message || + error.data.message || + "The requested resource could not be found.", + duration: duration || 4000 + }); + return; + } + + // Rate limiting (429) + if (error.isStatus(429)) { + toast.error(title || "Too Many Requests", { + description: + message || + error.data.message || + "You are making requests too quickly. Please wait a moment and try again.", + duration: duration || 5000 + }); + return; + } + + // Validation errors (400) + if (error.isStatus(400)) { + const fieldErrors = error.getFieldErrors(); + const hasFieldErrors = Object.keys(fieldErrors).length > 0; + + toast.error(title || "Validation Error", { + description: + message || + (hasFieldErrors + ? "Please check the form for errors." + : error.data.message || "The data you provided is invalid."), + duration: duration || 5000 + }); + return; + } + + // Server errors (500+) + if (error.status >= 500) { + const toastOptions: { + description: string; + duration: number; + action?: { label: string; onClick: () => void }; + } = { + description: + message || "An error occurred on the server. Please try again later.", + duration: duration || 6000 + }; + + if (action) { + toastOptions.action = { + label: action.label, + onClick: action.onClick + }; + } + + toast.error(title || "Server Error", toastOptions); + return; + } + + // Generic API error + const toastOptions: { + description: string; + duration: number; + action?: { label: string; onClick: () => void }; + } = { + description: message || error.data.message || error.message, + duration: duration || 4000 + }; + + if (action) { + toastOptions.action = { + label: action.label, + onClick: action.onClick + }; + } + + toast.error(title || "Error", toastOptions); + return; + } + + // Non-API errors + console.error("Unexpected error:", error); + toast.error(title || "Unexpected Error", { + description: message || "An unexpected error occurred. Please try again.", + duration: duration || 4000 + }); +} + +/** + * Show success notification + */ +export function showSuccessNotification( + message: string, + options: { title?: string; duration?: number } = {} +): void { + const { title, duration = 3000 } = options; + + if (title) { + toast.success(title, { + description: message, + duration + }); + } else { + toast.success(message, { duration }); + } +} + +/** + * Show info notification + */ +export function showInfoNotification( + message: string, + options: { title?: string; duration?: number } = {} +): void { + const { title, duration = 3000 } = options; + + if (title) { + toast.info(title, { + description: message, + duration + }); + } else { + toast.info(message, { duration }); + } +} + +/** + * Show warning notification + */ +export function showWarningNotification( + message: string, + options: { title?: string; duration?: number } = {} +): void { + const { title, duration = 4000 } = options; + + if (title) { + toast.warning(title, { + description: message, + duration + }); + } else { + toast.warning(message, { duration }); + } +} + +/** + * Wrapper for critical operations that should always notify on error + * + * @example + * ```typescript + * await withErrorNotification( + * () => api.post('/api/submit', data), + * { title: 'Submission Failed' } + * ); + * ``` + */ +export async function withErrorNotification( + operation: () => Promise, + options: NotificationOptions = {} +): Promise { + try { + return await operation(); + } catch (error) { + showErrorNotification(error, options); + throw error; + } +} diff --git a/libs/frontend/src/lib/components/external-wrapper/codemirror-wrapper.svelte b/libs/frontend/src/lib/components/external-wrapper/codemirror-wrapper.svelte index f37dec1a..70837883 100644 --- a/libs/frontend/src/lib/components/external-wrapper/codemirror-wrapper.svelte +++ b/libs/frontend/src/lib/components/external-wrapper/codemirror-wrapper.svelte @@ -212,7 +212,6 @@ ]); $effect(() => { - //eslint-disable-next-line @typescript-eslint/no-unused-expressions value; if (view) untrack(() => update(value)); }); diff --git a/libs/frontend/src/lib/components/nav/navigation/navigation.svelte b/libs/frontend/src/lib/components/nav/navigation/navigation.svelte index 390197fd..53102419 100644 --- a/libs/frontend/src/lib/components/nav/navigation/navigation.svelte +++ b/libs/frontend/src/lib/components/nav/navigation/navigation.svelte @@ -3,15 +3,30 @@ import ToggleTheme from "../toggle-theme.svelte"; import UserDropdown from "../user-dropdown.svelte"; import NavigationItem from "./navigation-item.svelte"; - import { isAuthenticated, isDarkTheme, toggleDarkTheme } from "@/stores"; + import { isDarkTheme, toggleDarkTheme } from "@/stores/theme.store"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu"; - import { authenticatedUserInfo } from "@/stores"; + import { isAuthenticated, authenticatedUserInfo } from "@/stores/auth.store"; import Menu from "@lucide/svelte/icons/menu"; import Moon from "@lucide/svelte/icons/moon"; import Sun from "@lucide/svelte/icons/sun"; import { testIds } from "types"; + import { logger } from "$lib/utils/debug-logger"; const version = import.meta.env.VITE_APP_VERSION; + + // Log navigation state changes + $effect(() => { + logger.nav("Navigation rendering with auth state", { + isAuthenticated: $isAuthenticated, + userInfo: $authenticatedUserInfo + ? { + userId: $authenticatedUserInfo.userId, + username: $authenticatedUserInfo.username, + isAuthenticated: $authenticatedUserInfo.isAuthenticated + } + : null + }); + });
@@ -108,7 +123,7 @@ {/snippet} - {#if $authenticatedUserInfo?.isAuthenticated} + {#if $isAuthenticated && $authenticatedUserInfo} {@const profileLink = frontendUrls.userProfileByUsername( $authenticatedUserInfo.username )} @@ -122,7 +137,7 @@ - {#if $authenticatedUserInfo?.isAuthenticated} + {#if $isAuthenticated} {#snippet child(props)} Settings @@ -139,7 +154,7 @@ - {#if $authenticatedUserInfo?.isAuthenticated} + {#if $isAuthenticated} {#snippet child(props)} Log out diff --git a/libs/frontend/src/lib/components/nav/toggle-theme.svelte b/libs/frontend/src/lib/components/nav/toggle-theme.svelte index e48f07dd..75383322 100644 --- a/libs/frontend/src/lib/components/nav/toggle-theme.svelte +++ b/libs/frontend/src/lib/components/nav/toggle-theme.svelte @@ -1,5 +1,5 @@ -{#if $authenticatedUserInfo?.isAuthenticated} +{#if $isAuthenticated && $authenticatedUserInfo} {#snippet child({ props: avatarProps })} diff --git a/libs/frontend/src/lib/components/typography/markdown.svelte b/libs/frontend/src/lib/components/typography/markdown.svelte index 02913b65..3188b838 100644 --- a/libs/frontend/src/lib/components/typography/markdown.svelte +++ b/libs/frontend/src/lib/components/typography/markdown.svelte @@ -6,7 +6,7 @@ fallbackText = "no fallback provided", markdown = undefined }: { - markdown?: string | undefined; + markdown?: string | null | undefined; fallbackText?: string; } = $props(); @@ -17,7 +17,7 @@ }; -{#if markdown !== undefined} +{#if markdown !== undefined && markdown !== null} {#await parseMarkdown(markdown)}

Loading...

{:then parsedMarkdown} diff --git a/libs/frontend/src/lib/components/ui/alert/index.ts b/libs/frontend/src/lib/components/ui/alert/index.ts index 84571318..a66ef40f 100644 --- a/libs/frontend/src/lib/components/ui/alert/index.ts +++ b/libs/frontend/src/lib/components/ui/alert/index.ts @@ -1,8 +1,8 @@ import { type VariantProps, tv } from "tailwind-variants/lite"; -import Root from "./alert.svelte"; import Description from "./alert-description.svelte"; import Title from "./alert-title.svelte"; +import Root from "./alert.svelte"; export const alertVariants = tv({ base: "[&>svg]:text-foreground relative w-full rounded-lg border p-4 [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4", @@ -26,11 +26,11 @@ export type Variant = VariantProps["variant"]; export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; export { - Root, - Description, - Title, // Root as Alert, Description as AlertDescription, - Title as AlertTitle + Title as AlertTitle, + Description, + Root, + Title }; diff --git a/libs/frontend/src/lib/components/ui/avatar/index.ts b/libs/frontend/src/lib/components/ui/avatar/index.ts index b08c7803..c265c5d8 100644 --- a/libs/frontend/src/lib/components/ui/avatar/index.ts +++ b/libs/frontend/src/lib/components/ui/avatar/index.ts @@ -1,13 +1,13 @@ -import Root from "./avatar.svelte"; -import Image from "./avatar-image.svelte"; import Fallback from "./avatar-fallback.svelte"; +import Image from "./avatar-image.svelte"; +import Root from "./avatar.svelte"; export { - Root, - Image, - Fallback, // Root as Avatar, + Fallback as AvatarFallback, Image as AvatarImage, - Fallback as AvatarFallback + Fallback, + Image, + Root }; diff --git a/libs/frontend/src/lib/components/ui/badge/index.ts b/libs/frontend/src/lib/components/ui/badge/index.ts index 64e0aa9b..c35b4f37 100644 --- a/libs/frontend/src/lib/components/ui/badge/index.ts +++ b/libs/frontend/src/lib/components/ui/badge/index.ts @@ -1,2 +1,5 @@ -export { default as Badge } from "./badge.svelte"; -export { badgeVariants, type BadgeVariant } from "./badge.svelte"; +export { + default as Badge, + badgeVariants, + type BadgeVariant +} from "./badge.svelte"; diff --git a/libs/frontend/src/lib/components/ui/breadcrumb/index.ts b/libs/frontend/src/lib/components/ui/breadcrumb/index.ts index 26519567..77917ca4 100644 --- a/libs/frontend/src/lib/components/ui/breadcrumb/index.ts +++ b/libs/frontend/src/lib/components/ui/breadcrumb/index.ts @@ -1,25 +1,25 @@ -import Root from "./breadcrumb.svelte"; import Ellipsis from "./breadcrumb-ellipsis.svelte"; import Item from "./breadcrumb-item.svelte"; -import Separator from "./breadcrumb-separator.svelte"; import Link from "./breadcrumb-link.svelte"; import List from "./breadcrumb-list.svelte"; import Page from "./breadcrumb-page.svelte"; +import Separator from "./breadcrumb-separator.svelte"; +import Root from "./breadcrumb.svelte"; export { - Root, - Ellipsis, - Item, - Separator, - Link, - List, - Page, // Root as Breadcrumb, Ellipsis as BreadcrumbEllipsis, Item as BreadcrumbItem, - Separator as BreadcrumbSeparator, Link as BreadcrumbLink, List as BreadcrumbList, - Page as BreadcrumbPage + Page as BreadcrumbPage, + Separator as BreadcrumbSeparator, + Ellipsis, + Item, + Link, + List, + Page, + Root, + Separator }; diff --git a/libs/frontend/src/lib/components/ui/button-group/index.ts b/libs/frontend/src/lib/components/ui/button-group/index.ts index 476bef81..b5458f0f 100644 --- a/libs/frontend/src/lib/components/ui/button-group/index.ts +++ b/libs/frontend/src/lib/components/ui/button-group/index.ts @@ -1,13 +1,13 @@ -import Root from "./button-group.svelte"; -import Text from "./button-group-text.svelte"; import Separator from "./button-group-separator.svelte"; +import Text from "./button-group-text.svelte"; +import Root from "./button-group.svelte"; export { - Root, - Text, - Separator, // Root as ButtonGroup, + Separator as ButtonGroupSeparator, Text as ButtonGroupText, - Separator as ButtonGroupSeparator + Root, + Separator, + Text }; diff --git a/libs/frontend/src/lib/components/ui/button/index.ts b/libs/frontend/src/lib/components/ui/button/index.ts index 068bfa26..2eb9e3eb 100644 --- a/libs/frontend/src/lib/components/ui/button/index.ts +++ b/libs/frontend/src/lib/components/ui/button/index.ts @@ -6,12 +6,12 @@ import Root, { } from "./button.svelte"; export { - Root, - type ButtonProps as Props, // Root as Button, buttonVariants, + Root, type ButtonProps, type ButtonSize, - type ButtonVariant + type ButtonVariant, + type ButtonProps as Props }; diff --git a/libs/frontend/src/lib/components/ui/card/index.ts b/libs/frontend/src/lib/components/ui/card/index.ts index d821ceb2..c20f9c94 100644 --- a/libs/frontend/src/lib/components/ui/card/index.ts +++ b/libs/frontend/src/lib/components/ui/card/index.ts @@ -1,22 +1,22 @@ -import Root from "./card.svelte"; import Content from "./card-content.svelte"; import Description from "./card-description.svelte"; import Footer from "./card-footer.svelte"; import Header from "./card-header.svelte"; import Title from "./card-title.svelte"; +import Root from "./card.svelte"; export { - Root, - Content, - Description, - Footer, - Header, - Title, // Root as Card, Content as CardContent, Description as CardDescription, Footer as CardFooter, Header as CardHeader, - Title as CardTitle + Title as CardTitle, + Content, + Description, + Footer, + Header, + Root, + Title }; diff --git a/libs/frontend/src/lib/components/ui/checkbox/index.ts b/libs/frontend/src/lib/components/ui/checkbox/index.ts index 5fba5a4d..85473cd8 100644 --- a/libs/frontend/src/lib/components/ui/checkbox/index.ts +++ b/libs/frontend/src/lib/components/ui/checkbox/index.ts @@ -1,6 +1,6 @@ import Root from "./checkbox.svelte"; export { - Root, // - Root as Checkbox + Root as Checkbox, + Root }; 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 72cb72c9..020aeda8 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 @@ -1,7 +1,7 @@ diff --git a/libs/frontend/src/lib/components/ui/table/index.ts b/libs/frontend/src/lib/components/ui/table/index.ts index 450c9b33..adbc1c0c 100644 --- a/libs/frontend/src/lib/components/ui/table/index.ts +++ b/libs/frontend/src/lib/components/ui/table/index.ts @@ -1,4 +1,3 @@ -import Root from "./table.svelte"; import Body from "./table-body.svelte"; import Caption from "./table-caption.svelte"; import Cell from "./table-cell.svelte"; @@ -6,15 +5,16 @@ import Footer from "./table-footer.svelte"; import Head from "./table-head.svelte"; import Header from "./table-header.svelte"; import Row from "./table-row.svelte"; +import Root from "./table.svelte"; export { - Root, Body, Caption, Cell, Footer, Head, Header, + Root, Row, // Root as Table, diff --git a/libs/frontend/src/lib/components/ui/tabs/index.ts b/libs/frontend/src/lib/components/ui/tabs/index.ts index 968804cc..45450e34 100644 --- a/libs/frontend/src/lib/components/ui/tabs/index.ts +++ b/libs/frontend/src/lib/components/ui/tabs/index.ts @@ -6,13 +6,13 @@ import Trigger from "./tabs-trigger.svelte"; const Root = TabsPrimitive.Root; export { - Root, Content, List, - Trigger, + Root, // Root as Tabs, Content as TabsContent, List as TabsList, - Trigger as TabsTrigger + Trigger as TabsTrigger, + Trigger }; diff --git a/libs/frontend/src/lib/components/websocket/connection-status.svelte b/libs/frontend/src/lib/components/websocket/connection-status.svelte index f3b2eddd..527afdf5 100644 --- a/libs/frontend/src/lib/components/websocket/connection-status.svelte +++ b/libs/frontend/src/lib/components/websocket/connection-status.svelte @@ -12,7 +12,7 @@ class: className = "", showLabel = false }: { - wsManager: WebSocketManager; + wsManager: WebSocketManager; state: WebSocketState; class?: string; showLabel?: boolean; diff --git a/libs/frontend/src/lib/config/websocket.ts b/libs/frontend/src/lib/config/websocket.ts index 97164b81..9c8268f9 100644 --- a/libs/frontend/src/lib/config/websocket.ts +++ b/libs/frontend/src/lib/config/websocket.ts @@ -1,14 +1,14 @@ -import { ERROR_MESSAGES } from "types"; - export function buildWebSocketUrl(path: string): string { - const wsBaseUrl = import.meta.env.VITE_BACKEND_WEBSOCKET_MULTIPLAYER; + // Use the Elixir backend WebSocket endpoint + // In development: ws://localhost:4000/socket + // The backend URL without the protocol prefix + const backendUrl = + import.meta.env.VITE_ELIXIR_BACKEND_URL || "http://localhost:4000"; - if (!wsBaseUrl) { - throw new Error( - `${ERROR_MESSAGES.SERVER.INTERNAL_ERROR}: VITE_BACKEND_WEBSOCKET_MULTIPLAYER environment variable is not set` - ); - } + // Convert http(s) to ws(s) + const wsBaseUrl = backendUrl.replace(/^http/, "ws"); + // Phoenix WebSocket endpoint is at /socket const baseUrl = wsBaseUrl.endsWith("/") ? wsBaseUrl.slice(0, -1) : wsBaseUrl; const normalizedPath = path.startsWith("/") ? path : `/${path}`; diff --git a/libs/frontend/src/lib/features/authentication/register/config/register-form-schema.ts b/libs/frontend/src/lib/features/authentication/register/config/register-form-schema.ts index 40435b14..3cb64c4a 100644 --- a/libs/frontend/src/lib/features/authentication/register/config/register-form-schema.ts +++ b/libs/frontend/src/lib/features/authentication/register/config/register-form-schema.ts @@ -1,8 +1,7 @@ -import { z } from "zod"; import { registerSchema } from "types"; +import { z } from "zod"; // import { fetchWithAuthenticationCookie } from "../../utils/fetch-with-authentication-cookie"; // import { buildBackendUrl } from "@/config/backend"; -import { backendUrls } from "types"; export const registerFormSchema = registerSchema; // TODO: fix this, doesn't go to backend right now, it does, but doesn't update the form diff --git a/libs/frontend/src/lib/features/authentication/utils/fetch-with-authentication-cookie.ts b/libs/frontend/src/lib/features/authentication/utils/fetch-with-authentication-cookie.ts index d876deaf..067a48dd 100644 --- a/libs/frontend/src/lib/features/authentication/utils/fetch-with-authentication-cookie.ts +++ b/libs/frontend/src/lib/features/authentication/utils/fetch-with-authentication-cookie.ts @@ -1,5 +1,4 @@ import { defaultFetchOptions } from "@/config/default-fetch-options"; -import { environment } from "types"; export function getCookieHeader(request: Request): Record { const cookie = request.headers.get("cookie"); diff --git a/libs/frontend/src/lib/features/authentication/utils/get-authenticated-user-info.ts b/libs/frontend/src/lib/features/authentication/utils/get-authenticated-user-info.ts index 8ba3805e..74eb383a 100644 --- a/libs/frontend/src/lib/features/authentication/utils/get-authenticated-user-info.ts +++ b/libs/frontend/src/lib/features/authentication/utils/get-authenticated-user-info.ts @@ -1,70 +1,87 @@ -import { httpRequestMethod, backendUrls, cookieKeys } from "types"; +import { logger } from "$lib/utils/debug-logger"; +import { codincodApiWebAccountControllerShow2 } from "@/api/generated/account/account"; import type { Cookies } from "@sveltejs/kit"; -import { buildBackendUrl } from "@/config/backend"; +import { cookieKeys } from "types"; +/** + * Verifies authentication status by checking with the backend + * @param cookies - SvelteKit cookies object + * @param eventFetch - SvelteKit's fetch function (for SSR) + * @returns Authentication info including user data if authenticated + */ export async function getAuthenticatedUserInfo( cookies: Cookies, eventFetch = fetch ) { - try { - const url = buildBackendUrl(backendUrls.ACCOUNT); + logger.auth("🔍 Checking authentication status..."); - // Get the token cookie to forward to the backend + try { + // Check if token cookie exists const token = cookies.get(cookieKeys.TOKEN); + logger.auth("Token cookie check", { + exists: !!token, + cookieKey: cookieKeys.TOKEN, + tokenPreview: token ? `${token.substring(0, 20)}...` : null + }); + if (!token) { + logger.auth("❌ No token found - user not authenticated"); return { isAuthenticated: false }; } - const headers: HeadersInit = { - "Content-Type": "application/json", - // Forward the cookie to the backend - Cookie: `${cookieKeys.TOKEN}=${token}` - }; - - const response = await eventFetch(url, { - method: httpRequestMethod.GET, - headers - }); - - if (!response.ok) { - if (response.status === 401) { - // Token is invalid, just return unauthenticated - return { - isAuthenticated: false - }; - } - console.error( - `Failed to verify authentication: ${response.status} ${response.statusText} from ${url}` - ); - const errorBody = await response - .text() - .catch(() => "Unable to read response body"); - console.error("Response body:", errorBody); - throw new Error( - `Failed to verify authentication: ${response.status} ${response.statusText}` - ); - } - - const authenticatedInfo = await response.json(); + // Use generated Orval endpoint with server-side fetch + logger.auth("Calling account endpoint to verify token..."); + const authenticatedInfo = await codincodApiWebAccountControllerShow2({ + fetch: eventFetch + } as RequestInit); + logger.auth("✅ Account endpoint response received", authenticatedInfo); - if (authenticatedInfo.isAuthenticated) { + // The backend returns { isAuthenticated: true, userId, username, role } + if ( + authenticatedInfo && + typeof authenticatedInfo === "object" && + "isAuthenticated" in authenticatedInfo + ) { + logger.auth("✅ User authenticated successfully", { + userId: authenticatedInfo.userId, + username: authenticatedInfo.username, + role: authenticatedInfo.role, + isAuthenticated: authenticatedInfo.isAuthenticated + }); return authenticatedInfo; } + + logger.auth( + "⚠️ Invalid response format from account endpoint - treating as not authenticated" + ); + return { + isAuthenticated: false + }; } catch (err) { + // Handle 401 Unauthorized gracefully (invalid/expired token) + if (err instanceof Error && err.message.includes("401")) { + logger.auth("❌ 401 Unauthorized - token invalid or expired"); + return { + isAuthenticated: false + }; + } + + // Log other errors for debugging if (err instanceof Error) { - console.error("Error verifying authentication:", err.message); - if ("cause" in err) { - console.error("Error cause:", err.cause); - } + logger.error("Error verifying authentication", { + message: err.message, + name: err.name, + stack: err.stack + }); } else { - console.error("Error verifying authentication:", err); + logger.error("Error verifying authentication (unknown error)", err); } - } - return { - isAuthenticated: false - }; + return { + isAuthenticated: false + }; + } } diff --git a/libs/frontend/src/lib/features/authentication/utils/is-sveltekit-redirect.ts b/libs/frontend/src/lib/features/authentication/utils/is-sveltekit-redirect.ts new file mode 100644 index 00000000..f8c7ae69 --- /dev/null +++ b/libs/frontend/src/lib/features/authentication/utils/is-sveltekit-redirect.ts @@ -0,0 +1,34 @@ +/** + * Type guard to check if an error is a SvelteKit redirect + * + * SvelteKit's redirect() function throws an object with status and location properties. + * This helper detects those redirect errors so they can be re-thrown to allow + * SvelteKit's routing layer to handle them properly. + * + * @param error - The caught error to check + * @returns true if the error is a SvelteKit redirect (status 3xx with location) + * + * @example + * ```ts + * try { + * await doSomething(); + * throw redirect(302, '/dashboard'); + * } catch (error) { + * if (isSvelteKitRedirect(error)) { + * throw error; // Let SvelteKit handle the redirect + * } + * // Handle other errors + * } + * ``` + */ +export function isSvelteKitRedirect(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "status" in error && + "location" in error && + typeof error.status === "number" && + error.status >= 300 && + error.status < 400 + ); +} diff --git a/libs/frontend/src/lib/features/authentication/utils/set-cookie.ts b/libs/frontend/src/lib/features/authentication/utils/set-cookie.ts index ceea463a..cf7b23b3 100644 --- a/libs/frontend/src/lib/features/authentication/utils/set-cookie.ts +++ b/libs/frontend/src/lib/features/authentication/utils/set-cookie.ts @@ -1,5 +1,5 @@ -import type { Cookies } from "@sveltejs/kit"; import { env } from "$env/dynamic/private"; +import type { Cookies } from "@sveltejs/kit"; import { environment, getCookieOptions } from "types"; export function setCookie(result: Response, cookies: Cookies) { diff --git a/libs/frontend/src/lib/features/chat/components/chat-message.svelte b/libs/frontend/src/lib/features/chat/components/chat-message.svelte index 80e0aa2f..082a3f19 100644 --- a/libs/frontend/src/lib/features/chat/components/chat-message.svelte +++ b/libs/frontend/src/lib/features/chat/components/chat-message.svelte @@ -1,6 +1,6 @@ diff --git a/libs/frontend/src/lib/features/comment/components/comment.svelte b/libs/frontend/src/lib/features/comment/components/comment.svelte index 1adf9dbc..f36b4689 100644 --- a/libs/frontend/src/lib/features/comment/components/comment.svelte +++ b/libs/frontend/src/lib/features/comment/components/comment.svelte @@ -2,12 +2,9 @@ import { commentTypeEnum, getUserIdFromUser, - httpRequestMethod, isAuthor, isCommentDto, - voteTypeEnum, type CommentDto, - type CommentVoteRequest, type ObjectId } from "types"; import CommentMetaInfo from "./comment-meta-info.svelte"; @@ -21,10 +18,14 @@ import MessageCircle from "@lucide/svelte/icons/message-circle"; import MessageCircleOff from "@lucide/svelte/icons/message-circle-off"; import Trash from "@lucide/svelte/icons/trash"; - import { fetchWithAuthenticationCookie } from "@/features/authentication/utils/fetch-with-authentication-cookie"; - import { buildBackendUrl } from "@/config/backend"; - import { backendUrls } from "types"; - import { authenticatedUserInfo, isAuthenticated } from "@/stores"; + import { + codincodApiWebCommentControllerVote2, + codincodApiWebCommentControllerShow2, + codincodApiWebCommentControllerDelete2 + } from "@/api/generated/default/default"; + import { VoteRequestType } from "@/api/generated/schemas/voteRequestType"; + import type { VoteRequest } from "@/api/generated/schemas/voteRequest"; + import { authenticatedUserInfo, isAuthenticated } from "@/stores/auth.store"; import * as DropdownMenu from "@/components/ui/dropdown-menu"; import { testIds } from "types"; @@ -38,17 +39,12 @@ let isReplying: boolean = $state(false); - async function handleVote(commentVoteRequest: CommentVoteRequest) { - const response = await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.commentByIdVote(comment._id)), - { - body: JSON.stringify(commentVoteRequest), - method: httpRequestMethod.POST - } + async function handleVote(commentVoteRequest: VoteRequest) { + const updatedComment = await codincodApiWebCommentControllerVote2( + comment._id, + commentVoteRequest ); - const updatedComment = await response.json(); - if (isCommentDto(updatedComment)) { comment = { ...comment, @@ -59,7 +55,7 @@ } function onCommentAdded(newComment: CommentDto) { - const newComments = [...(comment.comments ?? []), newComment] as any[]; // unfortunately needed because recursive types are hard + const newComments = [...(comment.comments ?? []), newComment._id]; comment = { ...comment, comments: newComments @@ -69,19 +65,13 @@ } async function fetchReplies() { - const response = await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.commentById(comment._id)), - { - method: httpRequestMethod.GET - } - ); - - const updatedCommentInfoWithSubComments = await response.json(); + const updatedCommentInfoWithSubComments = + await codincodApiWebCommentControllerShow2(comment._id); if (isCommentDto(updatedCommentInfoWithSubComments)) { comment = { ...comment, - comments: [...(updatedCommentInfoWithSubComments.comments ?? [])], + comments: updatedCommentInfoWithSubComments.comments ?? [], downvote: updatedCommentInfoWithSubComments.downvote, text: updatedCommentInfoWithSubComments.text, updatedAt: updatedCommentInfoWithSubComments.updatedAt, @@ -91,13 +81,7 @@ } async function deleteComment() { - await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.commentById(comment._id)), - { - method: httpRequestMethod.DELETE - } - ); - + await codincodApiWebCommentControllerDelete2(comment._id); onDeleted(comment._id); } @@ -154,7 +138,7 @@ data-testid={testIds.COMMENT_COMPONENT_BUTTON_UPVOTE_COMMENT} variant="outline" onclick={() => { - handleVote({ type: voteTypeEnum.UPVOTE }); + handleVote({ type: VoteRequestType.upvote }); }} > @@ -164,7 +148,7 @@ data-testid={testIds.COMMENT_COMPONENT_BUTTON_DOWNVOTE_COMMENT} variant="outline" onclick={() => { - handleVote({ type: voteTypeEnum.DOWNVOTE }); + handleVote({ type: VoteRequestType.downvote }); }} > diff --git a/libs/frontend/src/lib/features/game/components/codemirror.svelte b/libs/frontend/src/lib/features/game/components/codemirror.svelte index 047976bf..e3802647 100644 --- a/libs/frontend/src/lib/features/game/components/codemirror.svelte +++ b/libs/frontend/src/lib/features/game/components/codemirror.svelte @@ -1,7 +1,8 @@ {#if puzzle}

- {puzzle.title} + {puzzle.title ?? "Untitled Puzzle"}

- {#if isUserDto(puzzle.author)} + {#if authorUsername}
Created by
- +
{/if} -
Created on
-
- {formattedDateYearMonthDay(puzzle.createdAt)} -
+ {#if puzzle.createdAt} +
Created on
+
+ {formattedDateYearMonthDay(puzzle.createdAt as string | Date)} +
+ {/if} - {#if hasBeenUpdated} + {#if hasBeenUpdated && puzzle.updatedAt}
Updated on
- {formattedDateYearMonthDay(puzzle.createdAt)} + {formattedDateYearMonthDay(puzzle.updatedAt as string | Date)}
{/if}
diff --git a/libs/frontend/src/lib/features/puzzles/components/user-hover-card.svelte b/libs/frontend/src/lib/features/puzzles/components/user-hover-card.svelte index 9cdd45ab..2ed61b5c 100644 --- a/libs/frontend/src/lib/features/puzzles/components/user-hover-card.svelte +++ b/libs/frontend/src/lib/features/puzzles/components/user-hover-card.svelte @@ -4,9 +4,7 @@ import * as Avatar from "$lib/components/ui/avatar"; import { frontendUrls, isUserDto, type UserDto } from "types"; import Calendar from "@lucide/svelte/icons/calendar"; - import { buildBackendUrl } from "@/config/backend"; - import { backendUrls } from "types"; - import { fetchWithAuthenticationCookie } from "@/features/authentication/utils/fetch-with-authentication-cookie"; + import { codincodApiWebUserControllerShow2 } from "@/api/generated/user/user"; import type { Button as ButtonPrimitive } from "bits-ui"; import dayjs from "dayjs"; import { cn } from "@/utils/cn"; @@ -29,15 +27,10 @@ return userInfoCache[username]; } - let url = buildBackendUrl(backendUrls.userByUsername(username)); + const response = await codincodApiWebUserControllerShow2(username); + userInfoCache[username] = response as UserDto; - const response = await fetchWithAuthenticationCookie(url).then((res) => - res.json() - ); - - userInfoCache[username] = response; - - return response; + return response as UserDto; } @@ -56,7 +49,7 @@ {#await fetchUserInfo(username)} loading... - {:then { user }} + {:then user} {#if isUserDto(user)}
diff --git a/libs/frontend/src/lib/stores/auth.store.ts b/libs/frontend/src/lib/stores/auth.store.ts new file mode 100644 index 00000000..b3ba7094 --- /dev/null +++ b/libs/frontend/src/lib/stores/auth.store.ts @@ -0,0 +1,79 @@ +import { browser } from "$app/environment"; +import { logger } from "$lib/utils/debug-logger"; +import { derived, writable } from "svelte/store"; +import type { AuthenticatedInfo } from "types"; + +/** + * Store for authenticated user information + * Contains user ID, username, role, and authentication status + */ +export const authenticatedUserInfo = writable(null); + +/** + * Derived store that returns true if user is authenticated + */ +export const isAuthenticated = derived(authenticatedUserInfo, (userInfo) => { + const authenticated = userInfo?.isAuthenticated ?? false; + + logger.store("isAuthenticated derived update", { + authenticated, + userInfo: userInfo + ? { + userId: userInfo.userId, + username: userInfo.username, + isAuthenticated: userInfo.isAuthenticated + } + : null + }); + + return authenticated; +}); + +/** + * Update authenticated user information + */ +export function setAuthenticatedUser(userInfo: AuthenticatedInfo | null) { + authenticatedUserInfo.set(userInfo); +} + +/** + * Clear authenticated user information (logout) + */ +export function clearAuthenticatedUser() { + authenticatedUserInfo.set(null); +} + +/** + * Check if user has a specific role + */ +export const hasRole = (role: string) => + derived(authenticatedUserInfo, (userInfo) => userInfo?.role === role); + +/** + * Get current user ID + */ +export const currentUserId = derived( + authenticatedUserInfo, + (userInfo) => userInfo?.userId +); + +/** + * Get current username + */ +export const currentUsername = derived( + authenticatedUserInfo, + (userInfo) => userInfo?.username +); + +// Debug logging in development +if (browser) { + authenticatedUserInfo.subscribe((value) => { + logger.store("authenticatedUserInfo changed", { + isAuthenticated: value?.isAuthenticated ?? false, + userId: value?.userId, + username: value?.username, + role: value?.role, + fullValue: value + }); + }); +} diff --git a/libs/frontend/src/lib/stores/current-time.ts b/libs/frontend/src/lib/stores/current-time.store.ts similarity index 100% rename from libs/frontend/src/lib/stores/current-time.ts rename to libs/frontend/src/lib/stores/current-time.store.ts diff --git a/libs/frontend/src/lib/stores/languages.store.ts b/libs/frontend/src/lib/stores/languages.store.ts new file mode 100644 index 00000000..8d948fdb --- /dev/null +++ b/libs/frontend/src/lib/stores/languages.store.ts @@ -0,0 +1,187 @@ +import { browser } from "$app/environment"; +import { codincodApiWebProgrammingLanguageControllerIndex2 } from "@/api/generated/default/default"; +import { localStorageKeys } from "@/config/local-storage"; +import { logger } from "@/utils/debug-logger"; +import { get, writable } from "svelte/store"; +import { type ProgrammingLanguageDto } from "types"; +import { isAuthenticated } from "./auth.store"; + +const CACHE_DURATION_MS = 1000 * 60 * 60; // 1 hour +const RETRY_DELAY_MS = 5000; // 5 seconds +const MAX_RETRIES = 3; + +interface LanguagesCache { + languages: ProgrammingLanguageDto[]; + timestamp: number; +} + +const createLanguagesStore = () => { + const { set, subscribe } = writable([]); + let retryCount = 0; + let retryTimeout: ReturnType | null = null; + + // Helper to safely parse cached data + const getCachedLanguages = (): ProgrammingLanguageDto[] | null => { + if (!browser) { + logger.store("Languages cache check skipped (not in browser)"); + return null; + } + + try { + const cached = localStorage.getItem(localStorageKeys.LANGUAGES); + if (!cached) { + logger.store("No cached languages found"); + return null; + } + + const parsed: LanguagesCache = JSON.parse(cached); + const now = Date.now(); + + // Check if cache is still valid + if ( + parsed.timestamp && + parsed.languages && + Array.isArray(parsed.languages) + ) { + const age = now - parsed.timestamp; + if (age < CACHE_DURATION_MS) { + logger.store( + `Using cached languages (${parsed.languages.length} items, age: ${Math.round(age / 1000)}s)` + ); + return parsed.languages; + } else { + logger.store( + `Cached languages expired (age: ${Math.round(age / 1000)}s > ${CACHE_DURATION_MS / 1000}s)` + ); + } + } + } catch (error) { + logger.error("Failed to parse cached languages", error); + // Clear invalid cache + localStorage.removeItem(localStorageKeys.LANGUAGES); + } + return null; + }; + + // Helper to save to cache + const saveToCache = (languages: ProgrammingLanguageDto[]): void => { + if (!browser) return; + + try { + const cache: LanguagesCache = { + languages, + timestamp: Date.now() + }; + localStorage.setItem(localStorageKeys.LANGUAGES, JSON.stringify(cache)); + logger.store(`Cached ${languages.length} languages to localStorage`); + } catch (error) { + logger.error("Failed to save languages to cache", error); + } + }; + + const fetchLanguages = async (): Promise => { + if (!browser) { + logger.store("Languages fetch skipped (not in browser)"); + return; + } + + // Check if user is authenticated before fetching + const authenticated = get(isAuthenticated); + if (!authenticated) { + logger.store("Languages fetch skipped (user not authenticated)"); + return; + } + + try { + logger.store("Fetching languages from API..."); + const languagesArray = + await codincodApiWebProgrammingLanguageControllerIndex2(); + + logger.store("Raw languages response:", languagesArray); + + if (!Array.isArray(languagesArray) || languagesArray.length === 0) { + logger.error("Languages array is empty or invalid:", languagesArray); + throw new Error("Languages array is empty or invalid"); + } + + logger.store(`Successfully loaded ${languagesArray.length} languages`); + set(languagesArray as ProgrammingLanguageDto[]); + saveToCache(languagesArray as ProgrammingLanguageDto[]); + retryCount = 0; // Reset retry count on success + } catch (error) { + logger.error("Failed to load languages", error); + + // Retry logic + if (retryCount < MAX_RETRIES) { + retryCount++; + logger.store( + `Retrying language fetch (${retryCount}/${MAX_RETRIES}) in ${RETRY_DELAY_MS}ms...` + ); + + retryTimeout = setTimeout(() => { + fetchLanguages(); + }, RETRY_DELAY_MS); + } else { + logger.error("Max retries reached for loading languages"); + // Still keep cached data if available + } + } + }; + + return { + async loadLanguages() { + if (!browser) { + logger.store("loadLanguages() skipped (not in browser)"); + return; + } + + logger.store("loadLanguages() called"); + + // Try to load from cache first + const cached = getCachedLanguages(); + if (cached && cached.length > 0) { + logger.store(`Setting ${cached.length} cached languages to store`); + set(cached); + // Refresh in background + logger.store("Starting background refresh of languages"); + fetchLanguages().catch((error) => { + logger.error("Background refresh of languages failed", error); + }); + return; + } + + // No valid cache, fetch from server + logger.store("No valid cache, fetching languages from server"); + await fetchLanguages(); + }, + + async refreshLanguages() { + logger.store("refreshLanguages() called - forcing fresh fetch"); + await fetchLanguages(); + }, + + clearRetryTimeout() { + if (retryTimeout) { + logger.store("Clearing languages retry timeout"); + clearTimeout(retryTimeout); + retryTimeout = null; + } + }, + + subscribe + }; +}; + +export const languages = createLanguagesStore(); + +// Auto-load languages when user logs in +if (browser) { + isAuthenticated.subscribe((authenticated) => { + if (authenticated) { + logger.store("User authenticated - auto-loading languages"); + languages.loadLanguages(); + } else { + logger.store("User not authenticated - skipping language load"); + } + }); +} diff --git a/libs/frontend/src/lib/stores/languages.ts b/libs/frontend/src/lib/stores/languages.ts deleted file mode 100644 index 3114d8b3..00000000 --- a/libs/frontend/src/lib/stores/languages.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { browser } from "$app/environment"; -import { localStorageKeys } from "@/config/local-storage"; -import { buildBackendUrl } from "@/config/backend"; -import { writable } from "svelte/store"; -import { - backendUrls, - httpRequestMethod, - type ProgrammingLanguageDto -} from "types"; - -const CACHE_DURATION_MS = 1000 * 60 * 60; // 1 hour -const RETRY_DELAY_MS = 5000; // 5 seconds -const MAX_RETRIES = 3; - -interface LanguagesCache { - languages: ProgrammingLanguageDto[]; - timestamp: number; -} - -const createLanguagesStore = () => { - const { set, subscribe } = writable([]); - let retryCount = 0; - let retryTimeout: ReturnType | null = null; - - // Helper to safely parse cached data - const getCachedLanguages = (): ProgrammingLanguageDto[] | null => { - if (!browser) return null; - - try { - const cached = localStorage.getItem(localStorageKeys.LANGUAGES); - if (!cached) return null; - - const parsed: LanguagesCache = JSON.parse(cached); - const now = Date.now(); - - // Check if cache is still valid - if ( - parsed.timestamp && - parsed.languages && - Array.isArray(parsed.languages) - ) { - if (now - parsed.timestamp < CACHE_DURATION_MS) { - return parsed.languages; - } - } - } catch (error) { - console.error("Failed to parse cached languages:", error); - // Clear invalid cache - localStorage.removeItem(localStorageKeys.LANGUAGES); - } - return null; - }; - - // Helper to save to cache - const saveToCache = (languages: ProgrammingLanguageDto[]): void => { - if (!browser) return; - - try { - const cache: LanguagesCache = { - languages, - timestamp: Date.now() - }; - localStorage.setItem(localStorageKeys.LANGUAGES, JSON.stringify(cache)); - } catch (error) { - console.error("Failed to save languages to cache:", error); - } - }; - - const fetchLanguages = async (): Promise => { - if (!browser) return; - - try { - const response = await fetch( - buildBackendUrl(backendUrls.PROGRAMMING_LANGUAGE), - { - method: httpRequestMethod.GET, - headers: { - "Content-Type": "application/json" - } - } - ); - - if (!response.ok) { - throw new Error( - `Failed to fetch languages: ${response.status} ${response.statusText}` - ); - } - - const data = await response.json(); - - if (!data.languages || !Array.isArray(data.languages)) { - throw new Error("Invalid response format: expected { languages: [] }"); - } - - set(data.languages); - saveToCache(data.languages); - retryCount = 0; // Reset retry count on success - } catch (error) { - console.error("Failed to load languages:", error); - - // Retry logic - if (retryCount < MAX_RETRIES) { - retryCount++; - console.log( - `Retrying language fetch (${retryCount}/${MAX_RETRIES}) in ${RETRY_DELAY_MS}ms...` - ); - - retryTimeout = setTimeout(() => { - fetchLanguages(); - }, RETRY_DELAY_MS); - } else { - console.error("Max retries reached for loading languages"); - // Still keep cached data if available - } - } - }; - - return { - async loadLanguages() { - if (!browser) return; - - // Try to load from cache first - const cached = getCachedLanguages(); - if (cached && cached.length > 0) { - set(cached); - // Refresh in background - fetchLanguages().catch((error) => { - console.warn("Background refresh of languages failed:", error); - }); - return; - } - - // No valid cache, fetch from server - await fetchLanguages(); - }, - - async refreshLanguages() { - await fetchLanguages(); - }, - - clearRetryTimeout() { - if (retryTimeout) { - clearTimeout(retryTimeout); - retryTimeout = null; - } - }, - - subscribe - }; -}; - -export const languages = createLanguagesStore(); - -// Auto-load languages on initialization -if (browser) { - languages.loadLanguages(); -} diff --git a/libs/frontend/src/lib/stores/preferences.ts b/libs/frontend/src/lib/stores/preferences.store.ts similarity index 54% rename from libs/frontend/src/lib/stores/preferences.ts rename to libs/frontend/src/lib/stores/preferences.store.ts index 37ad1925..60d41bfb 100644 --- a/libs/frontend/src/lib/stores/preferences.ts +++ b/libs/frontend/src/lib/stores/preferences.store.ts @@ -1,23 +1,24 @@ import { browser } from "$app/environment"; -import { buildBackendUrl } from "@/config/backend"; +import { + codincodApiWebAccountPreferenceControllerDelete2, + codincodApiWebAccountPreferenceControllerReplace2, + codincodApiWebAccountPreferenceControllerShow2 +} from "@/api/generated/account-preferences/account-preferences"; import { localStorageKeys } from "@/config/local-storage"; -import { fetchWithAuthenticationCookie } from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { writable } from "svelte/store"; +import { derived, writable } from "svelte/store"; import { - backendUrls, editorPreferencesSchema, - httpRequestMethod, - httpResponseCodes, isPreferencesDto, isThemeOption, - type PreferencesDto + type PreferencesDto, + type UpdatePreferencesRequest } from "types"; +import { isAuthenticated } from "./auth.store"; +import { theme } from "./theme.store"; const createPreferencesStore = () => { const { set, subscribe, update } = writable(null); - const url = buildBackendUrl(backendUrls.ACCOUNT_PREFERENCES); - // Helper to safely parse localStorage data const parseStoredPreferences = ( stored: string | null @@ -78,33 +79,30 @@ const createPreferencesStore = () => { if (!browser) return; try { - const response = await fetchWithAuthenticationCookie(url); - - if (!response.ok) { - if (response.status === httpResponseCodes.CLIENT_ERROR.NOT_FOUND) { - // No preferences found, create default - const defaultPreferences: PreferencesDto = { - editor: editorPreferencesSchema.parse({}) - }; - - const theme = localStorage.getItem(localStorageKeys.THEME); - if (isThemeOption(theme)) { - defaultPreferences.theme = theme; - } + const data = await codincodApiWebAccountPreferenceControllerShow2(); + + set(data as PreferencesDto); + saveToLocalStorage(data as PreferencesDto); + } catch (error: unknown) { + // Handle 404 - create default preferences + const is404 = + error instanceof Error && error.message?.includes?.("404"); + if (is404) { + // Use Dto type which doesn't require owner (server will set it from auth) + const defaultPreferences: PreferencesDto = { + editor: editorPreferencesSchema.parse({}) + }; - await this.updatePreferences(defaultPreferences); - return; + const theme = localStorage.getItem(localStorageKeys.THEME); + if (isThemeOption(theme)) { + defaultPreferences.theme = theme; } - throw new Error( - `Failed to fetch preferences: ${response.status} ${response.statusText}` - ); + // Update will create preferences on the server + await this.updatePreferences(defaultPreferences); + return; } - const data = await response.json(); - set(data); - saveToLocalStorage(data); - } catch (error) { console.error("Failed to load preferences:", error); // Keep existing state if refresh fails } @@ -114,13 +112,7 @@ const createPreferencesStore = () => { if (!browser) return; try { - const response = await fetchWithAuthenticationCookie(url, { - method: httpRequestMethod.DELETE - }); - - if (!response.ok) { - throw new Error(`Failed to reset preferences: ${response.status}`); - } + await codincodApiWebAccountPreferenceControllerDelete2(); set(null); saveToLocalStorage(null); @@ -132,20 +124,17 @@ const createPreferencesStore = () => { subscribe, - async updatePreferences(updates: Partial) { + async updatePreferences(updates: UpdatePreferencesRequest) { if (!browser) return; try { - const response = await fetchWithAuthenticationCookie(url, { - body: JSON.stringify(updates), - method: httpRequestMethod.PUT - }); - - if (!response.ok) { - throw new Error(`Failed to update preferences: ${response.status}`); - } + // Clean up undefined values to satisfy exactOptionalPropertyTypes + const cleanUpdates = Object.fromEntries( + Object.entries(updates).filter(([_, value]) => value !== undefined) + ); - const updatedData = await response.json(); + const updatedData = + await codincodApiWebAccountPreferenceControllerReplace2(cleanUpdates); // Use the server response as source of truth update((current) => { @@ -156,7 +145,7 @@ const createPreferencesStore = () => { ...(current?.editor ?? editorPreferencesSchema.parse({})), ...(updatedData.editor ?? {}) } - }; + } as PreferencesDto; saveToLocalStorage(merged); return merged; }); @@ -169,3 +158,40 @@ const createPreferencesStore = () => { }; export const preferences = createPreferencesStore(); + +/** + * start integrate preferences store + */ + +if (browser) { + isAuthenticated.subscribe((isAuthenticated) => { + if (isAuthenticated) { + preferences.loadPreferences(); + } + }); + + preferences.subscribe((newPreferences) => { + if (newPreferences) { + localStorage.setItem( + localStorageKeys.PREFERENCES, + JSON.stringify(newPreferences) + ); + } + + if (newPreferences?.theme) { + theme.set(newPreferences.theme); + } + }); + + derived([theme, isAuthenticated], ([theme, isAuthenticated]) => { + return { isAuthenticated, theme }; + }).subscribe(({ isAuthenticated, theme }) => { + if (isAuthenticated) { + preferences.updatePreferences({ theme }); + } + }); +} + +/** + * end integrate preferences store + */ diff --git a/libs/frontend/src/lib/stores/index.ts b/libs/frontend/src/lib/stores/theme.store.ts similarity index 52% rename from libs/frontend/src/lib/stores/index.ts rename to libs/frontend/src/lib/stores/theme.store.ts index 46078af0..93db682a 100644 --- a/libs/frontend/src/lib/stores/index.ts +++ b/libs/frontend/src/lib/stores/theme.store.ts @@ -1,37 +1,56 @@ import { browser } from "$app/environment"; import { localStorageKeys } from "@/config/local-storage"; import { derived, writable } from "svelte/store"; -import { - isThemeOption, - themeOption, - type AuthenticatedInfo, - type ThemeOption -} from "types"; -import { preferences } from "./preferences"; +import { isThemeOption, themeOption, type ThemeOption } from "types"; -const theme = writable(); +/** + * Store for the current theme (light/dark mode) + */ +export const theme = writable(); + +/** + * Derived store that returns true if dark theme is active + */ export const isDarkTheme = derived( theme, (currentTheme) => currentTheme === themeOption.DARK ); + +/** + * Toggle between light and dark theme + */ export const toggleDarkTheme = () => theme.update((oldValue) => oldValue === themeOption.DARK ? themeOption.LIGHT : themeOption.DARK ); -if (browser) { +/** + * Initialize theme from localStorage or system preference + */ +function initializeTheme() { + if (!browser) return; + const prefersDarkTheme = window.matchMedia( "(prefers-color-scheme: dark)" ).matches; + const storedTheme = localStorage.getItem(localStorageKeys.THEME); const preferredTheme = prefersDarkTheme ? themeOption.DARK : themeOption.LIGHT; + const currentThemeOption = isThemeOption(storedTheme) ? storedTheme : preferredTheme; theme.set(currentThemeOption); +} + +/** + * Sync theme changes to DOM and localStorage + */ +function syncTheme() { + if (!browser) return; theme.subscribe((newTheme) => { const isDarkClass = document.documentElement.classList.contains( @@ -49,49 +68,6 @@ if (browser) { }); } -export const authenticatedUserInfo = writable(null); - -export const isAuthenticated = derived(authenticatedUserInfo, (userInfo) => { - return userInfo?.isAuthenticated ?? false; -}); - -/** - * end user-info store - */ - -/** - * start integrate preferences store - */ - -if (browser) { - isAuthenticated.subscribe((isAuthenticated) => { - if (isAuthenticated) { - preferences.loadPreferences(); - } - }); - - preferences.subscribe((newPreferences) => { - if (newPreferences) { - localStorage.setItem( - localStorageKeys.PREFERENCES, - JSON.stringify(newPreferences) - ); - } - - if (newPreferences?.theme) { - theme.set(newPreferences.theme); - } - }); - - derived([theme, isAuthenticated], ([theme, isAuthenticated]) => { - return { isAuthenticated, theme }; - }).subscribe(({ isAuthenticated, theme }) => { - if (isAuthenticated) { - preferences.updatePreferences({ theme }); - } - }); -} - -/** - * end integrate preferences store - */ +// Initialize theme on module load +initializeTheme(); +syncTheme(); diff --git a/libs/frontend/src/lib/utils/debug-logger.ts b/libs/frontend/src/lib/utils/debug-logger.ts new file mode 100644 index 00000000..4ae0199c --- /dev/null +++ b/libs/frontend/src/lib/utils/debug-logger.ts @@ -0,0 +1,138 @@ +/** + * Development-only debug logger + * Provides consistent, colorful logging with timestamps and categories + */ + +import { dev } from "$app/environment"; + +type LogCategory = + | "AUTH" + | "API" + | "STORE" + | "NAVIGATION" + | "PAGE" + | "WEBSOCKET" + | "FORM" + | "ERROR"; + +interface LogStyle { + bg: string; + color: string; + icon: string; +} + +const styles: Record = { + AUTH: { bg: "#006342ff", color: "#fff", icon: "🔐" }, + API: { bg: "#002f7bff", color: "#fff", icon: "🌐" }, + STORE: { bg: "#25007bff", color: "#fff", icon: "📦" }, + NAVIGATION: { bg: "#744900ff", color: "#fff", icon: "🧭" }, + PAGE: { bg: "#670034ff", color: "#fff", icon: "📄" }, + WEBSOCKET: { bg: "#006577ff", color: "#fff", icon: "🔌" }, + FORM: { bg: "#416c00ff", color: "#fff", icon: "📝" }, + ERROR: { bg: "#7d0000ff", color: "#fff", icon: "❌" } +}; + +class DebugLogger { + private enabled: boolean; + + constructor() { + this.enabled = dev; + } + + private getTimestamp(): string { + const now = new Date(); + return now.toISOString().split("T")[1].split(".")[0]; + } + + private log( + category: LogCategory, + message: string, + data?: unknown, + isError = false + ): void { + if (!this.enabled) return; + + const style = styles[category]; + const timestamp = this.getTimestamp(); + const prefix = `%c${style.icon} ${category}%c [${timestamp}]`; + const prefixStyles = [ + `background: ${style.bg}; color: ${style.color}; padding: 2px 6px; border-radius: 3px; font-weight: bold;`, + `color: #666; font-size: 0.9em;` + ]; + + if (isError) { + console.error(prefix, ...prefixStyles, message, data ?? ""); + } else if (data !== undefined) { + console.log(prefix, ...prefixStyles, message, data); + } else { + console.log(prefix, ...prefixStyles, message); + } + } + + // Authentication logs + auth(message: string, data?: unknown): void { + this.log("AUTH", message, data); + } + + // API request/response logs + api(message: string, data?: unknown): void { + this.log("API", message, data); + } + + // Store state changes + store(message: string, data?: unknown): void { + this.log("STORE", message, data); + } + + // Navigation events + nav(message: string, data?: unknown): void { + this.log("NAVIGATION", message, data); + } + + // Page lifecycle events + page(message: string, data?: unknown): void { + this.log("PAGE", message, data); + } + + // WebSocket events + ws(message: string, data?: unknown): void { + this.log("WEBSOCKET", message, data); + } + + // Form events + form(message: string, data?: unknown): void { + this.log("FORM", message, data); + } + + // Errors + error(message: string, error?: unknown): void { + this.log("ERROR", message, error, true); + } + + // Group related logs together + group(category: LogCategory, title: string, fn: () => void): void { + if (!this.enabled) return; + + const style = styles[category]; + console.group( + `%c${style.icon} ${category}: ${title}`, + `background: ${style.bg}; color: ${style.color}; padding: 2px 6px; border-radius: 3px; font-weight: bold;` + ); + fn(); + console.groupEnd(); + } + + // Table format for structured data + table(category: LogCategory, title: string, data: unknown): void { + if (!this.enabled) return; + + const style = styles[category]; + console.log( + `%c${style.icon} ${category}: ${title}`, + `background: ${style.bg}; color: ${style.color}; padding: 2px 6px; border-radius: 3px; font-weight: bold;` + ); + console.table(data); + } +} + +export const logger = new DebugLogger(); diff --git a/libs/frontend/src/lib/websocket/websocket-manager.svelte.ts b/libs/frontend/src/lib/websocket/websocket-manager.svelte.ts index c3a05229..b374f781 100644 --- a/libs/frontend/src/lib/websocket/websocket-manager.svelte.ts +++ b/libs/frontend/src/lib/websocket/websocket-manager.svelte.ts @@ -8,10 +8,11 @@ * - Type-safe message handling */ +import { logger } from "@/utils/debug-logger"; import { websocketCloseCodes } from "types"; import { - WEBSOCKET_STATES, WEBSOCKET_RECONNECT, + WEBSOCKET_STATES, type WebSocketState } from "./websocket-constants"; @@ -59,6 +60,13 @@ export class WebSocketManager { options.maxReconnectAttempts ?? WEBSOCKET_RECONNECT.MAX_ATTEMPTS; this.reconnectDelay = this.INITIAL_RECONNECT_DELAY; + logger.ws("WebSocketManager constructed", { + url: this.url, + maxReconnectAttempts: this.MAX_RECONNECT_ATTEMPTS, + initialDelay: this.INITIAL_RECONNECT_DELAY, + maxDelay: this.MAX_RECONNECT_DELAY + }); + // Set up network status monitoring this.setupNetworkMonitoring(); } @@ -67,9 +75,13 @@ export class WebSocketManager { * Set up listeners for online/offline events */ private setupNetworkMonitoring(): void { - if (globalThis.window === undefined) return; + if (globalThis.window === undefined) { + logger.ws("Network monitoring skipped (not in browser)"); + return; + } this.isOnline = navigator.onLine; + logger.ws(`Network monitoring initialized (online: ${this.isOnline})`); globalThis.window.addEventListener("online", this.handleOnline.bind(this)); globalThis.window.addEventListener( @@ -83,7 +95,7 @@ export class WebSocketManager { * Useful for user-initiated reconnect button */ reconnect(): void { - console.info("Manual reconnect triggered"); + logger.ws("Manual reconnect triggered"); // Reset reconnect state this.reconnectAttempts = 0; @@ -103,18 +115,18 @@ export class WebSocketManager { } private handleOnline(): void { - console.info("Network connection restored"); + logger.ws("Network connection restored"); this.isOnline = true; // Automatically attempt to reconnect when network comes back if (!this.isConnected() && this.shouldReconnect) { - console.info("Auto-reconnecting after network restoration"); + logger.ws("Auto-reconnecting after network restoration"); this.reconnect(); } } private handleOffline(): void { - console.info("Network connection lost"); + logger.ws("Network connection lost"); this.isOnline = false; // Clear any pending reconnect timers when offline @@ -126,18 +138,21 @@ export class WebSocketManager { */ connect(): void { if (this.socket?.readyState === WebSocket.OPEN) { - console.warn("WebSocket already connected"); + logger.ws("Connection attempt skipped - already connected"); return; } this.shouldReconnect = true; this.setState(WEBSOCKET_STATES.CONNECTING); + logger.ws(`Connecting to WebSocket: ${this.url}`); + try { this.socket = new WebSocket(this.url); this.attachEventListeners(); + logger.ws("WebSocket instance created, waiting for connection..."); } catch (error) { - console.error("Failed to create WebSocket connection:", error); + logger.error("Failed to create WebSocket connection", error); this.setState(WEBSOCKET_STATES.ERROR); this.scheduleReconnect(); } @@ -147,6 +162,7 @@ export class WebSocketManager { * Disconnect from the WebSocket server */ disconnect(): void { + logger.ws("Disconnecting WebSocket"); this.shouldReconnect = false; this.clearReconnectTimer(); @@ -165,14 +181,19 @@ export class WebSocketManager { send(data: TRequest): void { if (this.socket?.readyState === WebSocket.OPEN) { try { + logger.ws("Sending message", data); this.socket.send(JSON.stringify(data)); } catch (error) { - console.error("Failed to send message:", error); + logger.error("Failed to send message", error); this.messageQueue.push(data); + logger.ws(`Message queued (queue size: ${this.messageQueue.length})`); } } else { - console.warn("WebSocket not connected, queuing message"); + logger.ws( + `WebSocket not connected (state: ${this.socket?.readyState}), queuing message` + ); this.messageQueue.push(data); + logger.ws(`Message queued (queue size: ${this.messageQueue.length})`); } } @@ -210,7 +231,7 @@ export class WebSocketManager { } private handleOpen(): void { - console.info("WebSocket connection opened"); + logger.ws("WebSocket connection opened successfully"); this.setState(WEBSOCKET_STATES.CONNECTED); this.reconnectAttempts = 0; this.reconnectDelay = this.INITIAL_RECONNECT_DELAY; @@ -222,19 +243,24 @@ export class WebSocketManager { private handleMessage(event: MessageEvent): void { try { const data = JSON.parse(event.data); + logger.ws("Received message", data); if (this.validateResponse(data)) { this.onMessage(data); } else { - console.error("Received invalid message format:", data); + logger.error("Received invalid message format", data); } } catch (error) { - console.error("Failed to parse WebSocket message:", error); + logger.error("Failed to parse WebSocket message", error); } } private handleClose(event: CloseEvent): void { - console.info("WebSocket connection closed:", event.code, event.reason); + logger.ws("WebSocket connection closed", { + code: event.code, + reason: event.reason || "(no reason)", + wasClean: event.wasClean + }); // Don't reconnect if it was a clean close initiated by client if (event.code === websocketCloseCodes.NORMAL && !this.shouldReconnect) { @@ -244,7 +270,7 @@ export class WebSocketManager { // Handle authentication errors (code 1008) if (event.code === websocketCloseCodes.POLICY_VIOLATION) { - console.error("WebSocket authentication failed:", event.reason); + logger.error("WebSocket authentication failed", event.reason); this.setState(WEBSOCKET_STATES.ERROR); // Don't attempt to reconnect on auth errors - user needs to re-login this.shouldReconnect = false; @@ -261,7 +287,7 @@ export class WebSocketManager { (event.reason.includes("Game not found") || event.reason.includes("Invalid game ID")) ) { - console.error("WebSocket error:", event.reason); + logger.error("WebSocket error", event.reason); this.setState(WEBSOCKET_STATES.ERROR); this.shouldReconnect = false; this.messageQueue = []; @@ -276,20 +302,22 @@ export class WebSocketManager { } private handleError(event: Event): void { - console.error("WebSocket error:", event); + logger.error("WebSocket error event", event); this.setState(WEBSOCKET_STATES.ERROR); } private scheduleReconnect(): void { // Don't schedule reconnect if offline if (!this.isOnline) { - console.info("Skipping reconnect - device is offline"); + logger.ws("Skipping reconnect - device is offline"); this.setState(WEBSOCKET_STATES.DISCONNECTED); return; } if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) { - console.error("Max reconnect attempts reached"); + logger.error( + `Max reconnect attempts reached (${this.MAX_RECONNECT_ATTEMPTS})` + ); this.setState(WEBSOCKET_STATES.ERROR); this.shouldReconnect = false; return; @@ -298,8 +326,8 @@ export class WebSocketManager { this.setState(WEBSOCKET_STATES.RECONNECTING); this.reconnectAttempts++; - console.info( - `Scheduling reconnect attempt ${this.reconnectAttempts} in ${this.reconnectDelay}ms` + logger.ws( + `Scheduling reconnect attempt ${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS} in ${this.reconnectDelay}ms` ); this.clearReconnectTimer(); @@ -317,6 +345,7 @@ export class WebSocketManager { private clearReconnectTimer(): void { if (this.reconnectTimer) { + logger.ws("Clearing reconnect timer"); clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } @@ -325,7 +354,7 @@ export class WebSocketManager { private flushMessageQueue(): void { if (this.messageQueue.length === 0) return; - console.info(`Flushing ${this.messageQueue.length} queued messages`); + logger.ws(`Flushing ${this.messageQueue.length} queued messages`); while (this.messageQueue.length > 0) { const message = this.messageQueue.shift(); @@ -337,6 +366,7 @@ export class WebSocketManager { private setState(newState: WebSocketState): void { if (this.state !== newState) { + logger.ws(`State change: ${this.state} -> ${newState}`); this.state = newState; this.onStateChange?.(newState); } @@ -346,6 +376,7 @@ export class WebSocketManager { * Cleanup resources */ destroy(): void { + logger.ws("Destroying WebSocketManager"); this.disconnect(); this.messageQueue = []; diff --git a/libs/frontend/src/routes/(authenticated)/+layout.server.ts b/libs/frontend/src/routes/(authenticated)/+layout.server.ts index 71dfea69..bdee5a0a 100644 --- a/libs/frontend/src/routes/(authenticated)/+layout.server.ts +++ b/libs/frontend/src/routes/(authenticated)/+layout.server.ts @@ -1,8 +1,8 @@ +import { searchParamKeys } from "@/config/search-params"; import { getAuthenticatedUserInfo } from "@/features/authentication/utils/get-authenticated-user-info.js"; import { redirect } from "@sveltejs/kit"; import { frontendUrls } from "types"; import type { LayoutServerLoadEvent } from "./$types"; -import { searchParamKeys } from "@/config/search-params"; export async function load({ cookies, fetch, url }: LayoutServerLoadEvent) { const { pathname } = url; diff --git a/libs/frontend/src/routes/(authenticated)/logout/+page.server.ts b/libs/frontend/src/routes/(authenticated)/logout/+page.server.ts index 412010f8..029337f8 100644 --- a/libs/frontend/src/routes/(authenticated)/logout/+page.server.ts +++ b/libs/frontend/src/routes/(authenticated)/logout/+page.server.ts @@ -1,46 +1,32 @@ -import { - backendUrls, - cookieKeys, - environment, - frontendUrls, - getCookieOptions, - httpRequestMethod -} from "types"; -import type { Actions } from "./$types"; import { env } from "$env/dynamic/private"; +import { ApiError } from "$lib/api/errors"; +import { codincodApiWebAuthControllerLogout2 } from "$lib/api/generated"; import { redirect } from "@sveltejs/kit"; +import { cookieKeys, environment, frontendUrls, getCookieOptions } from "types"; +import type { Actions } from "./$types"; export const actions = { default: async ({ cookies, fetch }) => { - const token = cookies.get(cookieKeys.TOKEN); - try { - const backendUrl = `${env.BACKEND_HOST}${backendUrls.LOGOUT}`; - - // Call the backend logout endpoint to clear the httpOnly cookie - const response = await fetch(backendUrl, { - method: httpRequestMethod.POST, - headers: { - Cookie: `${cookieKeys.TOKEN}=${token ?? ""}` - } - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error("Backend logout failed:", errorText); + // Use the generated API endpoint for logout + await codincodApiWebAuthControllerLogout2({ credentials: "include" }); + } catch (error) { + // Log API errors but still proceed with local cleanup + if (error instanceof ApiError) { + console.error("Backend logout failed:", error.data); + } else { + console.error("Error calling logout endpoint:", error); } + } - // Also clear it on the frontend side with the same options - const isProduction = env.NODE_ENV === environment.PRODUCTION; - const cookieOptions = getCookieOptions({ - isProduction, - ...(env.FRONTEND_HOST && { frontendHost: env.FRONTEND_HOST }) - }); + // Also clear the cookie on the frontend side + const isProduction = env.NODE_ENV === environment.PRODUCTION; + const cookieOptions = getCookieOptions({ + isProduction, + ...(env.FRONTEND_HOST && { frontendHost: env.FRONTEND_HOST }) + }); - cookies.delete(cookieKeys.TOKEN, cookieOptions); - } catch (error) { - console.error("Error calling logout endpoint:", error); - } + cookies.delete(cookieKeys.TOKEN, cookieOptions); // Redirect to home page throw redirect(303, frontendUrls.ROOT); diff --git a/libs/frontend/src/routes/(authenticated)/logout/+page.svelte b/libs/frontend/src/routes/(authenticated)/logout/+page.svelte index bd1e995f..9b9c92a7 100644 --- a/libs/frontend/src/routes/(authenticated)/logout/+page.svelte +++ b/libs/frontend/src/routes/(authenticated)/logout/+page.svelte @@ -1,7 +1,7 @@ + + + Forgot Password | CodinCod + + +
+ + + Back to login + +
+
+ +

Reset your password

+

+ Enter your email and we'll send you a password reset link +

+
+ + {#if form?.success} + + + Check your email + + If an account exists with this email, a password reset link has been + sent. + + + {:else} +
{ + submitting = true; + return async ({ update }) => { + await update(); + submitting = false; + }; + }} + > +
+ {#if form?.error} + + Error + {form.error} + + {/if} + +
+ + + {#if form?.errors?.email} +

{form.errors.email}

+ {/if} +
+ + +
+
+ +

+ Remember your password? + + Sign in + +

+ {/if} +
+
diff --git a/libs/frontend/src/routes/(unauthenticated-only)/login/+page.server.ts b/libs/frontend/src/routes/(unauthenticated-only)/login/+page.server.ts index 39fb7295..ebcb342c 100644 --- a/libs/frontend/src/routes/(unauthenticated-only)/login/+page.server.ts +++ b/libs/frontend/src/routes/(unauthenticated-only)/login/+page.server.ts @@ -1,63 +1,84 @@ +import { codincodApiWebAuthControllerLogin2 } from "$lib/api/generated"; +import { logger } from "$lib/utils/debug-logger"; +import { searchParamKeys } from "@/config/search-params"; +import { isSvelteKitRedirect } from "@/features/authentication/utils/is-sveltekit-redirect"; +import { fail, redirect } from "@sveltejs/kit"; import { superValidate } from "sveltekit-superforms"; import { zod4 } from "sveltekit-superforms/adapters"; -import type { RequestEvent } from "./$types"; -import { fail, redirect } from "@sveltejs/kit"; import { - backendUrls, ERROR_MESSAGES, frontendUrls, - httpRequestMethod, httpResponseCodes, loginSchema } from "types"; -import { setCookie } from "@/features/authentication/utils/set-cookie"; -import { searchParamKeys } from "@/config/search-params"; -import { buildBackendUrl } from "@/config/backend"; -import type { LoginRequest } from "types/dist/core/api/schema/auth/login.schema"; +import type { RequestEvent } from "./$types"; export async function load() { + logger.page("Login page load"); const form = await superValidate(zod4(loginSchema)); return { form }; } export const actions = { - default: async ({ cookies, request, url }: RequestEvent) => { + default: async ({ cookies, request, url, fetch }: RequestEvent) => { + console.log("[SERVER] 🔑 Login action started"); + logger.auth("🔑 Login action started"); + const form = await superValidate(request, zod4(loginSchema)); if (!form.valid) { + console.log("[SERVER] ❌ Login form validation failed", form.errors); + logger.auth("❌ Login form validation failed", form.errors); return fail(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST, { form, message: ERROR_MESSAGES.FORM.VALIDATION_ERRORS }); } - const payload: LoginRequest = { + console.log("[SERVER] Login attempt for identifier:", form.data.identifier); + logger.auth("Login attempt", { identifier: form.data.identifier, - password: form.data.password - }; - - const result = await fetch(buildBackendUrl(backendUrls.LOGIN), { - method: httpRequestMethod.POST, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) + hasPassword: !!form.data.password }); - const data = await result.json(); - if (!result.ok) { - const message: string = data.message; + try { + console.log("[SERVER] Calling login endpoint using generated API"); + logger.auth("Calling login endpoint using generated API"); - return fail(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST, { - form, - message - }); - } + // Use the generated API endpoint - this returns the response directly + await codincodApiWebAuthControllerLogin2( + { + identifier: form.data.identifier, + password: form.data.password + }, + { credentials: "include" } + ); + + console.log("[SERVER] ✅ Login successful"); + logger.auth("✅ Login successful"); - setCookie(result, cookies); + const redirectUrl = url.searchParams.get(searchParamKeys.REDIRECT_URL); + const redirectTo = redirectUrl ?? frontendUrls.ROOT; - const redirectUrl = url.searchParams.get(searchParamKeys.REDIRECT_URL); - const redirectTo = redirectUrl ?? frontendUrls.ROOT; + console.log("[SERVER] ✅ Login successful, redirecting to:", redirectTo); + logger.auth("✅ Login successful, redirecting to", redirectTo); - throw redirect(httpResponseCodes.REDIRECTION.FOUND, redirectTo); + throw redirect(httpResponseCodes.REDIRECTION.FOUND, redirectTo); + } catch (error) { + // Re-throw SvelteKit redirect errors (successful login) + if (isSvelteKitRedirect(error)) { + console.log("[SERVER] Redirecting after successful login"); + logger.auth("Redirecting after successful login"); + throw error; + } + + console.error("[SERVER] Login error:", error); + logger.error("Login error", error); + return fail(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR, { + form, + message: "An unexpected error occurred. Please try again." + }); + } } }; diff --git a/libs/frontend/src/routes/(unauthenticated-only)/login/+page.svelte b/libs/frontend/src/routes/(unauthenticated-only)/login/+page.svelte index f9d2c63a..46e43c47 100644 --- a/libs/frontend/src/routes/(unauthenticated-only)/login/+page.svelte +++ b/libs/frontend/src/routes/(unauthenticated-only)/login/+page.svelte @@ -1,5 +1,5 @@ + + + Reset Password | CodinCod + + +
+
+
+ {#if form?.success} + +

+ Password reset successful +

+

+ Your password has been updated +

+ {:else} + +

Set new password

+

+ Choose a strong password for your account +

+ {/if} +
+ + {#if form?.success} +
+ + + Success + + You can now log in with your new password. + + + + +
+ {:else if !token} + + Invalid link + + This password reset link is invalid or has expired. + Request a new one + + + {:else} +
{ + submitting = true; + return async ({ update }) => { + await update(); + submitting = false; + }; + }} + > + + +
+ {#if form?.error} + + Error + {form.error} + + {/if} + +
+ + + {#if form?.errors?.password} +

{form.errors.password}

+ {/if} +
+ +
+ + + {#if password && confirmPassword && password !== confirmPassword} +

Passwords do not match

+ {/if} +
+ + +
+
+ +

+ Remember your password? + + Sign in + +

+ {/if} +
+
diff --git a/libs/frontend/src/routes/+layout.server.ts b/libs/frontend/src/routes/+layout.server.ts index a9883d22..eff4333e 100644 --- a/libs/frontend/src/routes/+layout.server.ts +++ b/libs/frontend/src/routes/+layout.server.ts @@ -1,7 +1,26 @@ +import { logger } from "$lib/utils/debug-logger"; import { getAuthenticatedUserInfo } from "@/features/authentication/utils/get-authenticated-user-info.js"; import type { ServerLoadEvent } from "@sveltejs/kit"; export async function load({ cookies, fetch }: ServerLoadEvent) { + // Server-side logs go to terminal, not browser console + console.log("[SERVER] +layout.server.ts load called"); + logger.page("+layout.server.ts load called"); + const currentUser = await getAuthenticatedUserInfo(cookies, fetch); + + console.log("[SERVER] +layout.server.ts user info:", { + isAuthenticated: currentUser.isAuthenticated, + userId: currentUser.userId, + username: currentUser.username, + role: currentUser.role + }); + logger.page("+layout.server.ts returning user data", { + isAuthenticated: currentUser.isAuthenticated, + userId: currentUser.userId, + username: currentUser.username, + role: currentUser.role + }); + return currentUser; } diff --git a/libs/frontend/src/routes/+layout.svelte b/libs/frontend/src/routes/+layout.svelte index cf699a4e..8f280324 100644 --- a/libs/frontend/src/routes/+layout.svelte +++ b/libs/frontend/src/routes/+layout.svelte @@ -1,30 +1,53 @@
- {Math.round(entry.bestScore).toLocaleString()} + {Math.round(entry.bestScore ?? 0).toLocaleString()} - {Math.round(entry.averageScore).toLocaleString()} + {Math.round(entry.averageScore ?? 0).toLocaleString()} {/each} @@ -255,8 +259,8 @@

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